feat(retro): retro_leftovers.py — хвосты чеклиста закрытия по состоянию репозиториев (#29)

SDD workspace в worktree активной задачи хвостом не считается (O-19, живой прогон).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andrey.epifantsev 2026-10-24 01:25:14 +04:00
parent dde575673d
commit 4bbf6ebb3a
4 changed files with 217 additions and 2 deletions

View File

@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Хвосты чеклиста закрытия по текущему состоянию репозиториев проекта или workspace (B12, #29).
Запуск: python3 retro_leftovers.py <папка проекта или workspace> [--fetch] [--markdown]
Репозитории: сам git-проект или каталоги из таблицы «| Каталог | Репозиторий | Роль |» в CLAUDE.md workspace.
Хвост: слитая в origin/main ветка на сервере или локально, worktree на ней или prunable, .superpowers/sdd/*,
неархивный openspec/changes/<id>/ в origin/main, основной checkout позади origin/main или с изменениями.
Ничего не меняет; --fetch делает только git fetch --prune origin. Спека: openspec/specs/harness-retro/spec.md.
"""
import collections
import glob
import json
import os
import re
import subprocess
import sys
TABLE = re.compile(r"^\|\s*Каталог\s*\|\s*Репозиторий\s*\|\s*Роль\s*\|")
PROTECTED = ("main", "master")
def git(repo, *args):
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
r = subprocess.run(["git", "-C", repo] + list(args), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
universal_newlines=True, env=env)
return r.stdout if r.returncode == 0 else None
def lines(s):
return [l.strip() for l in (s or "").splitlines() if l.strip()]
def repos_of(project):
project = os.path.abspath(project)
if os.path.exists(os.path.join(project, ".git")):
return [project]
md, out, on = os.path.join(project, "CLAUDE.md"), [], False
if not os.path.isfile(md):
return out
for line in open(md, encoding="utf-8", errors="replace"):
if TABLE.match(line):
on = True
continue
if not on:
continue
if not line.startswith("|"):
break
d = line.strip().strip("|").split("|")[0].strip()
if not d or set(d) <= set("-: "):
continue
p = os.path.join(project, d.rstrip("/"))
if os.path.exists(os.path.join(p, ".git")):
out.append(p)
return out
def worktrees(repo):
out, cur = [], {}
for l in (git(repo, "worktree", "list", "--porcelain") or "").splitlines() + [""]:
if not l.strip():
if cur:
out.append(cur)
cur = {}
continue
k, _, v = l.partition(" ")
cur[k] = v or True
return out
def leftovers(repo, fetch=False):
items = []
def add(kind, item, hint):
items.append({"kind": kind, "repo": repo, "item": item, "hint": hint})
if fetch:
git(repo, "fetch", "--prune", "-q", "origin")
sha = lambda ref: (git(repo, "rev-parse", "-q", "--verify", ref) or "").strip()
tip = sha("origin/main")
if not tip:
add("no-origin-main", "origin/main", "нет origin/main: git fetch origin или проверить remote")
return items
for b in lines(git(repo, "branch", "-r", "--merged", "origin/main")):
if "->" in b or not b.startswith("origin/") or b[len("origin/"):] in PROTECTED + ("HEAD",):
continue
if sha(b) != tip:
add("remote-branch", b, "git push origin --delete " + b[len("origin/"):])
stale = set()
for b in lines(git(repo, "branch", "--merged", "origin/main", "--format=%(refname:short)")):
if b not in PROTECTED and sha("refs/heads/" + b) != tip:
stale.add(b)
for l in lines(git(repo, "for-each-ref", "--format=%(refname:short) %(upstream:track)", "refs/heads")):
name, _, track = l.partition(" ")
if "gone" in track and name not in PROTECTED:
stale.add(name)
for b in sorted(stale):
add("local-branch", b, "git branch -D " + b)
wts = worktrees(repo)
main_wt = wts[0]["worktree"] if wts else repo
done = [main_wt] # SDD workspace — хвост в основном checkout и в worktree закрытой задачи, не в активном
for w in wts[1:]:
br = str(w.get("branch") or "")
if br.startswith("refs/heads/"):
br = br[len("refs/heads/"):]
if w.get("prunable") or br in stale:
add("worktree", w["worktree"], "ExitWorktree, затем git worktree remove " + w["worktree"])
done.append(w["worktree"])
for w in done:
for d in sorted(glob.glob(os.path.join(w, ".superpowers", "sdd", "*"))):
if os.path.isdir(d):
add("sdd-workspace", d, "rm -rf " + d)
for c in lines(git(repo, "ls-tree", "-d", "--name-only", "origin/main", "openspec/changes/")):
name = c.rstrip("/").rsplit("/", 1)[-1]
if name != "archive":
add("openspec-change", c, "openspec archive %s — отдельным PR" % name)
behind = (git(main_wt, "rev-list", "--count", "HEAD..origin/main") or "0").strip()
if behind not in ("", "0"):
add("main-behind", main_wt, "git pull --ff-only (позади origin/main на %s)" % behind)
dirty = [l for l in (git(main_wt, "status", "--porcelain") or "").splitlines() if l.strip()]
if dirty:
add("main-dirty", main_wt, "не трогать, перечислить: " + ", ".join(l[3:] for l in dirty[:5]))
return items
def to_markdown(items):
if not items:
return "Хвостов закрытия нет."
out = ["| Вид | Репозиторий | Что | Чем убрать |", "|---|---|---|---|"]
for i in items:
out.append("| %s | %s | `%s` | `%s` |" % (i["kind"], os.path.basename(i["repo"]), i["item"], i["hint"]))
return "\n".join(out)
def main(argv):
args = argv[1:]
pos = [a for a in args if not a.startswith("--")]
if not pos:
print(__doc__.strip().splitlines()[2])
return 1
repos = repos_of(pos[0])
if not repos:
sys.stderr.write("retro_leftovers: в %s нет git-репозитория и таблицы репозиториев workspace\n" % pos[0])
return 1
items = [i for r in repos for i in leftovers(r, "--fetch" in args)]
if "--markdown" in args:
print(to_markdown(items))
else:
print(json.dumps({"repos": repos, "items": items,
"totals": dict(collections.Counter(i["kind"] for i in items))}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@ -100,7 +100,7 @@ Bash-команда считается чтением кода, если хот
- `git branch -r --merged origin/main` без `origin/main` и `origin/HEAD`; - `git branch -r --merged origin/main` без `origin/main` и `origin/HEAD`;
- `git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads`, где слитые в `origin/main` или `[gone]`; - `git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads`, где слитые в `origin/main` или `[gone]`;
- `git worktree list --porcelain` — ветка из слитых или `prunable`; - `git worktree list --porcelain` — ветка из слитых или `prunable`;
- `ls .superpowers/sdd/*/` в основном checkout и в каждом worktree; - `ls .superpowers/sdd/*/` в основном checkout и в worktree, которые сами хвост (слитая или `gone` ветка, `prunable`). SDD workspace в worktree активной задачи — работа в процессе, не хвост;
- `git ls-tree -d --name-only origin/main openspec/changes/` без `archive`; - `git ls-tree -d --name-only origin/main openspec/changes/` без `archive`;
- `git rev-list --count HEAD..origin/main` и `git status --porcelain` основного checkout. - `git rev-list --count HEAD..origin/main` и `git status --porcelain` основного checkout.
@ -175,5 +175,6 @@ 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 | Свежая ветка, совпадающая с `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 не попадёт в метрику |

View File

@ -137,7 +137,7 @@
- ветка `origin/<ветка>`, слитая в `origin/main`; - ветка `origin/<ветка>`, слитая в `origin/main`;
- локальная ветка, слитая или с upstream `gone`; - локальная ветка, слитая или с upstream `gone`;
- worktree на такой ветке или `prunable`; - worktree на такой ветке или `prunable`;
- `.superpowers/sdd/*`; - `.superpowers/sdd/*` в основном checkout или в worktree такой ветки (в worktree активной задачи — не хвост);
- неархивный `openspec/changes/<id>/` в `origin/main`; - неархивный `openspec/changes/<id>/` в `origin/main`;
- отставший или грязный основной checkout. - отставший или грязный основной checkout.

View File

@ -0,0 +1,60 @@
# retro_leftovers.py: хвосты чеклиста закрытия по состоянию репозиториев (спека harness-retro, #29).
L="$ROOT/docs/retro/scripts/retro_leftovers.py"
G="$TMP/lo"; mkdir -p "$G"
git init -q --bare -b main "$G/o.git"
git clone -q "$G/o.git" "$G/c" 2>/dev/null; git -C "$G/c" symbolic-ref HEAD refs/heads/main
printf '.superpowers/\n.claude/worktrees/\n' > "$G/c/.gitignore"
git -C "$G/c" add .gitignore && git -C "$G/c" commit -q -m init && git -C "$G/c" push -q origin main 2>/dev/null
git -C "$G/c" switch -q -c feat/1 && git -C "$G/c" commit -q --allow-empty -m f1 && git -C "$G/c" push -q -u origin feat/1 2>/dev/null
git -C "$G/c" switch -q main && git -C "$G/c" merge -q --no-ff feat/1 -m "merge feat/1"
mkdir -p "$G/c/openspec/changes/foo" "$G/c/openspec/changes/archive/2026-01-01-bar"
echo x > "$G/c/openspec/changes/foo/proposal.md"; echo y > "$G/c/openspec/changes/archive/2026-01-01-bar/p.md"
git -C "$G/c" add openspec && git -C "$G/c" commit -q -m spec && git -C "$G/c" push -q origin main 2>/dev/null
git -C "$G/c" worktree add -q "$G/c/.claude/worktrees/w1" feat/1
mkdir -p "$G/c/.superpowers/sdd/plan-x" && touch "$G/c/.superpowers/sdd/plan-x/progress.md"
# активная задача: worktree на неслитой ветке со своим SDD workspace — не хвост
git -C "$G/c" worktree add -q "$G/c/.claude/worktrees/w2" -b feat/2 && git -C "$G/c/.claude/worktrees/w2" commit -q --allow-empty -m wip
mkdir -p "$G/c/.claude/worktrees/w2/.superpowers/sdd/plan-y" && touch "$G/c/.claude/worktrees/w2/.superpowers/sdd/plan-y/progress.md"
git clone -q "$G/o.git" "$G/c2" 2>/dev/null && git -C "$G/c2" commit -q --allow-empty -m ahead && git -C "$G/c2" push -q origin main 2>/dev/null
git -C "$G/c" fetch -q origin
echo x > "$G/c/untracked.txt"
J="$TMP/lo.json"
python3 "$L" "$G/c" > "$J"; rc=$?
assert_eq "хвосты: exit 0" "$rc" "0"
k(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(eval(sys.argv[2]))' "$J" "$1"; }
assert_eq "виды хвостов" "$(k 'sorted(d["totals"])')" "['local-branch', 'main-behind', 'main-dirty', 'openspec-change', 'remote-branch', 'sdd-workspace', 'worktree']"
assert_eq "ветка на сервере и подсказка" "$(k '[(i["item"], i["hint"]) for i in d["items"] if i["kind"] == "remote-branch"]')" "[('origin/feat/1', 'git push origin --delete feat/1')]"
assert_eq "локальная ветка" "$(k '[i["item"] for i in d["items"] if i["kind"] == "local-branch"]')" "['feat/1']"
assert_eq "worktree на слитой ветке" "$(k '[i["item"].endswith("/.claude/worktrees/w1") for i in d["items"] if i["kind"] == "worktree"]')" "[True]"
assert_eq "SDD workspace" "$(k '[i["item"].endswith("/sdd/plan-x") for i in d["items"] if i["kind"] == "sdd-workspace"]')" "[True]"
assert_eq "неархивный change — без archive" "$(k '[i["item"] for i in d["items"] if i["kind"] == "openspec-change"]')" "['openspec/changes/foo']"
assert_grep "отставание от origin/main" 'позади origin/main на 1' "$J"
assert_grep "грязное дерево с путём" 'untracked.txt' "$J"
# только чтение: без --fetch ссылки и дерево не меняются
git -C "$G/c2" commit -q --allow-empty -m ahead2 && git -C "$G/c2" push -q origin main 2>/dev/null
before="$(git -C "$G/c" for-each-ref)$(git -C "$G/c" status --porcelain)"
python3 "$L" "$G/c" > /dev/null
assert_eq "без --fetch ничего не меняется" "$(git -C "$G/c" for-each-ref)$(git -C "$G/c" status --porcelain)" "$before"
python3 "$L" "$G/c" --fetch > "$J"
assert_grep "--fetch видит новые коммиты" 'позади origin/main на 2' "$J"
md=$(python3 "$L" "$G/c" --markdown)
assert_grep "таблица для черновика" '^\| remote-branch \| c \| `origin/feat/1` \| `git push origin --delete feat/1` \|$' <(printf '%s' "$md")
# чистый репозиторий: свежая ветка на вершине origin/main — не хвост
git init -q --bare -b main "$G/k.git"; git clone -q "$G/k.git" "$G/k" 2>/dev/null; git -C "$G/k" symbolic-ref HEAD refs/heads/main
git -C "$G/k" commit -q --allow-empty -m init && git -C "$G/k" push -q origin main 2>/dev/null && git -C "$G/k" branch feat/new
python3 "$L" "$G/k" > "$J"
assert_eq "чистый репозиторий — хвостов нет" "$(k 'd["items"]')" "[]"
assert_eq "чистый репозиторий — markdown" "$(python3 "$L" "$G/k" --markdown)" "Хвостов закрытия нет."
# без origin
git init -q -b main "$G/n" && git -C "$G/n" commit -q --allow-empty -m init
python3 "$L" "$G/n" > "$J"; rc=$?
assert_eq "без origin: exit 0 и no-origin-main" "$rc $(k '[i["kind"] for i in d["items"]]')" "0 ['no-origin-main']"
# workspace: репозитории из таблицы CLAUDE.md
W="$TMP/ws"; mkdir -p "$W"; git clone -q "$G/o.git" "$W/r" 2>/dev/null
printf '# w\n\n| Каталог | Репозиторий | Роль |\n|---|---|---|\n| r/ | o/r | dev |\n| - | x | qa |\n' > "$W/CLAUDE.md"
python3 "$L" "$W" > "$J"
assert_eq "workspace: репозитории из таблицы" "$(k '[r.endswith("/ws/r") for r in d["repos"]]')" "[True]"
assert_eq "workspace: ветка на сервере видна" "$(k '[i["item"] for i in d["items"] if i["kind"] == "remote-branch"]')" "['origin/feat/1']"
# не проект и не workspace
mkdir -p "$TMP/empty"; python3 "$L" "$TMP/empty" > /dev/null 2>&1; rc=$?
assert_eq "не репозиторий и не workspace: exit 1" "$rc" "1"