diff --git a/.claude/skills/harness-retro/SKILL.md b/.claude/skills/harness-retro/SKILL.md index 61bfbc3..105d420 100644 --- a/.claude/skills/harness-retro/SKILL.md +++ b/.claude/skills/harness-retro/SKILL.md @@ -11,6 +11,8 @@ description: Ретро обвязки claude-harness по транскрипт 1. **Задача и worktree.** Issue «Ретро : <проект>» (`tea issues create`). Затем `EnterWorktree` с именем `retro-`, ветка `git branch -m retro/`, `codegraph init` в worktree. 2. **Срез.** Выполнить `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --out docs/retro/.metrics.json`. + - Период `` — `period.since` из `docs/retro/.metrics.json`. На шагах 3 и 5 передавать его явно (`--since `). Повторный запуск с тем же `--out` берёт период из него, поэтому черновик `.md` период не сдвигает. + - В срез входят записи периода, в том числе из длинных сессий, начатых раньше. Транскрипты сабагентов (`<сессия>/subagents/`) входят в сессию-родителя. - Exit 1 — сессий нет. Сообщить владельцу slug и период и остановиться. - Битых строк больше 1 % — отметить в «Что не измерялось». 3. **Блокировки.** Сначала выгрузить: `python3 docs/retro/scripts/hook_blocks_export.py --since --id-prefix <каталоги из sources.dirs> > docs/retro/fixtures/hook-blocks-.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-.json --out docs/retro/.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 --corrections`. - Отобрать только поправки по процессу: пропущенный шаг, лишний вопрос, неверный совет, обход правила, расширение задачи. Вопросы и просьбы по содержанию выбросить. - Сгруппировать по видам. - Вид, который повторяется (2 раза и больше) или стоил владельцу заметного времени, — находка `RT-n` и пункт бэклога. 6. **Качественная часть.** Прочитать память проекта целиком (`~/.claude/projects//memory/`) и примеры из выгрузок. Находки `RT-n` — сквозная нумерация: максимум в прошлых `docs/retro/*.md` плюс 1. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. 7. **Черновик.** Скопировать `.claude/skills/harness-retro/template.md` в `docs/retro/.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/.metrics.json --markdown --prev docs/retro/<прошлое>.metrics.json` — таблица из сохранённого среза, без пересчёта. - Секретов в черновике быть не должно. Цитаты брать только из вывода скриптов: он уже прошёл `redact`. 8. **PR.** `bash tests/run.sh` должен быть зелёным. Коммит: черновик, `metrics.json`, выгрузка, разметка, корпус. Затем `git push -u origin retro/` и `tea pulls create` со ссылкой на issue. Merge — только владелец. diff --git a/docs/retro/2026-10-11.metrics.json b/docs/retro/2026-10-11.metrics.json index a3e7354..cda91e2 100644 --- a/docs/retro/2026-10-11.metrics.json +++ b/docs/retro/2026-10-11.metrics.json @@ -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"} } diff --git a/docs/retro/scripts/hook_blocks_export.py b/docs/retro/scripts/hook_blocks_export.py index 3183d54..161ba7b 100644 --- a/docs/retro/scripts/hook_blocks_export.py +++ b/docs/retro/scripts/hook_blocks_export.py @@ -6,8 +6,9 @@ первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке. Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд, в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает. ---since — только сессии с первой меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — -поле id вида <префикс>-NNN (срез ретро, B12). Похожие на секреты подстроки заменяются на . +Транскрипты сабагентов (<сессия>/subagents/*.jsonl) выгружаются с сессией-родителем. --since — только вызовы +с меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — поле id вида <префикс>-NNN (срез +ретро, B12). Похожие на секреты подстроки заменяются на до обрезки. """ 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): diff --git a/docs/retro/scripts/hook_cases_build.py b/docs/retro/scripts/hook_cases_build.py index e63cf0a..8c91247 100644 --- a/docs/retro/scripts/hook_cases_build.py +++ b/docs/retro/scripts/hook_cases_build.py @@ -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")) diff --git a/docs/retro/scripts/retro_metrics.py b/docs/retro/scripts/retro_metrics.py index 84cceea..07c99c8 100644 --- a/docs/retro/scripts/retro_metrics.py +++ b/docs/retro/scripts/retro_metrics.py @@ -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"(?/<сессия>/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") == "": @@ -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 diff --git a/openspec/changes/harness-retro/design.md b/openspec/changes/harness-retro/design.md index a160171..4b6b1b6 100644 --- a/openspec/changes/harness-retro/design.md +++ b/openspec/changes/harness-retro/design.md @@ -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: ` — не ход модели: не считаются в ходах, токенах и стартовом контексте | Это сообщения 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/.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 не попадёт в метрику | diff --git a/openspec/changes/harness-retro/specs/harness-retro/spec.md b/openspec/changes/harness-retro/specs/harness-retro/spec.md index 18882be..b48adf3 100644 --- a/openspec/changes/harness-retro/specs/harness-retro/spec.md +++ b/openspec/changes/harness-retro/specs/harness-retro/spec.md @@ -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/.metrics.json`, создан черновик `docs/retro/.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` и строка `…` @@ -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//memory/MEMORY.md` в байтах, число и общий объём файлов в этом каталоге. Отсутствующий каталог MUST давать нули, а не ошибку. Отдельно SHALL считаться промпты «перечитай память/контекст». +Метрика памяти SHALL выводить размер `~/.claude/projects//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 ` MUST печатать таблицу из сохранённого среза без пересчёта. #### Scenario: Новая метрика - **WHEN** в прошлом файле нет метрики `corrections` - **THEN** строка поправок владельца показывает текущее значение и «—» в колонках прошлого и разницы ### Requirement: Секреты не попадают в черновик -Всё, что скрипты ретро выводят из транскриптов (промпты, команды, сообщения), SHALL проходить замену похожих на секреты подстрок на `` — той же функцией, что в `hook_blocks_export.py`. +Всё, что скрипты ретро выводят из транскриптов (промпты, команды, сообщения), SHALL проходить замену похожих на секреты подстрок на `` — той же функцией, что в `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` diff --git a/tests/test-hook-corpus.sh b/tests/test-hook-corpus.sh index aee9b52..e2ccb8b 100644 --- a/tests/test-hook-corpus.sh +++ b/tests/test-hook-corpus.sh @@ -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") diff --git a/tests/test-hook-export.sh b/tests/test-hook-export.sh index 4de0460..dbf41c6 100644 --- a/tests/test-hook-export.sh +++ b/tests/test-hook-export.sh @@ -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)" "" diff --git a/tests/test-retro-metrics.sh b/tests/test-retro-metrics.sh index 3a16525..8861f8a 100644 --- a/tests/test-retro-metrics.sh +++ b/tests/test-retro-metrics.sh @@ -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 "токен заменён на " '' "$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" diff --git a/tests/test-retro-skill.sh b/tests/test-retro-skill.sh index 43346db..7ab7e6d 100644 --- a/tests/test-retro-skill.sh +++ b/tests/test-retro-skill.sh @@ -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 нет)" "нет"