@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/bin/__tests__/codex-hooks.test.js +794 -21
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +1838 -112
  4. package/bin/__tests__/installer-safety.test.js +82 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +799 -0
  6. package/bin/__tests__/package-inventory.test.js +841 -10
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +2469 -71
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +153 -202
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
  22. package/src/claude/hooks/completion-authority-check.cjs +56 -0
  23. package/src/claude/hooks/completion-authority-state.cjs +527 -0
  24. package/src/claude/hooks/completion-authority.cjs +154 -0
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/session.cjs +33 -19
  31. package/src/claude/hooks/spec-gate.cjs +181 -139
  32. package/src/claude/hooks/spec-state.cjs +112 -47
  33. package/src/claude/migration-manifest.json +15 -2
  34. package/src/claude/rules/state-sync.md +52 -21
  35. package/src/claude/rules/workflow.md +17 -9
  36. package/src/claude/scripts/provenance.cjs +315 -0
  37. package/src/claude/scripts/spec-final-state.cjs +244 -0
  38. package/src/claude/scripts/spec-ground.cjs +535 -173
  39. package/src/claude/scripts/spec-readiness.cjs +118 -0
  40. package/src/claude/scripts/spec-receipt.cjs +162 -0
  41. package/src/claude/scripts/spec-resolver.cjs +501 -0
  42. package/src/claude/scripts/spec-scaffold.cjs +1945 -55
  43. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  44. package/src/claude/scripts/validate-spec-output.cjs +3204 -262
  45. package/src/claude/scripts/workflow-policy.cjs +1888 -203
  46. package/src/claude/settings/settings.json +12 -0
  47. package/src/claude/skills/code-review/SKILL.md +94 -66
  48. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  49. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  50. package/src/claude/skills/develop/SKILL.md +138 -283
  51. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  52. package/src/claude/skills/develop/references/quality-gate.md +123 -148
  53. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  54. package/src/claude/skills/specs/SKILL.md +192 -397
  55. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  56. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  57. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  58. package/src/claude/skills/specs/references/review.md +200 -260
  59. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  60. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  61. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  62. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  63. package/src/claude/skills/specs/rules/design-review.md +42 -97
  64. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  65. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  66. package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
  67. package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
  68. package/src/claude/skills/specs/templates/design.md +49 -263
  69. package/src/claude/skills/specs/templates/requirements.md +26 -36
  70. package/src/claude/skills/specs/templates/research.md +9 -98
  71. package/src/claude/skills/specs/templates/spec-state.json +26 -67
  72. package/src/claude/skills/specs/templates/task.md +30 -82
  73. package/src/claude/skills/sync/SKILL.md +45 -15
  74. package/src/claude/skills/sync/references/sync-protocols.md +34 -16
  75. package/src/claude/skills/test/SKILL.md +151 -167
  76. package/src/codex/AGENTS.md +6 -0
  77. package/src/codex/hooks/completion-authority-check.cjs +63 -0
  78. package/src/codex/hooks/completion-authority-state.cjs +527 -0
  79. package/src/codex/hooks/completion-authority.cjs +106 -0
  80. package/src/codex/hooks/lib/hook-context.cjs +58 -4
  81. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  82. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  83. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  84. package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
  85. package/src/codex/hooks/lib/spec-utils.cjs +104 -27
  86. package/src/codex/hooks/privacy-approval.cjs +1 -1
  87. package/src/codex/hooks/privacy-block.cjs +67 -11
  88. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  89. package/src/codex/hooks/session.cjs +2 -0
  90. package/src/codex/hooks/spec-gate.cjs +151 -24
  91. package/src/codex/hooks/spec-state.cjs +65 -12
  92. package/src/codex/hooks/state.cjs +2 -0
  93. package/src/codex/hooks.json +16 -1
  94. package/src/codex/rules/state-sync.md +47 -27
  95. package/src/common/AGENTS.md +1 -1
  96. package/src/opencode/AGENTS.md +4 -2
  97. package/src/opencode/plugins/spec-gate.ts +296 -122
  98. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -6,18 +6,263 @@ const fs = require('node:fs');
6
6
  const os = require('node:os');
7
7
  const path = require('node:path');
8
8
  const { spawnSync } = require('node:child_process');
9
- const { test } = require('node:test');
9
+ const { test, after } = require('node:test');
10
10
 
11
11
  const PACKAGE_ROOT = path.join(__dirname, '../..');
12
12
  const POLICY_PATH = path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs');
13
13
  const POLICY = require(POLICY_PATH);
14
+ const PROVENANCE_HELPER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'));
15
+ const FINAL_STATE = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-final-state.cjs'));
16
+ const VALIDATOR = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/validate-spec-output.cjs'));
17
+ const GROUNDER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-ground.cjs'));
18
+ const SEMANTIC_AUTHORITY = require(path.join(PACKAGE_ROOT, 'src/claude/hooks/semantic-review-authority.cjs'));
19
+ const READINESS = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-readiness.cjs'));
20
+ const { copyClaudeTestRuntime } = require('./test-runtime-dependency-closure.cjs');
14
21
  const DEVELOP = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/SKILL.md');
22
+ const SPECS = path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md');
23
+ const SCAFFOLD = path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-scaffold.cjs');
15
24
  const GATE = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/references/quality-gate.md');
16
25
  const TEST_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/test/SKILL.md');
17
26
  const SYNC_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/sync/SKILL.md');
18
27
  const CODE_REVIEW_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/code-review/SKILL.md');
19
28
  const PARALLEL_WAVES = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/references/parallel-waves.md');
20
29
  const PROVENANCE = path.join(PACKAGE_ROOT, '../../docs/provenance.md');
30
+ const RUNTIME_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-policy-runtime-'));
31
+ const RUNTIME_SPEC = path.join(RUNTIME_ROOT, 'specs', 'demo', 'spec.json');
32
+ fs.mkdirSync(path.dirname(RUNTIME_SPEC), { recursive: true });
33
+ fs.mkdirSync(path.join(RUNTIME_ROOT, 'src'), { recursive: true });
34
+ fs.writeFileSync(path.join(RUNTIME_ROOT, 'src', 'app.js'), 'runtime fixture\n');
35
+ fs.writeFileSync(RUNTIME_SPEC, JSON.stringify({ status: 'in_progress', feature_name: 'demo', task_registry: {} }));
36
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['add', '-A'], ['commit', '-qm', 'fixture']]) {
37
+ const result = spawnSync('git', ['-C', RUNTIME_ROOT, ...args], { encoding: 'utf8' });
38
+ assert.equal(result.status, 0, result.stderr);
39
+ }
40
+ const RUNTIME_CONTEXT = PROVENANCE_HELPER.deriveRuntimeContext({
41
+ projectRoot: RUNTIME_ROOT,
42
+ specsRoot: path.join(RUNTIME_ROOT, 'specs'),
43
+ specFile: RUNTIME_SPEC,
44
+ featureName: 'demo',
45
+ runtimeSession: 'implementation-session-1',
46
+ });
47
+ after(() => fs.rmSync(RUNTIME_ROOT, { recursive: true, force: true }));
48
+ const EXPECTED_BASE = RUNTIME_CONTEXT.base;
49
+ const EXPECTED_HEAD = RUNTIME_CONTEXT.head;
50
+ const EXPECTED_PROVENANCE = { base: EXPECTED_BASE, head: EXPECTED_HEAD };
51
+ const RECEIPT_BINDING = POLICY.createReceiptBinding(RUNTIME_CONTEXT);
52
+
53
+ function initFixtureGit(root) {
54
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['add', '-A'], ['commit', '-qm', 'fixture']]) {
55
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
56
+ assert.equal(result.status, 0, result.stderr);
57
+ }
58
+ }
59
+
60
+ function installClaudeRuntimeClosure(fixtureRoot, entry = 'hooks/spec-gate.cjs') {
61
+ const destinationRoot = path.join(fixtureRoot, '.claude');
62
+ copyClaudeTestRuntime(PACKAGE_ROOT, destinationRoot);
63
+ const hooksRoot = path.join(PACKAGE_ROOT, 'src', 'claude', 'hooks');
64
+ for (const hook of [
65
+ 'spec-gate.cjs',
66
+ 'completion-authority-check.cjs',
67
+ 'completion-authority-state.cjs',
68
+ 'semantic-review-authority.cjs',
69
+ ]) {
70
+ const target = path.join(destinationRoot, 'hooks', hook);
71
+ fs.mkdirSync(path.dirname(target), { recursive: true });
72
+ fs.copyFileSync(path.join(hooksRoot, hook), target);
73
+ }
74
+ return path.join(destinationRoot, entry);
75
+ }
76
+
77
+ function markdownSection(content, heading) {
78
+ const marker = `## ${heading}`;
79
+ const start = content.indexOf(marker);
80
+ assert.notEqual(start, -1, `missing section ${heading}`);
81
+ const rest = content.slice(start + marker.length);
82
+ const end = rest.search(/\n## /);
83
+ return end === -1 ? rest : rest.slice(0, end);
84
+ }
85
+
86
+ function markdownTable(section, firstHeader) {
87
+ const lines = section.split('\n').filter((line) => line.trim().startsWith('|'));
88
+ const cells = (line) => line.split('|').slice(1, -1).map((cell) => cell.trim());
89
+ const headerIndex = lines.findIndex((line) => cells(line)[0] === firstHeader);
90
+ assert.notEqual(headerIndex, -1, `missing table headed by ${firstHeader}`);
91
+ const headers = cells(lines[headerIndex]);
92
+ const rows = {};
93
+ for (const line of lines.slice(headerIndex + 2)) {
94
+ const values = cells(line);
95
+ if (values.length !== headers.length) break;
96
+ rows[values[0].replaceAll('`', '')] = Object.fromEntries(headers.map((header, index) => [header, values[index]]));
97
+ }
98
+ return rows;
99
+ }
100
+
101
+ function runNode(root, script, args) {
102
+ return spawnSync(process.execPath, [script, ...args], { cwd: root, encoding: 'utf8' });
103
+ }
104
+
105
+ function commandOutput(result) {
106
+ return `${result.stdout}\n${result.stderr}`;
107
+ }
108
+
109
+ function createCanonicalAuthoringFixture() {
110
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-develop-contract-'));
111
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
112
+ fs.mkdirSync(path.join(root, 'test'), { recursive: true });
113
+ fs.mkdirSync(path.join(root, 'artifacts'), { recursive: true });
114
+ fs.writeFileSync(path.join(root, 'src', 'entry.js'), 'module.exports = { enabled: true };\n');
115
+ fs.writeFileSync(path.join(root, 'test', 'service.test.js'), 'require("node:test")("service", () => {});\n');
116
+ const scaffold = runNode(root, SCAFFOLD, ['feature']);
117
+ assert.equal(scaffold.status, 0, commandOutput(scaffold));
118
+ const specDir = path.join(root, 'specs', 'feature');
119
+ const specPath = path.join(specDir, 'spec.json');
120
+ fs.writeFileSync(path.join(specDir, 'requirements.md'), `# Requirements
121
+
122
+ ## Requirements
123
+ ### Requirement 1: Observable service behavior
124
+ - **R1.1**: When valid input arrives, the service shall return enabled state; invalid input shall return rejected state.
125
+ - **R1.2**: The verifier shall produce separate observable proof for enabled and rejected states.
126
+ `);
127
+ fs.writeFileSync(path.join(specDir, 'design.md'), `# Design
128
+
129
+ ## Architecture
130
+ The entrypoint exposes the bounded service behavior and a separate proof artifact.
131
+
132
+ ## Typed Anchors
133
+ | ID | Type | Target | Role | Access | Action |
134
+ |---|---|---|---|---|---|
135
+ | A-D-01 | file | \`src/entry.js\` | service boundary | read | read |
136
+ | A-D-02 | artifact | \`artifacts/design-proof.json\` | proof evidence | write | create |
137
+
138
+ ## Canonical Contracts & Invariants
139
+ ### D1 — Dispatch decision
140
+ The entrypoint dispatches exactly once.
141
+ ### I1 — Invalid-state invariant
142
+ Invalid input never returns enabled state.
143
+ ### C1 — Result contract
144
+ The result contains one enabled boolean.
145
+
146
+ ## Verification Definitions
147
+ - **V1**: Subject criteria R1.1; Subject owner A-D-01; Proof criteria R1.2; Proof owner A-D-02; Evidence anchor A-D-02; Decision refs D1, I1, C1; Method command \`node --test test/service.test.js\`; Expected exit 0 with enabled state and separate proof evidence; Negative/failure invalid input returns rejected state without enabled output; Reachability/grounding entrypoint \`src/entry.js\` via A-D-01, A-D-02.
148
+ `);
149
+ return { root, specDir, specPath };
150
+ }
151
+
152
+ function createOrdinaryAuthoringFixture() {
153
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-ordinary-readiness-'));
154
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
155
+ fs.mkdirSync(path.join(root, 'test'), { recursive: true });
156
+ fs.writeFileSync(path.join(root, 'src', 'entry.js'), 'module.exports = { action: "delete" };\n');
157
+ fs.writeFileSync(path.join(root, 'test', 'entry.test.js'), 'require("node:test")("entry", () => {});\n');
158
+ const scaffold = runNode(root, SCAFFOLD, ['ordinary']);
159
+ assert.equal(scaffold.status, 0, commandOutput(scaffold));
160
+ const specDir = path.join(root, 'specs', 'ordinary');
161
+ const specPath = path.join(specDir, 'spec.json');
162
+ fs.writeFileSync(path.join(specDir, 'requirements.md'), `# Requirements
163
+
164
+ ## Requirements
165
+ ### Requirement 1: Delete behavior
166
+ - **R1.1**: When deletion is requested, the service shall delete the selected record and report deleted state.
167
+ `);
168
+ fs.writeFileSync(path.join(specDir, 'design.md'), `# Design
169
+
170
+ ## Typed Anchors
171
+ | ID | Type | Target | Role | Access | Action |
172
+ |---|---|---|---|---|---|
173
+ | A-D-01 | file | \`src/entry.js\` | behavior owner | read | read |
174
+
175
+ ## Canonical Contracts & Invariants
176
+ ### D1 — Delete decision
177
+ The selected record is deleted rather than archived.
178
+ ### I1 — State invariant
179
+ Deleted state is reported only after deletion succeeds.
180
+ ### C1 — Result contract
181
+ The result contains the literal deleted state.
182
+
183
+ ## Verification Definitions
184
+ - **V1**: Criteria R1.1; Owner A-D-01; Decision refs D1, I1, C1; Method command \`node --test test/entry.test.js\`; Expected exit 0 and the service reports deleted state; Negative/failure a missing record returns not-found without deleted state; Reachability/grounding entrypoint \`src/entry.js\` via A-D-01.
185
+ `);
186
+ const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
187
+ spec.authoring.requirements = 'validated';
188
+ spec.authoring.design = 'validated';
189
+ fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
190
+ initFixtureGit(root);
191
+ return { root, specDir, specPath };
192
+ }
193
+
194
+ function ordinaryReview() {
195
+ return {
196
+ reviewed_criteria: ['R1.1'],
197
+ counterexamples: [{
198
+ criterion: 'R1.1', case_kind: 'failure',
199
+ scenario: 'Deletion targets a record that does not exist in the current store.',
200
+ expected: 'The service returns not-found and never reports the record as deleted.',
201
+ decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
202
+ }],
203
+ };
204
+ }
205
+
206
+ function promoteCanonicalModel(fixture) {
207
+ const result = runNode(fixture.root, SCAFFOLD, ['feature', '--sync-semantic-model']);
208
+ assert.equal(result.status, 0, commandOutput(result));
209
+ }
210
+
211
+ function makeCanonicalReady(fixture) {
212
+ const spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
213
+ spec.authoring.requirements = 'validated';
214
+ spec.authoring.design = 'validated';
215
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
216
+ const result = READINESS.finalizeReadiness({
217
+ specDir: fixture.specDir,
218
+ projectRoot: fixture.root,
219
+ reviewResult: {
220
+ reviewed_criteria: ['R1.1', 'R1.2'],
221
+ counterexamples: [{
222
+ criterion: 'R1.1', case_kind: 'failure',
223
+ scenario: 'The service receives invalid input without its required discriminator.',
224
+ expected: 'The service returns rejected state and never emits enabled true.',
225
+ decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
226
+ }, {
227
+ criterion: 'R1.2', case_kind: 'adversarial',
228
+ scenario: 'The service reports enabled state without producing separate proof evidence.',
229
+ expected: 'Verification fails because the independent proof artifact is absent.',
230
+ decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
231
+ }],
232
+ },
233
+ });
234
+ return result.spec;
235
+ }
236
+
237
+ function bindFixtureReceipt(root, value, feature = 'demo', session = 'test') {
238
+ const context = PROVENANCE_HELPER.deriveRuntimeContext({
239
+ projectRoot: root,
240
+ specsRoot: path.join(root, 'specs'),
241
+ specFile: path.join(root, 'specs', feature, 'spec.json'),
242
+ featureName: feature,
243
+ runtimeSession: session,
244
+ });
245
+ return value.replaceAll('0123456789abcdef0123456789abcdef01234567', context.base)
246
+ .replaceAll('89abcdef0123456789abcdef0123456789abcdef', context.head);
247
+ }
248
+
249
+ function canonicalReceipt(command = 'pnpm test', extra = '') {
250
+ return `Verification: PASS\nCommand: ${command}\nExit: 0\nBase: ${EXPECTED_BASE}\nHead: ${EXPECTED_HEAD}\n${extra}`;
251
+ }
252
+
253
+ function canonicalFlashTask(overrides = {}) {
254
+ return {
255
+ status: 'in_progress',
256
+ receipt: 'FLASH_UNVERIFIED',
257
+ blocker: 'awaiting /hapo:test <feature>',
258
+ dependencyBlocked: true,
259
+ unblocks: false,
260
+ feature_name: 'demo',
261
+ runtime_context: RUNTIME_CONTEXT,
262
+ expected_provenance: { ...EXPECTED_PROVENANCE },
263
+ ...overrides,
264
+ };
265
+ }
21
266
 
22
267
  function read(filePath) {
23
268
  return fs.readFileSync(filePath, 'utf8');
@@ -56,16 +301,17 @@ test('develop pre-state guard names executable policy contract', () => {
56
301
  assert.match(develop, /flash\+parallel fail-fast/i);
57
302
  });
58
303
 
59
- test('delegation plan uses only shipped agents and exact Deep sequence', () => {
60
- const shipped = new Set(manifestAgents());
61
- assert.deepEqual(POLICY.delegationPlan({ tier: 'Light', mode: 'full-spec', taskCount: 2 }).delegated, []);
62
- assert.deepEqual(POLICY.delegationPlan({ tier: 'Standard', mode: 'specific-task' }).delegated, ['code-auditor']);
63
- const deep = POLICY.delegationPlan({ tier: 'Deep', mode: 'full-spec', taskCount: 2 }).delegated;
64
- assert.deepEqual(deep, [
65
- 'inspector', 'implementer', 'test-runner', 'code-auditor',
66
- 'inspector', 'implementer', 'test-runner', 'code-auditor',
67
- ]);
68
- for (const agent of deep) assert.ok(shipped.has(agent), `Deep agent is not shipped: ${agent}`);
304
+ test('delegation plan consumes legacy tiers as obligations without an agent chain', () => {
305
+ const light = POLICY.delegationPlan({ tier: 'Light', mode: 'full-spec', taskCount: 2 });
306
+ const standard = POLICY.delegationPlan({ tier: 'Standard', mode: 'specific-task' });
307
+ const deep = POLICY.delegationPlan({ tier: 'Deep', mode: 'full-spec', taskCount: 2 });
308
+ assert.deepEqual(light.proof_obligations, ['needsExecutionProof']);
309
+ assert.deepEqual(standard.proof_obligations, ['needsExecutionProof']);
310
+ assert.deepEqual(deep.proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit']);
311
+ for (const plan of [light, standard, deep]) {
312
+ assert.equal(Object.hasOwn(plan, 'delegated'), false);
313
+ assert.doesNotMatch(JSON.stringify(plan), /inspector|implementer|test-runner|code-auditor/);
314
+ }
69
315
  assert.doesNotMatch(read(GATE), /spec-review|quality-review/);
70
316
  assert.doesNotMatch(read(DEVELOP), /spec-review|quality-review/);
71
317
  });
@@ -75,42 +321,277 @@ test('lane classifier selects Direct for explicit reversible low-risk work', ()
75
321
  assert.equal(result.lane, 'Direct');
76
322
  assert.equal(result.automaticLane, 'Direct');
77
323
  assert.deepEqual(result.risks, []);
78
- assert.deepEqual(POLICY.delegationPlan({ lane: 'Direct' }).delegated, []);
79
- assert.equal(POLICY.lanePolicy(result).requiresSpec, false);
324
+ const policy = POLICY.lanePolicy(result);
325
+ assert.deepEqual(policy.proof_obligations, ['needsExecutionProof']);
326
+ assert.equal(policy.artifact_profile, 'targeted');
327
+ assert.equal(policy.requiresSpec, false);
328
+ assert.equal(policy.requiresState, false);
329
+ assert.equal(policy.proof_obligations.includes('needsDurableTaskState'), false);
80
330
  });
81
331
 
82
- test('lane classifier forces Critical for named high-risk signals', () => {
332
+ test('lane classifier raises named risks to Elevated without inventing independent-audit authority', () => {
83
333
  const result = POLICY.classifyLane({
84
334
  reversible: true,
85
335
  riskSignals: { auth: true, migration: true, publicContract: true },
86
336
  });
87
- assert.equal(result.lane, 'Critical');
337
+ assert.equal(result.lane, 'Standard');
338
+ assert.equal(result.assuranceLevel, 'Elevated');
339
+ assert.equal(result.automaticAssuranceLevel, 'Elevated');
88
340
  assert.deepEqual(result.risks, ['auth', 'migration', 'publicContract']);
89
- assert.deepEqual(POLICY.delegationPlan({ lane: 'Critical' }).delegated, [
90
- 'inspector', 'implementer', 'test-runner', 'code-auditor',
341
+ assert.deepEqual(POLICY.lanePolicy(result).proof_obligations, [
342
+ 'needsInspection', 'needsExecutionProof',
91
343
  ]);
344
+
345
+ const strict = POLICY.classifyLane({
346
+ riskSignals: { auth: true },
347
+ assurance_level: 'Strict',
348
+ });
349
+ assert.equal(strict.lane, 'Critical');
350
+ assert.equal(strict.automaticLane, 'Standard');
351
+ assert.equal(strict.assuranceLevel, 'Strict');
352
+ assert.equal(strict.automaticAssuranceLevel, 'Elevated');
353
+ assert.ok(POLICY.lanePolicy(strict).proof_obligations.includes('needsIndependentAudit'));
92
354
  });
93
355
 
94
- test('lane override keeps automatic result and surfaces downgrade warning', () => {
95
- const result = POLICY.classifyLane({
96
- reversible: true,
97
- riskSignals: { privacy: true },
356
+ test('P1 persists the minimal v2.1 policy and derives compatibility views', () => {
357
+ const initial = POLICY.persistWorkflowPolicySnapshot(
358
+ { feature_name: 'bounded' },
359
+ { riskSignals: {} },
360
+ );
361
+ const snapshot = initial.workflow_policy;
362
+ assert.deepEqual(Object.keys(snapshot).sort(), [...POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS].sort());
363
+ assert.equal(snapshot.version, POLICY.CANONICAL_WORKFLOW_POLICY_VERSION);
364
+ assert.equal(snapshot.planning_depth, 'Compact');
365
+ assert.equal(snapshot.assurance_level, 'Routine');
366
+ assert.deepEqual(snapshot.classified_minimum, {
367
+ planning_depth: 'Compact',
368
+ assurance_level: 'Routine',
369
+ });
370
+ for (const derivedField of ['lane', 'automatic_lane', 'artifact_profile', 'proof_obligations', 'actor_needs']) {
371
+ assert.equal(Object.hasOwn(snapshot, derivedField), false, `${derivedField} must not be persisted`);
372
+ }
373
+ const view = POLICY.readWorkflowPolicySnapshot(initial);
374
+ assert.equal(view.lane, 'Standard');
375
+ assert.equal(view.automatic_lane, 'Standard');
376
+ assert.equal(view.artifact_profile, 'bounded');
377
+ assert.deepEqual(view.proof_obligations, ['needsExecutionProof']);
378
+ assert.equal(Object.hasOwn(initial, 'override_' + 'receipt'), false);
379
+ assert.equal(POLICY.validateWorkflowPolicySnapshot(snapshot).valid, true);
380
+
381
+ const persistedAgain = POLICY.persistWorkflowPolicySnapshot(initial, {
382
+ riskSignals: { auth: true },
98
383
  override: 'Direct',
99
- userAuthorized: true,
100
384
  });
101
- assert.equal(result.lane, 'Direct');
102
- assert.equal(result.automaticLane, 'Critical');
103
- assert.equal(result.overridden, true);
104
- assert.match(result.warnings.join(' '), /downgrad|review and evidence coverage/i);
105
- assert.ok(POLICY.lanePolicy(result).warnings.length >= 2);
385
+ assert.deepEqual(persistedAgain.workflow_policy, snapshot, 'persist-once must not reclassify an existing snapshot');
386
+
387
+ const malformed = { ...snapshot, lane: 'Critical' };
388
+ const validation = POLICY.validateWorkflowPolicySnapshot(malformed);
389
+ assert.equal(validation.valid, false);
390
+ assert.match(validation.errors.join('; '), /v2\.1 fields must be exactly/);
391
+ assert.throws(() => POLICY.readWorkflowPolicySnapshot({ workflow_policy: malformed }), /Invalid workflow policy snapshot/);
392
+
393
+ const malformedShape = { ...snapshot, classified_minimum: { planning_depth: 'Compact' } };
394
+ const shapeValidation = POLICY.validateWorkflowPolicySnapshot(malformedShape);
395
+ assert.equal(shapeValidation.valid, false);
396
+ assert.match(shapeValidation.errors.join('; '), /classified_minimum must contain exactly/);
397
+
398
+ const malformedRisks = { ...snapshot, risks: { auth: true } };
399
+ const risksValidation = POLICY.validateWorkflowPolicySnapshot(malformedRisks);
400
+ assert.equal(risksValidation.valid, false);
401
+ assert.match(risksValidation.errors.join('; '), /risks must be an array/);
106
402
  });
107
403
 
108
- test('unsafe downgrade without user authorization is blocked', () => {
109
- assert.throws(() => POLICY.classifyLane({
110
- reversible: true,
111
- riskSignals: { privacy: true },
112
- override: 'Direct',
113
- }), /requires explicit user authorization/);
404
+ test('P1 workflow-policy validation is semantic and explicit workflow_policy values never reclassify', () => {
405
+ const direct = POLICY.workflowPolicySnapshot({ reversible: true, lowRisk: true, isolated: true });
406
+ const standardUnknown = POLICY.escalateWorkflowPolicy(direct, { risks: ['unclassified-risk'] });
407
+ const elevatedAuth = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
408
+ const escalatedElevated = POLICY.escalateWorkflowPolicy(direct, { risks: ['auth'] });
409
+
410
+ for (const legitimate of [direct, standardUnknown, elevatedAuth, escalatedElevated]) {
411
+ assert.equal(POLICY.validateWorkflowPolicySnapshot(legitimate).valid, true);
412
+ }
413
+ assert.equal(escalatedElevated.lane, 'Standard');
414
+ assert.equal(escalatedElevated.automatic_lane, 'Standard');
415
+ assert.equal(escalatedElevated.planning_depth, 'None');
416
+ assert.equal(escalatedElevated.assurance_level, 'Elevated');
417
+
418
+ const forgedDirectAuth = { ...direct, risks: ['auth'] };
419
+ const forgedDirectUnknown = { ...direct, risks: ['unclassified-risk'] };
420
+ const forgedStandardAuth = { ...standardUnknown, risks: ['auth'] };
421
+ for (const forged of [forgedDirectAuth, forgedDirectUnknown]) {
422
+ const result = POLICY.validateWorkflowPolicySnapshot(forged);
423
+ assert.equal(result.valid, false);
424
+ assert.match(result.errors.join('; '), /at least (Elevated|Strict)/);
425
+ }
426
+ assert.equal(
427
+ POLICY.validateWorkflowPolicySnapshot(forgedStandardAuth).valid,
428
+ true,
429
+ 'changing one normalized risk to another does not invent a higher assurance minimum',
430
+ );
431
+ for (const riskAlias of ['Auth', 'authentication', 'public_contract']) {
432
+ const escalated = POLICY.escalateWorkflowPolicy(direct, { risks: [riskAlias] });
433
+ assert.equal(escalated.lane, 'Standard', `${riskAlias} must classify as Elevated`);
434
+ assert.equal(escalated.assurance_level, 'Elevated');
435
+ assert.equal(POLICY.validateWorkflowPolicySnapshot({ ...direct, risks: [riskAlias] }).valid, false);
436
+ }
437
+ assert.throws(() => POLICY.assertWorkflowPolicySnapshot(forgedDirectAuth), /Invalid workflow policy snapshot/);
438
+ assert.throws(() => POLICY.readWorkflowPolicySnapshot({ workflow_policy: forgedDirectAuth }), /Invalid workflow policy snapshot/);
439
+ assert.throws(() => POLICY.lanePolicy({ workflow_policy: forgedDirectAuth }), /Invalid workflow policy snapshot/);
440
+
441
+ for (const explicitValue of [null, undefined]) {
442
+ const state = { workflow_policy: explicitValue, reversible: true, lowRisk: true, isolated: true };
443
+ assert.throws(() => POLICY.workflowPolicySnapshot(state), /Invalid workflow policy snapshot/);
444
+ assert.throws(() => POLICY.escalateWorkflowPolicy(state, {}), /Invalid workflow policy snapshot/);
445
+ assert.throws(() => POLICY.lanePolicy(state), /Invalid workflow policy snapshot/);
446
+ }
447
+
448
+ const forgedLanePolicyCli = spawnSync(process.execPath, [
449
+ POLICY_PATH,
450
+ '--lane-policy',
451
+ '--task-json',
452
+ JSON.stringify({ workflow_policy: forgedDirectAuth }),
453
+ '--json',
454
+ ], { encoding: 'utf8' });
455
+ assert.equal(forgedLanePolicyCli.status, 2, `${forgedLanePolicyCli.stdout}\n${forgedLanePolicyCli.stderr}`);
456
+ assert.match(forgedLanePolicyCli.stderr, /Invalid workflow policy snapshot/);
457
+
458
+ const nullLanePolicyCli = spawnSync(process.execPath, [
459
+ POLICY_PATH,
460
+ '--lane-policy',
461
+ '--task-json',
462
+ JSON.stringify({ workflow_policy: null, reversible: true, lowRisk: true, isolated: true }),
463
+ '--json',
464
+ ], { encoding: 'utf8' });
465
+ assert.equal(nullLanePolicyCli.status, 2, `${nullLanePolicyCli.stdout}\n${nullLanePolicyCli.stderr}`);
466
+ assert.match(nullLanePolicyCli.stderr, /Invalid workflow policy snapshot/);
467
+
468
+ const validatePolicyCli = spawnSync(process.execPath, [
469
+ POLICY_PATH,
470
+ '--validate-policy',
471
+ '--task-json',
472
+ JSON.stringify({ workflow_policy: forgedDirectAuth }),
473
+ '--json',
474
+ ], { encoding: 'utf8' });
475
+ assert.equal(validatePolicyCli.status, 2, `${validatePolicyCli.stdout}\n${validatePolicyCli.stderr}`);
476
+ assert.equal(JSON.parse(validatePolicyCli.stdout).valid, false);
477
+
478
+ const validateNullPolicyCli = spawnSync(process.execPath, [
479
+ POLICY_PATH,
480
+ '--validate-policy',
481
+ '--task-json',
482
+ JSON.stringify({ workflow_policy: null }),
483
+ '--json',
484
+ ], { encoding: 'utf8' });
485
+ assert.equal(validateNullPolicyCli.status, 2, `${validateNullPolicyCli.stdout}\n${validateNullPolicyCli.stderr}`);
486
+ assert.equal(JSON.parse(validateNullPolicyCli.stdout).valid, false);
487
+ });
488
+
489
+ test('P1 workflow policy escalation is monotonic across all newly classified risks', () => {
490
+ const direct = POLICY.workflowPolicySnapshot({ reversible: true, lowRisk: true, isolated: true });
491
+ assert.equal(direct.lane, 'Direct');
492
+ assert.deepEqual(POLICY.escalateWorkflowPolicy(direct, {}), direct, 'no new risks must not escalate');
493
+
494
+ const standard = POLICY.escalateWorkflowPolicy(direct, { risks: ['new-standard-risk'], riskLevel: 'standard' });
495
+ assert.equal(standard.lane, 'Standard');
496
+ assert.equal(standard.artifact_profile, 'targeted');
497
+ assert.equal(standard.planning_depth, 'None');
498
+ assert.equal(standard.assurance_level, 'Elevated');
499
+ assert.equal(standard.risks.includes('new-standard-risk'), true);
500
+ assert.deepEqual(standard.proof_obligations, ['needsInspection', 'needsExecutionProof']);
501
+
502
+ const ambiguityElevated = POLICY.escalateWorkflowPolicy(direct, { riskSignals: { ambiguity: true } });
503
+ assert.equal(ambiguityElevated.lane, 'Standard', 'risk discovery must require inspection without inventing an audit');
504
+ assert.equal(ambiguityElevated.assurance_level, 'Elevated');
505
+
506
+ const criticalSeverity = POLICY.escalateWorkflowPolicy(direct, { risks: ['payment'], riskLevel: 'critical' });
507
+ assert.equal(criticalSeverity.assurance_level, 'Elevated', 'severity labels must not create independent-audit authority');
508
+ assert.equal(criticalSeverity.proof_obligations.includes('needsIndependentAudit'), false);
509
+
510
+ const critical = POLICY.escalateWorkflowPolicy(standard, { risks: ['auth'], assurance_level: 'Strict' });
511
+ assert.equal(critical.lane, 'Critical');
512
+ assert.equal(critical.artifact_profile, 'targeted');
513
+ assert.equal(critical.planning_depth, 'None');
514
+ assert.equal(critical.assurance_level, 'Strict');
515
+ assert.equal(critical.automatic_assurance_level, 'Elevated');
516
+ assert.equal(critical.proof_obligations.includes('needsIndependentAudit'), true);
517
+ assert.equal(critical.proof_obligations.includes('needsResearchGrounding'), false);
518
+
519
+ const noDowngrade = POLICY.escalateWorkflowPolicy(critical, { risks: ['another-standard-risk'], riskLevel: 'standard' });
520
+ assert.equal(noDowngrade.lane, 'Critical');
521
+ assert.equal(noDowngrade.proof_obligations.includes('needsIndependentAudit'), true);
522
+ assert.equal(noDowngrade.proof_obligations.includes('needsResearchGrounding'), false);
523
+
524
+ const criticalAgain = POLICY.escalateWorkflowPolicy(critical, { risks: ['auth'] });
525
+ assert.deepEqual(criticalAgain, critical, 'repeating known risks must not create a new escalation');
526
+ });
527
+
528
+ test('P1 legacy execution tier is read-compatible without becoming emitted policy authority', () => {
529
+ const legacy = { design_context: { execution_tier: 'Deep' } };
530
+ const snapshot = POLICY.workflowPolicySnapshot(legacy);
531
+ assert.equal(snapshot.lane, 'Critical');
532
+ assert.equal(snapshot.artifact_profile, 'strict');
533
+ const policy = POLICY.lanePolicy(legacy);
534
+ assert.equal(policy.lane, 'Critical');
535
+ assert.equal(Object.hasOwn(policy, 'execution_tier'), false);
536
+ assert.equal(Object.hasOwn(policy, 'executionTier'), false);
537
+ const legacyPlan = POLICY.delegationPlan({ tier: 'Deep' });
538
+ assert.equal(Object.hasOwn(legacyPlan, 'execution_tier'), false);
539
+ assert.equal(Object.hasOwn(legacyPlan, 'executionTier'), false);
540
+ assert.deepEqual(legacyPlan.proof_obligations, snapshot.proof_obligations);
541
+ assert.equal(POLICY.workflowPolicySnapshot({ design_context: { execution_tier: 'standard' } }).lane, 'Standard');
542
+ });
543
+
544
+ test('legacy v1 policy is read-compatible, immutable on read, and explicitly migrates to v2.1', () => {
545
+ const cases = [
546
+ { lane: 'Direct', risks: [], obligations: ['needsExecutionProof'] },
547
+ { lane: 'Standard', risks: [], obligations: ['needsInspection', 'needsExecutionProof'] },
548
+ { lane: 'Critical', risks: ['auth'], obligations: ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit', 'needsResearchGrounding'] },
549
+ ];
550
+ for (const fixture of cases) {
551
+ const v1 = {
552
+ version: '1',
553
+ lane: fixture.lane,
554
+ automatic_lane: fixture.lane,
555
+ risks: fixture.risks,
556
+ artifact_profile: { Direct: 'targeted', Standard: 'bounded', Critical: 'strict' }[fixture.lane],
557
+ proof_obligations: fixture.obligations,
558
+ actor_needs: POLICY.actorNeedsFor(fixture.obligations),
559
+ override_receipt: null,
560
+ };
561
+ assert.equal(POLICY.validateWorkflowPolicySnapshot(v1).valid, true, fixture.lane);
562
+ const source = { feature_name: `legacy-${fixture.lane}`, workflow_policy: v1 };
563
+ const before = JSON.stringify(source);
564
+ const adapted = POLICY.readWorkflowPolicySnapshot(source);
565
+ assert.equal(adapted.version, '2');
566
+ assert.deepEqual(adapted.proof_obligations, fixture.obligations, fixture.lane);
567
+ assert.deepEqual(adapted.actor_needs, v1.actor_needs, fixture.lane);
568
+ assert.equal(POLICY.validateWorkflowPolicySnapshot(adapted).valid, true, fixture.lane);
569
+ assert.equal(JSON.stringify(source), before, 'read adapter must not mutate persisted v1 state');
570
+ const migrated = POLICY.persistWorkflowPolicySnapshot(source).workflow_policy;
571
+ assert.equal(migrated.version, POLICY.CANONICAL_WORKFLOW_POLICY_VERSION);
572
+ assert.deepEqual(Object.keys(migrated).sort(), [...POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS].sort());
573
+ const migratedView = POLICY.readWorkflowPolicySnapshot({ workflow_policy: migrated });
574
+ assert.equal(migratedView.lane, adapted.lane, fixture.lane);
575
+ assert.equal(migratedView.artifact_profile, adapted.artifact_profile, fixture.lane);
576
+ assert.deepEqual(migratedView.proof_obligations, POLICY.obligationsForAssurance(
577
+ migrated.assurance_level,
578
+ migrated.risks,
579
+ ), fixture.lane);
580
+ }
581
+ });
582
+
583
+ test('post-classification downgrade is unsupported', () => {
584
+ assert.throws(
585
+ () => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct' }),
586
+ (error) => /planning_depth downgrade/.test(error.message) && /not permitted/.test(error.message),
587
+ );
588
+ });
589
+
590
+ test('caller-requested downgrade is blocked before persistence', () => {
591
+ assert.throws(
592
+ () => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct' }),
593
+ (error) => /planning_depth downgrade/.test(error.message) && /not permitted/.test(error.message),
594
+ );
114
595
  // also via CLI
115
596
  const cli = spawnSync(process.execPath, [
116
597
  POLICY_PATH,
@@ -120,7 +601,7 @@ test('unsafe downgrade without user authorization is blocked', () => {
120
601
  '--json',
121
602
  ], { encoding: 'utf8' });
122
603
  assert.equal(cli.status, 2);
123
- assert.match(cli.stderr, /requires explicit user authorization/);
604
+ assert.match(cli.stderr, /planning_depth downgrade.*not permitted/i);
124
605
  });
125
606
 
126
607
  test('forged approval via legacy approved field is rejected', () => {
@@ -140,14 +621,14 @@ test('forged approval via legacy approved field is rejected', () => {
140
621
  assert.match(legacyCheck.error, /Legacy/);
141
622
  });
142
623
 
143
- test('approval state never infers user approval from generated or agent validation', () => {
624
+ test('technical approval readiness ignores legacy user_approved', () => {
144
625
  const state = POLICY.approvalState({ generated: true, agent_validated: true });
145
626
  assert.equal(state.generated, true);
146
627
  assert.equal(state.agent_validated, true);
147
- assert.equal(state.user_approved, false);
148
- assert.equal(state.ready, false);
628
+ assert.equal(state.ready, true);
149
629
  assert.equal(state.schema_version, '2.0');
150
630
  assert.equal(POLICY.approvalState({ generated: true, agent_validated: true, user_approved: true }).ready, true);
631
+ assert.equal(POLICY.approvalState({ generated: true, agent_validated: true, user_approved: false }).ready, true);
151
632
  const cli = spawnSync(process.execPath, [
152
633
  POLICY_PATH,
153
634
  '--approval-state',
@@ -156,10 +637,33 @@ test('approval state never infers user approval from generated or agent validati
156
637
  '--json',
157
638
  ], { encoding: 'utf8' });
158
639
  assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
159
- assert.equal(JSON.parse(cli.stdout).state.user_approved, false);
640
+ assert.equal(JSON.parse(cli.stdout).state.ready, true);
160
641
  });
161
642
 
162
- test('CLI exposes JSON lane classification and develop has no universal spec hard gate', () => {
643
+ test('approval schema fails closed for explicit null, array, empty, and malformed states', () => {
644
+ const absent = POLICY.validateApprovalSchema({});
645
+ assert.equal(absent.valid, true);
646
+ assert.equal(absent.absent, true);
647
+ assert.equal(POLICY.approvalState({}).present, false);
648
+
649
+ const invalidSpecs = [
650
+ [],
651
+ { approvals: null },
652
+ { approvals: [] },
653
+ { approvals: {} },
654
+ { approvals: { requirements: null } },
655
+ { approvals: { requirements: { generated: true, agent_validated: true, user_approved: null } } },
656
+ { approvals: { requirements: { generated: true, agent_validated: true, user_approved: true, extra: false } } },
657
+ ];
658
+ for (const spec of invalidSpecs) {
659
+ assert.equal(POLICY.validateApprovalSchema(spec).valid, false, `invalid approval should fail: ${JSON.stringify(spec)}`);
660
+ assert.throws(() => POLICY.approvalState(spec), /approval|approvals|schema/i);
661
+ }
662
+ assert.throws(() => POLICY.approvalState({ schema_version: null }), /schema_version/);
663
+ assert.throws(() => POLICY.approvalState({ schema_version: '1.0' }), /schema_version/);
664
+ });
665
+
666
+ test('CLI exposes a derived lane and Develop consumes independent canonical policy axes', () => {
163
667
  const cli = spawnSync(process.execPath, [
164
668
  POLICY_PATH,
165
669
  '--classify-lane',
@@ -173,40 +677,383 @@ test('CLI exposes JSON lane classification and develop has no universal spec har
173
677
  assert.equal(payload.policy.requiresSpec, false);
174
678
  const develop = read(DEVELOP);
175
679
  assert.doesNotMatch(develop, /DO NOT write implementation code until an approved spec exists/i);
176
- assert.match(develop, /Direct.*bypass spec\/state/i);
177
- assert.match(develop, /Standard.*approved.*spec/i);
178
- assert.match(develop, /Critical.*strict evidence/i);
179
- assert.match(develop, /C --> D\s*$/m);
680
+ const policySection = develop.split('## Policy authority')[1]?.split('\n## ')[0] || '';
681
+ for (const authorityTerm of ['planning_depth', 'assurance_level', 'Risk', 'derived']) {
682
+ assert.ok(policySection.includes(authorityTerm), `policy authority missing ${authorityTerm}`);
683
+ }
684
+ for (const compatibilityView of ['**Direct**', '**Standard**', '**Critical**']) {
685
+ assert.ok(policySection.includes(compatibilityView), `missing derived view ${compatibilityView}`);
686
+ }
687
+ assert.ok(policySection.includes('derived Strict view'));
688
+ assert.ok(policySection.includes('Legacy approval fields'));
689
+ assert.ok(policySection.replace(/\s+/g, ' ').includes('zero authority'));
690
+ assert.match(develop, /Classify lane[\s\S]*test receipt[\s\S]*docs-impact sync/i);
180
691
  assert.doesNotMatch(develop, /D2\[Step 3/);
181
- assert.match(read(path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md')), /requirements\.md.*design\.md.*current layout/i);
692
+ assert.match(develop, /--notes.*opt-in/i);
693
+ assert.doesNotMatch(develop, /--no-notes/);
694
+ assert.doesNotMatch(develop, /planner|implementer|test-runner|code-auditor|docs-keeper/i);
695
+ const fullRoutine = POLICY.readWorkflowPolicySnapshot({
696
+ workflow_policy: POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Full', assurance_level: 'Routine' }),
697
+ });
698
+ const compactStrict = POLICY.readWorkflowPolicySnapshot({
699
+ workflow_policy: POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' }),
700
+ });
701
+ assert.equal(fullRoutine.planning_depth, 'Full');
702
+ assert.equal(fullRoutine.assurance_level, 'Routine');
703
+ assert.equal(compactStrict.planning_depth, 'Compact');
704
+ assert.equal(compactStrict.assurance_level, 'Strict');
705
+ assert.notEqual(fullRoutine.artifact_profile, compactStrict.artifact_profile);
706
+ assert.notDeepEqual(fullRoutine.proof_obligations, compactStrict.proof_obligations);
182
707
  });
183
708
 
184
- test('review verdict consumer handles PASS, FAIL, and BLOCKED', () => {
185
- assert.deepEqual(POLICY.consumeReviewVerdict('PASS'), { action: 'proceed', terminal: false });
186
- assert.deepEqual(POLICY.consumeReviewVerdict('FAIL'), { action: 'fix-and-rerun', terminal: false });
187
- assert.deepEqual(POLICY.consumeReviewVerdict('BLOCKED'), {
188
- action: 'stop',
189
- terminal: true,
190
- blocker: 'review returned BLOCKED',
191
- });
192
- assert.throws(() => POLICY.consumeReviewVerdict('NO_TESTS'), /Unsupported review verdict/);
709
+ test('artifact router keeps optional expansion bound to uncertainty and typed topology', () => {
710
+ const router = markdownSection(read(SPECS), 'Artifact router');
711
+ const rows = markdownTable(router, 'Depth');
712
+ assert.deepEqual(Object.keys(rows), ['None', 'Compact', 'Full']);
713
+ assert.equal(rows.None['Durable core'], 'none');
714
+ for (const depth of ['Compact', 'Full']) {
715
+ assert.deepEqual(
716
+ rows[depth]['Durable core'].match(/`[^`]+`/g),
717
+ ['`spec.json`', '`requirements.md`', '`design.md`'],
718
+ `${depth} must share the canonical three-file core`,
719
+ );
720
+ assert.equal(rows[depth]['Research route'], 'only on a research trigger');
721
+ assert.equal(rows[depth]['Task route'], 'only on a typed topology trigger');
722
+ }
723
+ assert.notEqual(rows.Compact['Design expansion'], rows.Full['Design expansion']);
724
+ for (const falseTrigger of ['Requirement count', 'size', 'planning depth', 'assurance level', 'risk label', 'architectural layers']) {
725
+ assert.ok(router.includes(falseTrigger), `router must name inert trigger ${falseTrigger}`);
726
+ }
727
+ for (const boundary of ['ownership', 'dependency', 'transition', 'proof', 'parallel']) {
728
+ assert.ok(router.includes(boundary), `router must name typed topology ${boundary}`);
729
+ }
730
+ assert.ok(router.includes('unresolved material uncertainty'));
731
+ assert.ok(router.includes('external-current fact'));
732
+ assert.ok(router.includes('explicit user request'));
733
+ });
734
+
735
+ test('canonical verdict adapter handles completion and unfinished decisions', () => {
736
+ assert.deepEqual(POLICY.REVIEW_VERDICTS, ['PASS', 'PASS_WITH_WARNINGS', 'FAIL', 'BLOCKED']);
737
+ assert.doesNotThrow(() => POLICY.assertVerdict('PASS_WITH_WARNINGS'));
738
+ assert.throws(() => POLICY.assertVerdict('PARTIAL'), /Unsupported canonical verdict/);
739
+ assert.equal(POLICY.consumeReviewVerdict('PASS').completion, 'unfinished');
740
+ assert.equal(POLICY.consumeReviewVerdict('PASS').unfinished, true);
741
+ assert.equal(POLICY.consumeReviewVerdict('PASS').createsCompletion, false);
742
+ assert.equal(POLICY.consumeReviewVerdict('PASS_WITH_WARNINGS').unfinished, true);
743
+ assert.equal(POLICY.consumeReviewVerdict('FAIL').retry, true);
744
+ assert.equal(POLICY.consumeReviewVerdict('BLOCKED').retry, false);
745
+ assert.equal(POLICY.consumeReviewVerdict('BLOCKED').unfinished, true);
746
+ assert.equal(POLICY.consumeReviewVerdict('PARTIAL').completion, 'unfinished');
747
+ assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').completion, 'unfinished');
748
+ assert.equal(POLICY.consumeReviewVerdict('PARTIAL').verdict, 'BLOCKED');
749
+ assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').verdict, 'BLOCKED');
750
+ assert.equal(POLICY.consumeReviewVerdict('PARTIAL').retry, false);
751
+ assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').retry, false);
752
+ assert.throws(() => POLICY.consumeReviewVerdict('UNKNOWN'), /Unsupported canonical verdict/);
193
753
  const cli = spawnSync(process.execPath, [POLICY_PATH, '--consume-verdict', '--verdict', 'BLOCKED', '--json'], { encoding: 'utf8' });
194
754
  assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
195
755
  assert.deepEqual(JSON.parse(cli.stdout).action, 'stop');
196
- assert.match(read(GATE), /PASS \| FAIL \| BLOCKED/);
197
- assert.match(read(CODE_REVIEW_SKILL), /PASS \| FAIL \| BLOCKED/);
198
- assert.match(read(TEST_SKILL), /PASS.*FAIL.*BLOCKED/);
756
+ assert.match(read(GATE), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
757
+ assert.match(read(CODE_REVIEW_SKILL), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
758
+ assert.match(read(TEST_SKILL), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
199
759
  });
200
760
 
201
- test('flash PASS promotes proof without completion, finalize alone completes', () => {
202
- const initial = {
203
- status: 'in_progress',
204
- receipt: 'FLASH_UNVERIFIED',
205
- blocker: 'awaiting /hapo:test <feature>',
206
- dependencyBlocked: true,
207
- unblocks: false,
761
+ test('completion decision requires canonical execution receipt and every workflow obligation', () => {
762
+ const receipt = canonicalReceipt();
763
+ const critical = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' });
764
+ const audit = {
765
+ schema_version: '1',
766
+ reviewer_session_id: 'review-session-1',
767
+ implementation_session_id: 'implementation-session-1',
768
+ expected_provenance: { ...EXPECTED_PROVENANCE },
769
+ evidence: 'independent audit report for the bound change',
770
+ verdict: 'PASS',
208
771
  };
209
- for (const verdict of ['FAIL', 'BLOCKED', 'NO_TESTS']) {
772
+ const missing = POLICY.completionDecision('PASS', {
773
+ workflow_policy: critical,
774
+ receipt_binding: RECEIPT_BINDING,
775
+ execution_receipt: receipt,
776
+ proofs: {
777
+ needsInspection: { evidence: 'inspection receipt' },
778
+ needsResearchGrounding: { evidence: 'research receipt' },
779
+ },
780
+ });
781
+ assert.equal(missing.completion, 'unfinished');
782
+ assert.ok(missing.missingProof.includes('needsIndependentAudit'));
783
+ assert.equal(missing.unfinished, true);
784
+
785
+ const complete = POLICY.completionDecision('PASS', {
786
+ workflow_policy: critical,
787
+ receipt_binding: RECEIPT_BINDING,
788
+ execution_receipt: receipt,
789
+ proofs: {
790
+ needsInspection: { evidence: 'inspection receipt' },
791
+ needsResearchGrounding: { evidence: 'research receipt' },
792
+ needsIndependentAudit: audit,
793
+ },
794
+ });
795
+ assert.equal(complete.completion, 'complete');
796
+ assert.equal(complete.unfinished, false);
797
+ assert.equal(complete.warnings, undefined);
798
+ const warnings = POLICY.completionDecision('PASS_WITH_WARNINGS', {
799
+ workflow_policy: critical,
800
+ receipt_binding: RECEIPT_BINDING,
801
+ execution_receipt: receipt,
802
+ proofs: {
803
+ needsInspection: { evidence: 'inspection receipt' },
804
+ needsResearchGrounding: { evidence: 'research receipt' },
805
+ needsIndependentAudit: audit,
806
+ },
807
+ });
808
+ assert.equal(warnings.completion, 'unfinished');
809
+ assert.equal(warnings.unfinished, true);
810
+ assert.match(warnings.blocker, /literal PASS/);
811
+ assert.equal(POLICY.completionDecision('PASS', {
812
+ workflow_policy: { proof_obligations: ['needsExecutionProof'] },
813
+ receipt_binding: RECEIPT_BINDING,
814
+ execution_receipt: receipt,
815
+ }).completion, 'unfinished');
816
+ assert.equal(POLICY.completionDecision('PASS', {
817
+ workflow_policy: {},
818
+ receipt_binding: RECEIPT_BINDING,
819
+ execution_receipt: receipt,
820
+ }).completion, 'unfinished');
821
+ assert.equal(POLICY.completionDecision('PASS', {
822
+ workflow_policy: critical,
823
+ execution_receipt: receipt,
824
+ }).completion, 'unfinished', 'arbitrary Base/Head without runtime binding must not complete');
825
+ assert.equal(POLICY.completionDecision('PASS', {
826
+ workflow_policy: critical,
827
+ receipt_binding: RECEIPT_BINDING,
828
+ execution_receipt: receipt,
829
+ proofs: {
830
+ needsInspection: { evidence: 'inspection receipt' },
831
+ needsResearchGrounding: { evidence: 'research receipt' },
832
+ needsIndependentAudit: { independent: true, evidence: 'marker only', verdict: 'PASS' },
833
+ },
834
+ }).completion, 'unfinished', 'independent marker must not satisfy audit');
835
+ assert.equal(POLICY.validateIndependentAuditEvidence({ independent: true }, EXPECTED_PROVENANCE).valid, false);
836
+ assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, verdict: 'PASS_WITH_WARNINGS' }, EXPECTED_PROVENANCE).valid, false);
837
+ assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, reviewer_session_id: audit.implementation_session_id }, EXPECTED_PROVENANCE).valid, false);
838
+ assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, expected_provenance: { base: EXPECTED_BASE, head: 'fedcba9876543210fedcba9876543210fedcba98' } }, EXPECTED_PROVENANCE).valid, false);
839
+ assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, extra: 'forged' }, EXPECTED_PROVENANCE).valid, false);
840
+ assert.equal(POLICY.validateIndependentAuditEvidence([audit], EXPECTED_PROVENANCE).valid, false);
841
+ assert.equal(POLICY.validateIndependentAuditEvidence(audit, EXPECTED_PROVENANCE, RUNTIME_CONTEXT).valid, true);
842
+ assert.equal(POLICY.validateIndependentAuditEvidence(audit, EXPECTED_PROVENANCE, { ...RUNTIME_CONTEXT }).valid, false, 'forged runtime session context must not satisfy audit binding');
843
+ assert.equal(POLICY.completionDecision('PASS', {}).completion, 'unfinished');
844
+ const artifactContext = {
845
+ workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: {} }),
846
+ receipt_binding: RECEIPT_BINDING,
847
+ task_context: { artifacts: ['output/bundle.js'], expected_provenance: { ...EXPECTED_PROVENANCE } },
848
+ execution_receipt: receipt,
849
+ proofs: { needsInspection: { evidence: 'inspection receipt' } },
850
+ receipt_options: { requireArtifactHash: false, artifactPaths: [] },
851
+ };
852
+ const artifactBlocked = POLICY.completionDecision('PASS', artifactContext);
853
+ assert.equal(artifactBlocked.completion, 'unfinished', 'caller receipt options must not weaken task artifact requirements');
854
+ assert.ok(artifactBlocked.missingProof.some((item) => item.includes('artifact_hash')));
855
+ });
856
+
857
+ test('auto authoring reaches readiness only after promotion, validation, grounding, and current review', () => {
858
+ const fixture = createCanonicalAuthoringFixture();
859
+ try {
860
+ let spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
861
+ assert.equal(spec.semantic_model, null);
862
+ spec.ready_for_implementation = true;
863
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
864
+ let validation = VALIDATOR.validateSpec(fixture.specDir);
865
+ assert.ok(validation.errors.some((error) => error.includes('requires explicit promoted machine semantic authority')));
866
+
867
+ spec.ready_for_implementation = false;
868
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
869
+ promoteCanonicalModel(fixture);
870
+ spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
871
+ assert.ok(spec.semantic_model && spec.semantic_model.criteria.length === 2);
872
+ spec.ready_for_implementation = true;
873
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
874
+ validation = VALIDATOR.validateSpec(fixture.specDir);
875
+ assert.ok(validation.errors.some((error) => error.includes('readiness requires validated')));
876
+ assert.ok(validation.errors.some((error) => error.includes('requires completed semantic review')));
877
+
878
+ spec = makeCanonicalReady(fixture);
879
+ validation = VALIDATOR.validateSpec(fixture.specDir);
880
+ assert.deepEqual(validation.errors, [], validation.errors.join('\n'));
881
+ assert.equal(GROUNDER.groundSpec({ specDir: fixture.specDir, root: fixture.root, spec }).errors.length, 0);
882
+
883
+ const specs = read(SPECS);
884
+ const executableBlocks = [...specs.matchAll(/```bash\n([\s\S]*?)```/g)].map((match) => match[1]);
885
+ assert.ok(executableBlocks.every((block) => !/hapo:?develop/i.test(block)), 'Specs must never execute Develop');
886
+ const handoff = markdownSection(specs, 'Platform acceptance and handoff');
887
+ assert.ok(handoff.includes('After readiness'));
888
+ assert.ok(handoff.includes('/hapo:develop <feature>'));
889
+ assert.ok(handoff.includes('$hapo-develop <feature>'));
890
+ assert.ok(handoff.includes('must stop in Specs'));
891
+ } finally {
892
+ fs.rmSync(fixture.root, { recursive: true, force: true });
893
+ }
894
+ });
895
+
896
+ test('atomic readiness supports one-criterion Routine specs and binds semantic text', () => {
897
+ const fixture = createOrdinaryAuthoringFixture();
898
+ try {
899
+ const authored = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
900
+ authored.decisions = [
901
+ { id: 'Q1', classification: 'repository_fact', statement: 'The runtime entrypoint is src/entry.js.', status: 'grounded', evidence: 'Repository anchor A-D-01 resolves to src/entry.js.' },
902
+ { id: 'Q2', classification: 'reversible_assumption', statement: 'Use the existing direct module export for this bounded change.', status: 'recorded', evidence: 'Reversal is limited to src/entry.js and does not alter product data.' },
903
+ ];
904
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(authored, null, 2)}\n`);
905
+ const result = READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() });
906
+ assert.equal(result.spec.ready_for_implementation, true);
907
+ assert.equal(result.spec.semantic_model.criteria.length, 1);
908
+ assert.match(result.spec.semantic_model.criteria[0].text, /delete the selected record/);
909
+ assert.deepEqual(VALIDATOR.validateSpec(fixture.specDir).errors, []);
910
+
911
+ const beforeDigest = result.semantic_digest;
912
+ const design = path.join(fixture.specDir, 'design.md');
913
+ const originalDesign = fs.readFileSync(design, 'utf8');
914
+ fs.writeFileSync(design, originalDesign.replace('deleted rather than archived', 'archived rather than deleted'));
915
+ const decisionProjection = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-semantic-model.cjs')).modelFromMarkdown(fixture.specDir, result.spec);
916
+ assert.notDeepEqual(decisionProjection.model.design_records, result.spec.semantic_model.design_records);
917
+ const decisionDigest = VALIDATOR.computeSemanticDigest21(fixture.specDir, { ...result.spec, semantic_model: decisionProjection.model });
918
+ assert.equal(decisionDigest.errors.length, 0, decisionDigest.errors.join('\n'));
919
+ assert.notEqual(decisionDigest.digest, beforeDigest);
920
+ fs.writeFileSync(design, originalDesign);
921
+
922
+ const requirements = path.join(fixture.specDir, 'requirements.md');
923
+ fs.writeFileSync(requirements, fs.readFileSync(requirements, 'utf8').replace('delete the selected record', 'archive the selected record'));
924
+ const projection = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-semantic-model.cjs')).modelFromMarkdown(fixture.specDir, result.spec);
925
+ assert.notEqual(projection.model.criteria[0].text, result.spec.semantic_model.criteria[0].text);
926
+ const changed = VALIDATOR.computeSemanticDigest21(fixture.specDir, { ...result.spec, semantic_model: projection.model });
927
+ assert.equal(changed.errors.length, 0, changed.errors.join('\n'));
928
+ assert.notEqual(changed.digest, beforeDigest);
929
+ assert.ok(VALIDATOR.validateSpec(fixture.specDir).errors.some((error) => /projection|stale/.test(error)));
930
+ } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
931
+ });
932
+
933
+ test('atomic readiness preserves exact bytes on review, grounding, decision, and Strict authority failures', () => {
934
+ const fixture = createOrdinaryAuthoringFixture();
935
+ try {
936
+ const original = fs.readFileSync(fixture.specPath);
937
+ for (const mutate of [
938
+ (review) => ({ ...review, reviewed_criteria: [] }),
939
+ (review) => ({ ...review, counterexamples: [] }),
940
+ ]) {
941
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: mutate(ordinaryReview()) }), /readiness validation/);
942
+ assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
943
+ }
944
+ fs.unlinkSync(path.join(fixture.root, 'src', 'entry.js'));
945
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /grounding|validation/);
946
+ assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
947
+ fs.writeFileSync(path.join(fixture.root, 'src', 'entry.js'), 'module.exports = { action: "delete" };\n');
948
+
949
+ process.env.CAFEKIT_FINALIZE_FAIL_BEFORE_RENAME = '1';
950
+ try {
951
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /injected finalization failure/);
952
+ assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
953
+ } finally { delete process.env.CAFEKIT_FINALIZE_FAIL_BEFORE_RENAME; }
954
+
955
+ const blocked = JSON.parse(original);
956
+ blocked.decisions = [{ id: 'Q1', classification: 'user_owned', statement: 'Choose permanent deletion or retention archive behavior.', status: 'unresolved', evidence: null }];
957
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(blocked, null, 2)}\n`);
958
+ const blockedBytes = fs.readFileSync(fixture.specPath);
959
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /unresolved material user-owned decision/);
960
+ assert.equal(fs.readFileSync(fixture.specPath).equals(blockedBytes), true);
961
+
962
+ delete blocked.decisions;
963
+ blocked.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' });
964
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(blocked, null, 2)}\n`);
965
+ const strictBytes = fs.readFileSync(fixture.specPath);
966
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /Strict readiness/);
967
+ assert.equal(fs.readFileSync(fixture.specPath).equals(strictBytes), true);
968
+ } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
969
+ });
970
+
971
+ test('Strict atomic readiness succeeds only after current allowlisted host observation', () => {
972
+ const fixture = createOrdinaryAuthoringFixture();
973
+ try {
974
+ const spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
975
+ spec.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' });
976
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
977
+ const before = fs.readFileSync(fixture.specPath);
978
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /Strict readiness/);
979
+ assert.equal(fs.readFileSync(fixture.specPath).equals(before), true);
980
+
981
+ const digest = VALIDATOR.computeSemanticDigest21(fixture.specDir, spec);
982
+ assert.equal(digest.errors.length, 0, digest.errors.join('\n'));
983
+ const authorityHome = path.join(fixture.root, 'authority-home');
984
+ const claim = `CAFEKIT_SEMANTIC_REVIEW_ATTESTATION ${JSON.stringify({ feature_name: 'ordinary', spec_file: 'specs/ordinary/spec.json', semantic_digest: digest.digest, verdict: 'PASS' })}`;
985
+ const observed = spawnSync(process.execPath, [path.join(PACKAGE_ROOT, 'src/claude/hooks/semantic-review-authority.cjs')], {
986
+ cwd: fixture.root,
987
+ env: { ...process.env, HOME: authorityHome, USERPROFILE: authorityHome, PROJECT_ROOT: fixture.root },
988
+ input: JSON.stringify({ hook_event_name: 'SubagentStop', session_id: 'host-session', agent_id: 'reviewer-1', agent_type: 'code_auditor', last_assistant_message: claim, cwd: fixture.root }),
989
+ encoding: 'utf8',
990
+ });
991
+ assert.equal(observed.status, 0, observed.stderr);
992
+ const reviewFile = path.join(fixture.root, 'review.json');
993
+ fs.writeFileSync(reviewFile, `${JSON.stringify(ordinaryReview())}\n`);
994
+ const finalized = spawnSync(process.execPath, [path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-readiness.cjs'), fixture.specDir, '--review-result', reviewFile], {
995
+ cwd: fixture.root,
996
+ env: { ...process.env, HOME: authorityHome, USERPROFILE: authorityHome, PROJECT_ROOT: fixture.root },
997
+ encoding: 'utf8',
998
+ });
999
+ assert.equal(finalized.status, 0, `${finalized.stdout}\n${finalized.stderr}`);
1000
+ assert.equal(JSON.parse(fs.readFileSync(fixture.specPath, 'utf8')).ready_for_implementation, true);
1001
+ } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
1002
+ });
1003
+
1004
+ test('done is a final-state request bound to current semantic model and digest', () => {
1005
+ const fixture = createCanonicalAuthoringFixture();
1006
+ try {
1007
+ promoteCanonicalModel(fixture);
1008
+ const spec = makeCanonicalReady(fixture);
1009
+ spec.status = 'done';
1010
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
1011
+ const candidate = {
1012
+ featureDir: fixture.specDir,
1013
+ featureName: 'feature',
1014
+ specFile: fixture.specPath,
1015
+ spec,
1016
+ };
1017
+ const dependencies = () => ({
1018
+ validator: VALIDATOR,
1019
+ grounder: GROUNDER,
1020
+ semanticAuthority: SEMANTIC_AUTHORITY,
1021
+ });
1022
+ const current = FINAL_STATE.validateCanonicalFinalState({
1023
+ policy: POLICY, projectRoot: fixture.root, candidate, dependencies,
1024
+ });
1025
+ assert.equal(current.ok, true, current.reason);
1026
+ assert.equal(current.semanticDigest, spec.validation.semantic_review.semantic_digest);
1027
+
1028
+ const requirementsPath = path.join(fixture.specDir, 'requirements.md');
1029
+ const requirements = fs.readFileSync(requirementsPath, 'utf8');
1030
+ fs.writeFileSync(requirementsPath, requirements.replace('invalid input shall return rejected state', 'invalid input shall return a deterministic rejected state'));
1031
+ const staleDigest = FINAL_STATE.validateCanonicalFinalState({
1032
+ policy: POLICY, projectRoot: fixture.root, candidate, dependencies,
1033
+ });
1034
+ assert.equal(staleDigest.ok, false);
1035
+ assert.match(staleDigest.reason, /stale|differs from Markdown projection/i);
1036
+
1037
+ fs.writeFileSync(requirementsPath, requirements);
1038
+ const staleModel = structuredClone(spec);
1039
+ staleModel.semantic_model.criteria[0].owner = 'A-D-02';
1040
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(staleModel, null, 2)}\n`);
1041
+ const invalidModel = FINAL_STATE.validateCanonicalFinalState({
1042
+ policy: POLICY,
1043
+ projectRoot: fixture.root,
1044
+ candidate: { ...candidate, spec: staleModel },
1045
+ dependencies,
1046
+ });
1047
+ assert.equal(invalidModel.ok, false);
1048
+ assert.match(invalidModel.reason, /semantic projection|semantic_model|validation failed/i);
1049
+ } finally {
1050
+ fs.rmSync(fixture.root, { recursive: true, force: true });
1051
+ }
1052
+ });
1053
+
1054
+ test('flash PASS promotes proof; only trusted sync-finalize completes', () => {
1055
+ const initial = canonicalFlashTask();
1056
+ for (const verdict of ['FAIL', 'BLOCKED', 'PARTIAL', 'NO_TESTS', 'PASS_WITH_WARNINGS']) {
210
1057
  const result = POLICY.promoteFlashTask(initial, verdict);
211
1058
  assert.equal(result.status, 'in_progress');
212
1059
  assert.equal(result.receipt, 'FLASH_UNVERIFIED');
@@ -214,7 +1061,7 @@ test('flash PASS promotes proof without completion, finalize alone completes', (
214
1061
  assert.equal(result.unblocks, false);
215
1062
  assert.match(result.blocker, /verification|test proof/);
216
1063
  }
217
- const promoted = POLICY.promoteFlashTask(initial, 'PASS', 'Verification: PASS\nCommand: pnpm test\nResult: PASS');
1064
+ const promoted = POLICY.promoteFlashTask(initial, 'PASS', canonicalReceipt());
218
1065
  assert.equal(promoted.status, 'in_progress');
219
1066
  assert.equal(promoted.receipt.startsWith('Verification: PASS'), true);
220
1067
  assert.equal(promoted.blocker, null);
@@ -222,33 +1069,38 @@ test('flash PASS promotes proof without completion, finalize alone completes', (
222
1069
  assert.equal(promoted.unblocks, false);
223
1070
  assert.equal(promoted.readyForSync, true);
224
1071
  assert.equal(POLICY.finalizeFlashTask(promoted, 'sync'), promoted);
225
- const finalized = POLICY.finalizeFlashTask(promoted, 'sync-finalize');
1072
+ const forgedFinalize = POLICY.finalizeFlashTask(promoted, 'sync-finalize');
1073
+ assert.equal(forgedFinalize.status, 'in_progress');
1074
+ assert.equal(forgedFinalize.dependencyBlocked, true);
1075
+ assert.equal(forgedFinalize.unblocks, false);
1076
+ const finalized = POLICY.syncFinalizeFlashTask(initial, 'PASS', promoted.receipt);
226
1077
  assert.equal(finalized.status, 'done');
227
1078
  assert.equal(finalized.dependencyBlocked, false);
228
1079
  assert.equal(finalized.unblocks, true);
229
1080
  assert.equal(finalized.readyForSync, false);
230
1081
  assert.equal(POLICY.isStaleFlashDone({ status: 'done', receipt: 'FLASH_UNVERIFIED' }), true);
231
- assert.match(read(TEST_SKILL), /only explicit .*sync-finalize/);
1082
+ assert.match(read(TEST_SKILL), /only explicit .*sync-finalize/i);
232
1083
  assert.match(read(SYNC_SKILL), /sync-finalize/);
233
1084
  });
234
1085
 
235
1086
  test('spec-gate rejects stale FLASH_UNVERIFIED done state', () => {
236
1087
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-spec-gate-'));
237
- const claude = path.join(root, '.claude');
238
- fs.mkdirSync(path.join(claude, 'hooks'), { recursive: true });
239
- fs.mkdirSync(path.join(claude, 'scripts'), { recursive: true });
240
- fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs'), path.join(claude, 'hooks/spec-gate.cjs'));
241
- fs.copyFileSync(POLICY_PATH, path.join(claude, 'scripts/workflow-policy.cjs'));
1088
+ const specGate = installClaudeRuntimeClosure(root);
1089
+ assert.equal(fs.existsSync(path.join(root, '.claude', 'scripts', 'spec-final-state.cjs')), true);
1090
+ assert.equal(fs.existsSync(path.join(root, '.claude', 'scripts', 'validate-spec-output.cjs')), true);
242
1091
  fs.mkdirSync(path.join(root, 'specs', 'demo', 'tasks'), { recursive: true });
243
1092
  fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
244
1093
  status: 'in_progress',
1094
+ current_phase: 'closeout',
1095
+ feature_name: 'demo',
245
1096
  task_registry: { 'tasks/task.md': { status: 'done', receipt: 'FLASH_UNVERIFIED' } },
246
1097
  }));
1098
+ initFixtureGit(root);
247
1099
  try {
248
- const result = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
1100
+ const result = spawnSync(process.execPath, [specGate], {
249
1101
  cwd: root,
250
1102
  env: { ...process.env, PROJECT_ROOT: root },
251
- input: JSON.stringify({ cwd: root }),
1103
+ input: JSON.stringify({ cwd: root, session_id: 'test' }),
252
1104
  encoding: 'utf8',
253
1105
  });
254
1106
  assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
@@ -263,40 +1115,51 @@ test('spec-gate rejects stale FLASH_UNVERIFIED done state', () => {
263
1115
  test('first-run cache bypass is blocked - canonical receipt required even without cache', () => {
264
1116
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-first-run-'));
265
1117
  const claude = path.join(root, '.claude');
266
- fs.mkdirSync(path.join(claude, 'hooks'), { recursive: true });
267
- fs.mkdirSync(path.join(claude, 'scripts'), { recursive: true });
268
- fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs'), path.join(claude, 'hooks/spec-gate.cjs'));
269
- fs.copyFileSync(POLICY_PATH, path.join(claude, 'scripts/workflow-policy.cjs'));
1118
+ const specGate = installClaudeRuntimeClosure(root);
270
1119
  const specDir = path.join(root, 'specs', 'demo', 'tasks');
271
1120
  fs.mkdirSync(specDir, { recursive: true });
272
1121
  // Done task without canonical receipt (missing Command, provenance)
273
1122
  fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
274
- status: 'in_progress',
275
- task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } },
1123
+ status: 'done',
1124
+ current_phase: 'closeout',
1125
+ feature_name: 'demo',
1126
+ task_registry: { 'tasks/task-R1-01-one.md': { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } },
276
1127
  }));
277
- fs.writeFileSync(path.join(specDir, 'task.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\n```\nnpm test\n```\n');
1128
+ fs.writeFileSync(path.join(specDir, 'task-R1-01-one.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\n```\nnpm test\n```\n');
1129
+ initFixtureGit(root);
278
1130
  try {
279
- const result = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
1131
+ assert.equal(fs.existsSync(path.join(claude, 'hooks', '.logs', 'spec-gate-last.json')), false);
1132
+ const result = spawnSync(process.execPath, [specGate], {
280
1133
  cwd: root,
281
1134
  env: { ...process.env, PROJECT_ROOT: root },
282
- input: JSON.stringify({ cwd: root }),
1135
+ input: JSON.stringify({ cwd: root, session_id: 'test' }),
283
1136
  encoding: 'utf8',
284
1137
  });
285
1138
  assert.equal(result.status, 0);
286
1139
  const payload = JSON.parse(result.stdout);
287
1140
  assert.equal(payload.decision, 'block', 'first-run without canonical receipt must block');
288
- assert.match(payload.reason, /tasks\/task\.md/);
1141
+ assert.match(payload.reason, /tasks\/task-R1-01-one\.md/);
1142
+ assert.match(payload.reason, /receipt|command|provenance/i);
289
1143
  // Also test that with canonical receipt it passes
290
- fs.writeFileSync(path.join(specDir, 'task.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nResult: PASS\nBase: abc123\nHead: def456\n```\nnpm test\nPASS\n```\n');
1144
+ fs.writeFileSync(path.join(specDir, 'task-R1-01-one.md'), '# Task R1-01: One\n\n**Status:** done\n\n## Outcome\n\nVerified result.\n\n## Scope and Typed Anchors\n\n- **In scope:** focused result\n- **Out of scope:** none\n\n## Changes\n\n- [x] Verify result.\n\n## Acceptance\n\n- Result is verified.\n\n## Dependencies\n\n- none\n\n## Verification Plan\n\n- **Command:** `pnpm test`\n- **Expected:** focused tests pass\n- **Negative path:** not relevant for fixture\n- **Reachability:** fixture test entrypoint\n');
1145
+ fs.mkdirSync(path.join(root, 'specs', 'demo', 'receipts'), { recursive: true });
1146
+ fs.writeFileSync(
1147
+ path.join(root, 'specs', 'demo', 'receipts', 'task-R1-01-one.md'),
1148
+ bindFixtureReceipt(root, '# Task Receipt\n\nTask: task-R1-01-one.md\nTask path: tasks/task-R1-01-one.md\nVerification: PASS\nCommand: pnpm test\nExit: 0\nResult: PASS\nExpected: focused tests pass\nObserved: focused tests passed\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n```\nnpm test\nPASS\n```\n'),
1149
+ );
1150
+ fs.writeFileSync(
1151
+ path.join(root, 'specs', 'demo', 'feature-receipt.md'),
1152
+ bindFixtureReceipt(root, '# Feature Receipt\n\nFeature: demo\nVerification: PASS\nCommand: pnpm test\nExit: 0\nResult: PASS\nExpected: integrated feature passes\nObserved: integrated feature passed\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n'),
1153
+ );
291
1154
  // Clear cache to simulate fresh first-run with valid receipt
292
1155
  try { fs.unlinkSync(path.join(claude, 'hooks', '.logs', 'spec-gate-last.json')); } catch {}
293
1156
  try { fs.unlinkSync(path.join(__dirname, '..', '.logs', 'spec-gate-last.json')); } catch {}
294
1157
  // Need to clear the cache that the hook uses (under claude hooks .logs) - we already cleared above
295
1158
  // Re-run with valid receipt - should not block
296
- const result2 = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
1159
+ const result2 = spawnSync(process.execPath, [specGate], {
297
1160
  cwd: root,
298
1161
  env: { ...process.env, PROJECT_ROOT: root },
299
- input: JSON.stringify({ cwd: root }),
1162
+ input: JSON.stringify({ cwd: root, session_id: 'test' }),
300
1163
  encoding: 'utf8',
301
1164
  });
302
1165
  assert.equal(result2.stdout, '', 'valid canonical receipt on first run must not block');
@@ -307,7 +1170,7 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
307
1170
  });
308
1171
 
309
1172
  test('canonical receipt requires command, exit, provenance and unambiguous PASS', () => {
310
- assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\nHead: def\n'), []);
1173
+ assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n'), []);
311
1174
  const missingCommand = POLICY.validateCanonicalReceipt('Verification: PASS\nExit: 0\nBase: a\nHead: b\n');
312
1175
  assert.ok(missingCommand.includes('command'));
313
1176
  const missingProvenance = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\n');
@@ -317,7 +1180,7 @@ test('canonical receipt requires command, exit, provenance and unambiguous PASS'
317
1180
  const artifactWithoutHash = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: dist/bundle.js\n');
318
1181
  assert.ok(artifactWithoutHash.includes('artifact_hash'));
319
1182
  // CLI validation
320
- const cli = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n' }), '--json'], { encoding: 'utf8' });
1183
+ const cli = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n' }), '--json'], { encoding: 'utf8' });
321
1184
  assert.equal(cli.status, 0);
322
1185
  assert.equal(JSON.parse(cli.stdout).ok, true);
323
1186
  });
@@ -330,7 +1193,7 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
330
1193
  const onlyHead = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: def456\n');
331
1194
  assert.ok(onlyHead.includes('provenance'), 'only Head should fail provenance');
332
1195
  // Both Base and Head passes
333
- const bothLabels = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n');
1196
+ const bothLabels = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n');
334
1197
  assert.deepEqual(bothLabels, [], 'both Base and Head should pass');
335
1198
  // Only base_sha fails
336
1199
  const onlyBaseSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc123\n');
@@ -339,7 +1202,7 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
339
1202
  const onlyHeadSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nhead_sha: def456\n');
340
1203
  assert.ok(onlyHeadSha.includes('provenance'), 'only head_sha should fail provenance');
341
1204
  // Both base_sha and head_sha passes
342
- const bothSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc123\nhead_sha: def456\n');
1205
+ const bothSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: 0123456789abcdef0123456789abcdef01234567\nhead_sha: 89abcdef0123456789abcdef0123456789abcdef\n');
343
1206
  assert.deepEqual(bothSha, [], 'both base_sha and head_sha should pass');
344
1207
  // Alternation-precedence false acceptance check: ensure single Head or base_sha alone does not pass
345
1208
  const singleHeadLower = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: single\n');
@@ -365,16 +1228,16 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
365
1228
  assert.ok(bareBoth.includes('provenance'), 'bare base_sha head_sha without colon/value should fail');
366
1229
  const baseEmptyHeadValid = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
367
1230
  assert.ok(baseEmptyHeadValid.includes('provenance'));
368
- const validWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n');
1231
+ const validWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n');
369
1232
  assert.deepEqual(validWithValues, [], 'valid Base and Head with values should pass');
370
- const validShaWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc\nhead_sha: def\n');
1233
+ const validShaWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: 0123456789abcdef0123456789abcdef01234567\nhead_sha: 89abcdef0123456789abcdef0123456789abcdef\n');
371
1234
  assert.deepEqual(validShaWithValues, [], 'valid base_sha and head_sha with values should pass');
372
1235
  // CLI: only Base via --validate-receipt should fail
373
1236
  const cliOnlyBase = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\n' }), '--json'], { encoding: 'utf8' });
374
1237
  assert.equal(cliOnlyBase.status, 2);
375
1238
  assert.equal(JSON.parse(cliOnlyBase.stdout).ok, false);
376
1239
  assert.ok(JSON.parse(cliOnlyBase.stdout).failures.includes('provenance'));
377
- const cliBoth = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n' }), '--json'], { encoding: 'utf8' });
1240
+ const cliBoth = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n' }), '--json'], { encoding: 'utf8' });
378
1241
  assert.equal(cliBoth.status, 0);
379
1242
  assert.equal(JSON.parse(cliBoth.stdout).ok, true);
380
1243
  // CLI empty Base should fail
@@ -383,43 +1246,209 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
383
1246
  assert.ok(JSON.parse(cliEmptyBase.stdout).failures.includes('provenance'));
384
1247
  });
385
1248
 
386
- test('lane traces - Direct, Standard, Critical classification, override, state mutation and completion', () => {
1249
+ test('canonical receipt binds expected provenance when the runtime supplies it', () => {
1250
+ const body = canonicalReceipt();
1251
+ const binding = POLICY.createReceiptBinding(RUNTIME_CONTEXT);
1252
+ assert.deepEqual(binding.expectedProvenance, EXPECTED_PROVENANCE);
1253
+ assert.equal(binding.requireProvenanceBinding, true);
1254
+ assert.equal(binding.runtimeContext.context_id, RUNTIME_CONTEXT.context_id);
1255
+ assert.throws(() => POLICY.createReceiptBinding(EXPECTED_PROVENANCE), /runtime-derived provenance context/);
1256
+ assert.ok(POLICY.validateCanonicalReceipt(body, { requireProvenanceBinding: true }).includes('provenance'));
1257
+ assert.deepEqual(POLICY.validateCanonicalReceipt(body, { expectedProvenance: EXPECTED_PROVENANCE }), []);
1258
+ assert.ok(POLICY.validateCanonicalReceipt(body, { expectedProvenance: { base: EXPECTED_BASE, head: 'fedcba9876543210fedcba9876543210fedcba98' } }).includes('provenance'));
1259
+ const taskOptions = POLICY.receiptValidatorOptions({ expected_provenance: EXPECTED_PROVENANCE });
1260
+ assert.equal(taskOptions.expectedProvenance, null, 'task-authored expected provenance is diagnostic metadata only');
1261
+ assert.deepEqual(POLICY.validateCanonicalReceipt(body, taskOptions), [], 'diagnostic parsing may accept schema-valid anchors without treating them as identity');
1262
+ });
1263
+
1264
+ test('runtime provenance derives exact Git evidence, CLI context, and stale/forged blockers', () => {
1265
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-provenance-adversarial-'));
1266
+ const specsRoot = path.join(root, 'specs');
1267
+ const featureRoot = path.join(specsRoot, 'demo');
1268
+ const specFile = path.join(featureRoot, 'spec.json');
1269
+ const sourceFile = path.join(root, 'src', 'app.js');
1270
+ const renameSource = path.join(root, 'src', 'rename-old.js');
1271
+ const renameTarget = path.join(root, 'src', 'rename-new.js');
1272
+ fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
1273
+ fs.mkdirSync(featureRoot, { recursive: true });
1274
+ fs.writeFileSync(specFile, JSON.stringify({ status: 'in_progress', feature_name: 'demo', task_registry: {} }));
1275
+ fs.writeFileSync(sourceFile, 'original source\n');
1276
+ fs.writeFileSync(renameSource, 'rename source\n');
1277
+ fs.writeFileSync(path.join(root, '.gitignore'), 'ignored-secret.env\nnode_modules/\n.cache/\n');
1278
+ initFixtureGit(root);
1279
+ try {
1280
+ const input = {
1281
+ projectRoot: root,
1282
+ specsRoot,
1283
+ specFile,
1284
+ featureName: 'demo',
1285
+ runtimeSession: 'provenance-session',
1286
+ };
1287
+ const initial = PROVENANCE_HELPER.deriveRuntimeContext(input);
1288
+ const exactReceipt = `Verification: PASS\nCommand: node --test\nExit: 0\nBase: ${initial.base}\nHead: ${initial.head}\n`;
1289
+ assert.deepEqual(
1290
+ POLICY.validateCanonicalReceipt(exactReceipt, POLICY.receiptValidatorOptions({}, {
1291
+ runtimeContext: initial,
1292
+ requireProvenanceBinding: true,
1293
+ })),
1294
+ [],
1295
+ 'exact runtime-derived receipt must validate',
1296
+ );
1297
+ const arbitraryReceipt = 'Verification: PASS\nCommand: node --test\nExit: 0\nBase: ' + 'a'.repeat(40) + '\nHead: ' + 'b'.repeat(64) + '\n';
1298
+ assert.ok(
1299
+ POLICY.validateCanonicalReceipt(arbitraryReceipt, POLICY.receiptValidatorOptions({}, {
1300
+ runtimeContext: initial,
1301
+ requireProvenanceBinding: true,
1302
+ })).includes('provenance'),
1303
+ 'valid-shaped arbitrary SHA values must not bind',
1304
+ );
1305
+
1306
+ const cli = spawnSync(process.execPath, [
1307
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), '--json',
1308
+ '--project-root', root, '--specs-root', specsRoot, '--spec-file', specFile,
1309
+ '--feature-name', 'demo', '--runtime-session', 'provenance-session',
1310
+ ], { cwd: root, encoding: 'utf8' });
1311
+ assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
1312
+ const cliOutput = JSON.parse(cli.stdout);
1313
+ assert.equal(cliOutput.ok, true);
1314
+ assert.equal(cliOutput.Base, initial.base);
1315
+ assert.equal(cliOutput.Head, initial.head);
1316
+ assert.equal(cliOutput.context_id, initial.context_id);
1317
+
1318
+ const ignoredSecret = path.join(root, 'ignored-secret.env');
1319
+ fs.writeFileSync(ignoredSecret, 'TOKEN=first-secret\n');
1320
+ const withIgnoredSecret = PROVENANCE_HELPER.deriveRuntimeContext(input);
1321
+ fs.writeFileSync(ignoredSecret, 'TOKEN=changed-secret\n');
1322
+ const withChangedIgnoredSecret = PROVENANCE_HELPER.deriveRuntimeContext(input);
1323
+ assert.equal(withIgnoredSecret.head, initial.head, 'ignored secret-like files must not enter Head');
1324
+ assert.equal(withChangedIgnoredSecret.head, initial.head, 'changing an ignored secret-like file must not change Head');
1325
+
1326
+ const ignoredCacheFile = path.join(root, 'packages', 'app', 'node_modules', 'cache', 'runtime.bin');
1327
+ fs.mkdirSync(path.dirname(ignoredCacheFile), { recursive: true });
1328
+ fs.writeFileSync(ignoredCacheFile, 'runtime cache bytes\n');
1329
+ const withIgnoredCache = PROVENANCE_HELPER.deriveRuntimeContext(input);
1330
+ fs.writeFileSync(ignoredCacheFile, 'changed runtime cache bytes\n');
1331
+ const withChangedIgnoredCache = PROVENANCE_HELPER.deriveRuntimeContext(input);
1332
+ assert.equal(withIgnoredCache.head, initial.head, 'nested ignored node_modules/cache files must not enter Head');
1333
+ assert.equal(withChangedIgnoredCache.head, initial.head, 'changing nested ignored cache data must not change Head');
1334
+
1335
+ const forgedContext = { ...initial };
1336
+ const forgedDecision = POLICY.completionDecision('PASS', {
1337
+ workflow_policy: { proof_obligations: ['needsExecutionProof'] },
1338
+ runtime_context: forgedContext,
1339
+ execution_receipt: exactReceipt,
1340
+ });
1341
+ assert.equal(forgedDecision.completion, 'unfinished');
1342
+ assert.deepEqual(forgedDecision.missingProof, ['runtime_provenance']);
1343
+
1344
+ const flash = {
1345
+ status: 'in_progress',
1346
+ receipt: 'FLASH_UNVERIFIED',
1347
+ blocker: 'awaiting test proof',
1348
+ dependencyBlocked: true,
1349
+ unblocks: false,
1350
+ runtime_context: initial,
1351
+ };
1352
+ const promoted = POLICY.promoteFlashTask(flash, 'PASS', exactReceipt);
1353
+ assert.equal(promoted.readyForSync, true);
1354
+
1355
+ fs.writeFileSync(sourceFile, 'mutated source\n');
1356
+ const stale = POLICY.completionDecision('PASS', {
1357
+ workflow_policy: { proof_obligations: ['needsExecutionProof'] },
1358
+ receipt_binding: POLICY.createReceiptBinding(initial),
1359
+ execution_receipt: exactReceipt,
1360
+ });
1361
+ assert.equal(stale.completion, 'unfinished');
1362
+ assert.ok(stale.missingProof.some((item) => item.includes('execution_receipt:provenance')));
1363
+ const staleFinalized = POLICY.syncFinalizeFlashTask(flash, 'PASS', exactReceipt, initial);
1364
+ assert.equal(staleFinalized.status, 'in_progress');
1365
+ assert.equal(staleFinalized.readyForSync, false);
1366
+
1367
+ fs.writeFileSync(path.join(root, 'src', 'untracked.js'), 'untracked source\n');
1368
+ const withUntracked = PROVENANCE_HELPER.deriveRuntimeContext(input);
1369
+ assert.notEqual(withUntracked.head, withIgnoredCache.head, 'non-ignored untracked source must change Head');
1370
+ fs.chmodSync(renameSource, 0o755);
1371
+ const withMode = PROVENANCE_HELPER.deriveRuntimeContext(input);
1372
+ assert.notEqual(withMode.head, withUntracked.head, 'mode changes must change Head');
1373
+ fs.renameSync(renameSource, renameTarget);
1374
+ const withRename = PROVENANCE_HELPER.deriveRuntimeContext(input);
1375
+ assert.notEqual(withRename.head, withMode.head, 'rename path semantics must change Head');
1376
+ fs.unlinkSync(sourceFile);
1377
+ const withDeletion = PROVENANCE_HELPER.deriveRuntimeContext(input);
1378
+ assert.notEqual(withDeletion.head, withRename.head, 'tracked deletion must change Head');
1379
+ const staged = spawnSync('git', ['-C', root, 'add', '-A'], { encoding: 'utf8' });
1380
+ assert.equal(staged.status, 0, staged.stderr);
1381
+ const stagedRename = PROVENANCE_HELPER.deriveRuntimeContext(input);
1382
+ assert.notEqual(stagedRename.head, withDeletion.head, 'staged rename/deletion evidence must be included');
1383
+
1384
+ assert.throws(() => PROVENANCE_HELPER.deriveRuntimeContext({
1385
+ ...input,
1386
+ feature_name: 'other',
1387
+ }), /disagree/);
1388
+ assert.throws(() => PROVENANCE_HELPER.deriveRuntimeContext({
1389
+ ...input,
1390
+ specsRoot: root,
1391
+ }), /distinct project subdirectory/);
1392
+ } finally {
1393
+ fs.rmSync(root, { recursive: true, force: true });
1394
+ }
1395
+
1396
+ const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-provenance-non-git-'));
1397
+ const nonGitSpec = path.join(nonGit, 'specs', 'demo', 'spec.json');
1398
+ fs.mkdirSync(path.dirname(nonGitSpec), { recursive: true });
1399
+ fs.writeFileSync(nonGitSpec, JSON.stringify({ status: 'in_progress', feature_name: 'demo' }));
1400
+ try {
1401
+ const blocked = spawnSync(process.execPath, [
1402
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), '--json',
1403
+ '--project-root', nonGit, '--specs-root', path.join(nonGit, 'specs'),
1404
+ '--spec-file', nonGitSpec, '--feature-name', 'demo', '--runtime-session', 'non-git-session',
1405
+ ], { cwd: nonGit, encoding: 'utf8' });
1406
+ assert.equal(blocked.status, 2);
1407
+ assert.equal(JSON.parse(blocked.stdout).error.code, 'git_command_failed');
1408
+ } finally {
1409
+ fs.rmSync(nonGit, { recursive: true, force: true });
1410
+ }
1411
+ });
1412
+
1413
+ test('lane traces - Direct, Standard, explicit Strict classification, override, state mutation and completion', () => {
387
1414
  // Direct: isolated reversible low-risk
388
1415
  const direct = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1 });
389
1416
  assert.equal(direct.lane, 'Direct');
390
1417
  assert.equal(direct.automaticLane, 'Direct');
391
- assert.deepEqual(POLICY.lanePolicy(direct).delegated, []);
1418
+ assert.deepEqual(POLICY.lanePolicy(direct).proof_obligations, ['needsExecutionProof']);
392
1419
  assert.equal(POLICY.lanePolicy(direct).requiresSpec, false);
393
1420
  assert.equal(POLICY.lanePolicy(direct).shipPoint, 'task');
394
1421
  // Standard: default
395
1422
  const standard = POLICY.classifyLane({ title: 'add pagination to list', taskCount: 1 });
396
1423
  assert.equal(standard.lane, 'Standard');
397
- assert.deepEqual(POLICY.lanePolicy(standard).delegated, ['code-auditor']);
1424
+ assert.deepEqual(POLICY.lanePolicy(standard).proof_obligations, ['needsExecutionProof']);
398
1425
  assert.equal(POLICY.lanePolicy(standard).shipPoint, 'feature');
399
- // Critical: auth signal
400
- const critical = POLICY.classifyLane({ riskSignals: { auth: true }, taskCount: 1 });
1426
+ // Risk alone is Elevated; Critical requires an explicit Strict assurance choice.
1427
+ const elevated = POLICY.classifyLane({ riskSignals: { auth: true }, taskCount: 1 });
1428
+ assert.equal(elevated.lane, 'Standard');
1429
+ assert.deepEqual(POLICY.lanePolicy(elevated).proof_obligations, ['needsInspection', 'needsExecutionProof']);
1430
+ const critical = POLICY.classifyLane({ riskSignals: { auth: true }, assurance_level: 'Strict', taskCount: 1 });
401
1431
  assert.equal(critical.lane, 'Critical');
402
1432
  assert.ok(critical.risks.includes('auth'));
403
- assert.deepEqual(POLICY.lanePolicy(critical).delegated, ['inspector', 'implementer', 'test-runner', 'code-auditor']);
1433
+ assert.deepEqual(POLICY.lanePolicy(critical).proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit']);
404
1434
  // Override: Direct requesting Critical is allowed (upgrade) without extra auth
405
1435
  const upgrade = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1, override: 'Critical' });
406
1436
  assert.equal(upgrade.lane, 'Critical');
407
1437
  assert.equal(upgrade.automaticLane, 'Direct');
408
1438
  // Downgrade without auth is blocked
409
- assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard' }), /requires explicit user authorization/);
410
- // Downgrade with auth succeeds and surfaces warning
411
- const downgrade = POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', userAuthorized: true });
412
- assert.equal(downgrade.lane, 'Standard');
413
- assert.match(downgrade.warnings.join(' '), /Downgrade authorized by user/);
1439
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard' }), /assurance_level downgrade.*blocked/i);
1440
+ // Caller-owned booleans cannot weaken the classified minimum.
1441
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', userAuthorized: true }), /assurance_level downgrade.*blocked/i);
1442
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', user_approved: true }), /assurance_level downgrade.*blocked/i);
414
1443
  // State mutation: approvalState
415
1444
  const pendingApproval = POLICY.approvalState({ generated: true, agent_validated: false, user_approved: false });
416
1445
  assert.equal(pendingApproval.ready, false);
417
1446
  const agentValidated = POLICY.approvalState({ generated: true, agent_validated: true, user_approved: false });
418
- assert.equal(agentValidated.ready, false, 'agent_validated alone must not imply ready');
1447
+ assert.equal(agentValidated.ready, true, 'generated plus agent validation is technical readiness');
419
1448
  const approved = POLICY.approvalState({ generated: true, agent_validated: true, user_approved: true });
420
1449
  assert.equal(approved.ready, true);
421
1450
  // Completion: flash work remains in_progress and does not unblock
422
- const flashTask = { status: 'in_progress', receipt: 'FLASH_UNVERIFIED', blocker: 'awaiting /hapo:test <feature>', dependencyBlocked: true, unblocks: false };
1451
+ const flashTask = canonicalFlashTask();
423
1452
  const failRemains = POLICY.promoteFlashTask(flashTask, 'FAIL');
424
1453
  assert.equal(failRemains.status, 'in_progress');
425
1454
  assert.equal(failRemains.unblocks, false);
@@ -427,20 +1456,20 @@ test('lane traces - Direct, Standard, Critical classification, override, state m
427
1456
  assert.equal(blockedRemains.unblocks, false);
428
1457
  const noTestsRemains = POLICY.promoteFlashTask(flashTask, 'NO_TESTS');
429
1458
  assert.equal(noTestsRemains.unblocks, false);
430
- const promoted = POLICY.promoteFlashTask(flashTask, 'PASS', 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
1459
+ const promoted = POLICY.promoteFlashTask(flashTask, 'PASS', canonicalReceipt());
431
1460
  assert.equal(promoted.readyForSync, true);
432
1461
  assert.equal(promoted.unblocks, false, 'promoted flash must not unblock until sync-finalize');
433
- const finalized = POLICY.finalizeFlashTask(promoted, 'sync-finalize');
1462
+ const finalized = POLICY.syncFinalizeFlashTask(flashTask, 'PASS', promoted.receipt);
434
1463
  assert.equal(finalized.status, 'done');
435
1464
  assert.equal(finalized.unblocks, true);
436
1465
  });
437
1466
 
438
1467
  test('flash selective promotion - only specific task is promoted, not blanket', () => {
439
1468
  const registry = {
440
- 'tasks/task-a.md': { status: 'in_progress', receipt: 'FLASH_UNVERIFIED', dependencyBlocked: true, unblocks: false },
441
- 'tasks/task-b.md': { status: 'in_progress', receipt: 'FLASH_UNVERIFIED', dependencyBlocked: true, unblocks: false },
1469
+ 'tasks/task-a.md': canonicalFlashTask(),
1470
+ 'tasks/task-b.md': canonicalFlashTask(),
442
1471
  };
443
- const promotedA = POLICY.promoteFlashTask(registry['tasks/task-a.md'], 'PASS', 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
1472
+ const promotedA = POLICY.promoteFlashTask(registry['tasks/task-a.md'], 'PASS', canonicalReceipt());
444
1473
  const notPromotedB = registry['tasks/task-b.md']; // unchanged
445
1474
  assert.equal(promotedA.readyForSync, true);
446
1475
  assert.equal(notPromotedB.receipt, 'FLASH_UNVERIFIED');
@@ -1118,3 +2147,700 @@ process.stdin.on('end', ()=>{
1118
2147
  fs.rmSync(root, { recursive: true, force: true });
1119
2148
  }
1120
2149
  });
2150
+
2151
+ // ── P0 regressions ──────────────────────────────────────────────────────
2152
+ test('P0 receipt fail-closed: Exit 1/-1/abc/conflict/empty command rejected', () => {
2153
+ const base = 'Verification: PASS\nCommand: pnpm test\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
2154
+ const cases = [
2155
+ { body: `${base}Exit: 1\n`, shouldFail: 'exit_result', desc: 'Exit 1' },
2156
+ { body: `${base}Exit: -1\n`, shouldFail: 'exit_result', desc: 'Exit -1' },
2157
+ { body: `${base}Exit: abc\n`, shouldFail: 'exit_result', desc: 'Exit abc' },
2158
+ { body: `${base}Exit: 0\nExit: 1\n`, shouldFail: 'exit_result', desc: 'conflicting exits 0 and 1' },
2159
+ { body: `${base}Exit: 0\nExit: abc\n`, shouldFail: 'exit_result', desc: 'conflicting exit 0 and abc' },
2160
+ { body: 'Verification: PASS\nCommand:\nExit: 0\nBase: a\nHead: b\n', shouldFail: 'command', desc: 'empty command' },
2161
+ { body: 'Verification: PASS\nCommand: \nExit: 0\nBase: a\nHead: b\n', shouldFail: 'command', desc: 'command spaces only' },
2162
+ { body: `${base}Exit: 0\nResult: FAIL\n`, shouldFail: 'exit_result', desc: 'Result FAIL with Exit 0' },
2163
+ { body: `${base}Exit: 1\nResult: PASS\n`, shouldFail: 'exit_result', desc: 'Result PASS with Exit 1' },
2164
+ { body: 'Verification: PASS\nCommand: pnpm test\nResult: PASS\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n', shouldFail: null, desc: 'Result PASS without Exit (legacy alias ok)' },
2165
+ { body: 'Verification: PASS\nCommand: pnpm test\nResult: FAIL\nBase: a\nHead: b\n', shouldFail: 'exit_result', desc: 'Result FAIL without Exit' },
2166
+ { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: {{base}}\nHead: {{head}}\n', shouldFail: 'placeholder', desc: 'placeholder provenance' },
2167
+ { body: 'Verification: PASS\nCommand: {{cmd}}\nExit: 0\nBase: a\nHead: b\n', shouldFail: 'placeholder', desc: 'placeholder command' },
2168
+ { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\n', shouldFail: 'provenance', desc: 'missing Head' },
2169
+ ];
2170
+ for (const { body, shouldFail, desc } of cases) {
2171
+ const fails = POLICY.validateCanonicalReceipt(body);
2172
+ if (shouldFail) {
2173
+ assert.ok(fails.includes(shouldFail) || fails.includes('placeholder') || fails.includes('exit_result') || fails.includes('command') || fails.includes('provenance'), `case ${desc} should fail with ${shouldFail}, got ${fails}`);
2174
+ } else {
2175
+ assert.deepEqual(fails, [], `case ${desc} should pass, got ${fails}`);
2176
+ }
2177
+ }
2178
+ // CLI parity: Exit 1 must be rejected via CLI
2179
+ const cli = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: `${base}Exit: 1\n` }), '--json'], { encoding: 'utf8' });
2180
+ assert.equal(cli.status, 2);
2181
+ assert.equal(JSON.parse(cli.stdout).ok, false);
2182
+ });
2183
+
2184
+ test('P0 flash marker-only must not promote or finalize', () => {
2185
+ const flash = canonicalFlashTask({ blocker: 'awaiting /hapo:test' });
2186
+ const markerOnly = 'Verification: PASS';
2187
+ const promoted = POLICY.promoteFlashTask(flash, 'PASS', markerOnly);
2188
+ assert.equal(promoted.status, 'in_progress');
2189
+ assert.equal(promoted.receipt, 'FLASH_UNVERIFIED');
2190
+ assert.equal(promoted.readyForSync, false);
2191
+ assert.equal(promoted.dependencyBlocked, true);
2192
+ assert.equal(promoted.unblocks, false);
2193
+ assert.match(promoted.blocker, /canonical receipt/);
2194
+ // Even with PASS marker but missing command/exit/provenance, finalize must not complete
2195
+ const fakePromoted = { status: 'in_progress', receipt: markerOnly, readyForSync: true, dependencyBlocked: true, unblocks: false };
2196
+ const notFinalized = POLICY.finalizeFlashTask(fakePromoted, 'sync-finalize');
2197
+ assert.equal(notFinalized.status, 'in_progress', 'marker-only receipt must not finalize');
2198
+ // Valid receipt promotes correctly
2199
+ const valid = canonicalReceipt();
2200
+ const good = POLICY.promoteFlashTask(flash, 'PASS', valid);
2201
+ assert.equal(good.readyForSync, true);
2202
+ assert.equal(good.receipt, valid.trim());
2203
+ const forgedFinalize = POLICY.finalizeFlashTask(good, 'sync-finalize');
2204
+ assert.equal(forgedFinalize.status, 'in_progress');
2205
+ assert.equal(forgedFinalize.unblocks, false);
2206
+ const finalized = POLICY.syncFinalizeFlashTask(flash, 'PASS', valid);
2207
+ assert.equal(finalized.status, 'done');
2208
+ });
2209
+
2210
+ test('P0 canonical artifacts declaration requires SHA-256 and keeps no-artifact receipts compatible', () => {
2211
+ const receipt = 'Verification: PASS\nCommand: node --test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
2212
+ const invalid = `${receipt}sha256: abc123\n`;
2213
+ const digest = 'd'.repeat(64);
2214
+ const valid = `${receipt}Artifact: output/bundle.js\nSHA-256: ${digest}\n`;
2215
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt), []);
2216
+ const options = POLICY.receiptValidatorOptions({ artifacts: ['output/bundle.js'] });
2217
+ assert.equal(options.requireArtifactHash, true);
2218
+ assert.equal(options.artifactDeclarationValid, true);
2219
+ assert.ok(POLICY.validateCanonicalReceipt(receipt, options).includes('artifact_hash'));
2220
+ assert.ok(POLICY.validateCanonicalReceipt(invalid, options).includes('artifact_hash'));
2221
+ assert.deepEqual(POLICY.validateCanonicalReceipt(valid, options), []);
2222
+ assert.equal(POLICY.receiptValidatorOptions({}).requireArtifactHash, false);
2223
+ assert.equal(POLICY.receiptValidatorOptions({ artifact_ref: 'output/bundle.js' }).requireArtifactHash, true, 'legacy alias remains read-compatible');
2224
+ for (const declaration of [null, [], ['../outside'], [{ path: 'output/bundle.js' }]]) {
2225
+ const malformed = POLICY.receiptValidatorOptions({ artifacts: declaration });
2226
+ assert.equal(malformed.artifactDeclarationValid, false, `malformed declaration must be rejected: ${JSON.stringify(declaration)}`);
2227
+ assert.ok(POLICY.validateCanonicalReceipt(receipt, malformed).includes('artifact_declaration'));
2228
+ }
2229
+ });
2230
+
2231
+ test('P0 sync-finalize derives promotion from current FLASH_UNVERIFIED plus explicit PASS proof', () => {
2232
+ const receipt = canonicalReceipt('node --test');
2233
+ const forged = {
2234
+ status: 'in_progress',
2235
+ receipt,
2236
+ readyForSync: true,
2237
+ dependencyBlocked: true,
2238
+ unblocks: false,
2239
+ flashTransition: 'promoted',
2240
+ promotionReceipt: receipt,
2241
+ };
2242
+ const direct = spawnSync(process.execPath, [
2243
+ POLICY_PATH,
2244
+ '--sync-finalize',
2245
+ '--task-json',
2246
+ JSON.stringify(forged),
2247
+ '--json',
2248
+ ], { encoding: 'utf8' });
2249
+ assert.equal(direct.status, 2, `${direct.stdout}\n${direct.stderr}`);
2250
+ const directPayload = JSON.parse(direct.stdout);
2251
+ assert.equal(directPayload.ok, false);
2252
+ assert.equal(directPayload.task.status, 'in_progress');
2253
+ assert.equal(directPayload.task.receipt, 'FLASH_UNVERIFIED');
2254
+ assert.equal(directPayload.task.readyForSync, false);
2255
+ assert.equal(directPayload.task.dependencyBlocked, true);
2256
+ assert.equal(directPayload.task.unblocks, false);
2257
+ assert.match(directPayload.message, /current task must be FLASH_UNVERIFIED.*canonical/i);
2258
+
2259
+ const minimal = { status: 'in_progress', receipt: 'FLASH_UNVERIFIED' };
2260
+ const minimalResult = POLICY.syncFinalizeFlashTask(minimal, 'PASS', receipt);
2261
+ assert.equal(minimalResult.status, 'in_progress');
2262
+ assert.match(minimalResult.blocker, /exact canonical stored state/);
2263
+ const forgedOpen = { ...canonicalFlashTask(), readyForSync: true };
2264
+ const forgedOpenResult = POLICY.syncFinalizeFlashTask(forgedOpen, 'PASS', receipt);
2265
+ assert.equal(forgedOpenResult.status, 'in_progress');
2266
+ assert.equal(forgedOpenResult.unblocks, false);
2267
+
2268
+ const current = canonicalFlashTask({ blocker: 'awaiting /hapo:test' });
2269
+ const supported = spawnSync(process.execPath, [
2270
+ POLICY_PATH,
2271
+ '--sync-finalize',
2272
+ '--task-json',
2273
+ JSON.stringify(current),
2274
+ '--verdict',
2275
+ 'PASS',
2276
+ '--proof',
2277
+ receipt,
2278
+ '--project-root',
2279
+ RUNTIME_ROOT,
2280
+ '--specs-root',
2281
+ path.join(RUNTIME_ROOT, 'specs'),
2282
+ '--spec-file',
2283
+ RUNTIME_SPEC,
2284
+ '--feature-name',
2285
+ 'demo',
2286
+ '--runtime-session',
2287
+ 'implementation-session-1',
2288
+ '--json',
2289
+ ], { encoding: 'utf8' });
2290
+ assert.equal(supported.status, 0, `${supported.stdout}\n${supported.stderr}`);
2291
+ assert.equal(JSON.parse(supported.stdout).task.status, 'done');
2292
+ const warningFinalize = POLICY.syncFinalizeFlashTask(current, 'PASS_WITH_WARNINGS', receipt);
2293
+ assert.equal(warningFinalize.status, 'in_progress');
2294
+ assert.equal(warningFinalize.unblocks, false);
2295
+
2296
+ const initial = canonicalFlashTask();
2297
+ const promoted = POLICY.promoteFlashTask(initial, 'PASS', receipt);
2298
+ const persisted = JSON.parse(JSON.stringify(promoted));
2299
+ assert.equal(persisted.flashTransition, 'promoted');
2300
+ assert.equal(persisted.promotionReceipt, receipt.trim());
2301
+ const forgedPersistedFinalize = POLICY.finalizeFlashTask(persisted, 'sync-finalize');
2302
+ assert.equal(forgedPersistedFinalize.status, 'in_progress');
2303
+ assert.equal(forgedPersistedFinalize.unblocks, false);
2304
+ const finalized = POLICY.syncFinalizeFlashTask(initial, 'PASS', receipt);
2305
+ assert.equal(finalized.status, 'done');
2306
+ assert.equal(finalized.flashTransition, 'finalized');
2307
+
2308
+ const artifactInitial = { ...initial, artifacts: ['output/bundle.js'] };
2309
+ const artifactPromotion = POLICY.promoteFlashTask(artifactInitial, 'PASS', receipt);
2310
+ assert.equal(artifactPromotion.readyForSync, false, 'flash promotion must apply task artifact requirements');
2311
+
2312
+ const badProof = spawnSync(process.execPath, [
2313
+ POLICY_PATH,
2314
+ '--sync-finalize',
2315
+ '--task-json',
2316
+ JSON.stringify(current),
2317
+ '--verdict',
2318
+ 'PASS',
2319
+ '--proof',
2320
+ 'Verification: PASS',
2321
+ '--json',
2322
+ ], { encoding: 'utf8' });
2323
+ assert.equal(badProof.status, 2);
2324
+ assert.equal(JSON.parse(badProof.stdout).task.status, 'in_progress');
2325
+
2326
+ const badArtifact = spawnSync(process.execPath, [
2327
+ POLICY_PATH,
2328
+ '--sync-finalize',
2329
+ '--task-json',
2330
+ JSON.stringify({ ...current, artifacts: ['output/bundle.js'] }),
2331
+ '--verdict',
2332
+ 'PASS',
2333
+ '--proof',
2334
+ receipt,
2335
+ '--json',
2336
+ ], { encoding: 'utf8' });
2337
+ assert.equal(badArtifact.status, 2);
2338
+ assert.equal(JSON.parse(badArtifact.stdout).task.status, 'in_progress');
2339
+
2340
+ const markerOnly = {
2341
+ ...forged,
2342
+ flashTransition: 'promoted',
2343
+ promotionReceipt: 'Verification: PASS',
2344
+ receipt: 'Verification: PASS',
2345
+ };
2346
+ assert.equal(POLICY.finalizeFlashTask(markerOnly, 'sync-finalize').status, 'in_progress');
2347
+ });
2348
+
2349
+ test('P0 Claude and Codex reject configured specs roots outside the project', () => {
2350
+ const claudeResolver = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
2351
+ const codexUtils = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
2352
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-external-specs-'));
2353
+ const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-configured-specs-'));
2354
+ try {
2355
+ const local = path.join(tmp, 'specs', 'local');
2356
+ const external = path.join(externalRoot, 'remote');
2357
+ fs.mkdirSync(local, { recursive: true });
2358
+ fs.mkdirSync(external, { recursive: true });
2359
+ fs.writeFileSync(path.join(local, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'local' }));
2360
+ fs.writeFileSync(path.join(external, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'remote' }));
2361
+ const runtime = { paths: { specs: externalRoot } };
2362
+ const claude = claudeResolver.resolveActiveSpec({ projectRoot: tmp, runtime });
2363
+ const codex = codexUtils.resolveActiveSpec(tmp, runtime, null, null);
2364
+ assert.equal(claude.error, 'invalid_specs');
2365
+ assert.equal(codex.error, 'invalid_specs');
2366
+ assert.match(claude.reason, /escapes project root/);
2367
+ assert.match(codex.reason, /escapes project root/);
2368
+ } finally {
2369
+ fs.rmSync(tmp, { recursive: true, force: true });
2370
+ fs.rmSync(externalRoot, { recursive: true, force: true });
2371
+ }
2372
+ });
2373
+
2374
+ test('P0 caller-owned downgrade flags have no canonical capability', () => {
2375
+ for (const callerClaim of [{ user_approved: true }, { userApproved: true }, { userAuthorized: true }]) {
2376
+ assert.throws(
2377
+ () => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', ...callerClaim }),
2378
+ (error) => /downgrade/.test(error.message) && /not permitted/.test(error.message),
2379
+ );
2380
+ }
2381
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { auth: true }, override: 'Standard', confirmDowngrade: true }), /assurance_level downgrade.*blocked/i);
2382
+ assert.equal(POLICY.isUserAuthorizedForDowngrade, undefined);
2383
+ });
2384
+
2385
+ test('P0 canonical policy exposes no caller-owned downgrade authority', () => {
2386
+ assert.equal(POLICY.isUserAuthorizedForDowngrade, undefined);
2387
+ assert.equal(POLICY.isValidOverrideReceipt, undefined);
2388
+ assert.equal(POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS.includes('override_' + 'receipt'), false);
2389
+ assert.throws(
2390
+ () => POLICY.classifyLane({
2391
+ riskSignals: { privacy: true },
2392
+ override: 'Direct',
2393
+ overrideReceipt: { verifiedByRuntime: true },
2394
+ }),
2395
+ (error) => /downgrade/.test(error.message) && /not permitted/.test(error.message),
2396
+ );
2397
+ });
2398
+
2399
+ test('P0 active spec deterministic: multiple active ambiguity and explicit target/path containment', () => {
2400
+ const RESOLVER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
2401
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-resolver-'));
2402
+ try {
2403
+ const specsDir = path.join(tmp, 'specs');
2404
+ fs.mkdirSync(specsDir, { recursive: true });
2405
+ // Create two active specs
2406
+ for (const name of ['alpha', 'beta']) {
2407
+ fs.mkdirSync(path.join(specsDir, name), { recursive: true });
2408
+ fs.writeFileSync(path.join(specsDir, name, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: name, current_phase: 'design' }));
2409
+ }
2410
+ const amb = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
2411
+ assert.equal(amb.error, 'multiple_active');
2412
+ assert.deepEqual(amb.candidates.sort(), ['alpha', 'beta']);
2413
+ // Explicit feature resolves deterministically
2414
+ const alpha = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'alpha' });
2415
+ assert.equal(alpha.featureName, 'alpha');
2416
+ // Explicit not-found fail-closed
2417
+ const notFound = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'gamma' });
2418
+ assert.equal(notFound.error, 'explicit_not_found');
2419
+ // Explicit path containment: try to escape via ../
2420
+ const escape = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(tmp, 'specs', '..', 'etc', 'passwd') });
2421
+ assert.equal(escape.error, 'explicit_malformed');
2422
+ // Malformed feature name with slash must be rejected
2423
+ const malformed = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: '../alpha' });
2424
+ assert.equal(malformed.error, 'explicit_malformed');
2425
+ // Valid explicit path inside root
2426
+ const validPath = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(specsDir, 'beta', 'spec.json') });
2427
+ assert.equal(validPath.featureName, 'beta');
2428
+ } finally {
2429
+ fs.rmSync(tmp, { recursive: true, force: true });
2430
+ }
2431
+ });
2432
+
2433
+ test('P0 parity Claude/Codex: canonical receipt and multi-active', () => {
2434
+ // Codex receipt validator must match Claude policy
2435
+ const codexReceipt = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
2436
+ const claudeFails = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 1\nBase: a\nHead: b\n');
2437
+ const codexFails = codexReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 1\nBase: a\nHead: b\n');
2438
+ assert.deepEqual(claudeFails.sort(), codexFails.sort(), 'Claude and Codex validators must agree on Exit 1');
2439
+ const codexEmptyCmd = codexReceipt.validateCanonicalReceipt('Verification: PASS\nCommand:\nExit: 0\nBase: a\nHead: b\n');
2440
+ assert.ok(codexEmptyCmd.includes('command'), 'Codex must reject empty Command');
2441
+ // Codex resolver parity: same containment behaviour
2442
+ const codexUtils = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
2443
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-resolver-'));
2444
+ try {
2445
+ const specsDir = path.join(tmp, 'specs');
2446
+ fs.mkdirSync(specsDir, { recursive: true });
2447
+ for (const name of ['x', 'y']) {
2448
+ fs.mkdirSync(path.join(specsDir, name), { recursive: true });
2449
+ fs.writeFileSync(path.join(specsDir, name, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: name }));
2450
+ }
2451
+ const amb = codexUtils.resolveActiveSpec(tmp, {}, null, null);
2452
+ assert.equal(amb.error, 'multiple_active');
2453
+ const esc = codexUtils.resolveActiveSpec(tmp, {}, null, path.join(tmp, 'specs', '..', 'outside', 'spec.json'));
2454
+ assert.equal(esc.error, 'explicit_malformed');
2455
+ } finally {
2456
+ fs.rmSync(tmp, { recursive: true, force: true });
2457
+ }
2458
+ });
2459
+
2460
+ test('P0 regression: placeholder, explicit failure, artifact and lanePolicy forged are blocked', () => {
2461
+ // Probe A: explicit failure outcomes must fail canonical validator and not promote flash
2462
+ const bodyTestsFailed = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests failed: 1\n';
2463
+ assert.ok(POLICY.validateCanonicalReceipt(bodyTestsFailed).length > 0, 'Tests failed: 1 must be rejected');
2464
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nResult: FAIL\nResult: PASS\n').includes('exit_result'), 'Result FAIL then PASS must be rejected');
2465
+ const flashTask = { status: 'in_progress', receipt: 'FLASH_UNVERIFIED', blocker: 'awaiting', dependencyBlocked: true, unblocks: false };
2466
+ const notPromoted = POLICY.promoteFlashTask(flashTask, 'PASS', bodyTestsFailed);
2467
+ assert.equal(notPromoted.readyForSync, false, 'flash with Tests failed should not promote');
2468
+ assert.equal(notPromoted.receipt, 'FLASH_UNVERIFIED');
2469
+
2470
+ // Probe B: placeholder tokens must be rejected, but substring todo must pass
2471
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: TODO\nExit: 0\nBase: a\nHead: b\n').includes('command'), 'Command TODO must be rejected');
2472
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: TBD\nHead: b\n').includes('provenance'), 'Base TBD must be rejected');
2473
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: N/A\n').includes('provenance'), 'Head N/A must be rejected');
2474
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: pending\nhead_sha: unknown\n').includes('provenance'), 'base_sha pending must be rejected');
2475
+ assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: npm run todo:test --fix\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n'), [], 'command containing todo substring should pass');
2476
+
2477
+ // Probe C: artifact with empty or placeholder sha must fail, concrete passes, inline passes
2478
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: \n').includes('artifact_hash'), 'artifact empty sha must fail');
2479
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: TBD\n').includes('artifact_hash'), 'artifact TBD sha must fail');
2480
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: bundle\nsha256: abc123\n').includes('artifact_hash'), 'short artifact sha must fail');
2481
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact produced sha256:deadbeef\n').includes('artifact_hash'), 'short inline sha256 must fail');
2482
+ assert.deepEqual(POLICY.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\nArtifact: bundle\nsha256: ${'c'.repeat(64)}\n`), [], '64-hex artifact sha should pass');
2483
+
2484
+ // Probe F: lanePolicy forged plain object must not bypass classification
2485
+ const { spawnSync } = require('node:child_process');
2486
+ const POLICY_PATH = path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs');
2487
+ const forged = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct', riskSignals: { auth: true } }), '--json'], { encoding: 'utf8' });
2488
+ assert.equal(forged.status, 2, 'forged Direct with auth should be blocked via lanePolicy');
2489
+ assert.match(forged.stderr, /downgrade.*not permitted/i);
2490
+ const forged2 = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct' }), '--json'], { encoding: 'utf8' });
2491
+ assert.equal(forged2.status, 2, 'plain Direct without justification should be blocked');
2492
+
2493
+ const trusted = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true });
2494
+ assert.equal(trusted.lane, 'Direct');
2495
+ const viaPolicy = POLICY.lanePolicy(trusted);
2496
+ assert.equal(viaPolicy.lane, 'Direct', 'trusted Direct should be respected');
2497
+ assert.throws(() => { 'use strict'; trusted.lane = 'Direct'; }, /read only|Cannot assign|TypeError/);
2498
+ const paymentTrusted = POLICY.classifyLane({ riskSignals: { payment: true } });
2499
+ const forgedCopy = { ...paymentTrusted, lane: 'Direct' };
2500
+ assert.equal(POLICY.lanePolicy(forgedCopy).lane, 'Standard', 'compatibility lane is derived from authoritative axes');
2501
+ });
2502
+
2503
+ test('P0 regression: symlink spec/task containment and malformed spec handling', () => {
2504
+ const RESOLVER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
2505
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-regression-symlink-'));
2506
+ try {
2507
+ const specsDir = path.join(tmp, 'specs');
2508
+ fs.mkdirSync(specsDir, { recursive: true });
2509
+ fs.mkdirSync(path.join(specsDir, 'valid'), { recursive: true });
2510
+ fs.writeFileSync(path.join(specsDir, 'valid', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'valid' }));
2511
+ const outside = path.join(tmp, 'outside');
2512
+ fs.mkdirSync(outside, { recursive: true });
2513
+ fs.writeFileSync(path.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
2514
+ const link = path.join(specsDir, 'linked');
2515
+ fs.symlinkSync(outside, link);
2516
+ const e1 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'linked' });
2517
+ assert.equal(e1.error, 'explicit_malformed');
2518
+ const e2 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(specsDir, 'linked', 'spec.json') });
2519
+ assert.equal(e2.error, 'explicit_malformed');
2520
+ const nonExplicit = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
2521
+ assert.equal(nonExplicit.error, 'invalid_specs');
2522
+ assert.ok(nonExplicit.candidates.includes('linked'));
2523
+ const featDir = path.join(specsDir, 'valid');
2524
+ fs.mkdirSync(path.join(featDir, 'tasks'), { recursive: true });
2525
+ const taskOutside = path.join(tmp, 'outside-task.md');
2526
+ fs.writeFileSync(taskOutside, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
2527
+ const taskLink = path.join(featDir, 'tasks', 'task.md');
2528
+ fs.symlinkSync(taskOutside, taskLink);
2529
+ const codexReceipt = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
2530
+ const fails = codexReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
2531
+ assert.ok(fails.includes('a'), 'task symlink outside should be rejected as check a');
2532
+ fs.mkdirSync(path.join(specsDir, 'bad'), { recursive: true });
2533
+ fs.writeFileSync(path.join(specsDir, 'bad', 'spec.json'), '{ malformed');
2534
+ const mal = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
2535
+ assert.equal(mal.error, 'invalid_specs');
2536
+ assert.ok(mal.candidates.includes('bad'));
2537
+ } finally {
2538
+ fs.rmSync(tmp, { recursive: true, force: true });
2539
+ }
2540
+ });
2541
+
2542
+ test('P0 regression: artifact hash scoped to structured Artifact declaration and explicit failure structured only', () => {
2543
+ // Artifact hash only required when structured Artifact declaration exists
2544
+ const prefix = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
2545
+ const receipt = (suffix = '') => `${prefix}${suffix}`;
2546
+ const digestA = 'a'.repeat(64);
2547
+ const digestB = 'b'.repeat(64);
2548
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Command: npm run artifact:test\n')), [], 'Command containing artifact without hash should pass');
2549
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Notes: artifact behavior without declaration\n')), [], 'Notes containing artifact without structured declaration should pass');
2550
+ assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifact: bundle\n')).includes('artifact_hash'), 'Artifact: without hash should fail');
2551
+ assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifacts: bundle\nsha256: TBD\n')).includes('artifact_hash'), 'Artifacts: with TBD should fail');
2552
+ assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifact produced sha256:deadbeef\n')).includes('artifact_hash'), 'short artifact hash must fail');
2553
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt(`Artifact produced sha256:${digestA}\n`)), [], '64-hex artifact hash should pass');
2554
+
2555
+ const artifactOptions = POLICY.receiptValidatorOptions({ artifacts: ['artifacts/a.js', 'artifacts/b.js'] });
2556
+ assert.ok(POLICY.validateCanonicalReceipt(receipt(`Artifact: artifacts/a.js\nSHA-256: ${digestA}\n`), artifactOptions).includes('artifact_hash'), 'missing second artifact binding must fail');
2557
+ assert.ok(POLICY.validateCanonicalReceipt(receipt(`Artifact: artifacts/a.js + artifacts/b.js\nSHA-256: ${digestA}\n`), artifactOptions).includes('artifact_hash'), 'one hash must not bind a combined multi-artifact declaration');
2558
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt(`Artifact: artifacts/a.js\nSHA-256: ${digestA}\nArtifact: artifacts/b.js\nSHA-256: ${digestB}\n`), artifactOptions), [], 'each declared artifact must bind to its hash');
2559
+ assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifact: artifacts/a.js\nSHA-256: deadbeef\nArtifact: artifacts/b.js\nSHA-256: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n'), artifactOptions).includes('artifact_hash'), 'short per-artifact hash must fail');
2560
+
2561
+ const falseGreen = receipt([
2562
+ 'Tests run: 0',
2563
+ '0 passing',
2564
+ 'Failures: 1',
2565
+ 'failure summary',
2566
+ 'Result: PASS',
2567
+ 'Artifact produced sha256:deadbeef',
2568
+ 'Artifact: bundle + sha256:abc123',
2569
+ ].join('\n') + '\n');
2570
+ assert.ok(POLICY.validateCanonicalReceipt(falseGreen).length > 0, 'false-green independent-review vector must fail');
2571
+
2572
+ // Explicit failure structured only: prose containing failure should not block, Tests failed: 0 should pass
2573
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Notes: verifies failure handling\n')), [], 'Notes with failure handling should not block');
2574
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Description: failure recovery\n')), [], 'Description with failure should not block');
2575
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Tests failed: 0\n')), [], 'Tests failed: 0 should pass');
2576
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('fail 0\n')), [], 'fail 0 should pass');
2577
+ // Structured failures still blocked
2578
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nStatus: FAILED\n').length > 0, 'Status: FAILED should fail');
2579
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nOutcome: FAILURE\n').length > 0, 'Outcome: FAILURE should fail');
2580
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nResult: FAILED\n').length > 0, 'Result: FAILED should fail');
2581
+ // TAP and Jest runner summaries (structured, anchored)
2582
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n# fail 1\n').length > 0, 'TAP # fail 1 should fail');
2583
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nℹ fail 2\n').length > 0, 'TAP ℹ fail 2 should fail');
2584
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nnot ok 1 - test\n').length > 0, 'TAP not ok 1 should fail');
2585
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests: 1 failed, 2 passed\n').length > 0, 'Jest Tests: 1 failed should fail');
2586
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n1 failed, 2 passed\n').length > 0, '1 failed summary should fail');
2587
+ assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n# fail 0\n'), [], 'TAP # fail 0 should pass');
2588
+ assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\nTests: 0 failed\n'), [], 'Tests: 0 failed should pass');
2589
+ });
2590
+
2591
+ test('P0 regression: safeTaskFile fail-closed on realpath error and single authority', () => {
2592
+ const fs2 = require('node:fs');
2593
+ const path2 = require('node:path');
2594
+ const os2 = require('node:os');
2595
+ const specGatePath = path2.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs');
2596
+ const specGateSrc = fs2.readFileSync(specGatePath, 'utf8');
2597
+ // Single authority: Claude hook should delegate to POLICY, not duplicate parser
2598
+ assert.match(specGateSrc, /POLICY\.validateCanonicalReceipt/);
2599
+ assert.doesNotMatch(specGateSrc, /const EXPLICIT_FAILURE/);
2600
+ // Codex and OpenCode should delegate to shared workflow-policy
2601
+ const codexSrc = fs2.readFileSync(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'), 'utf8');
2602
+ assert.match(codexSrc, /getSharedValidate/);
2603
+ assert.match(codexSrc, /workflow-policy\.cjs/);
2604
+ assert.doesNotMatch(codexSrc, /const PLACEHOLDER_TOKENS/);
2605
+ const opencodeSrc = fs2.readFileSync(path2.join(PACKAGE_ROOT, 'src/opencode/plugins/spec-gate.ts'), 'utf8');
2606
+ assert.match(opencodeSrc, /getSharedValidate/);
2607
+ assert.match(opencodeSrc, /workflow-policy\.cjs/);
2608
+ // The parser should only exist in workflow-policy, not in adapters (check that adapter does not define full validate logic)
2609
+ const opencodeValidateCount = (opencodeSrc.match(/function validateCanonicalReceipt/g) || []).length;
2610
+ assert.equal(opencodeValidateCount, 1, 'OpenCode should have only delegating validate, not duplicate parser');
2611
+ // safeTaskFile fail-closed: simulate realpathSync throwing
2612
+ const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-realpath-'));
2613
+ try {
2614
+ const featDir = path2.join(tmp, 'feat');
2615
+ fs2.mkdirSync(path2.join(featDir, 'tasks'), { recursive: true });
2616
+ fs2.writeFileSync(path2.join(featDir, 'tasks', 'task.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
2617
+ const orig = fs2.realpathSync;
2618
+ fs2.realpathSync = () => { throw new Error('simulated realpath failure'); };
2619
+ try {
2620
+ const codexReceipt = require(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
2621
+ const fails = codexReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
2622
+ assert.ok(fails.includes('a'), 'realpath failure should be fail-closed with check a');
2623
+ } finally {
2624
+ fs2.realpathSync = orig;
2625
+ }
2626
+ } finally {
2627
+ fs2.rmSync(tmp, { recursive: true, force: true });
2628
+ }
2629
+ });
2630
+
2631
+ test('P0 regression: resolver fail-closed on canonicalization and lstat errors', () => {
2632
+ const fs2 = require('node:fs');
2633
+ const path2 = require('node:path');
2634
+ const os2 = require('node:os');
2635
+ const RESOLVER = require(path2.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
2636
+ const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-resolver-fail-'));
2637
+ try {
2638
+ const specsDir = path2.join(tmp, 'specs');
2639
+ fs2.mkdirSync(path2.join(specsDir, 'demo'), { recursive: true });
2640
+ fs2.writeFileSync(path2.join(specsDir, 'demo', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'demo' }));
2641
+ // Monkey patch realpathSync to always throw for explicitFeature
2642
+ const origRealpath = fs2.realpathSync;
2643
+ fs2.realpathSync = () => { throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' }); };
2644
+ try {
2645
+ const res = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'demo' });
2646
+ assert.equal(res.error, 'explicit_malformed', 'explicitFeature realpath failure should be explicit_malformed');
2647
+ assert.match(res.reason, /canonicalization error/);
2648
+ } finally {
2649
+ fs2.realpathSync = origRealpath;
2650
+ }
2651
+ // Non-explicit with specs root realpath failure should be invalid_specs <specs>
2652
+ const origRealpath2 = fs2.realpathSync;
2653
+ fs2.realpathSync = (p) => {
2654
+ if (p === specsDir || p === path2.resolve(specsDir)) throw Object.assign(new Error('simulated ELOOP'), { code: 'ELOOP' });
2655
+ return origRealpath2(p);
2656
+ };
2657
+ try {
2658
+ const res2 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
2659
+ assert.equal(res2.error, 'invalid_specs');
2660
+ assert.ok(res2.candidates.includes('<specs>'));
2661
+ } finally {
2662
+ fs2.realpathSync = origRealpath2;
2663
+ }
2664
+ // lstat error for entry (simulate EACCES on lstat)
2665
+ const origLstat = fs2.lstatSync;
2666
+ fs2.lstatSync = (p) => {
2667
+ if (p.includes('demo')) throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' });
2668
+ return origLstat(p);
2669
+ };
2670
+ try {
2671
+ const res3 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
2672
+ assert.equal(res3.error, 'invalid_specs');
2673
+ assert.ok(res3.candidates.includes('demo'));
2674
+ } finally {
2675
+ fs2.lstatSync = origLstat;
2676
+ }
2677
+ // Dangling spec.json symlink should be invalid_specs
2678
+ const outside = path2.join(tmp, 'outside-spec2');
2679
+ fs2.mkdirSync(outside, { recursive: true });
2680
+ fs2.writeFileSync(path2.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
2681
+ const featDir = path2.join(specsDir, 'dangle');
2682
+ fs2.mkdirSync(featDir, { recursive: true });
2683
+ const specLink = path2.join(featDir, 'spec.json');
2684
+ fs2.symlinkSync(path2.join(tmp, 'nonexistent-target'), specLink);
2685
+ const res4 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
2686
+ assert.equal(res4.error, 'invalid_specs');
2687
+ assert.ok(res4.candidates.includes('dangle'));
2688
+ } finally {
2689
+ fs2.rmSync(tmp, { recursive: true, force: true });
2690
+ }
2691
+ // Codex parity: same monkey patch should give explicit_malformed
2692
+ const codexUtils = require(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
2693
+ const tmp2 = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-codex-fail-'));
2694
+ try {
2695
+ const specsDir2 = path2.join(tmp2, 'specs');
2696
+ fs2.mkdirSync(path2.join(specsDir2, 'demo2'), { recursive: true });
2697
+ fs2.writeFileSync(path2.join(specsDir2, 'demo2', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'demo2' }));
2698
+ const orig = fs2.realpathSync;
2699
+ fs2.realpathSync = () => { throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' }); };
2700
+ try {
2701
+ const r = codexUtils.resolveActiveSpec(tmp2, {}, 'demo2', null);
2702
+ assert.equal(r.error, 'explicit_malformed');
2703
+ } finally {
2704
+ fs2.realpathSync = orig;
2705
+ }
2706
+ } finally {
2707
+ fs2.rmSync(tmp2, { recursive: true, force: true });
2708
+ }
2709
+ });
2710
+
2711
+ test('P0 canonical phantom vectors - zero, cancellation, suite, FAIL, error, collected (r3)', () => {
2712
+ const base = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
2713
+ // 1. Zero execution
2714
+ const zeroFail = [
2715
+ 'ℹ tests 0',
2716
+ 'Tests: 0 total',
2717
+ '0 tests',
2718
+ 'Tests: 0 passed',
2719
+ 'Tests: 0 passing',
2720
+ 'Test Suites: 0 failed, 0 passed',
2721
+ 'Tests passed: 0',
2722
+ 'Passed: 0',
2723
+ 'one failing',
2724
+ 'failureCount: 1',
2725
+ 'collected 0 items',
2726
+ 'No tests found.',
2727
+ 'No tests found',
2728
+ ];
2729
+ for (const v of zeroFail) {
2730
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `zero execution should fail: ${v}`);
2731
+ }
2732
+ // 2. Cancellation N>0
2733
+ const cancelFail = [
2734
+ 'ℹ cancelled 1',
2735
+ 'cancelled 1',
2736
+ '1 cancelled',
2737
+ 'canceled 1',
2738
+ '1 canceled',
2739
+ ];
2740
+ for (const v of cancelFail) {
2741
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `cancel should fail: ${v}`);
2742
+ }
2743
+ // 3. Suite / Files
2744
+ const suiteFail = [
2745
+ 'Test Suites: 1 failed, 1 total',
2746
+ 'Test Suites: 2 failed',
2747
+ 'Test Files: 1 failed',
2748
+ 'Test Files 1 failed',
2749
+ ];
2750
+ for (const v of suiteFail) {
2751
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `suite should fail: ${v}`);
2752
+ }
2753
+ // 4. Runner FAIL
2754
+ const runnerFail = [
2755
+ 'FAIL ./foo.test.js',
2756
+ 'FAIL\tpackage',
2757
+ 'FAIL\texample.test/probe\t0.431s',
2758
+ '--- FAIL: TestName (0.00s)',
2759
+ 'FAIL',
2760
+ 'FAIL: something',
2761
+ 'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
2762
+ 'FAILED tests/test_demo.py',
2763
+ '# fail 1',
2764
+ 'ℹ fail 1',
2765
+ 'not ok 1 - test',
2766
+ ];
2767
+ for (const v of runnerFail) {
2768
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `runner FAIL should fail: ${v}`);
2769
+ }
2770
+ // also standalone FAILED/FAILURE bare/colon
2771
+ for (const v of ['FAILED', 'FAILURE', 'FAILED:', 'FAILURE:']) {
2772
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `standalone ${v} should fail`);
2773
+ }
2774
+ // 5. Error summaries N>0
2775
+ const errorFail = [
2776
+ '1 error in 0.12s',
2777
+ '2 errors',
2778
+ 'ERROR collecting tests/test_demo.py',
2779
+ 'ERROR',
2780
+ 'Found 2 errors',
2781
+ 'Tests run: 5, Failures: 1, Errors: 0',
2782
+ '[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
2783
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
2784
+ '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
2785
+ '5 tests completed, 1 failed',
2786
+ ];
2787
+ for (const v of errorFail) {
2788
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `error should fail: ${v}`);
2789
+ }
2790
+ // Positive controls must pass
2791
+ const pass = [
2792
+ 'Tests failed: 0',
2793
+ 'fail 0',
2794
+ 'Tests: 0 failed',
2795
+ 'cancelled 0',
2796
+ 'canceled 0',
2797
+ 'Test Suites: 0 failed',
2798
+ 'Test Files: 0 failed',
2799
+ '5 tests completed, 0 failed',
2800
+ 'Tests run: 5, Failures: 0, Errors: 0',
2801
+ '# tests 1',
2802
+ '# suites 0',
2803
+ '# pass 1',
2804
+ '# fail 0',
2805
+ '# cancelled 0',
2806
+ '# skipped 0',
2807
+ '# todo 0',
2808
+ '# duration_ms 114.203625',
2809
+ '[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
2810
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
2811
+ '[ERROR] Error handling is documented',
2812
+ 'errors 0',
2813
+ 'Found 0 errors',
2814
+ 'tests 5',
2815
+ 'collected 1 item',
2816
+ 'collected 5 items',
2817
+ 'Notes: handles error/failure',
2818
+ 'Notes: verifies failure handling',
2819
+ 'Notes: Tests: 0 passed',
2820
+ 'Description: Tests passed: 0',
2821
+ 'Notes: one failing test is documented',
2822
+ 'Description: failureCount: 1 is documented',
2823
+ 'one failing test: 1 is documented',
2824
+ 'failureCount: 1 is documented',
2825
+ 'Tests failed previously but now fixed',
2826
+ 'Failure handling is documented',
2827
+ 'failure summary follows in the notes',
2828
+ 'ERROR handling is documented',
2829
+ 'Summary: failureCount: 1 is documented',
2830
+ 'Error handling is documented',
2831
+ 'FAILURE mode analysis',
2832
+ 'Notes: tests failed previously but now fixed',
2833
+ 'Notes: supports zero-test parsing',
2834
+ 'Command: npm run test --errorFlag',
2835
+ 'Command: npm run artifact:test',
2836
+ ];
2837
+ for (const v of pass) {
2838
+ // Command lines are skipped; others should not trigger hasExplicitFailure
2839
+ const body = v.startsWith('Command:') ? `Verification: PASS\n${v}\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n` : base + v + '\n';
2840
+ assert.deepEqual(POLICY.validateCanonicalReceipt(body), [], `should pass: ${v}`);
2841
+ }
2842
+ assert.equal(POLICY.isTapMetadataHeading('# fail 1'), true);
2843
+ assert.equal(POLICY.isTapMetadataHeading('# duration_ms 114.203625'), true);
2844
+ assert.equal(POLICY.isTapMetadataHeading('# Notes'), false);
2845
+ assert.equal(POLICY.isTapMetadataHeading('# pass 1 explanation'), false);
2846
+ });