diff --git a/CHANGELOG.md b/CHANGELOG.md index e0acb64..e076d0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format loosely follows Keep a Changelog. ### Fixed +- **Active review projection repair (#106)**: add an opt-in, fail-closed `execution review --repair-active-projection` path that rebuilds only the current root PASS projection from matching scoped evidence, preserves immutable revision state, and makes `execution show` prefer valid root evidence with a safe scoped fallback. - **Evidence-backed DP-5 escalation (#102)**: replace raw `state set dp_5_*` writes with a guarded debugging-attempt ledger. `ssf debug attempt record/show` keeps failed fixes distinct from Wave Review repair failures, rejects duplicate or stale evidence, and `ssf debug escalate --confirm` requires at least three current attempts. Audit reports unsupported legacy DP-5 records instead of treating them as valid approvals. - **Bounded automatic repair retries (#105)**: stop formal review repair after three unresolved failures instead of five. Every implementer retry must carry new failure evidence, an updated focused brief, and a changed strategy; the third unresolved failure returns control for user adjudication rather than dispatching another subagent. diff --git a/INSTALL.md b/INSTALL.md index d3b66c3..9ca6d2c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -849,6 +849,10 @@ 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 +# 只修復 current root active projection;必須已有相符的 current scoped PASS snapshot。 +ssf execution review changes/my-change --wave foundation --base --head \ + --report .superpowers/sdd/reviews/foundation.md --verdict pass \ + --repair-active-projection --json ``` `--report` 相对于 `` 解析,且必须位于 @@ -860,6 +864,16 @@ report 本身必须为普通、非空、非符号链接文件。 每一个 wave 均须有当前 `pass` review receipt,才可启动依赖 wave 或进入 closing; 修订计划会废止旧 receipt。恢复、切换和手动保存属于 control-plane overlay,不增加第九个状态。 +Repair 旗標僅接受 `pass`。root +`/.superpowers/sdd/reviews/.json` 是 current active projection; +`plans//reviews/...` 與 `repair-state` 是 immutable revision evidence。只有 current +plan、known wave、相符的 current scoped PASS snapshot、Git range 與 current report evidence +全部通過驗證後,repair 才會建立或更新 root receipt。相同 evidence 會回傳 no-op,保留檔案 +bytes、mtime 與 `recorded_at`。無效 FAIL、snapshot、report 或 range 會在不寫入的情況下拒絕, +且不會修改 scoped review、repair-state 或 workspace。`ssf execution show` 優先使用有效 root; +root 缺失或為無效 PASS 時才回退有效 scoped receipt,無效 FAIL 仍是 blocker。未帶 repair +旗標的 `execution review` 行為不變。 + Delta spec 的规范路径是 `specs//spec.md`。扁平的 `specs/.md` 和根级 `specs/spec.md` 都不会被当作合法规范静默通过。 ### `ssf inject` 用法 diff --git a/README.md b/README.md index bcadeeb..d61d535 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ npx spec-superflow list # 或通过 npx 使用 | `ssf execution plan ...` | 在用户确认选择后,为 Full/legacy Hotfix 保存受 guard 保护的执行计划 | | `ssf execution show [--json]` | 查看并校验当前执行计划、wave 与 receipt | | `ssf execution revise ...` | 将已有计划保留/升级为 SDD,并生成新 revision;不允许降级 | -| `ssf execution review ...` | 为一个计划 wave 记录 review receipt | +| `ssf execution review --wave --base --head --report --verdict pass\|fail [--repair-active-projection] [--json]` | 為一個計畫 wave 記錄 review receipt;repair 旗標只修復 current active projection | | `ssf install-cursor` | 部署到 Cursor `.cursor/` 目录 | | `ssf install-workbuddy` | 部署到 WorkBuddy marketplace 插件(含 skills/rules/runtime) | | `ssf install-codebuddy` | 部署到 `~/.codebuddy/`(CodeBuddy Code CLI) | @@ -245,6 +245,10 @@ 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 +# 只修復 current root active projection;必須已有相符的 current scoped PASS snapshot。 +ssf execution review changes/my-change --wave foundation --base --head \ + --report .superpowers/sdd/reviews/foundation.md --verdict pass \ + --repair-active-projection --json ``` `--report` 相对于 `` 解析,且必须位于 @@ -257,6 +261,16 @@ report 本身必须为普通、非空、非符号链接文件。 才会放行;修订计划会使旧 receipt 失效。恢复、切换和手动保存是 control-plane overlay,不会增加第九个状态;其 CLI 与 CodeBuddy/WorkBuddy Markdown adapter 保持相同 guard。 +Repair 旗標僅接受 `pass`:root +`/.superpowers/sdd/reviews/.json` 是 current active projection; +`plans//reviews/...` 與 `repair-state` 是 immutable revision evidence。它只會在 +current valid plan、known wave、既存且相符的 current scoped PASS snapshot、有效 Git range 與 +current report evidence 都通過時建立或更新 root,且只更新 current report hash 與 +`recorded_at`。同一 evidence 的 repair 為 no-op,不會重寫任何檔案;FAIL、snapshot 不符、 +report/range 無效皆拒絕且不寫入,也不會碰 scoped review、repair-state 或 workspace。 +`ssf execution show` 優先使用有效 root;root 缺失或無效 PASS 時才回退有效 scoped receipt, +無效 FAIL 仍是 blocker。未帶 repair 旗標的 `execution review` 行為不變。 + --- ## 为什么需要它 diff --git a/docs/README_en.md b/docs/README_en.md index 42a6dd7..b7ca874 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -300,6 +300,11 @@ 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 +# Repair only the current root active projection; a matching current scoped +# PASS snapshot must already exist. +ssf execution review changes/my-change --wave foundation --base --head \ + --report .superpowers/sdd/reviews/foundation.md --verdict pass \ + --repair-active-projection --json ``` The `--report` path is resolved relative to `` and must remain under @@ -315,6 +320,19 @@ 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. +The repair flag accepts only `pass`. The root +`/.superpowers/sdd/reviews/.json` is the current active +projection, while `plans//reviews/...` and `repair-state` remain +immutable revision evidence. Repair creates or updates only the root receipt, +and only after the current plan, known wave, matching current scoped PASS +snapshot, Git range, and current report evidence all validate. Matching root +evidence is a no-op that preserves the file bytes, mtime, and `recorded_at`. +Invalid FAIL, snapshot, report, or range evidence is rejected without writing; +scoped reviews, repair-state, and workspace are never changed. `ssf execution +show` prefers a valid root receipt, falls back to a valid scoped receipt only +when the root is missing or an invalid PASS, and keeps an invalid FAIL as a +blocker. Without the repair flag, `execution review` behaves as before. + ### Fast Paths (Quick / Hotfix / Tweak) - **Quick** — ≤3 single-module code files/tasks → direct acceptance when low risk; for PRD, Spec/Design, API, data/permission, or cross-module impact, show the risk and let the user choose Quick or Full. A chosen Quick records `tdd`, `new-test`, or `bounded` verification. diff --git a/docs/artifact-contract.md b/docs/artifact-contract.md index d2bbe59..d61657e 100644 --- a/docs/artifact-contract.md +++ b/docs/artifact-contract.md @@ -80,6 +80,27 @@ retains or upgrades an existing plan as `sdd`, requires fresh confirmation, creates a new revision, and clears prior review receipts; it never permits a downgrade. +#### Active review projection 與 immutable revision evidence + +對 current plan 的每個 wave,root +`/.superpowers/sdd/reviews/.json` 是 `ssf execution show` 使用的 current +active projection;`/.superpowers/sdd/plans//reviews/...` 與同 scope 的 +`repair-state` 則是 immutable revision evidence。`ssf execution show` 先採用有效 root;root +缺失或為無效 PASS 時才回退有效 scoped receipt,而無效 FAIL 仍維持為 blocker。 + +只可透過下列明確 opt-in 修復 root projection: + +```bash +ssf execution review --wave --base --head --report \ + --verdict pass --repair-active-projection [--json] +``` + +repair 必須通過 current valid plan、known wave、既存且完全相符的 current plan-scoped PASS +snapshot、有效 Git range 與 current report evidence 驗證。成功時只建立或更新 root,並只更新 +current report hash 與 `recorded_at`;既有相同 evidence 時為 no-op,不重寫。FAIL verdict、 +snapshot 不符或 report/range 無效都會拒絕且不寫入;repair 不會改寫 scoped review、 +repair-state 或 workspace。未帶此旗標的一般 `execution review` 保持原本行為。 + ### Recovery control-plane overlay Recovery commands operate beside the eight-state workflow, without creating a diff --git a/scripts/lib/cmd-execution.mjs b/scripts/lib/cmd-execution.mjs index c834584..d1a7418 100644 --- a/scripts/lib/cmd-execution.mjs +++ b/scripts/lib/cmd-execution.mjs @@ -1,5 +1,14 @@ import { parseArgs } from 'node:util'; -import { createPlan, describeWaves, EXECUTION_MODES, readPlan, recordReview, validatePlan, writePlan } from './execution-plan.mjs'; +import { + createPlan, + describeWaves, + EXECUTION_MODES, + readPlan, + recordReview, + repairActiveReviewProjection, + validatePlan, + writePlan, +} from './execution-plan.mjs'; import { createRecommendationReceipt, readCurrentRecommendationReceipt, @@ -22,6 +31,7 @@ export function run(args, io = { stdout: process.stdout, stderr: process.stderr head: { type: 'string' }, report: { type: 'string' }, verdict: { type: 'string' }, + 'repair-active-projection': { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, help: { type: 'boolean', default: false }, }, @@ -136,6 +146,23 @@ function recordAndPrintReview(changeDir, values, io) { requireOption(values.head, '--head'); requireOption(values.report, '--report'); if (!['pass', 'fail'].includes(values.verdict)) throw new Error("--verdict must be 'pass' or 'fail'"); + if (values['repair-active-projection']) { + if (values.verdict !== 'pass') throw new Error('Active projection repair request only accepts PASS verdict'); + const result = repairActiveReviewProjection(changeDir, values.wave[0], { + status: values.verdict, + base: values.base, + head: values.head, + report: values.report, + }); + print(values.json, { + ok: true, + wave: values.wave[0], + mode: 'active-projection-repair', + changed: result.changed, + receipt: result.receipt, + }, `Active review projection for ${values.wave[0]} ${result.changed ? 'repaired' : 'is already current'}.`, io); + return; + } const receipt = recordReview(changeDir, values.wave[0], { status: values.verdict, base: values.base, @@ -206,5 +233,5 @@ 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 [--repair-active-projection] [--json]\n`); } diff --git a/scripts/lib/execution-plan.mjs b/scripts/lib/execution-plan.mjs index b42c0d5..c82c2bd 100644 --- a/scripts/lib/execution-plan.mjs +++ b/scripts/lib/execution-plan.mjs @@ -174,6 +174,57 @@ export function recordReview(changeDir, waveId, receipt) { return savedReceipt; } +export function repairActiveReviewProjection(changeDir, waveId, receipt) { + const plan = readPlan(changeDir); + const validation = validatePlan(changeDir, plan); + if (!validation.valid) { + throw new Error(`Cannot repair the active review projection for 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(`Active projection repair references unknown wave '${waveId}'`); + if (receipt?.status !== 'pass') throw new Error('Active projection repair request only accepts PASS receipts'); + for (const field of ['base', 'head']) requireText(receipt?.[field], `receipt.${field}`); + + const reportEvidence = validateReviewReportEvidence(changeDir, receipt?.report); + const { base, head } = validateReviewRange(changeDir, receipt.base, receipt.head); + const receiptName = `${safeFileName(waveId)}.json`; + const scopedPath = join(getPlanScopedPaths(changeDir, plan).reviews, receiptName); + const scopedReceipt = readScopedRepairReceipt(scopedPath, plan, waveId); + if (scopedReceipt.status !== receipt.status + || scopedReceipt.base !== base + || scopedReceipt.head !== head + || scopedReceipt.report !== reportEvidence.path) { + throw new Error(`Current plan-scoped review snapshot for wave '${waveId}' does not match the active projection repair request`); + } + + const rootPath = join(getOverlayPaths(changeDir).reviews, receiptName); + const rootReceipt = readJsonIfPresent(rootPath); + if (rootReceipt?.plan_hash === plan.hash + && rootReceipt?.plan_revision === plan.revision + && rootReceipt?.status === 'fail') { + throw new Error(`Wave '${waveId}' has a current FAIL active receipt and cannot use PASS active projection repair`); + } + if (sameReviewEvidence(rootReceipt, { + ...scopedReceipt, + report_sha256: reportEvidence.sha256, + })) { + return { changed: false, receipt: rootReceipt }; + } + + const savedReceipt = { + status: scopedReceipt.status, + base, + head, + report: reportEvidence.path, + report_sha256: reportEvidence.sha256, + plan_hash: plan.hash, + plan_revision: plan.revision, + recorded_at: new Date().toISOString(), + }; + atomicWrite(rootPath, `${JSON.stringify(savedReceipt, null, 2)}\n`); + return { changed: true, receipt: savedReceipt }; +} + /** * Returns the current plan's receipt for one wave. Receipts from a previous * revision/hash are never evidence for the current plan. @@ -186,15 +237,18 @@ function readCurrentReviewEvidence(changeDir, waveId, plan = readPlan(changeDir) if (!plan) return { receipt: null, blocker: null }; const currentScope = getPlanScopedPaths(changeDir, plan); const currentPath = join(currentScope.reviews, `${safeFileName(waveId)}.json`); - const legacyPath = join(getOverlayPaths(changeDir).reviews, `${safeFileName(waveId)}.json`); - // Newer callers may have written a scoped receipt, but the established - // root receipt remains the compatibility source until a future format - // migration. In either case the plan identity below is mandatory. - const filePath = existsSync(currentPath) ? currentPath : legacyPath; + const rootPath = join(getOverlayPaths(changeDir).reviews, `${safeFileName(waveId)}.json`); + const rootEvidence = readReviewEvidenceFile(changeDir, rootPath, plan); + if (rootEvidence.receipt || rootEvidence.blocker) return rootEvidence; + return readReviewEvidenceFile(changeDir, currentPath, plan); +} + +function readReviewEvidenceFile(changeDir, filePath, plan) { if (!existsSync(filePath)) return { receipt: null, blocker: null }; try { const receipt = JSON.parse(readFileSync(filePath, 'utf8')); if (receipt?.plan_hash !== plan.hash || receipt?.plan_revision !== plan.revision) return { receipt: null, blocker: null }; + if (!REVIEW_STATUSES.has(receipt?.status)) return { receipt: null, blocker: null }; const range = validateReviewRange(changeDir, receipt?.base, receipt?.head); if (receipt.base !== range.base || receipt.head !== range.head) return { receipt: null, blocker: null }; // Reports remain evidence only while their safety and content identity can @@ -221,6 +275,41 @@ function readCurrentReviewEvidence(changeDir, waveId, plan = readPlan(changeDir) } } +function readScopedRepairReceipt(filePath, plan, waveId) { + if (!existsSync(filePath)) { + throw new Error(`Current plan-scoped review receipt is missing for wave '${waveId}'`); + } + let receipt; + try { + receipt = JSON.parse(readFileSync(filePath, 'utf8')); + } catch (error) { + throw new Error(`Current plan-scoped review receipt is invalid or cannot be parsed: ${error.message}`); + } + if (receipt?.plan_hash !== plan.hash || receipt?.plan_revision !== plan.revision) { + throw new Error(`Current plan-scoped review receipt plan identity does not match the current execution plan for wave '${waveId}'`); + } + if (receipt?.status !== 'pass') { + throw new Error(`Current plan-scoped review receipt for wave '${waveId}' must record PASS`); + } + return receipt; +} + +function readJsonIfPresent(filePath) { + if (!existsSync(filePath)) return null; + try { + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +function sameReviewEvidence(actual, expected) { + if (!actual || typeof actual !== 'object') return false; + if (typeof actual.recorded_at !== 'string' || actual.recorded_at.trim() === '') return false; + return ['status', 'base', 'head', 'report', 'report_sha256', 'plan_hash', 'plan_revision'] + .every(field => actual[field] === expected[field]); +} + /** * Machine-readable execution status used by `ssf execution show`. A wave is * eligible when it has no current receipt, or its current receipt failed and diff --git a/scripts/spec-superflow.mjs b/scripts/spec-superflow.mjs index b1c5632..db86d99 100755 --- a/scripts/spec-superflow.mjs +++ b/scripts/spec-superflow.mjs @@ -88,7 +88,7 @@ Commands: Show and validate the current execution plan execution revise --mode sdd --confirm --reason --wave :: [--acknowledge-recommendation] Upgrade inline/batch to SDD, or replan existing SDD waves, as a new revision - execution review --wave --base --head --report --verdict pass|fail + execution review --wave --base --head --report --verdict pass|fail [--repair-active-projection] [--json] Record one review receipt for a planned wave resume [change-dir] [--json] Recover the only active change or an explicit change context diff --git a/tests/lib/cmd-execution.test.mjs b/tests/lib/cmd-execution.test.mjs index 6817bbe..e6ddb1e 100644 --- a/tests/lib/cmd-execution.test.mjs +++ b/tests/lib/cmd-execution.test.mjs @@ -1,7 +1,8 @@ import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { getPlanScopedPaths } from '../../scripts/lib/sdd-overlay.mjs'; @@ -123,6 +124,101 @@ function currentReceiptPath(waveId) { return join(getPlanScopedPaths(changeDir, plan).reviews, `${Buffer.from(waveId, 'utf8').toString('base64url')}.json`); } +function rootReceiptPath(waveId) { + return join(rootReviewsPath(), `${Buffer.from(waveId, 'utf8').toString('base64url')}.json`); +} + +function rootReviewsPath() { + return join(changeDir, '.superpowers', 'sdd', 'reviews'); +} + +function reportHash(path) { + return `sha256:${createHash('sha256').update(readFileSync(path)).digest('hex')}`; +} + +function snapshotTree(path) { + if (!existsSync(path)) return { exists: false }; + const metadata = statSync(path); + if (metadata.isDirectory()) { + return { + exists: true, + type: 'directory', + mtimeMs: metadata.mtimeMs, + children: readdirSync(path).sort().map(name => [name, snapshotTree(join(path, name))]), + }; + } + return { exists: true, type: 'file', mtimeMs: metadata.mtimeMs, bytes: readFileSync(path) }; +} + +function freezeTreeMtime(path, at = new Date('2000-01-01T00:00:00.000Z')) { + if (!existsSync(path)) return; + const metadata = statSync(path); + if (metadata.isDirectory()) { + for (const name of readdirSync(path)) freezeTreeMtime(join(path, name), at); + } + utimesSync(path, at, at); +} + +function immutableEvidenceSnapshots(plan) { + const paths = getPlanScopedPaths(changeDir, plan); + return { + scopedReview: snapshotTree(join(paths.reviews, `${Buffer.from('wave-1', 'utf8').toString('base64url')}.json`)), + repairState: snapshotTree(join(paths.repairState, `${Buffer.from('wave-1', 'utf8').toString('base64url')}.json`)), + workspace: snapshotTree(paths.workspace), + }; +} + +function assertRootMatchesCurrentProjection(root, scopedReceipt, currentReportHash) { + for (const field of ['status', 'base', 'head', 'report', 'plan_hash', 'plan_revision']) { + assert.equal(root[field], scopedReceipt[field], `root ${field} must retain the current scoped identity`); + } + assert.equal(root.report_sha256, currentReportHash); + assert.notEqual(root.report_sha256, scopedReceipt.report_sha256); +} + +function prepareActiveProjectionRepairFixture() { + const planned = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', + '--reason', 'root projection repair requires immutable evidence', '--wave', 'wave-1:serial:1.1']); + assert.equal(planned.exitCode, 0, planned.stderr); + const reportPath = writeReviewReport('active-projection.md', 'Scoped PASS report before root repair.\n'); + const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.base, '--head', gitRefs.head, '--report', reportPath, '--verdict', 'pass']); + assert.equal(reviewed.exitCode, 0, reviewed.stderr); + + const plan = JSON.parse(readFileSync(join(changeDir, '.superpowers', 'sdd', 'execution-plan.json'), 'utf8')); + const paths = getPlanScopedPaths(changeDir, plan); + const scopedReceipt = JSON.parse(readFileSync(currentReceiptPath('wave-1'), 'utf8')); + writeFileSync(reportPath, 'Current report content used to repair the root projection.\n'); + const currentReportHash = reportHash(reportPath); + mkdirSync(paths.workspace, { recursive: true }); + writeFileSync(join(paths.workspace, 'task-brief.md'), 'immutable workspace evidence\n'); + mkdirSync(paths.repairState, { recursive: true }); + writeFileSync(join(paths.repairState, `${Buffer.from('wave-1', 'utf8').toString('base64url')}.json`), '{"preserve":"repair-state"}\n'); + freezeTreeMtime(paths.reviews); + freezeTreeMtime(paths.repairState); + freezeTreeMtime(paths.workspace); + freezeTreeMtime(rootReceiptPath('wave-1')); + + return { plan, paths, reportPath, scopedReceipt, currentReportHash }; +} + +function rejectActiveProjectionRepair(testCase) { + const fixture = prepareActiveProjectionRepairFixture(); + const commandInputs = testCase.mutate(fixture) ?? {}; + const rootReviewsBefore = snapshotTree(rootReviewsPath()); + const immutableBefore = immutableEvidenceSnapshots(fixture.plan); + const result = runSsf(['execution', 'review', changeDir, '--wave', testCase.wave ?? 'wave-1', + '--base', commandInputs.base ?? gitRefs.base, '--head', commandInputs.head ?? gitRefs.head, + '--report', commandInputs.report ?? fixture.reportPath, '--verdict', testCase.verdict ?? 'pass', + '--repair-active-projection', '--json']); + + assert.notEqual(result.exitCode, 0, testCase.name); + assert.equal(result.json, null, `${testCase.name} must not emit a success JSON receipt`); + assert.match(result.stderr, testCase.expected, testCase.name); + assert.deepEqual(snapshotTree(rootReviewsPath()), rootReviewsBefore, `${testCase.name} root reviews write`); + assert.deepEqual(immutableEvidenceSnapshots(fixture.plan), immutableBefore, `${testCase.name} scoped write`); +} + function createRepairCommit(label) { const marker = join(changeDir, `repair-${label}.txt`); writeFileSync(marker, `${label}\n`); @@ -406,10 +502,11 @@ describe('ssf execution', () => { '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); assert.equal(reviewed.exitCode, 0, reviewed.stderr); - const receiptPath = currentReceiptPath('wave-1'); + const receiptPath = rootReceiptPath('wave-1'); const receipt = JSON.parse(readFileSync(receiptPath, 'utf8')); receipt.base = '0000000000000000000000000000000000000001'; writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); + rmSync(currentReceiptPath('wave-1')); const shown = runSsf(['execution', 'show', changeDir, '--json']); assert.equal(shown.exitCode, 0, shown.stderr); @@ -426,11 +523,12 @@ describe('ssf execution', () => { '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); assert.equal(reviewed.exitCode, 0, reviewed.stderr); - const receiptPath = currentReceiptPath('wave-1'); + const receiptPath = rootReceiptPath('wave-1'); const receipt = JSON.parse(readFileSync(receiptPath, 'utf8')); receipt.base = gitRefs.head; receipt.head = gitRefs.divergent; writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); + rmSync(currentReceiptPath('wave-1')); const shown = runSsf(['execution', 'show', changeDir, '--json']); assert.equal(shown.exitCode, 0, shown.stderr); @@ -726,4 +824,250 @@ describe('ssf execution', () => { assert.notEqual(invalidRevision.exitCode, 0); assert.match(invalidRevision.stderr, /sdd|downgrade|upgrade/i); }); + + it('repairs a missing root active projection with JSON output while leaving scoped evidence byte-and-mtime immutable', () => { + // Mutation caught: route repair through recordReview(), which rewrites scoped receipt/repair-state or clears workspace. + const { plan, paths, reportPath, scopedReceipt, currentReportHash } = prepareActiveProjectionRepairFixture(); + const before = immutableEvidenceSnapshots(plan); + rmSync(rootReceiptPath('wave-1')); + + const result = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.base, '--head', gitRefs.head, '--report', reportPath, '--verdict', 'pass', + '--repair-active-projection', '--json']); + + assert.equal(result.exitCode, 0, result.stderr); + assert.deepEqual(Object.keys(result.json).sort(), ['changed', 'mode', 'ok', 'receipt', 'wave']); + assert.equal(result.json.mode, 'active-projection-repair'); + assert.equal(result.json.changed, true); + const rootReceipt = JSON.parse(readFileSync(rootReceiptPath('wave-1'), 'utf8')); + assertRootMatchesCurrentProjection(rootReceipt, scopedReceipt, currentReportHash); + assert.deepEqual(result.json.receipt, rootReceipt); + assert.deepEqual(immutableEvidenceSnapshots(plan), before); + assert.equal(snapshotTree(paths.workspace).children[0][0], 'task-brief.md'); + }); + + it('replaces a stale root projection but changes no plan-scoped evidence', () => { + // Mutation caught: accept a stale root receipt as current or overwrite the current scoped snapshot while repairing it. + const { plan, reportPath, scopedReceipt, currentReportHash } = prepareActiveProjectionRepairFixture(); + const before = immutableEvidenceSnapshots(plan); + assert.notEqual(JSON.parse(readFileSync(rootReceiptPath('wave-1'), 'utf8')).report_sha256, currentReportHash); + + const result = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.base, '--head', gitRefs.head, '--report', reportPath, '--verdict', 'pass', + '--repair-active-projection', '--json']); + + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.json.changed, true); + const rootReceipt = JSON.parse(readFileSync(rootReceiptPath('wave-1'), 'utf8')); + assertRootMatchesCurrentProjection(rootReceipt, scopedReceipt, currentReportHash); + assert.deepEqual(result.json.receipt, rootReceipt); + assert.deepEqual(immutableEvidenceSnapshots(plan), before); + }); + + it('reports a current root repair as JSON no-op without changing root or immutable evidence mtimes', () => { + // Mutation caught: always rewrite the root receipt even when it already equals the current scoped PASS snapshot. + const { plan, reportPath, scopedReceipt, currentReportHash } = prepareActiveProjectionRepairFixture(); + writeFileSync(rootReceiptPath('wave-1'), `${JSON.stringify({ ...scopedReceipt, report_sha256: currentReportHash, recorded_at: '2000-01-01T00:00:00.000Z' }, null, 2)}\n`); + freezeTreeMtime(rootReceiptPath('wave-1')); + const rootReviewsBefore = snapshotTree(rootReviewsPath()); + const immutableBefore = immutableEvidenceSnapshots(plan); + + const result = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.base, '--head', gitRefs.head, '--report', reportPath, '--verdict', 'pass', + '--repair-active-projection', '--json']); + + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.json.changed, false); + assert.deepEqual(result.json.receipt, JSON.parse(readFileSync(rootReceiptPath('wave-1'), 'utf8'))); + assert.deepEqual(snapshotTree(rootReviewsPath()), rootReviewsBefore); + assert.deepEqual(immutableEvidenceSnapshots(plan), immutableBefore); + }); + + it('rebuilds a matching root projection when recorded_at is missing', () => { + // Mutation caught: treat matching evidence fields as a no-op even when the returned receipt is incomplete. + const { plan, reportPath, scopedReceipt, currentReportHash } = prepareActiveProjectionRepairFixture(); + const incompleteReceipt = { ...scopedReceipt, report_sha256: currentReportHash }; + delete incompleteReceipt.recorded_at; + writeFileSync(rootReceiptPath('wave-1'), `${JSON.stringify(incompleteReceipt, null, 2)}\n`); + const immutableBefore = immutableEvidenceSnapshots(plan); + + const result = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.base, '--head', gitRefs.head, '--report', reportPath, '--verdict', 'pass', + '--repair-active-projection', '--json']); + + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.json.changed, true); + assert.equal(typeof result.json.receipt.recorded_at, 'string'); + assert.notEqual(result.json.receipt.recorded_at.trim(), ''); + assertRootMatchesCurrentProjection(result.json.receipt, scopedReceipt, currentReportHash); + assert.deepEqual(result.json.receipt, JSON.parse(readFileSync(rootReceiptPath('wave-1'), 'utf8'))); + assert.deepEqual(immutableEvidenceSnapshots(plan), immutableBefore); + }); + + it('rejects a missing scoped repair snapshot without successful JSON or writes', () => { + // Mutation caught: create root projection before checking that scoped evidence exists. + rejectActiveProjectionRepair({ + name: 'missing scoped receipt', + mutate: ({ paths }) => rmSync(join(paths.reviews, `${Buffer.from('wave-1', 'utf8').toString('base64url')}.json`)), + expected: /scoped.*(missing|receipt)|current.*pass/i, + }); + }); + + it('rejects a malformed scoped repair snapshot without successful JSON or writes', () => { + // Mutation caught: parse malformed scoped evidence after changing root state. + rejectActiveProjectionRepair({ + name: 'malformed scoped receipt', + mutate: ({ paths }) => writeFileSync(join(paths.reviews, `${Buffer.from('wave-1', 'utf8').toString('base64url')}.json`), '{ malformed'), + expected: /scoped.*(invalid|parse)|current.*pass/i, + }); + }); + + it('rejects a wrong-plan scoped repair snapshot without successful JSON or writes', () => { + // Mutation caught: accept a receipt belonging to another plan identity. + rejectActiveProjectionRepair({ + name: 'wrong-plan scoped receipt', + mutate: ({ paths }) => { + const path = join(paths.reviews, `${Buffer.from('wave-1', 'utf8').toString('base64url')}.json`); + writeFileSync(path, `${JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), plan_hash: `sha256:${'f'.repeat(64)}` }, null, 2)}\n`); + }, + expected: /plan.*(identity|hash)|scoped.*plan/i, + }); + }); + + it('rejects a non-PASS scoped repair snapshot without successful JSON or writes', () => { + // Mutation caught: promote a failed scoped receipt to an active PASS root projection. + rejectActiveProjectionRepair({ + name: 'non-PASS scoped receipt', + mutate: ({ paths }) => { + const path = join(paths.reviews, `${Buffer.from('wave-1', 'utf8').toString('base64url')}.json`); + writeFileSync(path, `${JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), status: 'fail' }, null, 2)}\n`); + }, + expected: /scoped.*pass|pass.*scoped/i, + }); + }); + + it('rejects a non-PASS repair request without successful JSON or writes', () => { + // Mutation caught: allow a FAIL repair request to create an active projection. + rejectActiveProjectionRepair({ + name: 'non-PASS repair request', + mutate: () => {}, + verdict: 'fail', + expected: /repair request.*pass|repair.*only.*pass/i, + }); + }); + + it('rejects a mismatched scoped repair snapshot without successful JSON or writes', () => { + // Mutation caught: repair a root from a scoped receipt whose range differs from CLI input. + rejectActiveProjectionRepair({ + name: 'mismatched scoped snapshot', + mutate: ({ paths }) => { + const path = join(paths.reviews, `${Buffer.from('wave-1', 'utf8').toString('base64url')}.json`); + writeFileSync(path, `${JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), head: gitRefs.base }, null, 2)}\n`); + }, + expected: /scoped.*(match|range|snapshot)|current.*pass/i, + }); + }); + + it('rejects an unknown repair wave without successful JSON or writes', () => { + // Mutation caught: write a new root reviews/.json receipt before rejecting the wave. + rejectActiveProjectionRepair({ + name: 'unknown wave', + wave: 'unknown-wave', + mutate: () => {}, + expected: /unknown wave/i, + }); + }); + + it('rejects a missing repair report without successful JSON or writes', () => { + // Mutation caught: defer report existence validation until after writing the root projection. + rejectActiveProjectionRepair({ + name: 'missing repair report', + mutate: ({ reportPath }) => rmSync(reportPath), + expected: /report.*(regular|missing|exist|cannot be read)|enoent/i, + }); + }); + + it('rejects an empty repair report without successful JSON or writes', () => { + // Mutation caught: accept an empty report as current review evidence. + rejectActiveProjectionRepair({ + name: 'empty repair report', + mutate: ({ reportPath }) => writeFileSync(reportPath, ''), + expected: /report.*(empty|non-empty)/i, + }); + }); + + it('rejects a repair report outside the review overlay without successful JSON or writes', () => { + // Mutation caught: omit the review-overlay containment check on the repair branch. + rejectActiveProjectionRepair({ + name: 'outside repair report', + mutate: () => { + const report = join(changeDir, 'outside-active-projection.md'); + writeFileSync(report, 'Outside report must not authorize active projection repair.\n'); + const scopedPath = currentReceiptPath('wave-1'); + const scopedReceipt = JSON.parse(readFileSync(scopedPath, 'utf8')); + writeFileSync(scopedPath, `${JSON.stringify({ + ...scopedReceipt, + report: 'outside-active-projection.md', + report_sha256: reportHash(report), + }, null, 2)}\n`); + return { report }; + }, + expected: /resolve inside.*review overlay/i, + }); + }); + + it('rejects a repair report reached through a symlink without successful JSON or writes', () => { + // Mutation caught: validate only the lexical report path and skip physical containment. + rejectActiveProjectionRepair({ + name: 'symlink repair report', + mutate: () => { + const outsideDir = join(changeDir, 'outside-repair-reports'); + mkdirSync(outsideDir, { recursive: true }); + writeFileSync(join(outsideDir, 'escaped.md'), 'Symlinked report must not authorize repair.\n'); + const linkedDir = join(rootReviewsPath(), 'linked-repair'); + symlinkSync(outsideDir, linkedDir, 'dir'); + const report = join(linkedDir, 'escaped.md'); + const scopedPath = currentReceiptPath('wave-1'); + const scopedReceipt = JSON.parse(readFileSync(scopedPath, 'utf8')); + writeFileSync(scopedPath, `${JSON.stringify({ + ...scopedReceipt, + report: 'outside-repair-reports/escaped.md', + report_sha256: reportHash(report), + }, null, 2)}\n`); + return { report }; + }, + expected: /resolve inside.*review overlay/i, + }); + }); + + it('rejects a repair range containing a nonexistent commit without successful JSON or writes', () => { + // Mutation caught: compare the requested range to scoped text before resolving both Git commits. + rejectActiveProjectionRepair({ + name: 'nonexistent repair base', + mutate: () => ({ base: '0000000000000000000000000000000000000001' }), + expected: /base|commit|git/i, + }); + }); + + it('rejects a non-ancestor repair range without successful JSON or writes', () => { + // Mutation caught: resolve commits without enforcing that base is an ancestor of head. + rejectActiveProjectionRepair({ + name: 'non-ancestor repair range', + mutate: () => ({ base: gitRefs.head, head: gitRefs.divergent }), + expected: /ancestor|range|git/i, + }); + }); + + it('preserves a current invalid FAIL blocker instead of repairing over it', () => { + // Mutation caught: fall back to scoped PASS and overwrite current failed evidence during repair. + rejectActiveProjectionRepair({ + name: 'current invalid FAIL blocker', + mutate: () => { + const path = rootReceiptPath('wave-1'); + const receipt = JSON.parse(readFileSync(path, 'utf8')); + writeFileSync(path, `${JSON.stringify({ ...receipt, status: 'fail' }, null, 2)}\n`); + }, + expected: /current fail|fail active|cannot.*repair/i, + }); + }); }); diff --git a/tests/lib/execution-plan.test.mjs b/tests/lib/execution-plan.test.mjs index 519da51..04dd4af 100644 --- a/tests/lib/execution-plan.test.mjs +++ b/tests/lib/execution-plan.test.mjs @@ -1,6 +1,7 @@ import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { @@ -8,7 +9,7 @@ import { } from '../../scripts/lib/execution-plan.mjs'; import { createRecommendationReceipt, recommendExecutionModes } from '../../scripts/lib/execution-recommendation.mjs'; import { readState } from '../../scripts/lib/state-loader.mjs'; -import { getPlanScopedPaths } from '../../scripts/lib/sdd-overlay.mjs'; +import { getOverlayPaths, getPlanScopedPaths } from '../../scripts/lib/sdd-overlay.mjs'; import { createGitSeedFixture } from '../helpers/git-seed-fixture.mjs'; let changeDir; @@ -85,6 +86,14 @@ function createPlan(directory, input) { }); } +function rootReceiptPath(waveId) { + return join(getOverlayPaths(changeDir).reviews, `${Buffer.from(waveId, 'utf8').toString('base64url')}.json`); +} + +function reportHash(path) { + return `sha256:${createHash('sha256').update(readFileSync(path)).digest('hex')}`; +} + describe('execution plan data contract', () => { it('reuses one immutable Git review range but re-resolves symbolic revisions', () => { const base = 'a'.repeat(40); @@ -642,6 +651,73 @@ describe('execution plan data contract', () => { assert.equal(existsSync(current.repairState), true); }); + it('prefers a valid root active receipt over the immutable current-plan receipt', () => { + // Mutation caught: keep the historical scoped-first read order after adding root receipts. + const plan = createPlan(changeDir, { + mode: 'sdd', source: 'default', rationale: 'root active projection takes priority', + waves: [{ id: 'wave-1', strategy: 'serial', tasks: ['1.1'], depends_on: [] }], + }); + writePlan(changeDir, plan); + const reportPath = writeReviewReport('root-first.md', 'Scoped PASS report before review refresh.\n'); + const scoped = recordReview(changeDir, 'wave-1', { + status: 'pass', base: gitRefs.base, head: gitRefs.head, report: reportPath, + }); + writeFileSync(reportPath, 'Current root projection report after review refresh.\n'); + const root = { ...scoped, report_sha256: reportHash(reportPath) }; + writeFileSync(rootReceiptPath('wave-1'), `${JSON.stringify(root, null, 2)}\n`); + + const wave = describeWaves(changeDir, plan)[0]; + + assert.ok(wave.receipt, 'valid root active receipt must remain readable when scoped evidence is stale'); + assert.equal(wave.receipt.head, scoped.head); + assert.equal(wave.receipt.report, scoped.report); + assert.equal(wave.receipt.report_sha256, root.report_sha256); + assert.notEqual(wave.receipt.report_sha256, scoped.report_sha256); + assert.equal(wave.receipt.recorded_at, root.recorded_at); + }); + + it('falls back to valid scoped evidence for missing or invalid root PASS receipts', () => { + // Mutation caught: make an absent or malformed root receipt suppress valid immutable scoped evidence. + const plan = createPlan(changeDir, { + mode: 'sdd', source: 'default', rationale: 'scoped evidence remains the valid fallback', + waves: [{ id: 'wave-1', strategy: 'serial', tasks: ['1.1'], depends_on: [] }], + }); + writePlan(changeDir, plan); + const scoped = recordReview(changeDir, 'wave-1', { + status: 'pass', base: gitRefs.base, head: gitRefs.head, report: writeReviewReport('scoped-fallback.md'), + }); + const rootPath = rootReceiptPath('wave-1'); + + rmSync(rootPath); + assert.equal(describeWaves(changeDir, plan)[0].receipt.head, scoped.head); + + writeFileSync(rootPath, `${JSON.stringify({ ...scoped, report_sha256: `sha256:${'0'.repeat(64)}` }, null, 2)}\n`); + assert.equal(describeWaves(changeDir, plan)[0].receipt.head, scoped.head); + + writeFileSync(rootPath, '{ malformed root PASS receipt'); + assert.equal(describeWaves(changeDir, plan)[0].receipt.head, scoped.head); + }); + + it('preserves an invalid root FAIL receipt as an active blocker instead of falling back', () => { + // Mutation caught: treat invalid FAIL evidence like invalid PASS and reopen the wave from scoped history. + const plan = createPlan(changeDir, { + mode: 'sdd', source: 'default', rationale: 'failed active evidence remains a blocker', + waves: [{ id: 'wave-1', strategy: 'serial', tasks: ['1.1'], depends_on: [] }], + }); + writePlan(changeDir, plan); + const scoped = recordReview(changeDir, 'wave-1', { + status: 'pass', base: gitRefs.base, head: gitRefs.head, report: writeReviewReport('fail-blocker.md'), + }); + const invalidFail = { ...scoped, status: 'fail', report_sha256: `sha256:${'0'.repeat(64)}` }; + writeFileSync(rootReceiptPath('wave-1'), `${JSON.stringify(invalidFail, null, 2)}\n`); + + const wave = describeWaves(changeDir, plan)[0]; + + assert.equal(wave.receipt, null); + assert.equal(wave.eligible, false); + assert.match(wave.blockers.join('\n'), /failed review report evidence is invalid|cannot be read/i); + }); + it('returns validation failures instead of throwing for malformed plans', () => { const result = validatePlan(changeDir, { mode: 'sdd', waves: 'not-an-array' });