From 7b1efc2d4a50a63114fd04ce4004d3b8bdb15b0e Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:25:45 -0700 Subject: [PATCH 01/10] feat: carry-over groundwork for 1.6.3 - Ruling ledger events record the role: by is implementer for implement and fix agents, reviewer for review and re-review agents. - The adjudicator's prompt shows the run rulings (pre-flight's, and its own pre-flight ruling), so it decides with them in force. - The run result keeps tasks..minor_findings (the approving review's, done tasks only) and tasks..rulings, so a later launch can read them. - A relaunch with every task done and reviewed runs no pre-flight and the dry run plans none (preflight.skipped: "no task left to run"). - reference.md: quitting Claude Code (2.1.300+) during a check kills it; the wait reports stopped, reason died. --- reference.md | 22 +++++++--- run.workflow.js | 42 +++++++++++++++---- src/adjudicator.js | 3 ++ src/phases.js | 19 ++++++++- src/plan.js | 10 ++++- src/prompts-task.js | 10 +++-- tests/dryrun.test.mjs | 10 +++++ tests/engine.test.mjs | 16 ++++++++ tests/run.test.mjs | 94 ++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 206 insertions(+), 20 deletions(-) diff --git a/reference.md b/reference.md index ab54d48..7f2e594 100644 --- a/reference.md +++ b/reference.md @@ -274,8 +274,12 @@ run is still not accepted. A security-flagged task is never parked or unblocked, findings (`adjudicator_stop: security`), and `allow_deferral: false` refuses it for every task (`adjudicator_stop: deferral_not_allowed`). A task adjudicated twice and still blocked ends with `adjudication_cap`. Every ruling is a -ledger `ruling` event `Ruling: decision - why - cost if wrong`; pre-flight rulings are in -`preflight.rulings`. +ledger `ruling` event `Ruling: decision - why - cost if wrong` whose `by` names its role: +`adjudicator`, `implementer` (implement and fix agents) or `reviewer` (review and re-review +agents); a ruling with no `by` comes from an implementer before 1.6.3. Pre-flight rulings are +in `preflight.rulings`. A task's adjudicator rulings are also in the run result's +`tasks..rulings`, and its approving review's minor findings in `tasks..minor_findings` +(`[]` unless the task was done in that launch), so a later launch can read both. Pre-flight also returns `code_deps` (`[{task, producer, what}]`, required, may be empty): code a task needs from another task's work. An entry the run order already meets (the placement rule @@ -286,11 +290,14 @@ with `preflight_conflicts` in both modes (the adjudicator is not called), and `preflight.schedule` lists each as `{task, producer, what, fix}`, `fix` being the move that meets it (`move T to join after P`, `move T after P in the prelude`, or `move T to join, or into P's lane after it`). `preflight.schedule` is always present on a full-profile run (`[]` when there -is none) and is `[]` under lite, which has no pre-flight agent. +is none) and is `[]` under lite, which has no pre-flight agent. A relaunch with every task done +and reviewed has none either: there is no task left to check, and a pre-flight there could only +re-rule work already built. Its `preflight` is empty with `skipped: "no task left to run"`, and +the dry run plans no pre-flight agent. Run rulings: pre-flight's own rulings, then the adjudicator's ruling on pre-flight conflicts, -reach every task not yet done and every final reviewer as "Rulings already made for this run -(binding)", in that order, each quoted as written, so no agent decides a settled point again. +reach every task not yet done, every adjudicator and every final reviewer as "Rulings already +made for this run (binding)", in that order, each quoted as written, so no agent decides a settled point again. The workflow builds that list (`run_rulings`) itself during the run and never reads it from the manifest file. The user's answers (`notes`) keep their own label, and an adjudicator's `answer` or `clarify_plan` for one task reaches only that task. Both appear in the hand-back under "Rulings made on your behalf". Under @@ -485,6 +492,11 @@ to run longer than 9 minutes) goes through one procedure built on again and report the checks as failed, never as passed, with both outcomes the wait printed. +Quitting Claude Code while a check runs stops it: from Claude Code 2.1.300, exit force-kills a +background command still running 1.5 seconds after being asked to stop. The wait then reports +`stopped` with reason `died` (exit 5), and anything the killed check left in the checkout shows +as `checks_unclean`. + A report with `tree_changed` true (below) means the checkout changed while the checks ran: run them once more, with a new reserve. Rerun only once; if the checkout changed again, keep that result and say in the notes that the checks themselves change the checkout. diff --git a/run.workflow.js b/run.workflow.js index e9f7224..37b7d7b 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -852,7 +852,7 @@ function planAgents(m) { } }; - if (!lite) add('Pre-flight', null, null, 'preflight', standard); + if (!lite && preflightNeeded(m)) add('Pre-flight', null, null, 'preflight', standard); addTasks('Prelude', null, m.prelude); for (const lane of m.lanes) addTasks(lane.name, lane.id, lane.tasks); if (!lite) { @@ -946,6 +946,14 @@ function hasWork(m, tasks) { return tasks.some((t) => taskState(m, t.id) !== 'skip'); } +// Pre-flight checks the plan for the tasks still to run. On a relaunch where +// every task is done and reviewed there is none, and a pre-flight there could +// only re-rule work already built (f1a's resume: a new ruling changed built +// code through the final review), so neither the run nor the plan has one. +function preflightNeeded(m) { + return hasWork(m, [...m.prelude, ...m.lanes.flatMap((l) => l.tasks), ...m.join]); +} + // The task_checks entry a unit uses (spec 4.1): its task's, or for a batch // unit its last task's; null when there is none. checks lists test, lint and // build in that order: a group the entry has replaces the ledger lane's @@ -1470,13 +1478,15 @@ function commitWithLine(message) { // {notes, amendments}: notes are decided on the user's behalf in this run (an // adjudicator answer, or a note an unblocked task carries to the next one); // amendments are adjudicator rulings that amend the task's brief for this run. -function taskContext(m, task, where, guidance = null) { +// by is the role a ruling the agent records carries: 'implementer' (implement +// and fix) or 'reviewer' (review and re-review). +function taskContext(m, task, where, guidance = null, by = 'implementer') { const files = taskFiles(m, task); const tasks = unitTasks(task); const userNote = (t) => m.notes && m.notes[t.id]; // A batch records its rulings under its first task. const ruling = ledgerCommand(m, where.lane, - { task: tasks[0].id, event: 'ruling', text: 'Ruling: - - ' }, where.dir); + { task: tasks[0].id, event: 'ruling', by, text: 'Ruling: - - ' }, where.dir); const runNotes = (guidance && guidance.notes) || []; const amendments = (guidance && guidance.amendments) || []; const briefs = isBatch(task) ? [ @@ -1853,7 +1863,7 @@ function reviewPrompt(m, task, where, base, head, rounds = 0, guidance = null) { ...scopeLines(task), taskEvidenceLine(m, task), '', - taskContext(m, task, where, guidance), + taskContext(m, task, where, guidance, 'reviewer'), '', reviewResultText(m, task, where, rounds, head), ].join('\n'); @@ -1927,7 +1937,7 @@ function reReviewPrompt(m, task, where, base, head, findings, round = 1, guidanc ...scopeLines(task), taskEvidenceLine(m, task), '', - taskContext(m, task, where, guidance), + taskContext(m, task, where, guidance, 'reviewer'), '', 'List every finding still open, and any new critical or important problem the fix introduced, as findings.', reviewResultText(m, task, where, round, head), @@ -3059,6 +3069,9 @@ function adjudicatorPrompt(m, ctx) { ` ${brief}`, ); } + // The run's rulings bind the adjudicator too (pre-flight's, and its own + // pre-flight ruling once made): runRulingsLines, [] when there are none. + parts.push(...runRulingsLines(m)); parts.push( '', 'Details (the diff range, the implementer report file, and the blocked reason or question, or the', @@ -4463,7 +4476,9 @@ function scheduleFix(m, entry) { // The whole run. io = {agent, log, phase, parallel}. Returns the report: // {status:'complete'|'stopped'|'preflight_conflicts'|'invalid', run_id, -// tasks:{:{status, rounds, tier_used, commits:[base,head]|null, notes}}, +// tasks:{:{status, rounds, tier_used, commits:[base,head]|null, notes, +// minor_findings (the approving review's, taskMinorFindings; [] unless done +// in this launch), rulings (the adjudicator's rulings for the task, [] when none)}}, // stopped_lanes:[{lane, task, reason}], preflight:{conflicts, rulings, undeclared, schedule}, // integrate:{status, notes, post_integrate, fix_review}, e2e:{items, checked_sha}|null, // final:{findings, fixed, declined, open, cannot_verify, missing_lenses, head, lens_heads, @@ -4489,7 +4504,9 @@ function scheduleFix(m, entry) { // (done and reviewed earlier), or not_run. Under profile lite no // pre-flight agent runs (preflight has no conflicts, rulings, undeclared or schedule // entries) and integrate stays null (validateManifest rejects lite with a -// post_integrate hook, so no configured hook is skipped). +// post_integrate hook, so no configured hook is skipped). Nor does one on a +// relaunch with every task done and reviewed (preflightNeeded): preflight is +// then empty with skipped 'no task left to run'. async function runAll(manifest, io) { // Windows paths in forward-slash form, and hooks {} when absent, before // anything reads them. @@ -4532,6 +4549,8 @@ async function runAll(manifest, io) { tier_used: null, commits: range ? [range.base, range.head] : null, notes: '', + minor_findings: [], + rulings: [], }; } // The minor findings of approved task reviews, for the final lenses (a @@ -4549,6 +4568,10 @@ async function runAll(manifest, io) { tier_used: r.tier_used, commits: present(r.base) && isSha(r.head) ? [r.base, r.head] : null, notes: r.notes, + // Kept as data for a later launch (1.6.3): the approving review's + // minor findings (done tasks only) and the adjudicator's rulings. + minor_findings: r.status === 'done' && Array.isArray(r.minor_findings) ? r.minor_findings : [], + rulings: Array.isArray(r.rulings) ? r.rulings : [], }; } }; @@ -4655,6 +4678,11 @@ async function runAll(manifest, io) { const lite = m.profile === 'lite'; if (lite) { preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [] }; + } else if (!preflightNeeded(m)) { + // A relaunch with every task done and reviewed: no pre-flight agent, so no + // rulings (preflightNeeded says why). + preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [], skipped: 'no task left to run' }; + io.log('parallel-lanes: pre-flight skipped: every task is done and reviewed'); } else { io.phase('Pre-flight'); const pre = await call('pre-flight', 'Pre-flight', preflightPrompt(m), preflightSchema()); diff --git a/src/adjudicator.js b/src/adjudicator.js index c679f3e..1ae265b 100644 --- a/src/adjudicator.js +++ b/src/adjudicator.js @@ -76,6 +76,9 @@ function adjudicatorPrompt(m, ctx) { ` ${brief}`, ); } + // The run's rulings bind the adjudicator too (pre-flight's, and its own + // pre-flight ruling once made): runRulingsLines, [] when there are none. + parts.push(...runRulingsLines(m)); parts.push( '', 'Details (the diff range, the implementer report file, and the blocked reason or question, or the', diff --git a/src/phases.js b/src/phases.js index d95ceab..a1c69ab 100644 --- a/src/phases.js +++ b/src/phases.js @@ -410,7 +410,9 @@ function scheduleFix(m, entry) { // The whole run. io = {agent, log, phase, parallel}. Returns the report: // {status:'complete'|'stopped'|'preflight_conflicts'|'invalid', run_id, -// tasks:{:{status, rounds, tier_used, commits:[base,head]|null, notes}}, +// tasks:{:{status, rounds, tier_used, commits:[base,head]|null, notes, +// minor_findings (the approving review's, taskMinorFindings; [] unless done +// in this launch), rulings (the adjudicator's rulings for the task, [] when none)}}, // stopped_lanes:[{lane, task, reason}], preflight:{conflicts, rulings, undeclared, schedule}, // integrate:{status, notes, post_integrate, fix_review}, e2e:{items, checked_sha}|null, // final:{findings, fixed, declined, open, cannot_verify, missing_lenses, head, lens_heads, @@ -436,7 +438,9 @@ function scheduleFix(m, entry) { // (done and reviewed earlier), or not_run. Under profile lite no // pre-flight agent runs (preflight has no conflicts, rulings, undeclared or schedule // entries) and integrate stays null (validateManifest rejects lite with a -// post_integrate hook, so no configured hook is skipped). +// post_integrate hook, so no configured hook is skipped). Nor does one on a +// relaunch with every task done and reviewed (preflightNeeded): preflight is +// then empty with skipped 'no task left to run'. async function runAll(manifest, io) { // Windows paths in forward-slash form, and hooks {} when absent, before // anything reads them. @@ -479,6 +483,8 @@ async function runAll(manifest, io) { tier_used: null, commits: range ? [range.base, range.head] : null, notes: '', + minor_findings: [], + rulings: [], }; } // The minor findings of approved task reviews, for the final lenses (a @@ -496,6 +502,10 @@ async function runAll(manifest, io) { tier_used: r.tier_used, commits: present(r.base) && isSha(r.head) ? [r.base, r.head] : null, notes: r.notes, + // Kept as data for a later launch (1.6.3): the approving review's + // minor findings (done tasks only) and the adjudicator's rulings. + minor_findings: r.status === 'done' && Array.isArray(r.minor_findings) ? r.minor_findings : [], + rulings: Array.isArray(r.rulings) ? r.rulings : [], }; } }; @@ -602,6 +612,11 @@ async function runAll(manifest, io) { const lite = m.profile === 'lite'; if (lite) { preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [] }; + } else if (!preflightNeeded(m)) { + // A relaunch with every task done and reviewed: no pre-flight agent, so no + // rulings (preflightNeeded says why). + preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [], skipped: 'no task left to run' }; + io.log('parallel-lanes: pre-flight skipped: every task is done and reviewed'); } else { io.phase('Pre-flight'); const pre = await call('pre-flight', 'Pre-flight', preflightPrompt(m), preflightSchema()); diff --git a/src/plan.js b/src/plan.js index d4b8b43..8ffc272 100644 --- a/src/plan.js +++ b/src/plan.js @@ -105,7 +105,7 @@ function planAgents(m) { } }; - if (!lite) add('Pre-flight', null, null, 'preflight', standard); + if (!lite && preflightNeeded(m)) add('Pre-flight', null, null, 'preflight', standard); addTasks('Prelude', null, m.prelude); for (const lane of m.lanes) addTasks(lane.name, lane.id, lane.tasks); if (!lite) { @@ -199,6 +199,14 @@ function hasWork(m, tasks) { return tasks.some((t) => taskState(m, t.id) !== 'skip'); } +// Pre-flight checks the plan for the tasks still to run. On a relaunch where +// every task is done and reviewed there is none, and a pre-flight there could +// only re-rule work already built (f1a's resume: a new ruling changed built +// code through the final review), so neither the run nor the plan has one. +function preflightNeeded(m) { + return hasWork(m, [...m.prelude, ...m.lanes.flatMap((l) => l.tasks), ...m.join]); +} + // The task_checks entry a unit uses (spec 4.1): its task's, or for a batch // unit its last task's; null when there is none. checks lists test, lint and // build in that order: a group the entry has replaces the ledger lane's diff --git a/src/prompts-task.js b/src/prompts-task.js index 4d9e0b1..79ae288 100644 --- a/src/prompts-task.js +++ b/src/prompts-task.js @@ -449,13 +449,15 @@ function commitWithLine(message) { // {notes, amendments}: notes are decided on the user's behalf in this run (an // adjudicator answer, or a note an unblocked task carries to the next one); // amendments are adjudicator rulings that amend the task's brief for this run. -function taskContext(m, task, where, guidance = null) { +// by is the role a ruling the agent records carries: 'implementer' (implement +// and fix) or 'reviewer' (review and re-review). +function taskContext(m, task, where, guidance = null, by = 'implementer') { const files = taskFiles(m, task); const tasks = unitTasks(task); const userNote = (t) => m.notes && m.notes[t.id]; // A batch records its rulings under its first task. const ruling = ledgerCommand(m, where.lane, - { task: tasks[0].id, event: 'ruling', text: 'Ruling: - - ' }, where.dir); + { task: tasks[0].id, event: 'ruling', by, text: 'Ruling: - - ' }, where.dir); const runNotes = (guidance && guidance.notes) || []; const amendments = (guidance && guidance.amendments) || []; const briefs = isBatch(task) ? [ @@ -832,7 +834,7 @@ function reviewPrompt(m, task, where, base, head, rounds = 0, guidance = null) { ...scopeLines(task), taskEvidenceLine(m, task), '', - taskContext(m, task, where, guidance), + taskContext(m, task, where, guidance, 'reviewer'), '', reviewResultText(m, task, where, rounds, head), ].join('\n'); @@ -906,7 +908,7 @@ function reReviewPrompt(m, task, where, base, head, findings, round = 1, guidanc ...scopeLines(task), taskEvidenceLine(m, task), '', - taskContext(m, task, where, guidance), + taskContext(m, task, where, guidance, 'reviewer'), '', 'List every finding still open, and any new critical or important problem the fix introduced, as findings.', reviewResultText(m, task, where, round, head), diff --git a/tests/dryrun.test.mjs b/tests/dryrun.test.mjs index d3659ae..9cdf42e 100644 --- a/tests/dryrun.test.mjs +++ b/tests/dryrun.test.mjs @@ -135,6 +135,16 @@ test('tasks in done and reviewed produce no agents', () => { assert.deepEqual(roles(forTask(agents, 'T3')), ['implement', 'review']); }); +test('a relaunch with every task done and reviewed plans no pre-flight agent', () => { + const all = ['T1', 'T2', 'T3', 'T4', 'T5']; + const agents = planAgents(manifest({ done: all, reviewed: all })); + assert.ok(!roles(agents).includes('preflight'), JSON.stringify(roles(agents))); + assert.ok(roles(agents).includes('integrate')); + // One task still to review keeps the pre-flight agent. + const one = planAgents(manifest({ done: all, reviewed: ['T1', 'T2', 'T3', 'T4'] })); + assert.ok(roles(one).includes('preflight')); +}); + test('a task in done but not reviewed produces a review agent only', () => { const agents = planAgents(manifest({ done: ['T2'], reviewed: [] })); assert.deepEqual(roles(forTask(agents, 'T2')), ['review']); diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs index 0992470..a0f8e12 100644 --- a/tests/engine.test.mjs +++ b/tests/engine.test.mjs @@ -408,6 +408,22 @@ test('ledgerCommand starts in the checkout of the agent that runs it', () => { } }); +// The ledger event a prompt's ruling command appends (its single-quoted JSON). +function rulingEvent(prompt) { + const line = prompt.split('\n').find((l) => l.includes(' append ') && l.includes('"event":"ruling"')); + assert.ok(line, 'a ruling ledger command'); + return JSON.parse(line.match(/'(\{[^']*\})'\s*$/)[1]); +} + +test('the ruling command of each task prompt records the role of the agent that runs it', () => { + const p = allPrompts(manifest()); + assert.equal(rulingEvent(p.implement).by, 'implementer'); + assert.equal(rulingEvent(p.fix).by, 'implementer'); + assert.equal(rulingEvent(p.review).by, 'reviewer'); + assert.equal(rulingEvent(p.reReview).by, 'reviewer'); + assert.equal(rulingEvent(p.implement).task, 'T2'); +}); + // Every prompt that hands an agent a Python helper command: the four task // prompts and the adjudicator's (task-brief and the ruling ledger command). function pythonPrompts(m) { diff --git a/tests/run.test.mjs b/tests/run.test.mjs index ec02cc4..4ba63b4 100644 --- a/tests/run.test.mjs +++ b/tests/run.test.mjs @@ -377,7 +377,8 @@ test('resume with every task done and reviewed: no task agents, integration stil const m = manifest({ done: [...ALL], reviewed: [...ALL], backfill: backfillFor(ALL) }); const { result, calls, logs } = await run(m, phaseScript()); assert.equal(result.status, 'complete'); - assert.deepEqual(labels(calls).slice(0, 3), ['pre-flight', 'integrate', 'post-integrate']); + // No pre-flight: no task is left to run (preflightNeeded, 1.6.3). + assert.deepEqual(labels(calls).slice(0, 2), ['integrate', 'post-integrate']); assert.ok(!calls.some((c) => c.phase.startsWith('Lane ') || c.phase === 'Prelude' || c.phase === 'Join')); for (const id of ALL) assert.equal(result.tasks[id].status, 'skipped', id); assert.ok(logs.includes('parallel-lanes: resuming run run-1: 5 tasks already committed'), JSON.stringify(logs)); @@ -2474,6 +2475,61 @@ function fixRoundScript(extra = {}) { }; } +test('a relaunch with every task done and reviewed runs no pre-flight and makes no rulings', async () => { + const resumed = manifest({ done: [...ALL], reviewed: [...ALL], backfill: backfillFor(ALL) }); + const { result, calls } = await run(resumed, phaseScript()); + assert.equal(result.status, 'complete', JSON.stringify(result)); + assert.ok(!calls.some((c) => c.label === 'pre-flight'), calls.map((c) => c.label).join(', ')); + assert.deepEqual(result.preflight, + { conflicts: [], rulings: [], undeclared: [], schedule: [], skipped: 'no task left to run' }); + assert.ok(calls.some((c) => c.label === 'integrate')); + // The final lenses get no run rulings. + assert.ok(!calls.find((c) => c.label === 'final review sp').prompt.includes(RUN_RULINGS)); +}); + +test('a relaunch with one task left to review still runs pre-flight', async () => { + const reviewed = ALL.filter((id) => id !== 'T5'); + const resumed = manifest({ done: [...ALL], reviewed, backfill: backfillFor(ALL) }); + const { result, calls } = await run(resumed, { ...phaseScript(), 'T5 review': [approve()] }); + assert.equal(result.status, 'complete', JSON.stringify(result)); + assert.ok(calls.some((c) => c.label === 'pre-flight')); + assert.equal(result.preflight.skipped, undefined); +}); + +test('pre-flight rulings reach the adjudicator of a blocked task as run rulings', async () => { + const rulings = ['Ruling: A - why - cost', 'Ruling: B - why - cost']; + const blocked = { status: 'blocked', head: 'T2-b', tests: '', notes: 'stuck' }; + const { result, calls } = await run(manifest(), { + ...phaseScript({ 'pre-flight': [{ conflicts: [], rulings, undeclared: [], code_deps: [] }] }), + ...taskScript(ALL), + 'T2 implement': [blocked], 'T2 adjudicate': [{ outcome: 'park', text: 'park it', head: H('T1-h') }], + }); + assert.equal(result.tasks.T2.status, 'deferred', JSON.stringify(result.tasks.T2)); + const adj = calls.find((c) => c.label === 'T2 adjudicate'); + assert.ok(adj.prompt.includes(`${RUN_RULINGS}\n- Ruling: A - why - cost\n- Ruling: B - why - cost`), adj.prompt); +}); + +test("pre-flight's own rulings reach the adjudicator of its conflicts", async () => { + const { calls } = await run(manifest(), { + ...phaseScript({ 'pre-flight': [{ conflicts: ['T2 vs T4 overlap'], rulings: ['Ruling: A - why - cost'], + undeclared: [], code_deps: [] }] }), + ...taskScript(ALL), + 'run adjudicate': [{ outcome: 'answer', text: 'use the v2 contract' }], + }); + const adj = calls.find((c) => c.label === 'run adjudicate'); + assert.ok(adj.prompt.includes(`${RUN_RULINGS}\n- Ruling: A - why - cost`), adj.prompt); +}); + +test('an adjudicator with no run rulings gets no rulings heading', async () => { + const blocked = { status: 'blocked', head: 'T2-b', tests: '', notes: 'stuck' }; + const { calls } = await run(manifest(), { + ...phaseScript({ 'pre-flight': [{ conflicts: [], rulings: [], undeclared: [], code_deps: [] }] }), + ...taskScript(ALL), + 'T2 implement': [blocked], 'T2 adjudicate': [{ outcome: 'park', text: 'park it', head: H('T1-h') }], + }); + assert.ok(!calls.find((c) => c.label === 'T2 adjudicate').prompt.includes(RUN_RULINGS)); +}); + test('pre-flight rulings reach every task prompt and the final lenses as run rulings', async () => { const rulings = ['Ruling: A - why - cost', 'Ruling: B - why - cost']; const { result, calls } = await run(manifest(), @@ -2674,6 +2730,42 @@ test('final lenses get the open minor findings of approved task reviews', async } }); +test("the result keeps each task's approved minor findings and its rulings", async () => { + const blocked = { status: 'blocked', head: 'T3-b', tests: '', notes: 'which key?' }; + const { result } = await run(manifest(), { + ...phaseScript(), + ...taskScript(ALL), + 'T2 review': [{ verdict: 'approve', findings: [minorT2], cannot_verify: [] }], + 'T3 implement': [blocked, done('T3-b', H('T3-h'))], + 'T3 adjudicate': [{ outcome: 'answer', text: 'Ruling: use the id key - stable - a rename later' }], + }); + assert.equal(result.status, 'complete', JSON.stringify(result)); + assert.deepEqual(result.tasks.T2.minor_findings, + [{ id: 'T2-1', severity: 'minor', file: 'src/T2.js', line: 5, issue: 'rename the helper', fix: 'rename it' }]); + assert.deepEqual(result.tasks.T2.rulings, []); + assert.deepEqual(result.tasks.T3.rulings, ['Ruling: use the id key - stable - a rename later']); + assert.deepEqual(result.tasks.T3.minor_findings, []); +}); + +test('tasks the run did not do carry empty minor findings and rulings', async () => { + const resumed = manifest({ done: ['T1'], reviewed: ['T1'], backfill: backfillFor(['T1']) }); + const { result } = await run(resumed, { ...phaseScript(), ...taskScript(['T2', 'T3', 'T4', 'T5']) }); + assert.equal(result.tasks.T1.status, 'skipped'); + assert.deepEqual([result.tasks.T1.minor_findings, result.tasks.T1.rulings], [[], []]); + // T2 stops lane alpha, so T3 and the join's T5 never run (T4's lane beta does). + const stopped = await run(manifest(), { + ...phaseScript(), ...taskScript(['T1', 'T4']), + 'T2 implement': [{ status: 'blocked', head: 'T2-b', tests: '', notes: 'stuck' }], + 'T2 adjudicate': [{ outcome: 'stop', text: 'cannot', stop_condition: 'plan_broken' }], + }); + assert.equal(stopped.result.status, 'stopped'); + for (const id of ['T3', 'T5']) { + const t = stopped.result.tasks[id]; + assert.equal(t.status, 'not_run', id); + assert.deepEqual([t.minor_findings, t.rulings], [[], []], id); + } +}); + test('a task minor no final lens raised is listed in final.task_minors_open', async () => { const base = { ...phaseScript(), From 9c1466878529adc341bf8719ca48bbe2f6799859 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:10:45 -0700 Subject: [PATCH 02/10] feat: ruling ids, roles, scope and ledger rulings --- scripts/ledger | 142 +++++++++++++++++++++++++++++++++++- tests/ledger-carry.test.mjs | 108 +++++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 tests/ledger-carry.test.mjs diff --git a/scripts/ledger b/scripts/ledger index 12eb362..42fe79b 100755 --- a/scripts/ledger +++ b/scripts/ledger @@ -169,7 +169,14 @@ REQUIRED = { "started": "base", "run_ended": "status", "accepted": "text", + "minors": "open", + "carried": "wf", } +RULING_ROLES = ("implementer", "reviewer", "adjudicator", "preflight") +RULING_SCOPES = ("lane", "run") +PF_ID = re.compile(r"^PF-[1-9][0-9]*$") +# Events only the _run file holds: one per launch, written by carry-launch. +RUN_ONLY = ("minors", "carried") SETTLED_OUTCOMES = ("park", "unblock") START_POINTS = {"setup": "prelude", "join": "join"} GIT_REDIRECTS = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", @@ -247,11 +254,57 @@ def entry_error(entry): elif event == "accepted": if not is_text(value) or not is_text(entry.get("head")): return "'text' and 'head' must be non-empty strings" + elif event == "ruling": + if not isinstance(value, str): + return "'text' must be a string" + return ruling_fields_error(entry) + elif event in RUN_ONLY: + return run_only_error(entry) elif not isinstance(value, str): return f"'{key}' must be a string" return None +def is_text_list(v): + return isinstance(v, list) and all(is_text(x) for x in v) + + +def ruling_fields_error(entry): + """Why a ruling's optional 1.6.3 fields are malformed, or None. The values + are checked; whether an agent told the truth about its role is not.""" + if "by" in entry and entry["by"] not in RULING_ROLES: + return f"'by' must be one of {', '.join(RULING_ROLES)}" + if "scope" in entry and entry["scope"] not in RULING_SCOPES: + return f"'scope' must be one of {', '.join(RULING_SCOPES)}" + for field in ("overrides", "files"): + if field in entry and not is_text_list(entry[field]): + return f"'{field}' must be a list of non-empty strings" + if "pf" in entry and not (isinstance(entry["pf"], str) and PF_ID.fullmatch(entry["pf"])): + return "'pf' must look like PF-1" + if "wf" in entry and not is_text(entry["wf"]): + return "'wf' must be a non-empty string" + return None + + +def run_only_error(entry): + """Why a minors or carried event is malformed, or None.""" + if entry["task"] != "_run": + return f"a {entry['event']} event belongs to task _run" + if not is_text(entry.get("wf")): + return "'wf' must be a non-empty string" + if entry["event"] == "minors": + if not isinstance(entry.get("open"), list) or not all(isinstance(m, dict) for m in entry["open"]): + return "'open' must be a list of objects" + if not is_text_list(entry.get("covered", [])): + return "'covered' must be a list of task ids" + else: + if not isinstance(entry.get("pf", {}), dict): + return "'pf' must be an object" + if not isinstance(entry.get("bound", []), list): + return "'bound' must be a list" + return None + + def write_entry(directory, lane, entry): if not LANE.fullmatch(lane): fail(3, f"unsafe lane name {lane!r}") @@ -334,6 +387,11 @@ def reviewed(directory, lane, task, rounds, plan, checkout, head, blocking, spec def read_events(directory): """Every valid event in ledger order (files by name, then lines).""" + return [entry for _, _, entry in read_events_at(directory)] + + +def read_events_at(directory): + """Every valid event as (file name, line number, event), in ledger order.""" events = [] if not os.path.isdir(directory): return events @@ -355,7 +413,7 @@ def read_events(directory): error = "invalid JSON" if entry is None else entry_error(entry) if error: fail(3, f"{path}:{number}: {error}") - events.append(entry) + events.append((name, number, entry)) return events @@ -530,6 +588,84 @@ def summarize(events, plan_text=None, plan_given=False, spec_sha=None, plan_sha= }, history +def ruling_records(directory): + """Every ruling as a dict: id (-R, n counting that file's + rulings in append order), stem, file, line, role, scope and the event. + A ruling with no by was written by a task agent before 1.6.3 and counts + as an implementer's.""" + out = [] + counts = {} + for name, number, e in read_events_at(directory): + if e.get("event") != "ruling": + continue + stem = name[:-len(".jsonl")] + counts[stem] = counts.get(stem, 0) + 1 + scope = e.get("scope") or ("run" if stem == "_run" else "lane") + out.append({"id": f"{stem}-R{counts[stem]}", "stem": stem, "file": name, "line": number, + "role": e.get("by") or "implementer", "scope": scope, "event": e}) + return out + + +def carried_events(directory): + return [e for e in read_events(directory) if e.get("event") == "carried"] + + +def resolve_overrides(records, carried): + """{ruling id: [(target id, known)]}: a PF- a ruling names resolves + through the carried event that bound that ruling (carry-launch); any + other id must be a ruling of this ledger.""" + ids = {r["id"] for r in records} + bound = {} + for c in carried: + for b in c.get("bound") or []: + if isinstance(b, dict): + bound[(b.get("file"), b.get("line"))] = c.get("pf") or {} + out = {} + for r in records: + targets = [] + for o in r["event"].get("overrides") or []: + if PF_ID.fullmatch(o): + mapped = bound.get((r["file"], r["line"]), {}).get(o) + targets.append((mapped or o, mapped is not None and mapped in ids)) + else: + targets.append((o, o in ids)) + out[r["id"]] = targets + return out + + +def ruling_line(r, targets): + e = r["event"] + line = f"{r['id']} [{r['role']}, {e['task']}, {r['scope']}] {e['text']}" + if targets: + line += " (overrides " + ", ".join(t if known else f"{t} (unknown)" for t, known in targets) + ")" + if e.get("files"): + line += " (files " + ", ".join(e["files"]) + ")" + return line + + +def rulings(directory, lane=None): + """Print the rulings in force, then the overridden ones. With lane: the + rulings of _run, the prelude and that lane, and every run-scope one.""" + records = ruling_records(directory) + overrides = resolve_overrides(records, carried_events(directory)) + overridden_by = {} + for r in records: + for target, known in overrides[r["id"]]: + if known: + overridden_by.setdefault(target, r["id"]) + + def shown(r): + return lane is None or r["stem"] in ("_run", "prelude", lane) or r["scope"] == "run" + + in_force = [ruling_line(r, overrides[r["id"]]) for r in records if shown(r) and r["id"] not in overridden_by] + gone = [f"{r['id']} by {overridden_by[r['id']]}" for r in records if shown(r) and r["id"] in overridden_by] + lines = in_force or ["(none)"] + if gone: + lines += ["Overridden:"] + gone + for line in lines: + print(line) + + def load_manifest(manifest_path): """The manifest, with its task lists checked to be readable.""" try: @@ -779,7 +915,7 @@ def conditions(directory, wf_id, doctor_file): PATH_ARGS = {"append": (1,), "reviewed": (1, 5, 6, 9), "status": (1,), "backfill": (1, 2), "accept": (1, 2), - "ended": (1,), "conditions": (1, 3)} + "ended": (1,), "conditions": (1, 3), "rulings": (1,)} PATH_OPTIONS = ("--plan", "--spec", "--manifest") @@ -823,6 +959,8 @@ def main(argv): ended(*argv[1:]) elif len(argv) == 4 and argv[0] == "conditions" and argv[1] and argv[3]: conditions(*argv[1:]) + elif argv[:1] == ["rulings"] and len(argv) in (2, 4) and argv[1] and (len(argv) == 2 or argv[2] == "--lane"): + rulings(argv[1], argv[3] if len(argv) == 4 and argv[3] != "-" else None) else: fail(2, "usage: ledger append DIR LANE JSON" " | ledger reviewed DIR LANE TASK ROUNDS PLAN CHECKOUT HEAD BLOCKING [SPEC]" diff --git a/tests/ledger-carry.test.mjs b/tests/ledger-carry.test.mjs new file mode 100644 index 0000000..f471c7a --- /dev/null +++ b/tests/ledger-carry.test.mjs @@ -0,0 +1,108 @@ +// 1.6.3 review carry-over: ruling roles, ids, scope and overrides; the +// rulings print; carry-launch and open-minor snapshots; the owner map. +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { SKILL_DIR } from './harness.mjs'; +import { tempDir, mergeEnv } from './platform.mjs'; + +const SCRIPTS = join(SKILL_DIR, 'scripts'); +const TMP = tempDir('pl-carry-'); +after(() => rmSync(TMP, { recursive: true, force: true })); + +let counter = 0; +function freshDir(name = 'ledger') { + counter += 1; + const dir = join(TMP, `case ${counter}`, name); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function run(script, args, env = {}) { + const res = spawnSync('python3', [join(SCRIPTS, script), ...args], { + encoding: 'utf8', env: mergeEnv(process.env, env), + }); + return { code: res.status, stdout: res.stdout, stderr: res.stderr }; +} +const ledger = (...args) => run('ledger', args); + +function appendOk(dir, lane, entry) { + const res = ledger('append', dir, lane, JSON.stringify(entry)); + assert.equal(res.code, 0, res.stderr); +} + +function rulingsOut(dir, ...extra) { + const res = ledger('rulings', dir, ...extra); + assert.equal(res.code, 0, res.stderr); + return res.stdout; +} + +// ---- Task 1: fields, ids, rulings ---- + +test('append refuses a ruling role, scope or overrides of the wrong kind', () => { + const dir = freshDir(); + for (const bad of [{ by: 'boss' }, { scope: 'all' }, { overrides: 'x' }, { files: 'a.js' }, { pf: 'P1' }]) { + const res = ledger('append', dir, 'alpha', JSON.stringify({ task: 'T2', event: 'ruling', text: 'R', ...bad })); + assert.equal(res.code, 2, `${JSON.stringify(bad)}: ${res.stderr}`); + } + appendOk(dir, 'alpha', { + task: 'T2', event: 'ruling', text: 'R', by: 'implementer', scope: 'run', overrides: ['alpha-R1'], + files: ['src/a.js'], + }); + appendOk(dir, '_run', { task: '_run', event: 'ruling', text: 'P', by: 'preflight', pf: 'PF-1', wf: 'wf_abcdef-123' }); +}); + +test('minors and carried events belong to _run only', () => { + const dir = freshDir(); + assert.equal(ledger('append', dir, 'alpha', JSON.stringify({ task: 'T2', event: 'minors', open: [] })).code, 2); + assert.equal(ledger('append', dir, '_run', JSON.stringify({ task: '_run', event: 'minors', open: 'x' })).code, 2); + appendOk(dir, '_run', { task: '_run', event: 'minors', wf: 'wf_abcdef-123', open: [], covered: [] }); + appendOk(dir, '_run', { task: '_run', event: 'carried', wf: 'wf_abcdef-123', pf: {}, bound: [] }); +}); + +test('rulings numbers each file in append order and prints role, task and scope', () => { + const dir = freshDir(); + appendOk(dir, 'alpha', { task: 'T2', event: 'ruling', text: 'Ruling: a - b - c', by: 'implementer' }); + appendOk(dir, 'alpha', { task: 'T3', event: 'ruling', text: 'Ruling: d - e - f', by: 'adjudicator', scope: 'run' }); + appendOk(dir, 'beta', { task: 'T4', event: 'ruling', text: 'Ruling: g - h - i' }); + const out = rulingsOut(dir); + assert.ok(out.includes('alpha-R1 [implementer, T2, lane] Ruling: a - b - c'), out); + assert.ok(out.includes('alpha-R2 [adjudicator, T3, run] Ruling: d - e - f'), out); + // A ruling with no by is an implementer's (written before 1.6.3). + assert.ok(out.includes('beta-R1 [implementer, T4, lane] Ruling: g - h - i'), out); +}); + +test('rulings --lane shows _run, the prelude, the lane and run-scope rulings only', () => { + const dir = freshDir(); + appendOk(dir, '_run', { task: '_run', event: 'ruling', text: 'RUN', by: 'adjudicator' }); + appendOk(dir, 'prelude', { task: 'T1', event: 'ruling', text: 'PRE', by: 'implementer' }); + appendOk(dir, 'alpha', { task: 'T2', event: 'ruling', text: 'ALPHA', by: 'implementer' }); + appendOk(dir, 'beta', { task: 'T4', event: 'ruling', text: 'BETA', by: 'implementer' }); + appendOk(dir, 'beta', { task: 'T4', event: 'ruling', text: 'BETA-RUN', by: 'implementer', scope: 'run' }); + const out = rulingsOut(dir, '--lane', 'alpha'); + for (const want of ['RUN', 'PRE', 'ALPHA', 'BETA-RUN']) assert.ok(out.includes(`] ${want}`), `${want}: ${out}`); + assert.ok(!out.includes('] BETA\n'), out); + assert.ok(out.includes('beta-R2 ['), 'ids count the whole file, not the filtered view'); +}); + +test('an overridden ruling is listed apart with the ruling that overrode it', () => { + const dir = freshDir(); + appendOk(dir, 'alpha', { task: 'T2', event: 'ruling', text: 'OLD', by: 'implementer' }); + appendOk(dir, 'alpha', { task: 'T3', event: 'ruling', text: 'NEW', by: 'adjudicator', overrides: ['alpha-R1'] }); + appendOk(dir, 'alpha', { task: 'T3', event: 'ruling', text: 'GHOST', by: 'adjudicator', overrides: ['zeta-R9'] }); + const out = rulingsOut(dir); + const [inForce, overridden] = out.split('Overridden:'); + assert.ok(inForce.includes('alpha-R2 [adjudicator, T3, lane] NEW (overrides alpha-R1)'), out); + assert.ok(inForce.includes('(overrides zeta-R9 (unknown))'), out); + assert.ok(!inForce.includes('] OLD'), out); + assert.ok(overridden.includes('alpha-R1 by alpha-R2'), out); +}); + +test('rulings lists the files a ruling permits, and prints (none) for an empty ledger', () => { + const dir = freshDir(); + assert.equal(rulingsOut(dir).trim(), '(none)'); + appendOk(dir, 'alpha', { task: 'T2', event: 'ruling', text: 'MAY', by: 'adjudicator', files: ['src/b.js', 'src/c.js'] }); + assert.ok(rulingsOut(dir).includes('MAY (files src/b.js, src/c.js)')); +}); From 672cb422ba16388667977c53c41e94f169f71f11 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:13:44 -0700 Subject: [PATCH 03/10] feat: ledger carry-launch and open-minor snapshots --- scripts/ledger | 149 +++++++++++++++++++++++++++++++++++- tests/ledger-carry.test.mjs | 132 ++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 2 deletions(-) diff --git a/scripts/ledger b/scripts/ledger index 42fe79b..33e691c 100755 --- a/scripts/ledger +++ b/scripts/ledger @@ -143,6 +143,7 @@ or task name, an unreadable ledger, plan or manifest, or a backfill error. """ import hashlib +import glob import json import os import posixpath @@ -731,15 +732,154 @@ def undeclared_files(m, ranges): return out +def latest_minors(events): + """The latest open-minors snapshot (carry-launch), or None.""" + snaps = [e for e in events if e.get("event") == "minors"] + return snaps[-1] if snaps else None + + +def overrides_list(directory): + """Every ruling that names others it departs from: [{id, by, task, + overrides, unknown}], overrides resolved (PF-n through carry-launch), + unknown the ids that resolve to no ruling.""" + records = ruling_records(directory) + resolved = resolve_overrides(records, carried_events(directory)) + return [{"id": r["id"], "by": r["role"], "task": r["event"]["task"], + "overrides": [t for t, _ in resolved[r["id"]]], + "unknown": [t for t, known in resolved[r["id"]] if not known]} + for r in records if resolved[r["id"]]] + + def status(directory, plan=None, spec=None, manifest_path=None): plan_text = read_text(plan, "plan") if plan is not None else None manifest = load_manifest(manifest_path) if manifest_path is not None else None - result, _ = summarize(read_events(directory), plan_text, plan is not None, + events = read_events(directory) + result, _ = summarize(events, plan_text, plan is not None, file_sha256(spec) if spec is not None else None, file_sha256(plan) if plan is not None else None, manifest) + snap = latest_minors(events) + result["minors"] = {"open": [m.get("id") for m in snap["open"]] if snap else [], + "covered": list(snap.get("covered") or []) if snap else []} + result["overrides"] = overrides_list(directory) print(json.dumps(result)) +WF_ID = re.compile(r"^wf_[a-z0-9-]{6,64}$") + + +def find_launch_file(source): + """The Workflow result file of a launch: source is its transcript dir, or + its wf id (a launch whose session died), looked up under the Claude config + dir as projects/*/*/workflows/.json. None when there is none.""" + if WF_ID.fullmatch(source) and not os.path.isdir(source): + config = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".claude") + found = sorted(glob.glob(os.path.join(glob.escape(config), "projects", "*", "*", "workflows", source + ".json"))) + return found[0] if found else None + for path in _shell.result_paths(source): + if os.path.isfile(path): + return path + return None + + +def open_minors(previous, result, args): + """The task minors still open after a launch, and the tasks covered. + + previous is the last snapshot event (or None). A task done in this launch + replaces its entries with its approving review's minors less those this + launch's final review raised (none raised without a final review). A + carried minor this launch's final raised drops out.""" + prev_open = list((previous or {}).get("open") or []) + covered = list((previous or {}).get("covered") or []) + tasks = result.get("tasks") if isinstance(result.get("tasks"), dict) else {} + final = result.get("final") if isinstance(result.get("final"), dict) else None + done_here = [t for t, v in tasks.items() if isinstance(v, dict) and v.get("status") == "done"] + carried_open = ((args or {}).get("carried_minors") or {}).get("open") or [] + still_carried = set((final or {}).get("carried_minors_open") or []) if final is not None else set(carried_open) + raised_carried = set(carried_open) - still_carried + task_open = None if final is None else {m.get("id") for m in final.get("task_minors_open") or [] + if isinstance(m, dict)} + out = [m for m in prev_open if m.get("task") not in done_here and m.get("id") not in raised_carried] + seen = {m.get("id") for m in out} + for t in done_here: + minors = tasks[t].get("minor_findings") + if not isinstance(minors, list): + continue + if t not in covered: + covered.append(t) + for m in minors: + if not isinstance(m, dict) or m.get("id") in seen: + continue + if task_open is not None and m.get("id") not in task_open: + continue + seen.add(m.get("id")) + out.append({"id": m.get("id"), "task": t, "file": m.get("file"), "line": m.get("line"), + "issue": m.get("issue"), "fix": m.get("fix")}) + return out, covered + + +def carry_launch(directory, source): + """Record what a launch knew that the ledger does not: pre-flight's own + rulings (by preflight, PF-n), the open task minors, and which PF-n + overrides recorded during the launch name. Once per wf id.""" + path = find_launch_file(source) + try: + data = json.loads(read_text(path, "launch result")) if path else None + except ValueError: + data = None + result = data.get("result") if isinstance(data, dict) else None + if not isinstance(result, dict): + fail(3, f"no readable workflow result for {source!r}") + wf = data.get("runId") if is_text(data.get("runId")) else os.path.basename(source.rstrip("/\\")) + events = read_events(directory) + if any(e.get("event") == "carried" and e.get("wf") == wf for e in events): + print(json.dumps({"carried": wf, "already": True})) + return + pre = result.get("preflight") if isinstance(result.get("preflight"), dict) else {} + texts = [t for t in pre.get("rulings") or [] if isinstance(t, str)] + adjudicated = pre.get("adjudicated") + already_mapped = {v for c in carried_events(directory) for v in (c.get("pf") or {}).values()} + adj_ids = [r["id"] for r in ruling_records(directory) + if r["stem"] == "_run" and r["role"] == "adjudicator" and r["id"] not in already_mapped] + if "adjudicated" not in pre and pre.get("conflicts") and texts and adj_ids: + # A 1.6.2 result has no marker: its engine adjudicated pre-flight + # conflicts only in autonomous runs and pushed the ruling last. With no + # adjudicator ruling in _run (a supervised stop) there was none. + adjudicated = len(texts) - 1 + pf = {} + written = 0 + for i, text in enumerate(texts): + key = f"PF-{i + 1}" + if isinstance(adjudicated, int) and not isinstance(adjudicated, bool) and i == adjudicated: + if adj_ids: + pf[key] = adj_ids[-1] + continue + write_entry(directory, "_run", {"task": "_run", "event": "ruling", "text": text, "by": "preflight", + "scope": "run", "pf": key, "wf": wf}) + written += 1 + pf[key] = [r["id"] for r in ruling_records(directory) if r["stem"] == "_run"][-1] + bound_before = {(b.get("file"), b.get("line")) for c in carried_events(directory) + for b in c.get("bound") or [] if isinstance(b, dict)} + bound = [{"file": r["file"], "line": r["line"]} for r in ruling_records(directory) + if any(PF_ID.fullmatch(o) for o in r["event"].get("overrides") or []) + and (r["file"], r["line"]) not in bound_before] + open_now, covered = open_minors(latest_minors(events), result, data.get("args")) + write_entry(directory, "_run", {"task": "_run", "event": "minors", "wf": wf, "open": open_now, + "covered": covered}) + write_entry(directory, "_run", {"task": "_run", "event": "carried", "wf": wf, "pf": pf, "bound": bound}) + print(json.dumps({"carried": wf, "rulings": written, "open_minors": len(open_now)})) + + +def minors(directory): + """Print the task minors still open (the latest snapshot).""" + snap = latest_minors(read_events(directory)) + entries = (snap or {}).get("open") or [] + if not entries: + print("(none)") + for m in entries: + print(f"[{m.get('id')}] {m.get('task')} {m.get('file')}:{m.get('line')} - {m.get('issue')} " + f"(fix: {m.get('fix')})") + + def accept(directory, checkout, head, text): if not text: fail(2, "TEXT must say what the user accepted") @@ -915,7 +1055,7 @@ def conditions(directory, wf_id, doctor_file): PATH_ARGS = {"append": (1,), "reviewed": (1, 5, 6, 9), "status": (1,), "backfill": (1, 2), "accept": (1, 2), - "ended": (1,), "conditions": (1, 3), "rulings": (1,)} + "ended": (1,), "conditions": (1, 3), "rulings": (1,), "minors": (1,), "carry-launch": (1,)} PATH_OPTIONS = ("--plan", "--spec", "--manifest") @@ -961,6 +1101,11 @@ def main(argv): conditions(*argv[1:]) elif argv[:1] == ["rulings"] and len(argv) in (2, 4) and argv[1] and (len(argv) == 2 or argv[2] == "--lane"): rulings(argv[1], argv[3] if len(argv) == 4 and argv[3] != "-" else None) + elif len(argv) == 2 and argv[0] == "minors" and argv[1]: + minors(argv[1]) + elif len(argv) == 3 and argv[0] == "carry-launch" and argv[1] and argv[2]: + source = argv[2] if WF_ID.fullmatch(argv[2]) else _shell.native_path(argv[2]) + carry_launch(argv[1], source) else: fail(2, "usage: ledger append DIR LANE JSON" " | ledger reviewed DIR LANE TASK ROUNDS PLAN CHECKOUT HEAD BLOCKING [SPEC]" diff --git a/tests/ledger-carry.test.mjs b/tests/ledger-carry.test.mjs index f471c7a..1708d0c 100644 --- a/tests/ledger-carry.test.mjs +++ b/tests/ledger-carry.test.mjs @@ -106,3 +106,135 @@ test('rulings lists the files a ruling permits, and prints (none) for an empty l appendOk(dir, 'alpha', { task: 'T2', event: 'ruling', text: 'MAY', by: 'adjudicator', files: ['src/b.js', 'src/c.js'] }); assert.ok(rulingsOut(dir).includes('MAY (files src/b.js, src/c.js)')); }); + +// ---- Task 2: carry-launch, open-minor snapshots, status ---- + +const minor = (id, task, issue = `issue ${id}`) => ({ id, severity: 'minor', file: `src/${task}.js`, line: 3, issue, fix: 'fix it' }); + +// A Claude config dir holding one session's workflow result file, as the +// Workflow tool writes it; returns {config, transcripts} (the transcript dir). +function launchFile(config, wf, { rulings = [], adjudicated = null, tasks = {}, final = null, args = {} } = {}) { + const session = join(config, 'projects', 'C--work', 'sess-1'); + mkdirSync(join(session, 'workflows'), { recursive: true }); + const transcripts = join(session, 'subagents', 'workflows', wf); + mkdirSync(transcripts, { recursive: true }); + const result = { + status: 'complete', run_id: 'run-1', tasks, + preflight: { conflicts: [], rulings, undeclared: [], schedule: [], adjudicated }, + final, + }; + writeFileSync(join(session, 'workflows', `${wf}.json`), JSON.stringify({ runId: wf, args, result })); + return transcripts; +} +const done = (minors) => ({ status: 'done', rounds: 0, tier_used: 'standard', commits: null, notes: '', + ...(minors === undefined ? {} : { minor_findings: minors, rulings: [] }) }); +const finalOf = (taskOpen, carriedOpen = []) => ({ findings: [], task_minors_open: taskOpen.map((id) => ({ id })), + carried_minors_open: carriedOpen }); + +function carry(dir, source, env = {}) { + const res = run('ledger', ['carry-launch', dir, source], env); + return { ...res, json: res.code === 0 ? JSON.parse(res.stdout) : null }; +} +function statusOf(dir) { + const res = ledger('status', dir); + assert.equal(res.code, 0, res.stderr); + return JSON.parse(res.stdout); +} + +test('carry-launch records pre-flight rulings, skipping the adjudication the adjudicator already recorded', () => { + const dir = freshDir(); + const config = freshDir('config'); + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'adjudicator', text: 'Ruling: ADJ - w - c' }); + const t = launchFile(config, 'wf_aaaaaa-111', { rulings: ['Ruling: A - w - c', 'ADJ', 'Ruling: B - w - c'], adjudicated: 1 }); + const out = carry(dir, t); + assert.equal(out.code, 0, out.stderr); + assert.deepEqual(out.json, { carried: 'wf_aaaaaa-111', rulings: 2, open_minors: 0 }); + const printed = rulingsOut(dir); + assert.ok(printed.includes('_run-R2 [preflight, _run, run] Ruling: A - w - c'), printed); + assert.ok(printed.includes('_run-R3 [preflight, _run, run] Ruling: B - w - c'), printed); + assert.equal(printed.split('ADJ').length, 2, 'the adjudication appears once'); +}); + +test('carry-launch finds a launch by its wf id under the Claude config dir, and runs once per launch', () => { + const dir = freshDir(); + const config = freshDir('config'); + launchFile(config, 'wf_bbbbbb-222', { rulings: ['R1'], tasks: { T1: done([minor('T1-1', 'T1')]) } }); + const first = carry(dir, 'wf_bbbbbb-222', { CLAUDE_CONFIG_DIR: config }); + assert.equal(first.code, 0, first.stderr); + assert.deepEqual(first.json, { carried: 'wf_bbbbbb-222', rulings: 1, open_minors: 1 }); + const before = readFileSync(join(dir, '_run.jsonl'), 'utf8'); + const again = carry(dir, 'wf_bbbbbb-222', { CLAUDE_CONFIG_DIR: config }); + assert.equal(again.code, 0, again.stderr); + assert.deepEqual(again.json, { carried: 'wf_bbbbbb-222', already: true }); + assert.equal(readFileSync(join(dir, '_run.jsonl'), 'utf8'), before); +}); + +test('carry-launch with no readable result writes nothing and exits 3', () => { + const dir = freshDir(); + const config = freshDir('config'); + const res = carry(dir, 'wf_cccccc-333', { CLAUDE_CONFIG_DIR: config }); + assert.equal(res.code, 3, res.stdout); + assert.equal(statusOf(dir).minors.open.length, 0); +}); + +test('open minors carry across three launches: raised ones drop, a re-reviewed task replaces its own', () => { + const dir = freshDir(); + const config = freshDir('config'); + // Launch 1 stopped before the final review: every minor stays open. + carry(dir, launchFile(config, 'wf_l1aaaa-001', { + tasks: { T1: done([minor('T1-1', 'T1'), minor('T1-2', 'T1')]), T2: done([minor('T2-1', 'T2')]) }, + })); + assert.deepEqual(statusOf(dir).minors, { open: ['T1-1', 'T1-2', 'T2-1'], covered: ['T1', 'T2'] }); + // Launch 2: its final raised T1-1 (carried) and T3-1 (its own). + carry(dir, launchFile(config, 'wf_l2aaaa-002', { + tasks: { T1: { status: 'skipped' }, T2: { status: 'skipped' }, T3: done([minor('T3-1', 'T3'), minor('T3-2', 'T3')]) }, + final: finalOf(['T3-2'], ['T1-2', 'T2-1']), + args: { carried_minors: { open: ['T1-1', 'T1-2', 'T2-1'], covered: ['T1', 'T2'] } }, + })); + assert.deepEqual(statusOf(dir).minors, { open: ['T1-2', 'T2-1', 'T3-2'], covered: ['T1', 'T2', 'T3'] }); + // Launch 3 reviews T2 again: its old entry is replaced by the new review's. + carry(dir, launchFile(config, 'wf_l3aaaa-003', { + tasks: { T2: done([minor('T2-1', 'T2', 'the new wording')]) }, + final: finalOf(['T2-1'], ['T1-2', 'T3-2']), + args: { carried_minors: { open: ['T1-2', 'T3-2'], covered: ['T1', 'T2', 'T3'] } }, + })); + const printed = ledger('minors', dir).stdout; + assert.ok(printed.includes('[T2-1] T2 src/T2.js:3 - the new wording (fix: fix it)'), printed); + assert.ok(!printed.includes('issue T2-1'), printed); + assert.deepEqual(statusOf(dir).minors.open, ['T1-2', 'T3-2', 'T2-1']); +}); + +test('a 1.6.2 result carries no minors and covers none of its tasks', () => { + const dir = freshDir(); + const config = freshDir('config'); + carry(dir, launchFile(config, 'wf_old162-001', { tasks: { T1: done(undefined) } })); + assert.deepEqual(statusOf(dir).minors, { open: [], covered: [] }); + assert.equal(ledger('minors', dir).stdout.trim(), '(none)'); +}); + +test("a ruling that overrides a launch's PF-n resolves to that launch's carried pre-flight ruling", () => { + const dir = freshDir(); + const config = freshDir('config'); + appendOk(dir, 'alpha', { task: 'T2', event: 'ruling', by: 'adjudicator', text: 'NEW', overrides: ['PF-2'] }); + carry(dir, launchFile(config, 'wf_pfaaaa-001', { rulings: ['ONE', 'TWO'] })); + const out = rulingsOut(dir); + assert.ok(out.includes('alpha-R1 [adjudicator, T2, lane] NEW (overrides _run-R2)'), out); + assert.ok(out.split('Overridden:')[1].includes('_run-R2 by alpha-R1'), out); + assert.deepEqual(statusOf(dir).overrides, + [{ id: 'alpha-R1', by: 'adjudicator', task: 'T2', overrides: ['_run-R2'], unknown: [] }]); +}); + +test('a 1.6.2 result with pre-flight conflicts: the last pre-flight ruling is the adjudication, carried once', () => { + const dir = freshDir(); + const config = freshDir('config'); + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'adjudicator', text: 'Ruling: clarify_plan - x - y' }); + const session = join(config, 'projects', 'C--work', 'sess-1'); + mkdirSync(join(session, 'workflows'), { recursive: true }); + // 1.6.2 had no preflight.adjudicated: it pushed the adjudication last. + const result = { status: 'complete', tasks: {}, final: null, + preflight: { conflicts: ['FP3 vs check-metas'], rulings: ['Ruling: A - w - c', 'Change to FP3 for this run'], undeclared: [], schedule: [] } }; + writeFileSync(join(session, 'workflows', 'wf_old162-adj.json'), JSON.stringify({ runId: 'wf_old162-adj', args: {}, result })); + const out = carry(dir, 'wf_old162-adj', { CLAUDE_CONFIG_DIR: config }); + assert.deepEqual(out.json, { carried: 'wf_old162-adj', rulings: 1, open_minors: 0 }, out.stderr); + assert.ok(!rulingsOut(dir).includes('Change to FP3')); +}); From be2694894b6e9518f62832b7cd460894f3b20b18 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:22:19 -0700 Subject: [PATCH 04/10] feat: owner map and annotated outside-Files changes --- scripts/_owners.py | 100 ++++++++++++++++++++++++ scripts/ledger | 147 ++++++++++++++++++++++++++++++++++-- scripts/setup | 9 +++ scripts/start-task | 50 +++++++++++- tests/ledger-carry.test.mjs | 54 +++++++++++++ tests/recovery.test.mjs | 2 +- tests/setup-script.test.mjs | 12 +++ tests/task-helpers.test.mjs | 38 ++++++++++ 8 files changed, 401 insertions(+), 11 deletions(-) create mode 100644 scripts/_owners.py diff --git a/scripts/_owners.py b/scripts/_owners.py new file mode 100644 index 0000000..62fd0a3 --- /dev/null +++ b/scripts/_owners.py @@ -0,0 +1,100 @@ +"""The owner map: which tasks list each path in their Files list, and where +each task runs (1.6.3). + +setup writes /owners.json through `ledger write-owners`; the +ledger's owners and undeclared commands and start-task --owners read it. + + {"repo": {"mode", "root", "git_dir"}, + "tasks": {id: {"where": "prelude" | | "join", "pos": n}}, + "files": {: [task ids, in run order]}} + +A task changing another task's file is judged by when that task runs: +"earlier" (an earlier task of the same list, the prelude seen from a lane or +the join, a lane seen from the join), "alongside" (another lane), or "later". +""" +import json +import posixpath + +PHRASES = { + "earlier": "earlier in the run", + "alongside": "another lane, running alongside", + "later": "later", +} + + +def path_key(path): + """A project-relative path as the ledger and validate.js compare it: + forward slashes, normalized, lower-cased; None for an absolute path or + one that leaves the project.""" + if not isinstance(path, str) or not path: + return None + p = path.replace("\\", "/") + if p.startswith("/") or (len(p) > 1 and p[1] == ":"): + return None + norm = posixpath.normpath(p) + if norm == ".." or norm.startswith("../"): + return None + return norm.lower() + + +def build(manifest): + """The owner map of a manifest.""" + repo = manifest.get("repo") or {} + lists = [("prelude", manifest.get("prelude") or [])] + lists += [(lane.get("id"), lane.get("tasks") or []) for lane in manifest.get("lanes") or []] + lists += [("join", manifest.get("join") or [])] + tasks = {} + files = {} + for where, items in lists: + for pos, t in enumerate(items): + tasks[t["id"]] = {"where": where, "pos": pos} + for f in t.get("files") or []: + key = path_key(f) + if key is not None and t["id"] not in files.setdefault(key, []): + files[key].append(t["id"]) + return {"repo": {k: repo.get(k) for k in ("mode", "root", "git_dir")}, "tasks": tasks, "files": files} + + +def load(path): + with open(path, encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict) or not isinstance(data.get("tasks"), dict) or not isinstance(data.get("files"), dict): + raise ValueError("not an owner map") + return data + + +def relation(owners, unit_ids, other): + """earlier, alongside or later: when other runs, seen from the unit.""" + tasks = owners["tasks"] + me = tasks.get(unit_ids[0]) + them = tasks.get(other) + if me is None or them is None: + return None + mine, theirs = me["where"], them["where"] + if mine == theirs: + first = min(tasks[u]["pos"] for u in unit_ids if u in tasks) + return "earlier" if them["pos"] < first else "later" + if theirs == "prelude" or mine == "join": + return "earlier" + if mine == "prelude" or theirs == "join": + return "later" + return "alongside" + + +def annotate(owners, unit_ids, path): + """What a changed path is, seen from the unit (its task ids).""" + listed = owners["files"].get(path_key(path), []) + others = [t for t in listed if t not in unit_ids] + if any(t in unit_ids for t in listed): + return "in your Files list" + "".join(f"; also in {t}'s Files" for t in others) + if not others: + return "no task owns it" + return "; ".join(f"owned by {t}, {PHRASES.get(relation(owners, unit_ids, t), 'unknown')}" for t in others) + + +def first_owner(owners, unit_ids, path): + """(owner, relation) of the first other task listing path, or (None, None).""" + others = [t for t in owners["files"].get(path_key(path), []) if t not in unit_ids] + if not others: + return None, None + return others[0], relation(owners, unit_ids, others[0]) diff --git a/scripts/ledger b/scripts/ledger index 33e691c..5e71050 100755 --- a/scripts/ledger +++ b/scripts/ledger @@ -9,6 +9,12 @@ usage: ledger accept DIR CHECKOUT HEAD TEXT ledger ended DIR STATUS AGENTS RULINGS ledger conditions DIR WF_ID DOCTOR_FILE + ledger rulings DIR [--lane LANE] + ledger carry-launch DIR TRANSCRIPT_DIR|WF_ID + ledger minors DIR + ledger write-owners DIR MANIFEST + ledger owners DIR --task ID [--task ID ...] PATH ... + ledger undeclared DIR append writes JSON as one line to DIR/.jsonl, creating DIR with mode 0700. JSON must be an object with a safe non-empty string 'task' (or "_run") @@ -33,6 +39,46 @@ and an 'event' of: accepted with 'text': non-empty string and 'head': non-empty string (the user explicitly accepted that revision despite named reasons or warnings; 'ledger accept' writes it) + minors task _run only, with 'wf' and 'open': a list of objects (the + task minors still open after that launch: id, task, file, line, + issue, fix) and 'covered': task ids; carry-launch writes it + carried task _run only, with 'wf', 'pf' ({"PF-n": ruling id}) and + 'bound' ([{file, line}]); carry-launch writes it last, once per + launch +A ruling may carry 'by' (implementer, reviewer, adjudicator, preflight; none: +a task agent before 1.6.3, read as an implementer), 'scope' (lane, run), +'overrides' and 'files' (lists of strings), 'pf' (PF-n) and 'wf'. The values +are checked, not whether an agent told the truth. + +rulings prints the rulings in force, one per line: ' [, , +] ', then ' (overrides )' (an id that resolves to no ruling +marked '(unknown)') and ' (files )'; then 'Overridden:' and ' by +' for each ruling another overrides; '(none)' when there is none. A +ruling's id is '-R', n counting that file's rulings in append +order. A PF-n in 'overrides' resolves through the carried event that bound +the ruling. With --lane LANE: the rulings of _run, prelude and LANE, and every +one whose scope is run (a ruling's scope defaults to run in _run, else lane). + +carry-launch reads a launch's Workflow result file (from its transcript dir, +or for a wf id from /projects/*/*/workflows/ +.json) and appends, once per wf: each of result.preflight.rulings as a +ruling by preflight with scope run and pf PF- (the adjudication at +preflight.adjudicated is skipped: the adjudicator recorded it; a result +without that field and with conflicts has it last); a minors event; and a +carried event binding every ruling whose overrides name a PF-n no earlier +carried event bound. The open minors: the previous snapshot's, less the +entries of tasks done in this launch and the carried ids this launch's final +review raised, plus each task done in this launch's minor_findings less the +ids the final review raised (none without one). Prints {"carried", "rulings", +"open_minors"}, or {"carried", "already": true}; exit 3 and nothing written +when no readable result is found. + +minors prints the latest snapshot: '[] : - +(fix: )' per minor, or '(none)'. write-owners writes DIR/owners.json +from MANIFEST (setup writes the same file itself); owners prints, for each +PATH, what it is seen from the given tasks (see start-task --owners); +undeclared prints status --manifest's undeclared from owners.json instead of +a manifest. Both exit 3 without a readable owners.json. reviewed appends a reviewed event for TASK with ROUNDS, the sha256 of TASK's own section in PLAN (what the review approved against; _brief.section_sha256) @@ -89,10 +135,16 @@ setup run_started events without a run_ended (their agents are not counted). acc With --manifest MANIFEST (and --plan), a reviewed task whose depends_on names a stale task is stale too ("it depends on , whose approval is stale"), transitively, in run order. undeclared (with --manifest; else {}): {task: -[paths]} for each task with a recorded range and a non-empty files list whose -range changes a path (git diff --name-only --no-renames base head) that is not -in its files, nor in the files of a task with the same range (a batch); -paths compare normalized and case-insensitively, non-ASCII ones unquoted. +[{path, owner, relation}]} for each task with a recorded range and a non-empty +files list whose range changes a path (git diff --name-only --no-renames base +head) that is not in its files, nor in the files of a task with the same range +(a batch); paths compare normalized and case-insensitively, non-ASCII ones +unquoted. owner is the first other task whose files list the path (null when +none does) and relation when that task runs: earlier, alongside or later. +minors: {"open": [ids], "covered": [task ids]} from the latest minors event +(empty lists without one). overrides: [{id, by, task, overrides, unknown}] for +each ruling that names others it departs from (resolved; unknown lists the +ones that name no ruling). A missing DIR has empty lists, no start points, and no carry. accept appends {"task": "_run", "event": "accepted", "head": , @@ -155,6 +207,7 @@ import time sys.dont_write_bytecode = True sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) import _brief # noqa: E402 +import _owners # noqa: E402 import _shell # noqa: E402 LANE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]*$") @@ -568,7 +621,7 @@ def summarize(events, plan_text=None, plan_given=False, spec_sha=None, plan_sha= undeclared = {} if manifest is not None: - undeclared = undeclared_files(manifest, ranges) + undeclared = annotated_undeclared(manifest, ranges) return { "done": list(done), @@ -750,6 +803,75 @@ def overrides_list(directory): for r in records if resolved[r["id"]]] +def annotated_undeclared(manifest, ranges, owners=None): + """undeclared_files with each path's owner: {task: [{path, owner, + relation}]}, owner the first other task listing the path (None when no + task does) and relation earlier, alongside or later (None likewise).""" + owners = owners or _owners.build(manifest) + out = {} + for tid, paths in undeclared_files(manifest, ranges).items(): + rows = [] + for p in paths: + owner, rel = _owners.first_owner(owners, [tid], p) + rows.append({"path": p, "owner": owner, "relation": rel}) + out[tid] = rows + return out + + +def manifest_from_owners(owners): + """The parts of a manifest undeclared_files reads, rebuilt from an owner + map: the repo, and each task with its Files in its list.""" + files_of = {} + for key, ids in owners["files"].items(): + for tid in ids: + files_of.setdefault(tid, []).append(key) + lists = {} + for tid, where in owners["tasks"].items(): + lists.setdefault(where["where"], []).append((where["pos"], {"id": tid, "files": files_of.get(tid, [])})) + ordered = {w: [t for _, t in sorted(items, key=lambda x: x[0])] for w, items in lists.items()} + return {"repo": owners.get("repo") or {}, "prelude": ordered.pop("prelude", []), + "join": ordered.pop("join", []), "lanes": [{"id": w, "tasks": ts} for w, ts in ordered.items()]} + + +def owners_path(directory): + return os.path.join(directory, "owners.json") + + +def load_owners(directory): + try: + return _owners.load(owners_path(directory)) + except (OSError, ValueError) as e: + fail(3, f"cannot read the owner map {owners_path(directory)!r}: {e}") + + +def write_owners(directory, manifest_path): + """Write /owners.json from the manifest (setup runs this).""" + data = _owners.build(load_manifest(manifest_path)) + os.makedirs(directory, exist_ok=True) + tmp = owners_path(directory) + ".tmp" + try: + _shell.write_text(tmp, json.dumps(data, indent=2) + "\n") + os.replace(tmp, owners_path(directory)) + except OSError as e: + fail(3, f"cannot write {owners_path(directory)!r}: {e}") + print(json.dumps({"owners": owners_path(directory), "files": len(data["files"])})) + + +def owners(directory, unit_ids, paths): + """Print what each path is, seen from the unit's tasks.""" + data = load_owners(directory) + for p in paths: + print(f"{p} ({_owners.annotate(data, unit_ids, p)})") + + +def undeclared(directory): + """Print the owner-annotated files each task changed outside its Files + list, from the owner map and the ledger's ranges.""" + data = load_owners(directory) + result, _ = summarize(read_events(directory)) + print(json.dumps(annotated_undeclared(manifest_from_owners(data), result["ranges"], data))) + + def status(directory, plan=None, spec=None, manifest_path=None): plan_text = read_text(plan, "plan") if plan is not None else None manifest = load_manifest(manifest_path) if manifest_path is not None else None @@ -1055,7 +1177,8 @@ def conditions(directory, wf_id, doctor_file): PATH_ARGS = {"append": (1,), "reviewed": (1, 5, 6, 9), "status": (1,), "backfill": (1, 2), "accept": (1, 2), - "ended": (1,), "conditions": (1, 3), "rulings": (1,), "minors": (1,), "carry-launch": (1,)} + "ended": (1,), "conditions": (1, 3), "rulings": (1,), "minors": (1,), "carry-launch": (1,), "write-owners": (1, 2), "owners": (1,), + "undeclared": (1,)} PATH_OPTIONS = ("--plan", "--spec", "--manifest") @@ -1103,6 +1226,18 @@ def main(argv): rulings(argv[1], argv[3] if len(argv) == 4 and argv[3] != "-" else None) elif len(argv) == 2 and argv[0] == "minors" and argv[1]: minors(argv[1]) + elif len(argv) == 3 and argv[0] == "write-owners" and argv[1] and argv[2]: + write_owners(argv[1], argv[2]) + elif len(argv) == 2 and argv[0] == "undeclared" and argv[1]: + undeclared(argv[1]) + elif len(argv) >= 4 and argv[0] == "owners" and argv[1] and argv[2] == "--task": + rest, unit = argv[2:], [] + while len(rest) >= 2 and rest[0] == "--task" and LANE.fullmatch(rest[1]): + unit.append(rest[1]) + rest = rest[2:] + if not unit or not rest: + fail(2, "usage: ledger owners DIR --task ID [--task ID ...] PATH ...") + owners(argv[1], unit, rest) elif len(argv) == 3 and argv[0] == "carry-launch" and argv[1] and argv[2]: source = argv[2] if WF_ID.fullmatch(argv[2]) else _shell.native_path(argv[2]) carry_launch(argv[1], source) diff --git a/scripts/setup b/scripts/setup index 46245c1..fb99d90 100755 --- a/scripts/setup +++ b/scripts/setup @@ -120,6 +120,7 @@ import tempfile sys.dont_write_bytecode = True sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) import _locks # noqa: E402 +import _owners # noqa: E402 import _shell # noqa: E402 SCRIPTS = os.path.dirname(os.path.abspath(__file__)) @@ -506,6 +507,14 @@ def setup(m, owner=None): capture_output=True, text=True, encoding="utf-8", errors="replace", env=ENV) if res.returncode != 0: raise Failure(1, f"ledger append failed: {res.stderr.strip()}") + # The owner map start-task --owners and ledger owners read (1.6.3): which + # tasks list each path, and where each task runs. + owners_file = os.path.join(repo_cfg["ledger_dir"], "owners.json") + try: + _shell.write_text(owners_file + ".tmp", json.dumps(_owners.build(m), indent=2) + "\n") + os.replace(owners_file + ".tmp", owners_file) + except OSError as e: + raise Failure(1, f"cannot write the owner map {owners_file!r}: {e}") worktrees = {lane["id"]: feature if lite else lane_dir(m, lane) for lane in m["lanes"]} return {"feature_head": head, "worktrees": worktrees, "discarded": discarded, "preserved": preserved, "setup_skipped": skipped} diff --git a/scripts/start-task b/scripts/start-task index 66fe18c..b592a2d 100755 --- a/scripts/start-task +++ b/scripts/start-task @@ -3,7 +3,8 @@ usage: start-task DIR PLAN [--sync BRANCH] [--package SCRIPT BASE HEAD OUT] [--artifacts ROOT] [--record-start LEDGER_DIR LANE BASE] - [--scope BASE HEAD --declared PATH [--declared PATH ...]] + [--scope BASE HEAD --declared PATH [--declared PATH ...] [--owners FILE]] + [--rulings LEDGER_DIR LANE] --brief TASK_ID OUT [--brief TASK_ID OUT ...] [--also TASK_ID PRODUCER_ID ...] @@ -31,7 +32,17 @@ In order: (in DIR) changes that are not a --declared PATH (normalized and case-insensitive; a rename changes both paths), one per line after a "===== files changed outside the task's Files list =====" - line, or "(none)". --scope without --declared exits 2. + line, or "(none)". --scope without --declared exits 2. With + --owners FILE (the ledger's owners.json, written by setup) each + path carries what it is, seen from the --brief tasks: "(owned by + X, earlier in the run)", "(owned by X, another lane, running + alongside)", "(owned by X, later)" or "(no task owns it)"; a + changed declared path that another task also lists follows as + "(in your Files list; also in X's Files)". An unreadable FILE + exits 3. + --rulings prints `ledger rulings LEDGER_DIR --lane LANE` (LANE "-": every + ruling) under a "===== rulings in force =====" line, after the + briefs. Relative SCRIPT, PLAN and OUT paths resolve against the caller's directory. On Windows every path argument may be given in any form (C:\\x, /c/x, /tmp/x) and is used in the C:/... form. @@ -44,6 +55,7 @@ stdout, only on success: head: ===== brief : ===== then the brief text, per brief ===== review package ===== then the script's stdout (--package) + ===== rulings in force ===== then the rulings (--rulings) ===== files changed outside ... ===== then the paths (--scope) Exit 0 on success; 1 if git rev-parse HEAD fails after the sync, a written @@ -65,6 +77,7 @@ HERE = os.path.dirname(os.path.realpath(__file__)) sys.dont_write_bytecode = True sys.path.insert(0, HERE) import _brief # noqa: E402 +import _owners # noqa: E402 import _shell # noqa: E402 # Variables that would point git at another repo than the one named. GIT_REDIRECTS = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", @@ -109,7 +122,11 @@ def parse(argv): parser.add_argument("--record-start", nargs=3, metavar=("LEDGER_DIR", "LANE", "BASE")) parser.add_argument("--scope", nargs=2, metavar=("BASE", "HEAD")) parser.add_argument("--declared", action="append", default=[], metavar="PATH") + parser.add_argument("--owners", metavar="FILE") + parser.add_argument("--rulings", nargs=2, metavar=("LEDGER_DIR", "LANE")) args = parser.parse_args(argv) + if args.owners is not None and args.scope is None: + parser.error("--owners needs --scope") if args.scope is not None and not args.declared: parser.error("--scope needs at least one --declared PATH") for task_id, _ in args.brief: @@ -125,6 +142,10 @@ def parse(argv): args.artifacts = native(args.artifacts) if args.record_start is not None: args.record_start = [native(args.record_start[0]), args.record_start[1], args.record_start[2]] + if args.owners is not None: + args.owners = native(args.owners) + if args.rulings is not None: + args.rulings = [native(args.rulings[0]), args.rulings[1]] briefed = {task_id for task_id, _ in args.brief} for task_id, _ in args.also: if task_id not in briefed: @@ -202,6 +223,16 @@ def run(args): fail(1, f"cannot read brief {path!r}: {e}") out.append(f"===== brief {task_id}: {path} =====\n{text}") + if args.rulings is not None: + ledger_dir, lane = args.rulings + res = subprocess.run([sys.executable, os.path.join(HERE, "ledger"), "rulings", ledger_dir, + *([] if lane == "-" else ["--lane", lane])], + env=ENV, capture_output=True, text=True, encoding="utf-8", errors="replace") + if res.returncode != 0: + sys.stderr.write(res.stderr) + sys.exit(res.returncode) + out.append(f"===== rulings in force =====\n{res.stdout}") + if args.package is not None: script, base, head_ref, package_out = args.package script = _shell.native_path(os.path.abspath(script)) @@ -226,9 +257,20 @@ def run(args): if res.returncode != 0: fail(1, f"git diff {' '.join(args.scope)} failed in {directory!r}: {res.stderr.strip()}") declared = {path_key(p) for p in args.declared} - outside = sorted(p for p in res.stdout.splitlines() if p and path_key(p) not in declared) + changed = [p for p in res.stdout.splitlines() if p] + outside = sorted(p for p in changed if path_key(p) not in declared) + lines = outside + if args.owners is not None: + try: + owners = _owners.load(args.owners) + except (OSError, ValueError) as e: + fail(3, f"cannot read the owner map {args.owners!r}: {e}") + unit = [task_id for task_id, _ in args.brief] + shared = sorted(p for p in changed if path_key(p) in declared + and any(t not in unit for t in owners["files"].get(_owners.path_key(p), []))) + lines = [f"{p} ({_owners.annotate(owners, unit, p)})" for p in outside + shared] out.append("===== files changed outside the task's Files list =====\n" - + ("\n".join(outside) if outside else "(none)") + "\n") + + ("\n".join(lines) if lines else "(none)") + "\n") if start_base is not None: ledger_dir, lane, _ = args.record_start diff --git a/tests/ledger-carry.test.mjs b/tests/ledger-carry.test.mjs index 1708d0c..0da5ead 100644 --- a/tests/ledger-carry.test.mjs +++ b/tests/ledger-carry.test.mjs @@ -238,3 +238,57 @@ test('a 1.6.2 result with pre-flight conflicts: the last pre-flight ruling is th assert.deepEqual(out.json, { carried: 'wf_old162-adj', rulings: 1, open_minors: 0 }, out.stderr); assert.ok(!rulingsOut(dir).includes('Change to FP3')); }); + +// ---- Task 3: the owner map ---- + +const otask = (id, files) => ({ id, title: id, files, tier: 'standard', security: false }); +function ownersManifest(dir) { + const m = { + version: 1, run_id: 'run-1', + repo: { mode: 'git', root: dir, git_dir: null, ledger_dir: join(dir, 'ledger') }, + prelude: [otask('P1', ['a.txt'])], + lanes: [ + { id: 'alpha', name: 'Lane alpha', tasks: [otask('T2', ['src/x.js', 'shared.css']), otask('T3', ['.\\SRC\\x2.js'])] }, + { id: 'beta', name: 'Lane beta', tasks: [otask('T4', ['src/y.js', 'shared.css'])] }, + ], + join: [otask('J1', ['src/z.js'])], + }; + const path = join(dir, 'm.lanes.json'); + writeFileSync(path, JSON.stringify(m)); + return path; +} +function ownersOf(dir, ...args) { + const res = ledger('owners', dir, ...args); + assert.equal(res.code, 0, res.stderr); + return res.stdout; +} + +test('write-owners maps every path to the tasks that list it, and owners annotates by run order', () => { + const root = freshDir('proj'); + const dir = join(root, 'ledger'); + const res = ledger('write-owners', dir, ownersManifest(root)); + assert.equal(res.code, 0, res.stderr); + const map = JSON.parse(readFileSync(join(dir, 'owners.json'), 'utf8')); + assert.deepEqual(map.files['shared.css'], ['T2', 'T4']); + assert.deepEqual(map.tasks.T3, { where: 'alpha', pos: 1 }); + const out = ownersOf(dir, '--task', 'T2', 'src/y.js', 'a.txt', 'src/z.js', 'shared.css', 'new.js', 'src/x2.js', 'SRC/X.js'); + assert.ok(out.includes('src/y.js (owned by T4, another lane, running alongside)'), out); + assert.ok(out.includes('a.txt (owned by P1, earlier in the run)'), out); + assert.ok(out.includes('src/z.js (owned by J1, later)'), out); + assert.ok(out.includes('shared.css (in your Files list; also in T4\'s Files)'), out); + assert.ok(out.includes('new.js (no task owns it)'), out); + assert.ok(out.includes('src/x2.js (owned by T3, later)'), out); + assert.ok(out.includes('SRC/X.js (in your Files list)'), out); + // Files entries match normalized: a Windows-form .\SRC\x2.js is src/x2.js. + assert.deepEqual(map.files['src/x2.js'], ['T3']); + assert.ok(ownersOf(dir, '--task', 'J1', 'src/x.js').includes('owned by T2, earlier in the run')); + assert.ok(ownersOf(dir, '--task', 'P1', 'src/x.js').includes('owned by T2, later')); + assert.ok(ownersOf(dir, '--task', 'T3', 'src/x.js').includes('owned by T2, earlier in the run')); + // A batch unit names all its tasks. + assert.ok(ownersOf(dir, '--task', 'T2', '--task', 'T3', 'src/x2.js').includes('(in your Files list)')); +}); + +test('owners without an owner map exits 3', () => { + const dir = freshDir(); + assert.equal(ledger('owners', dir, '--task', 'T2', 'a.txt').code, 3); +}); diff --git a/tests/recovery.test.mjs b/tests/recovery.test.mjs index a2f23b8..dac5d8f 100644 --- a/tests/recovery.test.mjs +++ b/tests/recovery.test.mjs @@ -287,7 +287,7 @@ test('ledger status --manifest lists the files a task changed outside its Files const status = ok(py('ledger', 'status', c.ledger, '--manifest', c.manifest)); // T1 declared a.txt (case and ./ do not matter); T2 declared nothing, so // there is nothing to compare against. - assert.deepEqual(status.undeclared, { T1: ['b.txt'] }); + assert.deepEqual(status.undeclared, { T1: [{ path: 'b.txt', owner: null, relation: null }] }); }); test('run_ended events add up to the budget spent across launches', () => { diff --git a/tests/setup-script.test.mjs b/tests/setup-script.test.mjs index 1f2c791..20a78b7 100644 --- a/tests/setup-script.test.mjs +++ b/tests/setup-script.test.mjs @@ -283,6 +283,18 @@ test('setup: lite runs an identical lane setup only once', () => { assertSamePaths(logLines(c), [c.project]); }); +test('setup writes the owner map from the manifest', () => { + const c = newCase(); + const m = manifest(c, { lanes: [ + { id: 'a', name: 'Lane A', tasks: [{ id: 'a1', files: ['src/a.js', 'shared.css'] }] }, + { id: 'b', name: 'Lane B', tasks: [{ id: 'b1', files: ['shared.css'] }] }, + ] }); + setupOk(c, m); + const owners = JSON.parse(readFileSync(join(c.ledgerDir, 'owners.json'), 'utf8')); + assert.deepEqual(owners.files, { 'src/a.js': ['a1'], 'shared.css': ['a1', 'b1'] }); + assert.deepEqual(owners.tasks, { a1: { where: 'a', pos: 0 }, b1: { where: 'b', pos: 0 } }); +}); + test('setup: ledger records run_started and status reports the earliest start point', () => { const c = newCase(); const m = manifest(c); diff --git a/tests/task-helpers.test.mjs b/tests/task-helpers.test.mjs index 3698eed..f599497 100644 --- a/tests/task-helpers.test.mjs +++ b/tests/task-helpers.test.mjs @@ -360,6 +360,44 @@ test('start-task --scope lists the files a range changes outside the declared on assert.equal(startTask(c.lane, c.plan, '--scope', base, head, '--brief', 'T1', out).code, 2); }); +test('start-task --owners names the owner of each changed path, and shared declared files', () => { + const c = newCase(); + const base = git(c.lane, 'rev-parse', 'HEAD'); + write(join(c.lane, 'src', 'a.js'), 'a\n'); + write(join(c.lane, 'src', 'b.js'), 'b\n'); + write(join(c.lane, 'shared.css'), 's\n'); + git(c.lane, 'add', '-A'); + git(c.lane, 'commit', '-q', '-m', 'work'); + const head = git(c.lane, 'rev-parse', 'HEAD'); + const owners = join(c.root, 'owners.json'); + write(owners, JSON.stringify({ + repo: {}, tasks: { T1: { where: 'alpha', pos: 0 }, T2: { where: 'beta', pos: 0 }, T5: { where: 'join', pos: 0 } }, + files: { 'src/a.js': ['T1'], 'src/b.js': ['T2'], 'shared.css': ['T1', 'T5'] }, + })); + const out = join(c.root, 'briefs', 'T1.md'); + const res = startTask(c.lane, c.plan, '--scope', base, head, '--declared', 'src/a.js', '--declared', 'shared.css', + '--owners', owners, '--brief', 'T1', out); + assert.equal(res.code, 0, res.stderr); + assert.ok(res.stdout.endsWith("===== files changed outside the task's Files list =====\n" + + "src/b.js (owned by T2, another lane, running alongside)\n" + + "shared.css (in your Files list; also in T5's Files)\n"), res.stdout); +}); + +test('start-task --rulings prints the rulings in force for the lane', () => { + const c = newCase(); + const ledgerDir = join(c.root, 'ledger'); + const append = (lane, entry) => assert.equal(script('ledger')('append', ledgerDir, lane, JSON.stringify(entry)).code, 0); + append('alpha', { task: 'T1', event: 'ruling', by: 'implementer', text: 'MINE' }); + append('beta', { task: 'T2', event: 'ruling', by: 'implementer', text: 'THEIRS' }); + const out = join(c.root, 'briefs', 'T1.md'); + const res = startTask(c.lane, c.plan, '--rulings', ledgerDir, 'alpha', '--brief', 'T1', out); + assert.equal(res.code, 0, res.stderr); + assert.ok(res.stdout.includes('===== rulings in force =====\nalpha-R1 [implementer, T1, lane] MINE\n'), res.stdout); + assert.ok(!res.stdout.includes('THEIRS'), res.stdout); + const all = startTask(c.lane, c.plan, '--rulings', ledgerDir, '-', '--brief', 'T1', out); + assert.ok(all.stdout.includes('THEIRS'), all.stdout); +}); + test('start-task without --brief exits 2', () => { const c = newCase(); const res = startTask(c.lane, c.plan); From e26b3923fe113313f52cfb2e78db44cdf93dd7c2 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:08:24 -0700 Subject: [PATCH 05/10] feat: pf ids, one lens owns task minors, carried minors --- run.workflow.js | 100 +++++++++++++++++++++++++++++++--------- src/phases.js | 28 +++++++++-- src/prompts-phase.js | 46 ++++++++++++------ src/prompts-task.js | 8 ++-- src/validate.js | 18 ++++++++ tests/engine.test.mjs | 2 +- tests/profiles.test.mjs | 2 +- tests/run.test.mjs | 77 +++++++++++++++++++++++++++---- tests/validate.test.mjs | 20 ++++++++ 9 files changed, 247 insertions(+), 54 deletions(-) diff --git a/run.workflow.js b/run.workflow.js index 37b7d7b..37b55ed 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -714,6 +714,24 @@ function validateManifest(m) { } } + // carried_minors (1.6.3): the open task minors an earlier launch left, as + // ledger status prints them: {open: [minor ids], covered: [task ids]}. + if ('carried_minors' in m) { + const c = m.carried_minors; + const ids = (v) => Array.isArray(v) && v.every(isText); + if (!isObject(c) || !ids(c.open) || !ids(c.covered)) { + err('carried_minors: must be {open: [minor ids], covered: [task ids]}'); + } + } + // inputs_changed (1.6.3): whether the plan and the spec changed since the + // last launch (ledger status inputs), for a relaunch's pre-flight. + if ('inputs_changed' in m) { + const flag = (v) => v === true || v === false || v === null; + const c = m.inputs_changed; + if (!isObject(c) || !('plan' in c) || !('spec' in c) || !flag(c.plan) || !flag(c.spec)) { + err('inputs_changed: must be {plan: true|false|null, spec: true|false|null}'); + } + } // start_points: feature heads from the ledger run_started events. if ('start_points' in m) { if (!isObject(m.start_points)) { @@ -1431,12 +1449,14 @@ function startFindingNote(findings) { // The rulings already made for this run (m.run_rulings, set only by the // workflow: pre-flight's rulings in order, then the adjudicator's pre-flight -// ruling), as prompt lines: a heading and one '- ' line each, every -// ruling quoted as written. Empty when there are none. +// ruling), as prompt lines: a heading and one '- PF-: ' line each, +// every ruling quoted as written. PF- is the id agents cite and override it +// by during this launch; carry-launch records it with the ruling afterwards +// (1.6.3). Empty when there are none. function runRulingsLines(m) { const rulings = Array.isArray(m.run_rulings) ? m.run_rulings : []; if (rulings.length === 0) return []; - return ['Rulings already made for this run (binding):', ...rulings.map((r) => `- ${r}`)]; + return ['Rulings already made for this run (binding):', ...rulings.map((r, i) => `- PF-${i + 1}: ${r}`)]; } // The message a task fix round commits with: 'fix: address review findings @@ -2613,23 +2633,42 @@ function finalLensFocus(m, lens, e2e) { ].join('\n'); } -// The task minors checklist for a final reviewer (taskMinorFindings): [] -// when there are none. -function taskMinorsLines(minors) { - if (!Array.isArray(minors) || minors.length === 0) return []; +// The task minors checklist for the reviewer that owns it (taskMinorFindings +// of this launch, and the open ones an earlier launch left, read from the +// ledger): [] when there are none. +function taskMinorsLines(m, minors) { + const here = Array.isArray(minors) ? minors : []; + const carried = carriedMinorIds(m); + if (here.length === 0 && carried.length === 0) return []; + const ledger = `${pythonCommand(m)} ${shellQuote(`${m.skill_dir}/scripts/ledger`)} minors ${shellQuote(m.repo.ledger_dir)}`; return [ - 'Minor findings of approved task reviews, never acted on (each with its id in brackets):', - findingsText(minors), + ...(here.length > 0 ? [ + 'Minor findings of approved task reviews, never acted on (each with its id in brackets):', + findingsText(here), + ] : []), + ...(carried.length > 0 ? [ + 'Minor findings an earlier launch\'s task reviews approved and no final review has raised yet. Read them with', + `(a read-only command): ${ledger}`, + `Carried: ${carried.join(', ')}`, + ] : []), 'For each one that still holds and is worth fixing, raise it as a finding with its id in brackets in the', 'issue (for example "[] ..."); otherwise leave it.', '', ]; } +// For a lens that does not own the checklist, when there is one: [] otherwise. +function otherReviewerMinorsLines(m, minors) { + const any = (Array.isArray(minors) && minors.length > 0) || carriedMinorIds(m).length > 0; + return any ? ['Task minors are checked by another reviewer: raise one only if you judge it critical or' + + ' important, citing its id.', ''] : []; +} + // A final reviewer's prompt around its focus text: read-only, the whole -// branch range, the commit-rules scan, the task minors checklist, and the -// findings/head result. -function finalReviewFrame(m, intro, focus, minors = []) { +// branch range, the commit-rules scan, the task minors checklist (or, with +// other true, the line for a lens that does not own it: minors is then the +// checklist another lens has), and the findings/head result. +function finalReviewFrame(m, intro, focus, minors = [], other = false) { const q = shellQuote; const dir = q(featureDir(m)); const log = `git -C ${dir} log ${q(`${m.repo.base_ref}..${m.repo.branch}`)}`; @@ -2645,7 +2684,7 @@ function finalReviewFrame(m, intro, focus, minors = []) { '', focus, '', - ...taskMinorsLines(minors), + ...(other ? otherReviewerMinorsLines(m, minors) : taskMinorsLines(m, minors)), ...runRulingsLines(m), 'Also scan every commit message in the range and the whole diff for anything the commit rules forbid;', 'report each as a finding (for a commit message use file "commit " and line 0).', @@ -2659,9 +2698,10 @@ function finalReviewFrame(m, intro, focus, minors = []) { ].join('\n'); } -// minors: the task minors checklist (taskMinorsLines). -function finalReviewPrompt(m, lens, e2e, minors = []) { - return finalReviewFrame(m, 'You are a final reviewer', finalLensFocus(m, lens, e2e), minors); +// minors: the task minors checklist (taskMinorsLines); other: this lens does +// not own the checklist (it gets otherReviewerMinorsLines when one exists). +function finalReviewPrompt(m, lens, e2e, minors = [], other = false) { + return finalReviewFrame(m, 'You are a final reviewer', finalLensFocus(m, lens, e2e), minors, other); } // Profile lite: one reviewer covers the three lenses of the full profile. @@ -4108,7 +4148,10 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t const lenses = m.profile === 'lite' ? [['final review', 'combined', combinedFinalReviewPrompt(m, { e2e, minors: taskMinors })]] : [['sp', 'superpowers'], ['security', 'security'], ['correctness', 'correctness']] - .map(([key, name]) => [`final review ${key}`, name, finalReviewPrompt(m, key, e2e, taskMinors)]); + // One lens owns the task minors checklist (1.6.3): the security lens, + // which worked it in every archived run; the others only escalate. + .map(([key, name]) => [`final review ${key}`, name, + finalReviewPrompt(m, key, e2e, taskMinors, key !== 'security')]); const results = await io.parallel(lenses.map(([label, , prompt]) => () => call(label, prompt, finalReviewSchema()))); const cannotVerify = []; @@ -4135,6 +4178,7 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t findings, fixed: [], declined: [], open: [], cannot_verify: cannotVerify, missing_lenses: missing, head: tip, lens_heads: lensHeads, repairs: [], task_minors_open: taskMinors.filter((t) => !raised.includes(`[${t.id}]`)), + carried_minors_open: carriedMinorIds(m).filter((id) => !raised.includes(`[${id}]`)), }; const unbound = reviewProblemOf(lensHeads, results); if (unbound !== null) final.review_problem = unbound; @@ -4220,6 +4264,12 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t return final; } +// The open task minors an earlier launch left (carried_minors.open, which the +// session copies from ledger status): [] without them. +function carriedMinorIds(m) { + return m.carried_minors && Array.isArray(m.carried_minors.open) ? m.carried_minors.open : []; +} + // The exit-code contract (1.6.2 spec 4.3): true when at least one command // failed and every failing exit marks no defect: 75 (the project was busy), // 124 (a timeout), 128 and above or negative (a signal), or not a number. A @@ -4677,11 +4727,12 @@ async function runAll(manifest, io) { // the single lane runs on the feature branch, so there is no integration. const lite = m.profile === 'lite'; if (lite) { - preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [] }; + preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [], adjudicated: null }; } else if (!preflightNeeded(m)) { // A relaunch with every task done and reviewed: no pre-flight agent, so no // rulings (preflightNeeded says why). - preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [], skipped: 'no task left to run' }; + preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [], skipped: 'no task left to run', + adjudicated: null }; io.log('parallel-lanes: pre-flight skipped: every task is done and reviewed'); } else { io.phase('Pre-flight'); @@ -4706,7 +4757,10 @@ async function runAll(manifest, io) { const schedule = codeDeps.kept.filter((e) => !codeDepMet(m, e.task, e.producer)) .map((e) => ({ ...e, fix: scheduleFix(m, e) })); for (const e of kept) io.log(`parallel-lanes: pre-flight: ${e.task} also consumes ${e.producer} (${e.what})`); - preflight = { conflicts: pre.conflicts, rulings: [...pre.rulings], undeclared: kept, schedule }; + // adjudicated: the index in rulings of the adjudicator's ruling on the + // conflicts, which the adjudicator recorded in the ledger itself, so + // carry-launch skips it (1.6.3); null when there is none. + preflight = { conflicts: pre.conflicts, rulings: [...pre.rulings], undeclared: kept, schedule, adjudicated: null }; if (schedule.length > 0) return report('preflight_conflicts'); m = withConsumesExtra(m, kept); // Pre-flight's rulings bind every task and final reviewer of this run @@ -4727,6 +4781,7 @@ async function runAll(manifest, io) { // The ruling joins the run rulings, so it reaches every task agent // still to run and the final reviewers. preflight.rulings.push(ruling.text); + preflight.adjudicated = preflight.rulings.length - 1; m = { ...m, run_rulings: [...m.run_rulings, ruling.text] }; } } @@ -4994,8 +5049,11 @@ async function runAll(manifest, io) { // earlier launch committed and reviewed are not re-run, so their approved // reviews' minor findings never reached the final lenses. Every resume // has such tasks, so this is a note (no source), not a warning. + // Tasks the session's carried_minors covers (carry-launch's snapshot) had + // their open minors carried to the security lens: only the rest are noted. + const covered = new Set(m.carried_minors ? m.carried_minors.covered : []); const earlier = [...m.prelude, ...m.lanes.flatMap((l) => l.tasks), ...m.join] - .filter((t) => taskState(m, t.id) === 'skip').map((t) => t.id); + .filter((t) => taskState(m, t.id) === 'skip' && !covered.has(t.id)).map((t) => t.id); if (earlier.length > 0) { final.cannot_verify.push(`the minor findings of the task reviews an earlier launch approved (${earlier.join(', ')})` + ' did not reach the final lenses or task_minors_open: a resumed run does not carry them; read those' diff --git a/src/phases.js b/src/phases.js index a1c69ab..631176f 100644 --- a/src/phases.js +++ b/src/phases.js @@ -42,7 +42,10 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t const lenses = m.profile === 'lite' ? [['final review', 'combined', combinedFinalReviewPrompt(m, { e2e, minors: taskMinors })]] : [['sp', 'superpowers'], ['security', 'security'], ['correctness', 'correctness']] - .map(([key, name]) => [`final review ${key}`, name, finalReviewPrompt(m, key, e2e, taskMinors)]); + // One lens owns the task minors checklist (1.6.3): the security lens, + // which worked it in every archived run; the others only escalate. + .map(([key, name]) => [`final review ${key}`, name, + finalReviewPrompt(m, key, e2e, taskMinors, key !== 'security')]); const results = await io.parallel(lenses.map(([label, , prompt]) => () => call(label, prompt, finalReviewSchema()))); const cannotVerify = []; @@ -69,6 +72,7 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t findings, fixed: [], declined: [], open: [], cannot_verify: cannotVerify, missing_lenses: missing, head: tip, lens_heads: lensHeads, repairs: [], task_minors_open: taskMinors.filter((t) => !raised.includes(`[${t.id}]`)), + carried_minors_open: carriedMinorIds(m).filter((id) => !raised.includes(`[${id}]`)), }; const unbound = reviewProblemOf(lensHeads, results); if (unbound !== null) final.review_problem = unbound; @@ -154,6 +158,12 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t return final; } +// The open task minors an earlier launch left (carried_minors.open, which the +// session copies from ledger status): [] without them. +function carriedMinorIds(m) { + return m.carried_minors && Array.isArray(m.carried_minors.open) ? m.carried_minors.open : []; +} + // The exit-code contract (1.6.2 spec 4.3): true when at least one command // failed and every failing exit marks no defect: 75 (the project was busy), // 124 (a timeout), 128 and above or negative (a signal), or not a number. A @@ -611,11 +621,12 @@ async function runAll(manifest, io) { // the single lane runs on the feature branch, so there is no integration. const lite = m.profile === 'lite'; if (lite) { - preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [] }; + preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [], adjudicated: null }; } else if (!preflightNeeded(m)) { // A relaunch with every task done and reviewed: no pre-flight agent, so no // rulings (preflightNeeded says why). - preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [], skipped: 'no task left to run' }; + preflight = { conflicts: [], rulings: [], undeclared: [], schedule: [], skipped: 'no task left to run', + adjudicated: null }; io.log('parallel-lanes: pre-flight skipped: every task is done and reviewed'); } else { io.phase('Pre-flight'); @@ -640,7 +651,10 @@ async function runAll(manifest, io) { const schedule = codeDeps.kept.filter((e) => !codeDepMet(m, e.task, e.producer)) .map((e) => ({ ...e, fix: scheduleFix(m, e) })); for (const e of kept) io.log(`parallel-lanes: pre-flight: ${e.task} also consumes ${e.producer} (${e.what})`); - preflight = { conflicts: pre.conflicts, rulings: [...pre.rulings], undeclared: kept, schedule }; + // adjudicated: the index in rulings of the adjudicator's ruling on the + // conflicts, which the adjudicator recorded in the ledger itself, so + // carry-launch skips it (1.6.3); null when there is none. + preflight = { conflicts: pre.conflicts, rulings: [...pre.rulings], undeclared: kept, schedule, adjudicated: null }; if (schedule.length > 0) return report('preflight_conflicts'); m = withConsumesExtra(m, kept); // Pre-flight's rulings bind every task and final reviewer of this run @@ -661,6 +675,7 @@ async function runAll(manifest, io) { // The ruling joins the run rulings, so it reaches every task agent // still to run and the final reviewers. preflight.rulings.push(ruling.text); + preflight.adjudicated = preflight.rulings.length - 1; m = { ...m, run_rulings: [...m.run_rulings, ruling.text] }; } } @@ -928,8 +943,11 @@ async function runAll(manifest, io) { // earlier launch committed and reviewed are not re-run, so their approved // reviews' minor findings never reached the final lenses. Every resume // has such tasks, so this is a note (no source), not a warning. + // Tasks the session's carried_minors covers (carry-launch's snapshot) had + // their open minors carried to the security lens: only the rest are noted. + const covered = new Set(m.carried_minors ? m.carried_minors.covered : []); const earlier = [...m.prelude, ...m.lanes.flatMap((l) => l.tasks), ...m.join] - .filter((t) => taskState(m, t.id) === 'skip').map((t) => t.id); + .filter((t) => taskState(m, t.id) === 'skip' && !covered.has(t.id)).map((t) => t.id); if (earlier.length > 0) { final.cannot_verify.push(`the minor findings of the task reviews an earlier launch approved (${earlier.join(', ')})` + ' did not reach the final lenses or task_minors_open: a resumed run does not carry them; read those' diff --git a/src/prompts-phase.js b/src/prompts-phase.js index 93813f7..1be055e 100644 --- a/src/prompts-phase.js +++ b/src/prompts-phase.js @@ -626,23 +626,42 @@ function finalLensFocus(m, lens, e2e) { ].join('\n'); } -// The task minors checklist for a final reviewer (taskMinorFindings): [] -// when there are none. -function taskMinorsLines(minors) { - if (!Array.isArray(minors) || minors.length === 0) return []; +// The task minors checklist for the reviewer that owns it (taskMinorFindings +// of this launch, and the open ones an earlier launch left, read from the +// ledger): [] when there are none. +function taskMinorsLines(m, minors) { + const here = Array.isArray(minors) ? minors : []; + const carried = carriedMinorIds(m); + if (here.length === 0 && carried.length === 0) return []; + const ledger = `${pythonCommand(m)} ${shellQuote(`${m.skill_dir}/scripts/ledger`)} minors ${shellQuote(m.repo.ledger_dir)}`; return [ - 'Minor findings of approved task reviews, never acted on (each with its id in brackets):', - findingsText(minors), + ...(here.length > 0 ? [ + 'Minor findings of approved task reviews, never acted on (each with its id in brackets):', + findingsText(here), + ] : []), + ...(carried.length > 0 ? [ + 'Minor findings an earlier launch\'s task reviews approved and no final review has raised yet. Read them with', + `(a read-only command): ${ledger}`, + `Carried: ${carried.join(', ')}`, + ] : []), 'For each one that still holds and is worth fixing, raise it as a finding with its id in brackets in the', 'issue (for example "[] ..."); otherwise leave it.', '', ]; } +// For a lens that does not own the checklist, when there is one: [] otherwise. +function otherReviewerMinorsLines(m, minors) { + const any = (Array.isArray(minors) && minors.length > 0) || carriedMinorIds(m).length > 0; + return any ? ['Task minors are checked by another reviewer: raise one only if you judge it critical or' + + ' important, citing its id.', ''] : []; +} + // A final reviewer's prompt around its focus text: read-only, the whole -// branch range, the commit-rules scan, the task minors checklist, and the -// findings/head result. -function finalReviewFrame(m, intro, focus, minors = []) { +// branch range, the commit-rules scan, the task minors checklist (or, with +// other true, the line for a lens that does not own it: minors is then the +// checklist another lens has), and the findings/head result. +function finalReviewFrame(m, intro, focus, minors = [], other = false) { const q = shellQuote; const dir = q(featureDir(m)); const log = `git -C ${dir} log ${q(`${m.repo.base_ref}..${m.repo.branch}`)}`; @@ -658,7 +677,7 @@ function finalReviewFrame(m, intro, focus, minors = []) { '', focus, '', - ...taskMinorsLines(minors), + ...(other ? otherReviewerMinorsLines(m, minors) : taskMinorsLines(m, minors)), ...runRulingsLines(m), 'Also scan every commit message in the range and the whole diff for anything the commit rules forbid;', 'report each as a finding (for a commit message use file "commit " and line 0).', @@ -672,9 +691,10 @@ function finalReviewFrame(m, intro, focus, minors = []) { ].join('\n'); } -// minors: the task minors checklist (taskMinorsLines). -function finalReviewPrompt(m, lens, e2e, minors = []) { - return finalReviewFrame(m, 'You are a final reviewer', finalLensFocus(m, lens, e2e), minors); +// minors: the task minors checklist (taskMinorsLines); other: this lens does +// not own the checklist (it gets otherReviewerMinorsLines when one exists). +function finalReviewPrompt(m, lens, e2e, minors = [], other = false) { + return finalReviewFrame(m, 'You are a final reviewer', finalLensFocus(m, lens, e2e), minors, other); } // Profile lite: one reviewer covers the three lenses of the full profile. diff --git a/src/prompts-task.js b/src/prompts-task.js index 79ae288..be99872 100644 --- a/src/prompts-task.js +++ b/src/prompts-task.js @@ -402,12 +402,14 @@ function startFindingNote(findings) { // The rulings already made for this run (m.run_rulings, set only by the // workflow: pre-flight's rulings in order, then the adjudicator's pre-flight -// ruling), as prompt lines: a heading and one '- ' line each, every -// ruling quoted as written. Empty when there are none. +// ruling), as prompt lines: a heading and one '- PF-: ' line each, +// every ruling quoted as written. PF- is the id agents cite and override it +// by during this launch; carry-launch records it with the ruling afterwards +// (1.6.3). Empty when there are none. function runRulingsLines(m) { const rulings = Array.isArray(m.run_rulings) ? m.run_rulings : []; if (rulings.length === 0) return []; - return ['Rulings already made for this run (binding):', ...rulings.map((r) => `- ${r}`)]; + return ['Rulings already made for this run (binding):', ...rulings.map((r, i) => `- PF-${i + 1}: ${r}`)]; } // The message a task fix round commits with: 'fix: address review findings diff --git a/src/validate.js b/src/validate.js index 892760a..366fd4c 100644 --- a/src/validate.js +++ b/src/validate.js @@ -623,6 +623,24 @@ function validateManifest(m) { } } + // carried_minors (1.6.3): the open task minors an earlier launch left, as + // ledger status prints them: {open: [minor ids], covered: [task ids]}. + if ('carried_minors' in m) { + const c = m.carried_minors; + const ids = (v) => Array.isArray(v) && v.every(isText); + if (!isObject(c) || !ids(c.open) || !ids(c.covered)) { + err('carried_minors: must be {open: [minor ids], covered: [task ids]}'); + } + } + // inputs_changed (1.6.3): whether the plan and the spec changed since the + // last launch (ledger status inputs), for a relaunch's pre-flight. + if ('inputs_changed' in m) { + const flag = (v) => v === true || v === false || v === null; + const c = m.inputs_changed; + if (!isObject(c) || !('plan' in c) || !('spec' in c) || !flag(c.plan) || !flag(c.spec)) { + err('inputs_changed: must be {plan: true|false|null, spec: true|false|null}'); + } + } // start_points: feature heads from the ledger run_started events. if ('start_points' in m) { if (!isObject(m.start_points)) { diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs index a0f8e12..dc6cb10 100644 --- a/tests/engine.test.mjs +++ b/tests/engine.test.mjs @@ -1529,7 +1529,7 @@ test('a user answer from a resume keeps its own label beside the run rulings', ( const lines = p.split('\n'); const at = lines.indexOf(RUN_RULINGS); assert.ok(at >= 0, p); - assert.equal(lines[at + 1], '- R1'); + assert.equal(lines[at + 1], '- PF-1: R1'); // The ruling is not given the user's label. assert.ok(!lines.some((l) => l.includes(USER_ANSWER) && l.includes('R1')), p); // Without run rulings there is no heading. diff --git a/tests/profiles.test.mjs b/tests/profiles.test.mjs index 127d939..40912be 100644 --- a/tests/profiles.test.mjs +++ b/tests/profiles.test.mjs @@ -122,7 +122,7 @@ test('lite: no setup, pre-flight, integrate or post-integrate agent; one combine assert.ok(calls.find((c) => c.label === 'final fix').prompt.includes(`(now at ${H('T4-h')})`)); assert.deepEqual(result.final.fixed.map((f) => f.issue), ['combined issue']); assert.deepEqual(result.final.cannot_verify, ['combined: e2e: none']); - assert.deepEqual(result.preflight, { conflicts: [], rulings: [], undeclared: [], schedule: [] }); + assert.deepEqual(result.preflight, { conflicts: [], rulings: [], undeclared: [], schedule: [], adjudicated: null }); assert.equal(result.integrate, null); assert.equal(result.agents_spawned, calls.length); }); diff --git a/tests/run.test.mjs b/tests/run.test.mjs index 4ba63b4..6b91fba 100644 --- a/tests/run.test.mjs +++ b/tests/run.test.mjs @@ -214,7 +214,8 @@ test('happy path: complete, phases in order, report filled in', async () => { assert.deepEqual(t.commits, [bases[id], H(`${id}-h`)]); } assert.deepEqual(result.stopped_lanes, []); - assert.deepEqual(result.preflight, { conflicts: [], rulings: ['Ruling: x - y - z'], undeclared: [], schedule: [] }); + assert.deepEqual(result.preflight, + { conflicts: [], rulings: ['Ruling: x - y - z'], undeclared: [], schedule: [], adjudicated: null }); assert.equal(result.integrate.status, 'done'); assert.equal(result.integrate.post_integrate.status, 'done'); assert.deepEqual(result.e2e.items.map((i) => i.result), ['PASS']); @@ -847,7 +848,7 @@ test('no final findings: no fix or re-review agent', async () => { assert.deepEqual(result.final, { findings: [], fixed: [], declined: [], open: [], cannot_verify: [], missing_lenses: [], head: H('T5-h'), lens_heads: ['superpowers', 'security', 'correctness'].map((lens) => ({ lens, head: H('T5-h') })), - task_minors_open: [], repairs: [], + task_minors_open: [], carried_minors_open: [], repairs: [], }); assert.equal(result.delivered_sha, H('T5-h')); assert.ok(!labels(calls).includes('e2e recheck'), 'the e2e result already covers the delivered revision'); @@ -1471,7 +1472,7 @@ test('autonomous pre-flight conflict: an adjudicator answer continues the run an // The ruling reaches every not-yet-done task as a run ruling. for (const id of ALL) { const impl = calls.find((c) => c.label === `${id} implement`).prompt; - assert.ok(impl.includes('Rulings already made for this run (binding):\n- use the v2 contract'), id); + assert.ok(impl.includes('Rulings already made for this run (binding):\n- PF-1: use the v2 contract'), id); } const adj = calls.find((c) => c.label === 'run adjudicate'); assert.equal(adj.phase, 'Pre-flight'); @@ -2481,7 +2482,7 @@ test('a relaunch with every task done and reviewed runs no pre-flight and makes assert.equal(result.status, 'complete', JSON.stringify(result)); assert.ok(!calls.some((c) => c.label === 'pre-flight'), calls.map((c) => c.label).join(', ')); assert.deepEqual(result.preflight, - { conflicts: [], rulings: [], undeclared: [], schedule: [], skipped: 'no task left to run' }); + { conflicts: [], rulings: [], undeclared: [], schedule: [], skipped: 'no task left to run', adjudicated: null }); assert.ok(calls.some((c) => c.label === 'integrate')); // The final lenses get no run rulings. assert.ok(!calls.find((c) => c.label === 'final review sp').prompt.includes(RUN_RULINGS)); @@ -2506,7 +2507,7 @@ test('pre-flight rulings reach the adjudicator of a blocked task as run rulings' }); assert.equal(result.tasks.T2.status, 'deferred', JSON.stringify(result.tasks.T2)); const adj = calls.find((c) => c.label === 'T2 adjudicate'); - assert.ok(adj.prompt.includes(`${RUN_RULINGS}\n- Ruling: A - why - cost\n- Ruling: B - why - cost`), adj.prompt); + assert.ok(adj.prompt.includes(`${RUN_RULINGS}\n- PF-1: Ruling: A - why - cost\n- PF-2: Ruling: B - why - cost`), adj.prompt); }); test("pre-flight's own rulings reach the adjudicator of its conflicts", async () => { @@ -2517,7 +2518,7 @@ test("pre-flight's own rulings reach the adjudicator of its conflicts", async () 'run adjudicate': [{ outcome: 'answer', text: 'use the v2 contract' }], }); const adj = calls.find((c) => c.label === 'run adjudicate'); - assert.ok(adj.prompt.includes(`${RUN_RULINGS}\n- Ruling: A - why - cost`), adj.prompt); + assert.ok(adj.prompt.includes(`${RUN_RULINGS}\n- PF-1: Ruling: A - why - cost`), adj.prompt); }); test('an adjudicator with no run rulings gets no rulings heading', async () => { @@ -2540,7 +2541,7 @@ test('pre-flight rulings reach every task prompt and the final lenses as run rul assert.ok(prompts.some((c) => c.label === role), role); } for (const c of prompts) { - assert.ok(c.prompt.includes(`${RUN_RULINGS}\n- Ruling: A - why - cost\n- Ruling: B - why - cost`), c.label); + assert.ok(c.prompt.includes(`${RUN_RULINGS}\n- PF-1: Ruling: A - why - cost\n- PF-2: Ruling: B - why - cost`), c.label); assert.ok(!c.prompt.includes(USER_ANSWER), c.label); } }); @@ -2555,7 +2556,7 @@ test('an adjudicated pre-flight ruling joins the run rulings, not the user answe const prompts = taskCalls(calls); assert.ok(prompts.length >= ALL.length * 2, String(prompts.length)); for (const c of prompts) { - assert.ok(c.prompt.includes(`${RUN_RULINGS}\n- Ruling: C - x - y`), c.label); + assert.ok(c.prompt.includes(`${RUN_RULINGS}\n- PF-1: Ruling: C - x - y`), c.label); assert.ok(!c.prompt.includes(USER_ANSWER), c.label); } }); @@ -2568,7 +2569,7 @@ test('run rulings keep their text whole in every task prompt', async () => { assert.equal(result.status, 'complete', JSON.stringify(result)); for (const c of taskCalls(calls)) { assert.equal(c.prompt.split(ruling).length, 2, `${c.label}: the ruling once, whole`); - assert.ok(c.prompt.includes(`- ${ruling}\n- ${other}`), `${c.label}: still one ruling, then the next`); + assert.ok(c.prompt.includes(`- PF-1: ${ruling}\n- PF-2: ${other}`), `${c.label}: still one ruling, then the next`); } }); @@ -2724,12 +2725,68 @@ test('final lenses get the open minor findings of approved task reviews', async 'T2 review': [{ verdict: 'approve', findings: [minorT2], cannot_verify: [] }], }; const { calls } = await run(manifest(), script); + // One lens owns the checklist (1.6.3: the security lens worked it in every + // run); the other two only escalate a task minor they judge serious. + const security = calls.find((c) => c.label === 'final review security').prompt; + assert.ok(security.includes('[T2-1]') && security.includes('rename the helper'), security); + for (const label of ['final review sp', 'final review correctness']) { + const p = calls.find((c) => c.label === label).prompt; + assert.ok(!p.includes('rename the helper'), `${label}: ${p}`); + assert.ok(p.includes('Task minors are checked by another reviewer: raise one only if you judge it critical or' + + ' important, citing its id.'), `${label}: ${p}`); + } +}); + +test('without task minors no lens gets the checklist or the other-reviewer line', async () => { + const { calls } = await run(manifest(), { ...phaseScript(), ...taskScript(ALL) }); for (const label of lensLabels) { const p = calls.find((c) => c.label === label).prompt; - assert.ok(p.includes('[T2-1]') && p.includes('rename the helper'), `${label}: ${p}`); + assert.ok(!p.includes('Minor findings of approved task reviews') && !p.includes('checked by another reviewer'), label); } }); +test('a pre-flight adjudication is marked by its index in preflight.rulings', async () => { + const { result } = await run(manifest(), { + ...phaseScript({ 'pre-flight': [{ conflicts: ['T2 vs T4'], rulings: ['Ruling: A - w - c'], undeclared: [], code_deps: [] }] }), + ...taskScript(ALL), + 'run adjudicate': [{ outcome: 'answer', text: 'use the v2 contract' }], + }); + assert.deepEqual(result.preflight.rulings, ['Ruling: A - w - c', 'use the v2 contract']); + assert.equal(result.preflight.adjudicated, 1); +}); + +// A resume whose earlier launch left T2-1 open (carry-launch's snapshot): +// the session copies the ids into carried_minors. +const carriedResume = (extra = {}) => manifest({ + done: ['T1', 'T2'], reviewed: ['T1', 'T2'], backfill: backfillFor(['T1', 'T2']), + carried_minors: { open: ['T2-1'], covered: ['T1', 'T2'] }, ...extra, +}); + +test('carried minors reach the security lens through ledger minors and are open until raised', async () => { + const { result, calls } = await run(carriedResume(), { ...phaseScript(), ...taskScript(['T3', 'T4', 'T5']) }); + assert.equal(result.status, 'complete', JSON.stringify(result)); + const security = calls.find((c) => c.label === 'final review security').prompt; + assert.ok(security.includes("scripts/ledger' minors '/work/ledger'"), security); + assert.ok(security.includes('Carried: T2-1'), security); + assert.deepEqual(result.final.carried_minors_open, ['T2-1']); + // Both skipped tasks are covered by the snapshot: no cannot-verify note. + assert.ok(!result.final.cannot_verify.some((c) => typeof c === 'string' && c.includes('earlier launch approved'))); + const raised = await run(carriedResume(), { + ...phaseScript({ 'final review security': [{ findings: [{ ...finding('x'), severity: 'minor', issue: '[T2-1] still holds' }], + cannot_verify: [], head: H('T5-h') }] }), + ...taskScript(['T3', 'T4', 'T5']), + }); + assert.deepEqual(raised.result.final.carried_minors_open, []); +}); + +test('the cannot-verify note names only skipped tasks the snapshot does not cover', async () => { + const { result } = await run(carriedResume({ carried_minors: { open: [], covered: ['T1'] } }), + { ...phaseScript(), ...taskScript(['T3', 'T4', 'T5']) }); + const note = result.final.cannot_verify.find((c) => typeof c === 'string' && c.includes('earlier launch approved')); + assert.ok(note && note.includes('(T2)') && !note.includes('T1,'), JSON.stringify(result.final.cannot_verify)); + assert.deepEqual(result.final.carried_minors_open, []); +}); + test("the result keeps each task's approved minor findings and its rulings", async () => { const blocked = { status: 'blocked', head: 'T3-b', tests: '', notes: 'which key?' }; const { result } = await run(manifest(), { diff --git a/tests/validate.test.mjs b/tests/validate.test.mjs index c89d926..06ad907 100644 --- a/tests/validate.test.mjs +++ b/tests/validate.test.mjs @@ -750,3 +750,23 @@ test('setup_skipped must name lanes without pending work', () => { m.setup_result = { ...m.setup_result, setup_skipped: 'alpha' }; assertError(validateManifest(m), 'setup_result.setup_skipped: must be a list of lane ids'); }); + +test('carried_minors holds open ids and covered task ids, as ledger status prints them', () => { + for (const ok of [{ open: [], covered: [] }, { open: ['T2-1'], covered: ['T1', 'T2'] }]) { + assert.deepEqual(validateManifest({ ...validManifest(), carried_minors: ok }), []); + } + for (const bad of [[], 'T2-1', { open: 'T2-1', covered: [] }, { open: [], covered: [3] }, { open: [''], covered: [] }]) { + assertError(validateManifest({ ...validManifest(), carried_minors: bad }), + 'carried_minors: must be {open: [minor ids], covered: [task ids]}'); + } +}); + +test('inputs_changed says whether the plan and spec changed since the last launch', () => { + for (const ok of [{ plan: true, spec: false }, { plan: null, spec: null }]) { + assert.deepEqual(validateManifest({ ...validManifest(), inputs_changed: ok }), []); + } + for (const bad of [true, { plan: 'yes', spec: false }, { plan: true }]) { + assertError(validateManifest({ ...validManifest(), inputs_changed: bad }), + 'inputs_changed: must be {plan: true|false|null, spec: true|false|null}'); + } +}); From d5b49b9e92bb4397a524f0b1b17d530c79f70c8a Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:19:43 -0700 Subject: [PATCH 06/10] feat: rulings, owners and observed failures in agent prompts --- run.workflow.js | 96 ++++++++++++++++++++++++++++-- src/adjudicator.js | 4 ++ src/prompts-phase.js | 37 +++++++++++- src/prompts-task.js | 55 +++++++++++++++-- tests/carry-prompts.test.mjs | 112 +++++++++++++++++++++++++++++++++++ 5 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 tests/carry-prompts.test.mjs diff --git a/run.workflow.js b/run.workflow.js index 37b55ed..459939b 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -1104,6 +1104,19 @@ function ledgerCommand(m, laneId, entry, dir) { `${shellQuote(laneId)} ${shellQuote(JSON.stringify(entry))}`; } +// A read-only ledger command (1.6.3: rulings, minors, owners, undeclared), +// started in the agent's checkout dir like every helper command it is given. +function ledgerReadCommand(m, dir, sub, ...args) { + return `cd ${shellQuote(dir)} && ${pythonCommand(m)} ${shellQuote(`${m.skill_dir}/scripts/ledger`)} ${sub} ` + + `${shellQuote(m.repo.ledger_dir)}${args.map((a) => ` ${a}`).join('')}`; +} + +// The lines every agent after the task loop gets: the full rulings print, +// run from dir (the agent's checkout). +function rulingsReadLines(m, dir) { + return ['Every ruling in force, with its id (a read-only command):', ` ${ledgerReadCommand(m, dir, 'rulings')}`]; +} + // The commands of one group for a ledger lane (null: project commands only); // lane_commands override per group. function commandGroup(m, laneId, name) { @@ -1549,9 +1562,35 @@ function taskContext(m, task, where, guidance = null, by = 'implementer') { 'record smaller decisions as `Ruling: decision - why - cost if wrong` with:', ` ${ruling}`, 'Ledger entries are shell single-quoted JSON: fill the <...> placeholders and keep quote characters out of the text.', + ...carryOverLines(m, task, where, by), ].join('\n'); } +// What every task agent is told about the rulings in force, and what an +// implementer is told about owners and observed failures (1.6.3). +function carryOverLines(m, task, where, by) { + const ids = unitTasks(task).map((t) => `--task ${shellQuote(t.id)}`).join(' '); + const lines = [ + 'The start command prints the rulings in force under "===== rulings in force =====": they bind you. Cite one by', + 'its id (for example alpha-R3 or PF-2).', + ]; + if (by !== 'implementer') return lines; + return [ + ...lines, + 'To depart from a ruling in force, add "overrides": [""] to your ruling\'s JSON.', + 'An implementer may override only an implementer ruling of its own lane; to depart from any other, report status', + 'question naming its id.', + 'Before changing a file outside your Files list, run (a read-only command):', + ` ${ledgerReadCommand(m, where.dir, 'owners', ids, '')}`, + 'A file owned by a task in another lane running alongside, or by a later task (or a later task\'s planned part of', + 'a file you share), needs a pre-flight or adjudicator ruling in force that lists it; without one, report status', + 'question instead of changing it. A file of a task earlier in the run is fine when your task needs the change.', + 'A check failure you judge outside your task (it passed on a rerun, or the failing test covers code your task did', + 'not touch): record it as a ruling whose JSON adds "scope": "run", with the text', + 'Observed: failed - - .', + ]; +} + function fallbackImplementer() { return [ 'Built-in instructions (superpowers not found):', @@ -1643,8 +1682,11 @@ function startCommand(m, task, where, opts = {}) { const declared = declaredFiles(task); if (pkg && declared.length > 0) { parts.push('--scope', shellQuote(pkg.base), shellQuote(pkg.head), - ...declared.flatMap((f) => ['--declared', shellQuote(f)])); + ...declared.flatMap((f) => ['--declared', shellQuote(f)]), + '--owners', shellQuote(`${m.repo.ledger_dir}/owners.json`)); } + // The rulings in force (1.6.3): a join task sees every lane's. + parts.push('--rulings', shellQuote(m.repo.ledger_dir), shellQuote(where.lane === 'join' ? '-' : where.lane)); if (present(opts.start)) { parts.push('--record-start', shellQuote(m.repo.ledger_dir), shellQuote(where.lane), shellQuote(opts.start)); } @@ -1857,9 +1899,14 @@ function scopeLines(task) { if (declaredFiles(task).length === 0) return []; return [ "The start command lists, under its \"===== files changed outside the task's Files list =====\" line, every", - `file the range changes that the ${unitNoun(task)}'s Files list does not name. Judge each: a change the task did`, - "not need (above all to a file another task or lane owns) is an important finding on that file; a change the", - 'task needed is fine.', + `file the range changes that the ${unitNoun(task)}'s Files list does not name, with its owner, and any changed`, + 'file of the Files list that another task also lists. Judge each:', + '- owned by a task earlier in the run: fine when the task needed the change;', + '- owned by a task in another lane running alongside, or a later task: an important finding unless a pre-flight or' + + ' adjudicator ruling in force covers it (cite its id); an implementer\'s own ruling never does;', + "- also in another task's Files: check the plan's split of that file; doing another task's planned part is a", + ' finding (important if it breaks that task, else minor);', + '- no task owns it: a change the task did not need is an important finding on that file; a needed one is fine.', ]; } @@ -2234,6 +2281,29 @@ function verifySchema() { }; } +// A relaunch's pre-flight (1.6.3): the tasks already built, the rulings that +// bind it, and whether the plan or spec changed since the last launch +// (inputs_changed, copied from ledger status). [] on a first launch. +function relaunchPreflightLines(m) { + if (!Array.isArray(m.done) || m.done.length === 0) return []; + const ic = m.inputs_changed; + const known = ic && typeof ic.plan === 'boolean' && typeof ic.spec === 'boolean'; + let changed = 'Whether the plan or the spec changed since the last launch is not known.'; + if (known && ic.plan && ic.spec) changed = 'The plan and the spec changed since the last launch.'; + else if (known && ic.plan) changed = 'The plan changed since the last launch; the spec did not.'; + else if (known && ic.spec) changed = 'The spec changed since the last launch; the plan did not.'; + else if (known) changed = 'Neither the plan nor the spec changed since the last launch.'; + return [ + `Built tasks (done in an earlier launch; never rule on their code): ${m.done.join(', ')}`, + 'Earlier rulings bind; read them with (a read-only command):', + ` ${ledgerReadCommand(m, featureDir(m), 'rulings')}`, + 'Check only the tasks still to run and their dependence on built work.', + 'A matter an earlier ruling settled is not a conflict and gets no new ruling, unless the plan or the spec changed', + `since that ruling; then report it as a conflict. ${changed}`, + '', + ]; +} + function preflightPrompt(m) { return [ `You are the pre-flight reviewer for parallel-lanes run ${m.run_id}. You are read-only: do not modify any`, @@ -2244,6 +2314,7 @@ function preflightPrompt(m) { 'Run layout:', layoutText(m), '', + ...relaunchPreflightLines(m), 'Check:', '1. Every task id above has a "Task :" heading in the plan.', '2. The plan against the spec: contradictions, and defects the plan mandates (instructions that are wrong', @@ -2640,7 +2711,7 @@ function taskMinorsLines(m, minors) { const here = Array.isArray(minors) ? minors : []; const carried = carriedMinorIds(m); if (here.length === 0 && carried.length === 0) return []; - const ledger = `${pythonCommand(m)} ${shellQuote(`${m.skill_dir}/scripts/ledger`)} minors ${shellQuote(m.repo.ledger_dir)}`; + const ledger = ledgerReadCommand(m, featureDir(m), 'minors'); return [ ...(here.length > 0 ? [ 'Minor findings of approved task reviews, never acted on (each with its id in brackets):', @@ -2648,7 +2719,8 @@ function taskMinorsLines(m, minors) { ] : []), ...(carried.length > 0 ? [ 'Minor findings an earlier launch\'s task reviews approved and no final review has raised yet. Read them with', - `(a read-only command): ${ledger}`, + '(a read-only command):', + ` ${ledger}`, `Carried: ${carried.join(', ')}`, ] : []), 'For each one that still holds and is worth fixing, raise it as a finding with its id in brackets in the', @@ -2686,6 +2758,11 @@ function finalReviewFrame(m, intro, focus, minors = [], other = false) { '', ...(other ? otherReviewerMinorsLines(m, minors) : taskMinorsLines(m, minors)), ...runRulingsLines(m), + ...rulingsReadLines(m, featureDir(m)), + "A finding that a ruling in force settles is not raised unless it is critical or important; then raise it and cite the ruling's id.", + 'Files each task changed outside its Files list, with their owners (a read-only command):', + ` ${ledgerReadCommand(m, featureDir(m), 'undeclared')}`, + "Check each change was needed and, where it is another lane's or a later task's file, covered by a ruling.", 'Also scan every commit message in the range and the whole diff for anything the commit rules forbid;', 'report each as a finding (for a commit message use file "commit " and line 0).', ...fixMessagesLines(), @@ -2748,6 +2825,7 @@ function finalFixPrompt(m, findings, base) { `Plan: ${m.plan}`, `Spec: ${m.spec === null ? '(none)' : m.spec}`, ...runRulingsLines(m), + ...rulingsReadLines(m, featureDir(m)), keepFilesRule(), phaseRules(m), '', @@ -2807,6 +2885,7 @@ function finalReReviewPrompt(m, base, head, findings, verify = null, repairs = [ 'check the fix for new critical or important problems; do not re-review code the fix did not touch.', ] : ['Check the repairs for new critical or important problems; do not re-review code they did not touch.']), ...runRulingsLines(m), + ...rulingsReadLines(m, featureDir(m)), ...fixMessagesLines(), phaseRules(m), '', @@ -2899,6 +2978,7 @@ function verifyRepairPrompt(m, verify, from, attempt) { `Plan: ${m.plan}`, `Spec: ${m.spec === null ? '(none)' : m.spec}`, ...runRulingsLines(m), + ...rulingsReadLines(m, featureDir(m)), keepFilesRule(), phaseRules(m), '', @@ -3146,8 +3226,12 @@ function adjudicatorPrompt(m, ctx) { ' - plan_broken: a plan so broken every path is a guess', 'Anything else is not a reason to stop: answer, clarify, park, or unblock instead.', '', + ...rulingsReadLines(m, dir), 'Record your ruling, in the format `Ruling: decision - why - cost if wrong`, as a ledger ruling event with:', ` ${ruling}`, + 'In its JSON add "scope": "run" when it concerns code, tests or checks outside this task\'s lane,', + '"overrides": [""] when it departs from a ruling in force, and', + '"files": [""] for each file it permits the task to change outside its Files list.', 'Ledger entries are shell single-quoted JSON: fill the <...> placeholders and keep quote characters out of the text.', 'For park or unblock, run the command the details above give for that outcome; it prints JSON with a head.', '', diff --git a/src/adjudicator.js b/src/adjudicator.js index 1ae265b..7ba77de 100644 --- a/src/adjudicator.js +++ b/src/adjudicator.js @@ -113,8 +113,12 @@ function adjudicatorPrompt(m, ctx) { ' - plan_broken: a plan so broken every path is a guess', 'Anything else is not a reason to stop: answer, clarify, park, or unblock instead.', '', + ...rulingsReadLines(m, dir), 'Record your ruling, in the format `Ruling: decision - why - cost if wrong`, as a ledger ruling event with:', ` ${ruling}`, + 'In its JSON add "scope": "run" when it concerns code, tests or checks outside this task\'s lane,', + '"overrides": [""] when it departs from a ruling in force, and', + '"files": [""] for each file it permits the task to change outside its Files list.', 'Ledger entries are shell single-quoted JSON: fill the <...> placeholders and keep quote characters out of the text.', 'For park or unblock, run the command the details above give for that outcome; it prints JSON with a head.', '', diff --git a/src/prompts-phase.js b/src/prompts-phase.js index 1be055e..63c5298 100644 --- a/src/prompts-phase.js +++ b/src/prompts-phase.js @@ -227,6 +227,29 @@ function verifySchema() { }; } +// A relaunch's pre-flight (1.6.3): the tasks already built, the rulings that +// bind it, and whether the plan or spec changed since the last launch +// (inputs_changed, copied from ledger status). [] on a first launch. +function relaunchPreflightLines(m) { + if (!Array.isArray(m.done) || m.done.length === 0) return []; + const ic = m.inputs_changed; + const known = ic && typeof ic.plan === 'boolean' && typeof ic.spec === 'boolean'; + let changed = 'Whether the plan or the spec changed since the last launch is not known.'; + if (known && ic.plan && ic.spec) changed = 'The plan and the spec changed since the last launch.'; + else if (known && ic.plan) changed = 'The plan changed since the last launch; the spec did not.'; + else if (known && ic.spec) changed = 'The spec changed since the last launch; the plan did not.'; + else if (known) changed = 'Neither the plan nor the spec changed since the last launch.'; + return [ + `Built tasks (done in an earlier launch; never rule on their code): ${m.done.join(', ')}`, + 'Earlier rulings bind; read them with (a read-only command):', + ` ${ledgerReadCommand(m, featureDir(m), 'rulings')}`, + 'Check only the tasks still to run and their dependence on built work.', + 'A matter an earlier ruling settled is not a conflict and gets no new ruling, unless the plan or the spec changed', + `since that ruling; then report it as a conflict. ${changed}`, + '', + ]; +} + function preflightPrompt(m) { return [ `You are the pre-flight reviewer for parallel-lanes run ${m.run_id}. You are read-only: do not modify any`, @@ -237,6 +260,7 @@ function preflightPrompt(m) { 'Run layout:', layoutText(m), '', + ...relaunchPreflightLines(m), 'Check:', '1. Every task id above has a "Task :" heading in the plan.', '2. The plan against the spec: contradictions, and defects the plan mandates (instructions that are wrong', @@ -633,7 +657,7 @@ function taskMinorsLines(m, minors) { const here = Array.isArray(minors) ? minors : []; const carried = carriedMinorIds(m); if (here.length === 0 && carried.length === 0) return []; - const ledger = `${pythonCommand(m)} ${shellQuote(`${m.skill_dir}/scripts/ledger`)} minors ${shellQuote(m.repo.ledger_dir)}`; + const ledger = ledgerReadCommand(m, featureDir(m), 'minors'); return [ ...(here.length > 0 ? [ 'Minor findings of approved task reviews, never acted on (each with its id in brackets):', @@ -641,7 +665,8 @@ function taskMinorsLines(m, minors) { ] : []), ...(carried.length > 0 ? [ 'Minor findings an earlier launch\'s task reviews approved and no final review has raised yet. Read them with', - `(a read-only command): ${ledger}`, + '(a read-only command):', + ` ${ledger}`, `Carried: ${carried.join(', ')}`, ] : []), 'For each one that still holds and is worth fixing, raise it as a finding with its id in brackets in the', @@ -679,6 +704,11 @@ function finalReviewFrame(m, intro, focus, minors = [], other = false) { '', ...(other ? otherReviewerMinorsLines(m, minors) : taskMinorsLines(m, minors)), ...runRulingsLines(m), + ...rulingsReadLines(m, featureDir(m)), + "A finding that a ruling in force settles is not raised unless it is critical or important; then raise it and cite the ruling's id.", + 'Files each task changed outside its Files list, with their owners (a read-only command):', + ` ${ledgerReadCommand(m, featureDir(m), 'undeclared')}`, + "Check each change was needed and, where it is another lane's or a later task's file, covered by a ruling.", 'Also scan every commit message in the range and the whole diff for anything the commit rules forbid;', 'report each as a finding (for a commit message use file "commit " and line 0).', ...fixMessagesLines(), @@ -741,6 +771,7 @@ function finalFixPrompt(m, findings, base) { `Plan: ${m.plan}`, `Spec: ${m.spec === null ? '(none)' : m.spec}`, ...runRulingsLines(m), + ...rulingsReadLines(m, featureDir(m)), keepFilesRule(), phaseRules(m), '', @@ -800,6 +831,7 @@ function finalReReviewPrompt(m, base, head, findings, verify = null, repairs = [ 'check the fix for new critical or important problems; do not re-review code the fix did not touch.', ] : ['Check the repairs for new critical or important problems; do not re-review code they did not touch.']), ...runRulingsLines(m), + ...rulingsReadLines(m, featureDir(m)), ...fixMessagesLines(), phaseRules(m), '', @@ -892,6 +924,7 @@ function verifyRepairPrompt(m, verify, from, attempt) { `Plan: ${m.plan}`, `Spec: ${m.spec === null ? '(none)' : m.spec}`, ...runRulingsLines(m), + ...rulingsReadLines(m, featureDir(m)), keepFilesRule(), phaseRules(m), '', diff --git a/src/prompts-task.js b/src/prompts-task.js index be99872..4e34d90 100644 --- a/src/prompts-task.js +++ b/src/prompts-task.js @@ -57,6 +57,19 @@ function ledgerCommand(m, laneId, entry, dir) { `${shellQuote(laneId)} ${shellQuote(JSON.stringify(entry))}`; } +// A read-only ledger command (1.6.3: rulings, minors, owners, undeclared), +// started in the agent's checkout dir like every helper command it is given. +function ledgerReadCommand(m, dir, sub, ...args) { + return `cd ${shellQuote(dir)} && ${pythonCommand(m)} ${shellQuote(`${m.skill_dir}/scripts/ledger`)} ${sub} ` + + `${shellQuote(m.repo.ledger_dir)}${args.map((a) => ` ${a}`).join('')}`; +} + +// The lines every agent after the task loop gets: the full rulings print, +// run from dir (the agent's checkout). +function rulingsReadLines(m, dir) { + return ['Every ruling in force, with its id (a read-only command):', ` ${ledgerReadCommand(m, dir, 'rulings')}`]; +} + // The commands of one group for a ledger lane (null: project commands only); // lane_commands override per group. function commandGroup(m, laneId, name) { @@ -502,9 +515,35 @@ function taskContext(m, task, where, guidance = null, by = 'implementer') { 'record smaller decisions as `Ruling: decision - why - cost if wrong` with:', ` ${ruling}`, 'Ledger entries are shell single-quoted JSON: fill the <...> placeholders and keep quote characters out of the text.', + ...carryOverLines(m, task, where, by), ].join('\n'); } +// What every task agent is told about the rulings in force, and what an +// implementer is told about owners and observed failures (1.6.3). +function carryOverLines(m, task, where, by) { + const ids = unitTasks(task).map((t) => `--task ${shellQuote(t.id)}`).join(' '); + const lines = [ + 'The start command prints the rulings in force under "===== rulings in force =====": they bind you. Cite one by', + 'its id (for example alpha-R3 or PF-2).', + ]; + if (by !== 'implementer') return lines; + return [ + ...lines, + 'To depart from a ruling in force, add "overrides": [""] to your ruling\'s JSON.', + 'An implementer may override only an implementer ruling of its own lane; to depart from any other, report status', + 'question naming its id.', + 'Before changing a file outside your Files list, run (a read-only command):', + ` ${ledgerReadCommand(m, where.dir, 'owners', ids, '')}`, + 'A file owned by a task in another lane running alongside, or by a later task (or a later task\'s planned part of', + 'a file you share), needs a pre-flight or adjudicator ruling in force that lists it; without one, report status', + 'question instead of changing it. A file of a task earlier in the run is fine when your task needs the change.', + 'A check failure you judge outside your task (it passed on a rerun, or the failing test covers code your task did', + 'not touch): record it as a ruling whose JSON adds "scope": "run", with the text', + 'Observed: failed - - .', + ]; +} + function fallbackImplementer() { return [ 'Built-in instructions (superpowers not found):', @@ -596,8 +635,11 @@ function startCommand(m, task, where, opts = {}) { const declared = declaredFiles(task); if (pkg && declared.length > 0) { parts.push('--scope', shellQuote(pkg.base), shellQuote(pkg.head), - ...declared.flatMap((f) => ['--declared', shellQuote(f)])); + ...declared.flatMap((f) => ['--declared', shellQuote(f)]), + '--owners', shellQuote(`${m.repo.ledger_dir}/owners.json`)); } + // The rulings in force (1.6.3): a join task sees every lane's. + parts.push('--rulings', shellQuote(m.repo.ledger_dir), shellQuote(where.lane === 'join' ? '-' : where.lane)); if (present(opts.start)) { parts.push('--record-start', shellQuote(m.repo.ledger_dir), shellQuote(where.lane), shellQuote(opts.start)); } @@ -810,9 +852,14 @@ function scopeLines(task) { if (declaredFiles(task).length === 0) return []; return [ "The start command lists, under its \"===== files changed outside the task's Files list =====\" line, every", - `file the range changes that the ${unitNoun(task)}'s Files list does not name. Judge each: a change the task did`, - "not need (above all to a file another task or lane owns) is an important finding on that file; a change the", - 'task needed is fine.', + `file the range changes that the ${unitNoun(task)}'s Files list does not name, with its owner, and any changed`, + 'file of the Files list that another task also lists. Judge each:', + '- owned by a task earlier in the run: fine when the task needed the change;', + '- owned by a task in another lane running alongside, or a later task: an important finding unless a pre-flight or' + + ' adjudicator ruling in force covers it (cite its id); an implementer\'s own ruling never does;', + "- also in another task's Files: check the plan's split of that file; doing another task's planned part is a", + ' finding (important if it breaks that task, else minor);', + '- no task owns it: a change the task did not need is an important finding on that file; a needed one is fine.', ]; } diff --git a/tests/carry-prompts.test.mjs b/tests/carry-prompts.test.mjs new file mode 100644 index 0000000..537d16d --- /dev/null +++ b/tests/carry-prompts.test.mjs @@ -0,0 +1,112 @@ +// 1.6.3 review carry-over in the agents' prompts: the rulings in force, the +// owner map, observed failures, and a relaunch's pre-flight. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadHelpers, H } from './harness.mjs'; + +const { + implementPrompt, reviewPrompt, fixPrompt, reReviewPrompt, adjudicatorPrompt, finalReviewPrompt, + combinedFinalReviewPrompt, finalFixPrompt, finalReReviewPrompt, verifyRepairPrompt, preflightPrompt, +} = await loadHelpers([ + 'implementPrompt', 'reviewPrompt', 'fixPrompt', 'reReviewPrompt', 'adjudicatorPrompt', 'finalReviewPrompt', + 'combinedFinalReviewPrompt', 'finalFixPrompt', 'finalReReviewPrompt', 'verifyRepairPrompt', 'preflightPrompt', +]); + +const task = (id, extra = {}) => ({ id, title: `Task ${id}`, files: [`src/${id}.js`], tier: 'standard', security: false, ...extra }); +function manifest(overrides = {}) { + return { + version: 1, run_id: 'run-1', plan: '/work/plan.md', spec: '/work/spec.md', commit_rules: 'plain ASCII', + repo: { + mode: 'git', root: '/work/repo', git_dir: null, base_ref: 'main', branch: 'pl/run-1', + worktree_root: '/work/wt', ledger_dir: '/work/ledger', + }, + commands: { setup: [], test: ['npm test'], lint: [], build: [] }, + prelude: [task('T1')], + lanes: [{ id: 'alpha', name: 'Lane alpha', tasks: [task('T2')] }], + join: [task('T4')], + hooks: {}, limits: { review_rounds: 5, max_parallel_lanes: 3 }, dry_run: false, done: [], reviewed: [], + sp_dir: null, skill_dir: '/skills/parallel-lanes', ...overrides, + }; +} +const ALPHA = { dir: '/work/wt/lane-alpha', branch: 'pl-run-1-alpha', lane: 'alpha' }; +const JOIN = { dir: '/work/repo', branch: 'pl/run-1', lane: 'join' }; +const LEDGER = "'python3' '/skills/parallel-lanes/scripts/ledger'"; +const RULINGS_CMD = `${LEDGER} rulings '/work/ledger'`; +const finding = { severity: 'important', file: 'src/a.js', line: 3, issue: 'x', fix: 'y' }; +const done = { status: 'done', head: H('h1'), tests: 'ok', notes: '' }; + +function taskPrompts(m, where, t = task('T2')) { + return { + implement: implementPrompt(m, t, where, H('b0')), + review: reviewPrompt(m, t, where, H('b0'), H('h1')), + fix: fixPrompt(m, t, where, [finding], done, H('h1')), + reReview: reReviewPrompt(m, t, where, H('h1'), H('h2'), [finding]), + }; +} + +test('every task agent starts with the rulings in force for its lane; join agents get them all', () => { + for (const [name, p] of Object.entries(taskPrompts(manifest(), ALPHA))) { + assert.ok(p.includes("--rulings '/work/ledger' 'alpha'"), name); + } + for (const [name, p] of Object.entries(taskPrompts(manifest(), JOIN, task('T4')))) { + assert.ok(p.includes("--rulings '/work/ledger' '-'"), name); + } +}); + +test('reviewers get the owner map with their scope list, and the four-way scope rule', () => { + const { review, reReview } = taskPrompts(manifest(), ALPHA); + for (const p of [review, reReview]) { + assert.ok(p.includes("--owners '/work/ledger/owners.json'"), p); + assert.ok(p.includes('owned by a task earlier in the run'), p); + assert.ok(p.includes('another lane running alongside, or a later task: an important finding unless a pre-flight or' + + ' adjudicator ruling in force covers it'), p); + assert.ok(p.includes("also in another task's Files: check the plan's split of that file"), p); + } +}); + +test('implementers check owners before going outside their Files list, record observed failures, and override by id', () => { + const p = implementPrompt(manifest(), task('T2'), ALPHA, H('b0')); + assert.ok(p.includes(`${LEDGER} owners '/work/ledger' --task 'T2' `), p); + assert.ok(p.includes('Observed: failed - - '), p); + assert.ok(p.includes('"scope": "run"'), p); + assert.ok(p.includes('"overrides": ['), p); + assert.ok(p.includes('may override only an implementer ruling of its own lane'), p); +}); + +test('the adjudicator reads every ruling and may scope, override and permit files', () => { + const ctx = { kind: 'blocked', task: task('T2'), where: ALPHA, details: 'stuck', findings: [] }; + const p = adjudicatorPrompt(manifest(), ctx); + assert.ok(p.includes(RULINGS_CMD), p); + for (const field of ['"scope": "run"', '"overrides": [', '"files": [']) assert.ok(p.includes(field), field); +}); + +test('final reviewers, the final fix, the re-review and the verify repair read every ruling', () => { + const m = manifest(); + const verify = { ok: false, head: H('f1'), results: [{ group: 'test', command: 'npm test', exit: 1, log: '/l', tail: 'x' }] }; + const prompts = { + lens: finalReviewPrompt(m, 'correctness', null, []), + combined: combinedFinalReviewPrompt(m, { e2e: null, minors: [] }), + fix: finalFixPrompt(m, [{ ...finding, id: 'F1' }], H('t0')), + reReview: finalReReviewPrompt(m, H('t0'), H('f1'), [{ ...finding, id: 'F1', dispositions: [] }], verify, []), + repair: verifyRepairPrompt(m, verify, H('f1'), 1), + }; + for (const [name, p] of Object.entries(prompts)) assert.ok(p.includes(RULINGS_CMD), name); + for (const p of [prompts.lens, prompts.combined]) { + assert.ok(p.includes('A finding that a ruling in force settles is not raised unless it is critical or important;' + + ' then raise it and cite the ruling\'s id.'), p); + assert.ok(p.includes(`${LEDGER} undeclared '/work/ledger'`), p); + } +}); + +test("a relaunch's pre-flight checks only the tasks still to run, bound by the earlier rulings", () => { + const fresh = preflightPrompt(manifest()); + assert.ok(!fresh.includes('Built tasks'), fresh); + const m = manifest({ done: ['T1', 'T2'], reviewed: ['T1'], inputs_changed: { plan: true, spec: false } }); + const p = preflightPrompt(m); + assert.ok(p.includes('Built tasks (done in an earlier launch; never rule on their code): T1, T2'), p); + assert.ok(p.includes(RULINGS_CMD), p); + assert.ok(p.includes('A matter an earlier ruling settled is not a conflict and gets no new ruling'), p); + assert.ok(p.includes('The plan changed since the last launch; the spec did not.'), p); + const unknown = preflightPrompt(manifest({ done: ['T1'], reviewed: ['T1'] })); + assert.ok(unknown.includes('Whether the plan or the spec changed since the last launch is not known.'), unknown); +}); From acc210a1ed0a5fc81094bf344de0842bf4eeba83 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:26:31 -0700 Subject: [PATCH 07/10] docs: review carry-over --- README.md | 6 +++ SKILL.md | 22 +++++------ reference.md | 102 +++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 22ca6ec..52bc225 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,12 @@ Other things to know: stop the run for you instead. You can switch modes when the table is shown. A task the adjudicator parks is deferred, never counted as delivered; security tasks are never parked, and `allow_deferral: false` forbids parking altogether. +- **Carry-over.** Rulings (pre-flight's, the adjudicator's, implementers') live in the ledger + and bind every agent that starts after them, a relaunch included; a relaunch's pre-flight + never re-rules work already built. Task minors an earlier launch left open reach the final + review, where one reviewer owns the checklist. A task that changes a file another lane or a + later task owns needs a ruling first; edits to earlier tasks' files are flagged. See + `reference.md` ("Carry-over"). - **Transient failures** (agent errors, missing results) relaunch once automatically. Anything substantive stops the run and asks you. - **Where files go.** Manifests and ledgers are stored beside the plan, or under diff --git a/SKILL.md b/SKILL.md index bcc4e4c..a545569 100644 --- a/SKILL.md +++ b/SKILL.md @@ -174,10 +174,8 @@ this. Say the table is the one required check, show it, and wait. token (keep it for relaunches). Exit 4: another session holds the run; stop and ask, and use `--takeover` only when the user confirms that session ended. Then run ` /scripts/setup --owner `. Its exit 4 naming another - run (`the checkout ... is in use by run ...`) means that run holds the checkout: setup changed - nothing, so release this run's own launch lock first (`active-run release setup_failed - --owner `, or `active-run release --remove --owner ` for a new run that never - started), then stop and report it. It creates the feature branch and + run (`the checkout ... is in use by run ...`): reference.md "Active-run markers and stops"; then stop + and report it. It creates the feature branch and worktrees and discards edits in run-owned worktrees, so never before the yes. On failure wait 10 seconds and retry once; then `active-run release setup_failed --owner `, report, and stop. Put its output in `setup_result` @@ -195,12 +193,12 @@ this. Say the table is the one required check, show it, and wait. never stops the run; keep the latest launch's refusal code for `tool-outcomes`. 5. Record each launch's spend (reference.md "Budgets"), a relaunch included, never a dry run: ` /scripts/ledger ended - ` (``: reference.md "Budgets"). - After a launch that a transient relaunch follows, record only the spend (keep the lock - through the relaunch). At the end, record the spend and release the lock in one command - line, so the spend is never forgotten once the lock goes, and a refused record (exit 2) - keeps the lock until it is fixed: ` /scripts/ledger ended - && bash /scripts/active-run release + ` (``: reference.md "Budgets"), then ` /scripts/ledger + carry-launch ` (reference.md "Carry-over"; exit 3: say that launch + is not carried). Before a transient relaunch, keep the lock. At the end, in one command line + (reference.md "Budgets"): ` /scripts/ledger ended + && { /scripts/ledger carry-launch + || true; } && bash /scripts/active-run release --owner `, where `` is `--remove` only for `complete` when the hand-back gate passes: `acceptance.status` `accepted`, and ` /scripts/check-verify ` exits 0 printing status `match` or `not_required`; otherwise `` (`unaccepted` for @@ -291,7 +289,9 @@ first, show its plan, and add `--yes` only on the user's yes. Keep the manifest 2. ` /scripts/ledger status --plan --manifest [--spec ]`. Set `done`, `reviewed`, and `deferred` from it (taking a deferred task up: reference.md "Backfill"), and `repairs_spent` from earlier reports' `final.repairs`; - put each `carry` entry into `notes`. Tasks in `stale` + put each `carry` entry into `notes`. First run `ledger carry-launch ` for + each earlier launch (`doctor-.json` in the ledger names them); then copy `minors` into + `carried_minors` and `inputs` into `inputs_changed` as printed (reference.md "Carry-over"). Tasks in `stale` (with their dependents) or `unbound` are left out of `reviewed`, so they are reviewed again: tell the user which and why, and say so when `inputs` reports a changed plan or spec. ` /scripts/coverage ` must exit 0 again diff --git a/reference.md b/reference.md index 7f2e594..93d2f96 100644 --- a/reference.md +++ b/reference.md @@ -62,6 +62,8 @@ PATH, else `C:/Program Files/Git`), never the WSL `bash.exe` in System32. | `autonomy` | `autonomous` (default: the adjudicator settles blocked tasks, questions, review caps, pre-flight conflicts) or `supervised` (they stop the run and wait for the user). The user may override it in the table. | | `profile` | `full` (default) or `lite` (see Profiles). | | `setup_result` | The output of `scripts/setup --owner `: `{feature_head, worktrees, discarded, preserved, setup_skipped}` (`setup_skipped`: the lanes whose setup commands were skipped because every task is done and reviewed; validation rejects a listed lane with pending work, and then setup reruns). Added after the yes to the table; required for a launch (there is no setup agent; a dry run does without it). `preserved` lists the commits under `refs/parallel-lanes//abandoned/` that hold changes setup discarded (see Cleanup). | +| `carried_minors` | Optional (1.6.3), set on a resume: `{open, covered}` from `ledger status`'s `minors`, copied as printed: the ids of the task minors earlier launches left open, and the tasks the snapshot covers ("Carry-over"). | +| `inputs_changed` | Optional (1.6.3), set on a resume: `{plan, spec}` from `ledger status --plan --spec`'s `inputs` (each `true`, `false` or `null`), read before setup, so a relaunch's pre-flight knows what changed since the last launch. | | `start_points` | The feature heads `ledger status` prints after setup, copied exactly as it prints them: `prelude` from setup, `join` only once a launch got past integration (a new run has no `join` key). | | `dry_run` | `true` only in the confirmation call. | | `done`, `reviewed` | `[]` for a new run; on resume, from `ledger status --plan`. Never by hand. | @@ -276,8 +278,10 @@ findings (`adjudicator_stop: security`), and `allow_deferral: false` refuses it `adjudication_cap`. Every ruling is a ledger `ruling` event `Ruling: decision - why - cost if wrong` whose `by` names its role: `adjudicator`, `implementer` (implement and fix agents) or `reviewer` (review and re-review -agents); a ruling with no `by` comes from an implementer before 1.6.3. Pre-flight rulings are -in `preflight.rulings`. A task's adjudicator rulings are also in the run result's +agents); a ruling with no `by` was written by a task agent before 1.6.3 (in every archive +checked an implementer or fixer, but a reviewer had the same command) and is read as an +implementer's. Pre-flight rulings are in `preflight.rulings`, and from 1.6.3 also in the ledger +once `carry-launch` records them ("Carry-over"). A task's adjudicator rulings are also in the run result's `tasks..rulings`, and its approving review's minor findings in `tasks..minor_findings` (`[]` unless the task was done in that launch), so a later launch can read both. @@ -304,6 +308,67 @@ the manifest file. The user's answers (`notes`) keep their own label, and an adj `supervised` there is no adjudicator: a blocked task, a question, or the review cap (`review_rounds`) stops the run. +## Carry-over + +From 1.6.3 what earlier agents decided or found reaches the agents after them, once. + +**The ledger holds every ruling.** A `ruling` event may carry `by` (`implementer`, `reviewer`, +`adjudicator`, `preflight`), `scope` (`lane` or `run`), `overrides` (the ids it departs from), +`files` (paths an adjudicator ruling permits outside a task's Files list), `pf` and `wf`. The +ledger checks the values, not whether an agent told the truth. A ruling's id is `-R`, n counting that ledger file's rulings in append order (`rig-R4`, `_run-R2`); this +launch's pre-flight rulings are numbered `PF-1`... in the prompts until `carry-launch` records +them. Run-wide: pre-flight rulings, an adjudicator ruling about code, tests or checks outside the +task's lane, and an observed failure; everything else is lane-wide. +` /scripts/ledger rulings [--lane ]` prints the rulings in +force (with `--lane`: `_run`, the prelude, that lane, and every run-scope one), then the +overridden ones. + +**Who sees them.** Every task agent's start command prints its lane's rulings under `===== +rulings in force =====` (join tasks: all); the adjudicator, the final lenses, the final fix, the +final re-review, the verify repair and a relaunch's pre-flight get the `ledger rulings` command. +An implementer may override only an implementer ruling of its own lane; departing from any +other is a question the adjudicator settles. An adjudicator may override any ruling, naming it; +pre-flight never does. A final lens does not raise a matter a ruling in force settles unless it +is critical or important, and then cites the ruling. + +**carry-launch.** After each launch (Launch step 5), ` /scripts/ledger +carry-launch ` reads that launch's Workflow result file and records, +once per wf id: each pre-flight ruling (by `preflight`, scope `run`, its `PF-n`), skipping the +pre-flight adjudication the adjudicator recorded itself (`preflight.adjudicated`); one `minors` +snapshot, the task minors still open after that launch; and a `carried` event that binds any +`PF-n` a ruling of that launch overrode. On a resume, `carry-launch ` finds a +launch whose session died by its wf id under the Claude config dir's +`projects/*/*/workflows/.json` (the ledger's `doctor-.json` files name the wf ids). Exit 3 +means that launch is not carried (no readable result); say so and go on. `ledger minors +` prints the open minors; `ledger status` prints `minors` (`{open: [ids], covered: +[task ids]}`) and `overrides`. + +**Task minors.** Only the security lens gets the task-minor checklist (it worked it in every +archived run); the superpowers and correctness lenses raise a task minor only when they judge it +critical or important. On a resume the session copies `ledger status`'s `minors` into +`carried_minors`; the security lens reads their text through `ledger minors`. + +**A relaunch's pre-flight** is told which tasks are built, gets the `ledger rulings` command and +`inputs_changed`, and checks only the tasks still to run: a matter an earlier ruling settled is +not a conflict and gets no new ruling unless the plan or spec changed since. With no task left to +run there is no pre-flight (`preflight.skipped`). + +**Outside the Files list.** Setup writes `/owners.json`: which tasks list each path, +and where each task runs. A reviewer's scope list and ` /scripts/ledger owners + --task ...` say, for each path: owned by a task `earlier in the run` +(fine when the task needed the change, and flagged), `another lane, running alongside` or +`later` (needs a pre-flight or adjudicator ruling in force that covers it; an implementer asks +instead of changing it, and its own ruling never counts), `in your Files list; also in X's +Files` (the reviewer checks the plan's split of that shared file), or `no task owns it`. +`ledger undeclared ` (and `ledger status --manifest`) report `{task: [{path, owner, +relation}]}`. + +**Observed failures.** A check failure an implementer judges outside its task (it passed on a +rerun, or the failing test covers code the task did not touch) is a run-wide ruling +`Observed: failed - - `, so verify, a +repair agent and the hand-back know it. How the failure itself is handled does not change. + ## Fix commits The plan's commit message applies to a task's first commit. Since the commit rules forbid @@ -390,6 +455,10 @@ raised it). running agent takes memory, so raise it only on a machine with memory to spare; lowering `max_parallel_lanes` instead keeps the table accurate. +The end of a run records the spend, carries the launch and releases the lock in one command +line (SKILL.md Launch step 5), so the spend is never forgotten once the lock goes, and a refused +record (exit 2) keeps the lock until it is fixed; a carry-launch that fails never keeps it. + ## Check ownership Each kind of check has one owner (1.6.0). Review depth is unchanged. From 1.6.1 every owner @@ -605,6 +674,11 @@ itself. ## Active-run markers and stops +Setup's exit 4 naming another run (`the checkout ... is in use by run ...`) means that run holds +the checkout: setup changed nothing, so release this run's own launch lock first (`active-run +release setup_failed --owner `, or `active-run release --remove --owner +` for a new run that never started), then stop and report it. + Markers live in `/parallel-lanes/active/` (`$PL_ACTIVE_DIR` overrides). `scripts/active-run acquire ` creates `.lock` atomically (it fails with exit 4 when it exists: another session may still be running the run), writes the marker @@ -876,11 +950,12 @@ gets an id `-`, with `T` put in front of a task id that starts with a d gives `T3-1`, task T2 gives `T2-1`), and goes to the final lenses as a checklist: a lens raises one by putting its id in brackets in a finding's issue, or leaves it. `final.task_minors_open` lists the ones no lens raised: show them in the report, so minors from task reviews are not -lost. Task minors are not stored in the ledger: after a resume, the tasks an earlier launch -committed and reviewed are not re-run, so their minors reach neither the final lenses nor -`task_minors_open`; the run says so in a `cannot_verify` note (a plain string, so not a warning, -since every resume has such tasks) naming those tasks (check them in the earlier launch's -report). The fixer gives each id a disposition with its evidence; the re-review names the +lost. From 1.6.3 only the security lens gets the checklist, and the minors an earlier launch +left open are carried ("Carry-over"): they reach it through `ledger minors`, and +`final.carried_minors_open` lists the carried ids no lens raised. A skipped task that no +snapshot covers (a launch `carry-launch` could not read, or a 1.6.2 launch) still reaches neither +the lenses nor `task_minors_open`; the run says so in a `cannot_verify` note (a plain string, so +not a warning) naming those tasks (check them in the earlier launch's report). The fixer gives each id a disposition with its evidence; the re-review names the revision it judged (`head`). A finding the fix gave no disposition for, a disposition or re-review result with blank evidence, a re-review of another revision than the fix head, or two different dispositions or results for one id leave the finding open (the new findings such a @@ -890,7 +965,11 @@ batch's files count for each of its tasks): list them in the report so the user that went beyond the plan. The hand-back appends the report, saves it beside the manifest, and lists -"Rulings made on your behalf" from the ledger `ruling` events plus `preflight.rulings`. When +"Rulings made on your behalf" from ` /scripts/ledger rulings ` +(each with its id and role, overridden ones apart), plus this launch's `preflight.rulings` when +the launch is not carried yet; then the rulings that override others (`ledger status`'s +`overrides`, flagging an implementer that overrode a pre-flight or adjudicator ruling) and the +observed failures (rulings whose text starts `Observed:`). When a task result's `rulings` has an entry starting `refused (security-gated):` or `refused (deferral_not_allowed):`, the policy refused the adjudicator's park or unblock: list that ruling with the prefix, never as one that took effect. An adjudicator that itself chose stop (`adjudicator_stop: ` with no @@ -911,9 +990,10 @@ ledger's `committed` events), review rounds, tier and escalations, notes, cannot "Rulings made on your behalf" and "Dependencies pre-flight added" (above). Then integration and post-integrate notes, E2E PASS/FAIL and the revision it checked, final review (fixed, declined, open, cannot verify, and the task minors no final lens raised, `final.task_minors_open`), -acceptance warnings, `agents_spawned`, `agent_type_fallback`, and the `undeclared` files of -`ledger status --manifest ` (changed outside their task's Files -list). +acceptance warnings, `agents_spawned`, `agent_type_fallback`, the carried minors left open +(`final.carried_minors_open`), and the `undeclared` files of `ledger status +--manifest ` (changed outside their task's Files list), each with its owner and +relation; mark each `alongside` or `later` one no adjudicator ruling's `files` covers. ### Run tools line From 0cd5ab20764eb1eb92db90e99c84d3be65917978 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:54:32 -0700 Subject: [PATCH 08/10] fix: address the final review findings --- SKILL.md | 12 +-- reference.md | 7 +- run.workflow.js | 11 ++- scripts/_owners.py | 5 +- scripts/ledger | 148 +++++++++++++++++++++++++++++------ scripts/start-task | 20 ++--- src/phases.js | 2 +- src/prompts-phase.js | 7 +- src/prompts-task.js | 2 +- tests/carry-prompts.test.mjs | 14 +++- tests/ledger-carry.test.mjs | 102 ++++++++++++++++++++++++ tests/task-helpers.test.mjs | 20 +++++ 12 files changed, 300 insertions(+), 50 deletions(-) diff --git a/SKILL.md b/SKILL.md index a545569..71fc7d4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -225,7 +225,8 @@ earlier snapshot belongs to the earlier launch. Relaunch once with the relaunch notice: carry budgets over (reference.md "Transient stops"). Set `repairs_spent` to the repairs in the stopped run's `final.repairs` plus the earlier value. -First recompute `done`, `reviewed`, `deferred`, the `carry` notes and `backfill` as in Resume +First recompute `done`, `reviewed`, `deferred`, the `carry` notes, `carried_minors`, +`inputs_changed` and `backfill` as in Resume steps 2-3, then rerun `scripts/setup --owner ` and take `start_points`; if validation rejects its `setup_skipped`, rerun setup. Launch again. No second table. A second transient stop is a real stop, unless it is a usage-limit stop (reference.md "Usage limit"). @@ -286,12 +287,13 @@ first, show its plan, and add `--yes` only on the user's yes. Keep the manifest 1. Invoked notice. Read the manifest (an `active-run list` marker names it); set `python` by Building step 0. No manifest (earlier work from a hand-run attempt): `/adopt.md` first. -2. ` /scripts/ledger status --plan --manifest /scripts/ledger carry-launch ` for each earlier + launch not yet carried, in launch order (`doctor-.json` gives its `setup_event`). Then + ` /scripts/ledger status --plan --manifest [--spec ]`. Set `done`, `reviewed`, and `deferred` from it (taking a deferred task up: reference.md "Backfill"), and `repairs_spent` from earlier reports' `final.repairs`; - put each `carry` entry into `notes`. First run `ledger carry-launch ` for - each earlier launch (`doctor-.json` in the ledger names them); then copy `minors` into - `carried_minors` and `inputs` into `inputs_changed` as printed (reference.md "Carry-over"). Tasks in `stale` + put each `carry` entry into `notes`; copy `minors` into `carried_minors`, and `inputs` into + `inputs_changed` as `{plan: plan_changed, spec: spec_changed}` (reference.md "Carry-over"). Tasks in `stale` (with their dependents) or `unbound` are left out of `reviewed`, so they are reviewed again: tell the user which and why, and say so when `inputs` reports a changed plan or spec. ` /scripts/coverage ` must exit 0 again diff --git a/reference.md b/reference.md index 93d2f96..b4950ac 100644 --- a/reference.md +++ b/reference.md @@ -63,7 +63,7 @@ PATH, else `C:/Program Files/Git`), never the WSL `bash.exe` in System32. | `profile` | `full` (default) or `lite` (see Profiles). | | `setup_result` | The output of `scripts/setup --owner `: `{feature_head, worktrees, discarded, preserved, setup_skipped}` (`setup_skipped`: the lanes whose setup commands were skipped because every task is done and reviewed; validation rejects a listed lane with pending work, and then setup reruns). Added after the yes to the table; required for a launch (there is no setup agent; a dry run does without it). `preserved` lists the commits under `refs/parallel-lanes//abandoned/` that hold changes setup discarded (see Cleanup). | | `carried_minors` | Optional (1.6.3), set on a resume: `{open, covered}` from `ledger status`'s `minors`, copied as printed: the ids of the task minors earlier launches left open, and the tasks the snapshot covers ("Carry-over"). | -| `inputs_changed` | Optional (1.6.3), set on a resume: `{plan, spec}` from `ledger status --plan --spec`'s `inputs` (each `true`, `false` or `null`), read before setup, so a relaunch's pre-flight knows what changed since the last launch. | +| `inputs_changed` | Optional (1.6.3), set on a resume: `{plan, spec}` from `ledger status --plan --spec`'s `inputs` (`plan` is `plan_changed`, `spec` is `spec_changed`; each `true`, `false` or `null`), read before setup, so a relaunch's pre-flight knows what changed since the last launch. | | `start_points` | The feature heads `ledger status` prints after setup, copied exactly as it prints them: `prelude` from setup, `join` only once a launch got past integration (a new run has no `join` key). | | `dry_run` | `true` only in the confirmation call. | | `done`, `reviewed` | `[]` for a new run; on resume, from `ledger status --plan`. Never by hand. | @@ -337,7 +337,10 @@ carry-launch ` reads that launch's Workflow result once per wf id: each pre-flight ruling (by `preflight`, scope `run`, its `PF-n`), skipping the pre-flight adjudication the adjudicator recorded itself (`preflight.adjudicated`); one `minors` snapshot, the task minors still open after that launch; and a `carried` event that binds any -`PF-n` a ruling of that launch overrode. On a resume, `carry-launch ` finds a +`PF-n` a ruling of that launch overrode. Launches are carried in launch order (each +`doctor-.json` names its `setup_event`; carrying one while an earlier one is not carried +exits 3 naming it). An override takes effect only within the rules and only in a view that +shows the ruling that makes it; otherwise the print marks it `(against the rule)`. On a resume, `carry-launch ` finds a launch whose session died by its wf id under the Claude config dir's `projects/*/*/workflows/.json` (the ledger's `doctor-.json` files name the wf ids). Exit 3 means that launch is not carried (no readable result); say so and go on. `ledger minors diff --git a/run.workflow.js b/run.workflow.js index 459939b..a1788a9 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -1581,7 +1581,7 @@ function carryOverLines(m, task, where, by) { 'An implementer may override only an implementer ruling of its own lane; to depart from any other, report status', 'question naming its id.', 'Before changing a file outside your Files list, run (a read-only command):', - ` ${ledgerReadCommand(m, where.dir, 'owners', ids, '')}`, + ` ${ledgerReadCommand(m, where.dir, 'owners', ids, "''")}`, 'A file owned by a task in another lane running alongside, or by a later task (or a later task\'s planned part of', 'a file you share), needs a pre-flight or adjudicator ruling in force that lists it; without one, report status', 'question instead of changing it. A file of a task earlier in the run is fine when your task needs the change.', @@ -2285,7 +2285,10 @@ function verifySchema() { // bind it, and whether the plan or spec changed since the last launch // (inputs_changed, copied from ledger status). [] on a first launch. function relaunchPreflightLines(m) { - if (!Array.isArray(m.done) || m.done.length === 0) return []; + // A relaunch: tasks are done, or the session carried an earlier launch + // (carried_minors, set on every resume) even when none got done. + const done = Array.isArray(m.done) ? m.done : []; + if (done.length === 0 && !m.carried_minors) return []; const ic = m.inputs_changed; const known = ic && typeof ic.plan === 'boolean' && typeof ic.spec === 'boolean'; let changed = 'Whether the plan or the spec changed since the last launch is not known.'; @@ -2294,7 +2297,7 @@ function relaunchPreflightLines(m) { else if (known && ic.spec) changed = 'The spec changed since the last launch; the plan did not.'; else if (known) changed = 'Neither the plan nor the spec changed since the last launch.'; return [ - `Built tasks (done in an earlier launch; never rule on their code): ${m.done.join(', ')}`, + `Built tasks (done in an earlier launch; never rule on their code): ${done.length > 0 ? done.join(', ') : '(none)'}`, 'Earlier rulings bind; read them with (a read-only command):', ` ${ledgerReadCommand(m, featureDir(m), 'rulings')}`, 'Check only the tasks still to run and their dependence on built work.', @@ -5140,7 +5143,7 @@ async function runAll(manifest, io) { .filter((t) => taskState(m, t.id) === 'skip' && !covered.has(t.id)).map((t) => t.id); if (earlier.length > 0) { final.cannot_verify.push(`the minor findings of the task reviews an earlier launch approved (${earlier.join(', ')})` - + ' did not reach the final lenses or task_minors_open: a resumed run does not carry them; read those' + + ' did not reach the final lenses or task_minors_open: no carried snapshot covers them; read those' + " tasks' approved reviews in the earlier launch's report"); } delivered = final.head; diff --git a/scripts/_owners.py b/scripts/_owners.py index 62fd0a3..41bd20e 100644 --- a/scripts/_owners.py +++ b/scripts/_owners.py @@ -1,8 +1,9 @@ """The owner map: which tasks list each path in their Files list, and where each task runs (1.6.3). -setup writes /owners.json through `ledger write-owners`; the -ledger's owners and undeclared commands and start-task --owners read it. +setup writes /owners.json (`ledger write-owners` writes the same +file from a manifest); the ledger's owners and undeclared commands and +start-task --owners read it. {"repo": {"mode", "root", "git_dir"}, "tasks": {id: {"where": "prelude" | | "join", "pos": n}}, diff --git a/scripts/ledger b/scripts/ledger index 5e71050..296a342 100755 --- a/scripts/ledger +++ b/scripts/ledger @@ -66,7 +66,11 @@ ruling by preflight with scope run and pf PF- (the adjudication at preflight.adjudicated is skipped: the adjudicator recorded it; a result without that field and with conflicts has it last); a minors event; and a carried event binding every ruling whose overrides name a PF-n no earlier -carried event bound. The open minors: the previous snapshot's, less the +carried event bound. Launches are carried in launch order: with the ledger's +doctor-.json files (ledger conditions) naming each launch's setup event, +carrying a launch while an earlier one is not carried exits 3 naming it, and +the adjudication is looked for between the launch's setup run_started and the +next one. The open minors: the previous snapshot's, less the entries of tasks done in this launch and the carried ids this launch's final review raised, plus each task done in this launch's minor_findings less the ids the final review raised (none without one). Prints {"carried", "rulings", @@ -562,7 +566,9 @@ def summarize(events, plan_text=None, plan_given=False, spec_sha=None, plan_sha= if setup_hashes is not None: if plan_sha is not None: inputs["plan_changed"] = setup_hashes[0] != plan_sha - if spec_sha is not None: + if spec_sha is not None or plan_given: + # Without --spec there is no spec now (as for stale reviews): a + # launch that recorded none did not change it. inputs["spec_changed"] = setup_hashes[1] != spec_sha stale, unbound = [], [] @@ -665,9 +671,10 @@ def carried_events(directory): def resolve_overrides(records, carried): - """{ruling id: [(target id, known)]}: a PF- a ruling names resolves - through the carried event that bound that ruling (carry-launch); any - other id must be a ruling of this ledger.""" + """{ruling id: [(target id, state)]}: state is known (a ruling of this + ledger), pending (a PF- no carried event bound yet: this launch's + pre-flight ruling) or unknown. A PF- resolves through the carried event + that bound the ruling naming it (carry-launch).""" ids = {r["id"] for r in records} bound = {} for c in carried: @@ -679,19 +686,38 @@ def resolve_overrides(records, carried): targets = [] for o in r["event"].get("overrides") or []: if PF_ID.fullmatch(o): - mapped = bound.get((r["file"], r["line"]), {}).get(o) - targets.append((mapped or o, mapped is not None and mapped in ids)) + pf = bound.get((r["file"], r["line"])) + if pf is None: + targets.append((o, "pending")) + else: + mapped = pf.get(o) + targets.append((mapped or o, "known" if mapped in ids else "unknown")) else: - targets.append((o, o in ids)) + targets.append((o, "known" if o in ids else "unknown")) out[r["id"]] = targets return out +def may_override(r, target): + """Whether ruling r may override target: an adjudicator any ruling; a task + agent only an implementer's or reviewer's ruling of its own lane file; + pre-flight none.""" + if r["role"] == "adjudicator": + return True + if r["role"] in ("implementer", "reviewer"): + return target["role"] in ("implementer", "reviewer") and target["stem"] == r["stem"] + return False + + +OVERRIDE_NOTES = {"unknown": " (unknown)", "pending": " (this launch's pre-flight)", "against": " (against the rule)"} + + def ruling_line(r, targets): + """targets: [(id, state)], state known, unknown, pending or against.""" e = r["event"] line = f"{r['id']} [{r['role']}, {e['task']}, {r['scope']}] {e['text']}" if targets: - line += " (overrides " + ", ".join(t if known else f"{t} (unknown)" for t, known in targets) + ")" + line += " (overrides " + ", ".join(t + OVERRIDE_NOTES.get(state, "") for t, state in targets) + ")" if e.get("files"): line += " (files " + ", ".join(e["files"]) + ")" return line @@ -701,17 +727,26 @@ def rulings(directory, lane=None): """Print the rulings in force, then the overridden ones. With lane: the rulings of _run, the prelude and that lane, and every run-scope one.""" records = ruling_records(directory) + by_id = {r["id"]: r for r in records} overrides = resolve_overrides(records, carried_events(directory)) - overridden_by = {} - for r in records: - for target, known in overrides[r["id"]]: - if known: - overridden_by.setdefault(target, r["id"]) def shown(r): return lane is None or r["stem"] in ("_run", "prelude", lane) or r["scope"] == "run" - in_force = [ruling_line(r, overrides[r["id"]]) for r in records if shown(r) and r["id"] not in overridden_by] + # An override takes effect only within the rules, and in this view only + # when the ruling that makes it is shown here too. + marked = {} + overridden_by = {} + for r in records: + marked[r["id"]] = [] + for target, state in overrides[r["id"]]: + if state == "known" and not may_override(r, by_id[target]): + state = "against" + marked[r["id"]].append((target, state)) + if state == "known" and shown(r): + overridden_by.setdefault(target, r["id"]) + + in_force = [ruling_line(r, marked[r["id"]]) for r in records if shown(r) and r["id"] not in overridden_by] gone = [f"{r['id']} by {overridden_by[r['id']]}" for r in records if shown(r) and r["id"] in overridden_by] lines = in_force or ["(none)"] if gone: @@ -799,7 +834,7 @@ def overrides_list(directory): resolved = resolve_overrides(records, carried_events(directory)) return [{"id": r["id"], "by": r["role"], "task": r["event"]["task"], "overrides": [t for t, _ in resolved[r["id"]]], - "unknown": [t for t, known in resolved[r["id"]] if not known]} + "unknown": [t for t, state in resolved[r["id"]] if state == "unknown"]} for r in records if resolved[r["id"]]] @@ -858,10 +893,26 @@ def write_owners(directory, manifest_path): def owners(directory, unit_ids, paths): - """Print what each path is, seen from the unit's tasks.""" + """Print what each path is, seen from the unit's tasks. An absolute path + is taken relative to the current directory (the agent's checkout); one + outside it, or a task the map does not know, is a usage error.""" data = load_owners(directory) + unknown = [t for t in unit_ids if t not in data["tasks"]] + if unknown: + fail(2, f"unknown task {unknown[0]!r}: not in the owner map") + here = os.path.abspath(os.getcwd()) for p in paths: - print(f"{p} ({_owners.annotate(data, unit_ids, p)})") + rel = p + if os.path.isabs(p) or (len(p) > 1 and p[1] == ":"): + full = os.path.abspath(p) + try: + inside = os.path.commonpath([full, here]) == here + except ValueError: + inside = False + if not inside: + fail(2, f"give {p!r} relative to the checkout {here!r}") + rel = os.path.relpath(full, here).replace(os.sep, "/") + print(f"{p} ({_owners.annotate(data, unit_ids, rel)})") def undeclared(directory): @@ -916,7 +967,11 @@ def open_minors(previous, result, args): final = result.get("final") if isinstance(result.get("final"), dict) else None done_here = [t for t, v in tasks.items() if isinstance(v, dict) and v.get("status") == "done"] carried_open = ((args or {}).get("carried_minors") or {}).get("open") or [] - still_carried = set((final or {}).get("carried_minors_open") or []) if final is not None else set(carried_open) + # A final review that reports no carried_minors_open raised none of them. + if final is not None and isinstance(final.get("carried_minors_open"), list): + still_carried = set(final["carried_minors_open"]) + else: + still_carried = set(carried_open) raised_carried = set(carried_open) - still_carried task_open = None if final is None else {m.get("id") for m in final.get("task_minors_open") or [] if isinstance(m, dict)} @@ -939,6 +994,39 @@ def open_minors(previous, result, args): return out, covered +def launch_order(directory): + """{wf id: n}: the setup event each launch's doctor-.json names (the + nth setup run_started in _run.jsonl), from ledger conditions.""" + order = {} + try: + names = os.listdir(directory) + except OSError: + return order + for name in names: + if not (name.startswith("doctor-") and name.endswith(".json")): + continue + try: + with open(os.path.join(directory, name), encoding="utf-8") as f: + launch = json.load(f).get("launch") or {} + except (OSError, ValueError, AttributeError): + continue + if is_text(launch.get("wf")) and isinstance(launch.get("setup_event"), int): + order[launch["wf"]] = launch["setup_event"] + return order + + +def setup_window(directory, n): + """(first line, end line) of launch n's events in _run.jsonl: after its + setup run_started, before the next one; None when n is unknown.""" + if not isinstance(n, int): + return None + starts = [number for name, number, e in read_events_at(directory) + if name == "_run.jsonl" and e.get("event") == "run_started" and e.get("phase") == "setup"] + if n < 1 or n > len(starts): + return None + return starts[n - 1], (starts[n] if n < len(starts) else float("inf")) + + def carry_launch(directory, source): """Record what a launch knew that the ledger does not: pre-flight's own rulings (by preflight, PF-n), the open task minors, and which PF-n @@ -959,9 +1047,18 @@ def carry_launch(directory, source): pre = result.get("preflight") if isinstance(result.get("preflight"), dict) else {} texts = [t for t in pre.get("rulings") or [] if isinstance(t, str)] adjudicated = pre.get("adjudicated") + order = launch_order(directory) + carried_wfs = {e.get("wf") for e in events if e.get("event") == "carried"} + if wf in order: + missing = [w for w, n in sorted(order.items(), key=lambda x: x[1]) + if n < order[wf] and w not in carried_wfs] + if missing: + fail(3, f"carry the earlier launch {missing[0]} first (launches are carried in launch order)") already_mapped = {v for c in carried_events(directory) for v in (c.get("pf") or {}).values()} + window = setup_window(directory, order.get(wf)) adj_ids = [r["id"] for r in ruling_records(directory) - if r["stem"] == "_run" and r["role"] == "adjudicator" and r["id"] not in already_mapped] + if r["stem"] == "_run" and r["role"] == "adjudicator" and r["id"] not in already_mapped + and (window is None or window[0] < r["line"] < window[1])] if "adjudicated" not in pre and pre.get("conflicts") and texts and adj_ids: # A 1.6.2 result has no marker: its engine adjudicated pre-flight # conflicts only in autonomous runs and pushed the ruling last. With no @@ -1232,10 +1329,12 @@ def main(argv): undeclared(argv[1]) elif len(argv) >= 4 and argv[0] == "owners" and argv[1] and argv[2] == "--task": rest, unit = argv[2:], [] - while len(rest) >= 2 and rest[0] == "--task" and LANE.fullmatch(rest[1]): + while len(rest) >= 2 and rest[0] == "--task": + if not LANE.fullmatch(rest[1]): + fail(2, f"unsafe task id {rest[1]!r}") unit.append(rest[1]) rest = rest[2:] - if not unit or not rest: + if not unit or not rest or any(a == "--task" for a in rest): fail(2, "usage: ledger owners DIR --task ID [--task ID ...] PATH ...") owners(argv[1], unit, rest) elif len(argv) == 3 and argv[0] == "carry-launch" and argv[1] and argv[2]: @@ -1246,7 +1345,10 @@ def main(argv): " | ledger reviewed DIR LANE TASK ROUNDS PLAN CHECKOUT HEAD BLOCKING [SPEC]" " | ledger status DIR [--plan PLAN] [--spec SPEC] [--manifest MANIFEST] | ledger backfill DIR MANIFEST" " | ledger accept DIR CHECKOUT HEAD TEXT | ledger ended DIR STATUS AGENTS RULINGS" - " | ledger conditions DIR WF_ID DOCTOR_FILE") + " | ledger conditions DIR WF_ID DOCTOR_FILE | ledger rulings DIR [--lane LANE]" + " | ledger carry-launch DIR TRANSCRIPT_DIR|WF_ID | ledger minors DIR" + " | ledger write-owners DIR MANIFEST | ledger owners DIR --task ID [--task ID ...] PATH ..." + " | ledger undeclared DIR") if __name__ == "__main__": diff --git a/scripts/start-task b/scripts/start-task index b592a2d..598afd2 100755 --- a/scripts/start-task +++ b/scripts/start-task @@ -38,11 +38,12 @@ In order: X, earlier in the run)", "(owned by X, another lane, running alongside)", "(owned by X, later)" or "(no task owns it)"; a changed declared path that another task also lists follows as - "(in your Files list; also in X's Files)". An unreadable FILE - exits 3. + "(in your Files list; also in X's Files)". A missing FILE prints + the plain list; an unreadable one exits 3. --rulings prints `ledger rulings LEDGER_DIR --lane LANE` (LANE "-": every ruling) under a "===== rulings in force =====" line, after the - briefs. + briefs; when that fails, one "(could not read the rulings: ...)" + line instead. Relative SCRIPT, PLAN and OUT paths resolve against the caller's directory. On Windows every path argument may be given in any form (C:\\x, /c/x, /tmp/x) and is used in the C:/... form. @@ -54,8 +55,8 @@ stdout, only on success: branch: head: ===== brief : ===== then the brief text, per brief - ===== review package ===== then the script's stdout (--package) ===== rulings in force ===== then the rulings (--rulings) + ===== review package ===== then the script's stdout (--package) ===== files changed outside ... ===== then the paths (--scope) Exit 0 on success; 1 if git rev-parse HEAD fails after the sync, a written @@ -228,10 +229,10 @@ def run(args): res = subprocess.run([sys.executable, os.path.join(HERE, "ledger"), "rulings", ledger_dir, *([] if lane == "-" else ["--lane", lane])], env=ENV, capture_output=True, text=True, encoding="utf-8", errors="replace") - if res.returncode != 0: - sys.stderr.write(res.stderr) - sys.exit(res.returncode) - out.append(f"===== rulings in force =====\n{res.stdout}") + # An unreadable ledger never keeps the agent from starting: it sees why. + text = res.stdout if res.returncode == 0 else \ + f"(could not read the rulings: {(res.stderr.strip().splitlines() or ['ledger failed'])[-1]})\n" + out.append(f"===== rulings in force =====\n{text}") if args.package is not None: script, base, head_ref, package_out = args.package @@ -260,7 +261,8 @@ def run(args): changed = [p for p in res.stdout.splitlines() if p] outside = sorted(p for p in changed if path_key(p) not in declared) lines = outside - if args.owners is not None: + if args.owners is not None and os.path.exists(args.owners): + # A ledger set up before 1.6.3 has no owner map: the plain list. try: owners = _owners.load(args.owners) except (OSError, ValueError) as e: diff --git a/src/phases.js b/src/phases.js index 631176f..64fdea8 100644 --- a/src/phases.js +++ b/src/phases.js @@ -950,7 +950,7 @@ async function runAll(manifest, io) { .filter((t) => taskState(m, t.id) === 'skip' && !covered.has(t.id)).map((t) => t.id); if (earlier.length > 0) { final.cannot_verify.push(`the minor findings of the task reviews an earlier launch approved (${earlier.join(', ')})` - + ' did not reach the final lenses or task_minors_open: a resumed run does not carry them; read those' + + ' did not reach the final lenses or task_minors_open: no carried snapshot covers them; read those' + " tasks' approved reviews in the earlier launch's report"); } delivered = final.head; diff --git a/src/prompts-phase.js b/src/prompts-phase.js index 63c5298..1317143 100644 --- a/src/prompts-phase.js +++ b/src/prompts-phase.js @@ -231,7 +231,10 @@ function verifySchema() { // bind it, and whether the plan or spec changed since the last launch // (inputs_changed, copied from ledger status). [] on a first launch. function relaunchPreflightLines(m) { - if (!Array.isArray(m.done) || m.done.length === 0) return []; + // A relaunch: tasks are done, or the session carried an earlier launch + // (carried_minors, set on every resume) even when none got done. + const done = Array.isArray(m.done) ? m.done : []; + if (done.length === 0 && !m.carried_minors) return []; const ic = m.inputs_changed; const known = ic && typeof ic.plan === 'boolean' && typeof ic.spec === 'boolean'; let changed = 'Whether the plan or the spec changed since the last launch is not known.'; @@ -240,7 +243,7 @@ function relaunchPreflightLines(m) { else if (known && ic.spec) changed = 'The spec changed since the last launch; the plan did not.'; else if (known) changed = 'Neither the plan nor the spec changed since the last launch.'; return [ - `Built tasks (done in an earlier launch; never rule on their code): ${m.done.join(', ')}`, + `Built tasks (done in an earlier launch; never rule on their code): ${done.length > 0 ? done.join(', ') : '(none)'}`, 'Earlier rulings bind; read them with (a read-only command):', ` ${ledgerReadCommand(m, featureDir(m), 'rulings')}`, 'Check only the tasks still to run and their dependence on built work.', diff --git a/src/prompts-task.js b/src/prompts-task.js index 4e34d90..63bf98c 100644 --- a/src/prompts-task.js +++ b/src/prompts-task.js @@ -534,7 +534,7 @@ function carryOverLines(m, task, where, by) { 'An implementer may override only an implementer ruling of its own lane; to depart from any other, report status', 'question naming its id.', 'Before changing a file outside your Files list, run (a read-only command):', - ` ${ledgerReadCommand(m, where.dir, 'owners', ids, '')}`, + ` ${ledgerReadCommand(m, where.dir, 'owners', ids, "''")}`, 'A file owned by a task in another lane running alongside, or by a later task (or a later task\'s planned part of', 'a file you share), needs a pre-flight or adjudicator ruling in force that lists it; without one, report status', 'question instead of changing it. A file of a task earlier in the run is fine when your task needs the change.', diff --git a/tests/carry-prompts.test.mjs b/tests/carry-prompts.test.mjs index 537d16d..8471a4e 100644 --- a/tests/carry-prompts.test.mjs +++ b/tests/carry-prompts.test.mjs @@ -66,7 +66,7 @@ test('reviewers get the owner map with their scope list, and the four-way scope test('implementers check owners before going outside their Files list, record observed failures, and override by id', () => { const p = implementPrompt(manifest(), task('T2'), ALPHA, H('b0')); - assert.ok(p.includes(`${LEDGER} owners '/work/ledger' --task 'T2' `), p); + assert.ok(p.includes(`${LEDGER} owners '/work/ledger' --task 'T2' ''`), p); assert.ok(p.includes('Observed: failed - - '), p); assert.ok(p.includes('"scope": "run"'), p); assert.ok(p.includes('"overrides": ['), p); @@ -110,3 +110,15 @@ test("a relaunch's pre-flight checks only the tasks still to run, bound by the e const unknown = preflightPrompt(manifest({ done: ['T1'], reviewed: ['T1'] })); assert.ok(unknown.includes('Whether the plan or the spec changed since the last launch is not known.'), unknown); }); + +test("a relaunch with carried minors but no task done still gets the earlier rulings in pre-flight", () => { + const m = manifest({ carried_minors: { open: [], covered: [] } }); + const p = preflightPrompt(m); + assert.ok(p.includes(RULINGS_CMD), p); + assert.ok(p.includes('Built tasks (done in an earlier launch; never rule on their code): (none)'), p); +}); + +test('the owners command in the implement prompt asks for a quoted path relative to the worktree', () => { + const p = implementPrompt(manifest(), task('T2'), ALPHA, H('b0')); + assert.ok(p.includes("--task 'T2' ''"), p); +}); diff --git a/tests/ledger-carry.test.mjs b/tests/ledger-carry.test.mjs index 0da5ead..a370b89 100644 --- a/tests/ledger-carry.test.mjs +++ b/tests/ledger-carry.test.mjs @@ -292,3 +292,105 @@ test('owners without an owner map exits 3', () => { const dir = freshDir(); assert.equal(ledger('owners', dir, '--task', 'T2', 'a.txt').code, 3); }); + +// ---- Review fixes ---- + +test('an override takes effect only in views that show the overriding ruling, and only within the rules', () => { + const dir = freshDir(); + appendOk(dir, 'beta', { task: 'T4', event: 'ruling', by: 'implementer', text: 'BETA-KEEPS' }); + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'adjudicator', text: 'RUN-WIDE' }); + appendOk(dir, 'alpha', { task: 'T2', event: 'ruling', by: 'implementer', text: 'MINE', overrides: ['beta-R1', '_run-R1'] }); + // beta's view does not show alpha-R1: both targets stay in force there. + const beta = rulingsOut(dir, '--lane', 'beta'); + assert.ok(beta.includes('beta-R1 [implementer, T4, lane] BETA-KEEPS') && beta.includes('] RUN-WIDE'), beta); + // Even where alpha-R1 shows, an implementer cannot override another lane's or an adjudicator's ruling. + const all = rulingsOut(dir); + assert.ok(all.includes('] RUN-WIDE') && all.includes('] BETA-KEEPS'), all); + assert.ok(all.includes('(overrides beta-R1 (against the rule), _run-R1 (against the rule))'), all); + // Within the rules it takes effect. + appendOk(dir, 'alpha', { task: 'T3', event: 'ruling', by: 'implementer', text: 'NEWER', overrides: ['alpha-R1'] }); + assert.ok(rulingsOut(dir, '--lane', 'alpha').split('Overridden:')[1].includes('alpha-R1 by alpha-R2')); +}); + +test("a PF-n not yet bound is this launch's pre-flight ruling, not unknown", () => { + const dir = freshDir(); + appendOk(dir, 'alpha', { task: 'T2', event: 'ruling', by: 'adjudicator', text: 'X', overrides: ['PF-2'] }); + const out = rulingsOut(dir); + assert.ok(out.includes("(overrides PF-2 (this launch's pre-flight))"), out); + assert.deepEqual(statusOf(dir).overrides[0].unknown, []); +}); + +// A ledger with two launches: setup run_started events and doctor files that +// name each launch's setup_event, as the session's ledger conditions writes. +function twoLaunchLedger(dir) { + const started = (head) => ({ task: '_run', event: 'run_started', phase: 'setup', head, plan_sha256: null, spec_sha256: null }); + appendOk(dir, '_run', started('h1')); + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'adjudicator', text: 'ADJ-ONE' }); + appendOk(dir, '_run', started('h2')); + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'adjudicator', text: 'ADJ-TWO' }); + writeFileSync(join(dir, 'doctor-wf_one111-001.json'), JSON.stringify({ launch: { wf: 'wf_one111-001', setup_event: 1 } })); + writeFileSync(join(dir, 'doctor-wf_two222-002.json'), JSON.stringify({ launch: { wf: 'wf_two222-002', setup_event: 2 } })); +} + +test("carry-launch maps a launch's adjudication to the adjudicator ruling of that launch's window", () => { + const dir = freshDir(); + const config = freshDir('config'); + twoLaunchLedger(dir); + const conflicts = { rulings: ['P', 'ADJ'], adjudicated: 1 }; + assert.equal(carry(dir, launchFile(config, 'wf_one111-001', conflicts)).code, 0); + assert.equal(carry(dir, launchFile(config, 'wf_two222-002', conflicts)).code, 0); + const carried = readFileSync(join(dir, '_run.jsonl'), 'utf8').trim().split('\n').map((l) => JSON.parse(l)) + .filter((e) => e.event === 'carried'); + assert.deepEqual(carried.map((c) => c.pf['PF-2']), ['_run-R1', '_run-R2']); +}); + +test('carry-launch refuses a launch while an earlier launch is not carried', () => { + const dir = freshDir(); + const config = freshDir('config'); + twoLaunchLedger(dir); + launchFile(config, 'wf_one111-001'); + const later = carry(dir, launchFile(config, 'wf_two222-002')); + assert.equal(later.code, 3, later.stdout); + assert.match(later.stderr, /wf_one111-001/); + assert.equal(carry(dir, 'wf_one111-001', { CLAUDE_CONFIG_DIR: config }).code, 0); + assert.equal(carry(dir, 'wf_two222-002', { CLAUDE_CONFIG_DIR: config }).code, 0); +}); + +test('status says the spec did not change when no launch recorded one and none is given', () => { + const dir = freshDir(); + const plan = join(dir, '..', 'plan.md'); + writeFileSync(plan, '# Plan\n'); + appendOk(dir, '_run', { task: '_run', event: 'run_started', phase: 'setup', head: 'h1', plan_sha256: null, spec_sha256: null }); + assert.equal(statusOf2(dir, '--plan', plan).inputs.spec_changed, false); +}); +function statusOf2(dir, ...extra) { + const res = ledger('status', dir, ...extra); + assert.equal(res.code, 0, res.stderr); + return JSON.parse(res.stdout); +} + +test('owners takes an absolute path inside the checkout as relative, refuses one outside, and checks task ids', () => { + const root = freshDir('proj'); + const dir = join(root, 'ledger'); + assert.equal(ledger('write-owners', dir, ownersManifest(root)).code, 0); + const inside = spawnSync('python3', [join(SCRIPTS, 'ledger'), 'owners', dir, '--task', 'T2', join(root, 'src', 'y.js')], + { encoding: 'utf8', cwd: root }); + assert.equal(inside.status, 0, inside.stderr); + assert.ok(inside.stdout.includes('(owned by T4, another lane, running alongside)'), inside.stdout); + const outside = spawnSync('python3', [join(SCRIPTS, 'ledger'), 'owners', dir, '--task', 'T2', join(TMP, 'elsewhere.js')], + { encoding: 'utf8', cwd: root }); + assert.equal(outside.status, 2, outside.stdout); + assert.equal(ledger('owners', dir, '--task', 'NOPE', 'a.txt').code, 2); + assert.equal(ledger('owners', dir, '--task', 'T2', '--task', 'bad/id', 'a.txt').code, 2); +}); + +test('a final review result without carried_minors_open raised none of the carried minors', () => { + const dir = freshDir(); + const config = freshDir('config'); + carry(dir, launchFile(config, 'wf_m1aaaa-001', { tasks: { T1: done([minor('T1-1', 'T1')]) } })); + carry(dir, launchFile(config, 'wf_m2aaaa-002', { + tasks: {}, final: { findings: [], task_minors_open: [] }, + args: { carried_minors: { open: ['T1-1'], covered: ['T1'] } }, + })); + assert.deepEqual(statusOf(dir).minors.open, ['T1-1']); +}); diff --git a/tests/task-helpers.test.mjs b/tests/task-helpers.test.mjs index f599497..07cabce 100644 --- a/tests/task-helpers.test.mjs +++ b/tests/task-helpers.test.mjs @@ -383,6 +383,26 @@ test('start-task --owners names the owner of each changed path, and shared decla + "shared.css (in your Files list; also in T5's Files)\n"), res.stdout); }); +test('start-task still starts without an owner map, or when the rulings print fails', () => { + const c = newCase(); + const base = git(c.lane, 'rev-parse', 'HEAD'); + write(join(c.lane, 'src', 'b.js'), 'b\n'); + git(c.lane, 'add', '-A'); + git(c.lane, 'commit', '-q', '-m', 'work'); + const head = git(c.lane, 'rev-parse', 'HEAD'); + const out = join(c.root, 'briefs', 'T1.md'); + const res = startTask(c.lane, c.plan, '--scope', base, head, '--declared', 'src/a.js', + '--owners', join(c.root, 'no-such-owners.json'), '--brief', 'T1', out); + assert.equal(res.code, 0, res.stderr); + assert.ok(res.stdout.endsWith("===== files changed outside the task's Files list =====\nsrc/b.js\n"), res.stdout); + // A torn ledger line makes ledger rulings fail: the agent still starts and sees why. + const ledgerDir = join(c.root, 'torn ledger'); + write(join(ledgerDir, 'alpha.jsonl'), '{"task":\n'); + const torn = startTask(c.lane, c.plan, '--rulings', ledgerDir, 'alpha', '--brief', 'T1', out); + assert.equal(torn.code, 0, torn.stderr); + assert.match(torn.stdout, /===== rulings in force =====\n\(could not read the rulings: [^\n]+\)\n/); +}); + test('start-task --rulings prints the rulings in force for the lane', () => { const c = newCase(); const ledgerDir = join(c.root, 'ledger'); From c1f2fe8cb564c0e632b09105defa281516d2caa3 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:59:04 -0700 Subject: [PATCH 09/10] fix: resolve symlinks when ledger owners checks an absolute path --- scripts/ledger | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/ledger b/scripts/ledger index 296a342..c6f23b0 100755 --- a/scripts/ledger +++ b/scripts/ledger @@ -900,11 +900,13 @@ def owners(directory, unit_ids, paths): unknown = [t for t in unit_ids if t not in data["tasks"]] if unknown: fail(2, f"unknown task {unknown[0]!r}: not in the owner map") - here = os.path.abspath(os.getcwd()) + # Resolved on both sides: a symlinked temp or home dir (macOS /var is + # /private/var) must not put a path inside the checkout outside it. + here = os.path.realpath(os.getcwd()) for p in paths: rel = p if os.path.isabs(p) or (len(p) > 1 and p[1] == ":"): - full = os.path.abspath(p) + full = os.path.realpath(p) try: inside = os.path.commonpath([full, here]) == here except ValueError: From d5ef3969318e4e9dc64da0697dc2dd1660dae676 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:59:05 -0700 Subject: [PATCH 10/10] chore: release 1.6.3 --- VERSION | 2 +- run.workflow.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index fdd3be6..266146b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.2 +1.6.3 diff --git a/run.workflow.js b/run.workflow.js index a1788a9..89dce8e 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -1,6 +1,6 @@ export const meta = { name: 'parallel-lanes', - description: 'parallel-lanes v1.6.2: Execute an implementation plan as parallel lanes of tasks with per-task review, then integrate.', + description: 'parallel-lanes v1.6.3: Execute an implementation plan as parallel lanes of tasks with per-task review, then integrate.', phases: [ { title: 'Pre-flight', detail: 'plan and spec conflicts' }, { title: 'Prelude', detail: 'shared tasks on the feature branch' }, @@ -5226,7 +5226,7 @@ function noHead(r, label) { // The skill version this script was built from (scripts/build stamps VERSION // here and in the meta description): the version actually running. Logged // first and returned with every result, dry runs included. -const VERSION = '1.6.2'; +const VERSION = '1.6.3'; log(`parallel-lanes v${VERSION} loaded`); if (args !== null && typeof args === 'object' && args.dry_run === true) {