diff --git a/README.md b/README.md index bcadeeb..e502644 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ npx spec-superflow list # 或通过 npx 使用 | `ssf execution show [--json]` | 查看并校验当前执行计划、wave 与 receipt | | `ssf execution revise ...` | 将已有计划保留/升级为 SDD,并生成新 revision;不允许降级 | | `ssf execution review ...` | 为一个计划 wave 记录 review receipt | +| `ssf execution adjudicate ...` | 为 `adjudication-required` wave 授权一次 review | | `ssf install-cursor` | 部署到 Cursor `.cursor/` 目录 | | `ssf install-workbuddy` | 部署到 WorkBuddy marketplace 插件(含 skills/rules/runtime) | | `ssf install-codebuddy` | 部署到 `~/.codebuddy/`(CodeBuddy Code CLI) | @@ -245,6 +246,8 @@ ssf execution revise changes/my-change --mode sdd --confirm --reason "need paral # 每个 wave 都先写入非空 review report,再记录 receipt。 ssf execution review changes/my-change --wave foundation --base --head \ --report .superpowers/sdd/reviews/foundation.md --verdict pass +ssf execution adjudicate changes/my-change --wave foundation --decision allow-review \ + --confirm --reason "reviewed the unresolved findings and authorizes one focused review" ``` `--report` 相对于 `` 解析,且必须位于 @@ -256,6 +259,8 @@ report 本身必须为普通、非空、非符号链接文件。 每个 wave 的 review receipt 必须是当前 revision 的 `pass`,依赖 wave 和 closing 才会放行;修订计划会使旧 receipt 失效。恢复、切换和手动保存是 control-plane overlay,不会增加第九个状态;其 CLI 与 CodeBuddy/WorkBuddy Markdown adapter 保持相同 guard。 +裁决不会生成 `pass` 或放行依赖;授权 review 若仍失败,wave 会再次进入 +`adjudication-required` 并需要新的人工裁决。 --- diff --git a/docs/README_en.md b/docs/README_en.md index 42a6dd7..1c08c6c 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -300,6 +300,8 @@ ssf execution revise changes/my-change --mode sdd --confirm --reason "need paral --wave integration:serial:2.1:foundation ssf execution review changes/my-change --wave foundation --base --head \ --report .superpowers/sdd/reviews/foundation.md --verdict pass +ssf execution adjudicate changes/my-change --wave foundation --decision allow-review \ + --confirm --reason "reviewed the unresolved findings and authorizes one focused review" ``` The `--report` path is resolved relative to `` and must remain under @@ -311,6 +313,8 @@ non-symlink file. Every planned wave needs a current `pass` review receipt before dependent waves or closing may proceed; revising a plan invalidates earlier receipts. +Adjudication never creates a pass or releases dependents. A failed authorized +review returns the wave to `adjudication-required` and needs a new human decision. Recovery, switching, and manual save form a control-plane overlay, not a ninth workflow state; their CLI and CodeBuddy/WorkBuddy Markdown adapters keep the same guards. diff --git a/scripts/lib/cmd-execution.mjs b/scripts/lib/cmd-execution.mjs index c834584..47090b6 100644 --- a/scripts/lib/cmd-execution.mjs +++ b/scripts/lib/cmd-execution.mjs @@ -1,5 +1,5 @@ import { parseArgs } from 'node:util'; -import { createPlan, describeWaves, EXECUTION_MODES, readPlan, recordReview, validatePlan, writePlan } from './execution-plan.mjs'; +import { adjudicateWave, createPlan, describeWaves, EXECUTION_MODES, readPlan, recordReview, validatePlan, writePlan } from './execution-plan.mjs'; import { createRecommendationReceipt, readCurrentRecommendationReceipt, @@ -7,7 +7,7 @@ import { } from './execution-recommendation.mjs'; import { readState, writeState } from './state-loader.mjs'; -const SUBCOMMANDS = ['recommend', 'plan', 'show', 'revise', 'review']; +const SUBCOMMANDS = ['recommend', 'plan', 'show', 'revise', 'review', 'adjudicate']; export function run(args, io = { stdout: process.stdout, stderr: process.stderr }) { const { positionals, values } = parseArgs({ @@ -22,6 +22,7 @@ export function run(args, io = { stdout: process.stdout, stderr: process.stderr head: { type: 'string' }, report: { type: 'string' }, verdict: { type: 'string' }, + decision: { type: 'string' }, json: { type: 'boolean', default: false }, help: { type: 'boolean', default: false }, }, @@ -52,9 +53,28 @@ export function run(args, io = { stdout: process.stdout, stderr: process.stderr case 'review': recordAndPrintReview(changeDir, values, io); return { exitCode: 0 }; + case 'adjudicate': + adjudicateAndPrint(changeDir, values, io); + return { exitCode: 0 }; } } +function adjudicateAndPrint(changeDir, values, io) { + requireOption(values.wave?.[0], '--wave'); + if (values.wave.length !== 1) throw new Error('Adjudication requires exactly one --wave value'); + if (values.decision !== 'allow-review') throw new Error("--decision must be 'allow-review'"); + requireOption(values.reason, '--reason'); + requireSafeReason(values.reason); + if (!values.confirm) throw new Error('Adjudication requires --confirm after human review of the failure chain'); + const adjudication = adjudicateWave(changeDir, values.wave[0], { + decision: values.decision, + reason: values.reason, + confirmed: true, + }); + print(values.json, { ok: true, wave: values.wave[0], adjudication }, + `Review authorization for ${values.wave[0]} recorded: ${adjudication.id}.`, io); +} + function createAndPrintPlan(changeDir, values, revise, io) { requireMode(values.mode); requireOption(values.reason, '--reason'); @@ -206,5 +226,6 @@ function printHelp(io) { ssf execution plan --mode --confirm --reason --wave ::[:] [--acknowledge-recommendation] ssf execution show [--json] ssf execution revise --mode sdd --confirm --reason --wave ::[:] [--acknowledge-recommendation] - ssf execution review --wave --base --head --report --verdict pass|fail\n`); + ssf execution review --wave --base --head --report --verdict pass|fail + ssf execution adjudicate --wave --decision allow-review --confirm --reason \n`); } diff --git a/scripts/lib/execution-plan.mjs b/scripts/lib/execution-plan.mjs index b42c0d5..011903a 100644 --- a/scripts/lib/execution-plan.mjs +++ b/scripts/lib/execution-plan.mjs @@ -140,13 +140,22 @@ export function recordReview(changeDir, waveId, receipt) { } const previousReceipt = currentReview.receipt; const previousRepair = readRepairState(changeDir, plan, waveId); + let authorization = null; if (previousRepair?.status === 'adjudication-required') { - throw new Error(`Wave '${waveId}' requires adjudication before another review can be recorded`); + authorization = readActiveAdjudication(changeDir, plan, waveId, previousRepair, previousReceipt); + if (!authorization) { + throw new Error(`Wave '${waveId}' requires adjudication before another review can be recorded`); + } } if (previousReceipt?.status === 'pass') { throw new Error(`Wave '${waveId}' already has a passing review receipt`); } - validateRepairContinuity(previousReceipt, previousRepair, { status: receipt.status, base, head, report: reportEvidence.path }); + validateRepairContinuity( + previousReceipt, + previousRepair, + { status: receipt.status, base, head, report: reportEvidence.path }, + { allowRepeatedRange: authorization === null }, + ); const savedReceipt = { status: receipt.status, @@ -165,6 +174,7 @@ export function recordReview(changeDir, waveId, receipt) { atomicWrite(join(paths.reviews, `${safeFileName(waveId)}.json`), serializedReceipt); atomicWrite(join(planPaths.reviews, `${safeFileName(waveId)}.json`), serializedReceipt); updateRepairState(changeDir, plan, waveId, previousRepair, previousReceipt, savedReceipt); + if (authorization) consumeAdjudication(changeDir, plan, waveId, authorization.id, savedReceipt); if (savedReceipt.status === 'pass') { // Task briefs, diff packages, and progress notes are regenerable for this // exact plan. Receipt and repair evidence deliberately live beside, not in, @@ -174,6 +184,59 @@ export function recordReview(changeDir, waveId, receipt) { return savedReceipt; } +/** + * Persists an explicit human decision that authorizes exactly one additional + * review for the current adjudication-required repair chain. + */ +export function adjudicateWave(changeDir, waveId, input) { + const plan = readPlan(changeDir); + const validation = validatePlan(changeDir, plan); + if (!validation.valid) throw new Error(`Cannot adjudicate an invalid execution plan: ${validation.failures.join('; ')}`); + const wave = Array.isArray(plan?.waves) && plan.waves.find(candidate => candidate?.id === waveId); + if (!wave) throw new Error(`Adjudication references unknown wave '${waveId}'`); + if (input?.decision !== 'allow-review') throw new Error("Adjudication decision must be 'allow-review'"); + if (input?.confirmed !== true) throw new Error('Adjudication requires confirmed human review of the failure chain'); + requireText(input?.reason, 'adjudication.reason'); + if (/[\p{Cc}\p{Zl}\p{Zp}]/u.test(input.reason)) { + throw new Error('Adjudication reason must not contain control characters or line separators'); + } + + const currentReview = readCurrentReviewEvidence(changeDir, waveId, plan); + if (currentReview.blocker) { + throw new Error(`Wave '${waveId}' cannot be adjudicated while its failed report evidence is invalid: ${currentReview.blocker}`); + } + const receipt = currentReview.receipt; + const repair = readRepairState(changeDir, plan, waveId); + if (receipt?.status !== 'fail' || repair?.status !== 'adjudication-required') { + throw new Error(`Wave '${waveId}' is not adjudication-required`); + } + if (readActiveAdjudication(changeDir, plan, waveId, repair, receipt)) { + throw new Error(`Wave '${waveId}' already has an active review authorization`); + } + + const ledger = readAdjudicationLedger(changeDir, plan, waveId) ?? { + plan_hash: plan.hash, + plan_revision: plan.revision, + wave_id: waveId, + adjudications: [], + }; + const authorization = { + id: randomUUID(), + status: 'authorized', + decision: input.decision, + confirmed: true, + reason: input.reason.trim(), + failure_count: repair.failure_count, + previous_head: repair.previous_head, + previous_report: repair.previous_report, + failed_receipt: adjudicationReceiptEvidence(receipt), + authorized_at: new Date().toISOString(), + }; + ledger.adjudications.push(authorization); + writeAdjudicationLedger(changeDir, plan, waveId, ledger); + return { ...authorization, active: true }; +} + /** * Returns the current plan's receipt for one wave. Receipts from a previous * revision/hash are never evidence for the current plan. @@ -236,7 +299,9 @@ export function describeWaves(changeDir, plan = readPlan(changeDir)) { ...(review.blocker ? [review.blocker] : []), ]; const repair = describeRepairState(changeDir, plan, wave.id, receipt); - const retryable = receipt?.status === 'fail' && repair.status !== 'adjudication-required'; + const adjudication = describeAdjudication(changeDir, plan, wave.id, repair, receipt); + const retryable = receipt?.status === 'fail' + && (repair.status !== 'adjudication-required' || adjudication?.active === true); return { id: wave.id, strategy: wave.strategy, @@ -247,19 +312,21 @@ export function describeWaves(changeDir, plan = readPlan(changeDir)) { receipt, blockers, repair, + ...(adjudication ? { adjudication } : {}), }; }); } -function validateRepairContinuity(previousReceipt, previousRepair, nextReceipt) { +function validateRepairContinuity(previousReceipt, previousRepair, nextReceipt, { allowRepeatedRange = true } = {}) { if (previousReceipt?.status !== 'fail') return; const previousHead = previousRepair?.previous_head ?? previousReceipt.head; if (!previousHead) throw new Error('Repair state is missing the previous review head'); // A failed re-review must examine a repair that starts at the prior review - // head. A pass may also certify the exact original range: this preserves the - // established fail→pass receipt flow for a corrected review finding. - const repeatsPreviousRange = nextReceipt.status === 'pass' + // head. Outside adjudication, a pass may also certify the exact original + // range to preserve the established fail→pass receipt flow. A human + // authorization disables that compatibility exception. + const repeatsPreviousRange = allowRepeatedRange && nextReceipt.status === 'pass' && nextReceipt.base === previousReceipt.base && nextReceipt.head === previousReceipt.head; if (nextReceipt.base !== previousHead && !repeatsPreviousRange) { @@ -320,6 +387,13 @@ function reviewEvidence(receipt) { }; } +function adjudicationReceiptEvidence(receipt) { + return { + ...reviewEvidence(receipt), + report_sha256: receipt.report_sha256, + }; +} + function readRepairState(changeDir, plan, waveId) { if (!plan) return null; const statePath = join(getPlanScopedPaths(changeDir, plan).repairState, `${safeFileName(waveId)}.json`); @@ -354,6 +428,63 @@ function describeRepairState(changeDir, plan, waveId, receipt) { }; } +function adjudicationPath(changeDir, plan, waveId) { + return join(getPlanScopedPaths(changeDir, plan).adjudications, `${safeFileName(waveId)}.json`); +} + +function readAdjudicationLedger(changeDir, plan, waveId) { + if (!plan) return null; + const filePath = adjudicationPath(changeDir, plan, waveId); + if (!existsSync(filePath)) return null; + try { + const ledger = JSON.parse(readFileSync(filePath, 'utf8')); + if (ledger?.plan_hash !== plan.hash || ledger?.plan_revision !== plan.revision + || ledger?.wave_id !== waveId || !Array.isArray(ledger.adjudications)) { + throw new Error('adjudication ledger identity or entries are invalid'); + } + return ledger; + } catch (error) { + throw new Error(`Unable to read adjudication evidence: ${error.message}`); + } +} + +function writeAdjudicationLedger(changeDir, plan, waveId, ledger) { + const directory = getPlanScopedPaths(changeDir, plan).adjudications; + mkdirSync(directory, { recursive: true }); + atomicWrite(adjudicationPath(changeDir, plan, waveId), `${JSON.stringify(ledger, null, 2)}\n`); +} + +function readActiveAdjudication(changeDir, plan, waveId, repair, receipt) { + const latest = readAdjudicationLedger(changeDir, plan, waveId)?.adjudications.at(-1); + if (!latest || latest.status !== 'authorized' || latest.decision !== 'allow-review' || latest.confirmed !== true) return null; + if (repair?.status !== 'adjudication-required' || receipt?.status !== 'fail') return null; + if (latest.failure_count !== repair.failure_count + || latest.previous_head !== repair.previous_head + || latest.previous_report !== repair.previous_report + || latest.failed_receipt?.base !== receipt.base + || latest.failed_receipt?.head !== receipt.head + || latest.failed_receipt?.report !== receipt.report + || latest.failed_receipt?.report_sha256 !== receipt.report_sha256 + || latest.failed_receipt?.recorded_at !== receipt.recorded_at) return null; + return latest; +} + +function describeAdjudication(changeDir, plan, waveId, repair, receipt) { + const latest = readAdjudicationLedger(changeDir, plan, waveId)?.adjudications.at(-1); + if (!latest) return null; + return { ...latest, active: readActiveAdjudication(changeDir, plan, waveId, repair, receipt)?.id === latest.id }; +} + +function consumeAdjudication(changeDir, plan, waveId, authorizationId, receipt) { + const ledger = readAdjudicationLedger(changeDir, plan, waveId); + const authorization = ledger?.adjudications.find(candidate => candidate.id === authorizationId); + if (!authorization || authorization.status !== 'authorized') return; + authorization.status = 'consumed'; + authorization.consumed_at = new Date().toISOString(); + authorization.review = reviewEvidence(receipt); + writeAdjudicationLedger(changeDir, plan, waveId, ledger); +} + function validateReviewReportEvidence(changeDir, report) { requireText(report, 'receipt.report'); if (/[\p{Cc}\p{Zl}\p{Zp}]/u.test(report)) { diff --git a/scripts/lib/sdd-overlay.mjs b/scripts/lib/sdd-overlay.mjs index 120466b..542314c 100644 --- a/scripts/lib/sdd-overlay.mjs +++ b/scripts/lib/sdd-overlay.mjs @@ -45,6 +45,7 @@ export function getPlanScopedPaths(changeDir, plan) { handoffs: join(planRoot, 'handoffs'), reviews: join(planRoot, 'reviews'), repairState: join(planRoot, 'repair-state'), + adjudications: join(planRoot, 'adjudications'), }; } diff --git a/scripts/spec-superflow.mjs b/scripts/spec-superflow.mjs index b1c5632..0907781 100755 --- a/scripts/spec-superflow.mjs +++ b/scripts/spec-superflow.mjs @@ -90,6 +90,8 @@ Commands: Upgrade inline/batch to SDD, or replan existing SDD waves, as a new revision execution review --wave --base --head --report --verdict pass|fail Record one review receipt for a planned wave + execution adjudicate --wave --decision allow-review --confirm --reason + Authorize one review for an adjudication-required wave resume [change-dir] [--json] Recover the only active change or an explicit change context switch [--json] diff --git a/skills/build-executor/SKILL.md b/skills/build-executor/SKILL.md index fa65f0f..0aaf35c 100644 --- a/skills/build-executor/SKILL.md +++ b/skills/build-executor/SKILL.md @@ -153,6 +153,10 @@ history, and must not write, edit, or modify a repair-state file directly. - **Third unresolved failure — stop:** the third unresolved receipt yields CLI status `adjudication-required`. Stop automatic dispatch and request a human adjudication rather than attempting a fourth repair. +- After human review, record the decision with `ssf execution adjudicate + --wave --decision allow-review --confirm --reason `. + It authorizes one continuous review only, never a pass; a failed authorized + review returns to `adjudication-required`. - Every focused re-review still writes its separate persisted report and is recorded only through `ssf execution review --wave --base --head --report .superpowers/sdd/reviews/-rereview.md --verdict `. diff --git a/skills/code-reviewer/SKILL.md b/skills/code-reviewer/SKILL.md index 7eb8655..1d10379 100644 --- a/skills/code-reviewer/SKILL.md +++ b/skills/code-reviewer/SKILL.md @@ -18,6 +18,7 @@ Two responsibilities: requesting review (dispatching a reviewer subagent) and re 3. Fill placeholders: `[DESCRIPTION]` (what was built), `[PLAN_OR_REQUIREMENTS]` (contract/spec reference), `[BASE_SHA]`, `[HEAD_SHA]`, `[WAVE_ID]`, and a distinct `[REVIEW_REPORT_FILE]`. 4. Require the reviewer to write a non-empty persisted review report at `.superpowers/sdd/reviews/.md`, then record that exact in-overlay path in the wave receipt with `ssf execution review --wave --base --head --report .superpowers/sdd/reviews/.md --verdict `. The execution plan initializes this directory; paths outside it are rejected for audit safety. 5. Act on feedback: Critical/Important findings require a `fail` receipt, focused repair, re-review, and replacement `pass` receipt before a dependent wave or closing can proceed. Note Minor for later, push back with reasoning if reviewer is wrong. +6. At `adjudication-required`, wait for a human to run `ssf execution adjudicate --wave --decision allow-review --confirm --reason ` before another review. It authorizes one review and never substitutes for `pass`. ### Minimality And Scope diff --git a/tests/lib/cmd-execution.test.mjs b/tests/lib/cmd-execution.test.mjs index 6817bbe..3683c5e 100644 --- a/tests/lib/cmd-execution.test.mjs +++ b/tests/lib/cmd-execution.test.mjs @@ -674,6 +674,49 @@ describe('ssf execution', () => { assert.deepEqual(shown.json.waves[1].blockers, ['wave-1']); }); + it('records a confirmed adjudication and exposes one active review authorization', () => { + const planned = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', + '--reason', 'adjudication recovery remains auditable', '--wave', 'wave-1:serial:1.1']); + assert.equal(planned.exitCode, 0, planned.stderr); + let base = gitRefs.base; + let head = gitRefs.head; + for (let failure = 1; failure <= 3; failure += 1) { + const failed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', base, '--head', head, '--report', writeReviewReport(`cli-adjudication-${failure}.md`), + '--verdict', 'fail']); + assert.equal(failed.exitCode, 0, failed.stderr); + base = head; + head = createRepairCommit(`cli-adjudication-${failure}`); + } + + for (const args of [ + ['--decision', 'allow-review', '--reason', 'Human reviewed the failure chain.'], + ['--decision', 'allow-review', '--confirm'], + ['--decision', 'pass', '--confirm', '--reason', 'Invalid decision must not pass.'], + ]) { + const rejected = runSsf(['execution', 'adjudicate', changeDir, '--wave', 'wave-1', ...args]); + assert.notEqual(rejected.exitCode, 0); + } + + const result = runSsf(['execution', 'adjudicate', changeDir, '--wave', 'wave-1', + '--decision', 'allow-review', '--confirm', '--reason', 'Human reviewed all failures and authorizes one focused review.', '--json']); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.json.adjudication.status, 'authorized'); + assert.equal(result.json.adjudication.confirmed, true); + assert.equal(result.json.adjudication.failure_count, 3); + + const shown = runSsf(['execution', 'show', changeDir, '--json']); + assert.equal(shown.json.waves[0].adjudication.active, true); + assert.equal(shown.json.waves[0].adjudication.confirmed, true); + assert.equal(shown.json.waves[0].retryable, true); + assert.equal(shown.json.waves[0].eligible, true); + + const replay = runSsf(['execution', 'adjudicate', changeDir, '--wave', 'wave-1', + '--decision', 'allow-review', '--confirm', '--reason', 'Replay must be rejected.']); + assert.notEqual(replay.exitCode, 0); + assert.match(replay.stderr, /active.*authorization/i); + }); + it('keeps the Task 1 state revision aligned through plan, show, revise, and show', () => { writeChangeDirectory(changeDir, 'full', 2); const initial = runSsf(['execution', 'plan', changeDir, '--mode', 'batch-inline', '--confirm', '--acknowledge-recommendation', diff --git a/tests/lib/execution-plan.test.mjs b/tests/lib/execution-plan.test.mjs index 519da51..7a035b3 100644 --- a/tests/lib/execution-plan.test.mjs +++ b/tests/lib/execution-plan.test.mjs @@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { - createGitRangeValidator, createPlan as createRawPlan, describeWaves, readPlan, recordReview, validatePlan, writePlan, + adjudicateWave, createGitRangeValidator, createPlan as createRawPlan, describeWaves, readPlan, recordReview, validatePlan, writePlan, } from '../../scripts/lib/execution-plan.mjs'; import { createRecommendationReceipt, recommendExecutionModes } from '../../scripts/lib/execution-recommendation.mjs'; import { readState } from '../../scripts/lib/state-loader.mjs'; @@ -613,6 +613,135 @@ describe('execution plan data contract', () => { assert.deepEqual(dependent.blockers, ['wave-1']); }); + it('authorizes exactly one current continuous review without releasing dependents', () => { + const plan = createPlan(changeDir, { + mode: 'sdd', source: 'default', rationale: 'human adjudication permits one review only', + waves: [ + { id: 'wave-1', strategy: 'serial', tasks: ['1.1'], depends_on: [] }, + { id: 'wave-2', strategy: 'serial', tasks: ['1.2'], depends_on: ['wave-1'] }, + ], + }); + writePlan(changeDir, plan); + + let base = gitRefs.base; + let head = gitRefs.head; + let failedBase; + let failedHead; + for (let failure = 1; failure <= 3; failure += 1) { + failedBase = base; + failedHead = head; + recordReview(changeDir, 'wave-1', { + status: 'fail', base, head, report: writeReviewReport(`authorize-${failure}.md`), + }); + base = head; + head = createRepairCommit(`authorize-${failure}`); + } + + assert.throws(() => adjudicateWave(changeDir, 'wave-1', { + decision: 'allow-review', reason: 'Unconfirmed direct API calls must be rejected.', + }), /confirmed human review/i); + const authorization = adjudicateWave(changeDir, 'wave-1', { + decision: 'allow-review', confirmed: true, + reason: 'Human confirmed one focused repair after reviewing all findings.', + }); + assert.equal(authorization.status, 'authorized'); + assert.equal(authorization.confirmed, true); + assert.equal(authorization.failure_count, 3); + assert.equal(authorization.previous_head, failedHead); + assert.match(authorization.failed_receipt.report_sha256, /^sha256:/); + + let [wave, dependent] = describeWaves(changeDir, plan); + assert.equal(wave.adjudication.status, 'authorized'); + assert.equal(wave.adjudication.active, true); + assert.equal(wave.retryable, true); + assert.equal(wave.eligible, true); + assert.equal(dependent.eligible, false); + assert.throws(() => adjudicateWave(changeDir, 'wave-1', { + decision: 'allow-review', confirmed: true, + reason: 'A replay must not mint another active authorization.', + }), /already.*active.*authorization|active authorization/i); + + assert.throws(() => recordReview(changeDir, 'wave-1', { + status: 'pass', base: failedBase, head: failedHead, + report: writeReviewReport('authorized-old-range-pass.md'), + }), /base must equal the previous review head/i); + + recordReview(changeDir, 'wave-1', { + status: 'fail', base: failedHead, head, + report: writeReviewReport('authorized-fail.md'), + }); + [wave, dependent] = describeWaves(changeDir, plan); + assert.equal(wave.repair.status, 'adjudication-required'); + assert.equal(wave.repair.failure_count, 4); + assert.equal(wave.adjudication.status, 'consumed'); + assert.equal(wave.adjudication.active, false); + assert.equal(wave.retryable, false); + assert.equal(dependent.eligible, false); + assert.throws(() => recordReview(changeDir, 'wave-1', { + status: 'pass', base: head, head, + report: writeReviewReport('unauthorized-pass.md'), + }), /requires adjudication/i); + + adjudicateWave(changeDir, 'wave-1', { + decision: 'allow-review', confirmed: true, + reason: 'Human reviewed the fourth failure and authorizes one final focused review.', + }); + const resolvedHead = createRepairCommit('authorized-pass'); + recordReview(changeDir, 'wave-1', { + status: 'pass', base: head, head: resolvedHead, + report: writeReviewReport('authorized-pass.md'), + }); + [wave, dependent] = describeWaves(changeDir, plan); + assert.equal(wave.repair.status, 'resolved'); + assert.equal(wave.adjudication.status, 'consumed'); + assert.equal(wave.receipt.status, 'pass'); + assert.equal(dependent.eligible, true); + }); + + it('rejects adjudication for a non-blocked wave and for a stale plan', () => { + const plan = createPlan(changeDir, { + mode: 'sdd', source: 'default', rationale: 'adjudication is bound to current blocked evidence', + waves: [{ id: 'wave-1', strategy: 'serial', tasks: ['1.1'], depends_on: [] }], + }); + writePlan(changeDir, plan); + + assert.throws(() => adjudicateWave(changeDir, 'wave-1', { + decision: 'allow-review', confirmed: true, reason: 'There is no blocked repair to adjudicate.', + }), /adjudication-required/i); + + for (let failure = 1, base = gitRefs.base; failure <= 3; failure += 1, base = gitRefs.head) { + recordReview(changeDir, 'wave-1', { + status: 'fail', base, head: gitRefs.head, report: writeReviewReport(`stale-${failure}.md`), + }); + } + writeFileSync(join(changeDir, 'tasks.md'), '# Tasks\n\n- [ ] 1.1 Changed task\n'); + assert.throws(() => adjudicateWave(changeDir, 'wave-1', { + decision: 'allow-review', confirmed: true, reason: 'A stale plan must not accept adjudication.', + }), /invalid execution plan|stale/i); + }); + + it('does not overwrite malformed adjudication evidence', () => { + const plan = createPlan(changeDir, { + mode: 'sdd', source: 'default', rationale: 'adjudication evidence is fail closed', + waves: [{ id: 'wave-1', strategy: 'serial', tasks: ['1.1'], depends_on: [] }], + }); + writePlan(changeDir, plan); + for (let failure = 1, base = gitRefs.base; failure <= 3; failure += 1, base = gitRefs.head) { + recordReview(changeDir, 'wave-1', { + status: 'fail', base, head: gitRefs.head, report: writeReviewReport(`malformed-${failure}.md`), + }); + } + const directory = getPlanScopedPaths(changeDir, plan).adjudications; + mkdirSync(directory, { recursive: true }); + const evidencePath = join(directory, `${Buffer.from('wave-1').toString('base64url')}.json`); + writeFileSync(evidencePath, '{invalid json\n'); + + assert.throws(() => adjudicateWave(changeDir, 'wave-1', { + decision: 'allow-review', confirmed: true, reason: 'Malformed evidence must block instead of being replaced.', + }), /adjudication evidence/i); + assert.equal(readFileSync(evidencePath, 'utf8'), '{invalid json\n'); + }); + it('cleans only the current plan workspace after a repaired pass while retaining its receipt and repair evidence', () => { const plan = createPlan(changeDir, { mode: 'sdd', source: 'default', rationale: 'only generated current-plan files are disposable',