feat(907): orchestrated sessions — mechanics page, standing prompt, pick/build/resume workflows #908
@@ -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/<area>/ 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 <Name>.
|
||||
- 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 <file> (efbbbf = BOM) and run bash -c 'dotnet format whitespace . --folder --verify-no-changes --include <files>' (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 <fresh CONFIG_DIR> — 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 <prompt>)" < /dev/null > <out> 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/<sha>/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 }
|
||||
@@ -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 '*<n>*'.
|
||||
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) }
|
||||
@@ -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/<area>/ 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 <files>'.
|
||||
- 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 <fresh CONFIG_DIR> — 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=<target> is already its shape, so GIT_SEQUENCE_EDITOR=true git rebase --autosquash <target>~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 <prompt>)" < /dev/null > <out> 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/<head-sha>/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 }
|
||||
@@ -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,
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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-<n>`, branch `<n>-<slug>`, 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 <worktree> <branch> && 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 <branch>`; 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 <pr> MERGEABLE
|
||||
<note>` 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.
|
||||
@@ -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-<n>` on branch `<n>-<slug>`, 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 <pr> MERGEABLE <note>` 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.
|
||||
Reference in New Issue
Block a user