From 9628f4294c5a48656225b571e0058962aa9a46af Mon Sep 17 00:00:00 2001 From: aperim-agent <216457062+aperim-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:30:11 +0000 Subject: [PATCH 1/3] test(governance): gates for the Bash-burn hook and the wave partitioner (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tests land before their subjects, per the R2 test-first rule. test-prefer-native-tools.mjs asserts three things about the PreToolUse hook: the repo's own contract commands still run, unbounded reads/searches are caught, and — the load-bearing one — no block ever names a tool this harness does not have. Grep and Glob do not exist here (verified 2026-07-27 by direct call), so a block recommending them would strand the agent with no action available. It also pins the fail-open contract on malformed payloads. lib/test.mjs pins wave partitioning and the DONE gate: unknown lanes coerce to single-writer, the wave cap defers rather than drops, DONE fails safe on every invalid input, and the dry-cycle requirement cannot be argued below 2. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/test-prefer-native-tools.mjs | 130 +++++++++++++++++++++ .claude/skills/orchestrate/lib/test.mjs | 50 ++++++++ 2 files changed, 180 insertions(+) create mode 100644 .claude/hooks/test-prefer-native-tools.mjs create mode 100644 .claude/skills/orchestrate/lib/test.mjs diff --git a/.claude/hooks/test-prefer-native-tools.mjs b/.claude/hooks/test-prefer-native-tools.mjs new file mode 100644 index 00000000..4df92fba --- /dev/null +++ b/.claude/hooks/test-prefer-native-tools.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * Regression test for prefer-native-tools.mjs. + * + * The hook can block every shell call in the repo, so it is gated like a safety + * control: the repo's own commands MUST keep working, and the unbounded forms + * MUST keep being caught. Run: node .claude/hooks/test-prefer-native-tools.mjs + * + * The load-bearing constraint, from the 2026-07-27 harness check: `Grep` and + * `Glob` do NOT exist here, so a block may never tell the agent to use them. + * Every block must name an action the agent can actually take — bound the + * search, or use `Read`. + */ + +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const HOOK = join(dirname(fileURLToPath(import.meta.url)), 'prefer-native-tools.mjs'); + +/** @returns {{blocked: boolean, stderr: string}} */ +function run(command, env = {}) { + const r = spawnSync('node', [HOOK], { + input: JSON.stringify({ tool_name: 'Bash', tool_input: { command } }), + encoding: 'utf8', + env: { ...process.env, ...env }, + }); + if (r.status !== 0 && r.status !== 2) throw new Error(`hook crashed (${r.status}) on: ${command}\n${r.stderr}`); + return { blocked: r.status === 2, stderr: r.stderr || '' }; +} + +const MUST_BLOCK = [ + // unbounded searches — the thing this hook exists to stop + ['rg "pattern" src', 'search with no cap'], + ['rg -n "ADR-T003" docs/', 'content search with no cap'], + ['grep -rn "invariant" docs/', 'recursive grep'], + ['grep -r TODO crates/', 'recursive grep, no path arg pattern'], + ['ag "foo" src', 'the silver searcher'], + ['find . -name "*.rs"', 'unbounded find'], + ["find . -name '*.ts'", 'unbounded find, single quotes'], + ['ls -R crates/', 'recursive ls'], + // whole-file reads — Read exists, so these always have a remedy + ['cat AGENTS.md', 'cat a file'], + ['cat crates/multiview-core/src/lib.rs', 'cat a source file'], + ['head -50 Cargo.toml', 'head a file'], + ['tail -20 CHANGELOG.md', 'tail a file'], + ["sed -n '10,40p' AGENTS.md", 'sed line range'], +]; + +const MUST_ALLOW = [ + // bounded searches + ['rg -l "pattern" src', 'names only'], + ['rg -m5 "pattern" src', 'attached max-count'], + ['rg -m 5 "pattern" src', 'detached max-count'], + ['rg --max-count=5 "pattern" src', 'long max-count'], + ['rg -c "pattern" src', 'counts'], + ['rg --files-with-matches "pattern" src', 'long names-only'], + ['rg --type rust -l "AVHWFramesContext" crates/multiview-ffmpeg', 'typed + bounded'], + ['grep -c foo file.txt', 'grep count'], + ['grep -rl TODO crates/', 'recursive but names-only'], + ['grep -q foo file.txt', 'quiet'], + // output consumed by a pipe never reaches the transcript + ['rg "pattern" src | head -n 50', 'piped into head'], + ['grep "${tarball}" checksums.txt | sha256sum -c -', 'gitleaks checksum verify (.github/workflows/gitleaks.yml)'], + ["ls docs/decisions/ | grep '^ADR-G'", 'the documented next-ADR-number idiom'], + ["find . -name '*.rs' | head -20", 'piped find'], + ['grep -rn "invariant" docs/ | wc -l', 'piped into wc'], + ['ls -R crates/ | head -n 20', 'piped recursive ls'], + // find with an action, and redirects + ['find . -name "*.tmp" -delete', 'find with an action'], + ['git diff origin/main...HEAD > /tmp/review.diff', 'redirect'], + ["cat \"$LOCK\" 2>/dev/null || echo ''", 'tick.sh lock read (has a redirect)'], + // the repo's real contract commands must never be touched + ['scripts/classify.sh', 'AGENTS.md'], + ['cargo check --workspace', 'AGENTS.md'], + ['cargo test --workspace', 'AGENTS.md'], + ['cargo fmt --all -- --check && cargo clippy --locked --workspace --all-targets -- -D warnings', 'AGENTS.md gate'], + ['npm --prefix web ci && npm --prefix web run lint && npm --prefix web run build', 'AGENTS.md web gate'], + ['git log --oneline -20', 'AGENTS.md working-efficiently'], + ['git worktree list', 'worktree-lane skill'], + ['git -C "$ROOT" worktree add --detach ".claude/worktrees/$LANE" HEAD', 'worktree-lane skill'], + ['gh pr list --state open --json number,title,statusCheckRollup,mergeable', 'orchestrate skill'], + ['node .claude/skills/orchestrate/lib/test.mjs', 'orchestrate lib'], + ['./.claude/skills/orchestrate/tick.sh', 'the scheduler entry point'], + ['ls crates/', 'plain non-recursive ls'], +]; + +let pass = 0; +const fail = []; + +for (const [cmd, why] of MUST_BLOCK) { + const { blocked, stderr } = run(cmd); + if (!blocked) { fail.push(`should BLOCK (${why}): ${cmd}`); continue; } + // every block must offer an action that exists in this harness + if (/\bGlob\b/.test(stderr) || /Use the Grep tool/.test(stderr)) { + fail.push(`block names a non-existent tool (${why}): ${cmd}\n ${stderr.split('\n')[0]}`); + continue; + } + if (!/Read tool|Cap the output|Cap it|# raw:/.test(stderr)) { + fail.push(`block offers no actionable remedy (${why}): ${cmd}`); + continue; + } + pass++; +} + +for (const [cmd, why] of MUST_ALLOW) { + const { blocked, stderr } = run(cmd); + if (blocked) { fail.push(`should ALLOW (${why}): ${cmd}\n ${stderr.split('\n')[0]}`); continue; } + pass++; +} + +// escape hatches +for (const [cmd, label] of [['# raw: rg "x" src', '# raw: prefix'], ['# raw: cat AGENTS.md', '# raw: on cat']]) { + if (run(cmd).blocked) fail.push(`escape hatch broken (${label}): ${cmd}`); else pass++; +} +if (run('cat AGENTS.md', { NATIVE_TOOL_HOOK: 'off' }).blocked) fail.push('NATIVE_TOOL_HOOK=off did not disable the hook'); else pass++; + +// fail-open contract: junk input must never block +for (const bad of ['', 'not json', '{}', '{"tool_name":"Read"}', '{"tool_name":"Bash"}', '{"tool_name":"Bash","tool_input":{}}']) { + const r = spawnSync('node', [HOOK], { input: bad, encoding: 'utf8' }); + if (r.status !== 0) fail.push(`must fail OPEN on malformed payload (exit ${r.status}): ${JSON.stringify(bad)}`); else pass++; +} + +const total = pass + fail.length; +if (fail.length) { + console.error(`\n${fail.length} FAILED of ${total}:\n`); + for (const f of fail) console.error(' ✗ ' + f); + process.exit(1); +} +console.log(`${pass}/${total} passed`); diff --git a/.claude/skills/orchestrate/lib/test.mjs b/.claude/skills/orchestrate/lib/test.mjs new file mode 100644 index 00000000..f0f2f2c7 --- /dev/null +++ b/.claude/skills/orchestrate/lib/test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { partitionWave } from './partition.mjs'; +import { evaluateDone, nextDryCycles, isMaterial } from './done-gate.mjs'; +let n=0; const ok=(m)=>{n++;console.log(' ok',m)}; +const CFG={knownLanes:['api','db','web','i18n','ui'],singleWriterLanes:['api','db'],scopedLanes:['ui'],maxWave:4}; + +// partition +let r=partitionWave([{number:3,lane:'api',blockedBy:[]},{number:1,lane:'api',blockedBy:[]}],CFG); +assert.deepEqual(r.wave,[1]); assert.match(r.deferred[0].reason,/single-writer/); ok('single-writer lane admits one, lowest number first'); + +r=partitionWave([{number:1,lane:'web',blockedBy:[]},{number:2,lane:'web',blockedBy:[]},{number:3,lane:'i18n',blockedBy:[]}],CFG); +assert.deepEqual(r.wave,[1,2,3]); ok('unbounded lanes run concurrently'); + +r=partitionWave([{number:1,lane:'api',blockedBy:[{number:9,satisfied:false}]}],CFG); +assert.equal(r.wave.length,0); assert.match(r.deferred[0].reason,/blocked by unmerged 9/); ok('unmet blocker defers'); + +r=partitionWave([{number:1,lane:'totally-new',blockedBy:[]},{number:2,lane:'totally-new',blockedBy:[]}],CFG); +assert.deepEqual(r.wave,[1]); ok('unknown lane coerces to single-writer (fail safe)'); + +r=partitionWave([{number:1,lane:'ui',scope:'a',blockedBy:[]},{number:2,lane:'ui',scope:'b',blockedBy:[]},{number:3,lane:'ui',scope:'a',blockedBy:[]}],CFG); +assert.deepEqual(r.wave,[1,2]); ok('scoped lane: one per scope'); + +r=partitionWave([{number:1,lane:'ui',blockedBy:[]},{number:2,lane:'ui',blockedBy:[]}],CFG); +assert.deepEqual(r.wave,[1]); ok('scoped lane without scope treated as single-writer'); + +r=partitionWave([1,2,3,4,5,6].map(i=>({number:i,lane:'web',blockedBy:[]})),CFG); +assert.equal(r.wave.length,4); assert.match(r.deferred[0].reason,/wave cap 4/); ok('wave cap enforced, remainder deferred not dropped'); + +r=partitionWave('nope',CFG); assert.equal(r.wave.length,0); assert.ok(r.error); ok('non-array input refuses to dispatch'); +r=partitionWave([{number:'x',lane:'web'},{number:1,lane:'web',blockedBy:[]}],CFG); +assert.deepEqual(r.wave,[1]); ok('malformed candidate skipped, not crashed'); + +// done-gate +assert.equal(evaluateDone({buildableOpenIssues:0,openOrchestratorPRs:0,consecutiveDryCycles:2}).done,true); ok('clean + K=2 => DONE'); +assert.equal(evaluateDone({buildableOpenIssues:1,openOrchestratorPRs:0,consecutiveDryCycles:9}).done,false); ok('work remaining => NOT done'); +assert.equal(evaluateDone({buildableOpenIssues:0,openOrchestratorPRs:1,consecutiveDryCycles:9}).done,false); ok('open PR => NOT done'); +assert.equal(evaluateDone({buildableOpenIssues:0,openOrchestratorPRs:0,consecutiveDryCycles:1}).done,false); ok('K not reached => NOT done'); +assert.equal(evaluateDone({buildableOpenIssues:0,openOrchestratorPRs:0,consecutiveDryCycles:1,requiredDryCycles:1}).done,false); ok('K floored at 2, cannot be argued down'); +assert.equal(evaluateDone({}).done,false); ok('missing input fails SAFE'); +assert.equal(evaluateDone({buildableOpenIssues:null,openOrchestratorPRs:0,consecutiveDryCycles:5}).done,false); ok('invalid count fails SAFE'); +let p=evaluateDone({buildableOpenIssues:0,openOrchestratorPRs:0,consecutiveDryCycles:3,blockedOnHuman:2}); +assert.equal(p.done,true); assert.equal(p.parked,true); ok('blocked-on-human => DONE but parked'); + +// materiality +assert.equal(nextDryCycles(2,[{kind:'docs-nit',confirmed:true}]),3); ok('sub-material finding does not reset K'); +assert.equal(nextDryCycles(2,[{kind:'defect',confirmed:true}]),0); ok('material finding resets K'); +assert.equal(nextDryCycles(2,[{kind:'defect',confirmed:false}]),3); ok('unconfirmed finding does not reset K'); +assert.equal(nextDryCycles(2,null),2); ok('unevaluable findings neither advance nor reset'); +assert.equal(isMaterial({kind:'security',confirmed:true}),true); ok('security is material'); +console.log(`\n${n}/${n} passed`); From cc6a3935769404b9670e9c78127b1da2d4c7cf04 Mon Sep 17 00:00:00 2001 From: aperim-agent <216457062+aperim-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:31:06 +0000 Subject: [PATCH 2/3] chore(governance): one cycle per session + a Bash-burn hook (ADR-G009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured over 7 days across 1,589 sessions: cache reads are 59.5% of true cost, and the 4% of sessions past 250 turns are 66% of spend — $0.47/session at 16-40 turns against $40.70 above 600. Cache read is prefix size x turn count, so the only lever that touches the 66% is ending the session. Instruction files are 0.4% of payload; shrinking documents cannot move this. Multiview had no Stop hook, no supervisor and no resume-directive. Its self-continuation was three lines: step 9 RESCHEDULE of the orchestrate skill and ADR-G007's Autonomy clause authorising a ScheduleWakeup-driven loop. Both are withdrawn. A cycle is now one session; tick.sh starts the next one in a fresh process under a single-owner lock, and continuity lives in GitHub. The orchestrate skill becomes short and repo-agnostic, reading orchestrate.config.json. Multiview's half — the lane map and the specific file that forces each lane serial, the class-scaled gate, the cross-vendor review and its never-merge-on-fallback rule, merge mechanics, salvage, build-cache discipline — moves to docs/runbooks/orchestrate.md rather than being deleted. Wave partitioning and the DONE gate become pure tested code: unknown lanes coerce to single-writer, and DONE fails safe on every invalid input. The Bash hook deviates from the sweep's supplied version, deliberately. That version redirected searches to Grep and Glob tools; neither exists in this harness, verified by direct call, including in the headless sessions tick.sh spawns. A block whose only remedy is an uncallable tool is worse than the burn it prevents, so searches must bound themselves (-l, -c, -m N, or | head) instead. Read does exist, so the file-read rules stand as written. Segments whose stdout is piped onward are exempt — those bytes never reach the transcript, which is why the gitleaks checksum verify still runs. Verified: 397 commands extracted from CI, the devcontainer, scripts/, xtask, web/package.json, the runbooks and agent docs; 394 pass. The 3 blocked were unbounded rg examples in working-in-this-monorepo.md, and that caller was fixed. Hook 53/53, partitioner 22/22, tick.sh guard paths exercised (STOP, DONE, live lock, stale-lock reclaim, lock release), links and inclusive language clean, cargo fmt and cargo check --workspace green. Ships disarmed: no ACTIVE sentinel, and the ready/blocked/loop-state labels do not exist yet. Arming is an explicit operator action. R2 (agent policy). No invariant touched; no test weakened; no Rust, CI, manifest or dependency file changed. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/prefer-native-tools.mjs | 221 ++++++++++++++ .claude/settings.json | 9 + .claude/skills/orchestrate/SKILL.md | 294 +++++++++---------- .claude/skills/orchestrate/lib/done-gate.mjs | 73 +++++ .claude/skills/orchestrate/lib/partition.mjs | 84 ++++++ .claude/skills/orchestrate/tick.sh | 57 ++++ .claude/workflows/README.md | 14 +- .claude/workflows/cleanup-sweep.js | 2 +- .claude/workflows/orient.js | 14 +- .claude/workflows/review-wave.js | 2 +- .claude/workflows/wave-fanout.js | 6 +- AGENTS.md | 17 ++ CLAUDE.md | 12 +- docs/decisions/ADR-G007.md | 30 +- docs/decisions/ADR-G008.md | 6 +- docs/decisions/ADR-G009.md | 149 ++++++++++ docs/decisions/README.md | 5 +- docs/development/working-in-this-monorepo.md | 24 +- docs/runbooks/codex-review.md | 7 +- docs/runbooks/memory-mcp.md | 11 +- docs/runbooks/orchestrate.md | 201 +++++++++++++ docs/standards/engineering.md | 11 +- orchestrate.config.json | 57 ++++ 23 files changed, 1092 insertions(+), 214 deletions(-) create mode 100644 .claude/hooks/prefer-native-tools.mjs create mode 100644 .claude/skills/orchestrate/lib/done-gate.mjs create mode 100644 .claude/skills/orchestrate/lib/partition.mjs create mode 100755 .claude/skills/orchestrate/tick.sh create mode 100644 docs/decisions/ADR-G009.md create mode 100644 docs/runbooks/orchestrate.md create mode 100644 orchestrate.config.json diff --git a/.claude/hooks/prefer-native-tools.mjs b/.claude/hooks/prefer-native-tools.mjs new file mode 100644 index 00000000..5b8cb986 --- /dev/null +++ b/.claude/hooks/prefer-native-tools.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * PreToolUse hook — keep file reads and searches from dumping into context. + * + * Why: measured at 3,179 Bash calls against 165 Read calls (19:1). Bash is + * 74.6% of all tool-result bytes and ~29% of request payload. Every Bash call + * is a full turn (~12s) whose output then rides in the cached prefix for every + * remaining turn of the session. cache_read is 59.5% of true spend. + * + * ADAPTED FROM THE UPSTREAM SWEEP-2 HOOK. Upstream redirected searches to the + * `Grep` and `Glob` tools. **Those two tools do not exist in this harness** — + * verified 2026-07-27 in an interactive session, and reported independently for + * subagents and for headless `claude -p`, which is exactly what tick.sh spawns. + * A block whose only remedy is a tool that cannot be called is worse than the + * burn it prevents, so the search rules bound output instead of redirecting: + * + * cat / head / tail / sed -n M,Np -> Read (Read DOES exist) + * rg / grep unbounded -> add -l, -c, -m N, or `| head -n 50` + * find / ls -R unbounded -> add `| head -n 50` + * + * A segment whose stdout is piped into something else never reaches context at + * all, so only the last segment of a pipeline is checked for the search family. + * + * Fails OPEN: any parse error, unknown shape, or unexpected exception allows + * the command. A hook that blocks legitimate work is worse than one that misses. + * + * Escape hatch: NATIVE_TOOL_HOOK=off, or prefix the command with `# raw:` when + * you genuinely need the unbounded shell form. + * + * Install in .claude/settings.json: + * "hooks": { "PreToolUse": [ { "matcher": "Bash", + * "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/prefer-native-tools.mjs\"" } ] } ] } + */ + +import { readFileSync } from 'node:fs'; + +const ALLOW = 0; +const BLOCK = 2; // exit 2 => blocked, stderr is shown to the agent + +function readStdin() { + try { + return readFileSync(0, 'utf8'); + } catch { + return ''; + } +} + +/** Split a command line into pipeline segments, ignoring separators inside quotes. */ +function segments(cmd) { + const out = []; + let buf = ''; + let quote = null; + for (let i = 0; i < cmd.length; i++) { + const c = cmd[i]; + if (quote) { + if (c === quote && cmd[i - 1] !== '\\') quote = null; + buf += c; + continue; + } + if (c === '"' || c === "'") { quote = c; buf += c; continue; } + if (c === '|' || c === ';' || c === '&') { + if (buf.trim()) out.push(buf.trim()); + buf = ''; + // consume doubled operators + while (i + 1 < cmd.length && (cmd[i + 1] === '|' || cmd[i + 1] === '&')) i++; + continue; + } + buf += c; + } + if (buf.trim()) out.push(buf.trim()); + return out; +} + +/** True when the segment reads piped stdin rather than naming files. */ +function isDownstream(cmd, seg) { + const idx = cmd.indexOf(seg); + if (idx <= 0) return false; + const before = cmd.slice(0, idx); + // last unquoted separator before this segment was a pipe + const m = before.match(/([|;&])[^|;&]*$/); + return !!m && m[1] === '|'; +} + +/** + * True when this segment's stdout is piped onward. Its bytes are consumed by + * the next stage and never enter the transcript, so output size is not our + * problem — only the tail of a pipeline reaches context. + */ +function pipesOut(cmd, seg) { + const idx = cmd.indexOf(seg); + if (idx < 0) return false; + const after = cmd.slice(idx + seg.length); + return /^\s*\|(?!\|)/.test(after); +} + +function tokenise(seg) { + return seg.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; +} + +// Flags that swallow the following token as their value. +const VALUE_FLAGS = new Set([ + '-m', '--max-count', '-A', '-B', '-C', '--after-context', '--before-context', + '--context', '-e', '--regexp', '-f', '--file', '-g', '--glob', '-t', '--type', + '--include', '--exclude', '--exclude-dir', '-d', '--color', '--colour', + '--iglob', '-M', '--max-columns', '--sort', '-name', '-iname', '-path', + '-type', '-maxdepth', '-mindepth', '-newer', '-size', '-perm', '-user', +]); + +/** Bounded search: -l/-L/-c/-q (incl. combined shorts) or an explicit -m/--max-count. */ +function isBoundedSearch(args) { + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '-m' || a === '--max-count' || a.startsWith('-m') && /^-m\d+$/.test(a)) return true; + if (/^--max-count(=|$)/.test(a)) return true; + if (/^--(files-with-matches|files-without-match|count|quiet|silent)$/.test(a)) return true; + // combined or single short flags: -l, -rl, -il, -c, -q, -L + if (/^-[A-Za-z]+$/.test(a) && /[lLcq]/.test(a.slice(1))) return true; + } + return false; +} + +/** Non-flag operands, excluding tokens consumed as flag values. */ +function operands(args) { + const out = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a.startsWith('-')) { + if (VALUE_FLAGS.has(a)) i++; // skip its value + continue; + } + out.push(a); + } + return out; +} + +function check(cmd) { + // explicit opt-out + if (/^\s*#\s*raw:/.test(cmd)) return null; + + for (const seg of segments(cmd)) { + const t = tokenise(seg); + if (!t.length) continue; + + // strip env assignments and common prefixes + let k = 0; + while (k < t.length && (/^[A-Z_][A-Z0-9_]*=/.test(t[k]) || ['sudo', 'command', 'nice', 'time'].includes(t[k]))) k++; + const bin = (t[k] || '').replace(/^.*\//, ''); + const args = t.slice(k + 1); + const rest = args.join(' '); + + // never touch write/heredoc forms or remote execution + if (/[><]|<<|ssh\s|docker\s|kubectl\s/.test(seg)) continue; + // downstream of a pipe: reading stdin, which is correct usage + if (isDownstream(cmd, seg)) continue; + + const namesFile = args.some((a) => !a.startsWith('-') && /[./]|\.\w+$/.test(a)); + // a segment feeding another command never lands in context + const consumed = pipesOut(cmd, seg); + + const CAP = 'Cap the output: add -l (names only), -c (count), -m N (first N matches), or pipe into `| head -n 50`.'; + + if (bin === 'cat' && namesFile) { + return { + bin, + use: 'Read', + why: 'cat dumps the whole file into context permanently. Read takes offset/limit and is bounded.', + }; + } + if ((bin === 'head' || bin === 'tail') && namesFile) { + return { bin, use: 'Read', why: 'Read with offset/limit does this without a shell round-trip.' }; + } + if (bin === 'sed' && /-n\s*['"]?\d+\s*,\s*\d+p/.test(rest) && namesFile) { + return { bin, use: 'Read', why: 'Reading a line range is exactly Read offset/limit.' }; + } + + // ---- search family: bound it, do not redirect it (no Grep/Glob here) ---- + if (consumed) continue; + + if (bin === 'grep' || bin === 'egrep' || bin === 'fgrep' || bin === 'rg' || bin === 'ag' || bin === 'ack') { + const recursive = /(^|\s)(-[A-Za-z]*[rR][A-Za-z]*|--recursive)(\s|$)/.test(rest); + const searchesPath = namesFile || operands(args).length >= 2 || recursive; + if (searchesPath && !isBoundedSearch(args)) { + return { bin, use: null, why: `An unbounded search dumps every match into context for the rest of the session. ${CAP}` }; + } + continue; + } + if (bin === 'find' && !/-delete|-exec|-execdir|-ok|-quit/.test(rest)) { + return { bin, use: null, why: 'find for discovery returns unbounded paths. Cap it: pipe into `| head -n 50`, or add -maxdepth and a -quit/-exec action.' }; + } + if (bin === 'ls' && /(^|\s)(-[A-Za-z]*R[A-Za-z]*|--recursive)(\s|$)/.test(rest)) { + return { bin, use: null, why: 'Recursive ls enumerates the whole tree into context. Cap it: pipe into `| head -n 50`.' }; + } + } + return null; +} + +try { + if (process.env.NATIVE_TOOL_HOOK === 'off') process.exit(ALLOW); + + const raw = readStdin(); + if (!raw.trim()) process.exit(ALLOW); + + let payload; + try { payload = JSON.parse(raw); } catch { process.exit(ALLOW); } + + if (payload?.tool_name !== 'Bash') process.exit(ALLOW); + const cmd = payload?.tool_input?.command; + if (typeof cmd !== 'string' || !cmd.trim()) process.exit(ALLOW); + + const hit = check(cmd); + if (!hit) process.exit(ALLOW); + + process.stderr.write( + `Blocked: \`${hit.bin}\` via Bash.` + (hit.use ? ` Use the ${hit.use} tool instead.\n` : '\n') + + `${hit.why}\n` + + `If you genuinely need the unbounded shell form, prefix the command with "# raw:".\n` + ); + process.exit(BLOCK); +} catch { + process.exit(ALLOW); // fail open, always +} diff --git a/.claude/settings.json b/.claude/settings.json index 145350ee..4d6e12eb 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -42,6 +42,15 @@ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-worktree.mjs\"" } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/prefer-native-tools.mjs\"" + } + ] } ] } diff --git a/.claude/skills/orchestrate/SKILL.md b/.claude/skills/orchestrate/SKILL.md index 51a5731b..4890a147 100644 --- a/.claude/skills/orchestrate/SKILL.md +++ b/.claude/skills/orchestrate/SKILL.md @@ -1,164 +1,140 @@ --- name: orchestrate -description: Run the single-orchestrator "Conductor" loop — plan a wave of dependency-ready work, fan it out across disjoint file territories via workflows and agent teams, integrate as the sole integrator, gate each diff through cross-vendor (Codex) review, merge, clean up, record, and reschedule. Use when driving the Multiview backlog forward as one orchestrator instead of many independent terminals (ADR-G007). +description: Run one autonomous delivery cycle — pick up what needs doing, dispatch it in parallel, merge what is ready, and record the result. Use when asked to orchestrate, run the loop, run a delivery cycle, work the backlog, or continue autonomous development. Repo-agnostic; reads orchestrate.config.json for local specifics. --- -# The Conductor loop - -One long-lived orchestrator session owns the work loop (ADR-G007). It is the **sole -board writer, sole integrator, sole `memory` client, and owns every PR to merge + -cleanup**. It fans out broadly but never lets two concurrent lanes touch the same file -territory. The *why* is [ADR-G007](../../../docs/decisions/ADR-G007.md); this skill is -the *how*. Lane mechanics are the [`worktree-lane`](../worktree-lane/SKILL.md) skill; -recall/record is the [`memory`](../memory/SKILL.md) skill. - -> **Golden rule:** collisions can't surface at merge if colliding lanes are never -> *assigned* at once. Disjoint territory per concurrent lane; **hot shared files are -> serial** (one owner). Everything else follows. - -## One iteration (a "wave") - -### ① PLAN -- Recall: `qdrant-find` the topic; read `docs/development/work-schedule.md` (Part 2 - checklist + Part 3 items; 400 KB — **search with `rg`, never read whole**) and - `gh pr list --state open`. -- Pick the next set of **dependency-ready** items (`deps:` satisfied, status `[ ]`/`[~]`), - each mappable to **one disjoint territory** (see table below). Cap the wave at the - concurrency you can integrate + review well (start ~3–5 lanes). -- Any item touching a **serial hot file** (`pipeline.rs`, `engine/{runtime,clock,drive}.rs`, - `control/{routes/mod,openapi,state}.rs`) goes to that file's single owner lane; other - lanes in the wave file the body and hand wiring to the owner. - -### ② ASSIGN -- Record each lane as `territory → item(s) → owner → class` on the board (you are the - only writer). The class comes from `scripts/classify.sh` and decides which gates the - lane owes; it is a floor you may raise, never lower. Note the **authoring vendor** per - lane so REVIEW can pick a different one. - -### ③ FAN OUT — two modes -- **Workflow mode** (`Workflow` tool; scripts in [`.claude/workflows/`](../../workflows/)): - for sub-steps that are themselves decompose→verify→synthesize fan-outs. Reusable named - workflows: `orient`, `wave-fanout`, `review-wave`, `cleanup-sweep`. Each agent that - mutates files runs `isolation: 'worktree'`. -- **Team mode** (background `Agent` + shared `TaskList` + `SendMessage`): for lane-length - stateful implementation. Each teammate owns exactly one territory's worktree, commits - the failing test first (required at R2/R3, and at R1 for a behavioural change), and - returns its commit SHA(s). -- **Every lane bases on current HEAD.** If a pre-existing lane is on a stale - base (common — every in-flight lane on 2026-06-16 was), **rebase onto current `main` - before integrating** or cherry-picks conflict. - -### ④ INTEGRATE (sole integrator) -- `git log origin/main..` to find **all** of a lane's commits; cherry-pick as - **individual single commits**, not ranges. -- **Rebuild from a clean, isolated `target/` before trusting green** — a shared cache can - link a sibling's stale artifacts and fake a pass. Never set `CARGO_TARGET_DIR` to `/tmp` - (operator directive: per-lane `/tmp` targets once filled the disk with terabytes). -- **The local gate is class-scaled** (`scripts/classify.sh`; - [engineering.md](../../../docs/standards/engineering.md) Part A). R0/R1 lean on CI plus - the focused crate suite (`cargo test -p multiview-`); **R2/R3 run the full gate - before the PR**: `cargo fmt --all -- --check`, - `cargo clippy --locked --workspace --all-targets -- -D warnings`, - `cargo test --locked --workspace`, `cargo deny check` if deps changed (+ `web/` - lint/typecheck/build if `web/` changed). - -### ⑤ REVIEW — adversarial, cross-vendor, fresh context -- **Depth is the class; the gate itself is fail-closed.** R0 skips review entirely. R1 - gets a single **diff-only** pass. R2 gets a scoped-context cross-vendor pass. R3 keeps - the **3-lens panel**. Above R0 the review is mandatory, is never self-performed by the - authoring vendor, and no lane merges without it. -- Dispatch the lane's diff to a **different vendor** than authored it, seeing **only** - diff + spec/PLAN + the checklist — never the author's chat history. Default here: - Claude-authored → **Codex** reviews. Invocation pattern: - - ```bash - git diff origin/main... > /tmp/review.diff - codex exec --sandbox read-only \ - "Adversarial review for correctness/security/spec/guardrail defects ONLY (see \ - docs/development/agent-guardrails.md §C). Here is the diff and the item spec. \ - Report concrete defects with file:line; if none, name the single highest-residual \ - risk. Do not comment on style." < /tmp/review.diff - ``` -- **Codex must be authenticated** or `codex exec` fails 401 and `review-wave` records a - labeled `claude-fallback` (fresh-context Claude, **not** cross-vendor). **Never merge on - a fallback verdict** — hold the PR until Codex auth lands. Setup + verify: - [codex-review runbook](../../../docs/runbooks/codex-review.md). -- Require **≥1 substantive risk statement** — unanimous bland approval is a yellow flag. - A finding blocks the merge when it carries a reproduction, a failing test, or a cited - line demonstrably violating a stated invariant; unreproducible findings are advisory - ([engineering.md](../../../docs/standards/engineering.md) Part D). Never argue a - reproduced finding away, and re-review only **the delta** after a fix. -- **R3 diffs** (invariant #1/#10 risk, keys, trust boundaries — authn/authz/egress/TLS, - licence enforcement, legal or public action, release publishing) → **3-lens panel**, - chaos/soak, a rehearsed recovery path, and explicit operator approval. - -### ⑥ MERGE (ADR-G005) -- Merge only on **green deterministic checks** + a passing cross-vendor review. Report - failures and skips honestly — a green summary over a skipped suite is a defect. Never - `--admin`/bypass branch protection; never weaken, skip, `#[ignore]` or delete a test to - go green — stop and ask the operator instead. - -### ⑦ CLEAN -- `git worktree remove` the lane (+ `git worktree prune`), delete its branch, then - `git fetch origin && git pull --ff-only origin main` in the root so the **next wave - bases on current HEAD**. Never force-remove a `locked` worktree of a *live* session; - if a `locked` worktree's owning pid is dead, salvage its WIP to a `salvage/*` branch - first (see Salvage below). - -### ⑧ RECORD -- Flip the board checkbox + set the Part-3 Status; add the red→green commit SHAs + PR - number inline. `qdrant-store` every non-obvious decision, operator correction, and - hard-won gotcha — proactively, not on request. Write/refresh the resource runbook in - the **same** change that provisioned or altered the infrastructure. An ADR when the - decision constrains future changes (required at R3), not for every wave. - -### ⑨ RESCHEDULE -- `ScheduleWakeup` the next iteration (fully self-paced per operator directive - 2026-06-16). Keep the agenda durable on the board so a fresh wake can resume it. - Operator can interrupt at any time and retains override (ADR-G005). - -## Territory map (disjoint; refines work-schedule.md §1c) - -Serial **one-owner-only** territories (never two concurrent lanes here): -- **LANE-CORE** — `multiview-cli/src/{pipeline,sink,run,control}.rs`, - `multiview-engine/src/{runtime,drive,clock}.rs`, `multiview-events/src/event.rs`, - `multiview-config/src/schema.rs`. -- **LANE-API** — `multiview-control/src/{routes/mod,openapi,openapi_schemas,asyncapi,state,lib}.rs`, - `docs/api/openapi.json`, auth/session/RBAC. - -One-owner territories (parallelizable across the wave): -LANE-WRTC (`multiview-webrtc/**`, `preview/src/whep*`, `control/routes/{whip,whep_serve}.rs`) -· LANE-IN (`multiview-input/**`, `multiview-rist-sys/**`) · LANE-PRV (`multiview-preview/**` -minus WHEP transport) · LANE-ENG (`multiview-engine/**` minus runtime/clock/drive; -`hal/src/load.rs`) · LANE-GPU (`multiview-compositor/**`, `multiview-framestore/**`, -`multiview-ffmpeg/**`, `hal/src/select.rs`) · LANE-AUDIO (`multiview-audio/**`, -`multiview-overlay/**`) · LANE-BCAST (`control/src/{nmos,is07}*`, `multiview-output/**`) -· LANE-WEB (`web/**`) · LANE-DEVICES (zowietek/display-kms/sync/cast/node-enroll, -`deploy/**`) · LANE-CONSPECT (`multiview-licence/**`, `multiview-mesh/**`) · -LANE-GOV (`.claude/**`, `docs/{decisions,research,runbooks}`, `.github/workflows/**`). - -When two items genuinely need the same territory in one wave, **serialize them under one -owner** — do not split the territory. - -## Salvage (orphaned `locked` lane with a dead owning pid) - -```bash -# work is preserved as a readable, recoverable branch before the worktree is removed -git -C add -A && git -C commit -m "wip(salvage): — recovered by Conductor" -git branch salvage/ $(git -C rev-parse HEAD) -git worktree unlock && git worktree remove --force +# Orchestrate + +One cycle. Then **stop and exit**. + +## The rule everything else serves + +**Continuity comes from GitHub, never from this context window.** You do one cycle and end the session. A scheduler starts the next one clean. + +You must never: loop back to the top in-context; call `ScheduleWakeup` to continue yourself; return a "block" decision from a Stop hook; or keep working because it feels unfinished. Those all keep one session alive across thousands of turns, and every turn re-sends the whole transcript. That pattern is 4% of sessions and 66% of spend. + +If the cycle ends with work outstanding, that is the correct outcome. Say so and exit. + +## Config + +Read `orchestrate.config.json` from the repo root. If absent, stop and ask for one — do not guess lanes. + +```json +{ + "readyLabel": "status: ready", + "blockedLabel": "status: blocked", + "knownLanes": ["api", "db", "web", "ui", "docs", "infra"], + "singleWriterLanes": ["api", "db", "infra"], + "scopedLanes": ["ui"], + "maxWave": 6, + "maxOpenPRs": 3, + "maxFilePerSweep": 10, + "requiredDryCycles": 2, + "commands": { "bootstrap": "./scripts/bootstrap", "verify": "./scripts/verify", "test": "./scripts/test" }, + "dimensions": ["spec-parity", "defects", "tests", "docs", "as-built", "security", "observability", "ux"] +} +``` + +## The cycle + +### 1. Orient — bounded, and from ground truth only + +Ground truth is `gh`, `git` and CI. Never a status document, never memory, never a previous cycle's say-so. + +```sh +gh issue list --state open --label "" --limit 100 --json number,title,labels,assignees +gh pr list --state open --author "@me" --json number,title,statusCheckRollup,mergeable +``` + +Read the loop-state issue (labelled `loop-state`) for `consecutiveDryCycles` and the ledger. **Read it by number**, not by search — list endpoints lag. + +If a live owner holds the lease (recent `heartbeatAt`, different `ownerRunId`), **exit now**. Two orchestrators is worse than none. + +Keep this step small. Use `--json` with named fields, never bare `gh` output. Never `cat` a file you could `Read`. + +### 2. Close — serial, single Closer + +You are the only thing that merges. Builders never merge; that rule exists because two PRs that were green alone landed red together. + +For each PR reporting READY, one at a time: rebase onto fresh `main` → wait for a **fresh** CI pass on the rebased head → merge → delete branch → `git worktree prune`. + +`Closes #n` must be plain text, never backticked, or GitHub ignores it. Never `gh pr merge --auto` before green — it merges immediately. `cancelled` is not a passing verdict. + +### 3. Dispatch — parallel build, disjoint lanes + +Classify each candidate into `{ number, lane, scope, blockedBy: [{ number, satisfied }] }`. Verify each blocker with `gh issue view --json state` and `gh pr list --search ""`; do not trust the label. + +Then partition **in code**, not by judgement: + +```sh +node .claude/skills/orchestrate/lib/partition.mjs # via a tiny driver, or import it +``` + +Dispatch the returned wave with the Workflow tool: one agent per issue, `isolation: "worktree"`, a `schema` on every agent, and model per lane — cheap tier for mechanical lanes, top tier for anything on `singleWriterLanes` or `unknown`. + +Every agent schema must cap its output. Require `file:line` evidence and **forbid file bodies in the return value**. N parallel agents returning file dumps is how the orchestrator's own context explodes. + +Builders take an issue to CI-green and report READY. They do not merge. + +Respect `maxOpenPRs`. If the cap is reached, skip dispatch this cycle and go to step 5. + +### 4. Discover — only when the frontier is empty + +An empty backlog is a trigger to look harder, not a reason to stop. Run one sweep across `dimensions`, one agent per dimension, in parallel. Each returns findings as `{ kind, confirmed, title, evidence }`. + +The generic dimensions, which is what "complete" means here: does it do what was designed (`spec-parity`); is it broken (`defects`); is it tested (`tests`); is it documented (`docs`); do the docs match what was actually built (`as-built`); is it safe (`security`); can you see it running (`observability`); is it decent to use (`ux`). + +File at most `maxFilePerSweep`. Anything beyond the cap goes into the cycle report as deferred with its title — **never silently dropped**; the next cycle files it. + +Before creating an issue, append `{ key, title, status: "reserving" }` to the loop-state ledger and save. Then create. Then mark `created` with the number. `gh issue list` lags creates by around a minute, and a naive re-check duplicates epics. + +**Materiality floor.** A finding only resets the dry-cycle counter if it is a real defect, a security or privacy problem, data loss, a missing designed feature, a wrong document, or something a user would notice. Nits get filed as chores and **do not** reset the counter. Without this floor a thorough reviewer always finds something and the loop can never converge. + +### 5. Record + +Update the loop-state issue: `ownerRunId`, `heartbeatAt`, `consecutiveDryCycles` (via `nextDryCycles`), the ledger, and a one-paragraph cycle summary. This is what the next session reads instead of your context. + +### 6. Decide, report, exit + +```sh +node .claude/skills/orchestrate/lib/done-gate.mjs # via a driver: evaluateDone({...}) +``` + +DONE is a computed fact. You may not declare it, override it, or argue `requiredDryCycles` below 2. + +- **DONE** → write the `DONE` sentinel into the loop state dir, comment the final summary on the loop-state issue, exit. +- **NOT DONE** → print the cycle report and **exit**. The scheduler starts the next cycle in a fresh session. + +Report exactly: + +``` +CYCLE: owner= +MERGED: +DISPATCHED: deferred= +FILED: deferred= +DRY: / +VERDICT: DONE | PARKED (blocked on human) | CONTINUE +``` + +## Never stop because + +The cycle felt complete. You merged something. You hit a milestone or finished an epic. CI is green. The PR list is momentarily empty. A wave finished. These are transitions, not endings — the only endings are the DONE gate, the STOP sentinel, and a stop condition below. + +## Never continue because + +There is more to do. There is always more to do. **One cycle per session** is the invariant that keeps this affordable. + +## Stop and escalate when + +Credentials or authority are missing; a destructive action has no tested recovery path; a secret or production dataset appears; CI is red on `main`; the same item has failed twice — label it `blocked`, file a tracking issue, and move on. + +## Operator + +```sh +touch .claude/loop/ACTIVE # arm +touch .claude/loop/STOP # stop after the current cycle +rm .claude/loop/DONE # resume after convergence +./.claude/skills/orchestrate/tick.sh # run one cycle now, in a fresh session ``` -Then queue the salvage branch for rebase + completion in the owning territory's lane. - -## Non-negotiables (never relax under self-pacing) - -- Invariants **#1 (output-clock)** and **#10 (isolation)** are blocking for any - engine/data-plane wave — a change that risks either is R3: stop, write a design note, - add a chaos/soak test. -- **The class matrix binds every wave** ([engineering.md](../../../docs/standards/engineering.md) - Parts A–C): each lane meets the gates its class owes, and the unconditional safety - invariants (secrets, authorization, supply chain, injection, licensing) hold at every - class. Autonomy is pace, never a lower bar. -- Confirm genuinely destructive/outward-facing actions with the operator (force-push - `main`, delete infra, public release, external comms) — the loop does not do these - silently. + +`tick.sh` is the scheduler's entry point. It takes a lock, runs exactly one cycle in a new `claude` process, and exits. Point cron at it. Do not replace it with anything that keeps a session alive. diff --git a/.claude/skills/orchestrate/lib/done-gate.mjs b/.claude/skills/orchestrate/lib/done-gate.mjs new file mode 100644 index 00000000..8198e1eb --- /dev/null +++ b/.claude/skills/orchestrate/lib/done-gate.mjs @@ -0,0 +1,73 @@ +/** + * Objective exit gate. Pure, no I/O, no model judgement. + * + * DONE is a computed fact, never a feeling. Every invalid input fails SAFE + * (NOT done), because "I could not confirm" must never read as "finished". + * + * K is floored: a caller cannot argue the loop into a one-cycle exit. + */ + +export const MIN_DRY_CYCLES = 2; + +const isCount = (n) => Number.isInteger(n) && n >= 0; + +/** + * @param {object} input + * buildableOpenIssues - open, unblocked, unassigned issues an agent could start + * openOrchestratorPRs - PRs this loop authored that are still open + * consecutiveDryCycles - cycles in a row that produced no MATERIAL finding + * requiredDryCycles - K, floored at MIN_DRY_CYCLES + * blockedOnHuman - items that only a human can unblock + */ +export function evaluateDone(input = {}) { + const reasons = []; + + if (!isCount(input.buildableOpenIssues)) reasons.push('invalid buildable-issue count — cannot confirm empty'); + if (!isCount(input.openOrchestratorPRs)) reasons.push('invalid open-PR count — cannot confirm empty'); + if (!isCount(input.consecutiveDryCycles)) reasons.push('invalid dry-cycle count — cannot confirm convergence'); + + if (reasons.length) return { done: false, reasons, k: MIN_DRY_CYCLES }; + + const k = isCount(input.requiredDryCycles) && input.requiredDryCycles >= MIN_DRY_CYCLES + ? input.requiredDryCycles + : MIN_DRY_CYCLES; + + if (input.buildableOpenIssues > 0) reasons.push(`${input.buildableOpenIssues} buildable issue(s) remain`); + if (input.openOrchestratorPRs > 0) reasons.push(`${input.openOrchestratorPRs} orchestrator PR(s) still open`); + if (input.consecutiveDryCycles < k) { + reasons.push(`${input.consecutiveDryCycles}/${k} consecutive dry cycles`); + } + + if (reasons.length === 0) { + // Blocked-on-human items are not "buildable", so a clean frontier can still + // have work outstanding. Converged either way, but say which it is. + const parked = isCount(input.blockedOnHuman) && input.blockedOnHuman > 0; + return { + done: true, + ...(parked ? { parked: true } : {}), + reasons: [parked + ? `converged; ${input.blockedOnHuman} item(s) blocked on a human` + : 'frontier empty, no open PRs, convergence reached'], + k, + }; + } + + return { done: false, reasons, k }; +} + +/** + * A finding resets the dry-cycle counter ONLY if it is material. Without this + * floor a sufficiently pedantic review always names something and the loop can + * never terminate. + */ +export const MATERIAL = new Set(['defect', 'security', 'privacy', 'data-loss', 'missing-spec-feature', 'broken-doc', 'user-visible']); + +export function isMaterial(finding) { + return !!finding && MATERIAL.has(finding.kind) && finding.confirmed === true; +} + +export function nextDryCycles(prev, findings) { + const p = isCount(prev) ? prev : 0; + if (!Array.isArray(findings)) return p; // could not evaluate: do not advance, do not reset + return findings.some(isMaterial) ? 0 : p + 1; +} diff --git a/.claude/skills/orchestrate/lib/partition.mjs b/.claude/skills/orchestrate/lib/partition.mjs new file mode 100644 index 00000000..395e8eff --- /dev/null +++ b/.claude/skills/orchestrate/lib/partition.mjs @@ -0,0 +1,84 @@ +/** + * Deterministic wave partitioning. Pure, no I/O, no model judgement. + * + * Collisions cannot surface at merge if colliding lanes are never assigned at + * once. Asking a model "are these safe to run together?" is how you land two + * PRs that are green alone and red together. + * + * Input candidates: { number, lane, blockedBy: [{ number, satisfied }], scope? } + * Config: { singleWriterLanes: string[], scopedLanes: string[], maxWave: number } + */ + +export const DEFAULT_MAX_WAVE = 6; + +/** Unknown lanes coerce to "unknown", which is always single-writer. */ +export function normaliseLane(lane, knownLanes) { + if (typeof lane !== 'string' || !lane.trim()) return 'unknown'; + return knownLanes.includes(lane) ? lane : 'unknown'; +} + +export function partitionWave(candidates, config = {}) { + const knownLanes = Array.isArray(config.knownLanes) ? config.knownLanes : []; + const singleWriter = new Set([...(config.singleWriterLanes || []), 'unknown']); + const scopedLanes = new Set(config.scopedLanes || []); + const maxWave = Number.isInteger(config.maxWave) && config.maxWave > 0 ? config.maxWave : DEFAULT_MAX_WAVE; + + const wave = []; + const deferred = []; + + if (!Array.isArray(candidates)) { + return { wave: [], deferred: [], error: 'candidates is not an array — refusing to dispatch' }; + } + + // Stable order: lowest issue number first. Determinism matters more than cleverness. + const ordered = [...candidates] + .filter((c) => c && Number.isInteger(c.number)) + .sort((a, b) => a.number - b.number); + + const laneTaken = new Set(); + const scopeTaken = new Set(); + + for (const c of ordered) { + const lane = normaliseLane(c.lane, knownLanes); + + if (wave.length >= maxWave) { + deferred.push({ number: c.number, reason: `wave cap ${maxWave} reached` }); + continue; + } + + const blockers = Array.isArray(c.blockedBy) ? c.blockedBy : []; + const unmet = blockers.filter((b) => b && b.satisfied !== true).map((b) => b.number); + if (unmet.length) { + deferred.push({ number: c.number, reason: `blocked by unmerged ${unmet.join(', ')}` }); + continue; + } + + if (singleWriter.has(lane)) { + if (laneTaken.has(lane)) { + deferred.push({ number: c.number, reason: `single-writer lane "${lane}" already taken this wave` }); + continue; + } + laneTaken.add(lane); + } else if (scopedLanes.has(lane)) { + // One item per (lane, scope). No scope declared => conservative, treat as single-writer. + const scope = typeof c.scope === 'string' && c.scope.trim() ? c.scope : null; + const key = `${lane}::${scope ?? '__unscoped__'}`; + if (scope === null) { + if (laneTaken.has(lane)) { + deferred.push({ number: c.number, reason: `lane "${lane}" item declares no scope — treated as single-writer` }); + continue; + } + laneTaken.add(lane); + } else if (scopeTaken.has(key)) { + deferred.push({ number: c.number, reason: `scope "${scope}" in lane "${lane}" already taken this wave` }); + continue; + } + scopeTaken.add(key); + } + // lanes that are neither single-writer nor scoped are disjoint by construction + + wave.push(c.number); + } + + return { wave, deferred }; +} diff --git a/.claude/skills/orchestrate/tick.sh b/.claude/skills/orchestrate/tick.sh new file mode 100755 index 00000000..16d1d659 --- /dev/null +++ b/.claude/skills/orchestrate/tick.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# One orchestration cycle, in a FRESH Claude Code session, then exit. +# +# This is the whole anti-burn mechanism. Continuity comes from GitHub, not from +# a context window. A re-injecting Stop hook keeps one session alive for +# thousands of turns and re-sends the entire transcript on every one of them; +# measured, that is 4% of sessions consuming 66% of spend. +# +# Run me from cron, a supervisor, or by hand. Every invocation is a clean slate. +# +# ./.claude/skills/orchestrate/tick.sh # one cycle +# watch -n 900 ./.claude/skills/orchestrate/tick.sh +# */15 * * * * cd /path/to/repo && ./.claude/skills/orchestrate/tick.sh >>.claude/loop/tick.log 2>&1 + +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +STATE_DIR="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")/.claude/loop" +mkdir -p "$STATE_DIR" +LOCK="$STATE_DIR/tick.lock" +MAX_SECONDS="${ORCH_MAX_SECONDS:-3600}" + +log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; } + +[[ -f "$STATE_DIR/STOP" ]] && { log "STOP sentinel present — not ticking."; exit 0; } +[[ -f "$STATE_DIR/DONE" ]] && { log "DONE sentinel present — loop converged. Remove it to resume."; exit 0; } +[[ -f "$STATE_DIR/ACTIVE" ]] || { log "not armed (no ACTIVE sentinel) — nothing to do."; exit 0; } + +# Single owner. Stale lock from a dead pid is reclaimed; a live one is respected. +if ! (set -o noclobber; printf '%s' "$$" >"$LOCK") 2>/dev/null; then + holder="$(cat "$LOCK" 2>/dev/null || echo '')" + if [[ -n "$holder" ]] && kill -0 "$holder" 2>/dev/null; then + log "cycle already running (pid $holder) — exiting."; exit 0 + fi + log "reclaiming stale lock from dead pid ${holder:-unknown}" + rm -f "$LOCK"; (set -o noclobber; printf '%s' "$$" >"$LOCK") 2>/dev/null || { log "lock race lost"; exit 0; } +fi +trap 'rm -f "$LOCK"' EXIT + +command -v claude >/dev/null || { log "claude CLI not on PATH"; exit 1; } + +log "cycle start (max ${MAX_SECONDS}s)" +set +e +timeout --signal=TERM --kill-after=60 "$MAX_SECONDS" \ + claude -p "Run one /orchestrate cycle. Follow .claude/skills/orchestrate/SKILL.md exactly. Do exactly one cycle, then stop — do not loop, do not schedule yourself, do not continue after the cycle report." \ + ${ORCH_CLAUDE_ARGS:-} +rc=$? +set -e + +case $rc in + 0) log "cycle complete" ;; + 124) log "cycle hit the ${MAX_SECONDS}s ceiling and was terminated — state is in GitHub, next tick resumes" ;; + *) log "cycle exited rc=$rc — next tick resumes from GitHub" ;; +esac + +# Never self-schedule. The scheduler owns cadence; this script owns one cycle. +exit 0 diff --git a/.claude/workflows/README.md b/.claude/workflows/README.md index 2ce73d07..d92d88e9 100644 --- a/.claude/workflows/README.md +++ b/.claude/workflows/README.md @@ -1,8 +1,12 @@ -# `.claude/workflows/` — committed Conductor workflow scripts +# `.claude/workflows/` — committed delivery-cycle workflow scripts -Reusable workflow scripts the Conductor invokes by name (`Workflow({ name: '' })`) -or by `scriptPath`. They are the loop's fan-out tools (ADR-G007; the -[`orchestrate`](../skills/orchestrate/SKILL.md) skill). Committed via the +Reusable workflow scripts an orchestration cycle invokes by name (`Workflow({ name: '' })`) +or by `scriptPath`. They are the delivery cycle's fan-out tools — one cycle per session, then +exit ([ADR-G009](../../docs/decisions/ADR-G009.md); the +[`orchestrate`](../skills/orchestrate/SKILL.md) skill, with Multiview's local half in the +[orchestrate runbook](../../docs/runbooks/orchestrate.md)). The disjoint territories they fan +out across and the cross-vendor review they gate on are +[ADR-G007](../../docs/decisions/ADR-G007.md). Committed via the `!/.claude/workflows/` negation in [`.gitignore`](../../.gitignore). ## Loader contract (read before "fixing" a top-level `return`) @@ -56,7 +60,7 @@ done | Script | Purpose | | --- | --- | -| `orient.js` | Read-only state-of-the-world map (lanes, branches, PRs, collisions, board) → synthesis for PLAN. | +| `orient.js` | Read-only state-of-the-world map (lanes, branches, PRs, collisions, board) → synthesis for the cycle's Orient step. | | `wave-fanout.js` | Run one wave of lane implementation across disjoint territories in isolated worktrees, each lane meeting the gates its class owes, returning committed work + opened PRs. | | `review-wave.js` | Adversarial cross-vendor (Codex) review of diffs/PRs — the mandatory pre-merge gate for R1+; R3/high-risk → 3-lens panel; fail-closed on fallback. | | `cleanup-sweep.js` | Read-only triage of branch/worktree sprawl → exact prune/remove/salvage lists for the orchestrator to execute. | diff --git a/.claude/workflows/cleanup-sweep.js b/.claude/workflows/cleanup-sweep.js index 28b45b22..d7ffb34b 100644 --- a/.claude/workflows/cleanup-sweep.js +++ b/.claude/workflows/cleanup-sweep.js @@ -1,5 +1,5 @@ // cleanup-sweep — read-only triage of branch/worktree sprawl into exact, verified -// prune/remove/salvage lists for the Conductor to execute. Never deletes anything +// prune/remove/salvage lists for the orchestrator to execute. Never deletes anything // itself (deletion is the orchestrator's accountable action). Usage: // Workflow({ name: 'cleanup-sweep' }) export const meta = { diff --git a/.claude/workflows/orient.js b/.claude/workflows/orient.js index a787b2ab..1a25ba01 100644 --- a/.claude/workflows/orient.js +++ b/.claude/workflows/orient.js @@ -1,11 +1,11 @@ // orient — map in-flight Multiview work (lanes, branches, PRs, hot-file collisions, -// coordination machinery) into one state-of-the-world for the Conductor's PLAN step. +// coordination machinery) into one state-of-the-world for a delivery cycle's Orient step. // Read-only. Usage: Workflow({ name: 'orient', args: { openPrs: [170, 172] } }) // args.openPrs is optional; if omitted the PR reader discovers open PRs via gh. export const meta = { name: 'orient', - description: 'Read-only map of in-flight work: worktree lanes, branch sprawl, open PRs, hot-file collisions, and coordination machinery — synthesized into one actionable state-of-the-world for the Conductor.', - whenToUse: 'At the start of a Conductor session or wave, to recover what is in flight and where lanes collide before assigning work.', + description: 'Read-only map of in-flight work: worktree lanes, branch sprawl, open PRs, hot-file collisions, and coordination machinery — synthesized into one actionable state-of-the-world for the orchestration cycle.', + whenToUse: 'At the start of a delivery cycle, to recover what is in flight and where lanes collide before dispatching work.', phases: [ { title: 'Map', detail: 'parallel readers: lanes, branches, PRs, hot-file collisions, governance' }, { title: 'Synthesize', detail: 'cross-reference into ready-to-merge / collisions / duplicates / prunable / risks' }, @@ -45,7 +45,7 @@ phase('Map') const prHint = args && args.openPrs ? `Known open PRs: ${JSON.stringify(args.openPrs)}.` : 'Discover open PRs via `gh pr list --state open`.' const [lanes, branches, prs, collisions, gov] = await parallel([ () => agent( - `Map every worktree lane in the Multiview repo. Run \`git worktree list --porcelain\` from the current checkout — it reports every lane of the repo whichever one you are in, so never hard-code a repo path. For each lane under .claude/worktrees/ (skip the root and any main-baseline): branch; locked? (porcelain 'locked' line); commits ahead of origin/main (\`git -C

rev-list --count origin/main..HEAD\`); dirty (\`git -C

status --porcelain\` non-empty); staleBase (its merge-base with origin/main is well behind origin/main); the LANE-* territory it maps to (see .claude/skills/orchestrate/SKILL.md); and a one-line summary of what it is doing. Use \`git -C \`, never cd. Read-only.`, + `Map every worktree lane in the Multiview repo. Run \`git worktree list --porcelain\` from the current checkout — it reports every lane of the repo whichever one you are in, so never hard-code a repo path. For each lane under .claude/worktrees/ (skip the root and any main-baseline): branch; locked? (porcelain 'locked' line); commits ahead of origin/main (\`git -C

rev-list --count origin/main..HEAD\`); dirty (\`git -C

status --porcelain\` non-empty); staleBase (its merge-base with origin/main is well behind origin/main); the lane/territory it maps to (see the "Lane map" section of docs/runbooks/orchestrate.md, which is what orchestrate.config.json encodes); and a one-line summary of what it is doing. Use \`git -C \`, never cd. Read-only.`, { label: 'lanes', phase: 'Map', schema: LANES_SCHEMA }), () => agent( `Triage local branches in the Multiview repo (branch refs are shared across every worktree — run git from the current checkout). origin/main is the base. mergedPrunable = \`git branch --merged origin/main\` minus main and minus any salvage/* branch. topicClusters = group the rest by prefix/keyword (webrtc, gpu, conspect, ndi, rist, ship/dev, ci, docs…); for clusters >1 judge duplicationRisk. staleCandidates = branches with tip committerdate older than 5 days and no open PR. Read-only; concrete branch names.`, @@ -57,15 +57,15 @@ const [lanes, branches, prs, collisions, gov] = await parallel([ `Detect cross-lane file collisions in the Multiview repo — the core failure mode is two in-flight refs editing the same hot file. For each open PR and each worktree lane and each fresh feature branch, get changed files vs origin/main (\`gh pr diff --name-only\` or \`git diff --name-only origin/main...\`). Build fileOverlaps: any file edited by >1 ref → {file, refs, severity} (severity high for serial hot files pipeline.rs / engine {runtime,clock,drive}.rs / control {routes/mod,openapi,state}.rs). hotPathRisks: any in-flight change risking invariant #1 (output clock) or #10 (isolation) — either risk makes that change R3 (3-lens panel + chaos/soak + operator approval), so name it explicitly. Read-only.`, { label: 'collisions', phase: 'Map', schema: COLLISION_SCHEMA }), () => agent( - `Summarize the Multiview work board for the Conductor. Search (rg, do NOT read whole — it is ~400 KB) docs/development/work-schedule.md: boardState (how many items, streams, how status is tracked); readyWork (a list of dependency-ready items: status [ ] or [~] whose deps appear satisfied, as "ID — title"); risks (top coordination risks visible right now). Also glance at qdrant-find for recent Conductor decisions. Read-only.`, + `Summarize the Multiview work board for the delivery cycle. Search it with BOUNDED rg (-l, -c, -m N, or pipe into \`head\`) — do NOT read it whole, it is ~400 KB — in docs/development/work-schedule.md: boardState (how many items, streams, how status is tracked); readyWork (a list of dependency-ready items: status [ ] or [~] whose deps appear satisfied, as "ID — title"); risks (top coordination risks visible right now). Also glance at qdrant-find for recent orchestration decisions. Read-only.`, { label: 'governance', phase: 'Map', schema: GOV_SCHEMA }), ]) phase('Synthesize') const synthesis = await agent( - `Synthesis step of a Conductor orientation. Cross-reference these findings into one actionable state-of-the-world.\n\n` + + `Synthesis step of a delivery-cycle orientation. Cross-reference these findings into one actionable state-of-the-world.\n\n` + `LANES:\n${JSON.stringify(lanes)}\n\nBRANCHES:\n${JSON.stringify(branches)}\n\nPRS:\n${JSON.stringify(prs)}\n\nCOLLISIONS:\n${JSON.stringify(collisions)}\n\nGOVERNANCE:\n${JSON.stringify(gov)}\n\n` + - `Produce: readyToMerge (PRs green + only needing review/merge); conflictHotspots (files edited by >1 ref, ranked, naming refs); duplicateLanes (refs that are the same work to consolidate under one owner); stalePrunable (branches + lane paths safe to remove, with any locked-but-dead-pid lanes flagged for salvage-first); nextWave (3–5 dependency-ready items mapped to disjoint territories, ready to ASSIGN); coordinationRisks. Be concrete — name files, refs, territories. This feeds the Conductor PLAN/ASSIGN steps.`, + `Produce: readyToMerge (PRs green + only needing review/merge); conflictHotspots (files edited by >1 ref, ranked, naming refs); duplicateLanes (refs that are the same work to consolidate under one owner); stalePrunable (branches + lane paths safe to remove, with any locked-but-dead-pid lanes flagged for salvage-first); nextWave (3–5 dependency-ready items mapped to disjoint territories, ready to dispatch); coordinationRisks. Be concrete — name files, refs, territories. This feeds the cycle's Orient and Dispatch steps.`, { label: 'synthesis', phase: 'Synthesize', schema: { type: 'object', additionalProperties: false, required: ['readyToMerge', 'conflictHotspots', 'duplicateLanes', 'stalePrunable', 'nextWave', 'coordinationRisks'], properties: { diff --git a/.claude/workflows/review-wave.js b/.claude/workflows/review-wave.js index 2c4923e2..a2061704 100644 --- a/.claude/workflows/review-wave.js +++ b/.claude/workflows/review-wave.js @@ -82,7 +82,7 @@ const verdicts = await pipeline(items, reviewItem) // (1) a null item result — the whole review died/dropped — becomes an EXPLICIT blocked verdict // so it can never silently vanish from the gate (the caller gets one verdict per item); and // (2) a fallback verdict (ranOk=false → not actually cross-vendor) can never clear the gate, so -// couple blocked to ranOk (codex-review runbook + orchestrate skill: never merge on fallback). +// couple blocked to ranOk (codex-review + orchestrate runbooks: never merge on fallback). const enforced = items.map((it, i) => { const v = verdicts[i] if (!v) { diff --git a/.claude/workflows/wave-fanout.js b/.claude/workflows/wave-fanout.js index 1c80e44d..5ce11a68 100644 --- a/.claude/workflows/wave-fanout.js +++ b/.claude/workflows/wave-fanout.js @@ -4,15 +4,15 @@ // itself with `scripts/classify.sh`; the class is a floor it may raise, never lower. // Territories must be disjoint and hot shared files (pipeline.rs, // engine/{runtime,clock,drive}.rs, control/{routes/mod,openapi,state}.rs) must be -// assigned to a single owner lane — the orchestrator guarantees this in its ASSIGN -// step before calling. Usage: +// assigned to a single owner lane — the orchestrator guarantees this when it partitions +// the wave (.claude/skills/orchestrate/lib/partition.mjs) before calling. Usage: // Workflow({ name: 'wave-fanout', args: { lanes: [ // { id: 'gpu-hwdefect', territory: 'LANE-GPU', item: 'HW-DEFECT-A', prompt: '...' , highRisk: false }, // ... ] } }) export const meta = { name: 'wave-fanout', description: 'Run one wave of lane implementation across disjoint territories in parallel: each lane classifies its change (scripts/classify.sh), works in an isolated worktree, runs the gates that class owes, and opens a PR. Returns per-lane results (class, branch, commits, PR, gate status) for the orchestrator to review and merge. Disjoint-territory assignment is the orchestrator’s responsibility before calling.', - whenToUse: 'The FAN OUT step of a Conductor wave once territories are assigned and dependency-ready.', + whenToUse: 'Dispatching one wave of lane work, once territories are partitioned and dependency-ready.', phases: [{ title: 'Implement' }], } diff --git a/AGENTS.md b/AGENTS.md index 2d499b61..bab57823 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,3 +82,20 @@ Dev automation is `cargo xtask --help`. | Runbooks — the operational *how* | [docs/runbooks/](docs/runbooks/) | | Toolchain + platform standards | [stack.md](docs/stack.md) | | Outside contributors, CLA, DCO | [CONTRIBUTING.md](CONTRIBUTING.md) | + +## Working efficiently + +Context is re-sent every turn, so anything you put in it you pay for repeatedly. + +- **Batch shell calls.** One `cmd-a && cmd-b && cmd-c` beats three turns. +- **Never read files through Bash.** `Read` is bounded; `cat`, `head` and `tail` are + not. Bound every search instead of redirecting it — `rg -l`, `-c`, `-m N`, or + `| head -n 50`. A hook enforces both; `# raw:` is the escape hatch. +- **Keep output small.** `git log --oneline -20`, `cargo test -q`, `npm --prefix web ci + --silent`. Redirect long build output to a file and read the tail. +- **Delegate breadth to subagents.** A subagent's transcript never enters this + context, only its final message. Reading 40 files to answer one question is a + subagent's job. +- **Do not narrate.** No preamble before a tool call, no summary of what a tool + returned, no recap. Report the outcome once, at the end. +- **Finish and stop.** Past ~150 turns, checkpoint to an issue or PR and start fresh. diff --git a/CLAUDE.md b/CLAUDE.md index 4db60d22..6a977e96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,12 +3,16 @@ [`AGENTS.md`](AGENTS.md) is canonical. This file adds only Claude-Code specifics. - **Lanes.** File-changing work at R1 and above goes in a worktree lane (`worktree-lane` skill); - the root checkout stays a clean mirror of `main`. The `PreToolUse` hook warns, never blocks - (ADR-G006). R0 prose batches may be edited in place. + the root checkout stays a clean mirror of `main`. The lane `PreToolUse` hook warns, never blocks + (ADR-G006); `prefer-native-tools.mjs` does block — read files with `Read`, not `cat`/`head`/ + `tail`, and bound every search (`rg -l`/`-c`/`-m N`, or `| head -n 50`). Escape hatch: prefix + the command `# raw:`. R0 prose batches may be edited in place. - **Skills** — `worktree-lane` (start of a file-changing task) · `adr` (a decision that constrains future changes; R2/R3 only) · `memory` (`qdrant-find` before non-trivial work, `qdrant-store` - when you learn something a future session needs) · `orchestrate` (driving the backlog as the - Conductor — [ADR-G007](docs/decisions/ADR-G007.md)). + when you learn something a future session needs) · `orchestrate` (one delivery cycle per session, + then exit — a scheduler starts the next; repo-agnostic, reads `orchestrate.config.json`, with + Multiview specifics in [orchestrate runbook](docs/runbooks/orchestrate.md) and + [ADR-G009](docs/decisions/ADR-G009.md)). - **Workflows** in [`.claude/workflows/`](.claude/workflows/) — `review-wave` (the cross-vendor gate; never self-performed by the authoring vendor) · `wave-fanout` · `orient` · `cleanup-sweep`. - **Subagents.** Delegate wide reading and mechanical edits; they return conclusions, not diff --git a/docs/decisions/ADR-G007.md b/docs/decisions/ADR-G007.md index 45b7f7fb..de0c459f 100644 --- a/docs/decisions/ADR-G007.md +++ b/docs/decisions/ADR-G007.md @@ -1,6 +1,6 @@ # ADR-G007: Single-orchestrator "Conductor" loop replaces N independent agent terminals -- **Status:** Accepted +- **Status:** Accepted — the **Autonomy** clause and step **⑨** are superseded by [ADR-G009](ADR-G009.md); everything else stands - **Area:** Guardrails - **Date:** 2026-06-16 - **Source:** operator request (2026-06-16 session) — "agents working at cross purposes, on the same issue, on GPU functionality without visibility into other changes" @@ -52,7 +52,9 @@ integrator, the sole `memory` MCP client, and owns every PR from open to merge a cleanup after. It fans work out **more** broadly than the separate terminals did, using two mechanisms, while making the repo's existing territory model actually hold. -The Conductor runs a repeating loop: +The Conductor runs one wave as these steps. **Amended by [ADR-G009](ADR-G009.md):** a wave is +now one *cycle*, the session **ends** with it, and an external scheduler starts the next cycle +in a fresh process. Step ⑨ is withdrawn. > **① PLAN** (read board + memory + open PRs; pick the next wave of dependency-ready > tasks, each mapped to a disjoint territory) → **② ASSIGN** (write lane→territory→owner @@ -64,7 +66,7 @@ The Conductor runs a repeating loop: > (green deterministic CI + passing review → merge, ADR-G005) → **⑦ CLEAN** (remove the > lane's worktree, prune its branch, `fetch && pull --ff-only` so the next wave bases on > current HEAD) → **⑧ RECORD** (flip board checkboxes, `qdrant-store` decisions/gotchas, -> write/refresh runbooks) → **⑨** reschedule the next iteration. +> write/refresh runbooks) → **the session ends**. Concrete shape: @@ -79,8 +81,9 @@ Concrete shape: the same territory, and **hot shared files are serial** — `pipeline.rs`, `engine/{runtime,clock,drive}.rs` → LANE-CORE; `control/{routes/mod,openapi,state}.rs` → LANE-API. Non-owner lanes file the handler **body** and hand the **wiring** to the - owner. The path globs live in the [`orchestrate` skill](../../.claude/skills/orchestrate/SKILL.md); - this partition refines `work-schedule.md` §1c. + owner. The path globs live in the [orchestrate runbook](../runbooks/orchestrate.md) (§ Lane + map), which is also what `orchestrate.config.json` encodes; this partition refines + `work-schedule.md` §1c. - **Cross-vendor review via Codex.** `codex` (codex-cli, verified present 2026-06-16) is the second vendor; Claude-authored diffs are reviewed by Codex in a fresh context seeing only diff + spec + checklist (rule 21). Gemini is not installed; if added it @@ -90,9 +93,11 @@ Concrete shape: - **Substrate fix.** `.mcp.json` uses the relative path `.memory/qdrant` (no variable expansion); `.gitignore` guards both `.memory/` and the stray `${CLAUDE_PROJECT_DIR}/` dir; the runbook is corrected (see [memory-mcp runbook](../runbooks/memory-mcp.md)). -- **Autonomy.** Per operator directive 2026-06-16 the loop runs **fully self-paced** - (ScheduleWakeup-driven), operator-interruptible, with the operator retaining ultimate - override (ADR-G005). +- **Autonomy** *(superseded by [ADR-G009](ADR-G009.md))*. Each delivery cycle is **one + session**, started by an external scheduler (`.claude/skills/orchestrate/tick.sh`); the loop + never continues itself in-context. The 2026-06-16 directive that it be self-paced is + withdrawn — the pacing moved out of the session, not the autonomy. Operator-interruptible + throughout, with the operator retaining ultimate override (ADR-G005). ### Territory partition (refines work-schedule.md §1c) @@ -141,16 +146,17 @@ Concrete shape: - **Easier:** no merge-time collisions on hot files; real cross-session recall; one accountable owner for CI-to-merge and cleanup; the branch/worktree count stays bounded; the rule-21 gate is always applied and recorded. -- **Harder / committed to maintain:** the Conductor is a **single point of throughput** — - if it stalls, the loop stalls (mitigated: fully self-paced ScheduleWakeup, operator can - interrupt/resume, and waves are independent so a failed wave doesn't block others). The +- **Harder / committed to maintain:** the orchestrator is a **single point of throughput** — + if it stalls, delivery stalls (mitigated by [ADR-G009](ADR-G009.md): a cycle is one session + under a time ceiling and its state lives in GitHub, so the next scheduled tick resumes; + waves are independent so a failed wave doesn't block others). The Conductor must **track lane authorship** to pick a different-vendor reviewer, and must **rebase stale lanes onto current HEAD** before integrating (every in-flight lane on 2026-06-16 sat on a stale base). Build-cache discipline (rule 11 — clean isolated `target/` before trusting green) and the rule-10 `/tmp` target-dir ban bind every wave. - **Invariants:** the loop's INTEGRATE/REVIEW steps must keep invariant **#1 (output-clock)** and **#10 (isolation)** as blocking gates for any engine/data-plane - wave (chaos/soak + mutation bars before merge); these never relax under self-pacing. + wave (chaos/soak + mutation bars before merge); autonomy is pace, never a lower bar. - **Operator authority retained** (ADR-G005): irreversible/outward-facing actions beyond routine merge — force-pushing `main`, deleting infrastructure, public releases, external comms — are surfaced to the operator, not done silently. diff --git a/docs/decisions/ADR-G008.md b/docs/decisions/ADR-G008.md index 5e447c0a..5d683069 100644 --- a/docs/decisions/ADR-G008.md +++ b/docs/decisions/ADR-G008.md @@ -74,8 +74,10 @@ Ceremony scales down hard. Accepted decision. R3 covers the outward-facing actions G005 reserves to the operator. - **[ADR-G006](ADR-G006.md)'s mechanisms stand.** The worktree hook stays **warn-only** (operator choice); ADRs stay in `docs/decisions/`; `.claude/` stays committed. -- **[ADR-G007](ADR-G007.md) stands.** The 9-step Conductor wave remains the live delivery loop; the - standard cites it rather than replacing it. +- **[ADR-G007](ADR-G007.md) stands.** Its single-orchestrator, disjoint-territory delivery model + remains live; the standard cites it rather than replacing it. *(Its **pacing** was later amended + by [ADR-G009](ADR-G009.md): one delivery cycle per session, started by a scheduler, replacing the + self-paced wave. The integrator, territory and review model this ADR leans on is unchanged.)* - **[ADR-G002](ADR-G002.md)'s mutation-testing mandate is not withdrawn** — but it is now recorded honestly as *not wired* (see Consequences). - No safety test was modified, no CI job was renamed, removed or split, and no branch-protection diff --git a/docs/decisions/ADR-G009.md b/docs/decisions/ADR-G009.md new file mode 100644 index 00000000..c9c2636f --- /dev/null +++ b/docs/decisions/ADR-G009.md @@ -0,0 +1,149 @@ +# ADR-G009: Scheduled ticks replace the self-paced loop; native tools replace Bash file reads + +- **Status:** Accepted +- **Area:** Guardrails +- **Date:** 2026-07-27 +- **Source:** operator request (Troy Kelly, @troykelly) — "sweep 2: cut token burn, replace the loop", carrying a 7-day measurement of 1,589 agent sessions + +## Context + +Agent cost was measured across 1,589 sessions over 7 days. The shape of the spend is not +where instruction-file hygiene assumed it was: + +- **Cache reads are 59.5% of true cost.** Cache read is prefix size × turn count and + nothing else — every turn re-sends the whole transcript. +- **The 4% of sessions past 250 turns are 66% of spend**, and the curve is superlinear: + $0.47/session at 16–40 turns, **$15.68** at 251–600, **$40.70** above 600. +- **Bash is 74.6% of all tool-result bytes** — 3,179 Bash calls against 165 `Read` calls, + a 19:1 ratio, at a mean result of 1,258 bytes. It is call *volume*, not large outputs. +- Assistant prose is ~30% of payload, thinking ~21%, tool schemas 2.7%, and **instruction + files 0.4%**. Shrinking documents cannot move this number. + +What is true in this repo today: there is **no** `Stop` hook, no `loop-continue.mjs`, no +`loop-supervisor.mjs`, no resume-directive re-injection and no per-session block budget — +those exist in sibling repos, not here. Multiview's self-continuation is three lines: step +⑨ RESCHEDULE of the `orchestrate` skill, and [ADR-G007](ADR-G007.md)'s Autonomy clause +authorising a `ScheduleWakeup`-driven, fully self-paced Conductor. The effect is the same +pattern the measurement indicts — one session that never ends. + +The constraints that bind the answer: the class matrix and its gates +([engineering.md](../standards/engineering.md), [ADR-G008](ADR-G008.md)); the cross-vendor +review gate ([ADR-G005](ADR-G005.md)); committed `.claude` machinery with a warn-only hook +([ADR-G006](ADR-G006.md)); and ADR-G007's territory partition, whose disjointness property +is the reason merges stopped colliding and must survive intact. + +## Decision + +**A cycle is one Claude Code session. Continuity lives in GitHub, never in a context +window.** An external scheduler starts each cycle clean; nothing in the loop may call +`ScheduleWakeup`, return a `block` decision from a `Stop` hook, or re-enter itself +in-context. ADR-G007's **Autonomy clause and step ⑨ are superseded by this ADR**; every +other part of ADR-G007 — single integrator, territory disjointness, cross-vendor review, +the memory substrate fix — stands unchanged. + +Concrete shape: + +- **`.claude/skills/orchestrate/tick.sh`** is the scheduler's entry point. It takes a + single-owner lock (reclaiming a dead pid's), runs exactly **one** cycle in a fresh + `claude -p` process under `ORCH_MAX_SECONDS` (default 3600), and exits. Sentinels in the + gitignored `.claude/loop/`: `ACTIVE` arms, `STOP` halts after the current cycle, `DONE` + records convergence. Point cron or a systemd timer at it; never anything that keeps a + session alive between cycles. +- **The `orchestrate` skill is now repo-agnostic** and reads `orchestrate.config.json` for + local specifics. Multiview's half — the lane map and the file that forces each lane + serial, the class-scaled gate, merge mechanics, salvage, memory — moves to + [docs/runbooks/orchestrate.md](../runbooks/orchestrate.md). +- **Partitioning and the exit gate are pure, tested code, not model judgement.** + `lib/partition.mjs` assigns a wave from `{ number, lane, scope, blockedBy }` against + `singleWriterLanes` / `scopedLanes` / `maxWave`, coercing unknown lanes to single-writer; + `lib/done-gate.mjs` computes DONE and fails **safe** on every invalid input, with the + dry-cycle requirement floored at 2 so no caller can argue the loop into a one-cycle exit + and a materiality floor so a pedantic reviewer cannot prevent convergence. + `lib/test.mjs` is 22 assertions over both. +- **`orchestrate.config.json`** encodes 15 lanes; 13 are single-writer, and only `gpu` and + `audio` are scoped-per-crate — each verified to share no source file. `maxWave: 5` keeps + ADR-G007's "what one integrator can integrate and review well". +- **A `PreToolUse` hook on `Bash`** (`.claude/hooks/prefer-native-tools.mjs`) sends + `cat`/`head`/`tail`/`sed -n M,Np` that name a file to `Read`, and requires + `rg`/`grep`/`find`/`ls -R` to **bound their own output** — `-l`, `-c`, `-m N`, or a pipe into + `head`. **This deviates deliberately from the hook the sweep supplied**, which redirected + searches to `Grep` and `Glob` tools: **neither tool exists in this harness**, verified + 2026-07-27 by direct call (`No such tool available`) and reported independently for subagents + and for headless `claude -p` — which is exactly what `tick.sh` spawns. A block whose only + remedy is an uncallable tool is worse than the burn it prevents, so the intent is kept and the + remedy is changed to one an agent can actually take. `Read` does exist, so the file-read rules + stand as written. The hook **fails open** on any parse error or unknown shape, and skips + segments downstream of a pipe, segments whose stdout is piped onward (those bytes never reach + the transcript), and any segment containing a redirect or remote exec. Escape hatches: a + `# raw:` prefix, or `NATIVE_TOOL_HOOK=off`. It chains after the existing warn-only + `enforce-worktree.mjs` rather than replacing it. +- **[AGENTS.md](../../AGENTS.md) gains "Working efficiently"** — batch shell calls, never + read files through Bash, keep output small, delegate breadth to subagents, do not + narrate, checkpoint and stop past ~150 turns. + +The loop ships **disarmed**. The `status: ready` / `status: blocked` / `loop-state` labels +do not exist yet, and the backlog is still `docs/development/work-schedule.md` rather than +issues; both are operator decisions recorded in the runbook, not defects in the machinery. + +## Rationale + +- **The loop shape is the whole cost.** At 1,589 sessions the difference between a + 250-turn session and a 40-turn one is $15.21; between a 600+ turn session and a 40-turn + one, $40.23. Ending the session is the only lever that touches the 66%. Sweep 1 shrank + instruction files, which are 0.4% of payload — a rounding error against this. +- **Bash volume, not Bash output, is the second lever.** At 19:1 against `Read` and 74.6% + of tool-result bytes, redirecting the discovery idioms (`cat`, recursive `grep`, `find`) + to bounded tools cuts both the per-call turn and the bytes that then ride in the prefix + for every remaining turn. Enforcing it in a hook rather than a document is deliberate: + documents are 0.4% of payload precisely because they are easy to not read. +- **Failing open is correct for this hook.** A hook that blocks legitimate work is worse + than one that misses; verification found it blocks 3 of 397 real repo commands, and two + of those three are exactly the discovery greps it exists to redirect. +- **Determinism where judgement was load-bearing.** ADR-G007's disjointness property was + enforced by one model's attention. Asking a model "are these safe together?" is how two + PRs that were green alone land red together. `partition.mjs` makes it arithmetic, and + unknown lanes coerce to single-writer so an unclassified item can never widen a wave. +- **DONE must be computed.** A self-terminating loop that may declare its own completion + either stops early on a green CI run or never stops at all. Failing safe on invalid input + means "I could not confirm" never reads as "finished". + +## Alternatives considered + +| Alternative | Rejected because | +| ----------- | ---------------- | +| **Keep the self-paced `ScheduleWakeup` Conductor** (status quo) | It is the measured pattern: 4% of sessions, 66% of spend, superlinear in turn count. Nothing else in the cost profile is worth attacking first. | +| **Shrink instruction files further** | Instruction files are **0.4%** of request payload. Sweep 1 already did this; repeating it cannot move a 59.5% cache-read cost. | +| **Cap turns per session and let the session resume itself** | A cap without an external starter either strands the work or re-injects a resume directive — which is the burn machinery under another name. The starter has to live outside the session. | +| **Keep the repo-specific 9-step skill and only delete step ⑨** | Leaves the territory partition enforced by model judgement, leaves no config seam for the tested libs, and keeps ~2 KB of Multiview specifics in a file every session loads. The runbook is a better home for the *how*. | +| **Supersede ADR-G007 outright** | Its territory partition, sole-integrator rule, cross-vendor gate and memory substrate fix are all still correct and load-bearing; only the pacing mechanism is wrong. A wholesale supersede would orphan the citations in `data-plane-safety.md` and `engineering.md`. | +| **Make the Bash hook fail closed, or allowlist commands** | Fails the "worse than one that misses" test — a fail-closed parser bug halts all shell work, and an allowlist rots against a 397-command surface across CI, scripts, xtask and runbooks. | +| **Ban `Bash` for file reads by policy only** | Policy lives in instruction files, which are 0.4% of payload and demonstrably not binding — the 19:1 ratio was measured under existing policy. | + +## Consequences + +- **Easier:** cost is bounded per cycle rather than compounding across a session; a crashed + or timed-out cycle costs one tick because state is in GitHub; wave partitioning and the + exit gate are unit-testable and were tested (22/22); the skill is portable across repos. +- **Harder / committed to maintaining:** `orchestrate.config.json` must track the crate + layout — a new crate outside a lane becomes `unknown`, which is safe but serial, so lanes + need reviewing when crates are added. The runbook is now the only home for the lane map + and must be updated in the same change that moves a territory. Cross-cycle continuity + depends on the loop-state issue being accurate; `gh issue list` lags creates by ~a minute, + so the reservation ledger is written **before** the create. +- **Operationally open:** the loop cannot dispatch until the three labels exist and the + backlog is represented as issues. It is armed only by an explicit + `touch .claude/loop/ACTIVE`. +- **Verified against the repo's own command surface:** 397 distinct commands were extracted + from CI, the devcontainer, `scripts/`, `xtask`, `web/package.json`, the runbooks and the agent + docs, and replayed through the hook. **394 pass.** The 3 blocked are all unbounded `rg` + examples in `working-in-this-monorepo.md` — exactly what the hook exists to stop — and that + caller was fixed to bound them. `.github/workflows/gitleaks.yml`'s + `grep "${tarball}" checksums.txt | sha256sum -c -` passes because its stdout is consumed by a + pipe; that security control is untouched, and CI never sees the hook in any case — it only + intercepts agent `Bash` calls. +- **Every block must leave an action available.** That is the standing constraint on this hook: + if a future harness drops `Read` too, the file-read rules must be re-adapted rather than left + pointing at something uncallable. +- **Invariants:** none touched. This is agent policy — **R2** by the class matrix — not a + data-plane change; invariants #1 and #10 are unaffected and their blocking status at + R3 is unchanged. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 2b9fae45..b3d3d911 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -1,6 +1,6 @@ # Architecture Decision Records -These ADRs capture the load-bearing decisions for the Multiview engine. 169 ADRs total. Most are **Proposed** — derived from the design briefs in [../research](../research/). The [Implementation Build-out](#implementation-build-out) series (`ADR-I*`) records decisions **Accepted** during the foundation build-out (the as-built state, which may deliberately and temporarily diverge from a Proposed ADR or from [conventions](../architecture/conventions.md) with a tracked follow-up). +These ADRs capture the load-bearing decisions for the Multiview engine. 238 ADRs total. Most are **Proposed** — derived from the design briefs in [../research](../research/). The [Implementation Build-out](#implementation-build-out) series (`ADR-I*`) records decisions **Accepted** during the foundation build-out (the as-built state, which may deliberately and temporarily diverge from a Proposed ADR or from [conventions](../architecture/conventions.md) with a tracked follow-up). ## Core Engine @@ -220,8 +220,9 @@ The **production-switcher layer** — M/E (mix/effects) stages with program/prev - [ADR-G004](ADR-G004.md) — Scope discipline, no-silent-suppression, secrets, and supply-chain guardrails for agents - [ADR-G005](ADR-G005.md) — Operator delegates routine review sign-off + merge to the agent (amends ADR-G003); cross-vendor review stays mandatory, operator retains override - [ADR-G006](ADR-G006.md) — Governance bootstrap: committed `.claude/` machinery (skills, warn-only worktree hook, settings), local qdrant memory MCP, gitleaks CI + local gate, `docs/runbooks/`+`stack.md`; the 42-rule working contract adopted into AGENTS.md (§7 amended by ADR-G008) -- [ADR-G007](ADR-G007.md) — Single-orchestrator "Conductor" loop (9-step wave) replaces N independent terminals; 13 disjoint LANE-\* territories, sole-integrator cherry-picks, invariants #1/#10 as blocking gates +- [ADR-G007](ADR-G007.md) — Single-orchestrator "Conductor" loop replaces N independent terminals; 13 disjoint LANE-\* territories, sole-integrator cherry-picks, invariants #1/#10 as blocking gates (Autonomy clause + the self-paced wave superseded by ADR-G009; the territory partition, sole-integrator rule, cross-vendor gate and memory substrate stand) - [ADR-G008](ADR-G008.md) — **Gates scale with blast radius**: the 42-rule prose contract is replaced by an R0–R3 change-class matrix in `docs/standards/engineering.md`, classified mechanically by `scripts/classify.sh`; `AGENTS.md` becomes a router (52 KB → 7.6 KB auto-loaded), the engine/FFI safety rules move to `architecture/data-plane-safety.md` with their §1–§8 anchors preserved, and CODEOWNERS is created. Amends ADR-G006 §7; ADR-G005/G006/G007 mechanisms unchanged *(Accepted)* +- [ADR-G009](ADR-G009.md) — **Scheduled ticks replace the self-paced loop; native tools replace Bash file reads**: one delivery cycle per Claude Code session, then the session exits — an external scheduler (`.claude/skills/orchestrate/tick.sh`, single-owner lock + `ACTIVE`/`STOP`/`DONE` sentinels) starts the next in a fresh process, and continuity lives in GitHub (issues + PRs + CI) rather than in a context window; nothing may `ScheduleWakeup`, block from a `Stop` hook, or re-enter itself in-context. The `orchestrate` skill becomes short and repo-agnostic, reading `orchestrate.config.json`, with Multiview's lane map, class-scaled gate, merge mechanics, salvage and build-cache discipline moved to the [orchestrate runbook](../runbooks/orchestrate.md); wave partitioning and the DONE gate become pure tested code (`lib/partition.mjs`/`lib/done-gate.mjs`, unknown lanes coerced single-writer, DONE fails safe). A `PreToolUse` hook (`.claude/hooks/prefer-native-tools.mjs`) sends `cat`/`head`/`tail`/`sed -n M,Np` that name a file to `Read`, and requires `rg`/`grep`/`find`/`ls -R` to bound their own output (`-l`, `-c`, `-m N`, or `| head`) — deliberately *not* the upstream form that redirected to `Grep`/`Glob` tools, since neither exists in this harness (verified 2026-07-27); fails open, skips anything piped onward, `# raw:` prefix and `NATIVE_TOOL_HOOK=off` escape hatches; 394 of the repo's 397 real commands pass. Supersedes ADR-G007's Autonomy clause and its step ⑨ only; ships disarmed. Agent policy, R2; no invariant touched *(Accepted)* - [ADR-0046](ADR-0046.md) — Skip the heavy CI matrix on **prose-docs-only** PRs via a fail-safe `dorny/paths-filter` `changes` job: `code = '**'` minus a strict prose allowlist under `predicate-quantifier: every`, so any non-prose file (incl. `docs/api/**` specs, lockfiles, configs, new extensions) keeps `code=true` → full matrix; only `.md`/`.txt`/images/`LICENSE*`/`NOTICE` outside `docs/api/**` can skip. Heavy jobs gate on `code=='true'` (and only on `pull_request`; pushes to `main` always run full); `inclusive-language` + a new dependency-free `docs-sanity` link check always run so docs PRs still get a green signal (and stay required-check-safe if branch protection is added). No code/config/spec/web PR loses any check — only *when* the unchanged jobs run changes *(Accepted)* ## Broadcast Multiviewer diff --git a/docs/development/working-in-this-monorepo.md b/docs/development/working-in-this-monorepo.md index fe986b8e..3f58be36 100644 --- a/docs/development/working-in-this-monorepo.md +++ b/docs/development/working-in-this-monorepo.md @@ -76,14 +76,26 @@ the lane — it will then misreport git state with total confidence. ## Navigation — ripgrep + the crate map -```bash -rg -n "out_pts|tick" # the output-clock timing logic -rg -n "trait Source|trait Sink" # stage trait definitions (multiview-core) -rg --type rust -l "AVHWFramesContext" crates/multiview-ffmpeg # FFI hwframe lifecycle -rg -n "ADR-T003" docs/ # everywhere a decision is referenced -fd CLAUDE.md crates web # list all nested agent docs +Search with `rg`, but **bound the output**. An unbounded search dumps every match into the context +window and then re-sends it on every remaining turn of the session; the +[`prefer-native-tools`](../../.claude/hooks/prefer-native-tools.mjs) hook blocks one. Bound it with +`-l` (file names only), `-c` (per-file counts), `-m N` (first N matches per file), or a pipe into +`head`. + +```sh +rg -l "out_pts|tick" # which files hold output-clock timing +rg -m3 "trait Source|trait Sink" # stage trait definitions (multiview-core) +rg --type rust -l "AVHWFramesContext" crates/multiview-ffmpeg # FFI hwframe lifecycle +rg -c "ADR-T003" docs/ # how often a decision is referenced +rg -n "ADR-T003" docs/ | head -n 30 # those lines themselves, capped +rg --files -g 'CLAUDE.md' | head -n 30 # list all nested agent docs ``` +Find *where* with `-l`/`-c`, then `Read` that file at an offset — ask for match **content** only +when you need the lines. `find` bounds the same way (`find . -name '*.rs' | head -n 50`). Anything +whose output is consumed by a pipe is fine as-is, because those bytes never reach the transcript. +For a genuinely unbounded search, prefix the command `# raw:` — the exception, not the habit. + Crate map and dependency direction: [`codebase-map.md`](codebase-map.md) and [`conventions.md` §3](../architecture/conventions.md) — **`core` ← everything; no cycles.** Knowing the direction tells you which crate a change belongs in before you start reading. diff --git a/docs/runbooks/codex-review.md b/docs/runbooks/codex-review.md index d33627fc..ca08b9c4 100644 --- a/docs/runbooks/codex-review.md +++ b/docs/runbooks/codex-review.md @@ -3,10 +3,11 @@ ## What it is and why Rule 21 requires that code authored by one vendor be reviewed by a **different** -vendor in a fresh context. Under the single-orchestrator model (Claude is the -author), the second vendor is **OpenAI Codex** via the `codex` CLI, driven by the +vendor in a fresh context. Under the single-integrator model (Claude is the +author — one integrator per delivery cycle, not one long-lived session), the +second vendor is **OpenAI Codex** via the `codex` CLI, driven by the [`review-wave`](../../.claude/workflows/review-wave.js) workflow and the -[`orchestrate`](../../.claude/skills/orchestrate/SKILL.md) skill (REVIEW step). +[orchestrate runbook](orchestrate.md) ("Gates a cycle owes here"). Decision: [ADR-G007](../decisions/ADR-G007.md); review standard: [agent-guardrails §C](../development/agent-guardrails.md). diff --git a/docs/runbooks/memory-mcp.md b/docs/runbooks/memory-mcp.md index 348dae11..fe8f83d3 100644 --- a/docs/runbooks/memory-mcp.md +++ b/docs/runbooks/memory-mcp.md @@ -93,11 +93,12 @@ acceptable if the copy is awkward. - **Single-process lock:** only one process per repo clone can hold the embedded qdrant store; a second concurrent `memory` server fails to connect (that is the - lock, not corruption). Under the single-orchestrator model (ADR-G007) the - **Conductor is the sole `memory` client**, so this contention does not arise in - normal operation — but a stray second session (or a leftover process) will still - block. If `qdrant-find` errors with a lock/connection failure, find and stop the - other holder; do not delete the store. + lock, not corruption). Under the single-integrator model (ADR-G007) delivery + cycles are serialized by `.claude/loop/tick.lock` ([orchestrate](orchestrate.md)), + so exactly one cycle runs at a time and it releases the store when it exits — + but a concurrent terminal in the same clone (or a leftover process) will still + fail to connect. If `qdrant-find` errors with a lock/connection failure, find + and stop the other holder; do not delete the store. - `~/.local/bin` must be on the PATH the Claude Code process sees, or the `uvx` command in `.mcp.json` fails to launch the server. - The store path is **relative to the server CWD**. Claude Code launches stdio MCP diff --git a/docs/runbooks/orchestrate.md b/docs/runbooks/orchestrate.md new file mode 100644 index 00000000..17cd593a --- /dev/null +++ b/docs/runbooks/orchestrate.md @@ -0,0 +1,201 @@ +# Runbook — the delivery loop (scheduled ticks) + +The backlog is driven by **one cycle per Claude Code session**, started by a scheduler. +The generic cycle is the [`orchestrate` skill](../../.claude/skills/orchestrate/SKILL.md); +this runbook is Multiview's local half — the lane map, the gates a cycle owes here, and +the operator procedures. The decision is [ADR-G009](../decisions/ADR-G009.md); the +single-integrator/territory model it builds on is [ADR-G007](../decisions/ADR-G007.md). + +**Why ticks and not a self-continuing session.** A session that re-enters its own loop +re-sends its whole transcript every turn. Measured across 1,589 sessions, cache reads are +59.5% of true cost and the 4% of sessions past 250 turns are 66% of spend — $0.47/session +at 16–40 turns against $40.70 above 600. Continuity therefore lives in GitHub, not in a +context window. Nothing here may call `ScheduleWakeup`, block a `Stop` hook, or loop back +in-context. + +## Current state + +The machinery is installed and **the loop is disarmed**. It stays disarmed until the +prerequisites below are met — arming it now would dispatch against an empty label set. + +| Thing | State | +| --- | --- | +| `.claude/skills/orchestrate/` (skill, `tick.sh`, `lib/`) | installed; `node .claude/skills/orchestrate/lib/test.mjs` → 22/22 | +| `orchestrate.config.json` | written; lanes derived from the territory map below | +| `.claude/loop/ACTIVE` | **absent** — not armed | +| `status: ready` / `status: blocked` / `loop-state` labels | **do not exist yet** | +| Backlog substrate | `docs/development/work-schedule.md` (402 KB board), **not** GitHub issues | + +### Prerequisites before arming + +1. **Create the labels.** The cycle's Orient step filters on them; without them + `gh issue list --label "status: ready"` returns nothing and every cycle is a no-op. + + ```sh + gh label create "status: ready" --description "Dependency-ready; an orchestrator cycle may dispatch it" --color 0E8A16 + gh label create "status: blocked" --description "Blocked on a human or an unmerged dependency" --color B60205 + gh label create "loop-state" --description "The orchestrator's cross-cycle state issue" --color 5319E7 + ``` + +2. **Open the loop-state issue** (one, labelled `loop-state`) holding `ownerRunId`, + `heartbeatAt`, `consecutiveDryCycles` and the reservation ledger. The cycle reads it + **by number**; record that number here once it exists. + +3. **Decide the backlog substrate.** The cycle reads GitHub issues. Multiview's backlog is + still the markdown board. Either promote ready items to issues carrying a `lane:` marker, + or point `readyLabel` at whatever convention replaces it. Until then a cycle has nothing + to dispatch. **This is an open operator decision, not a defect in the machinery.** + +## Run a cycle + +```sh +mkdir -p .claude/loop # once; .claude/loop/ is gitignored runtime state +touch .claude/loop/ACTIVE # arm +./.claude/skills/orchestrate/tick.sh +``` + +`tick.sh` takes a lock, runs exactly one cycle in a **fresh** `claude` process, and exits. +It is the scheduler's entry point and never self-schedules. + +| Sentinel / var | Effect | +| --- | --- | +| `.claude/loop/ACTIVE` | armed; absent = every tick is a no-op | +| `.claude/loop/STOP` | stop after the current cycle | +| `.claude/loop/DONE` | the DONE gate fired; `rm` it to resume | +| `.claude/loop/tick.lock` | single-owner lock; a dead pid's lock is reclaimed automatically | +| `ORCH_MAX_SECONDS` | per-cycle ceiling (default 3600); on timeout the next tick resumes from GitHub | + +Cadence — pick one, never something that keeps a session alive between cycles: + +```sh +# cron, every 15 minutes +*/15 * * * * cd /path/to/multiview && ./.claude/skills/orchestrate/tick.sh >>.claude/loop/tick.log 2>&1 +# or, foreground +watch -n 900 ./.claude/skills/orchestrate/tick.sh +``` + +## Lane map — what makes a lane single-writer + +`orchestrate.config.json` partitions work by lane; `lib/partition.mjs` enforces it in code. +A lane is single-writer because two concurrent items in it would both edit the file named +below — the registration table, schema or index that every change in that area touches. +Collisions cannot surface at merge if colliding lanes are never *assigned* at once. + +| Lane | Owns | Forces serialization | +| --- | --- | --- | +| `core` | `multiview-cli/src/{pipeline,run,control}.rs`, `multiview-engine/src/{runtime,drive,clock}.rs`, `multiview-events/src/event.rs`, `multiview-config/src/schema.rs` | `pipeline.rs` (the drive seam — 7 divergent blobs in the 2026-06-16 incident); `schema.rs`; `event.rs` | +| `core-types` | `multiview-core/**` | `core/src/{lib,frame,traits,color}.rs` — all 22 dependent crates rebuild, so it also ripples into every other lane in the wave | +| `api` | `multiview-control/src/{routes/mod,openapi,openapi_schemas,state,lib}.rs`, `docs/api/openapi.json`, auth/session/RBAC | `routes/mod.rs` route table; `openapi.rs`; `state.rs`; the generated `openapi.json` is CI staleness-gated | +| `wrtc` | `multiview-webrtc/**`, `multiview-preview/src/whep*`, `control/src/routes/whip*` | `webrtc/src/transport/mod.rs`; `session.rs` (one `[::]` socket session table shared by WHIP ingest / WHEP serve / WHIP push) | +| `input` | `multiview-input/**`, `multiview-rist-sys/**` | `input/src/lib.rs` (24 cfg-gated `pub mod`), `input/Cargo.toml` `[features]` | +| `preview` | `multiview-preview/**` minus WHEP transport | `preview/src/tap.rs` (the refcounted lazy-start tap registry), `encode.rs` (shared encoder pool) | +| `engine` | `multiview-engine/**` minus runtime/clock/drive, `multiview-hal/src/load.rs` | `engine/src/lib.rs`, `supervisor.rs` task registration | +| `gpu` | `multiview-compositor/**`, `multiview-framestore/**`, `multiview-ffmpeg/**`, `multiview-hal/src/select.rs` | **scoped** — one writer per crate; no file is shared across them (framestore depends only on core; ffmpeg is the FFI leaf) | +| `audio` | `multiview-audio/**`, `multiview-overlay/**` | **scoped** — one writer per crate; verified no shared source file (overlay depends only on core) | +| `bcast` | `control/src/{nmos,is07}*`, `multiview-output/**` | `output/src/lib.rs` (17 cfg-gated mods), `sink.rs` + `fanout.rs` (encode-once-mux-many, invariant #7) | +| `web` | `web/**` | `web/src/app/router.tsx`, `navigation.tsx`, `src/locales/*/messages.po` (`lingui extract` rewrites all three), generated `src/api/schema.ts` | +| `devices` | zowietek / display-kms / sync / cast / node-enroll, `deploy/**` | `control/src/devices/{mod,driver_registry,registry}.rs` — every driver registers in all three | +| `conspect` | `multiview-licence/**`, `multiview-mesh/**` | mesh depends on licence, so a licence public-type change forces a mesh edit; both land stores in `control/src/state.rs` | +| `telemetry` | `multiview-telemetry/**` | `telemetry/src/lib.rs` mod list | +| `gov` | `.claude/**`, `docs/{decisions,research,runbooks}`, `docs/development/work-schedule.md`, `.github/workflows/**`, build pins | `docs/decisions/README.md` (every ADR appends a row), `.github/workflows/ci.yml`, `AGENTS.md` | + +Rules that ride with the map: + +- **When two items genuinely need the same lane in one wave, serialize them under one + owner — do not split the territory.** +- Non-owner lanes file the handler **body** and hand the wiring to the owning lane. +- **Cross-cutting, owned by no lane:** `Cargo.lock` (any dep bump) and the generated + `docs/api/{openapi,asyncapi}.json`. Any route or event change from any lane lands there, + and CI staleness-gates them — regenerate with `cargo run --locked -p xtask -- gen-openapi` + and `gen-asyncapi` in the lane that caused the change. +- Wave size is capped by what one integrator can integrate and review well — **3–5 + concurrent lanes** (`maxWave: 5`). +- Unowned surfaces: `xtask/`, `scripts/`, `examples/`, most of `docs/`, root manifests. + Treat an item touching only these as `gov`, or as `unknown` — which `partition.mjs` + coerces to single-writer, which is the safe default. + +## Gates a cycle owes here + +Autonomy is pace, never a lower bar. + +- **Classify first.** `scripts/classify.sh` prints the class; it is a floor you may raise, + never lower. The gates per class are [engineering.md](../standards/engineering.md). +- **R1+ file changes go in a worktree lane** ([`worktree-lane` skill](../../.claude/skills/worktree-lane/SKILL.md)). + The root checkout stays a clean mirror of `main`. +- **Cross-vendor review above R0 is mandatory and never self-performed by the authoring + vendor** ([ADR-G005](../decisions/ADR-G005.md), [`review-wave` workflow](../../.claude/workflows/review-wave.js), + [codex-review runbook](codex-review.md)). Claude-authored → Codex reviews. + **Never merge on a `claude-fallback` verdict** — that is fresh-context Claude, not a + second vendor; hold the PR until Codex auth lands. +- Require **≥1 substantive risk statement**; unanimous bland approval is a yellow flag. + **Never argue a reproduced finding away**, and re-review only the delta after a fix. +- **Invariant #1 (output clock) and #10 (isolation) are blocking** for any engine or + data-plane item — a change that risks either is R3: stop, write a design note, add a + chaos/soak test, get explicit operator approval. + +## Merge and integration mechanics + +Each of these cost an incident once. + +- `Closes #n` is **plain text, never backticked** — GitHub ignores the backticked form. +- **Never `gh pr merge --auto` before green** — it merges immediately. +- **`cancelled` is not a passing verdict.** Neither is a green summary over a skipped suite. +- **Push as the authenticated user**; do not rewrite author identity to merge. +- **Rebase a stale lane onto current `main` before integrating** — every in-flight lane on + 2026-06-16 sat on a stale base and cherry-picks conflicted. +- Find **all** of a lane's commits with `git log origin/main..` and cherry-pick + them as **individual single commits**, never ranges. +- **Never share a build cache across worktrees, and never point a build dir at `/tmp`** — + per-lane `/tmp` targets once filled the disk with terabytes. A worktree's own `target/` + is already isolated. A shared cache can link a sibling's stale artifacts and fake a green + run, so **rebuild from a clean, isolated `target/` before trusting green**. +- After merge: remove the lane's worktree, `git worktree prune`, delete the branch, then + `git fetch origin && git pull --ff-only origin main` in the root so the next cycle bases + on current HEAD. + +## Salvage — an orphaned `locked` lane whose owning pid is dead + +Never force-remove a `locked` worktree belonging to a **live** session. When the pid is +dead, make the work a readable branch *before* the worktree dies: + +```sh +git -C add -A && git -C commit -m "wip(salvage): — recovered by the loop" +git branch salvage/ "$(git -C rev-parse HEAD)" +git worktree unlock && git worktree remove --force +``` + +Then queue the `salvage/*` branch for rebase and completion inside the owning lane. +[`cleanup-sweep`](../../.claude/workflows/cleanup-sweep.js) produces the prune/keep/salvage +lists; it never acts on them. + +## Verify + +```sh +node .claude/skills/orchestrate/lib/test.mjs # 22/22 — partitioning + the DONE gate +node .claude/hooks/test-prefer-native-tools.mjs # 53/53 — the Bash hook, incl. fail-open +bash -n .claude/skills/orchestrate/tick.sh # syntax +node -e 'JSON.parse(require("fs").readFileSync("orchestrate.config.json","utf8"))' +./.claude/skills/orchestrate/tick.sh # disarmed => "not armed (no ACTIVE sentinel)" +``` + +Both test files are gates, not samples. The hook test asserts that every one of the repo's own +contract commands still runs **and** that no block ever names a tool this harness does not have — +`Grep` and `Glob` do not exist here, so a block that recommends them would strand the agent. + +## Disarm / roll back + +```sh +touch .claude/loop/STOP # stop after the current cycle +rm -f .claude/loop/ACTIVE # disarm entirely; ticks become no-ops +crontab -e # remove the tick entry +``` + +Removing the machinery is `git revert` of the installing commit; there is no runtime state +to unwind — `.claude/loop/` is gitignored and holds only sentinels, a lock and a log. + +## Memory + +The `memory` MCP (embedded qdrant under `.memory/`) is **single-process — one holder per +clone**. Under scheduled ticks only one cycle runs at a time (`tick.lock`), so the cycle is +the single client while it runs and releases it on exit; a concurrent terminal in the same +clone will fail to connect. See [memory-mcp](memory-mcp.md). diff --git a/docs/standards/engineering.md b/docs/standards/engineering.md index 157275c3..f51adb40 100644 --- a/docs/standards/engineering.md +++ b/docs/standards/engineering.md @@ -147,9 +147,10 @@ review attention and increases disclosure. Read a file because a specific questi as precaution. Delegate breadth to subagents and keep their conclusions. Keep durable state outside the window — issues, PRs, commits, ADRs, the memory MCP — so a compaction boundary costs nothing. -Concretely: navigate with `rg` and [codebase-map](../development/codebase-map.md), not exhaustive -reads. Read a subsystem's brief when the change needs its reasoning, not as a standing toll. Never -open `target/`, `node_modules/`, `dist/` or `.multiview-build/`. +Concretely: navigate with **bounded** `rg` (`-l`, `-c`, `-m N`, or `| head -n 50`) and +[codebase-map](../development/codebase-map.md), not exhaustive reads. Read a subsystem's brief when +the change needs its reasoning, not as a standing toll. Never open `target/`, `node_modules/`, +`dist/` or `.multiview-build/`. ## G. Documentation proportionality @@ -174,4 +175,6 @@ satisfied honestly, say so and stop — those are the only two options. Toolchain forensics (lint ordering, clippy traps, mutation exit codes, tool-version gotchas): [agent-guardrails](../development/agent-guardrails.md). Agent operations (lanes, build-dir hygiene, context reload behaviour): [working-in-this-monorepo](../development/working-in-this-monorepo.md). -The live delivery loop: [ADR-G007](../decisions/ADR-G007.md) + the `orchestrate` skill. +The live delivery loop runs one cycle per Claude Code session, started by a scheduler: the +`orchestrate` skill plus Multiview's [orchestrate runbook](../runbooks/orchestrate.md) +([ADR-G009](../decisions/ADR-G009.md), [ADR-G007](../decisions/ADR-G007.md)). diff --git a/orchestrate.config.json b/orchestrate.config.json new file mode 100644 index 00000000..a1495dab --- /dev/null +++ b/orchestrate.config.json @@ -0,0 +1,57 @@ +{ + "readyLabel": "status: ready", + "blockedLabel": "status: blocked", + "knownLanes": [ + "core", + "core-types", + "api", + "wrtc", + "input", + "preview", + "engine", + "gpu", + "audio", + "bcast", + "web", + "devices", + "conspect", + "telemetry", + "gov" + ], + "singleWriterLanes": [ + "core", + "core-types", + "api", + "wrtc", + "input", + "preview", + "engine", + "bcast", + "web", + "devices", + "conspect", + "telemetry", + "gov" + ], + "scopedLanes": ["gpu", "audio"], + "maxWave": 5, + "maxOpenPRs": 3, + "maxFilePerSweep": 10, + "requiredDryCycles": 2, + "commands": { + "bootstrap": "cargo fetch --locked && npm --prefix web ci", + "verify": "cargo fmt --all -- --check && cargo clippy --locked --workspace --all-targets -- -D warnings && cargo test --locked --workspace && cargo deny check", + "test": "cargo test --workspace", + "classify": "scripts/classify.sh" + }, + "dimensions": [ + "spec-parity", + "defects", + "tests", + "docs", + "as-built", + "security", + "observability", + "ux" + ] +} From 763184880f9f32bd56591c0945ce4a1cecd40f08 Mon Sep 17 00:00:00 2001 From: aperim-agent <216457062+aperim-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:48:19 +0000 Subject: [PATCH 3/3] fix(governance): adversarial-review findings in the Bash hook and lane map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in the hook adaptation, all now pinned by tests: - Piped file reads were blocked while telling the agent to use Read, which cannot feed a pipe. `cat pkg.json | jq .version` and `head -100 build.log | grep error` had no available remedy — and the second is the very thing AGENTS.md now advises. The pipes-onward exemption ran after the file-read rules instead of before them, contradicting both the file header and ADR-G009. - ripgrep's -L is --follow, which WIDENS a search; it was counted as a bounding flag. It stays bounded for grep and ag, where -L really is files-without-match. - A repeated segment was resolved by first textual occurrence, so `rg foo src | head -5 && rg foo src` let the unbounded copy through, and `rg foo src > /tmp/a; rg foo src | head -5` was wrongly blocked. Segments now carry their own separators, and `||`/`&&` are recorded as control flow rather than pipes. Lane map: multiview-hal becomes its own lane. ADR-G007 split it across two territories — load.rs to engine, select.rs to gpu — but both are declared in one hal/src/lib.rs and share one Cargo.toml, so the split put a shared file in two concurrently-dispatchable lanes, which the partitioner cannot express. scopedLanes ships empty. gpu and audio would each parallelise safely per crate, but partition.mjs admits a second item into a scoped lane when the first declares no scope — it reserves the lane, not the scope namespace. That defect is upstream in a file this repo received verbatim, so it is reported and worked around rather than silently patched. Same for the two tick.sh lock defects (recycled pid stalls the loop; stale-lock reclaim is not atomic), which are documented with symptoms and recovery in the runbook. Restores two things the rewrite dropped: the board RECORD discipline (flip the Part-2 box, set Part-3 Status, red-to-green SHAs and PR number inline) and the proactive qdrant-store mandate. Hook 61/61, partitioner 22/22, links and inclusive language clean. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/prefer-native-tools.mjs | 77 +++++++++++++--------- .claude/hooks/test-prefer-native-tools.mjs | 14 ++++ docs/decisions/ADR-G009.md | 19 +++++- docs/runbooks/orchestrate.md | 44 ++++++++++++- orchestrate.config.json | 6 +- 5 files changed, 123 insertions(+), 37 deletions(-) diff --git a/.claude/hooks/prefer-native-tools.mjs b/.claude/hooks/prefer-native-tools.mjs index 5b8cb986..ac3b8712 100644 --- a/.claude/hooks/prefer-native-tools.mjs +++ b/.claude/hooks/prefer-native-tools.mjs @@ -19,7 +19,9 @@ * find / ls -R unbounded -> add `| head -n 50` * * A segment whose stdout is piped into something else never reaches context at - * all, so only the last segment of a pipeline is checked for the search family. + * all, so only the tail of a pipeline is checked — including for file reads, + * because `Read` cannot feed a pipe and `head -100 build.log | grep error` has + * no Read-shaped remedy. * * Fails OPEN: any parse error, unknown shape, or unexpected exception allows * the command. A hook that blocks legitimate work is worse than one that misses. @@ -45,11 +47,24 @@ function readStdin() { } } -/** Split a command line into pipeline segments, ignoring separators inside quotes. */ +/** + * Split a command line into segments, ignoring separators inside quotes, and + * record the separator on each side of every segment. + * + * The separators are carried on the segment rather than recovered later by + * searching the command string: `rg foo src | head -5 && rg foo src` contains + * the same segment text twice, and an indexOf-style lookup resolves both to the + * first occurrence — under-blocking the second (unbounded) one, and + * over-blocking the reverse arrangement. + * + * `||` and `&&` are recorded as 'logical', never as a pipe: `foo || cat f` + * does not feed `cat` anything. + */ function segments(cmd) { const out = []; let buf = ''; let quote = null; + let sepBefore = null; for (let i = 0; i < cmd.length; i++) { const c = cmd[i]; if (quote) { @@ -59,39 +74,29 @@ function segments(cmd) { } if (c === '"' || c === "'") { quote = c; buf += c; continue; } if (c === '|' || c === ';' || c === '&') { - if (buf.trim()) out.push(buf.trim()); + let sep = c; + // a doubled operator is control flow, not a pipe + while (i + 1 < cmd.length && (cmd[i + 1] === '|' || cmd[i + 1] === '&')) { i++; sep = 'logical'; } + if (buf.trim()) out.push({ text: buf.trim(), sepBefore, sepAfter: sep }); buf = ''; - // consume doubled operators - while (i + 1 < cmd.length && (cmd[i + 1] === '|' || cmd[i + 1] === '&')) i++; + sepBefore = sep; continue; } buf += c; } - if (buf.trim()) out.push(buf.trim()); + if (buf.trim()) out.push({ text: buf.trim(), sepBefore, sepAfter: null }); return out; } /** True when the segment reads piped stdin rather than naming files. */ -function isDownstream(cmd, seg) { - const idx = cmd.indexOf(seg); - if (idx <= 0) return false; - const before = cmd.slice(0, idx); - // last unquoted separator before this segment was a pipe - const m = before.match(/([|;&])[^|;&]*$/); - return !!m && m[1] === '|'; -} +const isDownstream = (seg) => seg.sepBefore === '|'; /** * True when this segment's stdout is piped onward. Its bytes are consumed by * the next stage and never enter the transcript, so output size is not our * problem — only the tail of a pipeline reaches context. */ -function pipesOut(cmd, seg) { - const idx = cmd.indexOf(seg); - if (idx < 0) return false; - const after = cmd.slice(idx + seg.length); - return /^\s*\|(?!\|)/.test(after); -} +const pipesOut = (seg) => seg.sepAfter === '|'; function tokenise(seg) { return seg.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; @@ -106,15 +111,23 @@ const VALUE_FLAGS = new Set([ '-type', '-maxdepth', '-mindepth', '-newer', '-size', '-perm', '-user', ]); -/** Bounded search: -l/-L/-c/-q (incl. combined shorts) or an explicit -m/--max-count. */ -function isBoundedSearch(args) { +/** + * Bounded search: -l / -c / -q (incl. combined shorts) or an explicit + * -m/--max-count. + * + * `-L` is bounded in grep and ag (files-WITHOUT-match) but in ripgrep it is + * --follow, which *widens* the search — so it counts only for the grep family. + */ +function isBoundedSearch(bin, args) { + const shorts = bin === 'rg' ? 'lcq' : 'lLcq'; + const re = new RegExp(`[${shorts}]`); for (let i = 0; i < args.length; i++) { const a = args[i]; - if (a === '-m' || a === '--max-count' || a.startsWith('-m') && /^-m\d+$/.test(a)) return true; + if (a === '-m' || a === '--max-count' || /^-m\d+$/.test(a)) return true; if (/^--max-count(=|$)/.test(a)) return true; if (/^--(files-with-matches|files-without-match|count|quiet|silent)$/.test(a)) return true; - // combined or single short flags: -l, -rl, -il, -c, -q, -L - if (/^-[A-Za-z]+$/.test(a) && /[lLcq]/.test(a.slice(1))) return true; + // combined or single short flags: -l, -rl, -il, -c, -q + if (/^-[A-Za-z]+$/.test(a) && re.test(a.slice(1))) return true; } return false; } @@ -137,7 +150,8 @@ function check(cmd) { // explicit opt-out if (/^\s*#\s*raw:/.test(cmd)) return null; - for (const seg of segments(cmd)) { + for (const segment of segments(cmd)) { + const seg = segment.text; const t = tokenise(seg); if (!t.length) continue; @@ -151,11 +165,12 @@ function check(cmd) { // never touch write/heredoc forms or remote execution if (/[><]|<<|ssh\s|docker\s|kubectl\s/.test(seg)) continue; // downstream of a pipe: reading stdin, which is correct usage - if (isDownstream(cmd, seg)) continue; + if (isDownstream(segment)) continue; + // feeding another command: these bytes never reach the transcript, and + // `Read` cannot feed a pipe, so blocking here would offer no remedy + if (pipesOut(segment)) continue; const namesFile = args.some((a) => !a.startsWith('-') && /[./]|\.\w+$/.test(a)); - // a segment feeding another command never lands in context - const consumed = pipesOut(cmd, seg); const CAP = 'Cap the output: add -l (names only), -c (count), -m N (first N matches), or pipe into `| head -n 50`.'; @@ -174,12 +189,10 @@ function check(cmd) { } // ---- search family: bound it, do not redirect it (no Grep/Glob here) ---- - if (consumed) continue; - if (bin === 'grep' || bin === 'egrep' || bin === 'fgrep' || bin === 'rg' || bin === 'ag' || bin === 'ack') { const recursive = /(^|\s)(-[A-Za-z]*[rR][A-Za-z]*|--recursive)(\s|$)/.test(rest); const searchesPath = namesFile || operands(args).length >= 2 || recursive; - if (searchesPath && !isBoundedSearch(args)) { + if (searchesPath && !isBoundedSearch(bin, args)) { return { bin, use: null, why: `An unbounded search dumps every match into context for the rest of the session. ${CAP}` }; } continue; diff --git a/.claude/hooks/test-prefer-native-tools.mjs b/.claude/hooks/test-prefer-native-tools.mjs index 4df92fba..5653e799 100644 --- a/.claude/hooks/test-prefer-native-tools.mjs +++ b/.claude/hooks/test-prefer-native-tools.mjs @@ -45,6 +45,13 @@ const MUST_BLOCK = [ ['head -50 Cargo.toml', 'head a file'], ['tail -20 CHANGELOG.md', 'tail a file'], ["sed -n '10,40p' AGENTS.md", 'sed line range'], + // -L is --follow in ripgrep (it WIDENS the search), not files-without-match + ['rg -L pattern src', 'rg -L must not count as bounded'], + // a repeated segment must be judged on its OWN separators, not the first + // textual occurrence's — else the unbounded tail escapes + ['rg foo src | head -5 && rg foo src', 'second, unbounded copy of a repeated segment'], + // `||` is control flow, not a pipe: nothing is feeding cat + ['false || cat file.txt', 'cat after a logical-or is not downstream of a pipe'], ]; const MUST_ALLOW = [ @@ -66,6 +73,13 @@ const MUST_ALLOW = [ ["find . -name '*.rs' | head -20", 'piped find'], ['grep -rn "invariant" docs/ | wc -l', 'piped into wc'], ['ls -R crates/ | head -n 20', 'piped recursive ls'], + // Read cannot feed a pipe, so a piped file read has no Read-shaped remedy + ['cat package.json | jq .version', 'cat feeding jq'], + ['head -100 build.log | grep error', 'AGENTS.md advice: redirect build output, read the tail'], + ['tail -f app.log | grep -m1 ready', 'streaming tail — Read cannot do this at all'], + // repeated segments judged on their own separators + ['rg foo src > /tmp/a; rg foo src | head -5', 'repeated segment: redirect then piped'], + ['echo "rg foo src"; rg foo src | head -5', 'a quoted look-alike must not shift the verdict'], // find with an action, and redirects ['find . -name "*.tmp" -delete', 'find with an action'], ['git diff origin/main...HEAD > /tmp/review.diff', 'redirect'], diff --git a/docs/decisions/ADR-G009.md b/docs/decisions/ADR-G009.md index c9c2636f..572b3cb2 100644 --- a/docs/decisions/ADR-G009.md +++ b/docs/decisions/ADR-G009.md @@ -143,7 +143,24 @@ issues; both are operator decisions recorded in the runbook, not defects in the intercepts agent `Bash` calls. - **Every block must leave an action available.** That is the standing constraint on this hook: if a future harness drops `Read` too, the file-read rules must be re-adapted rather than left - pointing at something uncallable. + pointing at something uncallable. Adversarial review caught three defects in the adaptation + before it landed, all now fixed and pinned by tests: piped file reads were blocked while + telling the agent to use `Read`, which cannot feed a pipe; ripgrep's `-L` (`--follow`, which + *widens* a search) was counted as a bounding flag; and a repeated segment was resolved by + first textual occurrence, so `rg foo src | head -5 && rg foo src` let the unbounded copy pass. +- **Four defects remain in the supplied libraries** and are worked around rather than silently + patched, because those files were received verbatim and other repos run the same copies — + they are reported upstream instead. `partition.mjs` admits a second item into a `scoped` lane + when the first declares no `scope` (it reserves the lane but not the scope namespace), and + drops a malformed candidate silently instead of deferring it; `tick.sh` stalls the loop + forever on a recycled pid, and its stale-lock reclaim is `rm`-then-create rather than atomic. + The first is neutralised by shipping `scopedLanes: []`; the `tick.sh` pair are documented with + their symptoms and recovery in the runbook. +- **`multiview-hal` is its own lane.** ADR-G007 split it across two territories — + `hal/src/load.rs` to the engine, `hal/src/select.rs` to the GPU lane — but both are declared + in one `hal/src/lib.rs` and share one `Cargo.toml`, so the split put a shared file in two + concurrently-dispatchable lanes. The partitioner has no cross-lane conflict notion, so the + fix is a lane boundary that matches the crate boundary. - **Invariants:** none touched. This is agent policy — **R2** by the class matrix — not a data-plane change; invariants #1 and #10 are unaffected and their blocking status at R3 is unchanged. diff --git a/docs/runbooks/orchestrate.md b/docs/runbooks/orchestrate.md index 17cd593a..9eb4a0ed 100644 --- a/docs/runbooks/orchestrate.md +++ b/docs/runbooks/orchestrate.md @@ -74,6 +74,21 @@ Cadence — pick one, never something that keeps a session alive between cycles: watch -n 900 ./.claude/skills/orchestrate/tick.sh ``` +### When ticks stop happening + +`tick.lock` holds the owning pid and a **live** holder is respected. Two sharp edges in that +scheme, both upstream in the supplied `tick.sh`: + +- **A recycled pid stalls the loop.** The lock stores a bare pid with no boot-id or start + time, so if that number belongs to *any* live process — trivially true for low pids after a + reboot — every tick logs `cycle already running (pid N)` and exits 0, forever. Symptom: + `tick.log` repeating that line and no cycles landing. Recovery: confirm no cycle is really + running, then `rm -f .claude/loop/tick.lock`. +- **The stale-lock reclaim is not atomic** — it `rm`s the dead holder's lock and then creates + its own, so two ticks that both see a dead pid can both acquire, and the first to finish + deletes the other's lock on exit. Keep the scheduler interval comfortably above a cycle's + runtime, and do not run `tick.sh` by hand while cron is armed. + ## Lane map — what makes a lane single-writer `orchestrate.config.json` partitions work by lane; `lib/partition.mjs` enforces it in code. @@ -89,9 +104,10 @@ Collisions cannot surface at merge if colliding lanes are never *assigned* at on | `wrtc` | `multiview-webrtc/**`, `multiview-preview/src/whep*`, `control/src/routes/whip*` | `webrtc/src/transport/mod.rs`; `session.rs` (one `[::]` socket session table shared by WHIP ingest / WHEP serve / WHIP push) | | `input` | `multiview-input/**`, `multiview-rist-sys/**` | `input/src/lib.rs` (24 cfg-gated `pub mod`), `input/Cargo.toml` `[features]` | | `preview` | `multiview-preview/**` minus WHEP transport | `preview/src/tap.rs` (the refcounted lazy-start tap registry), `encode.rs` (shared encoder pool) | -| `engine` | `multiview-engine/**` minus runtime/clock/drive, `multiview-hal/src/load.rs` | `engine/src/lib.rs`, `supervisor.rs` task registration | -| `gpu` | `multiview-compositor/**`, `multiview-framestore/**`, `multiview-ffmpeg/**`, `multiview-hal/src/select.rs` | **scoped** — one writer per crate; no file is shared across them (framestore depends only on core; ffmpeg is the FFI leaf) | -| `audio` | `multiview-audio/**`, `multiview-overlay/**` | **scoped** — one writer per crate; verified no shared source file (overlay depends only on core) | +| `engine` | `multiview-engine/**` minus runtime/clock/drive | `engine/src/lib.rs` (24 `pub mod`), `supervisor.rs` task registration | +| `gpu` | `multiview-compositor/**`, `multiview-framestore/**`, `multiview-ffmpeg/**` | `compositor/src/{lib,pipeline}.rs` (invariant #8 order), `gpu/shaders/common.wgsl`. The three crates share no file — see the scoping note below for why the lane is serial anyway | +| `hal` | `multiview-hal/**` | `hal/src/lib.rs` declares both `load` (the engine's concern) and `select` (the gpu lane's), and both share `hal/Cargo.toml`. Splitting `hal` across two lanes put one shared file in two lanes at once, which `partition.mjs` cannot express — so `hal` is its own lane | +| `audio` | `multiview-audio/**`, `multiview-overlay/**` | `audio/src/{lib,mixer}.rs`, `overlay/src/{lib,resolve}.rs`. The two crates share no file (overlay depends only on core) — again see the scoping note | | `bcast` | `control/src/{nmos,is07}*`, `multiview-output/**` | `output/src/lib.rs` (17 cfg-gated mods), `sink.rs` + `fanout.rs` (encode-once-mux-many, invariant #7) | | `web` | `web/**` | `web/src/app/router.tsx`, `navigation.tsx`, `src/locales/*/messages.po` (`lingui extract` rewrites all three), generated `src/api/schema.ts` | | `devices` | zowietek / display-kms / sync / cast / node-enroll, `deploy/**` | `control/src/devices/{mod,driver_registry,registry}.rs` — every driver registers in all three | @@ -99,6 +115,14 @@ Collisions cannot surface at merge if colliding lanes are never *assigned* at on | `telemetry` | `multiview-telemetry/**` | `telemetry/src/lib.rs` mod list | | `gov` | `.claude/**`, `docs/{decisions,research,runbooks}`, `docs/development/work-schedule.md`, `.github/workflows/**`, build pins | `docs/decisions/README.md` (every ADR appends a row), `.github/workflows/ci.yml`, `AGENTS.md` | +**Nothing is `scoped` here, deliberately.** `gpu` and `audio` would each parallelise safely +per crate, but `lib/partition.mjs` admits a *second* item into a scoped lane when the first +declares no `scope`: it reserves the lane but not the scope namespace, so an unscoped item — +which may touch any crate in the lane — and a scoped sibling can land in the same wave. +Reproduce with `partitionWave([{number:1,lane:'gpu',blockedBy:[]},{number:2,lane:'gpu',scope:'compositor',blockedBy:[]}], +{knownLanes:['gpu'],scopedLanes:['gpu'],maxWave:5})` → `wave:[1,2]`. Until that is fixed +upstream, `scopedLanes` stays empty and every lane is single-writer — the documented default. + Rules that ride with the map: - **When two items genuinely need the same lane in one wave, serialize them under one @@ -153,6 +177,20 @@ Each of these cost an incident once. `git fetch origin && git pull --ff-only origin main` in the root so the next cycle bases on current HEAD. +## Record — what a cycle must leave behind + +The next cycle starts with an empty context window, so anything not written down is lost. + +- **Flip the board.** Tick the Part-2 checklist box and set the item's Part-3 `Status:` in + `docs/development/work-schedule.md`, adding the red→green commit SHAs and the PR number + inline on the item. +- **`qdrant-store` proactively** — every non-obvious decision, operator correction and + hard-won gotcha, the moment you learn it, not when asked. See [memory-mcp](memory-mcp.md) + and the [`memory` skill](../../.claude/skills/memory/SKILL.md). +- **Write or refresh a resource's runbook in the same change** that provisioned or altered it. +- An **ADR** when the decision constrains future changes — required at R3, not every cycle + ([`adr` skill](../../.claude/skills/adr/SKILL.md)). + ## Salvage — an orphaned `locked` lane whose owning pid is dead Never force-remove a `locked` worktree belonging to a **live** session. When the pid is diff --git a/orchestrate.config.json b/orchestrate.config.json index a1495dab..0da41142 100644 --- a/orchestrate.config.json +++ b/orchestrate.config.json @@ -10,6 +10,7 @@ "preview", "engine", "gpu", + "hal", "audio", "bcast", "web", @@ -26,6 +27,9 @@ "input", "preview", "engine", + "gpu", + "hal", + "audio", "bcast", "web", "devices", @@ -33,7 +37,7 @@ "telemetry", "gov" ], - "scopedLanes": ["gpu", "audio"], + "scopedLanes": [], "maxWave": 5, "maxOpenPRs": 3, "maxFilePerSweep": 10,