diff --git a/.claude/workflows/ersatztv-issue-build.js b/.claude/workflows/ersatztv-issue-build.js new file mode 100644 index 000000000..4f3eb2108 --- /dev/null +++ b/.claude/workflows/ersatztv-issue-build.js @@ -0,0 +1,206 @@ +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' }, { title: 'Land' }], +} + +// 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(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] +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 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}". +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 (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. +- 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 (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.` + +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=${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) + +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' }, + 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'] }, + 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']), + properties: REPORT_SCHEMA.properties, +} +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' }, + }, +} + +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 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' } +if (!impl.done) return { issues, error: 'implementer stopped', impl } + +const reviewCommon = (e2ePort) => `${COMMON} + +${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 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).' }, + { 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(`${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 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(`${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(`${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.`, + { 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('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} + +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 — 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) { + 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, 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-pick-next.js b/.claude/workflows/ersatztv-pick-next.js new file mode 100644 index 000000000..47868b706 --- /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 { 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') +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..e8d43ad5b --- /dev/null +++ b/.claude/workflows/ersatztv-resume-branch.js @@ -0,0 +1,169 @@ +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' }, { title: 'Land' }], +} + +// 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(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(', ') +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 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}". +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: 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 (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, 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 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=${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) + +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'], + 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' }, + 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'] }, + 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']), + properties: REPORT_SCHEMA.properties, +} + +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. ${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} 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 reviewCommon = (e2ePort) => `${COMMON} + +${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 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.' }, + { 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(`${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 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(`${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(`${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.`, + { 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('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: LAND_SCHEMA }) +if (!land || !land.done) return { issues, error: 'finisher stopped', 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) { + 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/README.md b/docs/README.md index 9b57cbcb9..22e769e82 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; 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` | @@ -120,6 +121,8 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K 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..f7e932182 --- /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 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 +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 355793674..41bc4a514 100644 --- a/docs/handoffs/chicorytv-issue-queue.md +++ b/docs/handoffs/chicorytv-issue-queue.md @@ -364,6 +364,9 @@ 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. + → `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 new file mode 100644 index 000000000..960893ac8 --- /dev/null +++ b/docs/handoffs/orchestration.md @@ -0,0 +1,129 @@ +# 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 (`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, 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, 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`); 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 → +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`), 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`), 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. +- **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. +- **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 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**: 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. Tick each box whose evidence holds, then + re-read the head sha immediately before posting `scripts/post-review-verdict.sh MERGEABLE + ` 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 + 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; 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 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 + 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 (`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 new file mode 100644 index 000000000..0af2d2d13 --- /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); 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. +- 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 (`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.