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:
andrey.epifantsev 2026-10-24 01:59:41 +04:00
parent cc697c030c
commit 3d2ce3f41d
11 changed files with 299 additions and 125 deletions

View File

@ -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 — только владелец.

View File

@ -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"}
}

View File

@ -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,53 +64,53 @@ def _records(path):
yield j
def first_ts(path):
def _blocks(path, sid, since):
"""Блокировки одного транскрипта; с since — только вызовы с меткой времени не раньше даты."""
pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
for j in _records(path):
if isinstance(j.get("timestamp"), str) and j["timestamp"]:
return j["timestamp"]
return ""
m = j.get("message") or {}
cont = m.get("content")
if j.get("type") == "assistant":
for b in cont or []:
if isinstance(b, dict) and b.get("type") == "tool_use":
pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""),
j.get("gitBranch", ""), j.get("timestamp", ""))
elif j.get("type") == "user" and isinstance(cont, list):
for b in cont:
if not (isinstance(b, dict) and b.get("type") == "tool_result"):
continue
txt = b.get("content", "")
if isinstance(txt, list):
txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict))
if not isinstance(txt, str) or not HOOK_ERROR.match(txt):
continue # обычный вывод команды, даже если в нём есть слово BLOCKED
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), "")
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": 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"))):
if since and first_ts(f)[:10] < since:
continue
sid = os.path.basename(f)[:8]
pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
for j in _records(f):
m = j.get("message") or {}
cont = m.get("content")
if j.get("type") == "assistant":
for b in cont or []:
if isinstance(b, dict) and b.get("type") == "tool_use":
pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""),
j.get("gitBranch", ""), j.get("timestamp", ""))
elif j.get("type") == "user" and isinstance(cont, list):
for b in cont:
if not (isinstance(b, dict) and b.get("type") == "tool_result"):
continue
txt = b.get("content", "")
if isinstance(txt, list):
txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict))
if not isinstance(txt, str) or not HOOK_ERROR.match(txt):
continue # обычный вывод команды, даже если в нём есть слово BLOCKED
name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", ""))
if name not in ("Bash", "Edit", "Write", "MultiEdit"):
continue
msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "")
rec = {
"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,
}
if prefix:
n += 1
rec = dict({"id": "%s-%03d" % (prefix, n)}, **rec)
yield rec
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)
yield rec
def main(argv):

View File

@ -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"))

View File

@ -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
files.append(f)
return sorted(set(os.path.basename(os.path.dirname(f)) for f in files)), files
cwd, _ = first_meta(f)
if cwd and (inside(cwd, project) or inside(cwd, project + ".wt")):
files.append(f)
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 simple_commands(cmd):
"""Простые команды Bash списками слов: кавычки учитываются, делят &&, ||, ;, |, &, скобки; цель редиректа
пропускается. Незакрытая кавычка — разбор по пробелам."""
try:
lx = shlex.shlex(cmd or "", posix=True, punctuation_chars=True)
lx.whitespace_split = True
toks = list(lx)
except ValueError:
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 part in SEPARATORS.split(cmd or ""):
try:
words = shlex.split(part)
except ValueError:
words = part.split()
if not words or not (words[0] in READERS or (words[0] == "sed" and "-n" in words[1:2])):
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:
n = lambda k: u.get(k, 0) or 0
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 "?"
count_tool(s, b, rec.get("cwd"), cache)
elif t == "user":
if live:
count_tool(s, b, rec.get("cwd"), cache)
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

View File

@ -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 не попадёт в метрику |

View File

@ -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`

View File

@ -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")

View File

@ -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)" ""

View File

@ -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"

View File

@ -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 нет)" "нет"