From a380129700a928dab587f809c7854922dd0f0aeb Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 4 Sep 2026 22:27:42 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(907):=20orchestrated=20sessions=20?= =?UTF-8?q?=E2=80=94=20mechanics=20page,=20standing=20prompt,=20pick/build?= =?UTF-8?q?/resume=20workflows,=20E2E=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-issue kickoff stays as it is; this adds the layer that runs several of those under one referee. docs/handoffs/orchestration.md owns roles and sizing, one worktree per issue under ~/orca/workspaces/ersatztv/, the landing order with the review loop inside the worktree before the single push, and the merge through the consent hook. Three Workflow scripts encode it: a picker over scripts/select-queue.sh with two refuters, an issue-build pipeline (claim, recon, implement, gate, cold review with a cross-family runner for the rubric's risk classes, fix loop, finisher), and a resume pipeline for a paused branch. scripts/e2e-gate.sh serialises live-E2E across worktrees because e2e-local.sh refuses concurrent runs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .claude/workflows/ersatztv-issue-build.js | 171 ++++++++++++++++++++ .claude/workflows/ersatztv-pick-next.js | 52 ++++++ .claude/workflows/ersatztv-resume-branch.js | 131 +++++++++++++++ docs/README.md | 3 + docs/handoffs/chicorytv-issue-queue.md | 2 + docs/handoffs/orchestration.md | 110 +++++++++++++ docs/handoffs/orchestrator-prompt.md | 16 ++ docs/remote-state-inventory.md | 1 + scripts/e2e-gate.sh | 48 ++++++ 9 files changed, 534 insertions(+) create mode 100644 .claude/workflows/ersatztv-issue-build.js create mode 100644 .claude/workflows/ersatztv-pick-next.js create mode 100644 .claude/workflows/ersatztv-resume-branch.js create mode 100644 docs/handoffs/orchestration.md create mode 100644 docs/handoffs/orchestrator-prompt.md create mode 100755 scripts/e2e-gate.sh diff --git a/.claude/workflows/ersatztv-issue-build.js b/.claude/workflows/ersatztv-issue-build.js new file mode 100644 index 000000000..76c7a0a71 --- /dev/null +++ b/.claude/workflows/ersatztv-issue-build.js @@ -0,0 +1,171 @@ +export const meta = { + name: 'ersatztv-issue-build', + description: 'Close one ersatztv issue or bundle in its own worktree via PR: claim, recon, implement, local gate, adversarial review before the push, fix loop, single push, PR, closing record', + phases: [{ title: 'Recon' }, { title: 'Implement' }, { title: 'Review' }, { title: 'Fix' }], +} + +// args: { issues: [n,...], slug, title, body_summary, done_condition, files_likely, area, size, risk: 'routine'|'rubric', +// needs_e2e, avoid: [{issues, files}], trailer: 'Co-Authored-By: ...\nClaude-Session: ...', +// effort?: 'xhigh' for lock/threading/migration work, model?: override for the implementer/fixer } +const issues = args.issues +const ISSUE = issues[0] +const REF = issues.map(n => '#' + n).join(', ') +const BRANCH = `${issues.join('-')}-${(args.slug || 'work')}` +const WT = `/Users/timothy/orca/workspaces/ersatztv/wt-${issues.join('-')}` +const SHARED = '/Users/timothy/ersatztv' +const API = 'http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv' +const big = args.size === 'large' +const rubric = args.risk === 'rubric' +const implModel = args.model || (args.size === 'small' ? 'sonnet' : 'opus') +const implEffort = args.effort || (args.size === 'small' ? 'medium' : 'high') +const TRAILER = args.trailer || '' + +const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth. +Issue(s) ${REF}: "${args.title}". +Issue body (condensed by a picker; read the real thing): ${args.body_summary} +DONE CONDITION: ${args.done_condition} +Read every issue in the bundle and all its comments yourself: curl -s -u "$ETV_GITEA_BASICAUTH" ${API}/issues/${ISSUE} and ${API}/issues/${ISSUE}/comments (the env var is set; never write the credential into a file or a commit). + +Other slots of this session are working IN PARALLEL and will edit these files; do not touch them, and if your fix genuinely needs one of them, stop and report it instead of editing: +${JSON.stringify(args.avoid || [], null, 1)} + +Working rules, non-negotiable: +- Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key, never by chasing a file path named in an old comment. Do not reverse-engineer conventions from source before reading these. +- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a new or reversed convention gets a record under docs/decisions/records// and a regenerated catalog; a new or retitled doc updates docs/README.md. +- A TvContext model change needs a migration in BOTH providers: scripts/add-migration.sh . +- Tests are NUnit + Shouldly + NSubstitute in the existing *.Tests projects; vitest under web/. Pin the behaviour with a test that reddens when the fix alone is removed; never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS. +- Dependencies use Central Package Management: versions live only in Directory.Packages.props. +- Docs record the end state, never the investigation (docs.no-session-narrative): the path goes in the commit message and the issue comment. Date any measurement you write into a doc. +- Gitea labels take their own endpoint: POST ${API}/issues/{n}/labels {"labels":[100]} adds in-progress, DELETE ${API}/issues/{n}/labels/100 removes it; PATCH silently ignores labels. +- Never use bare git stash (the stash stack is shared across worktrees; commit WIP instead). Never push to main (it is refused server-side anyway). Never amend or force-push a pushed branch; a fix after the push is a new commit. Never cd out of your worktree except to read the shared checkout read-only. +- Kill only PIDs you started; never pkill by name — other sessions run dotnet and Playwright on this machine.` + +const WORKTREE = `Worktree: ${WT} on branch ${BRANCH}. Check git -C ${SHARED} worktree list; if absent: git -C ${SHARED} fetch origin && git -C ${SHARED} worktree add ${WT} -b ${BRANCH} origin/main. Then give it its own web/node_modules: if cmp -s ${SHARED}/web/package-lock.json ${WT}/web/package-lock.json then cp -Rc ${SHARED}/web/node_modules ${WT}/web/node_modules, else (cd ${WT}/web && npm ci). Do ALL work inside ${WT}. Commit as you go; every commit message ends with these trailer lines exactly: +${TRAILER}` + +const CLAIM = `CLAIM FIRST, the four-way check from the kickoff (process.parallel-session-claim): git -C ${SHARED} fetch origin; curl the open PRs (${API}/pulls?state=open&limit=50, page until empty) for a body saying fixes/refs ${REF}; git -C ${SHARED} ls-remote --heads origin '*${ISSUE}*'; read the issue comments for a claim that predates the label. If a PR, branch or comment shows another session already on ${REF} (other than this orchestrator's note, if any), STOP and report done=false with the evidence. Otherwise add the in-progress label and post a claiming comment naming branch ${BRANCH} and worktree ${WT}, on every issue in the bundle. If an issue body has no "## Done-when" section, append one (PATCH ${API}/issues/{n} with the full body): one unticked box per concrete completion criterion drawn from the issue, plus "- [ ] Adversarial review passed". The merge gate derives consent from those boxes, so write criteria you can evidence.` + +const GATE = `LOCAL GATE (process.local-gate-before-push) — run it inside ${WT} and read the real output; a skipped test is not a passing one: +- .NET: dotnet build the solution, then dotnet test on every test project that covers what you touched (ErsatzTV.Tests, ErsatzTV.Core.Tests, ErsatzTV.Scanner.Tests, ErsatzTV.FFmpeg.Tests, ErsatzTV.Architecture.Tests — all of them for anything under ErsatzTV.Core). Before any push touching .cs: BOM-check the touched set with od -A n -t x1 -N 3 (efbbbf = BOM) and run bash -c 'dotnet format whitespace . --folder --verify-no-changes --include ' (process.bom-format-detection-recipe). +- SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test. +- scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff check and ruff format --check on any Python you touched. A new executable under scripts/ or .claude/hooks/ needs its row in docs/remote-state-inventory.md and, if it is a guard, in docs/guard-inventory.md — the suites say so. +- Docs: python3 scripts/check-doc-narrative.py --diff origin/main and answer what it flags (it is advisory, the rule is not). +- Live-E2E${args.needs_e2e ? ' IS REQUIRED for this change (write path or UI)' : ' only if you changed a write path or a screen'}: scripts/e2e-gate.sh scripts/e2e-local.sh with a fresh CONFIG_DIR, on a port you pick with ETV_UI_PORT; curl the endpoints, never a browser tab; the gate serialises E2E across the machine, so wait for it. +- Builds on this Mac are capped at 3–4 concurrent and other slots are building too: run the .NET and web gates sequentially, not in parallel with each other.` + +const REPORT_SCHEMA = { + type: 'object', + required: ['done', 'summary', 'verified', 'left', 'commits'], + properties: { + done: { type: 'boolean' }, + summary: { type: 'string', description: 'what was built, file by file' }, + verified: { type: 'string', description: 'exact gate commands run and their real output summary (test counts, E2E result)' }, + left: { type: 'string', description: 'what is not done and why; what the next agent must know' }, + commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' }, + pr_url: { type: 'string' }, + head_sha: { type: 'string' }, + }, +} +const FINDINGS_SCHEMA = { + type: 'object', required: ['findings', 'verdict'], + properties: { + verdict: { type: 'string', enum: ['merge', 'send-back'] }, + findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: { + severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } }, + }, +} +const RECON_SCHEMA = { + type: 'object', required: ['plan', 'facts', 'risks', 'test_plan'], + properties: { + plan: { type: 'string', description: 'files, handlers, components, signatures, exact edits' }, + facts: { type: 'string', description: 'what the docs the task-signal map names and the existing code say, with paths and decision keys' }, + risks: { type: 'string' }, test_plan: { type: 'string', description: 'tests to add and the gate or E2E route that proves the done condition' }, + }, +} + +const FINISH = `FINISH, in this order: ${GATE} +Then git -C ${WT} fetch origin; if origin/main moved, rebase onto it (never merge main in; regenerate, never hand-resolve, generated artifacts) and re-run the gate. Then ONE push: git push -u origin ${BRANCH}. Open the PR with the Gitea API (POST ${API}/pulls; head=${BRANCH}, base=main, title, body). The body must contain "fixes #N" for every issue in the bundle so the merge closes them, the root cause for a bug fix, the measured numbers, what the reviewers found and how it was answered, and every deliberately-left item with an issue number (file follow-up issues where needed). End the body with: +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +${TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')} +Arm the CI monitor: note the head sha and read ${API}/commits//status once. Then the closing-an-issue skill (invoke it through the Skill tool if you have it, otherwise read .claude/skills/closing-an-issue/SKILL.md) with one modification: do NOT close the issue — the merge closes it. Post the "## Closing record" comment on each issue, linking the PR. Tick every "## Done-when" box you can evidence by editing the issue body (PATCH ${API}/issues/{n}); leave "Adversarial review passed" unticked, the orchestrator ticks it. Remove nothing; the orchestrator removes the worktree after the merge. Report the PR URL and the head sha.` + +let recon = null +if (big) { + phase('Recon') + recon = await agent(`${COMMON} + +You are the recon agent. Read-only, in ${SHARED}. Read the docs the task-signal map names for this task, then find every fact an implementer needs to close ${REF} without re-deriving it: the exact handlers, components, signatures, call sites and guards, the existing tests, and which gate or E2E route proves the done condition. For a multi-site sweep use the csharp-lsp MCP tools, not the LSP tool (docs/local-lsp-tooling.md). Produce a concrete plan.`, + { label: 'recon', model: 'opus', effort: 'high', schema: RECON_SCHEMA }) +} + +phase('Implement') +const impl = await agent(`${COMMON} + +${WORKTREE} + +${CLAIM} + +${recon ? `Recon (verify what you rely on):\nPLAN: ${recon.plan}\nFACTS: ${recon.facts}\nRISKS: ${recon.risks}\nTEST PLAN: ${recon.test_plan}\n` : ''} +You are the implementer. Close ${REF} completely: pin the behaviour with tests named for the branch they protect, update the docs the change obligates, commit. Then run the LOCAL GATE and STOP — do not push yet; reviewers read your worktree first, and you push once after the review loop is clean. ${GATE} +Report done=true with the gate output when the worktree is ready for review, with pr_url empty.`, + { label: `impl:${REF}`, model: implModel, effort: implEffort, schema: REPORT_SCHEMA }) +if (!impl) return { issues, error: 'implementer returned nothing' } +if (!impl.done) return { issues, error: 'implementer stopped', impl } + +const REVIEW_COMMON = `${COMMON} + +Worktree ${WT}, branch ${BRANCH}, not yet pushed; diff: git -C ${WT} diff origin/main...HEAD. Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or run tests, do it in your own isolated worktree, not in ${WT}, and run the .NET and web gates sequentially — other slots are building.` + +const LENSES = [ + { key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself through scripts/e2e-gate.sh, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test actually reddens when the fix alone is reverted (mutate the clause, not the file).' }, + { key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met in this diff (endpoint → api-conventions + regenerated v1.json/endpoint-index; screen/route → blazor-route-parity + domain-model; convention → decision record + catalog; new doc → README index); no narrative in docs; every new script or hook has its inventory row; CPM respected; both-provider migration if the model changed; tests are NUnit/vitest in the existing projects; no BOM in touched .cs; no edit to a file another slot owns (listed above); commit trailers present; Done-when boxes on the issue are evidence-backed; nothing pushed yet.' }, +] +async function codexReview(round) { + return agent(`${REVIEW_COMMON} + +You run the cross-family review — the diff touches a class where process.independent-review-rubric requires a reviewer from another model family, and you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of the diff of branch ${BRANCH} against origin/main in ${WT} for issue(s) ${REF} with done condition "${args.done_condition}", listing findings as blocking / should-fix / nit with file and evidence, ending with a line VERDICT: merge or VERDICT: send-back. Run it EXACTLY like this, in the background, output to a file, stdin from /dev/null (it hangs otherwise): codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 — then wait for the process to exit (poll pgrep on its PID with Monitor; a real review takes 10–40 minutes) and read the file. Return its findings faithfully in the schema; if the file has no VERDICT line the run failed — say so in a single blocking finding rather than inventing a verdict.`, + { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: FINDINGS_SCHEMA }) +} +async function review(round) { + const runs = LENSES.map(l => () => agent(`${REVIEW_COMMON} + +Review round ${round} of the branch for ${REF}. Lens: ${l.prompt} +Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`, + { label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })) + if (rubric) runs.push(() => codexReview(round)) + return parallel(runs) +} + +let round = 1 +let reviews = (await review(round)).filter(Boolean) +let blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking')) +const history = [{ round, reviews }] +while (blocking.length && round < 3) { + log(`${REF} round ${round}: ${blocking.length} blocking, sending back`) + const fix = await agent(`${COMMON} + +${WORKTREE} + +You are the fixer. Reviewers found these problems in the unpushed branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong: +${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)} +Then re-run the LOCAL GATE and STOP without pushing; the reviewers read the worktree again. ${GATE} +Report.`, + { label: `fix:r${round}`, phase: 'Fix', model: implModel, effort: implEffort, schema: REPORT_SCHEMA }) + round++ + reviews = (await review(round)).filter(Boolean) + blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking')) + history.push({ round, fix, reviews }) +} +if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history } + +phase('Fix') +const land = await agent(`${COMMON} + +${WORKTREE} + +You are the finisher. The branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`, + { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: REPORT_SCHEMA }) +if (!land || !land.done) return { issues, error: 'finisher stopped', land, history } +log(`${REF} PR: ${land.pr_url || 'none'} @ ${land.head_sha || '?'}`) +return { issues, pr_url: land.pr_url, head_sha: land.head_sha, impl, land, history } diff --git a/.claude/workflows/ersatztv-pick-next.js b/.claude/workflows/ersatztv-pick-next.js new file mode 100644 index 000000000..f9dfbf945 --- /dev/null +++ b/.claude/workflows/ersatztv-pick-next.js @@ -0,0 +1,52 @@ +export const meta = { + name: 'ersatztv-pick-next', + description: 'Pick the next N ersatztv issues by the kickoff queue rules from scripts/select-queue.sh and live Gitea state, mutually non-colliding and avoiding what other slots hold, then adversarially verify the set', + phases: [{ title: 'Pick' }, { title: 'Refute' }], +} +// args: { taken: [{issues:[n], files:[...]}], closed: [n...], notes: 'free text', count: how many picks to return (default 3) } +const taken = (args && args.taken) || [] +const closed = (args && args.closed) || [] +const notes = (args && args.notes) || '' +const count = (args && args.count) || 3 +const RULES = `Work read-only in /Users/timothy/ersatztv (the shared checkout; do not modify files, push, label or comment). Never read its git log or HEAD as truth about main: run git -C /Users/timothy/ersatztv fetch origin first, then read origin/main. +Read docs/handoffs/chicorytv-issue-queue.md fully — "Current phase", "Two concurrent tracks", the Selection and Bundles rules, and step 3's four-way claim check — and docs/handoffs/orchestration.md. +Ranking is NOT yours to derive: run ETV_GITEA_BASICAUTH="$ETV_GITEA_BASICAUTH" scripts/select-queue.sh 40 (the env var is already set) and take its order as given. It already excludes in-progress, parked, PRs, bot-authored issues and anything with an open blocker. Resolve only its CLAIM? and UMBRELLA? flags, by reading the flagged issue's body and comments. +Gitea REST: base http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv, auth -u "$ETV_GITEA_BASICAUTH", curl only. Issue: GET /issues/{n}; comments: GET /issues/{n}/comments; open PRs: GET /pulls?state=open&limit=50 (page until a page comes back empty — the endpoint caps limit at 50). Remote branches naming an issue: git -C /Users/timothy/ersatztv ls-remote --heads origin '**'. +A pick is claimable only if the four-way check is clean: no open PR whose body says fixes/refs #n, no remote branch naming n, no claiming comment on the issue (a claim can precede the label), and the issue is still open after the fetch. +Bundles: after choosing an issue, scan its milestone, its cross-references and its labels for small independent siblings that are cheap to sweep in the same worktree; a bundle is one pick with several issue numbers. Never bundle issues that a taken slot already holds. +ALREADY TAKEN by this orchestrator (in flight, with the files each edits): ${JSON.stringify(taken)} +Closed this session: ${JSON.stringify(closed)} +Orchestrator notes: ${notes}` +const PICK = { type: 'object', required: ['issues', 'title', 'slug', 'rationale', 'body_summary', 'done_condition', 'files_likely', 'area', 'size', 'risk', 'needs_e2e', 'skipped'], properties: { + issues: { type: 'array', items: { type: 'integer' } }, title: { type: 'string' }, + slug: { type: 'string', description: 'short kebab-case branch slug, e.g. null-font-family' }, + rationale: { type: 'string' }, + body_summary: { type: 'string', description: 'body plus all comments, condensed but complete; include the Done-when section verbatim if the issue has one' }, + done_condition: { type: 'string' }, + files_likely: { type: 'array', items: { type: 'string' } }, + area: { type: 'string', enum: ['spa', 'api', 'core', 'scanner', 'ffmpeg', 'ci', 'scripts', 'docs', 'mixed'] }, + size: { type: 'string', enum: ['small', 'medium', 'large'] }, + risk: { type: 'string', enum: ['routine', 'rubric'], description: 'rubric = touches locks/concurrency, auth/security, an API write-path handler, a DB migration, or will exceed ~150 changed C# lines (process.independent-review-rubric); needs a cross-family review' }, + needs_e2e: { type: 'boolean', description: 'true for a write path or UI change (testing.live-e2e-prepush-timing)' }, + skipped: { type: 'string', description: 'each higher-ranked issue skipped and the reason' } } } +const SCHEMA = { type: 'object', required: ['picks'], properties: { picks: { type: 'array', items: PICK, description: 'in queue order; each later pick avoids the files of every earlier one' } } } +const VERDICT = { type: 'object', required: ['refuted', 'reason'], properties: { refuted: { type: 'boolean' }, reason: { type: 'string' }, bad_picks: { type: 'array', items: { type: 'integer' }, description: 'issue numbers of the picks that fail, if not all' }, better: { type: 'array', items: { type: 'integer' } } } } +phase('Pick') +const res = await agent(`${RULES} + +Walk the selector's order and return up to ${count} issues or natural bundles, in that order, each of which (a) passes the four-way claim check, (b) edits no file a taken slot OR AN EARLIER PICK edits, (c) does not depend on another open issue (an earlier pick counts as open; a blocked-by dependency the selector already dropped), (d) is not a screen, handler or script an earlier pick is already on, (e) is not needs-hands or needs-the-user in disguise (a live-prod measurement nobody can take from here, a design question the body leaves open). Read each candidate's body and comments before accepting or rejecting it. Size is not a reason to skip: a large issue at the top of the queue is a pick, say size=large. Classify risk honestly — a write-path handler is rubric even when the diff is small. Stop early if the eligible queue runs out and say so in the last pick's skipped field; fewer than ${count} is fine, a colliding pair is not.`, { label: 'picker', model: 'sonnet', effort: 'medium', schema: SCHEMA }) +const picks = (res && res.picks) || [] +if (!picks.length) return { error: 'no pick' } +log('picks: ' + picks.map(p => '#' + p.issues.join('+#')).join(', ')) +phase('Refute') +const desc = picks.map(p => `- #${p.issues.join(', #')} "${p.title}" (size ${p.size}, risk ${p.risk}, area ${p.area}, e2e ${p.needs_e2e}). Rationale: ${p.rationale}. Files: ${p.files_likely.join(', ')}. Skipped: ${p.skipped}`).join('\n') +const votes = await parallel([ + 'ordering and claims: re-run scripts/select-queue.sh and the four-way claim check on every pick; refute if a higher-ranked eligible issue was skipped without a valid reason, the picks are out of selector order, or a pick is already claimed by a PR, branch or comment', + 'collisions and classification: read the code each pick will touch; refute if any pick edits a file a taken slot or another pick edits, or the same docs section, or depends on an open issue; also refute a risk=routine pick that touches a lock, auth, an API write-path handler or a migration, and a needs_e2e=false pick that changes a write path or a screen', +].map((lens, i) => () => + agent(`${RULES} + +Picks, in order: +${desc} +Lens: ${lens}. Try to refute; name the failing picks in bad_picks and a better ordering in better.`, { label: `refute:${i}`, model: 'sonnet', effort: 'medium', schema: VERDICT }))) +return { picks, refutations: votes.filter(Boolean).filter(v => v.refuted) } diff --git a/.claude/workflows/ersatztv-resume-branch.js b/.claude/workflows/ersatztv-resume-branch.js new file mode 100644 index 000000000..74c1a8978 --- /dev/null +++ b/.claude/workflows/ersatztv-resume-branch.js @@ -0,0 +1,131 @@ +export const meta = { + name: 'ersatztv-resume-branch', + description: 'Resume a paused ersatztv branch: finish or fix, rebase onto origin/main, local gate, adversarial review, fix loop, push, PR body and closing record refreshed', + phases: [{ title: 'Work' }, { title: 'Review' }, { title: 'Fix' }], +} + +// args: { issues, branch, wt, pr (number or ''), mode: 'fix'|'implement', title, risk: 'routine'|'rubric', needs_e2e, +// trailer, brief: path to a JSON file holding done_condition, findings, recon, context } +const issues = args.issues +const REF = issues.map(n => '#' + n).join(', ') +const WT = args.wt +const BRANCH = args.branch +const SHARED = '/Users/timothy/ersatztv' +const API = 'http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv' +const rubric = args.risk === 'rubric' +const TRAILER = args.trailer || '' + +const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth. +Issue(s) ${REF}: "${args.title}". +YOUR BRIEF is the JSON file ${args.brief}: read it first with cat. It holds done_condition, context from the orchestrator, findings (the last review round) and recon where they apply. +Read every issue in the bundle and all its comments: curl -s -u "$ETV_GITEA_BASICAUTH" ${API}/issues/N and ${API}/issues/N/comments (the env var is set; never write the credential into a file or a commit). + +Working rules, non-negotiable: +- Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key. +- Docs-update is part of done, same PR (endpoint → api-conventions + ./scripts/update-openapi.sh; screen/route → blazor-route-parity + domain-model; convention → decision record + catalog; new doc → README index). A TvContext change needs both providers' migrations via scripts/add-migration.sh. +- Tests are NUnit + Shouldly + NSubstitute; vitest under web/. Never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS. Dependencies only in Directory.Packages.props. +- Docs record the end state, never the investigation; the path goes in the commit message. +- Gitea labels: POST ${API}/issues/{n}/labels {"labels":[100]} / DELETE ${API}/issues/{n}/labels/100; PATCH ignores labels. +- Never use bare git stash. Never push to main. The ONLY sanctioned rewrite of a pushed branch is a rebase onto origin/main pushed with --force-with-lease; a fix is a new commit, never an amend. Never cd out of the worktree except to read the shared checkout read-only. Kill only PIDs you started. +Worktree: ${WT} on branch ${BRANCH}; it exists, do ALL work inside it. Give it its own web/node_modules if missing (cp -Rc from ${SHARED}/web when the lockfiles match, else npm ci). Every commit message ends with these trailer lines exactly: +${TRAILER}` + +const GATE = `LOCAL GATE (process.local-gate-before-push) — inside ${WT}, real output, a skipped test is not a pass: +- .NET: dotnet build, then dotnet test on every test project covering what the branch touches (all of them for anything under ErsatzTV.Core); BOM-check touched .cs with od -A n -t x1 -N 3 and bash -c 'dotnet format whitespace . --folder --verify-no-changes --include '. +- SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test. +- scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff on touched Python. +- Docs: python3 scripts/check-doc-narrative.py --diff origin/main. +- Live-E2E${args.needs_e2e ? ' IS REQUIRED (write path or UI)' : ' only for a write path or screen change'}: scripts/e2e-gate.sh scripts/e2e-local.sh with a fresh CONFIG_DIR and your own ETV_UI_PORT; curl, never a browser tab. +- Run the .NET and web gates sequentially; other slots are building.` + +const FINISH = `FINISH: git fetch origin and rebase onto origin/main (the branch is behind; resolve conflicts faithfully, keeping both sides' intent; regenerate generated artifacts rather than hand-resolving them; a commit titled "WIP: orchestrator checkpoint" holds uncommitted work from the paused session and must be squashed into the commit it belongs to, never left in history). ${GATE} +Then git push --force-with-lease origin ${BRANCH}. ${args.pr ? `Update PR #${args.pr}'s body (PATCH ${API}/pulls/${args.pr}) so it describes the branch as it now is` : `Open a PR (POST ${API}/pulls; head=${BRANCH}, base=main)`}: the body must contain "fixes #N" for every issue in the bundle, the root cause for a bug fix, the measured numbers, what the reviewers found and how it was answered, every deliberately-left item with an issue number, and end with: +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +${TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')} +Read ${API}/commits//status once to arm the CI monitor. Post or update the "## Closing record" comment on each issue (the closing-an-issue skill's template) linking the PR, without closing the issue. Tick every "## Done-when" box you can evidence; leave "Adversarial review passed" to the orchestrator. Report the PR URL and the head sha.` + +const REPORT_SCHEMA = { + type: 'object', required: ['done', 'summary', 'verified', 'left', 'commits'], + properties: { + done: { type: 'boolean' }, summary: { type: 'string' }, + verified: { type: 'string', description: 'exact gate commands run and their real output summary' }, + left: { type: 'string' }, commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' }, pr_url: { type: 'string' }, head_sha: { type: 'string' }, + }, +} +const FINDINGS_SCHEMA = { + type: 'object', required: ['findings', 'verdict'], + properties: { + verdict: { type: 'string', enum: ['merge', 'send-back'] }, + findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: { + severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } }, + }, +} + +phase('Work') +let work +if (args.mode === 'implement') { + work = await agent(`${COMMON} + +You are the implementer, continuing a paused session. Read git log and git show for the branch's commits first; a WIP checkpoint commit is the paused implementer's partial edit. The brief's recon is a plan; verify what you rely on. Finish the done condition completely, with a regression test that reddens against the unfixed code. Rebase onto origin/main, run the LOCAL GATE and STOP without pushing; reviewers read the worktree first. ${GATE}`, + { label: `impl:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA }) +} else { + work = await agent(`${COMMON} + +You are the fixer, continuing a paused session. The PR is #${args.pr}. Rebase onto origin/main first (conflicts resolved faithfully, generated artifacts regenerated). Then the brief's findings are the last review round's: fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first. ${GATE}`, + { label: `fix:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA }) +} +if (!work) return { issues, error: 'work agent returned nothing' } +if (!work.done) return { issues, error: 'work agent stopped', work } + +const REVIEW_COMMON = `${COMMON} + +Diff: git -C ${WT} diff origin/main...HEAD (rebased, not yet pushed). Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or test, do it in your own isolated worktree, sequentially.` + +const LENSES = [ + { key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself through scripts/e2e-gate.sh, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test reddens when the fix alone is reverted.' }, + { key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met; no narrative in docs; inventory rows for new scripts/hooks; CPM respected; both-provider migration if the model changed; no BOM in touched .cs; no WIP commit left in history; branch rebased on current origin/main; commit trailers present; Done-when boxes evidence-backed; PR body will carry fixes #N for each issue.' }, +] +async function codexReview(round) { + return agent(`${REVIEW_COMMON} + +You run the cross-family review required by process.independent-review-rubric; you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of branch ${BRANCH} against origin/main in ${WT} for ${REF} with done condition from the brief, findings as blocking / should-fix / nit with file and evidence, ending with VERDICT: merge or VERDICT: send-back. Run EXACTLY: codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 in the background, wait for the PID to exit (Monitor; 10–40 minutes), read the file, return its findings faithfully; no VERDICT line means the run failed — report that as one blocking finding, never invent a verdict.`, + { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: FINDINGS_SCHEMA }) +} +async function review(round) { + const runs = LENSES.map(l => () => agent(`${REVIEW_COMMON} + +Review round ${round} of the branch for ${REF}. Lens: ${l.prompt} +Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`, + { label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })) + if (rubric) runs.push(() => codexReview(round)) + return parallel(runs) +} + +let round = 1 +let reviews = (await review(round)).filter(Boolean) +let blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking')) +const history = [{ round, reviews }] +while (blocking.length && round < 3) { + log(`${REF} round ${round}: ${blocking.length} blocking, sending back`) + const fix = await agent(`${COMMON} + +You are the fixer. Reviewers found these problems in the unpushed, rebased branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong: +${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)} +Re-run the LOCAL GATE and STOP without pushing. ${GATE}`, + { label: `fix:r${round}`, phase: 'Fix', model: 'opus', effort: 'high', schema: REPORT_SCHEMA }) + round++ + reviews = (await review(round)).filter(Boolean) + blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking')) + history.push({ round, fix, reviews }) +} +if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history } + +phase('Fix') +const land = await agent(`${COMMON} + +You are the finisher. The rebased branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`, + { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: REPORT_SCHEMA }) +if (!land || !land.done) return { issues, error: 'finisher stopped', land, history } +log(`${REF} PR: ${land.pr_url || args.pr} @ ${land.head_sha || '?'}`) +return { issues, pr_url: land.pr_url || args.pr, head_sha: land.head_sha, work, land, history } diff --git a/docs/README.md b/docs/README.md index 9b57cbcb9..6753f0e58 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ doc below, or that changes which sections a task signal points to.** | Signal | Read | | --- | --- | | Session startup / "what's next" (no issue named) | `docs/handoffs/chicorytv-issue-queue.md` (standing kickoff — two concurrent tracks: orientation ‖ `scripts/select-queue.sh 5`) | +| Running SEVERAL issues in parallel under one orchestrator (the `orchestrator-prompt.md` kickoff) | `docs/handoffs/orchestration.md` — roles and sizing, one worktree per issue under `~/orca/workspaces/ersatztv/`, the landing order (gate + review before the single push; verdict via `scripts/post-review-verdict.sh`; merge through the consent hook), resuming a paused branch. The queue rules and HARD CONSTRAINTS stay in `chicorytv-issue-queue.md` | | Named-issue pickup | Skip queue selection; go straight to focused retrieval — see "Knowledge retrieval" below, then the issue body | | Adding/changing a `/api/*` endpoint | `docs/api-conventions.md` checklist + `docs/endpoint-index.md` | | Adding a ChicoryTV SPA screen | `docs/spa-conventions.md` | @@ -116,6 +117,8 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K OpenAPI tag). Do not edit by hand; regenerated by `scripts/generate-endpoint-index.py` / `scripts/update-openapi.sh`. - **`docs/handoffs/chicorytv-issue-queue.md`** — static session kickoff prompt + workflow lore. +- **`docs/handoffs/orchestration.md`** — mechanics of an orchestrated session: roles, isolation, landing a branch through the gate, resume, incidents. Workflow scripts: `.claude/workflows/ersatztv-{pick-next,issue-build,resume-branch}.js`. +- **`docs/handoffs/orchestrator-prompt.md`** — the standing prompt that starts an orchestrated session; the single-issue kickoff stays in `chicorytv-issue-queue.md`. Queue state is **live Gitea state**, retrieved each session via `scripts/select-queue.sh` — see that file's standing kickoff for the two concurrent tracks (orientation ‖ selection). ersatztv#237 is a closed, archival historical tracker (superseded by `startup.parallel-orientation` in diff --git a/docs/handoffs/chicorytv-issue-queue.md b/docs/handoffs/chicorytv-issue-queue.md index 355793674..a7e6d9f55 100644 --- a/docs/handoffs/chicorytv-issue-queue.md +++ b/docs/handoffs/chicorytv-issue-queue.md @@ -364,6 +364,8 @@ HARD CONSTRAINTS: task-specific delta. → `docs.convention-docs-session-start` - Run `scripts/select-queue.sh` for queue selection; trust its deps/tiering/ordering and resolve only its `CLAIM?`/`UMBRELLA?` flags. → `startup.parallel-orientation` +- A session that runs several issues at once is an orchestrated session: `docs/handoffs/orchestration.md` + owns its mechanics and `orchestrator-prompt.md` starts it; every slot is still bound by this file. - Claim with `in-progress` before working — but **check for an existing claim first** (open PRs referencing the issue, remote branches naming it, comments predating the label, a fresh `git fetch`), because a label prevents duplicate *pickup*, not duplicate *work*: #649 was diff --git a/docs/handoffs/orchestration.md b/docs/handoffs/orchestration.md new file mode 100644 index 000000000..681ec308b --- /dev/null +++ b/docs/handoffs/orchestration.md @@ -0,0 +1,110 @@ +# Orchestrated sessions + +How one session closes several issues at once: an orchestrator that never implements, subagents +that each own one issue in one worktree, and the repo's merge gate between every branch and `main`. +This page owns the mechanics. `orchestrator-prompt.md` is the standing prompt that starts such a +session; `chicorytv-issue-queue.md` still owns queue selection, claiming and the HARD CONSTRAINTS, +and nothing here relaxes them — an orchestrated session is several single-issue sessions run by +one referee, and each of them is bound by the whole kickoff. + +## Roles + +| Role | Model, effort | Does | +|---|---|---| +| Orchestrator | Fable or Opus, high | Reads verdicts and evidence, posts the H10 verdict, merges through the consent hook, clears labels, removes worktrees, watches for collisions and hangs. Never picks, claims, codes or pushes. | +| Picker + 2 refuters | sonnet, medium | Apply `scripts/select-queue.sh` and the kickoff's claim and bundle rules to live Gitea state; return up to `count` mutually non-colliding picks. `.claude/workflows/ersatztv-pick-next.js`, args `{taken, closed, notes, count}`. | +| Recon (large issues) | Opus, high | Reads the docs the task-signal map names, finds the handlers, call sites, tests and guards, returns a plan. | +| Implementer | Opus high; sonnet medium for `size: small` | Claims, builds, runs the local gate, sits inside the review loop, pushes once, opens the PR, posts the `## Closing record` without closing. | +| Reviewers | correctness: Opus high, in a worktree; conformance: sonnet high; cross-family: Codex via `codex exec` for the rubric's risk classes | Run the gate themselves; `blocking` / `should-fix` / `nit`. | +| Fixer | same tier as the implementer | Answers findings; two rounds, then the orchestrator decides. | + +**Sizing is a rule** (`process.per-agent-model-routing`): every launch names its model and effort. +sonnet at medium for picking, refuting, small well-specified fixes and mechanical finishing; sonnet at +high for conformance review; Opus at high for recon, implementation, fixing and correctness review; +Fable for orchestrating and for the frontier escalations the kickoff lists, never for implementing. +Cross-family review is mandatory for locks/concurrency, auth/security, API write-path handlers, DB +migrations and diffs over ~150 changed C# lines (`process.independent-review-rubric`); elsewhere a +cold same-family review is sufficient, and the picker's `risk` field is what decides. + +`.claude/workflows/ersatztv-issue-build.js` runs claim → recon → implement → gate → review → fix for +one issue or bundle (args: `issues, slug, title, size, risk, area, needs_e2e, body_summary, +done_condition, files_likely, avoid, trailer`). `ersatztv-resume-branch.js` finishes or fixes a +paused branch from a JSON brief. Both are passed to the Workflow tool by `scriptPath`. + +## Isolation + +- One worktree per issue: `~/orca/workspaces/ersatztv/wt-`, branch `-`, cut from a + freshly fetched `origin/main` (`process.shared-tree-readonly`). Never under `/tmp` — macOS purges + it mid-session — and never the shared checkout. `git worktree add` from the session stamps the + `.claude-worktree-owner` marker the commit guard reads, so the workflow's agents may commit there + and a sibling session's may not (`process.foreign-worktree-plumbing-merge`). +- `web/node_modules` is per worktree: clone it from the shared checkout with `cp -Rc` when the + lockfiles match, otherwise `npm ci`. The shared copy is kept current by + `scripts/refresh-shared-checkout.sh` at session end. +- **Three issues in flight**, not five: every implementer and every worktree-isolated reviewer runs + a `dotnet` build and a web build, and the cap is 3–4 concurrent builds gated on free RAM + (`process.build-concurrency-limits`). The Agent hook enforces the RAM gate for agents launched by + the Agent tool, not for a workflow's agents, so the orchestrator holds the count itself. +- **One live-E2E run at a time on the machine**: `scripts/e2e-local.sh` refuses concurrent runs even + on different ports, so every E2E invocation goes through `scripts/e2e-gate.sh ` — a + `mkdir` lock under `~/orca/workspaces/ersatztv/` that takes over a lock whose holder PID is dead + and releases on exit. Kill only PIDs you started (`testing.e2e-cleanup-scope-by-pid`). +- Reviewers create scratch only under `/private/tmp`, delete nothing outside it, and never build a + path with `..` segments. A review-only instruction is not enforcement; a reviewer that must build + gets `isolation: worktree`. + +## Landing a branch + +The order is the kickoff's (`process.pr-routine-sequence`, `process.local-gate-before-push`), with +the review loop *inside* the worktree, before the single push: + +1. **Implementer**: local gate — `dotnet build` and `dotnet test` on the touched test projects + (the full solution for anything under `ErsatzTV.Core`), `npm run check:api && npm run lint && + npm run typecheck && npm run build && npm test` under `web/` for SPA changes, `PYTHONPATH=. + python3 -m pytest scripts/tests -q` plus `ruff` for anything under `scripts/`, `.claude/`, + `.husky/` or `.gitea/`, the BOM check for touched `.cs` (`process.bom-format-detection-recipe`), + and live-E2E through the gate for a write path or UI change (`testing.live-e2e-prepush-timing`). +2. **Reviewers** read the worktree diff (`git diff origin/main...HEAD`) and run the gate again; + the fixer answers `blocking` and `should-fix` findings; the loop ends on a clean round, never on + "round one's findings are fixed". +3. **Implementer**: fetch, rebase onto `origin/main` if it moved and re-run the gate, then **one + push**, `git push -u origin `; open the PR with `fixes #N` for every issue in the bundle + and the session trailers; arm the CI monitor on the head sha; post the `## Closing record` on + each issue and tick every `## Done-when` box it can evidence, leaving the review box. +4. **Orchestrator**: read the review evidence, not the summaries. Send the PR back for anything + that lets a route or test pass having done nothing. Re-read the head sha immediately before + posting, then `scripts/post-review-verdict.sh MERGEABLE ` naming the reviewers and + rounds; tick the review box. Wait for CI — a `cancelled` job reads as `failure` at the combined + status endpoint, so resolve it via the run's jobs (`ci.cancelled-is-not-a-verdict`). +5. **Merge through the Gitea merge tool with the full head sha**; the consent hook derives consent + from the ticked boxes, the sha-bound verdict and green CI and auto-grants + (`release.merge-consent-autogrant`). A merge that `asks` is a state the orchestrator could not + derive, so derive it — never answer the prompt from memory. Then `DELETE /issues/{n}/labels/100` + on each issue, `git worktree remove`, refill the slot. + +A rebase changes the head sha and voids the verdict, and a fix after the push is a new commit, +never an amend (`process.pr-routine-sequence`). So rebase once, in step 3; a branch that conflicts +after its push goes back to the fixer for a rebase, another gate run and a fresh review of the +rebased head. Push a branch behind `origin/main` and the pre-push hook refuses it +(`release.format-as-you-touch-rebase`). + +## Resuming a paused branch + +A branch with a PR whose verdict is stale (head moved), whose CI is red, or that Gitea reports +unmergeable is resumed by `ersatztv-resume-branch.js` with a brief holding the done condition, the +last findings and the orchestrator's context. Its fixer rebases, re-runs the gate, sits in the same +review loop, pushes with `--force-with-lease` (the only sanctioned rewrite: the base changed, the +commits did not), and the orchestrator posts a fresh verdict for the new head. A resumed session +finishes every open branch before it picks anything new. + +## Hangs and incidents + +- A `dotnet test` or E2E run with no output for ten minutes is a hang, not a slow test. Kill it by + PID, gently, so the E2E gate's trap releases the lock; re-run once before diagnosing. +- Under an API incident (`https://status.claude.com/api/v2/status.json`), mechanical finishers and + probe-driven reviews run on whichever model is healthy; large implementation waits. Back off + before retrying. +- A workflow that returns an empty or unexpected result is read from its `journal.jsonl` before it + is re-run; a resumed run replays the unchanged prefix from cache. +- Before any stop: the H12 audit, `scripts/refresh-shared-checkout.sh`, and a handoff memory naming + every open branch, its worktree, its PR and its next step. diff --git a/docs/handoffs/orchestrator-prompt.md b/docs/handoffs/orchestrator-prompt.md new file mode 100644 index 000000000..03074db0a --- /dev/null +++ b/docs/handoffs/orchestrator-prompt.md @@ -0,0 +1,16 @@ +# Orchestrator prompt (paste unchanged into a fresh Claude Code session) + +/goal @docs/handoffs/chicorytv-issue-queue.md You are the orchestrator and referee, not an implementer: read the kickoff and docs/handoffs/orchestration.md, then run the queue in parallel through subagents and workflows until you are told to stop or the eligible queue is empty, and run /done at the end. + +Way of working: + +- Size every subagent to its task; this is a rule, not a preference, because value per token is what the session is judged on. Picking and refuting: sonnet, medium. Recon on a large issue, implementation, fixing and correctness review: Opus at high (xhigh for a lock, threading or migration fix). Fable is for orchestrating and for the frontier escalations the kickoff lists, never for implementing. Small, well-specified fixes and mechanical finishing (rebase, push, PR body, label clearing): sonnet, medium. Conformance review: sonnet, high. Cross-family review through `codex exec` for the rubric's risk classes (locks, auth, API write paths, migrations, more than ~150 changed C# lines) — a same-family cold review is not a substitute there. State the model and effort in every launch and revisit them when an incident degrades a model. +- You never pick, claim, code or push yourself. A picker (sonnet, medium; `.claude/workflows/ersatztv-pick-next.js` with the taken list as args) runs `scripts/select-queue.sh`, applies the kickoff's claim and bundle rules to live Gitea state, and two refuters try to overturn it; you accept, or take a refuter's better pick when its rule is right. Then one issue-build workflow per pick, from `.claude/workflows/ersatztv-issue-build.js`, passing the session's commit trailer as `trailer`. +- Before picking anything, finish what is already open: every open non-Renovate PR and every `in-progress` issue with a branch is a paused branch, resumed through `.claude/workflows/ersatztv-resume-branch.js` from a JSON brief. +- Keep three issues in flight, each in its own worktree under `~/orca/workspaces/ersatztv/wt-` on branch `-`, cut from a fresh `origin/main`, never under `/tmp`. Refill a slot as soon as one merges. Pass every running issue's files as the avoid list so two agents never edit one file; a pick that needs a file another slot holds waits for that slot. +- Each implementer claims its own issue after the four-way check (open PRs, remote branches, comments predating the label, a fresh fetch), with the `in-progress` label and a claiming comment; runs the local gate and sits inside the two-lens review loop BEFORE its single push; opens a PR whose body says `fixes #N` for every issue in the bundle; posts the `## Closing record` on each issue without closing it; and ticks every `## Done-when` box it can evidence, leaving the review box for you. +- You referee: read the verdicts and the evidence, send a PR back for anything that lets a route or test pass having done nothing, re-read the head sha, then post the verdict with `scripts/post-review-verdict.sh MERGEABLE ` and tick the review box. Merge only through the Gitea merge tool with the full head sha, so the consent hook derives consent from the ticked boxes, the sha-bound verdict and green CI; a rebase after the verdict voids it, so the branch is rebased once, before its push. After the merge clear the `in-progress` label and remove the worktree. +- Builds are capped at three to four concurrent and gated on free RAM, and live-E2E runs one at a time on the machine through `scripts/e2e-gate.sh`. Kill only PIDs you started, gently. A `dotnet test` or E2E run silent for ten minutes is a hang. +- Reviewers never delete anything outside a scratch directory they created under `/private/tmp` and never build a path with `..` segments; the auto-mode classifier is the last line, not the first. +- Under an API incident (watch `https://status.claude.com/api/v2/status.json`), move mechanical finishers and probe-driven reviews to whichever model is healthy and park large implementation until it clears; back off before retrying. +- Report to the user only what changes what they would do next: merged and closed issues, a send-back and why, a hang, an incident. Before any stop: the H12 audit, `scripts/refresh-shared-checkout.sh`, and a handoff memory naming every open branch, its worktree, its PR and its next step, so a resumed session finishes those before picking anything new. diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index ab81e72c6..d05467296 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -105,6 +105,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | `scripts/migration-smoke.sh` — `docker pull`, then `docker run` the same tag | `UNSAFE-KNOWN` | The same pull-then-run over a mutable tag as `security-scan.sh` above, and graded with it rather than left behind — spelled out rather than cross-referenced, because a backreference to another row's conclusion inverts silently when that row is regraded. This one deserves the grade MORE, not less — `security-scan.sh` boots a throwaway container and authorizes nothing, while this is the pre-deploy migration smoke that gates a production stack recreation. Accepted on the same bound (the run resolves against the local daemon holding the image the pull just placed) plus its own stated operator-trust gap: the resolved image id is **reported** for a human rather than compared against a prior read. | | `scripts/hook-fire-log.sh` | `N/A` | Entirely local: reads stdin and writes JSONL under the cache dir; the only `curl` in the file is in a comment. | | `scripts/e2e-local.sh` | `N/A` | No outbound call at all; readiness is a local log grep and a local port probe against a subprocess it started. | +| `scripts/e2e-gate.sh` | `N/A` | No outbound call at all; a `mkdir` lock under `~/orca/workspaces/ersatztv/` that serialises live-E2E runs across worktrees, then `exec`s its argument. | | `scripts/e2e-ui.sh` | `N/A` | Launches a local Chromium and runs specs against `http://localhost:$PORT`. | | `scripts/e2e-functional.sh` | `N/A` | Every call targets `$BASE_URL`, defaulting to `http://localhost:8409`. The one non-local-looking address, `192.0.2.1`, is TEST-NET-1 (RFC 5737) — written into the DB as a connection row precisely so it is unroutable, never dialed by the script. | diff --git a/scripts/e2e-gate.sh b/scripts/e2e-gate.sh new file mode 100755 index 000000000..39e0b8616 --- /dev/null +++ b/scripts/e2e-gate.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# e2e-gate.sh — run one command under the machine-wide live-E2E lock. +# +# `scripts/e2e-local.sh` (and `e2e-ui.sh`, `e2e-functional.sh`, which boot it) refuse to run +# concurrently even on different ports: each run boots an instance, and the cleanup kills only PIDs it +# started, so two overlapping runs fight over ports and the search-index probe. An orchestrated session +# (docs/handoffs/orchestration.md) runs several worktrees at once, so the serialisation lives here +# rather than in each caller's memory. +# +# Usage: scripts/e2e-gate.sh [args...] +# +# Lock: a `mkdir` on $ETV_E2E_LOCK (default ~/orca/workspaces/ersatztv/.e2e-lock). The holder writes +# its PID and cwd inside; a waiter polls, and takes over a lock whose holder PID is no longer alive. +# The lock is released by an EXIT trap, so kill a holder gently (TERM, by PID) and the trap runs; a +# KILL leaves a stale lock that the next waiter reclaims once the PID is gone. +# +# Reads no remote state; touches nothing outside the lock directory. +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "usage: $0 [args...]" >&2 + exit 64 +fi + +lock="${ETV_E2E_LOCK:-$HOME/orca/workspaces/ersatztv/.e2e-lock}" +mkdir -p "$(dirname "$lock")" + +release() { rm -rf "$lock"; } + +waited=0 +while ! mkdir "$lock" 2>/dev/null; do + holder=$(cat "$lock/pid" 2>/dev/null || true) + if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then + echo "e2e-gate: holder $holder is gone; taking over the lock" >&2 + rm -rf "$lock" + continue + fi + if [ $((waited % 60)) -eq 0 ]; then + echo "e2e-gate: waiting on ${holder:-unknown} ($(cat "$lock/cwd" 2>/dev/null || echo '?')) — ${waited}s" >&2 + fi + sleep 5 + waited=$((waited + 5)) +done +trap release EXIT +printf '%s\n' "$$" > "$lock/pid" +printf '%s\n' "$PWD" > "$lock/cwd" + +"$@" -- 2.47.3 From 63256afd96952c9cdf7825be25897e966104a63a Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 4 Sep 2026 23:06:43 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fix(907):=20answer=20the=20cold=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20the=20E2E=20lock,=20scope=20force-with-lease?= =?UTF-8?q?=20in=20a=20record,=20referee=20ticks=20the=20boxes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mkdir lock around scripts/e2e-local.sh serialised the launch, not the run (the launcher returns with the server up), and its stale-holder path double- acquired in 4 of 91 measured races; the launcher's documented conflict is its per-worktree wwwroot, so slots now run on their own port and the lock is gone with its inventory row. process.orchestrated-session records the two scopings the harness needed: a rebase pushed with --force-with-lease as the one sanctioned rewrite, and the referee as the only agent that ticks Done-when boxes. Scripts: required-arg guard, per-issue claim probe, reviewer fetch recipe, codex fallback to a cold review-only agent with the substitution stated in the PR body, rebase before the review loop with a patch-id check at the push, non-interactive squash recipe, Land phase. README bullets re-parented; kickoff bullet keyed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .claude/workflows/ersatztv-issue-build.js | 65 ++++++---- .claude/workflows/ersatztv-pick-next.js | 2 +- .claude/workflows/ersatztv-resume-branch.js | 67 ++++++---- docs/README.md | 6 +- docs/decisions/README.md | 1 + .../records/process/orchestrated-session.md | 34 +++++ docs/handoffs/chicorytv-issue-queue.md | 1 + docs/handoffs/orchestration.md | 122 ++++++++++-------- docs/handoffs/orchestrator-prompt.md | 10 +- docs/remote-state-inventory.md | 1 - scripts/e2e-gate.sh | 48 ------- 11 files changed, 201 insertions(+), 156 deletions(-) create mode 100644 docs/decisions/records/process/orchestrated-session.md delete mode 100755 scripts/e2e-gate.sh diff --git a/.claude/workflows/ersatztv-issue-build.js b/.claude/workflows/ersatztv-issue-build.js index 76c7a0a71..00b551d3a 100644 --- a/.claude/workflows/ersatztv-issue-build.js +++ b/.claude/workflows/ersatztv-issue-build.js @@ -1,12 +1,15 @@ export const meta = { name: 'ersatztv-issue-build', description: 'Close one ersatztv issue or bundle in its own worktree via PR: claim, recon, implement, local gate, adversarial review before the push, fix loop, single push, PR, closing record', - phases: [{ title: 'Recon' }, { title: 'Implement' }, { title: 'Review' }, { title: 'Fix' }], + phases: [{ title: 'Recon' }, { title: 'Implement' }, { title: 'Review' }, { title: 'Fix' }, { title: 'Land' }], } // args: { issues: [n,...], slug, title, body_summary, done_condition, files_likely, area, size, risk: 'routine'|'rubric', -// needs_e2e, avoid: [{issues, files}], trailer: 'Co-Authored-By: ...\nClaude-Session: ...', +// needs_e2e, port: the slot's ETV_UI_PORT, avoid: [{issues, files}], trailer: 'Co-Authored-By: ...\nClaude-Session: ...', // effort?: 'xhigh' for lock/threading/migration work, model?: override for the implementer/fixer } +if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !args.port) { + return { error: 'args.issues (non-empty), args.trailer and args.port are required' } +} const issues = args.issues const ISSUE = issues[0] const REF = issues.map(n => '#' + n).join(', ') @@ -18,7 +21,8 @@ const big = args.size === 'large' const rubric = args.risk === 'rubric' const implModel = args.model || (args.size === 'small' ? 'sonnet' : 'opus') const implEffort = args.effort || (args.size === 'small' ? 'medium' : 'high') -const TRAILER = args.trailer || '' +const TRAILER = args.trailer +const SESSION_URL = TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n') const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth. Issue(s) ${REF}: "${args.title}". @@ -31,7 +35,7 @@ ${JSON.stringify(args.avoid || [], null, 1)} Working rules, non-negotiable: - Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key, never by chasing a file path named in an old comment. Do not reverse-engineer conventions from source before reading these. -- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a new or reversed convention gets a record under docs/decisions/records// and a regenerated catalog; a new or retitled doc updates docs/README.md. +- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a new or reversed convention gets a record under docs/decisions/records// and a regenerated catalog (PYTHONPATH=. python3 scripts/build_decisions_catalog.py — the catalog docs/decisions/README.md is generated and shared with other slots: never hand-edit it, regenerate it, and resolve a rebase conflict in it by regenerating); a new or retitled doc updates docs/README.md. - A TvContext model change needs a migration in BOTH providers: scripts/add-migration.sh . - Tests are NUnit + Shouldly + NSubstitute in the existing *.Tests projects; vitest under web/. Pin the behaviour with a test that reddens when the fix alone is removed; never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS. - Dependencies use Central Package Management: versions live only in Directory.Packages.props. @@ -40,17 +44,17 @@ Working rules, non-negotiable: - Never use bare git stash (the stash stack is shared across worktrees; commit WIP instead). Never push to main (it is refused server-side anyway). Never amend or force-push a pushed branch; a fix after the push is a new commit. Never cd out of your worktree except to read the shared checkout read-only. - Kill only PIDs you started; never pkill by name — other sessions run dotnet and Playwright on this machine.` -const WORKTREE = `Worktree: ${WT} on branch ${BRANCH}. Check git -C ${SHARED} worktree list; if absent: git -C ${SHARED} fetch origin && git -C ${SHARED} worktree add ${WT} -b ${BRANCH} origin/main. Then give it its own web/node_modules: if cmp -s ${SHARED}/web/package-lock.json ${WT}/web/package-lock.json then cp -Rc ${SHARED}/web/node_modules ${WT}/web/node_modules, else (cd ${WT}/web && npm ci). Do ALL work inside ${WT}. Commit as you go; every commit message ends with these trailer lines exactly: +const WORKTREE = `Worktree: ${WT} on branch ${BRANCH}. Check git -C ${SHARED} worktree list; if absent: git -C ${SHARED} fetch origin && git -C ${SHARED} worktree add ${WT} -b ${BRANCH} origin/main (absolute path, as written). Then give it its own web/node_modules: if cmp -s ${SHARED}/web/package-lock.json ${WT}/web/package-lock.json then cp -Rc ${SHARED}/web/node_modules ${WT}/web/node_modules, else (cd ${WT}/web && npm ci). Do ALL work inside ${WT}. If git commit is denied by the worktree-owner guard naming another session id, the worktree was created by another agent of this same orchestrated session: overwrite ${WT}/.claude-worktree-owner with your own session id as the guard's message says, and retry. Commit as you go; every commit message ends with these trailer lines exactly: ${TRAILER}` -const CLAIM = `CLAIM FIRST, the four-way check from the kickoff (process.parallel-session-claim): git -C ${SHARED} fetch origin; curl the open PRs (${API}/pulls?state=open&limit=50, page until empty) for a body saying fixes/refs ${REF}; git -C ${SHARED} ls-remote --heads origin '*${ISSUE}*'; read the issue comments for a claim that predates the label. If a PR, branch or comment shows another session already on ${REF} (other than this orchestrator's note, if any), STOP and report done=false with the evidence. Otherwise add the in-progress label and post a claiming comment naming branch ${BRANCH} and worktree ${WT}, on every issue in the bundle. If an issue body has no "## Done-when" section, append one (PATCH ${API}/issues/{n} with the full body): one unticked box per concrete completion criterion drawn from the issue, plus "- [ ] Adversarial review passed". The merge gate derives consent from those boxes, so write criteria you can evidence.` +const CLAIM = `CLAIM FIRST, the four-way check from the kickoff (process.parallel-session-claim), for EVERY issue in the bundle: git -C ${SHARED} fetch origin; curl the open PRs (${API}/pulls?state=open&limit=50, page until empty) for a body saying fixes/refs ${REF}; ${issues.map(n => `git -C ${SHARED} ls-remote --heads origin '*${n}*'`).join('; ')}; read each issue's comments for a claim that predates the label. If a PR, branch or comment shows another session already on ${REF} (other than this orchestrator's note, if any), STOP and report done=false with the evidence. Otherwise add the in-progress label and post a claiming comment naming branch ${BRANCH} and worktree ${WT}, on every issue in the bundle. If an issue body has no "## Done-when" section, append one (PATCH ${API}/issues/{n} with the full body): one unticked box per concrete completion criterion drawn from the issue, plus "- [ ] Adversarial review passed". The merge gate derives consent from those boxes; the orchestrator ticks them from your evidence, so write criteria that can be evidenced.` const GATE = `LOCAL GATE (process.local-gate-before-push) — run it inside ${WT} and read the real output; a skipped test is not a passing one: - .NET: dotnet build the solution, then dotnet test on every test project that covers what you touched (ErsatzTV.Tests, ErsatzTV.Core.Tests, ErsatzTV.Scanner.Tests, ErsatzTV.FFmpeg.Tests, ErsatzTV.Architecture.Tests — all of them for anything under ErsatzTV.Core). Before any push touching .cs: BOM-check the touched set with od -A n -t x1 -N 3 (efbbbf = BOM) and run bash -c 'dotnet format whitespace . --folder --verify-no-changes --include ' (process.bom-format-detection-recipe). - SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test. - scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff check and ruff format --check on any Python you touched. A new executable under scripts/ or .claude/hooks/ needs its row in docs/remote-state-inventory.md and, if it is a guard, in docs/guard-inventory.md — the suites say so. - Docs: python3 scripts/check-doc-narrative.py --diff origin/main and answer what it flags (it is advisory, the rule is not). -- Live-E2E${args.needs_e2e ? ' IS REQUIRED for this change (write path or UI)' : ' only if you changed a write path or a screen'}: scripts/e2e-gate.sh scripts/e2e-local.sh with a fresh CONFIG_DIR, on a port you pick with ETV_UI_PORT; curl the endpoints, never a browser tab; the gate serialises E2E across the machine, so wait for it. +- Live-E2E${args.needs_e2e ? ' IS REQUIRED for this change (write path or UI)' : ' only if you changed a write path or a screen'}: ETV_UI_PORT=${args.port} scripts/e2e-local.sh — port ${args.port} is this slot's; one run at a time in this worktree; curl the endpoints, never a browser tab; when done, kill the PID the launcher printed and nothing else. The launcher's pre-flight refuses a busy port and names the holder: report that, do not pick another port and never kill the holder. - Builds on this Mac are capped at 3–4 concurrent and other slots are building too: run the .NET and web gates sequentially, not in parallel with each other.` const REPORT_SCHEMA = { @@ -64,12 +68,14 @@ const REPORT_SCHEMA = { commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' }, pr_url: { type: 'string' }, head_sha: { type: 'string' }, + patch_changed: { type: 'boolean', description: 'finisher only: true if the pre-push rebase changed the patch-id (a conflict resolved or an artifact regenerated)' }, }, } const FINDINGS_SCHEMA = { type: 'object', required: ['findings', 'verdict'], properties: { verdict: { type: 'string', enum: ['merge', 'send-back'] }, + ran: { type: 'boolean', description: 'cross-family runner only: false if codex produced no VERDICT line' }, findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: { severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } }, }, @@ -83,13 +89,6 @@ const RECON_SCHEMA = { }, } -const FINISH = `FINISH, in this order: ${GATE} -Then git -C ${WT} fetch origin; if origin/main moved, rebase onto it (never merge main in; regenerate, never hand-resolve, generated artifacts) and re-run the gate. Then ONE push: git push -u origin ${BRANCH}. Open the PR with the Gitea API (POST ${API}/pulls; head=${BRANCH}, base=main, title, body). The body must contain "fixes #N" for every issue in the bundle so the merge closes them, the root cause for a bug fix, the measured numbers, what the reviewers found and how it was answered, and every deliberately-left item with an issue number (file follow-up issues where needed). End the body with: -🤖 Generated with [Claude Code](https://claude.com/claude-code) - -${TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')} -Arm the CI monitor: note the head sha and read ${API}/commits//status once. Then the closing-an-issue skill (invoke it through the Skill tool if you have it, otherwise read .claude/skills/closing-an-issue/SKILL.md) with one modification: do NOT close the issue — the merge closes it. Post the "## Closing record" comment on each issue, linking the PR. Tick every "## Done-when" box you can evidence by editing the issue body (PATCH ${API}/issues/{n}); leave "Adversarial review passed" unticked, the orchestrator ticks it. Remove nothing; the orchestrator removes the worktree after the merge. Report the PR URL and the head sha.` - let recon = null if (big) { phase('Recon') @@ -107,7 +106,7 @@ ${WORKTREE} ${CLAIM} ${recon ? `Recon (verify what you rely on):\nPLAN: ${recon.plan}\nFACTS: ${recon.facts}\nRISKS: ${recon.risks}\nTEST PLAN: ${recon.test_plan}\n` : ''} -You are the implementer. Close ${REF} completely: pin the behaviour with tests named for the branch they protect, update the docs the change obligates, commit. Then run the LOCAL GATE and STOP — do not push yet; reviewers read your worktree first, and you push once after the review loop is clean. ${GATE} +You are the implementer. Close ${REF} completely: pin the behaviour with tests named for the branch they protect, update the docs the change obligates, commit. Then git fetch origin and rebase onto origin/main if it moved (never merge main in; regenerate generated artifacts), run the LOCAL GATE and STOP — do not push; reviewers read your worktree first, and a finisher pushes once after the review loop is clean. ${GATE} Report done=true with the gate output when the worktree is ready for review, with pr_url empty.`, { label: `impl:${REF}`, model: implModel, effort: implEffort, schema: REPORT_SCHEMA }) if (!impl) return { issues, error: 'implementer returned nothing' } @@ -115,17 +114,25 @@ if (!impl.done) return { issues, error: 'implementer stopped', impl } const REVIEW_COMMON = `${COMMON} -Worktree ${WT}, branch ${BRANCH}, not yet pushed; diff: git -C ${WT} diff origin/main...HEAD. Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or run tests, do it in your own isolated worktree, not in ${WT}, and run the .NET and web gates sequentially — other slots are building.` +Worktree ${WT}, branch ${BRANCH}, not yet pushed; diff: git -C ${WT} diff origin/main...HEAD. Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or run tests, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the unpushed branch there; run the .NET and web gates sequentially — other slots are building; E2E there on port ${Number(args.port) + 1}.` const LENSES = [ - { key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself through scripts/e2e-gate.sh, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test actually reddens when the fix alone is reverted (mutate the clause, not the file).' }, - { key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met in this diff (endpoint → api-conventions + regenerated v1.json/endpoint-index; screen/route → blazor-route-parity + domain-model; convention → decision record + catalog; new doc → README index); no narrative in docs; every new script or hook has its inventory row; CPM respected; both-provider migration if the model changed; tests are NUnit/vitest in the existing projects; no BOM in touched .cs; no edit to a file another slot owns (listed above); commit trailers present; Done-when boxes on the issue are evidence-backed; nothing pushed yet.' }, + { key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test actually reddens when the fix alone is reverted (mutate the clause, not the file).' }, + { key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met in this diff (endpoint → api-conventions + regenerated v1.json/endpoint-index; screen/route → blazor-route-parity + domain-model; convention → decision record + regenerated catalog; new doc → README index); no narrative in docs; every new script or hook has its inventory row; CPM respected; both-provider migration if the model changed; tests are NUnit/vitest in the existing projects; no BOM in touched .cs; no edit to a file another slot owns (listed above); commit trailers present; branch rebased on current origin/main; nothing pushed yet.' }, ] +let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)' async function codexReview(round) { + const r = await agent(`${REVIEW_COMMON} + +You run the cross-family review — the diff touches a class where process.independent-review-rubric requires a reviewer from another model family, and you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of the diff of branch ${BRANCH} against origin/main in ${WT} for issue(s) ${REF} with done condition "${args.done_condition}", listing findings as blocking / should-fix / nit with file and evidence, ending with a line VERDICT: merge or VERDICT: send-back. Run it EXACTLY like this, in the background, output to a file, stdin from /dev/null (it hangs otherwise): codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 — then wait for the process to exit (poll pgrep on its PID with Monitor; measured 2026-07-28, a real review took ~35 minutes for a 7-file diff) and read the file. Return its findings faithfully in the schema with ran=true; if the file has no VERDICT line the run failed (quota, tool error) — return ran=false, verdict merge, no findings, and put the file's tail in a single nit finding so the failure is visible; never invent a verdict.`, + { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: FINDINGS_SCHEMA }) + if (r && r.ran !== false) return r + xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window` + log(`${REF}: ${xfamily}`) return agent(`${REVIEW_COMMON} -You run the cross-family review — the diff touches a class where process.independent-review-rubric requires a reviewer from another model family, and you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of the diff of branch ${BRANCH} against origin/main in ${WT} for issue(s) ${REF} with done condition "${args.done_condition}", listing findings as blocking / should-fix / nit with file and evidence, ending with a line VERDICT: merge or VERDICT: send-back. Run it EXACTLY like this, in the background, output to a file, stdin from /dev/null (it hangs otherwise): codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 — then wait for the process to exit (poll pgrep on its PID with Monitor; a real review takes 10–40 minutes) and read the file. Return its findings faithfully in the schema; if the file has no VERDICT line the run failed — say so in a single blocking finding rather than inventing a verdict.`, - { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: FINDINGS_SCHEMA }) +You are a COLD, review-only substitute for a cross-family reviewer that could not run. You have seen none of this branch before. Lens: adversarial correctness AND security of the diff against the done condition — the classes process.independent-review-rubric names (locks/concurrency, auth/security, API write paths, migrations, large C# diffs). Run the gate in your own worktree and read the output; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`, + { label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA }) } async function review(round) { const runs = LENSES.map(l => () => agent(`${REVIEW_COMMON} @@ -159,7 +166,13 @@ Report.`, } if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history } -phase('Fix') +phase('Land') +const FINISH = `FINISH, in this order. Record the patch-id first: git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable. Then git fetch origin; if origin/main moved, rebase onto it (never merge main in; regenerate, never hand-resolve, generated artifacts — the decisions catalog by its generator), re-run the LOCAL GATE, and recompute the patch-id: report patch_changed=true if it differs. ${GATE} +Then ONE push: git push -u origin ${BRANCH}. Open the PR with the Gitea API (POST ${API}/pulls; head=${BRANCH}, base=main, title, body). The body must contain "fixes #N" for every issue in the bundle so the merge closes them, the root cause for a bug fix, the measured numbers, what the reviewers found across ${round} round(s) and how it was answered, the cross-family review status verbatim — "${xfamily}" — and every deliberately-left item with an issue number (file follow-up issues where needed). End the body with: +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +${SESSION_URL} +Arm the CI monitor: note the head sha and read ${API}/commits//status once. Then the closing-an-issue skill (invoke it through the Skill tool if you have it, otherwise read .claude/skills/closing-an-issue/SKILL.md) with two modifications: do NOT close the issue — the merge closes it — and do NOT tick any "## Done-when" box; instead the "## Closing record" comment you post on each issue, linking the PR, ends with a "Done-when evidence" list giving, for every box, the command or artifact that evidences it — the orchestrator ticks from that. Remove nothing; the orchestrator removes the worktree after the merge. Report the PR URL, the head sha and patch_changed.` const land = await agent(`${COMMON} ${WORKTREE} @@ -167,5 +180,11 @@ ${WORKTREE} You are the finisher. The branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`, { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: REPORT_SCHEMA }) if (!land || !land.done) return { issues, error: 'finisher stopped', land, history } -log(`${REF} PR: ${land.pr_url || 'none'} @ ${land.head_sha || '?'}`) -return { issues, pr_url: land.pr_url, head_sha: land.head_sha, impl, land, history } +log(`${REF} PR: ${land.pr_url || 'none'} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`) +let post_rebase_reviews = null +if (land.patch_changed) { + log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`) + round++ + post_rebase_reviews = (await review(round)).filter(Boolean) +} +return { issues, pr_url: land.pr_url, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, impl, land, history, post_rebase_reviews } diff --git a/.claude/workflows/ersatztv-pick-next.js b/.claude/workflows/ersatztv-pick-next.js index f9dfbf945..47868b706 100644 --- a/.claude/workflows/ersatztv-pick-next.js +++ b/.claude/workflows/ersatztv-pick-next.js @@ -36,7 +36,7 @@ const res = await agent(`${RULES} Walk the selector's order and return up to ${count} issues or natural bundles, in that order, each of which (a) passes the four-way claim check, (b) edits no file a taken slot OR AN EARLIER PICK edits, (c) does not depend on another open issue (an earlier pick counts as open; a blocked-by dependency the selector already dropped), (d) is not a screen, handler or script an earlier pick is already on, (e) is not needs-hands or needs-the-user in disguise (a live-prod measurement nobody can take from here, a design question the body leaves open). Read each candidate's body and comments before accepting or rejecting it. Size is not a reason to skip: a large issue at the top of the queue is a pick, say size=large. Classify risk honestly — a write-path handler is rubric even when the diff is small. Stop early if the eligible queue runs out and say so in the last pick's skipped field; fewer than ${count} is fine, a colliding pair is not.`, { label: 'picker', model: 'sonnet', effort: 'medium', schema: SCHEMA }) const picks = (res && res.picks) || [] -if (!picks.length) return { error: 'no pick' } +if (!picks.length) return { picks: [], refutations: [], note: 'the picker returned no eligible pick', raw: res } log('picks: ' + picks.map(p => '#' + p.issues.join('+#')).join(', ')) phase('Refute') const desc = picks.map(p => `- #${p.issues.join(', #')} "${p.title}" (size ${p.size}, risk ${p.risk}, area ${p.area}, e2e ${p.needs_e2e}). Rationale: ${p.rationale}. Files: ${p.files_likely.join(', ')}. Skipped: ${p.skipped}`).join('\n') diff --git a/.claude/workflows/ersatztv-resume-branch.js b/.claude/workflows/ersatztv-resume-branch.js index 74c1a8978..7b39b6ed8 100644 --- a/.claude/workflows/ersatztv-resume-branch.js +++ b/.claude/workflows/ersatztv-resume-branch.js @@ -1,11 +1,14 @@ export const meta = { name: 'ersatztv-resume-branch', description: 'Resume a paused ersatztv branch: finish or fix, rebase onto origin/main, local gate, adversarial review, fix loop, push, PR body and closing record refreshed', - phases: [{ title: 'Work' }, { title: 'Review' }, { title: 'Fix' }], + phases: [{ title: 'Work' }, { title: 'Review' }, { title: 'Fix' }, { title: 'Land' }], } // args: { issues, branch, wt, pr (number or ''), mode: 'fix'|'implement', title, risk: 'routine'|'rubric', needs_e2e, -// trailer, brief: path to a JSON file holding done_condition, findings, recon, context } +// port: the slot's ETV_UI_PORT, trailer, brief: path to a JSON file holding done_condition, findings, recon, context } +if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !args.port || !args.wt || !args.branch || !args.brief) { + return { error: 'args.issues (non-empty), trailer, port, wt, branch and brief are required' } +} const issues = args.issues const REF = issues.map(n => '#' + n).join(', ') const WT = args.wt @@ -13,7 +16,8 @@ const BRANCH = args.branch const SHARED = '/Users/timothy/ersatztv' const API = 'http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv' const rubric = args.risk === 'rubric' -const TRAILER = args.trailer || '' +const TRAILER = args.trailer +const SESSION_URL = TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n') const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth. Issue(s) ${REF}: "${args.title}". @@ -22,12 +26,12 @@ Read every issue in the bundle and all its comments: curl -s -u "$ETV_GITEA_BASI Working rules, non-negotiable: - Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key. -- Docs-update is part of done, same PR (endpoint → api-conventions + ./scripts/update-openapi.sh; screen/route → blazor-route-parity + domain-model; convention → decision record + catalog; new doc → README index). A TvContext change needs both providers' migrations via scripts/add-migration.sh. +- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a convention gets a record under docs/decisions/records// and a regenerated catalog (PYTHONPATH=. python3 scripts/build_decisions_catalog.py — the catalog is generated and shared with other slots: never hand-edit it, regenerate it, and resolve a rebase conflict in it by regenerating); a new doc updates docs/README.md. A TvContext change needs both providers' migrations via scripts/add-migration.sh. - Tests are NUnit + Shouldly + NSubstitute; vitest under web/. Never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS. Dependencies only in Directory.Packages.props. - Docs record the end state, never the investigation; the path goes in the commit message. - Gitea labels: POST ${API}/issues/{n}/labels {"labels":[100]} / DELETE ${API}/issues/{n}/labels/100; PATCH ignores labels. -- Never use bare git stash. Never push to main. The ONLY sanctioned rewrite of a pushed branch is a rebase onto origin/main pushed with --force-with-lease; a fix is a new commit, never an amend. Never cd out of the worktree except to read the shared checkout read-only. Kill only PIDs you started. -Worktree: ${WT} on branch ${BRANCH}; it exists, do ALL work inside it. Give it its own web/node_modules if missing (cp -Rc from ${SHARED}/web when the lockfiles match, else npm ci). Every commit message ends with these trailer lines exactly: +- Never use bare git stash. Never push to main. The ONLY sanctioned rewrite of a pushed branch is a rebase onto origin/main pushed with --force-with-lease (process.orchestrated-session); a fix is a new commit, never an amend. Never cd out of the worktree except to read the shared checkout read-only. Kill only PIDs you started. +Worktree: ${WT} on branch ${BRANCH}; it exists, do ALL work inside it. Give it its own web/node_modules if missing (cp -Rc from ${SHARED}/web when the lockfiles match, else npm ci). If git commit is denied by the worktree-owner guard naming another session id, the worktree belongs to this same orchestrated session: overwrite ${WT}/.claude-worktree-owner with your own session id as the guard's message says, and retry. Every commit message ends with these trailer lines exactly: ${TRAILER}` const GATE = `LOCAL GATE (process.local-gate-before-push) — inside ${WT}, real output, a skipped test is not a pass: @@ -35,15 +39,10 @@ const GATE = `LOCAL GATE (process.local-gate-before-push) — inside ${WT}, real - SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test. - scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff on touched Python. - Docs: python3 scripts/check-doc-narrative.py --diff origin/main. -- Live-E2E${args.needs_e2e ? ' IS REQUIRED (write path or UI)' : ' only for a write path or screen change'}: scripts/e2e-gate.sh scripts/e2e-local.sh with a fresh CONFIG_DIR and your own ETV_UI_PORT; curl, never a browser tab. +- Live-E2E${args.needs_e2e ? ' IS REQUIRED (write path or UI)' : ' only for a write path or screen change'}: ETV_UI_PORT=${args.port} scripts/e2e-local.sh — port ${args.port} is this slot's; one run at a time in this worktree; curl, never a browser tab; kill the PID the launcher printed when done and nothing else; a busy port is reported, never taken over. - Run the .NET and web gates sequentially; other slots are building.` -const FINISH = `FINISH: git fetch origin and rebase onto origin/main (the branch is behind; resolve conflicts faithfully, keeping both sides' intent; regenerate generated artifacts rather than hand-resolving them; a commit titled "WIP: orchestrator checkpoint" holds uncommitted work from the paused session and must be squashed into the commit it belongs to, never left in history). ${GATE} -Then git push --force-with-lease origin ${BRANCH}. ${args.pr ? `Update PR #${args.pr}'s body (PATCH ${API}/pulls/${args.pr}) so it describes the branch as it now is` : `Open a PR (POST ${API}/pulls; head=${BRANCH}, base=main)`}: the body must contain "fixes #N" for every issue in the bundle, the root cause for a bug fix, the measured numbers, what the reviewers found and how it was answered, every deliberately-left item with an issue number, and end with: -🤖 Generated with [Claude Code](https://claude.com/claude-code) - -${TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')} -Read ${API}/commits//status once to arm the CI monitor. Post or update the "## Closing record" comment on each issue (the closing-an-issue skill's template) linking the PR, without closing the issue. Tick every "## Done-when" box you can evidence; leave "Adversarial review passed" to the orchestrator. Report the PR URL and the head sha.` +const REBASE = `Rebase onto origin/main FIRST: git fetch origin; git rebase origin/main; resolve conflicts faithfully, keeping both sides' intent; regenerate generated artifacts rather than hand-resolving them. A commit titled "WIP: orchestrator checkpoint" holds uncommitted work from the paused session and must be folded into the commit it belongs to, never left in history — if it sits directly on that commit: git reset --soft HEAD~1 && git commit --amend --no-edit; otherwise: git commit --fixup= is already its shape, so GIT_SEQUENCE_EDITOR=true git rebase --autosquash ~1 folds it non-interactively.` const REPORT_SCHEMA = { type: 'object', required: ['done', 'summary', 'verified', 'left', 'commits'], @@ -51,12 +50,14 @@ const REPORT_SCHEMA = { done: { type: 'boolean' }, summary: { type: 'string' }, verified: { type: 'string', description: 'exact gate commands run and their real output summary' }, left: { type: 'string' }, commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' }, pr_url: { type: 'string' }, head_sha: { type: 'string' }, + patch_changed: { type: 'boolean', description: 'finisher only: true if a second rebase before the push changed the patch-id' }, }, } const FINDINGS_SCHEMA = { type: 'object', required: ['findings', 'verdict'], properties: { verdict: { type: 'string', enum: ['merge', 'send-back'] }, + ran: { type: 'boolean', description: 'cross-family runner only: false if codex produced no VERDICT line' }, findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: { severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } }, }, @@ -67,12 +68,12 @@ let work if (args.mode === 'implement') { work = await agent(`${COMMON} -You are the implementer, continuing a paused session. Read git log and git show for the branch's commits first; a WIP checkpoint commit is the paused implementer's partial edit. The brief's recon is a plan; verify what you rely on. Finish the done condition completely, with a regression test that reddens against the unfixed code. Rebase onto origin/main, run the LOCAL GATE and STOP without pushing; reviewers read the worktree first. ${GATE}`, +You are the implementer, continuing a paused session. Read git log and git show for the branch's commits first; a WIP checkpoint commit is the paused implementer's partial edit. ${REBASE} The brief's recon is a plan; verify what you rely on. Finish the done condition completely, with a regression test that reddens against the unfixed code. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first. ${GATE}`, { label: `impl:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA }) } else { work = await agent(`${COMMON} -You are the fixer, continuing a paused session. The PR is #${args.pr}. Rebase onto origin/main first (conflicts resolved faithfully, generated artifacts regenerated). Then the brief's findings are the last review round's: fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first. ${GATE}`, +You are the fixer, continuing a paused session. The PR is #${args.pr}. ${REBASE} Then the brief's findings are the last review round's: fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first. ${GATE}`, { label: `fix:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA }) } if (!work) return { issues, error: 'work agent returned nothing' } @@ -80,17 +81,25 @@ if (!work.done) return { issues, error: 'work agent stopped', work } const REVIEW_COMMON = `${COMMON} -Diff: git -C ${WT} diff origin/main...HEAD (rebased, not yet pushed). Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or test, do it in your own isolated worktree, sequentially.` +Diff: git -C ${WT} diff origin/main...HEAD (rebased, not yet pushed). Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or test, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the branch there; gates sequentially; E2E there on port ${Number(args.port) + 1}.` const LENSES = [ - { key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself through scripts/e2e-gate.sh, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test reddens when the fix alone is reverted.' }, - { key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met; no narrative in docs; inventory rows for new scripts/hooks; CPM respected; both-provider migration if the model changed; no BOM in touched .cs; no WIP commit left in history; branch rebased on current origin/main; commit trailers present; Done-when boxes evidence-backed; PR body will carry fixes #N for each issue.' }, + { key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test reddens when the fix alone is reverted.' }, + { key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met; no narrative in docs; inventory rows for new scripts/hooks; CPM respected; both-provider migration if the model changed; no BOM in touched .cs; no WIP commit left in history; branch rebased on current origin/main; commit trailers present; PR body will carry fixes #N for each issue.' }, ] +let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)' async function codexReview(round) { + const r = await agent(`${REVIEW_COMMON} + +You run the cross-family review required by process.independent-review-rubric; you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of branch ${BRANCH} against origin/main in ${WT} for ${REF} with done condition from the brief, findings as blocking / should-fix / nit with file and evidence, ending with VERDICT: merge or VERDICT: send-back. Run EXACTLY: codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 in the background, wait for the PID to exit (Monitor; measured 2026-07-28, ~35 minutes for a 7-file diff), read the file, return its findings faithfully with ran=true; no VERDICT line means the run failed — return ran=false, verdict merge, no findings, and the file's tail in one nit finding; never invent a verdict.`, + { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: FINDINGS_SCHEMA }) + if (r && r.ran !== false) return r + xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window` + log(`${REF}: ${xfamily}`) return agent(`${REVIEW_COMMON} -You run the cross-family review required by process.independent-review-rubric; you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of branch ${BRANCH} against origin/main in ${WT} for ${REF} with done condition from the brief, findings as blocking / should-fix / nit with file and evidence, ending with VERDICT: merge or VERDICT: send-back. Run EXACTLY: codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 in the background, wait for the PID to exit (Monitor; 10–40 minutes), read the file, return its findings faithfully; no VERDICT line means the run failed — report that as one blocking finding, never invent a verdict.`, - { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: FINDINGS_SCHEMA }) +You are a COLD, review-only substitute for a cross-family reviewer that could not run. Lens: adversarial correctness AND security of the diff against the done condition in the brief. Run the gate in your own worktree; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`, + { label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA }) } async function review(round) { const runs = LENSES.map(l => () => agent(`${REVIEW_COMMON} @@ -121,11 +130,23 @@ Re-run the LOCAL GATE and STOP without pushing. ${GATE}`, } if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history } -phase('Fix') +phase('Land') +const FINISH = `FINISH: record the patch-id (git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable); git fetch origin; if origin/main moved again, rebase onto it, re-run the LOCAL GATE, and recompute the patch-id — report patch_changed=true if it differs. ${GATE} +Then git push --force-with-lease origin ${BRANCH}. ${args.pr ? `Update PR #${args.pr}'s body (PATCH ${API}/pulls/${args.pr}) so it describes the branch as it now is` : `Open a PR (POST ${API}/pulls; head=${BRANCH}, base=main)`}: the body must contain "fixes #N" for every issue in the bundle, the root cause for a bug fix, the measured numbers, what the reviewers found across ${round} round(s) and how it was answered, the cross-family review status verbatim — "${xfamily}" — every deliberately-left item with an issue number, and end with: +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +${SESSION_URL} +Read ${API}/commits//status once to arm the CI monitor. Post or update the "## Closing record" comment on each issue (the closing-an-issue skill's template) linking the PR, without closing the issue and without ticking any "## Done-when" box; end it with a "Done-when evidence" list naming, for every box, the command or artifact that evidences it — the orchestrator ticks from that. Report the PR URL, the head sha and patch_changed.` const land = await agent(`${COMMON} You are the finisher. The rebased branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`, { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: REPORT_SCHEMA }) if (!land || !land.done) return { issues, error: 'finisher stopped', land, history } -log(`${REF} PR: ${land.pr_url || args.pr} @ ${land.head_sha || '?'}`) -return { issues, pr_url: land.pr_url || args.pr, head_sha: land.head_sha, work, land, history } +log(`${REF} PR: ${land.pr_url || args.pr} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`) +let post_rebase_reviews = null +if (land.patch_changed) { + log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`) + round++ + post_rebase_reviews = (await review(round)).filter(Boolean) +} +return { issues, pr_url: land.pr_url || args.pr, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, work, land, history, post_rebase_reviews } diff --git a/docs/README.md b/docs/README.md index 6753f0e58..22e769e82 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ doc below, or that changes which sections a task signal points to.** | Signal | Read | | --- | --- | | Session startup / "what's next" (no issue named) | `docs/handoffs/chicorytv-issue-queue.md` (standing kickoff — two concurrent tracks: orientation ‖ `scripts/select-queue.sh 5`) | -| Running SEVERAL issues in parallel under one orchestrator (the `orchestrator-prompt.md` kickoff) | `docs/handoffs/orchestration.md` — roles and sizing, one worktree per issue under `~/orca/workspaces/ersatztv/`, the landing order (gate + review before the single push; verdict via `scripts/post-review-verdict.sh`; merge through the consent hook), resuming a paused branch. The queue rules and HARD CONSTRAINTS stay in `chicorytv-issue-queue.md` | +| Running SEVERAL issues in parallel under one orchestrator (the `orchestrator-prompt.md` kickoff) | `docs/handoffs/orchestration.md` — roles and sizing, one worktree per issue under `~/orca/workspaces/ersatztv/`, the landing order (gate + review before the single push; verdict via `scripts/post-review-verdict.sh`; merge through the consent hook), resuming a paused branch; rule: `process.orchestrated-session`. The queue rules and HARD CONSTRAINTS stay in `chicorytv-issue-queue.md` | | Named-issue pickup | Skip queue selection; go straight to focused retrieval — see "Knowledge retrieval" below, then the issue body | | Adding/changing a `/api/*` endpoint | `docs/api-conventions.md` checklist + `docs/endpoint-index.md` | | Adding a ChicoryTV SPA screen | `docs/spa-conventions.md` | @@ -117,12 +117,12 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K OpenAPI tag). Do not edit by hand; regenerated by `scripts/generate-endpoint-index.py` / `scripts/update-openapi.sh`. - **`docs/handoffs/chicorytv-issue-queue.md`** — static session kickoff prompt + workflow lore. -- **`docs/handoffs/orchestration.md`** — mechanics of an orchestrated session: roles, isolation, landing a branch through the gate, resume, incidents. Workflow scripts: `.claude/workflows/ersatztv-{pick-next,issue-build,resume-branch}.js`. -- **`docs/handoffs/orchestrator-prompt.md`** — the standing prompt that starts an orchestrated session; the single-issue kickoff stays in `chicorytv-issue-queue.md`. Queue state is **live Gitea state**, retrieved each session via `scripts/select-queue.sh` — see that file's standing kickoff for the two concurrent tracks (orientation ‖ selection). ersatztv#237 is a closed, archival historical tracker (superseded by `startup.parallel-orientation` in `docs/decisions.md`) — not a live pointer. +- **`docs/handoffs/orchestration.md`** — mechanics of an orchestrated session: roles, isolation, landing a branch through the gate (`process.orchestrated-session`), resume, incidents. Workflow scripts: `.claude/workflows/ersatztv-{pick-next,issue-build,resume-branch}.js`. +- **`docs/handoffs/orchestrator-prompt.md`** — the standing prompt that starts an orchestrated session; the single-issue kickoff stays in `chicorytv-issue-queue.md`. - **`docs/defect-shapes-773.md`** — root-cause analysis of the recurring defect shapes across the whole closed-issue corpus (#773): the measured class ranking, the four families they consolidate into, the cheapest mechanical detector per class, the classes where **no** detector is plausible, diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 0d9371842..89994e5b7 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -132,6 +132,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `process.local-gate-before-push` | Run the local build/test gate and a cold-context, scoped "review only" adversarial review over the diff, fold the fixes, and only then push or open the PR. | 2026-07-21 | [link](records/process/local-gate-before-push.md) | | `process.lock-ownership-enumerate-producers` | Before trusting any "single owner / no double release / no cross-release" claim, grep the whole host project for every writer of that channel message (or acquirer of that lock) — the background scheduler/worker is the usual missing producer. | 2026-07-21 | [link](records/process/lock-ownership-enumerate-producers.md) | | `process.one-worktree-one-committing-agent` | Never run two committing agents concurrently on one worktree — give each parallel slice its own worktree branched off the feature branch and merge back. | 2026-07-21 | [link](records/process/one-worktree-one-committing-agent.md) | +| `process.orchestrated-session` | A session may run several issues at once only as an orchestrated session — an orchestrator that never picks, claims, codes or pushes; one worktree per issue under `~/orca/workspaces/ersatztv/`; the local gate and cold review inside the worktree BEFORE the single push; the referee alone ticks `## Done-when` boxes and posts the verdict. Two scopings hold across every slot — a rebase onto `origin/main` pushed with `--force-with-lease` is the ONE sanctioned rewrite of a pushed branch (the commits are unchanged, only the base moved; H11 refuses the alternative and merging main in is forbidden), and it voids the verdict, so the rebased head is re-reviewed. Live-E2E needs no machine-wide lock — the launcher's conflict is its per-worktree `wwwroot`, so slots run on distinct ports and the launcher's pre-flight refuses a busy one. | 2026-09-04 | [link](records/process/orchestrated-session.md) | | `process.parallel-session-claim` | Before starting an issue, check for an existing claim four ways — open PRs referencing it, remote branches naming it, recent comments (a claim can precede the label), and a fresh `git fetch origin main` — then claim with the `in-progress` label plus a comment. A claim prevents duplicate PICKUP, not duplicate WORK. Re-fetch `origin/main` before every push, not only at branch time. | 2026-07-21 | [link](records/process/parallel-session-claim.md) | | `process.per-agent-model-routing` | State the model tier (and effort, where the client exposes it) in the dispatch itself for every delegated agent — bounded recon → cheapest fast tier at `low`; mechanical slice against a documented contract → mid tier; judgment-heavy work → orchestrator tier; independent review → a different model family than the implementer. | 2026-07-25 | [link](records/process/per-agent-model-routing.md) | | `process.pr-routine-sequence` | Worktree off origin/main → implement → regenerate API artifacts → full local tests + cold review + live-E2E ALL before the push → push, open PR, arm the CI monitor at open → fixes after the push are follow-up commits, never amend/force-push. | 2026-07-21 | [link](records/process/pr-routine-sequence.md) | diff --git a/docs/decisions/records/process/orchestrated-session.md b/docs/decisions/records/process/orchestrated-session.md new file mode 100644 index 000000000..ec0512a9b --- /dev/null +++ b/docs/decisions/records/process/orchestrated-session.md @@ -0,0 +1,34 @@ +--- +key: process.orchestrated-session +title: 2026-09-04 — An orchestrated session runs several issue slots under one referee, and each slot is still a whole kickoff session (#907) +status: active +since: '2026-09-04' +supersedes: none +superseded-by: none +rule: A session may run several issues at once only as an orchestrated session — an orchestrator that never picks, claims, codes or pushes; one worktree per issue under `~/orca/workspaces/ersatztv/`; the local gate and cold review inside the worktree BEFORE the single push; the referee alone ticks `## Done-when` boxes and posts the verdict. Two scopings hold across every slot — a rebase onto `origin/main` pushed with `--force-with-lease` is the ONE sanctioned rewrite of a pushed branch (the commits are unchanged, only the base moved; H11 refuses the alternative and merging main in is forbidden), and it voids the verdict, so the rebased head is re-reviewed. Live-E2E needs no machine-wide lock — the launcher's conflict is its per-worktree `wwwroot`, so slots run on distinct ports and the launcher's pre-flight refuses a busy one. +signals: 'orchestrator · referee · slots · parallel issues · worktree per issue · force-with-lease · rebase voids verdict · who ticks Done-when · E2E per worktree port · paths: `docs/handoffs/orchestration.md`, `docs/handoffs/orchestrator-prompt.md`, `.claude/workflows/ersatztv-issue-build.js` · issues: #907' +mechanics: '`docs/handoffs/orchestration.md` owns roles, isolation and the landing order; the Workflow scripts under `.claude/workflows/` encode it. Box-ticking: the implementer writes the evidence per box into the `## Closing record`; the orchestrator ticks after reading it and the review evidence, then `scripts/post-review-verdict.sh`.' +--- + +The kickoff (`docs/handoffs/chicorytv-issue-queue.md`) binds a session that closes one issue. Running +three at once does not relax any of it; what it adds is a referee, and two places where the +single-session rules needed a stated scope rather than a contradiction. + +**Force-with-lease.** `process.pr-routine-sequence` forbids amending or force-pushing a pushed branch; +`release.format-as-you-touch-rebase` (H11) refuses to push a branch behind `origin/main` and forbids +merging main in. With several slots merging, a pushed branch is behind main as the normal case, so the +only path that satisfies both is a rebase whose commits are byte-for-byte the reviewed ones, pushed +with `--force-with-lease`. That is not the rewrite the first rule targets, which is a fix folded into +an already-pushed commit. The cost is the verdict: `review-verdict/h10` binds to a sha, so the rebased +head is reviewed again before a new verdict is posted. Rebase once, right after the review loop, and +`git range-diff` proves the patch did not change. + +**Who ticks the boxes.** `release.merge-consent-autogrant` derives consent from the ticked boxes, so a +box ticked by the agent whose work it certifies is self-consent. The implementer supplies the evidence +per box; the referee ticks. + +**No E2E lock.** A `mkdir` lock around `scripts/e2e-local.sh` was tried and rejected 2026-09-04: the +script is a launcher that returns with the server still running, so the lock released before the +assertions ran, and its stale-holder path double-acquired in 4 of 91 measured races. The conflict the +launcher documents is its `wwwroot` under the repo root, which is per worktree; two worktrees on two +ports do not share it. diff --git a/docs/handoffs/chicorytv-issue-queue.md b/docs/handoffs/chicorytv-issue-queue.md index a7e6d9f55..41bc4a514 100644 --- a/docs/handoffs/chicorytv-issue-queue.md +++ b/docs/handoffs/chicorytv-issue-queue.md @@ -366,6 +366,7 @@ HARD CONSTRAINTS: `CLAIM?`/`UMBRELLA?` flags. → `startup.parallel-orientation` - A session that runs several issues at once is an orchestrated session: `docs/handoffs/orchestration.md` owns its mechanics and `orchestrator-prompt.md` starts it; every slot is still bound by this file. + → `process.orchestrated-session` - Claim with `in-progress` before working — but **check for an existing claim first** (open PRs referencing the issue, remote branches naming it, comments predating the label, a fresh `git fetch`), because a label prevents duplicate *pickup*, not duplicate *work*: #649 was diff --git a/docs/handoffs/orchestration.md b/docs/handoffs/orchestration.md index 681ec308b..e7add1921 100644 --- a/docs/handoffs/orchestration.md +++ b/docs/handoffs/orchestration.md @@ -2,42 +2,52 @@ How one session closes several issues at once: an orchestrator that never implements, subagents that each own one issue in one worktree, and the repo's merge gate between every branch and `main`. -This page owns the mechanics. `orchestrator-prompt.md` is the standing prompt that starts such a -session; `chicorytv-issue-queue.md` still owns queue selection, claiming and the HARD CONSTRAINTS, -and nothing here relaxes them — an orchestrated session is several single-issue sessions run by -one referee, and each of them is bound by the whole kickoff. +This page owns the mechanics (`process.orchestrated-session`). `orchestrator-prompt.md` is the +standing prompt that starts such a session; `chicorytv-issue-queue.md` still owns queue selection, +claiming and the HARD CONSTRAINTS, and nothing here relaxes them — an orchestrated session is +several single-issue sessions run by one referee, and each of them is bound by the whole kickoff. ## Roles | Role | Model, effort | Does | |---|---|---| -| Orchestrator | Fable or Opus, high | Reads verdicts and evidence, posts the H10 verdict, merges through the consent hook, clears labels, removes worktrees, watches for collisions and hangs. Never picks, claims, codes or pushes. | +| Orchestrator | Fable or Opus, high | Reads verdicts and evidence, ticks the `## Done-when` boxes, posts the H10 verdict, merges through the consent hook, clears labels, removes worktrees, watches for collisions and hangs. Never picks, claims, codes or pushes. | | Picker + 2 refuters | sonnet, medium | Apply `scripts/select-queue.sh` and the kickoff's claim and bundle rules to live Gitea state; return up to `count` mutually non-colliding picks. `.claude/workflows/ersatztv-pick-next.js`, args `{taken, closed, notes, count}`. | | Recon (large issues) | Opus, high | Reads the docs the task-signal map names, finds the handlers, call sites, tests and guards, returns a plan. | -| Implementer | Opus high; sonnet medium for `size: small` | Claims, builds, runs the local gate, sits inside the review loop, pushes once, opens the PR, posts the `## Closing record` without closing. | -| Reviewers | correctness: Opus high, in a worktree; conformance: sonnet high; cross-family: Codex via `codex exec` for the rubric's risk classes | Run the gate themselves; `blocking` / `should-fix` / `nit`. | +| Implementer | Opus high; sonnet medium for `size: small` | Claims, builds, rebases onto `origin/main`, runs the local gate, sits inside the review loop, supplies the evidence per Done-when box. | +| Reviewers | correctness: Opus high, in its own worktree; conformance: sonnet high; cross-family: Codex via `codex exec` for the rubric's risk classes, with a cold Opus review-only fallback when Codex cannot run | Run the gate themselves; `blocking` / `should-fix` / `nit`. | | Fixer | same tier as the implementer | Answers findings; two rounds, then the orchestrator decides. | +| Finisher | sonnet, medium | The single push, the PR, the closing record. Ticks nothing. | **Sizing is a rule** (`process.per-agent-model-routing`): every launch names its model and effort. sonnet at medium for picking, refuting, small well-specified fixes and mechanical finishing; sonnet at high for conformance review; Opus at high for recon, implementation, fixing and correctness review; Fable for orchestrating and for the frontier escalations the kickoff lists, never for implementing. Cross-family review is mandatory for locks/concurrency, auth/security, API write-path handlers, DB -migrations and diffs over ~150 changed C# lines (`process.independent-review-rubric`); elsewhere a -cold same-family review is sufficient, and the picker's `risk` field is what decides. +migrations and diffs over ~150 changed C# lines (`process.independent-review-rubric`); the picker's +`risk` field decides. When Codex cannot run, the workflow substitutes a cold same-family review-only +agent and the PR body states the substitution and its reason, as that record requires. -`.claude/workflows/ersatztv-issue-build.js` runs claim → recon → implement → gate → review → fix for -one issue or bundle (args: `issues, slug, title, size, risk, area, needs_e2e, body_summary, -done_condition, files_likely, avoid, trailer`). `ersatztv-resume-branch.js` finishes or fixes a -paused branch from a JSON brief. Both are passed to the Workflow tool by `scriptPath`. +`.claude/workflows/ersatztv-issue-build.js` runs claim → recon → implement → gate → review → fix → +land for one issue or bundle (args: `issues, slug, title, size, risk, area, needs_e2e, port, +body_summary, done_condition, files_likely, avoid, trailer`; `issues`, `trailer` and `port` are +required and the script refuses without them). `ersatztv-resume-branch.js` finishes or fixes a +paused branch from a JSON brief. Both are passed to the Workflow tool inline; the tool persists the +script and returns a path for later runs. ## Isolation - One worktree per issue: `~/orca/workspaces/ersatztv/wt-`, branch `-`, cut from a - freshly fetched `origin/main` (`process.shared-tree-readonly`). Never under `/tmp` — macOS purges - it mid-session — and never the shared checkout. `git worktree add` from the session stamps the - `.claude-worktree-owner` marker the commit guard reads, so the workflow's agents may commit there - and a sibling session's may not (`process.foreign-worktree-plumbing-merge`). + freshly fetched `origin/main` (`process.shared-tree-readonly`), by absolute path. Never under + `/tmp` — macOS purges it mid-session — and never the shared checkout. +- The commit guard (`pretooluse-worktree-guard.sh`) reads a `.claude-worktree-owner` marker that + `posttooluse-worktree-marker.sh` writes on `git worktree add`. Measured 2026-09-04: a workflow + agent's `git worktree add` writes no marker (project hooks do not run for a workflow's agents), + and a `~`-prefixed path from the session writes none either, so orchestrated worktrees carry no + marker and the guard fails open inside them — acceptable, because they all belong to one session. + A sibling *session's* worktree keeps its marker and its denial + (`process.foreign-worktree-plumbing-merge`). Should a marker ever appear naming another agent of + this session, the guard's own message names the remedy and the scripts repeat it. - `web/node_modules` is per worktree: clone it from the shared checkout with `cp -Rc` when the lockfiles match, otherwise `npm ci`. The shared copy is kept current by `scripts/refresh-shared-checkout.sh` at session end. @@ -45,66 +55,74 @@ paused branch from a JSON brief. Both are passed to the Workflow tool by `script a `dotnet` build and a web build, and the cap is 3–4 concurrent builds gated on free RAM (`process.build-concurrency-limits`). The Agent hook enforces the RAM gate for agents launched by the Agent tool, not for a workflow's agents, so the orchestrator holds the count itself. -- **One live-E2E run at a time on the machine**: `scripts/e2e-local.sh` refuses concurrent runs even - on different ports, so every E2E invocation goes through `scripts/e2e-gate.sh ` — a - `mkdir` lock under `~/orca/workspaces/ersatztv/` that takes over a lock whose holder PID is dead - and releases on exit. Kill only PIDs you started (`testing.e2e-cleanup-scope-by-pid`). +- **Live-E2E runs per worktree on the slot's own port.** `scripts/e2e-local.sh` refuses concurrent + runs *within one repo root* because each run re-copies that root's `wwwroot`; across worktrees + there is nothing shared but the ports, so every slot gets a distinct `port` argument, one run at a + time inside the slot, and the launcher's pre-flight refuses a busy port and names the holder. A + busy port is reported, never taken over; kill only the PID the launcher printed to you + (`testing.e2e-cleanup-scope-by-pid`). - Reviewers create scratch only under `/private/tmp`, delete nothing outside it, and never build a - path with `..` segments. A review-only instruction is not enforcement; a reviewer that must build - gets `isolation: worktree`. + path with `..` segments. A reviewer that must build gets `isolation: worktree` and fetches the + unpushed branch into it (`git fetch && git checkout --detach FETCH_HEAD`); it + never builds in the slot's worktree. ## Landing a branch The order is the kickoff's (`process.pr-routine-sequence`, `process.local-gate-before-push`), with the review loop *inside* the worktree, before the single push: -1. **Implementer**: local gate — `dotnet build` and `dotnet test` on the touched test projects - (the full solution for anything under `ErsatzTV.Core`), `npm run check:api && npm run lint && - npm run typecheck && npm run build && npm test` under `web/` for SPA changes, `PYTHONPATH=. - python3 -m pytest scripts/tests -q` plus `ruff` for anything under `scripts/`, `.claude/`, - `.husky/` or `.gitea/`, the BOM check for touched `.cs` (`process.bom-format-detection-recipe`), - and live-E2E through the gate for a write path or UI change (`testing.live-e2e-prepush-timing`). -2. **Reviewers** read the worktree diff (`git diff origin/main...HEAD`) and run the gate again; - the fixer answers `blocking` and `should-fix` findings; the loop ends on a clean round, never on - "round one's findings are fixed". -3. **Implementer**: fetch, rebase onto `origin/main` if it moved and re-run the gate, then **one - push**, `git push -u origin `; open the PR with `fixes #N` for every issue in the bundle - and the session trailers; arm the CI monitor on the head sha; post the `## Closing record` on - each issue and tick every `## Done-when` box it can evidence, leaving the review box. +1. **Implementer**: rebase onto a fresh `origin/main`, then the local gate — `dotnet build` and + `dotnet test` on the touched test projects (the full solution for anything under + `ErsatzTV.Core`), `npm run check:api && npm run lint && npm run typecheck && npm run build && + npm test` under `web/` for SPA changes, `PYTHONPATH=. python3 -m pytest scripts/tests -q` plus + `ruff` for anything under `scripts/`, `.claude/`, `.husky/` or `.gitea/`, the BOM check for + touched `.cs` (`process.bom-format-detection-recipe`), and live-E2E on the slot's port for a + write path or UI change (`testing.live-e2e-prepush-timing`). +2. **Reviewers** read the worktree diff (`git diff origin/main...HEAD`) and run the gate in their + own worktrees; the fixer answers `blocking` and `should-fix` findings; the loop ends on a clean + round, never on "round one's findings are fixed". +3. **Finisher**: fetch; if `origin/main` moved, rebase, re-run the gate, and compare the patch-id + before and after — a changed patch (a conflict resolved, an artifact regenerated) sends the + branch through one more review round before the verdict, because the reviewed tree must be the + pushed tree. Then **one push**, `git push -u origin `; open the PR with `fixes #N` for + every issue in the bundle, the cross-family review status, and the session trailers; arm the CI + monitor on the head sha; post the `## Closing record` on each issue with the evidence for every + `## Done-when` box, ticking none. 4. **Orchestrator**: read the review evidence, not the summaries. Send the PR back for anything - that lets a route or test pass having done nothing. Re-read the head sha immediately before - posting, then `scripts/post-review-verdict.sh MERGEABLE ` naming the reviewers and - rounds; tick the review box. Wait for CI — a `cancelled` job reads as `failure` at the combined - status endpoint, so resolve it via the run's jobs (`ci.cancelled-is-not-a-verdict`). + that lets a route or test pass having done nothing. Tick each box whose evidence holds, then + re-read the head sha immediately before posting `scripts/post-review-verdict.sh MERGEABLE + ` naming the reviewers and rounds; tick the review box. Wait for CI — a `cancelled` job + reads as `failure` at the combined status endpoint, so resolve it via the run's jobs + (`ci.cancelled-is-not-a-verdict`). 5. **Merge through the Gitea merge tool with the full head sha**; the consent hook derives consent from the ticked boxes, the sha-bound verdict and green CI and auto-grants (`release.merge-consent-autogrant`). A merge that `asks` is a state the orchestrator could not derive, so derive it — never answer the prompt from memory. Then `DELETE /issues/{n}/labels/100` on each issue, `git worktree remove`, refill the slot. -A rebase changes the head sha and voids the verdict, and a fix after the push is a new commit, -never an amend (`process.pr-routine-sequence`). So rebase once, in step 3; a branch that conflicts -after its push goes back to the fixer for a rebase, another gate run and a fresh review of the -rebased head. Push a branch behind `origin/main` and the pre-push hook refuses it -(`release.format-as-you-touch-rebase`). +A rebase changes the head sha and voids the verdict; a fix after the push is a new commit, never an +amend (`process.pr-routine-sequence`). A branch that conflicts after its push goes back to the fixer +for a rebase pushed with `--force-with-lease` — the one sanctioned rewrite, scoped in +`process.orchestrated-session` — another gate run and a fresh review of the rebased head. Push a +branch behind `origin/main` and the pre-push hook refuses it (`release.format-as-you-touch-rebase`). ## Resuming a paused branch A branch with a PR whose verdict is stale (head moved), whose CI is red, or that Gitea reports unmergeable is resumed by `ersatztv-resume-branch.js` with a brief holding the done condition, the -last findings and the orchestrator's context. Its fixer rebases, re-runs the gate, sits in the same -review loop, pushes with `--force-with-lease` (the only sanctioned rewrite: the base changed, the -commits did not), and the orchestrator posts a fresh verdict for the new head. A resumed session -finishes every open branch before it picks anything new. +last findings and the orchestrator's context. Its fixer rebases first, re-runs the gate, sits in the +same review loop, and the finisher pushes with `--force-with-lease`; the orchestrator posts a fresh +verdict for the new head. A resumed session finishes every open branch before it picks anything new. ## Hangs and incidents - A `dotnet test` or E2E run with no output for ten minutes is a hang, not a slow test. Kill it by - PID, gently, so the E2E gate's trap releases the lock; re-run once before diagnosing. + the PID you started, gently; re-run once before diagnosing. - Under an API incident (`https://status.claude.com/api/v2/status.json`), mechanical finishers and probe-driven reviews run on whichever model is healthy; large implementation waits. Back off before retrying. - A workflow that returns an empty or unexpected result is read from its `journal.jsonl` before it is re-run; a resumed run replays the unchanged prefix from cache. -- Before any stop: the H12 audit, `scripts/refresh-shared-checkout.sh`, and a handoff memory naming - every open branch, its worktree, its PR and its next step. +- Before any stop: the H12 audit (`process.issue-qualification-audit`), + `scripts/refresh-shared-checkout.sh` (`session.shared-checkout-refresh`), and a handoff memory + naming every open branch, its worktree, its PR and its next step. diff --git a/docs/handoffs/orchestrator-prompt.md b/docs/handoffs/orchestrator-prompt.md index 03074db0a..fccdc7f8f 100644 --- a/docs/handoffs/orchestrator-prompt.md +++ b/docs/handoffs/orchestrator-prompt.md @@ -7,10 +7,10 @@ Way of working: - Size every subagent to its task; this is a rule, not a preference, because value per token is what the session is judged on. Picking and refuting: sonnet, medium. Recon on a large issue, implementation, fixing and correctness review: Opus at high (xhigh for a lock, threading or migration fix). Fable is for orchestrating and for the frontier escalations the kickoff lists, never for implementing. Small, well-specified fixes and mechanical finishing (rebase, push, PR body, label clearing): sonnet, medium. Conformance review: sonnet, high. Cross-family review through `codex exec` for the rubric's risk classes (locks, auth, API write paths, migrations, more than ~150 changed C# lines) — a same-family cold review is not a substitute there. State the model and effort in every launch and revisit them when an incident degrades a model. - You never pick, claim, code or push yourself. A picker (sonnet, medium; `.claude/workflows/ersatztv-pick-next.js` with the taken list as args) runs `scripts/select-queue.sh`, applies the kickoff's claim and bundle rules to live Gitea state, and two refuters try to overturn it; you accept, or take a refuter's better pick when its rule is right. Then one issue-build workflow per pick, from `.claude/workflows/ersatztv-issue-build.js`, passing the session's commit trailer as `trailer`. - Before picking anything, finish what is already open: every open non-Renovate PR and every `in-progress` issue with a branch is a paused branch, resumed through `.claude/workflows/ersatztv-resume-branch.js` from a JSON brief. -- Keep three issues in flight, each in its own worktree under `~/orca/workspaces/ersatztv/wt-` on branch `-`, cut from a fresh `origin/main`, never under `/tmp`. Refill a slot as soon as one merges. Pass every running issue's files as the avoid list so two agents never edit one file; a pick that needs a file another slot holds waits for that slot. -- Each implementer claims its own issue after the four-way check (open PRs, remote branches, comments predating the label, a fresh fetch), with the `in-progress` label and a claiming comment; runs the local gate and sits inside the two-lens review loop BEFORE its single push; opens a PR whose body says `fixes #N` for every issue in the bundle; posts the `## Closing record` on each issue without closing it; and ticks every `## Done-when` box it can evidence, leaving the review box for you. -- You referee: read the verdicts and the evidence, send a PR back for anything that lets a route or test pass having done nothing, re-read the head sha, then post the verdict with `scripts/post-review-verdict.sh MERGEABLE ` and tick the review box. Merge only through the Gitea merge tool with the full head sha, so the consent hook derives consent from the ticked boxes, the sha-bound verdict and green CI; a rebase after the verdict voids it, so the branch is rebased once, before its push. After the merge clear the `in-progress` label and remove the worktree. -- Builds are capped at three to four concurrent and gated on free RAM, and live-E2E runs one at a time on the machine through `scripts/e2e-gate.sh`. Kill only PIDs you started, gently. A `dotnet test` or E2E run silent for ten minutes is a hang. +- Keep three issues in flight, each in its own worktree under `~/orca/workspaces/ersatztv/wt-` on branch `-`, cut from a fresh `origin/main` by absolute path, never under `/tmp`, and each with its own E2E port passed as `port`. Refill a slot as soon as one merges. Pass every running issue's files as the avoid list so two agents never edit one file; a pick that needs a file another slot holds waits for that slot. +- Each implementer claims its own issue after the four-way check (open PRs, remote branches, comments predating the label, a fresh fetch), with the `in-progress` label and a claiming comment; rebases onto a fresh `origin/main`, runs the local gate and sits inside the two-lens review loop BEFORE its single push; its finisher opens a PR whose body says `fixes #N` for every issue in the bundle and states the cross-family review status, and posts the `## Closing record` on each issue with the evidence per `## Done-when` box, ticking nothing — a box ticked by the agent it certifies is self-consent. +- You referee: read the verdicts and the evidence, send a PR back for anything that lets a route or test pass having done nothing, tick each Done-when box whose evidence holds, re-read the head sha, then post the verdict with `scripts/post-review-verdict.sh MERGEABLE ` and tick the review box. Merge only through the Gitea merge tool with the full head sha, so the consent hook derives consent from the ticked boxes, the sha-bound verdict and green CI. A rebase voids the verdict: the finisher rebases once before the push and reports whether the patch changed; a changed patch gets one more review round before you post. After the merge clear the `in-progress` label and remove the worktree. +- Builds are capped at three to four concurrent and gated on free RAM; live-E2E runs one at a time per worktree on the slot's own port, and a busy port is reported, never taken over. Kill only PIDs you started, gently. A `dotnet test` or E2E run silent for ten minutes is a hang. - Reviewers never delete anything outside a scratch directory they created under `/private/tmp` and never build a path with `..` segments; the auto-mode classifier is the last line, not the first. - Under an API incident (watch `https://status.claude.com/api/v2/status.json`), move mechanical finishers and probe-driven reviews to whichever model is healthy and park large implementation until it clears; back off before retrying. -- Report to the user only what changes what they would do next: merged and closed issues, a send-back and why, a hang, an incident. Before any stop: the H12 audit, `scripts/refresh-shared-checkout.sh`, and a handoff memory naming every open branch, its worktree, its PR and its next step, so a resumed session finishes those before picking anything new. +- Report to the user only what changes what they would do next: merged and closed issues, a send-back and why, a hang, an incident. Before any stop: the H12 audit (`process.issue-qualification-audit`), `scripts/refresh-shared-checkout.sh`, and a handoff memory naming every open branch, its worktree, its PR and its next step, so a resumed session finishes those before picking anything new. diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index d05467296..ab81e72c6 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -105,7 +105,6 @@ classifications differ; otherwise the strictest applies and the Note names the e | `scripts/migration-smoke.sh` — `docker pull`, then `docker run` the same tag | `UNSAFE-KNOWN` | The same pull-then-run over a mutable tag as `security-scan.sh` above, and graded with it rather than left behind — spelled out rather than cross-referenced, because a backreference to another row's conclusion inverts silently when that row is regraded. This one deserves the grade MORE, not less — `security-scan.sh` boots a throwaway container and authorizes nothing, while this is the pre-deploy migration smoke that gates a production stack recreation. Accepted on the same bound (the run resolves against the local daemon holding the image the pull just placed) plus its own stated operator-trust gap: the resolved image id is **reported** for a human rather than compared against a prior read. | | `scripts/hook-fire-log.sh` | `N/A` | Entirely local: reads stdin and writes JSONL under the cache dir; the only `curl` in the file is in a comment. | | `scripts/e2e-local.sh` | `N/A` | No outbound call at all; readiness is a local log grep and a local port probe against a subprocess it started. | -| `scripts/e2e-gate.sh` | `N/A` | No outbound call at all; a `mkdir` lock under `~/orca/workspaces/ersatztv/` that serialises live-E2E runs across worktrees, then `exec`s its argument. | | `scripts/e2e-ui.sh` | `N/A` | Launches a local Chromium and runs specs against `http://localhost:$PORT`. | | `scripts/e2e-functional.sh` | `N/A` | Every call targets `$BASE_URL`, defaulting to `http://localhost:8409`. The one non-local-looking address, `192.0.2.1`, is TEST-NET-1 (RFC 5737) — written into the DB as a connection row precisely so it is unroutable, never dialed by the script. | diff --git a/scripts/e2e-gate.sh b/scripts/e2e-gate.sh deleted file mode 100755 index 39e0b8616..000000000 --- a/scripts/e2e-gate.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# e2e-gate.sh — run one command under the machine-wide live-E2E lock. -# -# `scripts/e2e-local.sh` (and `e2e-ui.sh`, `e2e-functional.sh`, which boot it) refuse to run -# concurrently even on different ports: each run boots an instance, and the cleanup kills only PIDs it -# started, so two overlapping runs fight over ports and the search-index probe. An orchestrated session -# (docs/handoffs/orchestration.md) runs several worktrees at once, so the serialisation lives here -# rather than in each caller's memory. -# -# Usage: scripts/e2e-gate.sh [args...] -# -# Lock: a `mkdir` on $ETV_E2E_LOCK (default ~/orca/workspaces/ersatztv/.e2e-lock). The holder writes -# its PID and cwd inside; a waiter polls, and takes over a lock whose holder PID is no longer alive. -# The lock is released by an EXIT trap, so kill a holder gently (TERM, by PID) and the trap runs; a -# KILL leaves a stale lock that the next waiter reclaims once the PID is gone. -# -# Reads no remote state; touches nothing outside the lock directory. -set -euo pipefail - -if [ $# -lt 1 ]; then - echo "usage: $0 [args...]" >&2 - exit 64 -fi - -lock="${ETV_E2E_LOCK:-$HOME/orca/workspaces/ersatztv/.e2e-lock}" -mkdir -p "$(dirname "$lock")" - -release() { rm -rf "$lock"; } - -waited=0 -while ! mkdir "$lock" 2>/dev/null; do - holder=$(cat "$lock/pid" 2>/dev/null || true) - if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then - echo "e2e-gate: holder $holder is gone; taking over the lock" >&2 - rm -rf "$lock" - continue - fi - if [ $((waited % 60)) -eq 0 ]; then - echo "e2e-gate: waiting on ${holder:-unknown} ($(cat "$lock/cwd" 2>/dev/null || echo '?')) — ${waited}s" >&2 - fi - sleep 5 - waited=$((waited + 5)) -done -trap release EXIT -printf '%s\n' "$$" > "$lock/pid" -printf '%s\n' "$PWD" > "$lock/cwd" - -"$@" -- 2.47.3 From a0203f286397d04502fdf0501c72a47bbddab711 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 4 Sep 2026 23:25:49 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix(907):=20round=20two=20=E2=80=94=20a=20g?= =?UTF-8?q?uard=20denial=20means=20a=20foreign=20worktree,=20required=20ke?= =?UTF-8?q?ys=20under=20every=20branch,=20a=20gated=20post-rebase=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker-overwrite instruction asserted an antecedent no agent can verify and, since same-session worktrees carry no marker, could only fire in another session's worktree; the scripts now stop and report. `ran` and `patch_changed` move into required keys of their own schemas so a missing field cannot read as a successful cross-family review or an unchanged patch. A blocking finding in the post-rebase round now returns an error like every other failure path. Each reviewer lens gets its own E2E port; the gate text travels with the reviewer brief. The standing prompt no longer contradicts the substitution the scripts perform; the record names patch-id, the mechanism the scripts use. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .claude/workflows/ersatztv-issue-build.js | 40 ++++++++++++------ .claude/workflows/ersatztv-resume-branch.js | 41 +++++++++++++------ .../records/process/orchestrated-session.md | 2 +- docs/handoffs/orchestration.md | 7 ++-- docs/handoffs/orchestrator-prompt.md | 2 +- 5 files changed, 61 insertions(+), 31 deletions(-) diff --git a/.claude/workflows/ersatztv-issue-build.js b/.claude/workflows/ersatztv-issue-build.js index 00b551d3a..a7bb39517 100644 --- a/.claude/workflows/ersatztv-issue-build.js +++ b/.claude/workflows/ersatztv-issue-build.js @@ -7,8 +7,8 @@ export const meta = { // args: { issues: [n,...], slug, title, body_summary, done_condition, files_likely, area, size, risk: 'routine'|'rubric', // needs_e2e, port: the slot's ETV_UI_PORT, avoid: [{issues, files}], trailer: 'Co-Authored-By: ...\nClaude-Session: ...', // effort?: 'xhigh' for lock/threading/migration work, model?: override for the implementer/fixer } -if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !args.port) { - return { error: 'args.issues (non-empty), args.trailer and args.port are required' } +if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !Number.isInteger(Number(args.port))) { + return { error: 'args.issues (non-empty), args.trailer (with a Claude-Session: line) and an integer args.port are required' } } const issues = args.issues const ISSUE = issues[0] @@ -44,7 +44,7 @@ Working rules, non-negotiable: - Never use bare git stash (the stash stack is shared across worktrees; commit WIP instead). Never push to main (it is refused server-side anyway). Never amend or force-push a pushed branch; a fix after the push is a new commit. Never cd out of your worktree except to read the shared checkout read-only. - Kill only PIDs you started; never pkill by name — other sessions run dotnet and Playwright on this machine.` -const WORKTREE = `Worktree: ${WT} on branch ${BRANCH}. Check git -C ${SHARED} worktree list; if absent: git -C ${SHARED} fetch origin && git -C ${SHARED} worktree add ${WT} -b ${BRANCH} origin/main (absolute path, as written). Then give it its own web/node_modules: if cmp -s ${SHARED}/web/package-lock.json ${WT}/web/package-lock.json then cp -Rc ${SHARED}/web/node_modules ${WT}/web/node_modules, else (cd ${WT}/web && npm ci). Do ALL work inside ${WT}. If git commit is denied by the worktree-owner guard naming another session id, the worktree was created by another agent of this same orchestrated session: overwrite ${WT}/.claude-worktree-owner with your own session id as the guard's message says, and retry. Commit as you go; every commit message ends with these trailer lines exactly: +const WORKTREE = `Worktree: ${WT} on branch ${BRANCH}. Check git -C ${SHARED} worktree list; if absent: git -C ${SHARED} fetch origin && git -C ${SHARED} worktree add ${WT} -b ${BRANCH} origin/main (absolute path, as written). Then give it its own web/node_modules: if cmp -s ${SHARED}/web/package-lock.json ${WT}/web/package-lock.json then cp -Rc ${SHARED}/web/node_modules ${WT}/web/node_modules, else (cd ${WT}/web && npm ci). Do ALL work inside ${WT}. If git commit is denied by the worktree-owner guard, the worktree belongs to ANOTHER session (orchestrated worktrees carry no marker): never overwrite the marker — STOP and report done=false with the guard's message. Commit as you go; every commit message ends with these trailer lines exactly: ${TRAILER}` const CLAIM = `CLAIM FIRST, the four-way check from the kickoff (process.parallel-session-claim), for EVERY issue in the bundle: git -C ${SHARED} fetch origin; curl the open PRs (${API}/pulls?state=open&limit=50, page until empty) for a body saying fixes/refs ${REF}; ${issues.map(n => `git -C ${SHARED} ls-remote --heads origin '*${n}*'`).join('; ')}; read each issue's comments for a claim that predates the label. If a PR, branch or comment shows another session already on ${REF} (other than this orchestrator's note, if any), STOP and report done=false with the evidence. Otherwise add the in-progress label and post a claiming comment naming branch ${BRANCH} and worktree ${WT}, on every issue in the bundle. If an issue body has no "## Done-when" section, append one (PATCH ${API}/issues/{n} with the full body): one unticked box per concrete completion criterion drawn from the issue, plus "- [ ] Adversarial review passed". The merge gate derives consent from those boxes; the orchestrator ticks them from your evidence, so write criteria that can be evidenced.` @@ -75,11 +75,21 @@ const FINDINGS_SCHEMA = { type: 'object', required: ['findings', 'verdict'], properties: { verdict: { type: 'string', enum: ['merge', 'send-back'] }, - ran: { type: 'boolean', description: 'cross-family runner only: false if codex produced no VERDICT line' }, findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: { severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } }, }, } +const RUNNER_SCHEMA = { + type: 'object', required: ['findings', 'verdict', 'ran'], + properties: { + ran: { type: 'boolean', description: 'false if codex produced no VERDICT line — required, because the fallback branches on it' }, + verdict: FINDINGS_SCHEMA.properties.verdict, findings: FINDINGS_SCHEMA.properties.findings, + }, +} +const LAND_SCHEMA = { + type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed', 'head_sha', 'pr_url']), + properties: REPORT_SCHEMA.properties, +} const RECON_SCHEMA = { type: 'object', required: ['plan', 'facts', 'risks', 'test_plan'], properties: { @@ -112,9 +122,11 @@ Report done=true with the gate output when the worktree is ready for review, wit if (!impl) return { issues, error: 'implementer returned nothing' } if (!impl.done) return { issues, error: 'implementer stopped', impl } -const REVIEW_COMMON = `${COMMON} +const reviewCommon = (e2ePort) => `${COMMON} -Worktree ${WT}, branch ${BRANCH}, not yet pushed; diff: git -C ${WT} diff origin/main...HEAD. Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or run tests, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the unpushed branch there; run the .NET and web gates sequentially — other slots are building; E2E there on port ${Number(args.port) + 1}.` +${GATE.split(String(args.port)).join(String(e2ePort))} + +Worktree ${WT}, branch ${BRANCH}, not yet pushed; diff: git -C ${WT} diff origin/main...HEAD. Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or run tests, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the unpushed branch there; run the .NET and web gates sequentially — other slots are building; E2E there on port ${e2ePort} (the GATE above is restated for that port).` const LENSES = [ { key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test actually reddens when the fix alone is reverted (mutate the clause, not the file).' }, @@ -122,20 +134,20 @@ const LENSES = [ ] let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)' async function codexReview(round) { - const r = await agent(`${REVIEW_COMMON} + const r = await agent(`${reviewCommon(Number(args.port) + 3)} -You run the cross-family review — the diff touches a class where process.independent-review-rubric requires a reviewer from another model family, and you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of the diff of branch ${BRANCH} against origin/main in ${WT} for issue(s) ${REF} with done condition "${args.done_condition}", listing findings as blocking / should-fix / nit with file and evidence, ending with a line VERDICT: merge or VERDICT: send-back. Run it EXACTLY like this, in the background, output to a file, stdin from /dev/null (it hangs otherwise): codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 — then wait for the process to exit (poll pgrep on its PID with Monitor; measured 2026-07-28, a real review took ~35 minutes for a 7-file diff) and read the file. Return its findings faithfully in the schema with ran=true; if the file has no VERDICT line the run failed (quota, tool error) — return ran=false, verdict merge, no findings, and put the file's tail in a single nit finding so the failure is visible; never invent a verdict.`, - { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: FINDINGS_SCHEMA }) - if (r && r.ran !== false) return r +You run the cross-family review — the diff touches a class where process.independent-review-rubric requires a reviewer from another model family, and you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of the diff of branch ${BRANCH} against origin/main in ${WT} for issue(s) ${REF} with done condition "${args.done_condition}", listing findings as blocking / should-fix / nit with file and evidence, ending with a line VERDICT: merge or VERDICT: send-back. Run it EXACTLY like this, in the background, output to a file, stdin from /dev/null (it hangs otherwise): codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 — then wait for the process to exit (poll pgrep on its PID with Monitor; measured 2026-07-28 in the #672 session, a real review took ~35 minutes for a 7-file diff) and read the file. Return its findings faithfully in the schema with ran=true; if the file has no VERDICT line the run failed (quota, tool error) — return ran=false, verdict merge, no findings, and put the file's tail in a single nit finding so the failure is visible; never invent a verdict.`, + { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA }) + if (r && r.ran === true) return r xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window` log(`${REF}: ${xfamily}`) - return agent(`${REVIEW_COMMON} + return agent(`${reviewCommon(Number(args.port) + 2)} You are a COLD, review-only substitute for a cross-family reviewer that could not run. You have seen none of this branch before. Lens: adversarial correctness AND security of the diff against the done condition — the classes process.independent-review-rubric names (locks/concurrency, auth/security, API write paths, migrations, large C# diffs). Run the gate in your own worktree and read the output; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`, { label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA }) } async function review(round) { - const runs = LENSES.map(l => () => agent(`${REVIEW_COMMON} + const runs = LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)} Review round ${round} of the branch for ${REF}. Lens: ${l.prompt} Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`, @@ -178,7 +190,7 @@ const land = await agent(`${COMMON} ${WORKTREE} You are the finisher. The branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`, - { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: REPORT_SCHEMA }) + { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA }) if (!land || !land.done) return { issues, error: 'finisher stopped', land, history } log(`${REF} PR: ${land.pr_url || 'none'} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`) let post_rebase_reviews = null @@ -186,5 +198,7 @@ if (land.patch_changed) { log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`) round++ post_rebase_reviews = (await review(round)).filter(Boolean) + const late = post_rebase_reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking')) + if (late.length) return { issues, error: 'blocking findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url || args.pr, head_sha: land.head_sha, blocking_remaining: late, cross_family: xfamily, history, post_rebase_reviews } } return { issues, pr_url: land.pr_url, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, impl, land, history, post_rebase_reviews } diff --git a/.claude/workflows/ersatztv-resume-branch.js b/.claude/workflows/ersatztv-resume-branch.js index 7b39b6ed8..49d4e0d82 100644 --- a/.claude/workflows/ersatztv-resume-branch.js +++ b/.claude/workflows/ersatztv-resume-branch.js @@ -6,8 +6,8 @@ export const meta = { // args: { issues, branch, wt, pr (number or ''), mode: 'fix'|'implement', title, risk: 'routine'|'rubric', needs_e2e, // port: the slot's ETV_UI_PORT, trailer, brief: path to a JSON file holding done_condition, findings, recon, context } -if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !args.port || !args.wt || !args.branch || !args.brief) { - return { error: 'args.issues (non-empty), trailer, port, wt, branch and brief are required' } +if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !Number.isInteger(Number(args.port)) || !args.wt || !args.branch || !args.brief) { + return { error: 'args.issues (non-empty), trailer (with a Claude-Session: line), an integer port, wt, branch and brief are required' } } const issues = args.issues const REF = issues.map(n => '#' + n).join(', ') @@ -31,7 +31,7 @@ Working rules, non-negotiable: - Docs record the end state, never the investigation; the path goes in the commit message. - Gitea labels: POST ${API}/issues/{n}/labels {"labels":[100]} / DELETE ${API}/issues/{n}/labels/100; PATCH ignores labels. - Never use bare git stash. Never push to main. The ONLY sanctioned rewrite of a pushed branch is a rebase onto origin/main pushed with --force-with-lease (process.orchestrated-session); a fix is a new commit, never an amend. Never cd out of the worktree except to read the shared checkout read-only. Kill only PIDs you started. -Worktree: ${WT} on branch ${BRANCH}; it exists, do ALL work inside it. Give it its own web/node_modules if missing (cp -Rc from ${SHARED}/web when the lockfiles match, else npm ci). If git commit is denied by the worktree-owner guard naming another session id, the worktree belongs to this same orchestrated session: overwrite ${WT}/.claude-worktree-owner with your own session id as the guard's message says, and retry. Every commit message ends with these trailer lines exactly: +Worktree: ${WT} on branch ${BRANCH}; it exists, do ALL work inside it. Give it its own web/node_modules if missing (cp -Rc from ${SHARED}/web when the lockfiles match, else npm ci). If git commit is denied by the worktree-owner guard, the worktree belongs to ANOTHER session (orchestrated worktrees carry no marker): never overwrite the marker — STOP and report done=false with the guard's message. Every commit message ends with these trailer lines exactly: ${TRAILER}` const GATE = `LOCAL GATE (process.local-gate-before-push) — inside ${WT}, real output, a skipped test is not a pass: @@ -57,12 +57,23 @@ const FINDINGS_SCHEMA = { type: 'object', required: ['findings', 'verdict'], properties: { verdict: { type: 'string', enum: ['merge', 'send-back'] }, - ran: { type: 'boolean', description: 'cross-family runner only: false if codex produced no VERDICT line' }, findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: { severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } }, }, } +const RUNNER_SCHEMA = { + type: 'object', required: ['findings', 'verdict', 'ran'], + properties: { + ran: { type: 'boolean', description: 'false if codex produced no VERDICT line — required, because the fallback branches on it' }, + verdict: FINDINGS_SCHEMA.properties.verdict, findings: FINDINGS_SCHEMA.properties.findings, + }, +} +const LAND_SCHEMA = { + type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed', 'head_sha', 'pr_url']), + properties: REPORT_SCHEMA.properties, +} + phase('Work') let work if (args.mode === 'implement') { @@ -79,9 +90,11 @@ You are the fixer, continuing a paused session. The PR is #${args.pr}. ${REBASE} if (!work) return { issues, error: 'work agent returned nothing' } if (!work.done) return { issues, error: 'work agent stopped', work } -const REVIEW_COMMON = `${COMMON} +const reviewCommon = (e2ePort) => `${COMMON} -Diff: git -C ${WT} diff origin/main...HEAD (rebased, not yet pushed). Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or test, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the branch there; gates sequentially; E2E there on port ${Number(args.port) + 1}.` +${GATE.split(String(args.port)).join(String(e2ePort))} + +Diff: git -C ${WT} diff origin/main...HEAD (rebased, not yet pushed). Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or test, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the branch there; gates sequentially; E2E there on port ${e2ePort} (the GATE above is restated for that port).` const LENSES = [ { key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test reddens when the fix alone is reverted.' }, @@ -89,20 +102,20 @@ const LENSES = [ ] let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)' async function codexReview(round) { - const r = await agent(`${REVIEW_COMMON} + const r = await agent(`${reviewCommon(Number(args.port) + 3)} -You run the cross-family review required by process.independent-review-rubric; you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of branch ${BRANCH} against origin/main in ${WT} for ${REF} with done condition from the brief, findings as blocking / should-fix / nit with file and evidence, ending with VERDICT: merge or VERDICT: send-back. Run EXACTLY: codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 in the background, wait for the PID to exit (Monitor; measured 2026-07-28, ~35 minutes for a 7-file diff), read the file, return its findings faithfully with ran=true; no VERDICT line means the run failed — return ran=false, verdict merge, no findings, and the file's tail in one nit finding; never invent a verdict.`, - { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: FINDINGS_SCHEMA }) - if (r && r.ran !== false) return r +You run the cross-family review required by process.independent-review-rubric; you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of branch ${BRANCH} against origin/main in ${WT} for ${REF} with done condition from the brief, findings as blocking / should-fix / nit with file and evidence, ending with VERDICT: merge or VERDICT: send-back. Run EXACTLY: codex exec -C ${WT} -s read-only "$(cat )" < /dev/null > 2>&1 in the background, wait for the PID to exit (Monitor; measured 2026-07-28 in the #672 session, ~35 minutes for a 7-file diff), read the file, return its findings faithfully with ran=true; no VERDICT line means the run failed — return ran=false, verdict merge, no findings, and the file's tail in one nit finding; never invent a verdict.`, + { label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA }) + if (r && r.ran === true) return r xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window` log(`${REF}: ${xfamily}`) - return agent(`${REVIEW_COMMON} + return agent(`${reviewCommon(Number(args.port) + 2)} You are a COLD, review-only substitute for a cross-family reviewer that could not run. Lens: adversarial correctness AND security of the diff against the done condition in the brief. Run the gate in your own worktree; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`, { label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA }) } async function review(round) { - const runs = LENSES.map(l => () => agent(`${REVIEW_COMMON} + const runs = LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)} Review round ${round} of the branch for ${REF}. Lens: ${l.prompt} Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`, @@ -140,7 +153,7 @@ Read ${API}/commits//status once to arm the CI monitor. Post or update const land = await agent(`${COMMON} You are the finisher. The rebased branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`, - { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: REPORT_SCHEMA }) + { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA }) if (!land || !land.done) return { issues, error: 'finisher stopped', land, history } log(`${REF} PR: ${land.pr_url || args.pr} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`) let post_rebase_reviews = null @@ -148,5 +161,7 @@ if (land.patch_changed) { log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`) round++ post_rebase_reviews = (await review(round)).filter(Boolean) + const late = post_rebase_reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking')) + if (late.length) return { issues, error: 'blocking findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url || args.pr, head_sha: land.head_sha, blocking_remaining: late, cross_family: xfamily, history, post_rebase_reviews } } return { issues, pr_url: land.pr_url || args.pr, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, work, land, history, post_rebase_reviews } diff --git a/docs/decisions/records/process/orchestrated-session.md b/docs/decisions/records/process/orchestrated-session.md index ec0512a9b..f7e932182 100644 --- a/docs/decisions/records/process/orchestrated-session.md +++ b/docs/decisions/records/process/orchestrated-session.md @@ -21,7 +21,7 @@ only path that satisfies both is a rebase whose commits are byte-for-byte the re with `--force-with-lease`. That is not the rewrite the first rule targets, which is a fix folded into an already-pushed commit. The cost is the verdict: `review-verdict/h10` binds to a sha, so the rebased head is reviewed again before a new verdict is posted. Rebase once, right after the review loop, and -`git range-diff` proves the patch did not change. +`git patch-id --stable` before and after proves the patch did not change. **Who ticks the boxes.** `release.merge-consent-autogrant` derives consent from the ticked boxes, so a box ticked by the agent whose work it certifies is self-consent. The implementer supplies the evidence diff --git a/docs/handoffs/orchestration.md b/docs/handoffs/orchestration.md index e7add1921..960893ac8 100644 --- a/docs/handoffs/orchestration.md +++ b/docs/handoffs/orchestration.md @@ -46,8 +46,8 @@ script and returns a path for later runs. and a `~`-prefixed path from the session writes none either, so orchestrated worktrees carry no marker and the guard fails open inside them — acceptable, because they all belong to one session. A sibling *session's* worktree keeps its marker and its denial - (`process.foreign-worktree-plumbing-merge`). Should a marker ever appear naming another agent of - this session, the guard's own message names the remedy and the scripts repeat it. + (`process.foreign-worktree-plumbing-merge`), so a denial inside an orchestrated worktree means the + worktree is foreign: the scripts stop and report it, and never overwrite the marker. - `web/node_modules` is per worktree: clone it from the shared checkout with `cp -Rc` when the lockfiles match, otherwise `npm ci`. The shared copy is kept current by `scripts/refresh-shared-checkout.sh` at session end. @@ -91,7 +91,8 @@ the review loop *inside* the worktree, before the single push: 4. **Orchestrator**: read the review evidence, not the summaries. Send the PR back for anything that lets a route or test pass having done nothing. Tick each box whose evidence holds, then re-read the head sha immediately before posting `scripts/post-review-verdict.sh MERGEABLE - ` naming the reviewers and rounds; tick the review box. Wait for CI — a `cancelled` job + ` naming the reviewers, the rounds and the workflow's returned `cross_family` status (a + Codex failure in a post-rebase round is in that return, not in the PR body); tick the review box. Wait for CI — a `cancelled` job reads as `failure` at the combined status endpoint, so resolve it via the run's jobs (`ci.cancelled-is-not-a-verdict`). 5. **Merge through the Gitea merge tool with the full head sha**; the consent hook derives consent diff --git a/docs/handoffs/orchestrator-prompt.md b/docs/handoffs/orchestrator-prompt.md index fccdc7f8f..0af2d2d13 100644 --- a/docs/handoffs/orchestrator-prompt.md +++ b/docs/handoffs/orchestrator-prompt.md @@ -4,7 +4,7 @@ Way of working: -- Size every subagent to its task; this is a rule, not a preference, because value per token is what the session is judged on. Picking and refuting: sonnet, medium. Recon on a large issue, implementation, fixing and correctness review: Opus at high (xhigh for a lock, threading or migration fix). Fable is for orchestrating and for the frontier escalations the kickoff lists, never for implementing. Small, well-specified fixes and mechanical finishing (rebase, push, PR body, label clearing): sonnet, medium. Conformance review: sonnet, high. Cross-family review through `codex exec` for the rubric's risk classes (locks, auth, API write paths, migrations, more than ~150 changed C# lines) — a same-family cold review is not a substitute there. State the model and effort in every launch and revisit them when an incident degrades a model. +- Size every subagent to its task; this is a rule, not a preference, because value per token is what the session is judged on. Picking and refuting: sonnet, medium. Recon on a large issue, implementation, fixing and correctness review: Opus at high (xhigh for a lock, threading or migration fix). Fable is for orchestrating and for the frontier escalations the kickoff lists, never for implementing. Small, well-specified fixes and mechanical finishing (rebase, push, PR body, label clearing): sonnet, medium. Conformance review: sonnet, high. Cross-family review through `codex exec` for the rubric's risk classes (locks, auth, API write paths, migrations, more than ~150 changed C# lines); when Codex cannot run, the workflow substitutes a cold same-family review-only agent and states the substitution in the PR body and its return, and your verdict note repeats it — a substitution is never silent. State the model and effort in every launch and revisit them when an incident degrades a model. - You never pick, claim, code or push yourself. A picker (sonnet, medium; `.claude/workflows/ersatztv-pick-next.js` with the taken list as args) runs `scripts/select-queue.sh`, applies the kickoff's claim and bundle rules to live Gitea state, and two refuters try to overturn it; you accept, or take a refuter's better pick when its rule is right. Then one issue-build workflow per pick, from `.claude/workflows/ersatztv-issue-build.js`, passing the session's commit trailer as `trailer`. - Before picking anything, finish what is already open: every open non-Renovate PR and every `in-progress` issue with a branch is a paused branch, resumed through `.claude/workflows/ersatztv-resume-branch.js` from a JSON brief. - Keep three issues in flight, each in its own worktree under `~/orca/workspaces/ersatztv/wt-` on branch `-`, cut from a fresh `origin/main` by absolute path, never under `/tmp`, and each with its own E2E port passed as `port`. Refill a slot as soon as one merges. Pass every running issue's files as the avoid list so two agents never edit one file; a pick that needs a file another slot holds waits for that slot. -- 2.47.3 From 181847f70a6f90a0570396c581ab41b75af9450a Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 4 Sep 2026 23:34:33 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(907):=20round=20three=20=E2=80=94=20the?= =?UTF-8?q?=20gate=20is=20a=20function=20of=20port=20and=20worktree,=20the?= =?UTF-8?q?=20port=20guard=20demands=20an=20integer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embedding the slot's gate text in the reviewer brief carried the slot worktree into the one prompt that forbids it, and a substring port substitution could rewrite a path containing the same digits; gateFor(port, where) renders each brief for its own tree and port. The port guard accepted "", null and false through Number(); it now requires a JS integer in (1024, 65000). The finisher schema requires only patch_changed, and a done report without a PR URL or head sha is an error rather than a placeholder. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .claude/workflows/ersatztv-issue-build.js | 18 ++++++++++-------- .claude/workflows/ersatztv-resume-branch.js | 16 +++++++++------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.claude/workflows/ersatztv-issue-build.js b/.claude/workflows/ersatztv-issue-build.js index a7bb39517..7018bab9a 100644 --- a/.claude/workflows/ersatztv-issue-build.js +++ b/.claude/workflows/ersatztv-issue-build.js @@ -7,8 +7,8 @@ export const meta = { // args: { issues: [n,...], slug, title, body_summary, done_condition, files_likely, area, size, risk: 'routine'|'rubric', // needs_e2e, port: the slot's ETV_UI_PORT, avoid: [{issues, files}], trailer: 'Co-Authored-By: ...\nClaude-Session: ...', // effort?: 'xhigh' for lock/threading/migration work, model?: override for the implementer/fixer } -if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !Number.isInteger(Number(args.port))) { - return { error: 'args.issues (non-empty), args.trailer (with a Claude-Session: line) and an integer args.port are required' } +if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !(Number.isInteger(args.port) && args.port > 1024 && args.port < 65000)) { + return { error: 'args.issues (non-empty), args.trailer (with a Claude-Session: line) and an integer args.port in (1024, 65000) are required' } } const issues = args.issues const ISSUE = issues[0] @@ -49,13 +49,14 @@ ${TRAILER}` const CLAIM = `CLAIM FIRST, the four-way check from the kickoff (process.parallel-session-claim), for EVERY issue in the bundle: git -C ${SHARED} fetch origin; curl the open PRs (${API}/pulls?state=open&limit=50, page until empty) for a body saying fixes/refs ${REF}; ${issues.map(n => `git -C ${SHARED} ls-remote --heads origin '*${n}*'`).join('; ')}; read each issue's comments for a claim that predates the label. If a PR, branch or comment shows another session already on ${REF} (other than this orchestrator's note, if any), STOP and report done=false with the evidence. Otherwise add the in-progress label and post a claiming comment naming branch ${BRANCH} and worktree ${WT}, on every issue in the bundle. If an issue body has no "## Done-when" section, append one (PATCH ${API}/issues/{n} with the full body): one unticked box per concrete completion criterion drawn from the issue, plus "- [ ] Adversarial review passed". The merge gate derives consent from those boxes; the orchestrator ticks them from your evidence, so write criteria that can be evidenced.` -const GATE = `LOCAL GATE (process.local-gate-before-push) — run it inside ${WT} and read the real output; a skipped test is not a passing one: +const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) — run it inside ${where} and read the real output; a skipped test is not a passing one: - .NET: dotnet build the solution, then dotnet test on every test project that covers what you touched (ErsatzTV.Tests, ErsatzTV.Core.Tests, ErsatzTV.Scanner.Tests, ErsatzTV.FFmpeg.Tests, ErsatzTV.Architecture.Tests — all of them for anything under ErsatzTV.Core). Before any push touching .cs: BOM-check the touched set with od -A n -t x1 -N 3 (efbbbf = BOM) and run bash -c 'dotnet format whitespace . --folder --verify-no-changes --include ' (process.bom-format-detection-recipe). - SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test. - scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff check and ruff format --check on any Python you touched. A new executable under scripts/ or .claude/hooks/ needs its row in docs/remote-state-inventory.md and, if it is a guard, in docs/guard-inventory.md — the suites say so. - Docs: python3 scripts/check-doc-narrative.py --diff origin/main and answer what it flags (it is advisory, the rule is not). -- Live-E2E${args.needs_e2e ? ' IS REQUIRED for this change (write path or UI)' : ' only if you changed a write path or a screen'}: ETV_UI_PORT=${args.port} scripts/e2e-local.sh — port ${args.port} is this slot's; one run at a time in this worktree; curl the endpoints, never a browser tab; when done, kill the PID the launcher printed and nothing else. The launcher's pre-flight refuses a busy port and names the holder: report that, do not pick another port and never kill the holder. +- Live-E2E${args.needs_e2e ? ' IS REQUIRED for this change (write path or UI)' : ' only if you changed a write path or a screen'}: ETV_UI_PORT=${port} scripts/e2e-local.sh — port ${port} is this slot's; one run at a time in this worktree; curl the endpoints, never a browser tab; when done, kill the PID the launcher printed and nothing else. The launcher's pre-flight refuses a busy port and names the holder: report that, do not pick another port and never kill the holder. - Builds on this Mac are capped at 3–4 concurrent and other slots are building too: run the .NET and web gates sequentially, not in parallel with each other.` +const GATE = gateFor(args.port, WT) const REPORT_SCHEMA = { type: 'object', @@ -87,7 +88,7 @@ const RUNNER_SCHEMA = { }, } const LAND_SCHEMA = { - type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed', 'head_sha', 'pr_url']), + type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed']), properties: REPORT_SCHEMA.properties, } const RECON_SCHEMA = { @@ -124,9 +125,9 @@ if (!impl.done) return { issues, error: 'implementer stopped', impl } const reviewCommon = (e2ePort) => `${COMMON} -${GATE.split(String(args.port)).join(String(e2ePort))} +${gateFor(e2ePort, 'your own isolated worktree (never ' + WT + ')')} -Worktree ${WT}, branch ${BRANCH}, not yet pushed; diff: git -C ${WT} diff origin/main...HEAD. Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or run tests, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the unpushed branch there; run the .NET and web gates sequentially — other slots are building; E2E there on port ${e2ePort} (the GATE above is restated for that port).` +Worktree ${WT}, branch ${BRANCH}, not yet pushed; diff: git -C ${WT} diff origin/main...HEAD. Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or run tests, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the unpushed branch there; run the .NET and web gates sequentially — other slots are building; E2E there on port ${e2ePort} (the GATE above is written for your worktree and that port).` const LENSES = [ { key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test actually reddens when the fix alone is reverted (mutate the clause, not the file).' }, @@ -192,6 +193,7 @@ ${WORKTREE} You are the finisher. The branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`, { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA }) if (!land || !land.done) return { issues, error: 'finisher stopped', land, history } +if (!land.pr_url || !land.head_sha) return { issues, error: 'finisher reported done without a PR URL or head sha', land, history } log(`${REF} PR: ${land.pr_url || 'none'} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`) let post_rebase_reviews = null if (land.patch_changed) { @@ -199,6 +201,6 @@ if (land.patch_changed) { round++ post_rebase_reviews = (await review(round)).filter(Boolean) const late = post_rebase_reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking')) - if (late.length) return { issues, error: 'blocking findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url || args.pr, head_sha: land.head_sha, blocking_remaining: late, cross_family: xfamily, history, post_rebase_reviews } + if (late.length) return { issues, error: 'blocking findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url, head_sha: land.head_sha, blocking_remaining: late, cross_family: xfamily, history, post_rebase_reviews } } return { issues, pr_url: land.pr_url, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, impl, land, history, post_rebase_reviews } diff --git a/.claude/workflows/ersatztv-resume-branch.js b/.claude/workflows/ersatztv-resume-branch.js index 49d4e0d82..1b8fbb65e 100644 --- a/.claude/workflows/ersatztv-resume-branch.js +++ b/.claude/workflows/ersatztv-resume-branch.js @@ -6,8 +6,8 @@ export const meta = { // args: { issues, branch, wt, pr (number or ''), mode: 'fix'|'implement', title, risk: 'routine'|'rubric', needs_e2e, // port: the slot's ETV_UI_PORT, trailer, brief: path to a JSON file holding done_condition, findings, recon, context } -if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !Number.isInteger(Number(args.port)) || !args.wt || !args.branch || !args.brief) { - return { error: 'args.issues (non-empty), trailer (with a Claude-Session: line), an integer port, wt, branch and brief are required' } +if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !(Number.isInteger(args.port) && args.port > 1024 && args.port < 65000) || !args.wt || !args.branch || !args.brief) { + return { error: 'args.issues (non-empty), trailer (with a Claude-Session: line), an integer port in (1024, 65000), wt, branch and brief are required' } } const issues = args.issues const REF = issues.map(n => '#' + n).join(', ') @@ -34,13 +34,14 @@ Working rules, non-negotiable: Worktree: ${WT} on branch ${BRANCH}; it exists, do ALL work inside it. Give it its own web/node_modules if missing (cp -Rc from ${SHARED}/web when the lockfiles match, else npm ci). If git commit is denied by the worktree-owner guard, the worktree belongs to ANOTHER session (orchestrated worktrees carry no marker): never overwrite the marker — STOP and report done=false with the guard's message. Every commit message ends with these trailer lines exactly: ${TRAILER}` -const GATE = `LOCAL GATE (process.local-gate-before-push) — inside ${WT}, real output, a skipped test is not a pass: +const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) — inside ${where}, real output, a skipped test is not a pass: - .NET: dotnet build, then dotnet test on every test project covering what the branch touches (all of them for anything under ErsatzTV.Core); BOM-check touched .cs with od -A n -t x1 -N 3 and bash -c 'dotnet format whitespace . --folder --verify-no-changes --include '. - SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test. - scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff on touched Python. - Docs: python3 scripts/check-doc-narrative.py --diff origin/main. -- Live-E2E${args.needs_e2e ? ' IS REQUIRED (write path or UI)' : ' only for a write path or screen change'}: ETV_UI_PORT=${args.port} scripts/e2e-local.sh — port ${args.port} is this slot's; one run at a time in this worktree; curl, never a browser tab; kill the PID the launcher printed when done and nothing else; a busy port is reported, never taken over. +- Live-E2E${args.needs_e2e ? ' IS REQUIRED (write path or UI)' : ' only for a write path or screen change'}: ETV_UI_PORT=${port} scripts/e2e-local.sh — port ${port} is this slot's; one run at a time in this worktree; curl, never a browser tab; kill the PID the launcher printed when done and nothing else; a busy port is reported, never taken over. - Run the .NET and web gates sequentially; other slots are building.` +const GATE = gateFor(args.port, WT) const REBASE = `Rebase onto origin/main FIRST: git fetch origin; git rebase origin/main; resolve conflicts faithfully, keeping both sides' intent; regenerate generated artifacts rather than hand-resolving them. A commit titled "WIP: orchestrator checkpoint" holds uncommitted work from the paused session and must be folded into the commit it belongs to, never left in history — if it sits directly on that commit: git reset --soft HEAD~1 && git commit --amend --no-edit; otherwise: git commit --fixup= is already its shape, so GIT_SEQUENCE_EDITOR=true git rebase --autosquash ~1 folds it non-interactively.` @@ -70,7 +71,7 @@ const RUNNER_SCHEMA = { }, } const LAND_SCHEMA = { - type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed', 'head_sha', 'pr_url']), + type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed']), properties: REPORT_SCHEMA.properties, } @@ -92,9 +93,9 @@ if (!work.done) return { issues, error: 'work agent stopped', work } const reviewCommon = (e2ePort) => `${COMMON} -${GATE.split(String(args.port)).join(String(e2ePort))} +${gateFor(e2ePort, 'your own isolated worktree (never ' + WT + ')')} -Diff: git -C ${WT} diff origin/main...HEAD (rebased, not yet pushed). Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or test, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the branch there; gates sequentially; E2E there on port ${e2ePort} (the GATE above is restated for that port).` +Diff: git -C ${WT} diff origin/main...HEAD (rebased, not yet pushed). Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or test, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the branch there; gates sequentially; E2E there on port ${e2ePort} (the GATE above is written for your worktree and that port).` const LENSES = [ { key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test reddens when the fix alone is reverted.' }, @@ -155,6 +156,7 @@ const land = await agent(`${COMMON} You are the finisher. The rebased branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`, { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA }) if (!land || !land.done) return { issues, error: 'finisher stopped', land, history } +if (!land.pr_url || !land.head_sha) return { issues, error: 'finisher reported done without a PR URL or head sha', land, history } log(`${REF} PR: ${land.pr_url || args.pr} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`) let post_rebase_reviews = null if (land.patch_changed) { -- 2.47.3 From 208ae00953e88cd0d628965575f97db688f1e0a9 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 4 Sep 2026 23:39:20 +0200 Subject: [PATCH 5/5] =?UTF-8?q?fix(907):=20round=20four=20=E2=80=94=20a=20?= =?UTF-8?q?resume=20that=20updated=20its=20existing=20PR=20is=20a=20succes?= =?UTF-8?q?s,=20and=20a=20post-push=20error=20says=20so?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .claude/workflows/ersatztv-issue-build.js | 4 ++-- .claude/workflows/ersatztv-resume-branch.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/workflows/ersatztv-issue-build.js b/.claude/workflows/ersatztv-issue-build.js index 7018bab9a..4f3eb2108 100644 --- a/.claude/workflows/ersatztv-issue-build.js +++ b/.claude/workflows/ersatztv-issue-build.js @@ -54,7 +54,7 @@ const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) - SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test. - scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff check and ruff format --check on any Python you touched. A new executable under scripts/ or .claude/hooks/ needs its row in docs/remote-state-inventory.md and, if it is a guard, in docs/guard-inventory.md — the suites say so. - Docs: python3 scripts/check-doc-narrative.py --diff origin/main and answer what it flags (it is advisory, the rule is not). -- Live-E2E${args.needs_e2e ? ' IS REQUIRED for this change (write path or UI)' : ' only if you changed a write path or a screen'}: ETV_UI_PORT=${port} scripts/e2e-local.sh — port ${port} is this slot's; one run at a time in this worktree; curl the endpoints, never a browser tab; when done, kill the PID the launcher printed and nothing else. The launcher's pre-flight refuses a busy port and names the holder: report that, do not pick another port and never kill the holder. +- Live-E2E${args.needs_e2e ? ' IS REQUIRED for this change (write path or UI)' : ' only if you changed a write path or a screen'}: ETV_UI_PORT=${port} scripts/e2e-local.sh — port ${port} is yours; one run at a time in that worktree; curl the endpoints, never a browser tab; when done, kill the PID the launcher printed and nothing else. The launcher's pre-flight refuses a busy port and names the holder: report that, do not pick another port and never kill the holder. - Builds on this Mac are capped at 3–4 concurrent and other slots are building too: run the .NET and web gates sequentially, not in parallel with each other.` const GATE = gateFor(args.port, WT) @@ -193,7 +193,7 @@ ${WORKTREE} You are the finisher. The branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`, { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA }) if (!land || !land.done) return { issues, error: 'finisher stopped', land, history } -if (!land.pr_url || !land.head_sha) return { issues, error: 'finisher reported done without a PR URL or head sha', land, history } +if (!land.pr_url || !land.head_sha) return { issues, error: 'finisher reported done without a PR URL or head sha — the branch may already be pushed; read its report before re-running', land, history } log(`${REF} PR: ${land.pr_url || 'none'} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`) let post_rebase_reviews = null if (land.patch_changed) { diff --git a/.claude/workflows/ersatztv-resume-branch.js b/.claude/workflows/ersatztv-resume-branch.js index 1b8fbb65e..e8d43ad5b 100644 --- a/.claude/workflows/ersatztv-resume-branch.js +++ b/.claude/workflows/ersatztv-resume-branch.js @@ -39,7 +39,7 @@ const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) - SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test. - scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff on touched Python. - Docs: python3 scripts/check-doc-narrative.py --diff origin/main. -- Live-E2E${args.needs_e2e ? ' IS REQUIRED (write path or UI)' : ' only for a write path or screen change'}: ETV_UI_PORT=${port} scripts/e2e-local.sh — port ${port} is this slot's; one run at a time in this worktree; curl, never a browser tab; kill the PID the launcher printed when done and nothing else; a busy port is reported, never taken over. +- Live-E2E${args.needs_e2e ? ' IS REQUIRED (write path or UI)' : ' only for a write path or screen change'}: ETV_UI_PORT=${port} scripts/e2e-local.sh — port ${port} is yours; one run at a time in that worktree; curl, never a browser tab; kill the PID the launcher printed when done and nothing else; a busy port is reported, never taken over. - Run the .NET and web gates sequentially; other slots are building.` const GATE = gateFor(args.port, WT) @@ -156,7 +156,7 @@ const land = await agent(`${COMMON} You are the finisher. The rebased branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`, { label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA }) if (!land || !land.done) return { issues, error: 'finisher stopped', land, history } -if (!land.pr_url || !land.head_sha) return { issues, error: 'finisher reported done without a PR URL or head sha', land, history } +if (!(land.pr_url || args.pr) || !land.head_sha) return { issues, error: 'finisher reported done without a PR or head sha — the branch may already be pushed; read its report before re-running', land, history } log(`${REF} PR: ${land.pr_url || args.pr} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`) let post_rebase_reviews = null if (land.patch_changed) { -- 2.47.3