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',