fix(retro): финальное ревью — сабагенты, период по записям, разбор Bash, redact до обрезки, --from (#29)
- C-1: транскрипты сабагентов (<сессия>/subagents/*.jsonl) вливаются в сессию-родителя — ходы, токены, инструменты, codegraph, блокировки, отказы; промпты, роль, стартовый контекст — только из основного. hook_blocks_export.py выгружает их блокировки с сессией-родителем. - I-7: период режется по меткам времени записей; сессия на стыке делится между ретро, стартовый контекст — если первый ход в периоде. - I-1: простые команды Bash — shlex с punctuation_chars, «|» в кавычках команду не делит. I-2: правило src/ ловит и сам каталог src. - I-3: clip() — redact до обрезки (кандидаты, выгрузка), роль — redact. - I-4: без --since период берётся из существующего --out; --from рисует «Цифры» из сохранённого среза. SKILL.md передаёт период явно. - I-5: выгрузка ретро без разметки — сборка корпуса падает. - I-6: сводка компакции — не промпт; «перечитай» — побуждение перечитать. - 2026-10-11.metrics.json: несравнимое помечено (без сабагентов, широкий «перечитай»). Спека и design.md (Decisions) обновлены. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cc697c030c
commit
3d2ce3f41d
@ -11,6 +11,8 @@ description: Ретро обвязки claude-harness по транскрипт
|
|||||||
|
|
||||||
1. **Задача и worktree.** Issue «Ретро <D>: <проект>» (`tea issues create`). Затем `EnterWorktree` с именем `retro-<D>`, ветка `git branch -m retro/<D>`, `codegraph init` в worktree.
|
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`.
|
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 и период и остановиться.
|
- Exit 1 — сессий нет. Сообщить владельцу slug и период и остановиться.
|
||||||
- Битых строк больше 1 % — отметить в «Что не измерялось».
|
- Битых строк больше 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/<имя>`.
|
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"` и завести находку.
|
- Собрать корпус: `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`.
|
- Пересчитать срез с `--labels docs/retro/fixtures/hook-labels-<D>.json --out docs/retro/<D>.metrics.json`.
|
||||||
4. **Хвосты закрытия.** `python3 docs/retro/scripts/retro_leftovers.py <папка> --fetch --markdown`. Ничего не удалять: таблица идёт в черновик, уборка — по решению владельца.
|
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` и пункт бэклога.
|
- Вид, который повторяется (2 раза и больше) или стоил владельцу заметного времени, — находка `RT-n` и пункт бэклога.
|
||||||
6. **Качественная часть.** Прочитать память проекта целиком (`~/.claude/projects/<slug>/memory/`) и примеры из выгрузок. Находки `RT-n` — сквозная нумерация: максимум в прошлых `docs/retro/*.md` плюс 1. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение.
|
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` и заполнить.
|
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`.
|
- Секретов в черновике быть не должно. Цитаты брать только из вывода скриптов: он уже прошёл `redact`.
|
||||||
8. **PR.** `bash tests/run.sh` должен быть зелёным. Коммит: черновик, `metrics.json`, выгрузка, разметка, корпус. Затем `git push -u origin retro/<D>` и `tea pulls create` со ссылкой на issue. Merge — только владелец.
|
8. **PR.** `bash tests/run.sh` должен быть зелёным. Коммит: черновик, `metrics.json`, выгрузка, разметка, корпус. Затем `git push -u origin retro/<D>` и `tea pulls create` со ссылкой на issue. Merge — только владелец.
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
"prompts": 613,
|
"prompts": 613,
|
||||||
"interrupts": null,
|
"interrupts": null,
|
||||||
"turns": {"value": 12121, "note": "по строкам транскрипта, не по сообщениям"},
|
"turns": {"value": 12121, "note": "по строкам транскрипта, не по сообщениям"},
|
||||||
"tool_calls": 6583,
|
"tool_calls": {"value": 6583, "note": "без сабагентов"},
|
||||||
"tools": {"Bash": 4302, "Playwright": 1100, "Read": 346, "Write": 271, "Edit": 233},
|
"tools": {"Bash": 4302, "Playwright": 1100, "Read": 346, "Write": 271, "Edit": 233},
|
||||||
"roles": {"dev": 32, "admin": 7, "qa": 5},
|
"roles": {"dev": 32, "admin": 7, "qa": 5},
|
||||||
"subagents": 61,
|
"subagents": 61,
|
||||||
@ -29,15 +29,15 @@
|
|||||||
},
|
},
|
||||||
"compactions": 4,
|
"compactions": 4,
|
||||||
"start_context": {"median": 56000, "min": 52000, "max": 61000},
|
"start_context": {"median": 56000, "min": 52000, "max": 61000},
|
||||||
"hook_blocks": 76,
|
"hook_blocks": {"value": 76, "note": "без сабагентов"},
|
||||||
"hook_blocks_by_hook": null,
|
"hook_blocks_by_hook": null,
|
||||||
"false_blocks": {"labelled": 76, "skipped": 0, "false": 72, "share": 0.95, "fixed": 72, "known": 0},
|
"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},
|
"classifier_by_reason": {"Git Destructive": 2, "Auto-Mode Bypass": 2, "Interfere With Workloads": 1, "safety-check": 1},
|
||||||
"owner_denials": 6,
|
"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,
|
"corrections": null,
|
||||||
"recall": 9,
|
"recall": {"value": 9, "note": "широкий регэксп, со сводками компакции"},
|
||||||
"memory": {"index_bytes": null, "files": 47, "bytes": 183296},
|
"memory": {"index_bytes": null, "files": 47, "bytes": 183296},
|
||||||
"cd_main": {"retired": "хуки 0.4.0 не зависят от cwd (RT-2); считалось по именам репозиториев awp-ca, было 84"}
|
"cd_main": {"retired": "хуки 0.4.0 не зависят от cwd (RT-2); считалось по именам репозиториев awp-ca, было 84"}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,9 @@
|
|||||||
первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке.
|
первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке.
|
||||||
Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд,
|
Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд,
|
||||||
в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает.
|
в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает.
|
||||||
--since — только сессии с первой меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) —
|
Транскрипты сабагентов (<сессия>/subagents/*.jsonl) выгружаются с сессией-родителем. --since — только вызовы
|
||||||
поле id вида <префикс>-NNN (срез ретро, B12). Похожие на секреты подстроки заменяются на <redacted>.
|
с меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — поле id вида <префикс>-NNN (срез
|
||||||
|
ретро, B12). Похожие на секреты подстроки заменяются на <redacted> до обрезки.
|
||||||
"""
|
"""
|
||||||
import glob
|
import glob
|
||||||
import json
|
import json
|
||||||
@ -31,6 +32,12 @@ def redact(s):
|
|||||||
return RE_SECRET.sub(sub, 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):
|
def hook_of(txt, tool):
|
||||||
if "[guard-main]" in txt:
|
if "[guard-main]" in txt:
|
||||||
return "guard-main(Bash)" if tool == "Bash" else "guard-main(Edit/Write)"
|
return "guard-main(Bash)" if tool == "Bash" else "guard-main(Edit/Write)"
|
||||||
@ -57,53 +64,53 @@ def _records(path):
|
|||||||
yield j
|
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):
|
for j in _records(path):
|
||||||
if isinstance(j.get("timestamp"), str) and j["timestamp"]:
|
m = j.get("message") or {}
|
||||||
return j["timestamp"]
|
cont = m.get("content")
|
||||||
return ""
|
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):
|
def export(dirs, since=None, prefix=None):
|
||||||
"""Записи выгрузки по каталогам транскриптов, по порядку файлов и строк."""
|
"""Записи выгрузки по каталогам транскриптов: сессия, затем её сабагенты (<сессия>/subagents/*.jsonl)."""
|
||||||
n = 0
|
n = 0
|
||||||
for d in dirs:
|
for d in dirs:
|
||||||
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))):
|
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]
|
sid = os.path.basename(f)[:8]
|
||||||
pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
|
subs = sorted(glob.glob(os.path.join(f[:-len(".jsonl")], "subagents", "*.jsonl")))
|
||||||
for j in _records(f):
|
for path in [f] + subs:
|
||||||
m = j.get("message") or {}
|
for rec in _blocks(path, sid, since):
|
||||||
cont = m.get("content")
|
if prefix:
|
||||||
if j.get("type") == "assistant":
|
n += 1
|
||||||
for b in cont or []:
|
rec = dict({"id": "%s-%03d" % (prefix, n)}, **rec)
|
||||||
if isinstance(b, dict) and b.get("type") == "tool_use":
|
yield rec
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
def main(argv):
|
||||||
|
|||||||
@ -82,6 +82,11 @@ def why_of(cid, r):
|
|||||||
|
|
||||||
def label_segments(fix):
|
def label_segments(fix):
|
||||||
"""Сегменты ретро: пары hook-blocks-<дата>.jsonl + hook-labels-<дата>.json. Разметка должна совпадать с выгрузкой."""
|
"""Сегменты ретро: пары 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"))):
|
for lp in sorted(glob.glob(os.path.join(fix, "hook-labels-*.json"))):
|
||||||
date = os.path.basename(lp)[len("hook-labels-"):-len(".json")]
|
date = os.path.basename(lp)[len("hook-labels-"):-len(".json")]
|
||||||
lab = json.load(open(lp, encoding="utf-8"))
|
lab = json.load(open(lp, encoding="utf-8"))
|
||||||
|
|||||||
@ -20,17 +20,19 @@ import sys
|
|||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.insert(0, HERE)
|
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"
|
INTERRUPT = "[Request interrupted by user"
|
||||||
OWNER_DENY = "The user doesn't want to proceed with this tool use"
|
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: \[([^\]]+)\]")
|
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)(нет|не так|почему|зачем|опять|снова|я же|стоп|подожди|не надо|сначала|верни|откати)"
|
MARKERS = re.compile(r"(?<!\w)(нет|не так|почему|зачем|опять|снова|я же|стоп|подожди|не надо|сначала|верни|откати)"
|
||||||
r"(?!\w)", re.I)
|
r"(?!\w)", re.I)
|
||||||
READERS = ("grep", "rg", "find", "cat", "head")
|
READERS = ("grep", "rg", "find", "cat", "head")
|
||||||
SEPARATORS = re.compile(r"&&|\|\||;|\|")
|
|
||||||
FILE_TOOLS = ("Edit", "Write", "Read", "MultiEdit", "NotebookEdit")
|
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):
|
def slug(path):
|
||||||
@ -71,8 +73,17 @@ def first_meta(path):
|
|||||||
return cwd, ts
|
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):
|
def project_sessions(project, root, since):
|
||||||
"""(использованные каталоги, транскрипты среза): каталоги по префиксу slug, сессии по первому cwd и since."""
|
"""Транскрипты проекта: каталоги по префиксу slug, сессии по первому cwd. Период режется по записям (since)."""
|
||||||
pre = slug(project)
|
pre = slug(project)
|
||||||
best = {}
|
best = {}
|
||||||
for d in sorted(glob.glob(os.path.join(root, "*"))):
|
for d in sorted(glob.glob(os.path.join(root, "*"))):
|
||||||
@ -84,13 +95,10 @@ def project_sessions(project, root, since):
|
|||||||
best[sid] = f
|
best[sid] = f
|
||||||
files = []
|
files = []
|
||||||
for f in sorted(best.values()):
|
for f in sorted(best.values()):
|
||||||
cwd, ts = first_meta(f)
|
cwd, _ = first_meta(f)
|
||||||
if not cwd or not (inside(cwd, project) or inside(cwd, project + ".wt")):
|
if cwd and (inside(cwd, project) or inside(cwd, project + ".wt")):
|
||||||
continue
|
files.append(f)
|
||||||
if since and (not ts or ts[:10] < since):
|
return files
|
||||||
continue
|
|
||||||
files.append(f)
|
|
||||||
return sorted(set(os.path.basename(os.path.dirname(f)) for f in files)), files
|
|
||||||
|
|
||||||
|
|
||||||
def blocks(rec):
|
def blocks(rec):
|
||||||
@ -111,6 +119,8 @@ def prompt_text(rec):
|
|||||||
"""Текст промпта владельца или None: служебное, tool_result, прерывание, сабагент."""
|
"""Текст промпта владельца или None: служебное, tool_result, прерывание, сабагент."""
|
||||||
if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"):
|
if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"):
|
||||||
return None
|
return None
|
||||||
|
if rec.get("isCompactSummary") or rec.get("isVisibleInTranscriptOnly"):
|
||||||
|
return None # сводка после компакции, не промпт владельца
|
||||||
if rec.get("userType", "external") != "external":
|
if rec.get("userType", "external") != "external":
|
||||||
return None
|
return None
|
||||||
bs = blocks(rec)
|
bs = blocks(rec)
|
||||||
@ -164,17 +174,39 @@ def is_code(path, cache):
|
|||||||
return False
|
return False
|
||||||
pre = code_prefixes(repo, cache)
|
pre = code_prefixes(repo, cache)
|
||||||
if pre is None:
|
if pre is None:
|
||||||
return "/src/" in "/" + rel
|
return "/src/" in "/" + rel + "/"
|
||||||
return any(rel == x or rel.startswith(x + "/") for x in pre)
|
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):
|
def bash_reads_code(cmd, cwd, cache):
|
||||||
for part in SEPARATORS.split(cmd or ""):
|
for words in simple_commands(cmd):
|
||||||
try:
|
if not (words[0] in READERS or (words[0] == "sed" and "-n" in words[1:2])):
|
||||||
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])):
|
|
||||||
continue
|
continue
|
||||||
for w in words[1:]:
|
for w in words[1:]:
|
||||||
if w.startswith("-") or not (os.path.isabs(w) or cwd):
|
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 "?"
|
sk = inp.get("skill") or "?"
|
||||||
s["skills"][sk] += 1
|
s["skills"][sk] += 1
|
||||||
if s["role"] is None and (sk == "role" or sk.endswith(":role")):
|
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:
|
if "codegraph_explore" in name:
|
||||||
s["cg_explore"] += 1
|
s["cg_explore"] += 1
|
||||||
elif name == "Read":
|
elif name == "Read":
|
||||||
@ -215,20 +247,24 @@ def count_tool(s, b, cwd, cache):
|
|||||||
s["code_reads"] += bash_reads_code(cmd, 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(),
|
s = {"prompts": 0, "interrupts": 0, "turns": 0, "tools": collections.Counter(),
|
||||||
"subagents": collections.Counter(), "skills": collections.Counter(), "role": None,
|
"subagents": collections.Counter(), "skills": collections.Counter(), "role": None,
|
||||||
"tokens": collections.Counter(), "compactions": 0, "start": None, "blocks": collections.Counter(),
|
"tokens": collections.Counter(), "compactions": 0, "start": None, "blocks": collections.Counter(),
|
||||||
"classifier": collections.Counter(), "owner_denials": 0, "cg_init": 0, "cg_explore": 0,
|
"classifier": collections.Counter(), "owner_denials": 0, "cg_init": 0, "cg_explore": 0,
|
||||||
"code_reads": 0, "recall": 0, "last_ts": ""}
|
"code_reads": 0, "recall": 0, "last_ts": ""}
|
||||||
seen, pending = set(), {}
|
seen, pending, cur, first_turn = set(), {}, "", True
|
||||||
for rec in read_session(path, bad):
|
for rec in read_session(path, bad):
|
||||||
ts = rec.get("timestamp")
|
ts = rec.get("timestamp")
|
||||||
if isinstance(ts, str) and ts > s["last_ts"]:
|
if isinstance(ts, str) and ts:
|
||||||
s["last_ts"] = ts
|
cur = ts
|
||||||
|
live = in_period(cur, since)
|
||||||
|
if live and cur > s["last_ts"]:
|
||||||
|
s["last_ts"] = cur
|
||||||
t = rec.get("type")
|
t = rec.get("type")
|
||||||
if t == "system" and rec.get("subtype") == "compact_boundary":
|
if t == "system" and rec.get("subtype") == "compact_boundary":
|
||||||
s["compactions"] += 1
|
s["compactions"] += live
|
||||||
elif t == "assistant":
|
elif t == "assistant":
|
||||||
msg = rec.get("message") or {}
|
msg = rec.get("message") or {}
|
||||||
if rec.get("isApiErrorMessage") or msg.get("model") == "<synthetic>":
|
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 None or mid not in seen:
|
||||||
if mid is not None:
|
if mid is not None:
|
||||||
seen.add(mid)
|
seen.add(mid)
|
||||||
s["turns"] += 1
|
|
||||||
u = msg.get("usage") or {}
|
u = msg.get("usage") or {}
|
||||||
if u:
|
n = lambda k: u.get(k, 0) or 0
|
||||||
n = lambda k: u.get(k, 0) or 0
|
if u and first_turn:
|
||||||
if s["start"] is None:
|
first_turn = False # стартовый контекст — первый ход сессии, если он в периоде
|
||||||
|
if live:
|
||||||
s["start"] = n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens")
|
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"]["output"] += n("output_tokens")
|
||||||
s["tokens"]["cache_read"] += n("cache_read_input_tokens")
|
s["tokens"]["cache_read"] += n("cache_read_input_tokens")
|
||||||
s["tokens"]["cache_write"] += n("cache_creation_input_tokens")
|
s["tokens"]["cache_write"] += n("cache_creation_input_tokens")
|
||||||
for b in blocks(rec):
|
for b in blocks(rec):
|
||||||
if b.get("type") == "tool_use":
|
if b.get("type") == "tool_use":
|
||||||
pending[b.get("id")] = b.get("name") or "?"
|
pending[b.get("id")] = b.get("name") or "?"
|
||||||
count_tool(s, b, rec.get("cwd"), cache)
|
if live:
|
||||||
elif t == "user":
|
count_tool(s, b, rec.get("cwd"), cache)
|
||||||
|
elif t == "user" and live:
|
||||||
txt = prompt_text(rec)
|
txt = prompt_text(rec)
|
||||||
if txt is not None:
|
if txt is not None:
|
||||||
s["prompts"] += 1
|
s["prompts"] += 1
|
||||||
@ -267,6 +306,11 @@ def session_stats(path, cache, bad):
|
|||||||
if m:
|
if m:
|
||||||
s["classifier"][m.group(1)] += 1
|
s["classifier"][m.group(1)] += 1
|
||||||
s["owner_denials"] += OWNER_DENY in rt
|
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
|
return s
|
||||||
|
|
||||||
|
|
||||||
@ -314,10 +358,12 @@ def false_blocks(labels):
|
|||||||
"known": len(known)}
|
"known": len(known)}
|
||||||
|
|
||||||
|
|
||||||
def corrections(path):
|
def corrections(path, since=None):
|
||||||
"""Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними."""
|
"""Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними (записи периода)."""
|
||||||
out, act, reply, sid = [], "", "", os.path.basename(path)[:8]
|
out, act, reply, sid, cur = [], "", "", os.path.basename(path)[:8], ""
|
||||||
for rec in read_session(path, {}):
|
for rec in read_session(path, {}):
|
||||||
|
if isinstance(rec.get("timestamp"), str) and rec["timestamp"]:
|
||||||
|
cur = rec["timestamp"]
|
||||||
t = rec.get("type")
|
t = rec.get("type")
|
||||||
if t == "assistant":
|
if t == "assistant":
|
||||||
for b in blocks(rec):
|
for b in blocks(rec):
|
||||||
@ -326,7 +372,7 @@ def corrections(path):
|
|||||||
elif b.get("type") == "text" and (b.get("text") or "").strip():
|
elif b.get("type") == "text" and (b.get("text") or "").strip():
|
||||||
reply = b["text"]
|
reply = b["text"]
|
||||||
continue
|
continue
|
||||||
if t != "user":
|
if t != "user" or not in_period(cur, since):
|
||||||
continue
|
continue
|
||||||
txt, kind = prompt_text(rec), None
|
txt, kind = prompt_text(rec), None
|
||||||
if txt is not 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)):
|
elif any(b.get("type") == "tool_result" and OWNER_DENY in result_text(b) for b in blocks(rec)):
|
||||||
kind, txt = "denial", "отказ владельца в разрешении"
|
kind, txt = "denial", "отказ владельца в разрешении"
|
||||||
if kind:
|
if kind:
|
||||||
out.append({"session": sid, "ts": rec.get("timestamp") or "", "kind": kind, "text": redact(txt[:300]),
|
out.append({"session": sid, "ts": rec.get("timestamp") or "", "kind": kind, "text": clip(txt, 300),
|
||||||
"action": redact(act[:300]), "reply_tail": redact(" ".join(reply.split())[-200:])})
|
"action": clip(act, 300), "reply_tail": clip(" ".join(reply.split()), 200, tail=True)})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@ -437,22 +483,38 @@ def parse_args(argv):
|
|||||||
p.add_argument("--out")
|
p.add_argument("--out")
|
||||||
p.add_argument("--markdown", action="store_true")
|
p.add_argument("--markdown", action="store_true")
|
||||||
p.add_argument("--prev")
|
p.add_argument("--prev")
|
||||||
|
p.add_argument("--from", dest="from_file")
|
||||||
p.add_argument("--corrections", action="store_true")
|
p.add_argument("--corrections", action="store_true")
|
||||||
return p.parse_args(argv[1:])
|
return p.parse_args(argv[1:])
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
def main(argv):
|
||||||
a = parse_args(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)
|
project = os.path.abspath(a.project)
|
||||||
since = a.since or default_since(a.harness)
|
since = a.since
|
||||||
dirs, files = project_sessions(project, a.projects_root, since)
|
if not since and a.out and os.path.isfile(a.out): # повторный срез того же ретро — тот же период
|
||||||
bad, cache = {"n": 0}, {}
|
try:
|
||||||
stats = [s for s in (session_stats(f, cache, bad) for f in files) if s["prompts"] or s["turns"]]
|
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:
|
if not stats:
|
||||||
sys.stderr.write("retro_metrics: нет сессий проекта %s (slug %s) в %s с %s\n"
|
sys.stderr.write("retro_metrics: нет сессий проекта %s (slug %s) в %s с %s\n"
|
||||||
% (project, slug(project), a.projects_root, since or "начала"))
|
% (project, slug(project), a.projects_root, since or "начала"))
|
||||||
return 1
|
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:
|
if a.corrections:
|
||||||
for c in cands:
|
for c in cands:
|
||||||
print(json.dumps(c, ensure_ascii=False))
|
print(json.dumps(c, ensure_ascii=False))
|
||||||
@ -472,7 +534,7 @@ def main(argv):
|
|||||||
json.dump(doc, fh, ensure_ascii=False, indent=2)
|
json.dump(doc, fh, ensure_ascii=False, indent=2)
|
||||||
fh.write("\n")
|
fh.write("\n")
|
||||||
if a.markdown:
|
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:
|
elif not a.out:
|
||||||
print(json.dumps(doc, ensure_ascii=False, indent=2))
|
print(json.dumps(doc, ensure_ascii=False, indent=2))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@ -67,7 +67,11 @@ retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--har
|
|||||||
Каждая метрика считается в одном месте, в `session_stats`, и проверяется отдельным тестом.
|
Каждая метрика считается в одном месте, в `session_stats`, и проверяется отдельным тестом.
|
||||||
|
|
||||||
### 3. Сессии проекта
|
### 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. Что такое «код»
|
### 4. Что такое «код»
|
||||||
Пути в транскриптах относятся к репозиториям, которых может уже не быть: worktree удалены. Относительный путь считается от корня worktree или репозитория. Корень определяется в таком порядке:
|
Пути в транскриптах относятся к репозиториям, которых может уже не быть: worktree удалены. Относительный путь считается от корня worktree или репозитория. Корень определяется в таком порядке:
|
||||||
@ -78,7 +82,7 @@ retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--har
|
|||||||
|
|
||||||
Префиксы кода берутся из `.claude/code-paths` основного репозитория, без него — правило `src/` на любой глубине. Так же решает require-plan.
|
Префиксы кода берутся из `.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. Блокировки, отказы, роли
|
### 5. Блокировки, отказы, роли
|
||||||
- Блокировка: результат инструмента совпадает с `^\s*PreToolUse:\S+ hook error` и содержит `BLOCKED`. Хук определяется так же, как в `hook_blocks_export.hook_of`, функция импортируется.
|
- Блокировка: результат инструмента совпадает с `^\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 | `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 | Ходы и токены — по уникальному `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 | Записи ассистента с `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 | Свежая ветка, совпадающая с `origin/main`, — не хвост | `--merged` считает слитой и ветку без коммитов, например только что созданный worktree | Брошенная пустая ветка не попадёт в хвосты |
|
||||||
| 2026-10-24 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику |
|
| 2026-10-24 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику |
|
||||||
|
|||||||
@ -25,12 +25,27 @@
|
|||||||
- **WHEN** в каталоге `~/.claude/projects/-p-repo-tools/` первая запись сессии имеет `cwd` `/p/repo-tools`
|
- **WHEN** в каталоге `~/.claude/projects/-p-repo-tools/` первая запись сессии имеет `cwd` `/p/repo-tools`
|
||||||
- **THEN** сессия в срез проекта `/p/repo` не входит
|
- **THEN** сессия в срез проекта `/p/repo` не входит
|
||||||
|
|
||||||
|
### Requirement: Транскрипты сабагентов
|
||||||
|
Транскрипты сабагентов сессии `<каталог>/<сессия>/subagents/*.jsonl` SHALL входить в сессию-родителя: ходы, токены, вызовы инструментов, codegraph, блокировки, отказы. Промпты, роль, стартовый контекст и кандидаты в поправки MUST браться только из основного транскрипта.
|
||||||
|
|
||||||
|
#### Scenario: codegraph у implementer-а
|
||||||
|
- **WHEN** сабагент сессии вызвал `codegraph_explore` и прочитал файл кода, а его хук заблокировал запись
|
||||||
|
- **THEN** вызов, чтение и блокировка считаются в срезе, а сообщение контроллера сабагенту промптом владельца не считается
|
||||||
|
|
||||||
### Requirement: Период среза
|
### 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: Период по умолчанию
|
#### Scenario: Период по умолчанию
|
||||||
- **WHEN** последний ретро-документ — `docs/retro/2026-10-11.md` и `--since` не указан
|
- **WHEN** последний ретро-документ — `docs/retro/2026-10-11.md`, `--since` не указан и `--out` ещё нет
|
||||||
- **THEN** в срез входят сессии с первой меткой времени 2026-10-11 и позже
|
- **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: Нет транскриптов
|
#### Scenario: Нет транскриптов
|
||||||
- **WHEN** ни одного каталога со slug проекта нет или в периоде нет ни одной сессии
|
- **WHEN** ни одного каталога со slug проекта нет или в периоде нет ни одной сессии
|
||||||
@ -44,7 +59,7 @@
|
|||||||
- **THEN** срез считается по остальным строкам, а `sources.bad_lines` равно 1
|
- **THEN** срез считается по остальным строкам, а `sources.bad_lines` равно 1
|
||||||
|
|
||||||
### Requirement: Промпты владельца
|
### Requirement: Промпты владельца
|
||||||
Промпт владельца SHALL считаться так: сообщение пользователя без `tool_result`, не `isMeta`, не начинающееся с `<`. Прерывания `[Request interrupted by user]` MUST считаться отдельной метрикой, не промптами.
|
Промпт владельца SHALL считаться так: сообщение пользователя без `tool_result`, не `isMeta`, не сводка компакции (`isCompactSummary`), не начинающееся с `<`. Прерывания `[Request interrupted by user]` MUST считаться отдельной метрикой, не промптами.
|
||||||
|
|
||||||
#### Scenario: Служебные сообщения не считаются промптами
|
#### Scenario: Служебные сообщения не считаются промптами
|
||||||
- **WHEN** в транскрипте есть сообщение пользователя с `tool_result`, сообщение с `isMeta` и строка `<command-name>…`
|
- **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/`
|
- **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` чтением кода не считается
|
- **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: Память проекта
|
### Requirement: Память проекта
|
||||||
Метрика памяти SHALL выводить размер `~/.claude/projects/<slug проекта>/memory/MEMORY.md` в байтах, число и общий объём файлов в этом каталоге. Отсутствующий каталог MUST давать нули, а не ошибку. Отдельно SHALL считаться промпты «перечитай память/контекст».
|
Метрика памяти SHALL выводить размер `~/.claude/projects/<slug проекта>/memory/MEMORY.md` в байтах, число и общий объём файлов в этом каталоге. Отсутствующий каталог MUST давать нули, а не ошибку. Отдельно SHALL считаться промпты-побуждения перечитать: «перечитай», «прочитай», «сверься», «вспомни», «освежи» рядом с «память», «контекст» или `.md`.
|
||||||
|
|
||||||
#### Scenario: Памяти нет
|
#### Scenario: Памяти нет
|
||||||
- **WHEN** каталога памяти проекта нет
|
- **WHEN** каталога памяти проекта нет
|
||||||
- **THEN** метрики памяти равны нулю, срез завершается с кодом 0
|
- **THEN** метрики памяти равны нулю, срез завершается с кодом 0
|
||||||
|
|
||||||
|
#### Scenario: Упоминание контекста — не побуждение
|
||||||
|
- **WHEN** промпт «я сейчас смотрю на размер контекста» и сводка компакции со словами «перечитай память»
|
||||||
|
- **THEN** ни то, ни другое не считается промптом «перечитай память/контекст»
|
||||||
|
|
||||||
### Requirement: Кандидаты в поправки владельца
|
### Requirement: Кандидаты в поправки владельца
|
||||||
`retro_metrics.py --corrections` SHALL выгружать кандидатов в поправки владельца по процессу. Кандидат — это:
|
`retro_metrics.py --corrections` SHALL выгружать кандидатов в поправки владельца по процессу. Кандидат — это:
|
||||||
- промпт владельца со словом-признаком: «нет», «не так», «почему», «зачем», «опять», «снова», «я же», «стоп», «подожди», «не надо», «сначала», «верни», «откати»;
|
- промпт владельца со словом-признаком: «нет», «не так», «почему», «зачем», «опять», «снова», «я же», «стоп», «подожди», «не надо», «сначала», «верни», «откати»;
|
||||||
@ -119,14 +146,14 @@
|
|||||||
- **THEN** у кандидата указано действие `Bash: git branch -m feat/x`
|
- **THEN** у кандидата указано действие `Bash: git branch -m feat/x`
|
||||||
|
|
||||||
### Requirement: Сравнение с прошлым ретро
|
### 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: Новая метрика
|
#### Scenario: Новая метрика
|
||||||
- **WHEN** в прошлом файле нет метрики `corrections`
|
- **WHEN** в прошлом файле нет метрики `corrections`
|
||||||
- **THEN** строка поправок владельца показывает текущее значение и «—» в колонках прошлого и разницы
|
- **THEN** строка поправок владельца показывает текущее значение и «—» в колонках прошлого и разницы
|
||||||
|
|
||||||
### Requirement: Секреты не попадают в черновик
|
### Requirement: Секреты не попадают в черновик
|
||||||
Всё, что скрипты ретро выводят из транскриптов (промпты, команды, сообщения), SHALL проходить замену похожих на секреты подстрок на `<redacted>` — той же функцией, что в `hook_blocks_export.py`.
|
Всё, что скрипты ретро выводят из транскриптов (промпты, команды, сообщения), SHALL проходить замену похожих на секреты подстрок на `<redacted>` — той же функцией, что в `hook_blocks_export.py`, и MUST проходить её до обрезки, чтобы секрет на границе обрезки не утёк частично.
|
||||||
|
|
||||||
#### Scenario: Токен в промпте
|
#### Scenario: Токен в промпте
|
||||||
- **WHEN** промпт-кандидат содержит `tea login add --token 0123456789abcdef0123456789abcdef01234567`
|
- **WHEN** промпт-кандидат содержит `tea login add --token 0123456789abcdef0123456789abcdef01234567`
|
||||||
@ -161,18 +188,18 @@
|
|||||||
- **THEN** ссылки `refs/remotes/` и рабочее дерево репозитория не меняются
|
- **THEN** ссылки `refs/remotes/` и рабочее дерево репозитория не меняются
|
||||||
|
|
||||||
### Requirement: Выгрузка блокировок среза
|
### 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: Ранняя сессия не выгружается
|
#### Scenario: Ранний вызов не выгружается
|
||||||
- **WHEN** у сессии первая метка времени раньше `--since`
|
- **WHEN** в длинной сессии блокировка до `--since` и блокировка после
|
||||||
- **THEN** её блокировки в выгрузку не попадают
|
- **THEN** выгружается только вторая
|
||||||
|
|
||||||
### Requirement: Разметка блокировок в корпус
|
### Requirement: Разметка блокировок в корпус
|
||||||
Агент SHALL размечать выгрузку в `docs/retro/fixtures/hook-labels-<дата>.json`:
|
Агент SHALL размечать выгрузку в `docs/retro/fixtures/hook-labels-<дата>.json`:
|
||||||
- для записи — `expected` (`allow|block`) и `why` по спеке `hook-guards`, либо `skip` с причиной;
|
- для записи — `expected` (`allow|block`) и `why` по спеке `hook-guards`, либо `skip` с причиной;
|
||||||
- для файла — раскладку песочницы.
|
- для файла — раскладку песочницы.
|
||||||
|
|
||||||
`hook_cases_build.py` MUST добавлять по сегменту на каждую пару выгрузка + разметка. Записи со `skip` в корпус не входят.
|
`hook_cases_build.py` MUST добавлять по сегменту на каждую пару выгрузка + разметка и падать на выгрузке без разметки. Записи со `skip` в корпус не входят.
|
||||||
|
|
||||||
#### Scenario: Размеченная выгрузка в корпусе
|
#### Scenario: Размеченная выгрузка в корпусе
|
||||||
- **WHEN** в `hook-labels-2026-11-01.json` три записи размечены и одна имеет `skip`
|
- **WHEN** в `hook-labels-2026-11-01.json` три записи размечены и одна имеет `skip`
|
||||||
|
|||||||
@ -56,3 +56,9 @@ PY
|
|||||||
out=$(python3 "$B" --fixtures-dir "$FX" 2>&1 >/dev/null); rc=$?
|
out=$(python3 "$B" --fixtures-dir "$FX" 2>&1 >/dev/null); rc=$?
|
||||||
assert_eq "разметка не совпадает с выгрузкой: exit 1" "$rc" "1"
|
assert_eq "разметка не совпадает с выгрузкой: exit 1" "$rc" "1"
|
||||||
assert_grep "сообщение называет недостающий и лишний id" '2026-11-01-002.*2026-11-01-009' <(printf '%s' "$out")
|
assert_grep "сообщение называет недостающий и лишний id" '2026-11-01-002.*2026-11-01-009' <(printf '%s' "$out")
|
||||||
|
# I-5: выгрузка ретро без парной разметки — сборка падает, а не пропускает её молча
|
||||||
|
FX2="$TMP/fx2"; mkdir -p "$FX2"; cp "$ROOT/docs/retro/fixtures/hook-blocks.jsonl" "$FX2/"
|
||||||
|
head -1 "$FX/hook-blocks-2026-11-01.jsonl" > "$FX2/hook-blocks-2026-11-07.jsonl"
|
||||||
|
out=$(python3 "$B" --fixtures-dir "$FX2" 2>&1 >/dev/null); rc=$?
|
||||||
|
assert_eq "выгрузка без разметки: exit 1" "$rc" "1"
|
||||||
|
assert_grep "сообщение называет файл выгрузки" 'hook-blocks-2026-11-07\.jsonl' <(printf '%s' "$out")
|
||||||
|
|||||||
@ -18,8 +18,18 @@ T2="$TMP/tr2"; mkdir -p "$T2"
|
|||||||
blk='{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u1","is_error":true,"content":"PreToolUse:Bash hook error: BLOCKED [guard-main]: запись в f"}]}}'
|
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-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"
|
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")
|
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 "--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 "--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 "секрет на границе обрезки сообщения не утекает" "$(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 "без флагов поля 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)" ""
|
assert_eq "импорт модуля не выгружает" "$(python3 -c "import sys; sys.argv=['x', '$T2']; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import hook_blocks_export" 2>&1)" ""
|
||||||
|
|||||||
@ -50,6 +50,35 @@ w(A, "s1.jsonl", [
|
|||||||
{"type": "system", "subtype": "compact_boundary", "timestamp": T % 9},
|
{"type": "system", "subtype": "compact_boundary", "timestamp": T % 9},
|
||||||
"not json",
|
"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 = P + "/.claude/worktrees/x"
|
||||||
w(A + "--claude-worktrees-x", "s2.jsonl", [
|
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"
|
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"; }
|
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 "схема 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 'len(d["sources"]["dirs"])')" "3"
|
||||||
assert_eq "битые строки" "$(q 'd["sources"]["bad_lines"]')" "1"
|
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 "прерывания" "$(q 'm["interrupts"]')" "1"
|
||||||
assert_eq "ходы — по message.id" "$(q 'm["turns"]')" "7"
|
assert_eq "ходы — по message.id, с сабагентом, только в периоде" "$(q 'm["turns"]')" "11"
|
||||||
assert_eq "вызовы инструментов" "$(q 'm["tool_calls"]')" "15"
|
assert_eq "вызовы инструментов с сабагентом" "$(q 'm["tool_calls"]')" "22"
|
||||||
assert_eq "токены вывода один раз на сообщение" "$(q 'm["tokens"]["output"]')" "10"
|
assert_eq "токены вывода один раз на сообщение, с сабагентом" "$(q 'm["tokens"]["output"]')" "15"
|
||||||
assert_eq "стартовый контекст" "$(q 'sorted(m["start_context"].items())')" "[('max', 61000), ('median', 56000), ('min', 51100)]"
|
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["hook_blocks"], m["hook_blocks_by_hook"]')" "(2, {'guard-main(Bash)': 2})"
|
||||||
assert_eq "отказы классификатора по причине" "$(q 'm["classifier_by_reason"]')" "{'Self-Modification': 1}"
|
assert_eq "отказы классификатора по причине, с сабагентом" "$(q 'sorted(m["classifier_by_reason"].items())')" "[('Git Destructive', 1), ('Self-Modification', 1)]"
|
||||||
assert_eq "отказы владельца" "$(q 'm["owner_denials"]')" "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 "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), ('не выбрана', 2)]"
|
assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 3)]"
|
||||||
assert_eq "сабагенты по моделям" "$(q 'sorted(m["subagent_models"].items())')" "[('sonnet', 1), ('не указана', 1)]"
|
assert_eq "сабагенты по моделям" "$(q 'sorted(m["subagent_models"].items())')" "[('sonnet', 1), ('не указана', 1)]"
|
||||||
assert_eq "skills" "$(q 'm["skills"]')" "{'role': 1}"
|
assert_eq "skills" "$(q 'm["skills"]')" "{'role': 1}"
|
||||||
assert_eq "компакции, перечитай" "$(q 'm["compactions"], m["recall"]')" "(1, 1)"
|
assert_eq "компакции; «перечитай» — только побуждение перечитать" "$(q 'm["compactions"], m["recall"]')" "(1, 1)"
|
||||||
assert_eq "кандидаты в поправки" "$(q 'm["corrections"]')" "4"
|
assert_eq "кандидаты в поправки" "$(q 'm["corrections"]')" "5"
|
||||||
assert_eq "память" "$(q 'sorted(m["memory"].items())')" "[('bytes', 150), ('files', 2), ('index_bytes', 100)]"
|
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)]"
|
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
|
# кандидаты в поправки: контекст и redact
|
||||||
C="$TMP/corr.jsonl"
|
C="$TMP/corr.jsonl"
|
||||||
python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --corrections > "$C"
|
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 "«почему» после 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 "последнее действие 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_eq "токен замазан" "$(grep -c '0123456789abcdef' "$C")" "0"
|
||||||
assert_grep "токен заменён на <redacted>" '<redacted>' "$C"
|
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']"
|
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'
|
cat > "$TMP/prev.json" <<'EOF'
|
||||||
{"schema": 1, "metrics": {"sessions": 44, "turns": {"value": 12121, "note": "по строкам"}, "cd_main": {"retired": "хуки 0.4.0"}}}
|
{"schema": 1, "metrics": {"sessions": 44, "turns": {"value": 12121, "note": "по строкам"}, "cd_main": {"retired": "хуки 0.4.0"}}}
|
||||||
EOF
|
EOF
|
||||||
md=$(python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --markdown --prev "$TMP/prev.json")
|
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 \| 44 \| -40 \|$' <(printf '%s' "$md")
|
||||||
assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 4 \| — \| — \|$' <(printf '%s' "$md")
|
assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 5 \| — \| — \|$' <(printf '%s' "$md")
|
||||||
assert_grep "снятая метрика" '^\| cd в основной checkout на main \| — \| — \| не считается: хуки 0.4.0 \|$' <(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'
|
cat > "$TMP/labels.json" <<'EOF'
|
||||||
{"home": "/h", "layout": [], "cases": {"x-001": {"expected": "allow", "why": "a"}, "x-002": {"expected": "allow", "why": "b"},
|
{"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)]"
|
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"
|
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"
|
python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J"
|
||||||
assert_eq "период с последнего ретро" "$(q 'd["period"]["since"], m["sessions"]')" "('2026-11-03', 2)"
|
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=$?
|
err=$(python3 "$M" "$P" --since 2030-01-01 --projects-root "$CR" 2>&1 >/dev/null); rc=$?
|
||||||
assert_eq "нет сессий: exit 1" "$rc" "1"
|
assert_eq "нет сессий: exit 1" "$rc" "1"
|
||||||
|
|||||||
@ -10,7 +10,8 @@ for sec in "## 1. Цифры" "## 2. Находки" "## 3. Поправки в
|
|||||||
assert_grep "шаблон: $sec" "^$sec" "$S/template.md"
|
assert_grep "шаблон: $sec" "^$sec" "$S/template.md"
|
||||||
done
|
done
|
||||||
J="$ROOT/docs/retro/2026-10-11.metrics.json"
|
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'
|
out=$(python3 - "$ROOT/docs/retro/scripts" "$J" <<'PY'
|
||||||
import json, sys
|
import json, sys
|
||||||
sys.path.insert(0, sys.argv[1])
|
sys.path.insert(0, sys.argv[1])
|
||||||
@ -22,7 +23,7 @@ print(retro_metrics.to_markdown(doc, prev))
|
|||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
assert_grep "сравнение с 11.10: сессии" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$out")
|
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: cd снят" '^\| cd в основной checkout на main \| — \| — \| не считается' <(printf '%s' "$out")
|
||||||
assert_grep "сравнение с 11.10: ходы считались по строкам" '^\| Ходов модели \| 7 \| 12 121 \(по строкам' <(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 нет)" "нет"
|
assert_eq "retro_stats.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_stats.py" ] && echo есть || echo нет)" "нет"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user