fix(retro): финальное ревью — сабагенты, период по записям, разбор Bash, redact до обрезки, --from (#29)
- C-1: транскрипты сабагентов (<сессия>/subagents/*.jsonl) вливаются в сессию-родителя — ходы, токены, инструменты, codegraph, блокировки, отказы; промпты, роль, стартовый контекст — только из основного. hook_blocks_export.py выгружает их блокировки с сессией-родителем. - I-7: период режется по меткам времени записей; сессия на стыке делится между ретро, стартовый контекст — если первый ход в периоде. - I-1: простые команды Bash — shlex с punctuation_chars, «|» в кавычках команду не делит. I-2: правило src/ ловит и сам каталог src. - I-3: clip() — redact до обрезки (кандидаты, выгрузка), роль — redact. - I-4: без --since период берётся из существующего --out; --from рисует «Цифры» из сохранённого среза. SKILL.md передаёт период явно. - I-5: выгрузка ретро без разметки — сборка корпуса падает. - I-6: сводка компакции — не промпт; «перечитай» — побуждение перечитать. - 2026-10-11.metrics.json: несравнимое помечено (без сабагентов, широкий «перечитай»). Спека и design.md (Decisions) обновлены. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cc697c030c
commit
3d2ce3f41d
@ -11,6 +11,8 @@ description: Ретро обвязки claude-harness по транскрипт
|
||||
|
||||
1. **Задача и worktree.** Issue «Ретро <D>: <проект>» (`tea issues create`). Затем `EnterWorktree` с именем `retro-<D>`, ветка `git branch -m retro/<D>`, `codegraph init` в worktree.
|
||||
2. **Срез.** Выполнить `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --out docs/retro/<D>.metrics.json`.
|
||||
- Период `<since>` — `period.since` из `docs/retro/<D>.metrics.json`. На шагах 3 и 5 передавать его явно (`--since <since>`). Повторный запуск с тем же `--out` берёт период из него, поэтому черновик `<D>.md` период не сдвигает.
|
||||
- В срез входят записи периода, в том числе из длинных сессий, начатых раньше. Транскрипты сабагентов (`<сессия>/subagents/`) входят в сессию-родителя.
|
||||
- Exit 1 — сессий нет. Сообщить владельцу slug и период и остановиться.
|
||||
- Битых строк больше 1 % — отметить в «Что не измерялось».
|
||||
3. **Блокировки.** Сначала выгрузить: `python3 docs/retro/scripts/hook_blocks_export.py --since <since> --id-prefix <D> <каталоги из sources.dirs> > docs/retro/fixtures/hook-blocks-<D>.jsonl`. Каталоги — `~/.claude/projects/<имя>`.
|
||||
@ -21,13 +23,13 @@ description: Ретро обвязки claude-harness по транскрипт
|
||||
- Собрать корпус: `python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` и прогнать `bash tests/run.sh hook-corpus`. Если текущий хук даёт не тот вердикт — проставить записи `"known": "RT-n"` и завести находку.
|
||||
- Пересчитать срез с `--labels docs/retro/fixtures/hook-labels-<D>.json --out docs/retro/<D>.metrics.json`.
|
||||
4. **Хвосты закрытия.** `python3 docs/retro/scripts/retro_leftovers.py <папка> --fetch --markdown`. Ничего не удалять: таблица идёт в черновик, уборка — по решению владельца.
|
||||
5. **Поправки владельца.** `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --corrections`.
|
||||
5. **Поправки владельца.** `python3 docs/retro/scripts/retro_metrics.py <папка> --since <since> --corrections`.
|
||||
- Отобрать только поправки по процессу: пропущенный шаг, лишний вопрос, неверный совет, обход правила, расширение задачи. Вопросы и просьбы по содержанию выбросить.
|
||||
- Сгруппировать по видам.
|
||||
- Вид, который повторяется (2 раза и больше) или стоил владельцу заметного времени, — находка `RT-n` и пункт бэклога.
|
||||
6. **Качественная часть.** Прочитать память проекта целиком (`~/.claude/projects/<slug>/memory/`) и примеры из выгрузок. Находки `RT-n` — сквозная нумерация: максимум в прошлых `docs/retro/*.md` плюс 1. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение.
|
||||
7. **Черновик.** Скопировать `.claude/skills/harness-retro/template.md` в `docs/retro/<D>.md` и заполнить.
|
||||
- «Цифры»: `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --labels … --markdown --prev docs/retro/<прошлое>.metrics.json`.
|
||||
- «Цифры»: `python3 docs/retro/scripts/retro_metrics.py <папка> --from docs/retro/<D>.metrics.json --markdown --prev docs/retro/<прошлое>.metrics.json` — таблица из сохранённого среза, без пересчёта.
|
||||
- Секретов в черновике быть не должно. Цитаты брать только из вывода скриптов: он уже прошёл `redact`.
|
||||
8. **PR.** `bash tests/run.sh` должен быть зелёным. Коммит: черновик, `metrics.json`, выгрузка, разметка, корпус. Затем `git push -u origin retro/<D>` и `tea pulls create` со ссылкой на issue. Merge — только владелец.
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
"prompts": 613,
|
||||
"interrupts": null,
|
||||
"turns": {"value": 12121, "note": "по строкам транскрипта, не по сообщениям"},
|
||||
"tool_calls": 6583,
|
||||
"tool_calls": {"value": 6583, "note": "без сабагентов"},
|
||||
"tools": {"Bash": 4302, "Playwright": 1100, "Read": 346, "Write": 271, "Edit": 233},
|
||||
"roles": {"dev": 32, "admin": 7, "qa": 5},
|
||||
"subagents": 61,
|
||||
@ -29,15 +29,15 @@
|
||||
},
|
||||
"compactions": 4,
|
||||
"start_context": {"median": 56000, "min": 52000, "max": 61000},
|
||||
"hook_blocks": 76,
|
||||
"hook_blocks": {"value": 76, "note": "без сабагентов"},
|
||||
"hook_blocks_by_hook": null,
|
||||
"false_blocks": {"labelled": 76, "skipped": 0, "false": 72, "share": 0.95, "fixed": 72, "known": 0},
|
||||
"classifier_denials": 6,
|
||||
"classifier_denials": {"value": 6, "note": "без сабагентов"},
|
||||
"classifier_by_reason": {"Git Destructive": 2, "Auto-Mode Bypass": 2, "Interfere With Workloads": 1, "safety-check": 1},
|
||||
"owner_denials": 6,
|
||||
"codegraph": {"init": 43, "explore": 15, "code_reads": null, "explore_share": null},
|
||||
"codegraph": {"init": {"value": 43, "note": "без сабагентов"}, "explore": {"value": 15, "note": "без сабагентов"}, "code_reads": null, "explore_share": null},
|
||||
"corrections": null,
|
||||
"recall": 9,
|
||||
"recall": {"value": 9, "note": "широкий регэксп, со сводками компакции"},
|
||||
"memory": {"index_bytes": null, "files": 47, "bytes": 183296},
|
||||
"cd_main": {"retired": "хуки 0.4.0 не зависят от cwd (RT-2); считалось по именам репозиториев awp-ca, было 84"}
|
||||
}
|
||||
|
||||
@ -6,8 +6,9 @@
|
||||
первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке.
|
||||
Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд,
|
||||
в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает.
|
||||
--since — только сессии с первой меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) —
|
||||
поле id вида <префикс>-NNN (срез ретро, B12). Похожие на секреты подстроки заменяются на <redacted>.
|
||||
Транскрипты сабагентов (<сессия>/subagents/*.jsonl) выгружаются с сессией-родителем. --since — только вызовы
|
||||
с меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — поле id вида <префикс>-NNN (срез
|
||||
ретро, B12). Похожие на секреты подстроки заменяются на <redacted> до обрезки.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
@ -31,6 +32,12 @@ def redact(s):
|
||||
return RE_SECRET.sub(sub, s)
|
||||
|
||||
|
||||
def clip(s, n, tail=False):
|
||||
"""redact, затем обрезка: секрет на границе обрезки не утекает частично."""
|
||||
s = redact(s or "")
|
||||
return s[-n:] if tail else s[:n]
|
||||
|
||||
|
||||
def hook_of(txt, tool):
|
||||
if "[guard-main]" in txt:
|
||||
return "guard-main(Bash)" if tool == "Bash" else "guard-main(Edit/Write)"
|
||||
@ -57,23 +64,10 @@ def _records(path):
|
||||
yield j
|
||||
|
||||
|
||||
def first_ts(path):
|
||||
for j in _records(path):
|
||||
if isinstance(j.get("timestamp"), str) and j["timestamp"]:
|
||||
return j["timestamp"]
|
||||
return ""
|
||||
|
||||
|
||||
def export(dirs, since=None, prefix=None):
|
||||
"""Записи выгрузки по каталогам транскриптов, по порядку файлов и строк."""
|
||||
n = 0
|
||||
for d in dirs:
|
||||
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))):
|
||||
if since and first_ts(f)[:10] < since:
|
||||
continue
|
||||
sid = os.path.basename(f)[:8]
|
||||
def _blocks(path, sid, since):
|
||||
"""Блокировки одного транскрипта; с since — только вызовы с меткой времени не раньше даты."""
|
||||
pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
|
||||
for j in _records(f):
|
||||
for j in _records(path):
|
||||
m = j.get("message") or {}
|
||||
cont = m.get("content")
|
||||
if j.get("type") == "assistant":
|
||||
@ -93,13 +87,26 @@ def export(dirs, since=None, prefix=None):
|
||||
name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", ""))
|
||||
if name not in ("Bash", "Edit", "Write", "MultiEdit"):
|
||||
continue
|
||||
if since and (ts or "")[:10] < since:
|
||||
continue
|
||||
msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "")
|
||||
rec = {
|
||||
yield {
|
||||
"session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name,
|
||||
"command": redact(inp.get("command", "")) if name == "Bash" else None,
|
||||
"file_path": inp.get("file_path") if name != "Bash" else None,
|
||||
"hook": hook_of(txt, name), "message": redact(msg[:300]), "expected": None,
|
||||
"hook": hook_of(txt, name), "message": clip(msg, 300), "expected": None,
|
||||
}
|
||||
|
||||
|
||||
def export(dirs, since=None, prefix=None):
|
||||
"""Записи выгрузки по каталогам транскриптов: сессия, затем её сабагенты (<сессия>/subagents/*.jsonl)."""
|
||||
n = 0
|
||||
for d in dirs:
|
||||
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))):
|
||||
sid = os.path.basename(f)[:8]
|
||||
subs = sorted(glob.glob(os.path.join(f[:-len(".jsonl")], "subagents", "*.jsonl")))
|
||||
for path in [f] + subs:
|
||||
for rec in _blocks(path, sid, since):
|
||||
if prefix:
|
||||
n += 1
|
||||
rec = dict({"id": "%s-%03d" % (prefix, n)}, **rec)
|
||||
|
||||
@ -82,6 +82,11 @@ def why_of(cid, r):
|
||||
|
||||
def label_segments(fix):
|
||||
"""Сегменты ретро: пары hook-blocks-<дата>.jsonl + hook-labels-<дата>.json. Разметка должна совпадать с выгрузкой."""
|
||||
for bp in sorted(glob.glob(os.path.join(fix, "hook-blocks-*.jsonl"))):
|
||||
date = os.path.basename(bp)[len("hook-blocks-"):-len(".jsonl")]
|
||||
if not os.path.isfile(os.path.join(fix, "hook-labels-%s.json" % date)):
|
||||
raise SystemExit("%s: нет разметки hook-labels-%s.json — ретро не уходит с неразмеченными блокировками"
|
||||
% (os.path.basename(bp), date))
|
||||
for lp in sorted(glob.glob(os.path.join(fix, "hook-labels-*.json"))):
|
||||
date = os.path.basename(lp)[len("hook-labels-"):-len(".json")]
|
||||
lab = json.load(open(lp, encoding="utf-8"))
|
||||
|
||||
@ -20,17 +20,19 @@ import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
from hook_blocks_export import HOOK_ERROR, hook_of, redact # noqa: E402
|
||||
from hook_blocks_export import HOOK_ERROR, clip, hook_of, redact # noqa: E402
|
||||
|
||||
INTERRUPT = "[Request interrupted by user"
|
||||
OWNER_DENY = "The user doesn't want to proceed with this tool use"
|
||||
CLASSIFIER = re.compile(r"denied by the Claude Code auto mode classifier\. Reason: \[([^\]]+)\]")
|
||||
RECALL = re.compile(r"памят|перечитай|сверься|контекст|вспомни|прочитай .*md", re.I)
|
||||
RECALL = re.compile(r"(перечитай|прочитай|сверься|вспомни|освежи)[^.?!\n]{0,40}?(памят|контекст|claude\.md|\.md\b)",
|
||||
re.I)
|
||||
MARKERS = re.compile(r"(?<!\w)(нет|не так|почему|зачем|опять|снова|я же|стоп|подожди|не надо|сначала|верни|откати)"
|
||||
r"(?!\w)", re.I)
|
||||
READERS = ("grep", "rg", "find", "cat", "head")
|
||||
SEPARATORS = re.compile(r"&&|\|\||;|\|")
|
||||
FILE_TOOLS = ("Edit", "Write", "Read", "MultiEdit", "NotebookEdit")
|
||||
MERGED = ("turns", "tools", "subagents", "skills", "tokens", "blocks", "classifier", "owner_denials", "cg_init",
|
||||
"cg_explore", "code_reads") # что сабагент добавляет сессии-родителю
|
||||
|
||||
|
||||
def slug(path):
|
||||
@ -71,8 +73,17 @@ def first_meta(path):
|
||||
return cwd, ts
|
||||
|
||||
|
||||
def subagent_files(path):
|
||||
"""Транскрипты сабагентов сессии: <каталог>/<сессия>/subagents/*.jsonl."""
|
||||
return sorted(glob.glob(os.path.join(path[:-len(".jsonl")], "subagents", "*.jsonl")))
|
||||
|
||||
|
||||
def in_period(ts, since):
|
||||
return not since or (ts or "")[:10] >= since
|
||||
|
||||
|
||||
def project_sessions(project, root, since):
|
||||
"""(использованные каталоги, транскрипты среза): каталоги по префиксу slug, сессии по первому cwd и since."""
|
||||
"""Транскрипты проекта: каталоги по префиксу slug, сессии по первому cwd. Период режется по записям (since)."""
|
||||
pre = slug(project)
|
||||
best = {}
|
||||
for d in sorted(glob.glob(os.path.join(root, "*"))):
|
||||
@ -84,13 +95,10 @@ def project_sessions(project, root, since):
|
||||
best[sid] = f
|
||||
files = []
|
||||
for f in sorted(best.values()):
|
||||
cwd, ts = first_meta(f)
|
||||
if not cwd or not (inside(cwd, project) or inside(cwd, project + ".wt")):
|
||||
continue
|
||||
if since and (not ts or ts[:10] < since):
|
||||
continue
|
||||
cwd, _ = first_meta(f)
|
||||
if cwd and (inside(cwd, project) or inside(cwd, project + ".wt")):
|
||||
files.append(f)
|
||||
return sorted(set(os.path.basename(os.path.dirname(f)) for f in files)), files
|
||||
return files
|
||||
|
||||
|
||||
def blocks(rec):
|
||||
@ -111,6 +119,8 @@ def prompt_text(rec):
|
||||
"""Текст промпта владельца или None: служебное, tool_result, прерывание, сабагент."""
|
||||
if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"):
|
||||
return None
|
||||
if rec.get("isCompactSummary") or rec.get("isVisibleInTranscriptOnly"):
|
||||
return None # сводка после компакции, не промпт владельца
|
||||
if rec.get("userType", "external") != "external":
|
||||
return None
|
||||
bs = blocks(rec)
|
||||
@ -164,17 +174,39 @@ def is_code(path, cache):
|
||||
return False
|
||||
pre = code_prefixes(repo, cache)
|
||||
if pre is None:
|
||||
return "/src/" in "/" + rel
|
||||
return "/src/" in "/" + rel + "/"
|
||||
return any(rel == x or rel.startswith(x + "/") for x in pre)
|
||||
|
||||
|
||||
def bash_reads_code(cmd, cwd, cache):
|
||||
for part in SEPARATORS.split(cmd or ""):
|
||||
def simple_commands(cmd):
|
||||
"""Простые команды Bash списками слов: кавычки учитываются, делят &&, ||, ;, |, &, скобки; цель редиректа
|
||||
пропускается. Незакрытая кавычка — разбор по пробелам."""
|
||||
try:
|
||||
words = shlex.split(part)
|
||||
lx = shlex.shlex(cmd or "", posix=True, punctuation_chars=True)
|
||||
lx.whitespace_split = True
|
||||
toks = list(lx)
|
||||
except ValueError:
|
||||
words = part.split()
|
||||
if not words or not (words[0] in READERS or (words[0] == "sed" and "-n" in words[1:2])):
|
||||
toks = (cmd or "").split()
|
||||
segs, cur, skip = [], [], False
|
||||
for t in toks:
|
||||
if t and set(t) <= set(";&|()"):
|
||||
if cur:
|
||||
segs.append(cur)
|
||||
cur = []
|
||||
elif t and set(t) <= set("<>"):
|
||||
skip = True
|
||||
elif skip:
|
||||
skip = False
|
||||
else:
|
||||
cur.append(t)
|
||||
if cur:
|
||||
segs.append(cur)
|
||||
return segs
|
||||
|
||||
|
||||
def bash_reads_code(cmd, cwd, cache):
|
||||
for words in simple_commands(cmd):
|
||||
if not (words[0] in READERS or (words[0] == "sed" and "-n" in words[1:2])):
|
||||
continue
|
||||
for w in words[1:]:
|
||||
if w.startswith("-") or not (os.path.isabs(w) or cwd):
|
||||
@ -202,7 +234,7 @@ def count_tool(s, b, cwd, cache):
|
||||
sk = inp.get("skill") or "?"
|
||||
s["skills"][sk] += 1
|
||||
if s["role"] is None and (sk == "role" or sk.endswith(":role")):
|
||||
s["role"] = (inp.get("args") or "").strip() or "?"
|
||||
s["role"] = redact(str(inp.get("args") or "").strip()) or "?"
|
||||
if "codegraph_explore" in name:
|
||||
s["cg_explore"] += 1
|
||||
elif name == "Read":
|
||||
@ -215,20 +247,24 @@ def count_tool(s, b, cwd, cache):
|
||||
s["code_reads"] += bash_reads_code(cmd, cwd, cache)
|
||||
|
||||
|
||||
def session_stats(path, cache, bad):
|
||||
def session_stats(path, cache, bad, since=None):
|
||||
"""Счётчики сессии по записям периода (запись без метки — по предыдущей). Сабагенты сессии добавляют MERGED."""
|
||||
s = {"prompts": 0, "interrupts": 0, "turns": 0, "tools": collections.Counter(),
|
||||
"subagents": collections.Counter(), "skills": collections.Counter(), "role": None,
|
||||
"tokens": collections.Counter(), "compactions": 0, "start": None, "blocks": collections.Counter(),
|
||||
"classifier": collections.Counter(), "owner_denials": 0, "cg_init": 0, "cg_explore": 0,
|
||||
"code_reads": 0, "recall": 0, "last_ts": ""}
|
||||
seen, pending = set(), {}
|
||||
seen, pending, cur, first_turn = set(), {}, "", True
|
||||
for rec in read_session(path, bad):
|
||||
ts = rec.get("timestamp")
|
||||
if isinstance(ts, str) and ts > s["last_ts"]:
|
||||
s["last_ts"] = ts
|
||||
if isinstance(ts, str) and ts:
|
||||
cur = ts
|
||||
live = in_period(cur, since)
|
||||
if live and cur > s["last_ts"]:
|
||||
s["last_ts"] = cur
|
||||
t = rec.get("type")
|
||||
if t == "system" and rec.get("subtype") == "compact_boundary":
|
||||
s["compactions"] += 1
|
||||
s["compactions"] += live
|
||||
elif t == "assistant":
|
||||
msg = rec.get("message") or {}
|
||||
if rec.get("isApiErrorMessage") or msg.get("model") == "<synthetic>":
|
||||
@ -237,20 +273,23 @@ def session_stats(path, cache, bad):
|
||||
if mid is None or mid not in seen:
|
||||
if mid is not None:
|
||||
seen.add(mid)
|
||||
s["turns"] += 1
|
||||
u = msg.get("usage") or {}
|
||||
if u:
|
||||
n = lambda k: u.get(k, 0) or 0
|
||||
if s["start"] is None:
|
||||
if u and first_turn:
|
||||
first_turn = False # стартовый контекст — первый ход сессии, если он в периоде
|
||||
if live:
|
||||
s["start"] = n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens")
|
||||
if live:
|
||||
s["turns"] += 1
|
||||
s["tokens"]["output"] += n("output_tokens")
|
||||
s["tokens"]["cache_read"] += n("cache_read_input_tokens")
|
||||
s["tokens"]["cache_write"] += n("cache_creation_input_tokens")
|
||||
for b in blocks(rec):
|
||||
if b.get("type") == "tool_use":
|
||||
pending[b.get("id")] = b.get("name") or "?"
|
||||
if live:
|
||||
count_tool(s, b, rec.get("cwd"), cache)
|
||||
elif t == "user":
|
||||
elif t == "user" and live:
|
||||
txt = prompt_text(rec)
|
||||
if txt is not None:
|
||||
s["prompts"] += 1
|
||||
@ -267,6 +306,11 @@ def session_stats(path, cache, bad):
|
||||
if m:
|
||||
s["classifier"][m.group(1)] += 1
|
||||
s["owner_denials"] += OWNER_DENY in rt
|
||||
for sf in subagent_files(path):
|
||||
sub = session_stats(sf, cache, bad, since)
|
||||
for k in MERGED:
|
||||
s[k] = s[k] + sub[k]
|
||||
s["last_ts"] = max(s["last_ts"], sub["last_ts"])
|
||||
return s
|
||||
|
||||
|
||||
@ -314,10 +358,12 @@ def false_blocks(labels):
|
||||
"known": len(known)}
|
||||
|
||||
|
||||
def corrections(path):
|
||||
"""Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними."""
|
||||
out, act, reply, sid = [], "", "", os.path.basename(path)[:8]
|
||||
def corrections(path, since=None):
|
||||
"""Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними (записи периода)."""
|
||||
out, act, reply, sid, cur = [], "", "", os.path.basename(path)[:8], ""
|
||||
for rec in read_session(path, {}):
|
||||
if isinstance(rec.get("timestamp"), str) and rec["timestamp"]:
|
||||
cur = rec["timestamp"]
|
||||
t = rec.get("type")
|
||||
if t == "assistant":
|
||||
for b in blocks(rec):
|
||||
@ -326,7 +372,7 @@ def corrections(path):
|
||||
elif b.get("type") == "text" and (b.get("text") or "").strip():
|
||||
reply = b["text"]
|
||||
continue
|
||||
if t != "user":
|
||||
if t != "user" or not in_period(cur, since):
|
||||
continue
|
||||
txt, kind = prompt_text(rec), None
|
||||
if txt is not None:
|
||||
@ -337,8 +383,8 @@ def corrections(path):
|
||||
elif any(b.get("type") == "tool_result" and OWNER_DENY in result_text(b) for b in blocks(rec)):
|
||||
kind, txt = "denial", "отказ владельца в разрешении"
|
||||
if kind:
|
||||
out.append({"session": sid, "ts": rec.get("timestamp") or "", "kind": kind, "text": redact(txt[:300]),
|
||||
"action": redact(act[:300]), "reply_tail": redact(" ".join(reply.split())[-200:])})
|
||||
out.append({"session": sid, "ts": rec.get("timestamp") or "", "kind": kind, "text": clip(txt, 300),
|
||||
"action": clip(act, 300), "reply_tail": clip(" ".join(reply.split()), 200, tail=True)})
|
||||
return out
|
||||
|
||||
|
||||
@ -437,22 +483,38 @@ def parse_args(argv):
|
||||
p.add_argument("--out")
|
||||
p.add_argument("--markdown", action="store_true")
|
||||
p.add_argument("--prev")
|
||||
p.add_argument("--from", dest="from_file")
|
||||
p.add_argument("--corrections", action="store_true")
|
||||
return p.parse_args(argv[1:])
|
||||
|
||||
|
||||
def main(argv):
|
||||
a = parse_args(argv)
|
||||
prev = json.load(open(a.prev, encoding="utf-8")) if a.prev else None
|
||||
if a.from_file: # таблица из сохранённого среза, без пересчёта
|
||||
print(to_markdown(json.load(open(a.from_file, encoding="utf-8")), prev))
|
||||
return 0
|
||||
project = os.path.abspath(a.project)
|
||||
since = a.since or default_since(a.harness)
|
||||
dirs, files = project_sessions(project, a.projects_root, since)
|
||||
bad, cache = {"n": 0}, {}
|
||||
stats = [s for s in (session_stats(f, cache, bad) for f in files) if s["prompts"] or s["turns"]]
|
||||
since = a.since
|
||||
if not since and a.out and os.path.isfile(a.out): # повторный срез того же ретро — тот же период
|
||||
try:
|
||||
since = json.load(open(a.out, encoding="utf-8"))["period"]["since"]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
since = None
|
||||
since = since or default_since(a.harness)
|
||||
bad, cache, kept = {"n": 0}, {}, []
|
||||
stats = []
|
||||
for f in project_sessions(project, a.projects_root, since):
|
||||
s = session_stats(f, cache, bad, since)
|
||||
if s["prompts"] or s["turns"]:
|
||||
stats.append(s)
|
||||
kept.append(f)
|
||||
if not stats:
|
||||
sys.stderr.write("retro_metrics: нет сессий проекта %s (slug %s) в %s с %s\n"
|
||||
% (project, slug(project), a.projects_root, since or "начала"))
|
||||
return 1
|
||||
cands = [c for f in files for c in corrections(f)]
|
||||
files, dirs = kept, sorted(set(os.path.basename(os.path.dirname(f)) for f in kept))
|
||||
cands = [c for f in files for c in corrections(f, since)]
|
||||
if a.corrections:
|
||||
for c in cands:
|
||||
print(json.dumps(c, ensure_ascii=False))
|
||||
@ -472,7 +534,7 @@ def main(argv):
|
||||
json.dump(doc, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
if a.markdown:
|
||||
print(to_markdown(doc, json.load(open(a.prev, encoding="utf-8")) if a.prev else None))
|
||||
print(to_markdown(doc, prev))
|
||||
elif not a.out:
|
||||
print(json.dumps(doc, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
@ -67,7 +67,11 @@ retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--har
|
||||
Каждая метрика считается в одном месте, в `session_stats`, и проверяется отдельным тестом.
|
||||
|
||||
### 3. Сессии проекта
|
||||
Каталоги-кандидаты — те, чьё имя начинается со slug проекта. В срез сессия входит по первому `cwd`: путь внутри проекта или внутри `<проект>.wt/` (старое место worktree). Префикс slug нужен, чтобы найти каталоги worktree; проверка `cwd` отсекает чужой проект `/p/repo-tools` при проекте `/p/repo`.
|
||||
Каталоги-кандидаты — те, чьё имя начинается со slug проекта. Сессия принадлежит проекту по первому `cwd`: путь внутри проекта или внутри `<проект>.wt/` (старое место worktree). Префикс slug нужен, чтобы найти каталоги worktree; проверка `cwd` отсекает чужой проект `/p/repo-tools` при проекте `/p/repo`.
|
||||
|
||||
Период режется по записям, а не по сессиям: запись входит, если её метка времени (у записи без метки — предыдущая) не раньше `since`. Сессия входит, если в ней есть ходы или промпты периода. Стартовый контекст — только если первый ход сессии в периоде. Так длинная сессия на стыке периодов (владелец держит одну сессию на много задач) делится между ретро, а не теряется.
|
||||
|
||||
Транскрипты сабагентов `<каталог>/<сессия>/subagents/*.jsonl` (Claude Code пишет их отдельно, в основном транскрипте `isSidechain` нет) вливаются в сессию-родителя: ходы, токены, инструменты, codegraph, блокировки, отказы. Промпты, роль, стартовый контекст и кандидаты в поправки — только из основного транскрипта.
|
||||
|
||||
### 4. Что такое «код»
|
||||
Пути в транскриптах относятся к репозиториям, которых может уже не быть: worktree удалены. Относительный путь считается от корня worktree или репозитория. Корень определяется в таком порядке:
|
||||
@ -78,7 +82,7 @@ retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--har
|
||||
|
||||
Префиксы кода берутся из `.claude/code-paths` основного репозитория, без него — правило `src/` на любой глубине. Так же решает require-plan.
|
||||
|
||||
Bash-команда считается чтением кода, если хотя бы один её простой сегмент (разбиение по `&&`, `||`, `;`, `|`) начинается с `grep`, `rg`, `find`, `cat`, `sed -n` или `head` и хотя бы один аргумент после разбора `shlex` (при ошибке — `split`) указывает в каталог кода относительно `cwd`.
|
||||
Bash-команда считается чтением кода, если хотя бы одна её простая команда начинается с `grep`, `rg`, `find`, `cat`, `sed -n` или `head` и хотя бы один аргумент указывает в каталог кода относительно `cwd`. Простые команды выделяет `shlex` с `punctuation_chars`: кавычки учитываются, делят `&&`, `||`, `;`, `|`, `&` и скобки, цель редиректа пропускается; при незакрытой кавычке — `split`. Правило `src/` срабатывает и на сам каталог `src` в аргументе.
|
||||
|
||||
### 5. Блокировки, отказы, роли
|
||||
- Блокировка: результат инструмента совпадает с `^\s*PreToolUse:\S+ hook error` и содержит `BLOCKED`. Хук определяется так же, как в `hook_blocks_export.hook_of`, функция импортируется.
|
||||
@ -175,6 +179,12 @@ Bash-команда считается чтением кода, если хот
|
||||
| 2026-10-24 | `retro_stats.py` и `retro_blocks.py` удаляются | Их заменяет `retro_metrics.py`; в ретро 11.10 они упомянуты как история | Чтобы воспроизвести 11.10 буква в букву, нужен git history |
|
||||
| 2026-10-24 | Ходы и токены — по уникальному `message.id`, а не по строкам транскрипта | Строки одного сообщения повторяют `usage`; подсчёт по строкам завышает токены в 2–3 раза | Ходы и токены 11.10 (по строкам) несравнимы; в `2026-10-11.metrics.json` они помечены |
|
||||
| 2026-10-24 | Записи ассистента с `isApiErrorMessage` или `model: <synthetic>` — не ход модели: не считаются в ходах, токенах и стартовом контексте | Это сообщения Claude Code об ошибке API с нулевым `usage`; на живых транскриптах давали стартовый контекст 0 (O-18) | Если Claude Code пометит так настоящий ход, он выпадет из подсчёта |
|
||||
| 2026-10-24 | SDD workspace в worktree неслитой ветки — не хвост | Живой прогон показал рабочий каталог текущего плана как хвост (O-19); это работа в процессе | Брошенная неслитая задача со своим SDD workspace не попадёт в хвосты (её видно по worktree) |
|
||||
| 2026-10-24 | SDD workspace в worktree неслитой ветки — не хвост | Живой прогон показал рабочий каталог текущего плана как хвост (O-19); это работа в процессе | Брошенная неслитая задача со своим SDD workspace в хвосты не попадёт вовсе: worktree на неслитой ветке скрипт тоже не выводит |
|
||||
| 2026-10-24 | Транскрипты сабагентов вливаются в сессию-родителя (C-1, финальное ревью) | В режиме SDD почти вся работа — в сабагентах; без них замер codegraph (решение владельца 3) пуст по построению | Метрики 11.10 считались без сабагентов: `tool_calls`, `hook_blocks`, `classifier_denials`, `codegraph.init/explore` в `2026-10-11.metrics.json` помечены несравнимыми |
|
||||
| 2026-10-24 | Период режется по меткам времени записей, а не по первой метке сессии (I-7, финальное ревью) | Владелец держит одну сессию на много задач; при фильтре по началу сессии её работа после прошлого ретро не измерялась бы никогда | Сессия на стыке делится между двумя ретро; запись без метки относится к предыдущей |
|
||||
| 2026-10-24 | Без `--since` период берётся из существующего `--out`; «Цифры» — `--from` сохранённого среза (I-4, финальное ревью) | Черновик `docs/retro/<D>.md` иначе сдвигал период по умолчанию на дату самого ретро | Чтобы пересчитать срез за другой период, нужен явный `--since` или новый `--out` |
|
||||
| 2026-10-24 | Простые команды Bash — `shlex` с `punctuation_chars`, а не `Lexer` хука (I-1, финальное ревью) | Самодостаточно, учитывает кавычки; ретро не зависит от внутреннего API `harness_target.py` | Разбор heredoc и `$(…)` грубее, чем у хука: чтение кода внутри них может не посчитаться |
|
||||
| 2026-10-24 | Секреты заменяются до обрезки — `clip()` в `hook_blocks_export.py` (I-3, финальное ревью) | Обрезка до `redact` оставляла обрывок токена, который шаблон уже не узнаёт | Нет |
|
||||
| 2026-10-24 | «Перечитай» — побуждение перечитать рядом с «память», «контекст», `.md`; сводка компакции — не промпт (I-6, финальное ревью) | Широкий регэксп 11.10 ловил любое упоминание контекста и сводки компакции | `recall` 11.10 (широкий регэксп) помечен несравнимым |
|
||||
| 2026-10-24 | Свежая ветка, совпадающая с `origin/main`, — не хвост | `--merged` считает слитой и ветку без коммитов, например только что созданный worktree | Брошенная пустая ветка не попадёт в хвосты |
|
||||
| 2026-10-24 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику |
|
||||
|
||||
@ -25,12 +25,27 @@
|
||||
- **WHEN** в каталоге `~/.claude/projects/-p-repo-tools/` первая запись сессии имеет `cwd` `/p/repo-tools`
|
||||
- **THEN** сессия в срез проекта `/p/repo` не входит
|
||||
|
||||
### Requirement: Транскрипты сабагентов
|
||||
Транскрипты сабагентов сессии `<каталог>/<сессия>/subagents/*.jsonl` SHALL входить в сессию-родителя: ходы, токены, вызовы инструментов, codegraph, блокировки, отказы. Промпты, роль, стартовый контекст и кандидаты в поправки MUST браться только из основного транскрипта.
|
||||
|
||||
#### Scenario: codegraph у implementer-а
|
||||
- **WHEN** сабагент сессии вызвал `codegraph_explore` и прочитал файл кода, а его хук заблокировал запись
|
||||
- **THEN** вызов, чтение и блокировка считаются в срезе, а сообщение контроллера сабагенту промптом владельца не считается
|
||||
|
||||
### Requirement: Период среза
|
||||
Сессия SHALL входить в срез, если её первая метка времени не раньше `--since`. Без `--since` начало периода MUST браться из даты в имени последнего `docs/retro/ГГГГ-ММ-ДД*.md` репозитория обвязки. Если сессий нет, `retro_metrics.py` MUST завершаться с кодом 1 и сообщением, какой slug и период искались.
|
||||
Запись SHALL входить в срез, если её метка времени (у записи без метки — предыдущая) не раньше `--since`; сессия — если в ней есть записи периода. Стартовый контекст сессии MUST считаться, только если её первый ход в периоде. Без `--since` начало периода MUST браться из `period.since` существующего `--out`, иначе из даты последнего `docs/retro/ГГГГ-ММ-ДД*.md`. Если сессий нет — код 1 и сообщение со slug и периодом.
|
||||
|
||||
#### Scenario: Период по умолчанию
|
||||
- **WHEN** последний ретро-документ — `docs/retro/2026-10-11.md` и `--since` не указан
|
||||
- **THEN** в срез входят сессии с первой меткой времени 2026-10-11 и позже
|
||||
- **WHEN** последний ретро-документ — `docs/retro/2026-10-11.md`, `--since` не указан и `--out` ещё нет
|
||||
- **THEN** в срез входят записи с меткой времени 2026-10-11 и позже
|
||||
|
||||
#### Scenario: Длинная сессия на стыке периодов
|
||||
- **WHEN** сессия начата 2026-10-30, а `--since` — 2026-11-01
|
||||
- **THEN** её записи с 2026-11-01 входят в срез, записи до этой даты и её стартовый контекст — нет
|
||||
|
||||
#### Scenario: Черновик текущего ретро не сдвигает период
|
||||
- **WHEN** срез уже записан в `docs/retro/<D>.metrics.json`, создан черновик `docs/retro/<D>.md`, и срез запускается снова с тем же `--out` без `--since`
|
||||
- **THEN** период тот же, что в существующем `--out`
|
||||
|
||||
#### Scenario: Нет транскриптов
|
||||
- **WHEN** ни одного каталога со slug проекта нет или в периоде нет ни одной сессии
|
||||
@ -44,7 +59,7 @@
|
||||
- **THEN** срез считается по остальным строкам, а `sources.bad_lines` равно 1
|
||||
|
||||
### Requirement: Промпты владельца
|
||||
Промпт владельца SHALL считаться так: сообщение пользователя без `tool_result`, не `isMeta`, не начинающееся с `<`. Прерывания `[Request interrupted by user]` MUST считаться отдельной метрикой, не промптами.
|
||||
Промпт владельца SHALL считаться так: сообщение пользователя без `tool_result`, не `isMeta`, не сводка компакции (`isCompactSummary`), не начинающееся с `<`. Прерывания `[Request interrupted by user]` MUST считаться отдельной метрикой, не промптами.
|
||||
|
||||
#### Scenario: Служебные сообщения не считаются промптами
|
||||
- **WHEN** в транскрипте есть сообщение пользователя с `tool_result`, сообщение с `isMeta` и строка `<command-name>…`
|
||||
@ -88,13 +103,25 @@
|
||||
- **WHEN** в сессии 2 вызова `codegraph_explore`, Read `hooks/a.py` при `.claude/code-paths` = `hooks/`, Read `docs/x.md` и `grep -rn x hooks/`
|
||||
- **THEN** вызовов explore 2, чтений кода 2, доля explore 0.5; Read `docs/x.md` чтением кода не считается
|
||||
|
||||
#### Scenario: Разделитель внутри кавычек
|
||||
- **WHEN** Bash-команда `grep -n "a\|b" hooks/x.py` при `.claude/code-paths` = `hooks/`
|
||||
- **THEN** это одно чтение кода: `|` внутри кавычек команду не делит
|
||||
|
||||
#### Scenario: Каталог src без code-paths
|
||||
- **WHEN** в репозитории без `.claude/code-paths` выполнены `grep -rn foo src/` и `find svc/src -name x`
|
||||
- **THEN** обе команды — чтение кода
|
||||
|
||||
### Requirement: Память проекта
|
||||
Метрика памяти SHALL выводить размер `~/.claude/projects/<slug проекта>/memory/MEMORY.md` в байтах, число и общий объём файлов в этом каталоге. Отсутствующий каталог MUST давать нули, а не ошибку. Отдельно SHALL считаться промпты «перечитай память/контекст».
|
||||
Метрика памяти SHALL выводить размер `~/.claude/projects/<slug проекта>/memory/MEMORY.md` в байтах, число и общий объём файлов в этом каталоге. Отсутствующий каталог MUST давать нули, а не ошибку. Отдельно SHALL считаться промпты-побуждения перечитать: «перечитай», «прочитай», «сверься», «вспомни», «освежи» рядом с «память», «контекст» или `.md`.
|
||||
|
||||
#### Scenario: Памяти нет
|
||||
- **WHEN** каталога памяти проекта нет
|
||||
- **THEN** метрики памяти равны нулю, срез завершается с кодом 0
|
||||
|
||||
#### Scenario: Упоминание контекста — не побуждение
|
||||
- **WHEN** промпт «я сейчас смотрю на размер контекста» и сводка компакции со словами «перечитай память»
|
||||
- **THEN** ни то, ни другое не считается промптом «перечитай память/контекст»
|
||||
|
||||
### Requirement: Кандидаты в поправки владельца
|
||||
`retro_metrics.py --corrections` SHALL выгружать кандидатов в поправки владельца по процессу. Кандидат — это:
|
||||
- промпт владельца со словом-признаком: «нет», «не так», «почему», «зачем», «опять», «снова», «я же», «стоп», «подожди», «не надо», «сначала», «верни», «откати»;
|
||||
@ -119,14 +146,14 @@
|
||||
- **THEN** у кандидата указано действие `Bash: git branch -m feat/x`
|
||||
|
||||
### Requirement: Сравнение с прошлым ретро
|
||||
`retro_metrics.py --markdown --prev <прошлый>.metrics.json` SHALL печатать таблицу «Цифры» в три колонки: текущее значение, значение прошлого ретро, разница. Если метрики нет в прошлом файле или там `null`, MUST ставиться «—». Метрика, отмеченная в прошлом файле как больше не считаемая, MUST выводиться с этой пометкой.
|
||||
`retro_metrics.py --markdown --prev <прошлый>.metrics.json` SHALL печатать таблицу «Цифры» в три колонки: текущее значение, значение прошлого ретро, разница. Если метрики нет в прошлом файле или там `null`, MUST ставиться «—»; снятая или иначе считавшаяся метрика — с пометкой. `--from <metrics.json>` MUST печатать таблицу из сохранённого среза без пересчёта.
|
||||
|
||||
#### Scenario: Новая метрика
|
||||
- **WHEN** в прошлом файле нет метрики `corrections`
|
||||
- **THEN** строка поправок владельца показывает текущее значение и «—» в колонках прошлого и разницы
|
||||
|
||||
### Requirement: Секреты не попадают в черновик
|
||||
Всё, что скрипты ретро выводят из транскриптов (промпты, команды, сообщения), SHALL проходить замену похожих на секреты подстрок на `<redacted>` — той же функцией, что в `hook_blocks_export.py`.
|
||||
Всё, что скрипты ретро выводят из транскриптов (промпты, команды, сообщения), SHALL проходить замену похожих на секреты подстрок на `<redacted>` — той же функцией, что в `hook_blocks_export.py`, и MUST проходить её до обрезки, чтобы секрет на границе обрезки не утёк частично.
|
||||
|
||||
#### Scenario: Токен в промпте
|
||||
- **WHEN** промпт-кандидат содержит `tea login add --token 0123456789abcdef0123456789abcdef01234567`
|
||||
@ -161,18 +188,18 @@
|
||||
- **THEN** ссылки `refs/remotes/` и рабочее дерево репозитория не меняются
|
||||
|
||||
### Requirement: Выгрузка блокировок среза
|
||||
`hook_blocks_export.py --since <дата>` SHALL выгружать только блокировки сессий не раньше даты, с идентификаторами `<дата>-NNN`. Выгрузка среза MUST сохраняться в `docs/retro/fixtures/hook-blocks-<дата>.jsonl`; прежняя выгрузка 12.10 не меняется.
|
||||
`hook_blocks_export.py --since <дата>` SHALL выгружать только блокировки вызовов не раньше даты, вместе с транскриптами сабагентов сессии, с идентификаторами `<дата>-NNN`. Выгрузка среза MUST сохраняться в `docs/retro/fixtures/hook-blocks-<дата>.jsonl`; прежняя выгрузка 12.10 не меняется.
|
||||
|
||||
#### Scenario: Ранняя сессия не выгружается
|
||||
- **WHEN** у сессии первая метка времени раньше `--since`
|
||||
- **THEN** её блокировки в выгрузку не попадают
|
||||
#### Scenario: Ранний вызов не выгружается
|
||||
- **WHEN** в длинной сессии блокировка до `--since` и блокировка после
|
||||
- **THEN** выгружается только вторая
|
||||
|
||||
### Requirement: Разметка блокировок в корпус
|
||||
Агент SHALL размечать выгрузку в `docs/retro/fixtures/hook-labels-<дата>.json`:
|
||||
- для записи — `expected` (`allow|block`) и `why` по спеке `hook-guards`, либо `skip` с причиной;
|
||||
- для файла — раскладку песочницы.
|
||||
|
||||
`hook_cases_build.py` MUST добавлять по сегменту на каждую пару выгрузка + разметка. Записи со `skip` в корпус не входят.
|
||||
`hook_cases_build.py` MUST добавлять по сегменту на каждую пару выгрузка + разметка и падать на выгрузке без разметки. Записи со `skip` в корпус не входят.
|
||||
|
||||
#### Scenario: Размеченная выгрузка в корпусе
|
||||
- **WHEN** в `hook-labels-2026-11-01.json` три записи размечены и одна имеет `skip`
|
||||
|
||||
@ -56,3 +56,9 @@ PY
|
||||
out=$(python3 "$B" --fixtures-dir "$FX" 2>&1 >/dev/null); rc=$?
|
||||
assert_eq "разметка не совпадает с выгрузкой: exit 1" "$rc" "1"
|
||||
assert_grep "сообщение называет недостающий и лишний id" '2026-11-01-002.*2026-11-01-009' <(printf '%s' "$out")
|
||||
# I-5: выгрузка ретро без парной разметки — сборка падает, а не пропускает её молча
|
||||
FX2="$TMP/fx2"; mkdir -p "$FX2"; cp "$ROOT/docs/retro/fixtures/hook-blocks.jsonl" "$FX2/"
|
||||
head -1 "$FX/hook-blocks-2026-11-01.jsonl" > "$FX2/hook-blocks-2026-11-07.jsonl"
|
||||
out=$(python3 "$B" --fixtures-dir "$FX2" 2>&1 >/dev/null); rc=$?
|
||||
assert_eq "выгрузка без разметки: exit 1" "$rc" "1"
|
||||
assert_grep "сообщение называет файл выгрузки" 'hook-blocks-2026-11-07\.jsonl' <(printf '%s' "$out")
|
||||
|
||||
@ -18,8 +18,18 @@ T2="$TMP/tr2"; mkdir -p "$T2"
|
||||
blk='{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u1","is_error":true,"content":"PreToolUse:Bash hook error: BLOCKED [guard-main]: запись в f"}]}}'
|
||||
printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-10-01T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo a > f"}}]}}' "$blk" > "$T2/old.jsonl"
|
||||
printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-11-02T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo b > f"}}]}}' "$blk" > "$T2/new.jsonl"
|
||||
# C-1: блокировка сабагента — в выгрузку сессии-родителя
|
||||
mkdir -p "$T2/new/subagents"
|
||||
printf '%s\n%s\n' '{"type":"assistant","isSidechain":true,"cwd":"/r","gitBranch":"main","timestamp":"2026-11-02T11:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo c > f"}}]}}' "$blk" > "$T2/new/subagents/agent-x.jsonl"
|
||||
# I-7: длинная сессия на стыке — блок до периода не выгружается, в периоде — выгружается; I-3: секрет на границе обрезки
|
||||
lblk='{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u2","is_error":true,"content":"PreToolUse:Bash hook error: BLOCKED [guard-main] '"$(printf 'z%.0s' $(seq 235))"' fedcba9876543210fedcba9876543210fedcba98"}]}}'
|
||||
printf '%s\n%s\n%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-10-01T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo k > f"}}]}}' "$blk" '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-11-03T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u2","name":"Bash","input":{"command":"echo l > f"}}]}}' "$lblk" > "$T2/long.jsonl"
|
||||
out=$(python3 "$X" --since 2026-11-01 --id-prefix 2026-11-05 "$T2")
|
||||
assert_eq "--since: только сессия не раньше даты" "$(printf '%s\n' "$out" | grep -c .)" "1"
|
||||
assert_eq "--id-prefix: id <дата>-NNN" "$(printf '%s\n' "$out" | python3 -c 'import json,sys; r=json.loads(sys.stdin.readline()); print(r["id"], r["command"])')" "2026-11-05-001 echo b > f"
|
||||
assert_eq "--since по записям, сабагенты — в выгрузке, id <дата>-NNN" "$(printf '%s\n' "$out" | python3 -c 'import json,sys; print([(r["id"], r["session"], r["command"]) for r in map(json.loads, sys.stdin)])')" "[('2026-11-05-001', 'long.jso', 'echo l > f'), ('2026-11-05-002', 'new.json', 'echo b > f'), ('2026-11-05-003', 'new.json', 'echo c > f')]"
|
||||
assert_eq "секрет на границе обрезки сообщения не утекает" "$(printf '%s\n' "$out" | python3 -c '
|
||||
import sys
|
||||
out, t = sys.stdin.read(), "fedcba9876543210fedcba9876543210fedcba98"
|
||||
print(sum(t[i:i + 8] in out for i in range(len(t) - 7)))')" "0"
|
||||
assert_eq "старая обрезка до redact утекла бы (проверка теста)" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import hook_blocks_export as h; m='PreToolUse:Bash hook error: BLOCKED [guard-main] ' + 'z'*235 + ' fedcba9876543210fedcba9876543210fedcba98'; print('fedcba98' in h.redact(m[:300]), 'fedcba98' in h.clip(m, 300))")" "True False"
|
||||
assert_eq "без флагов поля id нет (выгрузка 12.10 не меняется)" "$(python3 "$X" "$T2" | python3 -c 'import json,sys; print(sum("id" in json.loads(l) for l in sys.stdin))')" "0"
|
||||
assert_eq "импорт модуля не выгружает" "$(python3 -c "import sys; sys.argv=['x', '$T2']; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import hook_blocks_export" 2>&1)" ""
|
||||
|
||||
@ -50,6 +50,35 @@ w(A, "s1.jsonl", [
|
||||
{"type": "system", "subtype": "compact_boundary", "timestamp": T % 9},
|
||||
"not json",
|
||||
"[]",
|
||||
# I-3: секрет на границе обрезки хвоста ответа (200) и текста кандидата (300)
|
||||
a("m6", [{"type": "text", "text": "Выполни: tea login add --token fedcba9876543210fedcba9876543210fedcba98 " + "y" * 170}], T % 10),
|
||||
u("стоп, " + "x" * 270 + " 0123abcd0123abcd0123abcd0123abcd0123abcd", T % 11),
|
||||
# I-6: упоминание контекста — не «перечитай»; сводка компакции — не промпт владельца
|
||||
u("я сейчас смотрю на размер контекста в чистой сессии", T % 12),
|
||||
{"type": "user", "timestamp": T % 13, "cwd": P, "isCompactSummary": True, "isVisibleInTranscriptOnly": True,
|
||||
"message": {"role": "user", "content": "This session is being continued from a previous conversation. Нет, перечитай память."}},
|
||||
# I-1: «|» внутри кавычек не режет команду
|
||||
a("m7", [tu("t13", "Bash", command='grep -n "a\\|b" hooks/x.py'), tu("t14", "Bash", command='cat docs/a.md | grep "x|y" docs/')], T % 14),
|
||||
])
|
||||
# C-1: транскрипт сабагента — в сессию-родителя (инструменты, ходы, токены, codegraph, блокировки, отказы), без промптов
|
||||
os.makedirs(os.path.join(CR, A, "s1", "subagents"), exist_ok=True)
|
||||
w(os.path.join(A, "s1", "subagents"), "agent-a1.jsonl", [
|
||||
{"type": "user", "isSidechain": True, "timestamp": "2026-11-02T10:03:30Z", "cwd": P,
|
||||
"message": {"role": "user", "content": "Задача: реализуй, почему бы и нет"}},
|
||||
a("sa1", [tu("sx1", "mcp__codegraph__codegraph_explore", query="z"), tu("sx2", "Read", file_path=P + "/hooks/b.py"),
|
||||
tu("sx3", "Bash", command="echo y > g"), tu("sx4", "Bash", command="git push --force")], "2026-11-02T10:03:40Z",
|
||||
usage={"input_tokens": 10, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 5}),
|
||||
tr("sx3", 'PreToolUse:Bash hook error: BLOCKED [guard-main]: запись в g', "2026-11-02T10:03:41Z"),
|
||||
tr("sx4", "denied by the Claude Code auto mode classifier. Reason: [Git Destructive]", "2026-11-02T10:03:42Z"),
|
||||
])
|
||||
# I-7: длинная сессия на стыке периодов — считаются только записи периода, стартовый контекст — вне периода
|
||||
w(A, "s6.jsonl", [
|
||||
u("старт до периода", "2026-10-30T09:00:00Z"),
|
||||
a("p1", [], "2026-10-30T09:01:00Z",
|
||||
usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 40000, "output_tokens": 0}),
|
||||
u("продолжаю", "2026-11-02T08:00:00Z"),
|
||||
a("p2", [tu("z1", "Bash", command="ls")], "2026-11-02T08:01:00Z",
|
||||
usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 70000, "output_tokens": 0}),
|
||||
])
|
||||
W = P + "/.claude/worktrees/x"
|
||||
w(A + "--claude-worktrees-x", "s2.jsonl", [
|
||||
@ -77,44 +106,52 @@ python3 "$M" "$P/" --since 2026-11-01 --projects-root "$CR" --out "$J"; rc=$?
|
||||
assert_eq "срез: exit 0" "$rc" "0"
|
||||
q(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(eval(sys.argv[2]))' "$J" "$1"; }
|
||||
assert_eq "схема 1" "$(q 'd["schema"]')" "1"
|
||||
assert_eq "сессии: s1, s2 (worktree), s5 (.wt) — без старой и чужой" "$(q 'm["sessions"]')" "3"
|
||||
assert_eq "сессии: s1, s2 (worktree), s5 (.wt), s6 (на стыке) — без старой и чужой" "$(q 'm["sessions"]')" "4"
|
||||
assert_eq "каталоги транскриптов среза" "$(q 'len(d["sources"]["dirs"])')" "3"
|
||||
assert_eq "битые строки" "$(q 'd["sources"]["bad_lines"]')" "1"
|
||||
assert_eq "промпты владельца без служебных и прерываний" "$(q 'm["prompts"]')" "6"
|
||||
assert_eq "промпты владельца без служебных, прерываний, сводки компакции, сабагента и записей до периода" "$(q 'm["prompts"]')" "9"
|
||||
assert_eq "прерывания" "$(q 'm["interrupts"]')" "1"
|
||||
assert_eq "ходы — по message.id" "$(q 'm["turns"]')" "7"
|
||||
assert_eq "вызовы инструментов" "$(q 'm["tool_calls"]')" "15"
|
||||
assert_eq "токены вывода один раз на сообщение" "$(q 'm["tokens"]["output"]')" "10"
|
||||
assert_eq "стартовый контекст" "$(q 'sorted(m["start_context"].items())')" "[('max', 61000), ('median', 56000), ('min', 51100)]"
|
||||
assert_eq "блокировки только по ошибке хука" "$(q 'm["hook_blocks"], m["hook_blocks_by_hook"]')" "(1, {'guard-main(Bash)': 1})"
|
||||
assert_eq "отказы классификатора по причине" "$(q 'm["classifier_by_reason"]')" "{'Self-Modification': 1}"
|
||||
assert_eq "ходы — по message.id, с сабагентом, только в периоде" "$(q 'm["turns"]')" "11"
|
||||
assert_eq "вызовы инструментов с сабагентом" "$(q 'm["tool_calls"]')" "22"
|
||||
assert_eq "токены вывода один раз на сообщение, с сабагентом" "$(q 'm["tokens"]["output"]')" "15"
|
||||
assert_eq "стартовый контекст — без сабагента и без сессии, начатой до периода" "$(q 'sorted(m["start_context"].items())')" "[('max', 61000), ('median', 56000), ('min', 51100)]"
|
||||
assert_eq "блокировки только по ошибке хука, с сабагентом" "$(q 'm["hook_blocks"], m["hook_blocks_by_hook"]')" "(2, {'guard-main(Bash)': 2})"
|
||||
assert_eq "отказы классификатора по причине, с сабагентом" "$(q 'sorted(m["classifier_by_reason"].items())')" "[('Git Destructive', 1), ('Self-Modification', 1)]"
|
||||
assert_eq "отказы владельца" "$(q 'm["owner_denials"]')" "1"
|
||||
assert_eq "codegraph против чтения кода" "$(q 'sorted(m["codegraph"].items())')" "[('code_reads', 5), ('explore', 2), ('explore_share', 0.29), ('init', 1)]"
|
||||
assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 2)]"
|
||||
assert_eq "codegraph против чтения кода: с сабагентом и grep с «|» в кавычках" "$(q 'sorted(m["codegraph"].items())')" "[('code_reads', 7), ('explore', 3), ('explore_share', 0.3), ('init', 1)]"
|
||||
assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 3)]"
|
||||
assert_eq "сабагенты по моделям" "$(q 'sorted(m["subagent_models"].items())')" "[('sonnet', 1), ('не указана', 1)]"
|
||||
assert_eq "skills" "$(q 'm["skills"]')" "{'role': 1}"
|
||||
assert_eq "компакции, перечитай" "$(q 'm["compactions"], m["recall"]')" "(1, 1)"
|
||||
assert_eq "кандидаты в поправки" "$(q 'm["corrections"]')" "4"
|
||||
assert_eq "компакции; «перечитай» — только побуждение перечитать" "$(q 'm["compactions"], m["recall"]')" "(1, 1)"
|
||||
assert_eq "кандидаты в поправки" "$(q 'm["corrections"]')" "5"
|
||||
assert_eq "память" "$(q 'sorted(m["memory"].items())')" "[('bytes', 150), ('files', 2), ('index_bytes', 100)]"
|
||||
assert_eq "памяти нет — нули" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics; print(sorted(retro_metrics.memory_stats('$TMP/none', '$P').items()))")" "[('bytes', 0), ('files', 0), ('index_bytes', 0)]"
|
||||
# I-2: правило src/ без code-paths — каталог src как аргумент тоже код
|
||||
NC="$TMP/nocp"; mkdir -p "$NC/.git" "$NC/src" "$NC/svc/src"
|
||||
assert_eq "src/ без code-paths: каталог как аргумент" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics as r; print([r.bash_reads_code(c, '$NC', {}) for c in ('grep -rn foo src/', 'grep -rn foo src', 'find svc/src -name x', 'grep -rn foo docs/')])")" "[True, True, True, False]"
|
||||
# кандидаты в поправки: контекст и redact
|
||||
C="$TMP/corr.jsonl"
|
||||
python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --corrections > "$C"
|
||||
assert_eq "кандидатов 4" "$(grep -c . "$C")" "4"
|
||||
assert_eq "кандидатов 5" "$(grep -c . "$C")" "5"
|
||||
assert_eq "«почему» после EnterWorktree" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if "почему" in c["text"]])' "$C")" "['EnterWorktree']"
|
||||
assert_eq "последнее действие Bash" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if c["text"].startswith("нет")])' "$C")" "['Bash: git branch -m feat/x']"
|
||||
assert_eq "токен замазан" "$(grep -c '0123456789abcdef' "$C")" "0"
|
||||
assert_grep "токен заменён на <redacted>" '<redacted>' "$C"
|
||||
assert_eq "секрет на границе обрезки не утекает частично (I-3)" "$(python3 -c '
|
||||
import sys
|
||||
out = open(sys.argv[1], encoding="utf-8").read()
|
||||
toks = ["fedcba9876543210fedcba9876543210fedcba98", "0123abcd0123abcd0123abcd0123abcd0123abcd"]
|
||||
print(sum(t[i:i + 8] in out for t in toks for i in range(len(t) - 7)))' "$C")" "0"
|
||||
assert_eq "виды кандидатов" "$(python3 -c 'import json,sys; print(sorted(set(c["kind"] for c in map(json.loads, open(sys.argv[1])))))' "$C")" "['denial', 'interrupt', 'prompt']"
|
||||
# сравнение с прошлым ретро
|
||||
cat > "$TMP/prev.json" <<'EOF'
|
||||
{"schema": 1, "metrics": {"sessions": 44, "turns": {"value": 12121, "note": "по строкам"}, "cd_main": {"retired": "хуки 0.4.0"}}}
|
||||
EOF
|
||||
md=$(python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --markdown --prev "$TMP/prev.json")
|
||||
assert_grep "строка с разницей" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$md")
|
||||
assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 4 \| — \| — \|$' <(printf '%s' "$md")
|
||||
assert_grep "строка с разницей" '^\| Сессий \| 4 \| 44 \| -40 \|$' <(printf '%s' "$md")
|
||||
assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 5 \| — \| — \|$' <(printf '%s' "$md")
|
||||
assert_grep "снятая метрика" '^\| cd в основной checkout на main \| — \| — \| не считается: хуки 0.4.0 \|$' <(printf '%s' "$md")
|
||||
assert_grep "метрика с другим способом подсчёта" '^\| Ходов модели \| 7 \| 12 121 \(по строкам\) \| — \|$' <(printf '%s' "$md")
|
||||
assert_grep "метрика с другим способом подсчёта" '^\| Ходов модели \| 11 \| 12 121 \(по строкам\) \| — \|$' <(printf '%s' "$md")
|
||||
# ложные блокировки по разметке
|
||||
cat > "$TMP/labels.json" <<'EOF'
|
||||
{"home": "/h", "layout": [], "cases": {"x-001": {"expected": "allow", "why": "a"}, "x-002": {"expected": "allow", "why": "b"},
|
||||
@ -124,8 +161,15 @@ python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --labels "$TMP/labels
|
||||
assert_eq "ложные блокировки" "$(q 'sorted(m["false_blocks"].items())')" "[('false', 3), ('fixed', 2), ('known', 1), ('labelled', 4), ('share', 0.75), ('skipped', 1)]"
|
||||
# период по умолчанию — от даты последнего ретро
|
||||
H="$TMP/harness"; mkdir -p "$H/docs/retro"; touch "$H/docs/retro/2026-11-03.md" "$H/docs/retro/2026-10-11.md"
|
||||
J="$TMP/d.json"
|
||||
python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J"
|
||||
assert_eq "период с последнего ретро" "$(q 'd["period"]["since"], m["sessions"]')" "('2026-11-03', 2)"
|
||||
# I-4: черновик текущего ретро не сдвигает период — при существующем --out берётся его period.since
|
||||
touch "$H/docs/retro/2026-11-05.md"
|
||||
python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J"; rc=$?
|
||||
assert_eq "повторный срез в тот же --out: тот же период" "$rc $(q 'd["period"]["since"], m["sessions"]')" "0 ('2026-11-03', 2)"
|
||||
md=$(python3 "$M" "$P" --from "$J" --markdown --projects-root "$TMP/нет-такого")
|
||||
assert_grep "--from: таблица из сохранённого среза без пересчёта" '^\| Сессий \| 2 \| — \| — \|$' <(printf '%s' "$md")
|
||||
# нет сессий
|
||||
err=$(python3 "$M" "$P" --since 2030-01-01 --projects-root "$CR" 2>&1 >/dev/null); rc=$?
|
||||
assert_eq "нет сессий: exit 1" "$rc" "1"
|
||||
|
||||
@ -10,7 +10,8 @@ for sec in "## 1. Цифры" "## 2. Находки" "## 3. Поправки в
|
||||
assert_grep "шаблон: $sec" "^$sec" "$S/template.md"
|
||||
done
|
||||
J="$ROOT/docs/retro/2026-10-11.metrics.json"
|
||||
assert_eq "метрики 11.10: схема, cd снят, ложные" "$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(d["schema"], "retired" in m["cd_main"], m["false_blocks"]["false"], m["hook_blocks"])' "$J")" "1 True 72 76"
|
||||
assert_eq "метрики 11.10: схема, cd снят, ложные, блокировки без сабагентов" "$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(d["schema"], "retired" in m["cd_main"], m["false_blocks"]["false"], m["hook_blocks"]["value"])' "$J")" "1 True 72 76"
|
||||
assert_eq "метрики 11.10: несравнимое помечено (сабагенты, широкий «перечитай»)" "$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1]))["metrics"]; print([k for k in ("tool_calls", "hook_blocks", "classifier_denials", "recall") if "note" in m[k]], [k for k in ("init", "explore") if "note" in m["codegraph"][k]])' "$J")" "['tool_calls', 'hook_blocks', 'classifier_denials', 'recall'] ['init', 'explore']"
|
||||
out=$(python3 - "$ROOT/docs/retro/scripts" "$J" <<'PY'
|
||||
import json, sys
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
@ -22,7 +23,7 @@ print(retro_metrics.to_markdown(doc, prev))
|
||||
PY
|
||||
)
|
||||
assert_grep "сравнение с 11.10: сессии" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$out")
|
||||
assert_grep "сравнение с 11.10: блокировки" '^\| Блокировок хуков \| 1 \| 76 \| -75 \|$' <(printf '%s' "$out")
|
||||
assert_grep "сравнение с 11.10: блокировки считались без сабагентов" '^\| Блокировок хуков \| 1 \| 76 \(без сабагентов\) \| — \|$' <(printf '%s' "$out")
|
||||
assert_grep "сравнение с 11.10: cd снят" '^\| cd в основной checkout на main \| — \| — \| не считается' <(printf '%s' "$out")
|
||||
assert_grep "сравнение с 11.10: ходы считались по строкам" '^\| Ходов модели \| 7 \| 12 121 \(по строкам' <(printf '%s' "$out")
|
||||
assert_eq "retro_stats.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_stats.py" ] && echo есть || echo нет)" "нет"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user