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

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 (49) hide show
  1. package/bin/__tests__/codex-hooks.test.js +355 -12
  2. package/bin/__tests__/codex-native.test.js +1 -1
  3. package/bin/__tests__/develop-contract.test.js +1374 -78
  4. package/bin/__tests__/installer-safety.test.js +43 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +791 -0
  6. package/bin/__tests__/package-inventory.test.js +157 -0
  7. package/bin/__tests__/validator-grounding.test.js +707 -2
  8. package/package.json +1 -1
  9. package/src/claude/agents/spec-maker.md +107 -200
  10. package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
  11. package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
  12. package/src/claude/hooks/completion-authority-check.cjs +307 -0
  13. package/src/claude/hooks/completion-authority-state.cjs +525 -0
  14. package/src/claude/hooks/completion-authority.cjs +148 -0
  15. package/src/claude/hooks/session.cjs +33 -19
  16. package/src/claude/hooks/spec-gate.cjs +173 -61
  17. package/src/claude/hooks/spec-state.cjs +104 -45
  18. package/src/claude/migration-manifest.json +6 -1
  19. package/src/claude/scripts/provenance.cjs +315 -0
  20. package/src/claude/scripts/spec-resolver.cjs +450 -0
  21. package/src/claude/scripts/spec-scaffold.cjs +716 -41
  22. package/src/claude/scripts/validate-spec-output.cjs +355 -53
  23. package/src/claude/scripts/workflow-policy.cjs +1529 -162
  24. package/src/claude/settings/settings.json +8 -0
  25. package/src/claude/skills/code-review/SKILL.md +85 -66
  26. package/src/claude/skills/develop/SKILL.md +153 -285
  27. package/src/claude/skills/develop/references/quality-gate.md +116 -148
  28. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  29. package/src/claude/skills/specs/SKILL.md +170 -381
  30. package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
  31. package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
  32. package/src/claude/skills/specs/templates/spec-state.json +23 -2
  33. package/src/claude/skills/specs/templates/task.md +20 -0
  34. package/src/claude/skills/sync/SKILL.md +15 -8
  35. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  36. package/src/claude/skills/test/SKILL.md +141 -167
  37. package/src/codex/hooks/completion-authority-check.cjs +260 -0
  38. package/src/codex/hooks/completion-authority-state.cjs +525 -0
  39. package/src/codex/hooks/completion-authority.cjs +100 -0
  40. package/src/codex/hooks/lib/hook-context.cjs +42 -2
  41. package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
  42. package/src/codex/hooks/lib/spec-utils.cjs +53 -27
  43. package/src/codex/hooks/session.cjs +2 -0
  44. package/src/codex/hooks/spec-gate.cjs +132 -20
  45. package/src/codex/hooks/spec-state.cjs +56 -10
  46. package/src/codex/hooks.json +10 -0
  47. package/src/opencode/AGENTS.md +4 -2
  48. package/src/opencode/plugins/spec-gate.ts +296 -122
  49. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -6,11 +6,12 @@ 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'));
14
15
  const DEVELOP = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/SKILL.md');
15
16
  const GATE = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/references/quality-gate.md');
16
17
  const TEST_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/test/SKILL.md');
@@ -18,6 +19,65 @@ const SYNC_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/sync/SKILL.md');
18
19
  const CODE_REVIEW_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/code-review/SKILL.md');
19
20
  const PARALLEL_WAVES = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/references/parallel-waves.md');
20
21
  const PROVENANCE = path.join(PACKAGE_ROOT, '../../docs/provenance.md');
22
+ const RUNTIME_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-policy-runtime-'));
23
+ const RUNTIME_SPEC = path.join(RUNTIME_ROOT, 'specs', 'demo', 'spec.json');
24
+ fs.mkdirSync(path.dirname(RUNTIME_SPEC), { recursive: true });
25
+ fs.mkdirSync(path.join(RUNTIME_ROOT, 'src'), { recursive: true });
26
+ fs.writeFileSync(path.join(RUNTIME_ROOT, 'src', 'app.js'), 'runtime fixture\n');
27
+ fs.writeFileSync(RUNTIME_SPEC, JSON.stringify({ status: 'in_progress', feature_name: 'demo', task_registry: {} }));
28
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['add', '-A'], ['commit', '-qm', 'fixture']]) {
29
+ const result = spawnSync('git', ['-C', RUNTIME_ROOT, ...args], { encoding: 'utf8' });
30
+ assert.equal(result.status, 0, result.stderr);
31
+ }
32
+ const RUNTIME_CONTEXT = PROVENANCE_HELPER.deriveRuntimeContext({
33
+ projectRoot: RUNTIME_ROOT,
34
+ specsRoot: path.join(RUNTIME_ROOT, 'specs'),
35
+ specFile: RUNTIME_SPEC,
36
+ featureName: 'demo',
37
+ runtimeSession: 'implementation-session-1',
38
+ });
39
+ after(() => fs.rmSync(RUNTIME_ROOT, { recursive: true, force: true }));
40
+ const EXPECTED_BASE = RUNTIME_CONTEXT.base;
41
+ const EXPECTED_HEAD = RUNTIME_CONTEXT.head;
42
+ const EXPECTED_PROVENANCE = { base: EXPECTED_BASE, head: EXPECTED_HEAD };
43
+ const RECEIPT_BINDING = POLICY.createReceiptBinding(RUNTIME_CONTEXT);
44
+
45
+ function initFixtureGit(root) {
46
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['add', '-A'], ['commit', '-qm', 'fixture']]) {
47
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
48
+ assert.equal(result.status, 0, result.stderr);
49
+ }
50
+ }
51
+
52
+ function bindFixtureReceipt(root, value, feature = 'demo', session = 'test') {
53
+ const context = PROVENANCE_HELPER.deriveRuntimeContext({
54
+ projectRoot: root,
55
+ specsRoot: path.join(root, 'specs'),
56
+ specFile: path.join(root, 'specs', feature, 'spec.json'),
57
+ featureName: feature,
58
+ runtimeSession: session,
59
+ });
60
+ return value.replaceAll('0123456789abcdef0123456789abcdef01234567', context.base)
61
+ .replaceAll('89abcdef0123456789abcdef0123456789abcdef', context.head);
62
+ }
63
+
64
+ function canonicalReceipt(command = 'pnpm test', extra = '') {
65
+ return `Verification: PASS\nCommand: ${command}\nExit: 0\nBase: ${EXPECTED_BASE}\nHead: ${EXPECTED_HEAD}\n${extra}`;
66
+ }
67
+
68
+ function canonicalFlashTask(overrides = {}) {
69
+ return {
70
+ status: 'in_progress',
71
+ receipt: 'FLASH_UNVERIFIED',
72
+ blocker: 'awaiting /hapo:test <feature>',
73
+ dependencyBlocked: true,
74
+ unblocks: false,
75
+ feature_name: 'demo',
76
+ runtime_context: RUNTIME_CONTEXT,
77
+ expected_provenance: { ...EXPECTED_PROVENANCE },
78
+ ...overrides,
79
+ };
80
+ }
21
81
 
22
82
  function read(filePath) {
23
83
  return fs.readFileSync(filePath, 'utf8');
@@ -56,16 +116,17 @@ test('develop pre-state guard names executable policy contract', () => {
56
116
  assert.match(develop, /flash\+parallel fail-fast/i);
57
117
  });
58
118
 
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}`);
119
+ test('delegation plan consumes legacy tiers as obligations without an agent chain', () => {
120
+ const light = POLICY.delegationPlan({ tier: 'Light', mode: 'full-spec', taskCount: 2 });
121
+ const standard = POLICY.delegationPlan({ tier: 'Standard', mode: 'specific-task' });
122
+ const deep = POLICY.delegationPlan({ tier: 'Deep', mode: 'full-spec', taskCount: 2 });
123
+ assert.deepEqual(light.proof_obligations, ['needsExecutionProof']);
124
+ assert.deepEqual(standard.proof_obligations, ['needsInspection', 'needsExecutionProof']);
125
+ assert.deepEqual(deep.proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit']);
126
+ for (const plan of [light, standard, deep]) {
127
+ assert.equal(Object.hasOwn(plan, 'delegated'), false);
128
+ assert.doesNotMatch(JSON.stringify(plan), /inspector|implementer|test-runner|code-auditor/);
129
+ }
69
130
  assert.doesNotMatch(read(GATE), /spec-review|quality-review/);
70
131
  assert.doesNotMatch(read(DEVELOP), /spec-review|quality-review/);
71
132
  });
@@ -75,8 +136,12 @@ test('lane classifier selects Direct for explicit reversible low-risk work', ()
75
136
  assert.equal(result.lane, 'Direct');
76
137
  assert.equal(result.automaticLane, 'Direct');
77
138
  assert.deepEqual(result.risks, []);
78
- assert.deepEqual(POLICY.delegationPlan({ lane: 'Direct' }).delegated, []);
79
- assert.equal(POLICY.lanePolicy(result).requiresSpec, false);
139
+ const policy = POLICY.lanePolicy(result);
140
+ assert.deepEqual(policy.proof_obligations, ['needsExecutionProof']);
141
+ assert.equal(policy.artifact_profile, 'targeted');
142
+ assert.equal(policy.requiresSpec, false);
143
+ assert.equal(policy.requiresState, false);
144
+ assert.equal(policy.proof_obligations.includes('needsDurableTaskState'), false);
80
145
  });
81
146
 
82
147
  test('lane classifier forces Critical for named high-risk signals', () => {
@@ -86,23 +151,198 @@ test('lane classifier forces Critical for named high-risk signals', () => {
86
151
  });
87
152
  assert.equal(result.lane, 'Critical');
88
153
  assert.deepEqual(result.risks, ['auth', 'migration', 'publicContract']);
89
- assert.deepEqual(POLICY.delegationPlan({ lane: 'Critical' }).delegated, [
90
- 'inspector', 'implementer', 'test-runner', 'code-auditor',
154
+ assert.deepEqual(POLICY.delegationPlan({ lane: 'Critical' }).proof_obligations, [
155
+ 'needsInspection', 'needsExecutionProof', 'needsIndependentAudit',
91
156
  ]);
92
157
  });
93
158
 
159
+ test('P1 persists one strict workflow-policy snapshot and rejects malformed snapshots', () => {
160
+ const initial = POLICY.persistWorkflowPolicySnapshot(
161
+ { feature_name: 'bounded', override_receipt: 'legacy-duplicate' },
162
+ { riskSignals: {} },
163
+ );
164
+ const snapshot = initial.workflow_policy;
165
+ assert.deepEqual(Object.keys(snapshot).sort(), [...POLICY.WORKFLOW_POLICY_FIELDS].sort());
166
+ assert.equal(snapshot.version, '1');
167
+ assert.equal(snapshot.lane, 'Standard');
168
+ assert.equal(snapshot.automatic_lane, 'Standard');
169
+ assert.equal(snapshot.artifact_profile, 'bounded');
170
+ assert.deepEqual(snapshot.proof_obligations, ['needsInspection', 'needsExecutionProof']);
171
+ assert.equal(Object.hasOwn(initial, 'override_receipt'), false);
172
+ assert.equal(POLICY.validateWorkflowPolicySnapshot(snapshot).valid, true);
173
+
174
+ const persistedAgain = POLICY.persistWorkflowPolicySnapshot(initial, {
175
+ riskSignals: { auth: true },
176
+ override: 'Direct',
177
+ });
178
+ assert.deepEqual(persistedAgain.workflow_policy, snapshot, 'persist-once must not reclassify an existing snapshot');
179
+
180
+ const malformed = { ...snapshot, artifact_profile: 'strict', extra: true };
181
+ const validation = POLICY.validateWorkflowPolicySnapshot(malformed);
182
+ assert.equal(validation.valid, false);
183
+ assert.match(validation.errors.join('; '), /fields must be exactly|artifact_profile/);
184
+ assert.throws(() => POLICY.readWorkflowPolicySnapshot({ workflow_policy: malformed }), /Invalid workflow policy snapshot/);
185
+
186
+ const malformedShape = { ...snapshot, proof_obligations: { needsExecutionProof: true } };
187
+ const shapeValidation = POLICY.validateWorkflowPolicySnapshot(malformedShape);
188
+ assert.equal(shapeValidation.valid, false);
189
+ assert.match(shapeValidation.errors.join('; '), /proof_obligations must be an array/);
190
+
191
+ const malformedRisks = { ...snapshot, risks: { auth: true } };
192
+ const risksValidation = POLICY.validateWorkflowPolicySnapshot(malformedRisks);
193
+ assert.equal(risksValidation.valid, false);
194
+ assert.match(risksValidation.errors.join('; '), /risks must be an array/);
195
+ });
196
+
197
+ test('P1 workflow-policy validation is semantic and explicit workflow_policy values never reclassify', () => {
198
+ const direct = POLICY.workflowPolicySnapshot({ reversible: true, lowRisk: true, isolated: true });
199
+ const standardUnknown = POLICY.escalateWorkflowPolicy(direct, { risks: ['unclassified-risk'] });
200
+ const criticalAuth = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
201
+ const escalatedCritical = POLICY.escalateWorkflowPolicy(direct, { risks: ['auth'] });
202
+
203
+ for (const legitimate of [direct, standardUnknown, criticalAuth, escalatedCritical]) {
204
+ assert.equal(POLICY.validateWorkflowPolicySnapshot(legitimate).valid, true);
205
+ }
206
+ assert.equal(escalatedCritical.lane, 'Critical');
207
+ assert.equal(escalatedCritical.automatic_lane, 'Direct');
208
+
209
+ const forgedDirectAuth = { ...direct, risks: ['auth'] };
210
+ const forgedDirectUnknown = { ...direct, risks: ['unclassified-risk'] };
211
+ const forgedStandardAuth = { ...standardUnknown, risks: ['auth'] };
212
+ for (const forged of [forgedDirectAuth, forgedDirectUnknown, forgedStandardAuth]) {
213
+ const result = POLICY.validateWorkflowPolicySnapshot(forged);
214
+ assert.equal(result.valid, false);
215
+ assert.match(result.errors.join('; '), /at least (Standard|Critical)/);
216
+ }
217
+ for (const riskAlias of ['Auth', 'authentication', 'public_contract']) {
218
+ const escalated = POLICY.escalateWorkflowPolicy(direct, { risks: [riskAlias] });
219
+ assert.equal(escalated.lane, 'Critical', `${riskAlias} must classify as Critical`);
220
+ assert.equal(POLICY.validateWorkflowPolicySnapshot({ ...direct, risks: [riskAlias] }).valid, false);
221
+ }
222
+ assert.throws(() => POLICY.assertWorkflowPolicySnapshot(forgedDirectAuth), /Invalid workflow policy snapshot/);
223
+ assert.throws(() => POLICY.readWorkflowPolicySnapshot({ workflow_policy: forgedDirectAuth }), /Invalid workflow policy snapshot/);
224
+ assert.throws(() => POLICY.lanePolicy({ workflow_policy: forgedDirectAuth }), /Invalid workflow policy snapshot/);
225
+
226
+ for (const explicitValue of [null, undefined]) {
227
+ const state = { workflow_policy: explicitValue, reversible: true, lowRisk: true, isolated: true };
228
+ assert.throws(() => POLICY.workflowPolicySnapshot(state), /Invalid workflow policy snapshot/);
229
+ assert.throws(() => POLICY.escalateWorkflowPolicy(state, {}), /Invalid workflow policy snapshot/);
230
+ assert.throws(() => POLICY.lanePolicy(state), /Invalid workflow policy snapshot/);
231
+ }
232
+
233
+ const forgedLanePolicyCli = spawnSync(process.execPath, [
234
+ POLICY_PATH,
235
+ '--lane-policy',
236
+ '--task-json',
237
+ JSON.stringify({ workflow_policy: forgedDirectAuth }),
238
+ '--json',
239
+ ], { encoding: 'utf8' });
240
+ assert.equal(forgedLanePolicyCli.status, 2, `${forgedLanePolicyCli.stdout}\n${forgedLanePolicyCli.stderr}`);
241
+ assert.match(forgedLanePolicyCli.stderr, /Invalid workflow policy snapshot/);
242
+
243
+ const nullLanePolicyCli = spawnSync(process.execPath, [
244
+ POLICY_PATH,
245
+ '--lane-policy',
246
+ '--task-json',
247
+ JSON.stringify({ workflow_policy: null, reversible: true, lowRisk: true, isolated: true }),
248
+ '--json',
249
+ ], { encoding: 'utf8' });
250
+ assert.equal(nullLanePolicyCli.status, 2, `${nullLanePolicyCli.stdout}\n${nullLanePolicyCli.stderr}`);
251
+ assert.match(nullLanePolicyCli.stderr, /Invalid workflow policy snapshot/);
252
+
253
+ const validatePolicyCli = spawnSync(process.execPath, [
254
+ POLICY_PATH,
255
+ '--validate-policy',
256
+ '--task-json',
257
+ JSON.stringify({ workflow_policy: forgedDirectAuth }),
258
+ '--json',
259
+ ], { encoding: 'utf8' });
260
+ assert.equal(validatePolicyCli.status, 2, `${validatePolicyCli.stdout}\n${validatePolicyCli.stderr}`);
261
+ assert.equal(JSON.parse(validatePolicyCli.stdout).valid, false);
262
+
263
+ const validateNullPolicyCli = spawnSync(process.execPath, [
264
+ POLICY_PATH,
265
+ '--validate-policy',
266
+ '--task-json',
267
+ JSON.stringify({ workflow_policy: null }),
268
+ '--json',
269
+ ], { encoding: 'utf8' });
270
+ assert.equal(validateNullPolicyCli.status, 2, `${validateNullPolicyCli.stdout}\n${validateNullPolicyCli.stderr}`);
271
+ assert.equal(JSON.parse(validateNullPolicyCli.stdout).valid, false);
272
+ });
273
+
274
+ test('P1 workflow policy escalation is monotonic across all newly classified risks', () => {
275
+ const direct = POLICY.workflowPolicySnapshot({ reversible: true, lowRisk: true, isolated: true });
276
+ assert.equal(direct.lane, 'Direct');
277
+ assert.deepEqual(POLICY.escalateWorkflowPolicy(direct, {}), direct, 'no new risks must not escalate');
278
+
279
+ const standard = POLICY.escalateWorkflowPolicy(direct, { risks: ['new-standard-risk'], riskLevel: 'standard' });
280
+ assert.equal(standard.lane, 'Standard');
281
+ assert.equal(standard.artifact_profile, 'bounded');
282
+ assert.equal(standard.risks.includes('new-standard-risk'), true);
283
+ assert.deepEqual(standard.proof_obligations, ['needsInspection', 'needsExecutionProof']);
284
+
285
+ const ambiguityCritical = POLICY.escalateWorkflowPolicy(direct, { riskSignals: { ambiguity: true } });
286
+ assert.equal(ambiguityCritical.lane, 'Critical', 'every classified Critical risk must escalate to Critical');
287
+
288
+ const critical = POLICY.escalateWorkflowPolicy(standard, { risks: ['auth'] });
289
+ assert.equal(critical.lane, 'Critical');
290
+ assert.equal(critical.artifact_profile, 'strict');
291
+ assert.equal(critical.proof_obligations.includes('needsIndependentAudit'), true);
292
+ assert.equal(critical.proof_obligations.includes('needsResearchGrounding'), true);
293
+
294
+ const noDowngrade = POLICY.escalateWorkflowPolicy(critical, { risks: ['another-standard-risk'], riskLevel: 'standard' });
295
+ assert.equal(noDowngrade.lane, 'Critical');
296
+ assert.equal(noDowngrade.proof_obligations.includes('needsIndependentAudit'), true);
297
+ assert.equal(noDowngrade.proof_obligations.includes('needsResearchGrounding'), true);
298
+
299
+ const criticalAgain = POLICY.escalateWorkflowPolicy(critical, { risks: ['auth'] });
300
+ assert.deepEqual(criticalAgain, critical, 'repeating known risks must not create a new escalation');
301
+ });
302
+
303
+ test('P1 legacy execution tier is read-compatible without becoming emitted policy authority', () => {
304
+ const legacy = { design_context: { execution_tier: 'Deep' } };
305
+ const snapshot = POLICY.workflowPolicySnapshot(legacy);
306
+ assert.equal(snapshot.lane, 'Critical');
307
+ assert.equal(snapshot.artifact_profile, 'strict');
308
+ const policy = POLICY.lanePolicy(legacy);
309
+ assert.equal(policy.lane, 'Critical');
310
+ assert.equal(Object.hasOwn(policy, 'execution_tier'), false);
311
+ assert.equal(Object.hasOwn(policy, 'executionTier'), false);
312
+ const legacyPlan = POLICY.delegationPlan({ tier: 'Deep' });
313
+ assert.equal(Object.hasOwn(legacyPlan, 'execution_tier'), false);
314
+ assert.equal(Object.hasOwn(legacyPlan, 'executionTier'), false);
315
+ assert.deepEqual(legacyPlan.proof_obligations, snapshot.proof_obligations);
316
+ assert.equal(POLICY.workflowPolicySnapshot({ design_context: { execution_tier: 'standard' } }).lane, 'Standard');
317
+ });
318
+
94
319
  test('lane override keeps automatic result and surfaces downgrade warning', () => {
95
- const result = POLICY.classifyLane({
320
+ const validReceipt = {
321
+ automaticLane: 'Critical',
322
+ requestedLane: 'Direct',
323
+ risks: ['privacy'],
324
+ source: 'runtime',
325
+ timestamp: '2026-08-11T00:00:00.000Z',
326
+ sessionId: 'test-session-123',
327
+ };
328
+ // P0.4 fail-closed: even shape-correct runtime receipt without verified issuer is rejected
329
+ assert.throws(() => POLICY.classifyLane({
96
330
  reversible: true,
97
331
  riskSignals: { privacy: true },
98
332
  override: 'Direct',
99
- userAuthorized: true,
100
- });
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);
333
+ overrideReceipt: validReceipt,
334
+ }), /is blocked.*no trusted runtime issuer/);
335
+ // Also verify that a receipt with verified flag still fails if not properly bound? For now fail-closed.
336
+ const forgedVerified = { ...validReceipt, verifiedByRuntime: true };
337
+ // Still blocked because we require additional binding beyond shape; keep blocked until trusted issuer lands
338
+ // (The verified flag makes isValidOverrideReceipt check pass lane binding, but we keep fail-closed by requiring internal issuer proof that tests don't have)
339
+ // For P0 we keep downgrade blocked regardless — this assert documents fail-closed.
340
+ assert.throws(() => POLICY.classifyLane({
341
+ reversible: true,
342
+ riskSignals: { privacy: true },
343
+ override: 'Direct',
344
+ overrideReceipt: forgedVerified,
345
+ }), /is blocked.*no trusted runtime issuer/);
106
346
  });
107
347
 
108
348
  test('unsafe downgrade without user authorization is blocked', () => {
@@ -110,7 +350,7 @@ test('unsafe downgrade without user authorization is blocked', () => {
110
350
  reversible: true,
111
351
  riskSignals: { privacy: true },
112
352
  override: 'Direct',
113
- }), /requires explicit user authorization/);
353
+ }), /is blocked.*no trusted runtime issuer/);
114
354
  // also via CLI
115
355
  const cli = spawnSync(process.execPath, [
116
356
  POLICY_PATH,
@@ -120,7 +360,7 @@ test('unsafe downgrade without user authorization is blocked', () => {
120
360
  '--json',
121
361
  ], { encoding: 'utf8' });
122
362
  assert.equal(cli.status, 2);
123
- assert.match(cli.stderr, /requires explicit user authorization/);
363
+ assert.match(cli.stderr, /is blocked.*no trusted runtime issuer/);
124
364
  });
125
365
 
126
366
  test('forged approval via legacy approved field is rejected', () => {
@@ -159,6 +399,29 @@ test('approval state never infers user approval from generated or agent validati
159
399
  assert.equal(JSON.parse(cli.stdout).state.user_approved, false);
160
400
  });
161
401
 
402
+ test('approval schema fails closed for explicit null, array, empty, and malformed states', () => {
403
+ const absent = POLICY.validateApprovalSchema({});
404
+ assert.equal(absent.valid, true);
405
+ assert.equal(absent.absent, true);
406
+ assert.equal(POLICY.approvalState({}).present, false);
407
+
408
+ const invalidSpecs = [
409
+ [],
410
+ { approvals: null },
411
+ { approvals: [] },
412
+ { approvals: {} },
413
+ { approvals: { requirements: null } },
414
+ { approvals: { requirements: { generated: true, agent_validated: true, user_approved: null } } },
415
+ { approvals: { requirements: { generated: true, agent_validated: true, user_approved: true, extra: false } } },
416
+ ];
417
+ for (const spec of invalidSpecs) {
418
+ assert.equal(POLICY.validateApprovalSchema(spec).valid, false, `invalid approval should fail: ${JSON.stringify(spec)}`);
419
+ assert.throws(() => POLICY.approvalState(spec), /approval|approvals|schema/i);
420
+ }
421
+ assert.throws(() => POLICY.approvalState({ schema_version: null }), /schema_version/);
422
+ assert.throws(() => POLICY.approvalState({ schema_version: '1.0' }), /schema_version/);
423
+ });
424
+
162
425
  test('CLI exposes JSON lane classification and develop has no universal spec hard gate', () => {
163
426
  const cli = spawnSync(process.execPath, [
164
427
  POLICY_PATH,
@@ -173,40 +436,204 @@ test('CLI exposes JSON lane classification and develop has no universal spec har
173
436
  assert.equal(payload.policy.requiresSpec, false);
174
437
  const develop = read(DEVELOP);
175
438
  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);
439
+ assert.match(develop, /Direct.*Skip spec\/state/i);
440
+ assert.match(develop, /Standard.*bounded.*spec profile/i);
441
+ assert.match(develop, /workflow-policy snapshot/i);
442
+ assert.match(develop, /user_approved[\s\S]*explicit user[\s\S]*approval/i);
443
+ assert.match(develop, /Critical.*strict profile/i);
444
+ assert.match(develop, /Classify lane[\s\S]*test receipt[\s\S]*docs-impact sync/i);
180
445
  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);
446
+ assert.match(develop, /--notes.*opt-in/i);
447
+ assert.doesNotMatch(develop, /--no-notes/);
448
+ assert.doesNotMatch(develop, /planner|implementer|test-runner|code-auditor|docs-keeper/i);
449
+ const specsSkill = read(path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md'));
450
+ assert.match(specsSkill, /Standard[\s\S]*bounded[\s\S]*exactly `spec\.json`, `requirements\.md`, `design\.md`, and one `feature-receipt\.md`/i);
451
+ assert.match(specsSkill, /persisted.*spec\.json\.workflow_policy/i);
452
+ assert.match(specsSkill, /explicit user approval/i);
182
453
  });
183
454
 
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/);
455
+ test('P2 reduces ceremony without weakening lane and proof contracts', () => {
456
+ const specs = read(path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md'));
457
+ const develop = read(DEVELOP);
458
+ const testSkill = read(TEST_SKILL);
459
+ const review = read(CODE_REVIEW_SKILL);
460
+ const gate = read(GATE);
461
+ const specMaker = read(path.join(PACKAGE_ROOT, 'src/claude/agents/spec-maker.md'));
462
+
463
+ assert.ok(specs.trimEnd().split('\n').length >= 150 && specs.trimEnd().split('\n').length <= 220);
464
+ assert.ok(develop.trimEnd().split('\n').length >= 140 && develop.trimEnd().split('\n').length <= 200);
465
+ assert.match(specs, /Direct[\s\S]*Do not create a spec/i);
466
+ assert.ok(specs.includes('Do not require') && specs.includes('task_registry'));
467
+ assert.match(specs, /Critical[\s\S]*registry\/DAG[\s\S]*research/i);
468
+ assert.match(specs, /Load\s+`rules\/ears-format\.md` when/);
469
+ assert.match(specs, /Task scoring is optional advisory/);
470
+ assert.match(specs, /Cynefin is advisory/);
471
+ assert.match(specs, /execution_tier[\s\S]*legacy read adapter/i);
472
+ assert.match(specs, /spec-ready[\s\S]*PENDING/i);
473
+ assert.match(specs, /canonical execution receipt/i);
474
+ assert.match(specs, /Audit: PASS.*not.*evidence/i);
475
+ assert.match(specs, /Set `ready_for_implementation = true` only[\s\S]*deterministic spec validation[\s\S]*grounded requirement\/task mappings and contracts/i);
476
+ assert.match(specs, /neither a[\s\S]*canonical execution receipt[\s\S]*independent closeout audit is required yet/i);
477
+ assert.match(specs, /After implementation, feature\/task closeout is a separate gate[\s\S]*canonical execution receipt/i);
478
+
479
+ assert.match(develop, /specific-task mode never selects or chains/i);
480
+ assert.match(develop, /exactly one closeout owner/i);
481
+ assert.match(develop, /test owner[\s\S]*canonical execution receipt/i);
482
+ assert.match(develop, /review owner[\s\S]*never creates or claims execution proof/i);
483
+ assert.match(develop, /docs impact/i);
484
+ assert.match(gate, /no fixed Light\/Standard\/Deep agent sequence/i);
485
+ assert.match(gate, /no\s+blocking Medium finding/i);
486
+ assert.match(gate, /Finding count never selects review depth/i);
487
+ assert.doesNotMatch(gate, /at most one Medium/i);
488
+ assert.match(testSkill, /sole owner of canonical execution proof/i);
489
+ assert.match(review, /does not[\s\S]*canonical execution[\s\S]*receipt/i);
490
+ assert.match(review, /lane, risk, and blast radius/i);
491
+ assert.match(review, /no\s+blocking Medium finding/i);
492
+ assert.match(review, /Finding count never selects depth/i);
493
+ assert.match(specMaker, /no canonical execution receipt[\s\S]*independent closeout audit is required[\s\S]*yet/i);
494
+ assert.match(specMaker, /passing deterministic spec validation/i);
495
+ for (const content of [develop, gate, review]) {
496
+ assert.doesNotMatch(content, /planner|implementer|test-runner|code-auditor|docs-keeper/i);
497
+ }
498
+ });
499
+
500
+ test('canonical verdict adapter handles completion and unfinished decisions', () => {
501
+ assert.deepEqual(POLICY.REVIEW_VERDICTS, ['PASS', 'PASS_WITH_WARNINGS', 'FAIL', 'BLOCKED']);
502
+ assert.doesNotThrow(() => POLICY.assertVerdict('PASS_WITH_WARNINGS'));
503
+ assert.throws(() => POLICY.assertVerdict('PARTIAL'), /Unsupported canonical verdict/);
504
+ assert.equal(POLICY.consumeReviewVerdict('PASS').completion, 'unfinished');
505
+ assert.equal(POLICY.consumeReviewVerdict('PASS').unfinished, true);
506
+ assert.equal(POLICY.consumeReviewVerdict('PASS').createsCompletion, false);
507
+ assert.equal(POLICY.consumeReviewVerdict('PASS_WITH_WARNINGS').unfinished, true);
508
+ assert.equal(POLICY.consumeReviewVerdict('FAIL').retry, true);
509
+ assert.equal(POLICY.consumeReviewVerdict('BLOCKED').retry, false);
510
+ assert.equal(POLICY.consumeReviewVerdict('BLOCKED').unfinished, true);
511
+ assert.equal(POLICY.consumeReviewVerdict('PARTIAL').completion, 'unfinished');
512
+ assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').completion, 'unfinished');
513
+ assert.equal(POLICY.consumeReviewVerdict('PARTIAL').verdict, 'BLOCKED');
514
+ assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').verdict, 'BLOCKED');
515
+ assert.equal(POLICY.consumeReviewVerdict('PARTIAL').retry, false);
516
+ assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').retry, false);
517
+ assert.throws(() => POLICY.consumeReviewVerdict('UNKNOWN'), /Unsupported canonical verdict/);
193
518
  const cli = spawnSync(process.execPath, [POLICY_PATH, '--consume-verdict', '--verdict', 'BLOCKED', '--json'], { encoding: 'utf8' });
194
519
  assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
195
520
  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/);
521
+ assert.match(read(GATE), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
522
+ assert.match(read(CODE_REVIEW_SKILL), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
523
+ assert.match(read(TEST_SKILL), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
199
524
  });
200
525
 
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,
526
+ test('completion decision requires canonical execution receipt and every workflow obligation', () => {
527
+ const receipt = canonicalReceipt();
528
+ const critical = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
529
+ const audit = {
530
+ schema_version: '1',
531
+ reviewer_session_id: 'review-session-1',
532
+ implementation_session_id: 'implementation-session-1',
533
+ expected_provenance: { ...EXPECTED_PROVENANCE },
534
+ evidence: 'independent audit report for the bound change',
535
+ verdict: 'PASS',
208
536
  };
209
- for (const verdict of ['FAIL', 'BLOCKED', 'NO_TESTS']) {
537
+ const missing = POLICY.completionDecision('PASS', {
538
+ workflow_policy: critical,
539
+ receipt_binding: RECEIPT_BINDING,
540
+ execution_receipt: receipt,
541
+ proofs: {
542
+ needsInspection: { evidence: 'inspection receipt' },
543
+ needsResearchGrounding: { evidence: 'research receipt' },
544
+ },
545
+ });
546
+ assert.equal(missing.completion, 'unfinished');
547
+ assert.ok(missing.missingProof.includes('needsIndependentAudit'));
548
+ assert.equal(missing.unfinished, true);
549
+
550
+ const complete = POLICY.completionDecision('PASS', {
551
+ workflow_policy: critical,
552
+ receipt_binding: RECEIPT_BINDING,
553
+ execution_receipt: receipt,
554
+ proofs: {
555
+ needsInspection: { evidence: 'inspection receipt' },
556
+ needsResearchGrounding: { evidence: 'research receipt' },
557
+ needsIndependentAudit: audit,
558
+ },
559
+ });
560
+ assert.equal(complete.completion, 'complete');
561
+ assert.equal(complete.unfinished, false);
562
+ assert.equal(complete.warnings, undefined);
563
+ const warnings = POLICY.completionDecision('PASS_WITH_WARNINGS', {
564
+ workflow_policy: critical,
565
+ receipt_binding: RECEIPT_BINDING,
566
+ execution_receipt: receipt,
567
+ proofs: {
568
+ needsInspection: { evidence: 'inspection receipt' },
569
+ needsResearchGrounding: { evidence: 'research receipt' },
570
+ needsIndependentAudit: audit,
571
+ },
572
+ });
573
+ assert.equal(warnings.completion, 'unfinished');
574
+ assert.equal(warnings.unfinished, true);
575
+ assert.match(warnings.blocker, /literal PASS/);
576
+ assert.equal(POLICY.completionDecision('PASS', {
577
+ workflow_policy: { proof_obligations: ['needsExecutionProof'] },
578
+ receipt_binding: RECEIPT_BINDING,
579
+ execution_receipt: receipt,
580
+ }).completion, 'unfinished');
581
+ assert.equal(POLICY.completionDecision('PASS', {
582
+ workflow_policy: {},
583
+ receipt_binding: RECEIPT_BINDING,
584
+ execution_receipt: receipt,
585
+ }).completion, 'unfinished');
586
+ assert.equal(POLICY.completionDecision('PASS', {
587
+ workflow_policy: critical,
588
+ execution_receipt: receipt,
589
+ }).completion, 'unfinished', 'arbitrary Base/Head without runtime binding must not complete');
590
+ assert.equal(POLICY.completionDecision('PASS', {
591
+ workflow_policy: critical,
592
+ receipt_binding: RECEIPT_BINDING,
593
+ execution_receipt: receipt,
594
+ proofs: {
595
+ needsInspection: { evidence: 'inspection receipt' },
596
+ needsResearchGrounding: { evidence: 'research receipt' },
597
+ needsIndependentAudit: { independent: true, evidence: 'marker only', verdict: 'PASS' },
598
+ },
599
+ }).completion, 'unfinished', 'independent marker must not satisfy audit');
600
+ assert.equal(POLICY.validateIndependentAuditEvidence({ independent: true }, EXPECTED_PROVENANCE).valid, false);
601
+ assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, verdict: 'PASS_WITH_WARNINGS' }, EXPECTED_PROVENANCE).valid, false);
602
+ assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, reviewer_session_id: audit.implementation_session_id }, EXPECTED_PROVENANCE).valid, false);
603
+ assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, expected_provenance: { base: EXPECTED_BASE, head: 'fedcba9876543210fedcba9876543210fedcba98' } }, EXPECTED_PROVENANCE).valid, false);
604
+ assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, extra: 'forged' }, EXPECTED_PROVENANCE).valid, false);
605
+ assert.equal(POLICY.validateIndependentAuditEvidence([audit], EXPECTED_PROVENANCE).valid, false);
606
+ assert.equal(POLICY.validateIndependentAuditEvidence(audit, EXPECTED_PROVENANCE, RUNTIME_CONTEXT).valid, true);
607
+ assert.equal(POLICY.validateIndependentAuditEvidence(audit, EXPECTED_PROVENANCE, { ...RUNTIME_CONTEXT }).valid, false, 'forged runtime session context must not satisfy audit binding');
608
+ assert.equal(POLICY.completionDecision('PASS', {}).completion, 'unfinished');
609
+ const artifactContext = {
610
+ workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: {} }),
611
+ receipt_binding: RECEIPT_BINDING,
612
+ task_context: { artifacts: ['output/bundle.js'], expected_provenance: { ...EXPECTED_PROVENANCE } },
613
+ execution_receipt: receipt,
614
+ proofs: { needsInspection: { evidence: 'inspection receipt' } },
615
+ receipt_options: { requireArtifactHash: false, artifactPaths: [] },
616
+ };
617
+ const artifactBlocked = POLICY.completionDecision('PASS', artifactContext);
618
+ assert.equal(artifactBlocked.completion, 'unfinished', 'caller receipt options must not weaken task artifact requirements');
619
+ assert.ok(artifactBlocked.missingProof.some((item) => item.includes('artifact_hash')));
620
+ });
621
+
622
+ test('P1 auto generation pauses without forging approval or readiness', () => {
623
+ const specsSkill = read(path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md'));
624
+ const specMaker = read(path.join(PACKAGE_ROOT, 'src/claude/agents/spec-maker.md'));
625
+ assert.match(specsSkill, /--auto/i);
626
+ assert.match(specsSkill, /explicit user approval/i);
627
+ assert.match(specsSkill, /paused.*not-ready/i);
628
+ assert.match(specMaker, /--auto.*never sets `user_approved`/s);
629
+ assert.match(specMaker, /paused\/not-ready/i);
630
+ assert.match(specMaker, /never sets `user_approved`/i);
631
+ assert.doesNotMatch(specMaker, /auto-approval/i);
632
+ });
633
+
634
+ test('flash PASS promotes proof; only trusted sync-finalize completes', () => {
635
+ const initial = canonicalFlashTask();
636
+ for (const verdict of ['FAIL', 'BLOCKED', 'PARTIAL', 'NO_TESTS', 'PASS_WITH_WARNINGS']) {
210
637
  const result = POLICY.promoteFlashTask(initial, verdict);
211
638
  assert.equal(result.status, 'in_progress');
212
639
  assert.equal(result.receipt, 'FLASH_UNVERIFIED');
@@ -214,7 +641,7 @@ test('flash PASS promotes proof without completion, finalize alone completes', (
214
641
  assert.equal(result.unblocks, false);
215
642
  assert.match(result.blocker, /verification|test proof/);
216
643
  }
217
- const promoted = POLICY.promoteFlashTask(initial, 'PASS', 'Verification: PASS\nCommand: pnpm test\nResult: PASS');
644
+ const promoted = POLICY.promoteFlashTask(initial, 'PASS', canonicalReceipt());
218
645
  assert.equal(promoted.status, 'in_progress');
219
646
  assert.equal(promoted.receipt.startsWith('Verification: PASS'), true);
220
647
  assert.equal(promoted.blocker, null);
@@ -222,13 +649,17 @@ test('flash PASS promotes proof without completion, finalize alone completes', (
222
649
  assert.equal(promoted.unblocks, false);
223
650
  assert.equal(promoted.readyForSync, true);
224
651
  assert.equal(POLICY.finalizeFlashTask(promoted, 'sync'), promoted);
225
- const finalized = POLICY.finalizeFlashTask(promoted, 'sync-finalize');
652
+ const forgedFinalize = POLICY.finalizeFlashTask(promoted, 'sync-finalize');
653
+ assert.equal(forgedFinalize.status, 'in_progress');
654
+ assert.equal(forgedFinalize.dependencyBlocked, true);
655
+ assert.equal(forgedFinalize.unblocks, false);
656
+ const finalized = POLICY.syncFinalizeFlashTask(initial, 'PASS', promoted.receipt);
226
657
  assert.equal(finalized.status, 'done');
227
658
  assert.equal(finalized.dependencyBlocked, false);
228
659
  assert.equal(finalized.unblocks, true);
229
660
  assert.equal(finalized.readyForSync, false);
230
661
  assert.equal(POLICY.isStaleFlashDone({ status: 'done', receipt: 'FLASH_UNVERIFIED' }), true);
231
- assert.match(read(TEST_SKILL), /only explicit .*sync-finalize/);
662
+ assert.match(read(TEST_SKILL), /only explicit .*sync-finalize/i);
232
663
  assert.match(read(SYNC_SKILL), /sync-finalize/);
233
664
  });
234
665
 
@@ -239,16 +670,20 @@ test('spec-gate rejects stale FLASH_UNVERIFIED done state', () => {
239
670
  fs.mkdirSync(path.join(claude, 'scripts'), { recursive: true });
240
671
  fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs'), path.join(claude, 'hooks/spec-gate.cjs'));
241
672
  fs.copyFileSync(POLICY_PATH, path.join(claude, 'scripts/workflow-policy.cjs'));
673
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), path.join(claude, 'scripts/provenance.cjs'));
674
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'), path.join(claude, 'scripts/spec-resolver.cjs'));
242
675
  fs.mkdirSync(path.join(root, 'specs', 'demo', 'tasks'), { recursive: true });
243
676
  fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
244
677
  status: 'in_progress',
678
+ feature_name: 'demo',
245
679
  task_registry: { 'tasks/task.md': { status: 'done', receipt: 'FLASH_UNVERIFIED' } },
246
680
  }));
681
+ initFixtureGit(root);
247
682
  try {
248
683
  const result = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
249
684
  cwd: root,
250
685
  env: { ...process.env, PROJECT_ROOT: root },
251
- input: JSON.stringify({ cwd: root }),
686
+ input: JSON.stringify({ cwd: root, session_id: 'test' }),
252
687
  encoding: 'utf8',
253
688
  });
254
689
  assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
@@ -267,19 +702,23 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
267
702
  fs.mkdirSync(path.join(claude, 'scripts'), { recursive: true });
268
703
  fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs'), path.join(claude, 'hooks/spec-gate.cjs'));
269
704
  fs.copyFileSync(POLICY_PATH, path.join(claude, 'scripts/workflow-policy.cjs'));
705
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), path.join(claude, 'scripts/provenance.cjs'));
706
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'), path.join(claude, 'scripts/spec-resolver.cjs'));
270
707
  const specDir = path.join(root, 'specs', 'demo', 'tasks');
271
708
  fs.mkdirSync(specDir, { recursive: true });
272
709
  // Done task without canonical receipt (missing Command, provenance)
273
710
  fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
274
711
  status: 'in_progress',
712
+ feature_name: 'demo',
275
713
  task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } },
276
714
  }));
277
715
  fs.writeFileSync(path.join(specDir, 'task.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\n```\nnpm test\n```\n');
716
+ initFixtureGit(root);
278
717
  try {
279
718
  const result = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
280
719
  cwd: root,
281
720
  env: { ...process.env, PROJECT_ROOT: root },
282
- input: JSON.stringify({ cwd: root }),
721
+ input: JSON.stringify({ cwd: root, session_id: 'test' }),
283
722
  encoding: 'utf8',
284
723
  });
285
724
  assert.equal(result.status, 0);
@@ -287,7 +726,7 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
287
726
  assert.equal(payload.decision, 'block', 'first-run without canonical receipt must block');
288
727
  assert.match(payload.reason, /tasks\/task\.md/);
289
728
  // 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');
729
+ fs.writeFileSync(path.join(specDir, 'task.md'), bindFixtureReceipt(root, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nResult: PASS\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n```\nnpm test\nPASS\n```\n'));
291
730
  // Clear cache to simulate fresh first-run with valid receipt
292
731
  try { fs.unlinkSync(path.join(claude, 'hooks', '.logs', 'spec-gate-last.json')); } catch {}
293
732
  try { fs.unlinkSync(path.join(__dirname, '..', '.logs', 'spec-gate-last.json')); } catch {}
@@ -296,7 +735,7 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
296
735
  const result2 = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
297
736
  cwd: root,
298
737
  env: { ...process.env, PROJECT_ROOT: root },
299
- input: JSON.stringify({ cwd: root }),
738
+ input: JSON.stringify({ cwd: root, session_id: 'test' }),
300
739
  encoding: 'utf8',
301
740
  });
302
741
  assert.equal(result2.stdout, '', 'valid canonical receipt on first run must not block');
@@ -307,7 +746,7 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
307
746
  });
308
747
 
309
748
  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'), []);
749
+ assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n'), []);
311
750
  const missingCommand = POLICY.validateCanonicalReceipt('Verification: PASS\nExit: 0\nBase: a\nHead: b\n');
312
751
  assert.ok(missingCommand.includes('command'));
313
752
  const missingProvenance = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\n');
@@ -317,7 +756,7 @@ test('canonical receipt requires command, exit, provenance and unambiguous PASS'
317
756
  const artifactWithoutHash = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: dist/bundle.js\n');
318
757
  assert.ok(artifactWithoutHash.includes('artifact_hash'));
319
758
  // 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' });
759
+ 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
760
  assert.equal(cli.status, 0);
322
761
  assert.equal(JSON.parse(cli.stdout).ok, true);
323
762
  });
@@ -330,7 +769,7 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
330
769
  const onlyHead = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: def456\n');
331
770
  assert.ok(onlyHead.includes('provenance'), 'only Head should fail provenance');
332
771
  // Both Base and Head passes
333
- const bothLabels = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n');
772
+ const bothLabels = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n');
334
773
  assert.deepEqual(bothLabels, [], 'both Base and Head should pass');
335
774
  // Only base_sha fails
336
775
  const onlyBaseSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc123\n');
@@ -339,7 +778,7 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
339
778
  const onlyHeadSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nhead_sha: def456\n');
340
779
  assert.ok(onlyHeadSha.includes('provenance'), 'only head_sha should fail provenance');
341
780
  // 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');
781
+ const bothSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: 0123456789abcdef0123456789abcdef01234567\nhead_sha: 89abcdef0123456789abcdef0123456789abcdef\n');
343
782
  assert.deepEqual(bothSha, [], 'both base_sha and head_sha should pass');
344
783
  // Alternation-precedence false acceptance check: ensure single Head or base_sha alone does not pass
345
784
  const singleHeadLower = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: single\n');
@@ -365,16 +804,16 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
365
804
  assert.ok(bareBoth.includes('provenance'), 'bare base_sha head_sha without colon/value should fail');
366
805
  const baseEmptyHeadValid = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
367
806
  assert.ok(baseEmptyHeadValid.includes('provenance'));
368
- const validWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n');
807
+ const validWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n');
369
808
  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');
809
+ const validShaWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: 0123456789abcdef0123456789abcdef01234567\nhead_sha: 89abcdef0123456789abcdef0123456789abcdef\n');
371
810
  assert.deepEqual(validShaWithValues, [], 'valid base_sha and head_sha with values should pass');
372
811
  // CLI: only Base via --validate-receipt should fail
373
812
  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
813
  assert.equal(cliOnlyBase.status, 2);
375
814
  assert.equal(JSON.parse(cliOnlyBase.stdout).ok, false);
376
815
  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' });
816
+ 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
817
  assert.equal(cliBoth.status, 0);
379
818
  assert.equal(JSON.parse(cliBoth.stdout).ok, true);
380
819
  // CLI empty Base should fail
@@ -383,34 +822,199 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
383
822
  assert.ok(JSON.parse(cliEmptyBase.stdout).failures.includes('provenance'));
384
823
  });
385
824
 
825
+ test('canonical receipt binds expected provenance when the runtime supplies it', () => {
826
+ const body = canonicalReceipt();
827
+ const binding = POLICY.createReceiptBinding(RUNTIME_CONTEXT);
828
+ assert.deepEqual(binding.expectedProvenance, EXPECTED_PROVENANCE);
829
+ assert.equal(binding.requireProvenanceBinding, true);
830
+ assert.equal(binding.runtimeContext.context_id, RUNTIME_CONTEXT.context_id);
831
+ assert.throws(() => POLICY.createReceiptBinding(EXPECTED_PROVENANCE), /runtime-derived provenance context/);
832
+ assert.ok(POLICY.validateCanonicalReceipt(body, { requireProvenanceBinding: true }).includes('provenance'));
833
+ assert.deepEqual(POLICY.validateCanonicalReceipt(body, { expectedProvenance: EXPECTED_PROVENANCE }), []);
834
+ assert.ok(POLICY.validateCanonicalReceipt(body, { expectedProvenance: { base: EXPECTED_BASE, head: 'fedcba9876543210fedcba9876543210fedcba98' } }).includes('provenance'));
835
+ const taskOptions = POLICY.receiptValidatorOptions({ expected_provenance: EXPECTED_PROVENANCE });
836
+ assert.equal(taskOptions.expectedProvenance, null, 'task-authored expected provenance is diagnostic metadata only');
837
+ assert.deepEqual(POLICY.validateCanonicalReceipt(body, taskOptions), [], 'diagnostic parsing may accept schema-valid anchors without treating them as identity');
838
+ });
839
+
840
+ test('runtime provenance derives exact Git evidence, CLI context, and stale/forged blockers', () => {
841
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-provenance-adversarial-'));
842
+ const specsRoot = path.join(root, 'specs');
843
+ const featureRoot = path.join(specsRoot, 'demo');
844
+ const specFile = path.join(featureRoot, 'spec.json');
845
+ const sourceFile = path.join(root, 'src', 'app.js');
846
+ const renameSource = path.join(root, 'src', 'rename-old.js');
847
+ const renameTarget = path.join(root, 'src', 'rename-new.js');
848
+ fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
849
+ fs.mkdirSync(featureRoot, { recursive: true });
850
+ fs.writeFileSync(specFile, JSON.stringify({ status: 'in_progress', feature_name: 'demo', task_registry: {} }));
851
+ fs.writeFileSync(sourceFile, 'original source\n');
852
+ fs.writeFileSync(renameSource, 'rename source\n');
853
+ fs.writeFileSync(path.join(root, '.gitignore'), 'ignored-secret.env\nnode_modules/\n.cache/\n');
854
+ initFixtureGit(root);
855
+ try {
856
+ const input = {
857
+ projectRoot: root,
858
+ specsRoot,
859
+ specFile,
860
+ featureName: 'demo',
861
+ runtimeSession: 'provenance-session',
862
+ };
863
+ const initial = PROVENANCE_HELPER.deriveRuntimeContext(input);
864
+ const exactReceipt = `Verification: PASS\nCommand: node --test\nExit: 0\nBase: ${initial.base}\nHead: ${initial.head}\n`;
865
+ assert.deepEqual(
866
+ POLICY.validateCanonicalReceipt(exactReceipt, POLICY.receiptValidatorOptions({}, {
867
+ runtimeContext: initial,
868
+ requireProvenanceBinding: true,
869
+ })),
870
+ [],
871
+ 'exact runtime-derived receipt must validate',
872
+ );
873
+ const arbitraryReceipt = 'Verification: PASS\nCommand: node --test\nExit: 0\nBase: ' + 'a'.repeat(40) + '\nHead: ' + 'b'.repeat(64) + '\n';
874
+ assert.ok(
875
+ POLICY.validateCanonicalReceipt(arbitraryReceipt, POLICY.receiptValidatorOptions({}, {
876
+ runtimeContext: initial,
877
+ requireProvenanceBinding: true,
878
+ })).includes('provenance'),
879
+ 'valid-shaped arbitrary SHA values must not bind',
880
+ );
881
+
882
+ const cli = spawnSync(process.execPath, [
883
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), '--json',
884
+ '--project-root', root, '--specs-root', specsRoot, '--spec-file', specFile,
885
+ '--feature-name', 'demo', '--runtime-session', 'provenance-session',
886
+ ], { cwd: root, encoding: 'utf8' });
887
+ assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
888
+ const cliOutput = JSON.parse(cli.stdout);
889
+ assert.equal(cliOutput.ok, true);
890
+ assert.equal(cliOutput.Base, initial.base);
891
+ assert.equal(cliOutput.Head, initial.head);
892
+ assert.equal(cliOutput.context_id, initial.context_id);
893
+
894
+ const ignoredSecret = path.join(root, 'ignored-secret.env');
895
+ fs.writeFileSync(ignoredSecret, 'TOKEN=first-secret\n');
896
+ const withIgnoredSecret = PROVENANCE_HELPER.deriveRuntimeContext(input);
897
+ fs.writeFileSync(ignoredSecret, 'TOKEN=changed-secret\n');
898
+ const withChangedIgnoredSecret = PROVENANCE_HELPER.deriveRuntimeContext(input);
899
+ assert.equal(withIgnoredSecret.head, initial.head, 'ignored secret-like files must not enter Head');
900
+ assert.equal(withChangedIgnoredSecret.head, initial.head, 'changing an ignored secret-like file must not change Head');
901
+
902
+ const ignoredCacheFile = path.join(root, 'packages', 'app', 'node_modules', 'cache', 'runtime.bin');
903
+ fs.mkdirSync(path.dirname(ignoredCacheFile), { recursive: true });
904
+ fs.writeFileSync(ignoredCacheFile, 'runtime cache bytes\n');
905
+ const withIgnoredCache = PROVENANCE_HELPER.deriveRuntimeContext(input);
906
+ fs.writeFileSync(ignoredCacheFile, 'changed runtime cache bytes\n');
907
+ const withChangedIgnoredCache = PROVENANCE_HELPER.deriveRuntimeContext(input);
908
+ assert.equal(withIgnoredCache.head, initial.head, 'nested ignored node_modules/cache files must not enter Head');
909
+ assert.equal(withChangedIgnoredCache.head, initial.head, 'changing nested ignored cache data must not change Head');
910
+
911
+ const forgedContext = { ...initial };
912
+ const forgedDecision = POLICY.completionDecision('PASS', {
913
+ workflow_policy: { proof_obligations: ['needsExecutionProof'] },
914
+ runtime_context: forgedContext,
915
+ execution_receipt: exactReceipt,
916
+ });
917
+ assert.equal(forgedDecision.completion, 'unfinished');
918
+ assert.deepEqual(forgedDecision.missingProof, ['runtime_provenance']);
919
+
920
+ const flash = {
921
+ status: 'in_progress',
922
+ receipt: 'FLASH_UNVERIFIED',
923
+ blocker: 'awaiting test proof',
924
+ dependencyBlocked: true,
925
+ unblocks: false,
926
+ runtime_context: initial,
927
+ };
928
+ const promoted = POLICY.promoteFlashTask(flash, 'PASS', exactReceipt);
929
+ assert.equal(promoted.readyForSync, true);
930
+
931
+ fs.writeFileSync(sourceFile, 'mutated source\n');
932
+ const stale = POLICY.completionDecision('PASS', {
933
+ workflow_policy: { proof_obligations: ['needsExecutionProof'] },
934
+ receipt_binding: POLICY.createReceiptBinding(initial),
935
+ execution_receipt: exactReceipt,
936
+ });
937
+ assert.equal(stale.completion, 'unfinished');
938
+ assert.ok(stale.missingProof.some((item) => item.includes('execution_receipt:provenance')));
939
+ const staleFinalized = POLICY.syncFinalizeFlashTask(flash, 'PASS', exactReceipt, initial);
940
+ assert.equal(staleFinalized.status, 'in_progress');
941
+ assert.equal(staleFinalized.readyForSync, false);
942
+
943
+ fs.writeFileSync(path.join(root, 'src', 'untracked.js'), 'untracked source\n');
944
+ const withUntracked = PROVENANCE_HELPER.deriveRuntimeContext(input);
945
+ assert.notEqual(withUntracked.head, withIgnoredCache.head, 'non-ignored untracked source must change Head');
946
+ fs.chmodSync(renameSource, 0o755);
947
+ const withMode = PROVENANCE_HELPER.deriveRuntimeContext(input);
948
+ assert.notEqual(withMode.head, withUntracked.head, 'mode changes must change Head');
949
+ fs.renameSync(renameSource, renameTarget);
950
+ const withRename = PROVENANCE_HELPER.deriveRuntimeContext(input);
951
+ assert.notEqual(withRename.head, withMode.head, 'rename path semantics must change Head');
952
+ fs.unlinkSync(sourceFile);
953
+ const withDeletion = PROVENANCE_HELPER.deriveRuntimeContext(input);
954
+ assert.notEqual(withDeletion.head, withRename.head, 'tracked deletion must change Head');
955
+ const staged = spawnSync('git', ['-C', root, 'add', '-A'], { encoding: 'utf8' });
956
+ assert.equal(staged.status, 0, staged.stderr);
957
+ const stagedRename = PROVENANCE_HELPER.deriveRuntimeContext(input);
958
+ assert.notEqual(stagedRename.head, withDeletion.head, 'staged rename/deletion evidence must be included');
959
+
960
+ assert.throws(() => PROVENANCE_HELPER.deriveRuntimeContext({
961
+ ...input,
962
+ feature_name: 'other',
963
+ }), /disagree/);
964
+ assert.throws(() => PROVENANCE_HELPER.deriveRuntimeContext({
965
+ ...input,
966
+ specsRoot: root,
967
+ }), /distinct project subdirectory/);
968
+ } finally {
969
+ fs.rmSync(root, { recursive: true, force: true });
970
+ }
971
+
972
+ const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-provenance-non-git-'));
973
+ const nonGitSpec = path.join(nonGit, 'specs', 'demo', 'spec.json');
974
+ fs.mkdirSync(path.dirname(nonGitSpec), { recursive: true });
975
+ fs.writeFileSync(nonGitSpec, JSON.stringify({ status: 'in_progress', feature_name: 'demo' }));
976
+ try {
977
+ const blocked = spawnSync(process.execPath, [
978
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), '--json',
979
+ '--project-root', nonGit, '--specs-root', path.join(nonGit, 'specs'),
980
+ '--spec-file', nonGitSpec, '--feature-name', 'demo', '--runtime-session', 'non-git-session',
981
+ ], { cwd: nonGit, encoding: 'utf8' });
982
+ assert.equal(blocked.status, 2);
983
+ assert.equal(JSON.parse(blocked.stdout).error.code, 'git_command_failed');
984
+ } finally {
985
+ fs.rmSync(nonGit, { recursive: true, force: true });
986
+ }
987
+ });
988
+
386
989
  test('lane traces - Direct, Standard, Critical classification, override, state mutation and completion', () => {
387
990
  // Direct: isolated reversible low-risk
388
991
  const direct = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1 });
389
992
  assert.equal(direct.lane, 'Direct');
390
993
  assert.equal(direct.automaticLane, 'Direct');
391
- assert.deepEqual(POLICY.lanePolicy(direct).delegated, []);
994
+ assert.deepEqual(POLICY.lanePolicy(direct).proof_obligations, ['needsExecutionProof']);
392
995
  assert.equal(POLICY.lanePolicy(direct).requiresSpec, false);
393
996
  assert.equal(POLICY.lanePolicy(direct).shipPoint, 'task');
394
997
  // Standard: default
395
998
  const standard = POLICY.classifyLane({ title: 'add pagination to list', taskCount: 1 });
396
999
  assert.equal(standard.lane, 'Standard');
397
- assert.deepEqual(POLICY.lanePolicy(standard).delegated, ['code-auditor']);
1000
+ assert.deepEqual(POLICY.lanePolicy(standard).proof_obligations, ['needsInspection', 'needsExecutionProof']);
398
1001
  assert.equal(POLICY.lanePolicy(standard).shipPoint, 'feature');
399
1002
  // Critical: auth signal
400
1003
  const critical = POLICY.classifyLane({ riskSignals: { auth: true }, taskCount: 1 });
401
1004
  assert.equal(critical.lane, 'Critical');
402
1005
  assert.ok(critical.risks.includes('auth'));
403
- assert.deepEqual(POLICY.lanePolicy(critical).delegated, ['inspector', 'implementer', 'test-runner', 'code-auditor']);
1006
+ assert.deepEqual(POLICY.lanePolicy(critical).proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit', 'needsResearchGrounding']);
404
1007
  // Override: Direct requesting Critical is allowed (upgrade) without extra auth
405
1008
  const upgrade = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1, override: 'Critical' });
406
1009
  assert.equal(upgrade.lane, 'Critical');
407
1010
  assert.equal(upgrade.automaticLane, 'Direct');
408
1011
  // 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/);
1012
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard' }), /is blocked.*no trusted runtime issuer/);
1013
+ // Downgrade with legacy boolean flags is still blocked (P0.4 fail-closed, no trusted issuer)
1014
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', userAuthorized: true }), /is blocked.*no trusted runtime issuer/);
1015
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', user_approved: true }), /is blocked.*no trusted runtime issuer/);
1016
+ // Even structured receipt without verified issuer is blocked fail-closed
1017
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', overrideReceipt: { automaticLane: 'Critical', requestedLane: 'Standard', risks: ['payment'], source: 'runtime', timestamp: '2026-08-11T00:00:00.000Z', sessionId: 's-123' } }), /is blocked.*no trusted runtime issuer/);
414
1018
  // State mutation: approvalState
415
1019
  const pendingApproval = POLICY.approvalState({ generated: true, agent_validated: false, user_approved: false });
416
1020
  assert.equal(pendingApproval.ready, false);
@@ -419,7 +1023,7 @@ test('lane traces - Direct, Standard, Critical classification, override, state m
419
1023
  const approved = POLICY.approvalState({ generated: true, agent_validated: true, user_approved: true });
420
1024
  assert.equal(approved.ready, true);
421
1025
  // 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 };
1026
+ const flashTask = canonicalFlashTask();
423
1027
  const failRemains = POLICY.promoteFlashTask(flashTask, 'FAIL');
424
1028
  assert.equal(failRemains.status, 'in_progress');
425
1029
  assert.equal(failRemains.unblocks, false);
@@ -427,20 +1031,20 @@ test('lane traces - Direct, Standard, Critical classification, override, state m
427
1031
  assert.equal(blockedRemains.unblocks, false);
428
1032
  const noTestsRemains = POLICY.promoteFlashTask(flashTask, 'NO_TESTS');
429
1033
  assert.equal(noTestsRemains.unblocks, false);
430
- const promoted = POLICY.promoteFlashTask(flashTask, 'PASS', 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
1034
+ const promoted = POLICY.promoteFlashTask(flashTask, 'PASS', canonicalReceipt());
431
1035
  assert.equal(promoted.readyForSync, true);
432
1036
  assert.equal(promoted.unblocks, false, 'promoted flash must not unblock until sync-finalize');
433
- const finalized = POLICY.finalizeFlashTask(promoted, 'sync-finalize');
1037
+ const finalized = POLICY.syncFinalizeFlashTask(flashTask, 'PASS', promoted.receipt);
434
1038
  assert.equal(finalized.status, 'done');
435
1039
  assert.equal(finalized.unblocks, true);
436
1040
  });
437
1041
 
438
1042
  test('flash selective promotion - only specific task is promoted, not blanket', () => {
439
1043
  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 },
1044
+ 'tasks/task-a.md': canonicalFlashTask(),
1045
+ 'tasks/task-b.md': canonicalFlashTask(),
442
1046
  };
443
- const promotedA = POLICY.promoteFlashTask(registry['tasks/task-a.md'], 'PASS', 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
1047
+ const promotedA = POLICY.promoteFlashTask(registry['tasks/task-a.md'], 'PASS', canonicalReceipt());
444
1048
  const notPromotedB = registry['tasks/task-b.md']; // unchanged
445
1049
  assert.equal(promotedA.readyForSync, true);
446
1050
  assert.equal(notPromotedB.receipt, 'FLASH_UNVERIFIED');
@@ -1118,3 +1722,695 @@ process.stdin.on('end', ()=>{
1118
1722
  fs.rmSync(root, { recursive: true, force: true });
1119
1723
  }
1120
1724
  });
1725
+
1726
+ // ── P0 regressions ──────────────────────────────────────────────────────
1727
+ test('P0 receipt fail-closed: Exit 1/-1/abc/conflict/empty command rejected', () => {
1728
+ const base = 'Verification: PASS\nCommand: pnpm test\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
1729
+ const cases = [
1730
+ { body: `${base}Exit: 1\n`, shouldFail: 'exit_result', desc: 'Exit 1' },
1731
+ { body: `${base}Exit: -1\n`, shouldFail: 'exit_result', desc: 'Exit -1' },
1732
+ { body: `${base}Exit: abc\n`, shouldFail: 'exit_result', desc: 'Exit abc' },
1733
+ { body: `${base}Exit: 0\nExit: 1\n`, shouldFail: 'exit_result', desc: 'conflicting exits 0 and 1' },
1734
+ { body: `${base}Exit: 0\nExit: abc\n`, shouldFail: 'exit_result', desc: 'conflicting exit 0 and abc' },
1735
+ { body: 'Verification: PASS\nCommand:\nExit: 0\nBase: a\nHead: b\n', shouldFail: 'command', desc: 'empty command' },
1736
+ { body: 'Verification: PASS\nCommand: \nExit: 0\nBase: a\nHead: b\n', shouldFail: 'command', desc: 'command spaces only' },
1737
+ { body: `${base}Exit: 0\nResult: FAIL\n`, shouldFail: 'exit_result', desc: 'Result FAIL with Exit 0' },
1738
+ { body: `${base}Exit: 1\nResult: PASS\n`, shouldFail: 'exit_result', desc: 'Result PASS with Exit 1' },
1739
+ { body: 'Verification: PASS\nCommand: pnpm test\nResult: PASS\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n', shouldFail: null, desc: 'Result PASS without Exit (legacy alias ok)' },
1740
+ { body: 'Verification: PASS\nCommand: pnpm test\nResult: FAIL\nBase: a\nHead: b\n', shouldFail: 'exit_result', desc: 'Result FAIL without Exit' },
1741
+ { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: {{base}}\nHead: {{head}}\n', shouldFail: 'placeholder', desc: 'placeholder provenance' },
1742
+ { body: 'Verification: PASS\nCommand: {{cmd}}\nExit: 0\nBase: a\nHead: b\n', shouldFail: 'placeholder', desc: 'placeholder command' },
1743
+ { body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\n', shouldFail: 'provenance', desc: 'missing Head' },
1744
+ ];
1745
+ for (const { body, shouldFail, desc } of cases) {
1746
+ const fails = POLICY.validateCanonicalReceipt(body);
1747
+ if (shouldFail) {
1748
+ 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}`);
1749
+ } else {
1750
+ assert.deepEqual(fails, [], `case ${desc} should pass, got ${fails}`);
1751
+ }
1752
+ }
1753
+ // CLI parity: Exit 1 must be rejected via CLI
1754
+ const cli = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: `${base}Exit: 1\n` }), '--json'], { encoding: 'utf8' });
1755
+ assert.equal(cli.status, 2);
1756
+ assert.equal(JSON.parse(cli.stdout).ok, false);
1757
+ });
1758
+
1759
+ test('P0 flash marker-only must not promote or finalize', () => {
1760
+ const flash = canonicalFlashTask({ blocker: 'awaiting /hapo:test' });
1761
+ const markerOnly = 'Verification: PASS';
1762
+ const promoted = POLICY.promoteFlashTask(flash, 'PASS', markerOnly);
1763
+ assert.equal(promoted.status, 'in_progress');
1764
+ assert.equal(promoted.receipt, 'FLASH_UNVERIFIED');
1765
+ assert.equal(promoted.readyForSync, false);
1766
+ assert.equal(promoted.dependencyBlocked, true);
1767
+ assert.equal(promoted.unblocks, false);
1768
+ assert.match(promoted.blocker, /canonical receipt/);
1769
+ // Even with PASS marker but missing command/exit/provenance, finalize must not complete
1770
+ const fakePromoted = { status: 'in_progress', receipt: markerOnly, readyForSync: true, dependencyBlocked: true, unblocks: false };
1771
+ const notFinalized = POLICY.finalizeFlashTask(fakePromoted, 'sync-finalize');
1772
+ assert.equal(notFinalized.status, 'in_progress', 'marker-only receipt must not finalize');
1773
+ // Valid receipt promotes correctly
1774
+ const valid = canonicalReceipt();
1775
+ const good = POLICY.promoteFlashTask(flash, 'PASS', valid);
1776
+ assert.equal(good.readyForSync, true);
1777
+ assert.equal(good.receipt, valid.trim());
1778
+ const forgedFinalize = POLICY.finalizeFlashTask(good, 'sync-finalize');
1779
+ assert.equal(forgedFinalize.status, 'in_progress');
1780
+ assert.equal(forgedFinalize.unblocks, false);
1781
+ const finalized = POLICY.syncFinalizeFlashTask(flash, 'PASS', valid);
1782
+ assert.equal(finalized.status, 'done');
1783
+ });
1784
+
1785
+ test('P0 canonical artifacts declaration requires SHA-256 and keeps no-artifact receipts compatible', () => {
1786
+ const receipt = 'Verification: PASS\nCommand: node --test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
1787
+ const invalid = `${receipt}sha256: abc123\n`;
1788
+ const digest = 'd'.repeat(64);
1789
+ const valid = `${receipt}Artifact: output/bundle.js\nSHA-256: ${digest}\n`;
1790
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt), []);
1791
+ const options = POLICY.receiptValidatorOptions({ artifacts: ['output/bundle.js'] });
1792
+ assert.equal(options.requireArtifactHash, true);
1793
+ assert.equal(options.artifactDeclarationValid, true);
1794
+ assert.ok(POLICY.validateCanonicalReceipt(receipt, options).includes('artifact_hash'));
1795
+ assert.ok(POLICY.validateCanonicalReceipt(invalid, options).includes('artifact_hash'));
1796
+ assert.deepEqual(POLICY.validateCanonicalReceipt(valid, options), []);
1797
+ assert.equal(POLICY.receiptValidatorOptions({}).requireArtifactHash, false);
1798
+ assert.equal(POLICY.receiptValidatorOptions({ artifact_ref: 'output/bundle.js' }).requireArtifactHash, true, 'legacy alias remains read-compatible');
1799
+ for (const declaration of [null, [], ['../outside'], [{ path: 'output/bundle.js' }]]) {
1800
+ const malformed = POLICY.receiptValidatorOptions({ artifacts: declaration });
1801
+ assert.equal(malformed.artifactDeclarationValid, false, `malformed declaration must be rejected: ${JSON.stringify(declaration)}`);
1802
+ assert.ok(POLICY.validateCanonicalReceipt(receipt, malformed).includes('artifact_declaration'));
1803
+ }
1804
+ });
1805
+
1806
+ test('P0 sync-finalize derives promotion from current FLASH_UNVERIFIED plus explicit PASS proof', () => {
1807
+ const receipt = canonicalReceipt('node --test');
1808
+ const forged = {
1809
+ status: 'in_progress',
1810
+ receipt,
1811
+ readyForSync: true,
1812
+ dependencyBlocked: true,
1813
+ unblocks: false,
1814
+ flashTransition: 'promoted',
1815
+ promotionReceipt: receipt,
1816
+ };
1817
+ const direct = spawnSync(process.execPath, [
1818
+ POLICY_PATH,
1819
+ '--sync-finalize',
1820
+ '--task-json',
1821
+ JSON.stringify(forged),
1822
+ '--json',
1823
+ ], { encoding: 'utf8' });
1824
+ assert.equal(direct.status, 2, `${direct.stdout}\n${direct.stderr}`);
1825
+ const directPayload = JSON.parse(direct.stdout);
1826
+ assert.equal(directPayload.ok, false);
1827
+ assert.equal(directPayload.task.status, 'in_progress');
1828
+ assert.equal(directPayload.task.receipt, 'FLASH_UNVERIFIED');
1829
+ assert.equal(directPayload.task.readyForSync, false);
1830
+ assert.equal(directPayload.task.dependencyBlocked, true);
1831
+ assert.equal(directPayload.task.unblocks, false);
1832
+ assert.match(directPayload.message, /current task must be FLASH_UNVERIFIED.*canonical/i);
1833
+
1834
+ const minimal = { status: 'in_progress', receipt: 'FLASH_UNVERIFIED' };
1835
+ const minimalResult = POLICY.syncFinalizeFlashTask(minimal, 'PASS', receipt);
1836
+ assert.equal(minimalResult.status, 'in_progress');
1837
+ assert.match(minimalResult.blocker, /exact canonical stored state/);
1838
+ const forgedOpen = { ...canonicalFlashTask(), readyForSync: true };
1839
+ const forgedOpenResult = POLICY.syncFinalizeFlashTask(forgedOpen, 'PASS', receipt);
1840
+ assert.equal(forgedOpenResult.status, 'in_progress');
1841
+ assert.equal(forgedOpenResult.unblocks, false);
1842
+
1843
+ const current = canonicalFlashTask({ blocker: 'awaiting /hapo:test' });
1844
+ const supported = spawnSync(process.execPath, [
1845
+ POLICY_PATH,
1846
+ '--sync-finalize',
1847
+ '--task-json',
1848
+ JSON.stringify(current),
1849
+ '--verdict',
1850
+ 'PASS',
1851
+ '--proof',
1852
+ receipt,
1853
+ '--project-root',
1854
+ RUNTIME_ROOT,
1855
+ '--specs-root',
1856
+ path.join(RUNTIME_ROOT, 'specs'),
1857
+ '--spec-file',
1858
+ RUNTIME_SPEC,
1859
+ '--feature-name',
1860
+ 'demo',
1861
+ '--runtime-session',
1862
+ 'implementation-session-1',
1863
+ '--json',
1864
+ ], { encoding: 'utf8' });
1865
+ assert.equal(supported.status, 0, `${supported.stdout}\n${supported.stderr}`);
1866
+ assert.equal(JSON.parse(supported.stdout).task.status, 'done');
1867
+ const warningFinalize = POLICY.syncFinalizeFlashTask(current, 'PASS_WITH_WARNINGS', receipt);
1868
+ assert.equal(warningFinalize.status, 'in_progress');
1869
+ assert.equal(warningFinalize.unblocks, false);
1870
+
1871
+ const initial = canonicalFlashTask();
1872
+ const promoted = POLICY.promoteFlashTask(initial, 'PASS', receipt);
1873
+ const persisted = JSON.parse(JSON.stringify(promoted));
1874
+ assert.equal(persisted.flashTransition, 'promoted');
1875
+ assert.equal(persisted.promotionReceipt, receipt.trim());
1876
+ const forgedPersistedFinalize = POLICY.finalizeFlashTask(persisted, 'sync-finalize');
1877
+ assert.equal(forgedPersistedFinalize.status, 'in_progress');
1878
+ assert.equal(forgedPersistedFinalize.unblocks, false);
1879
+ const finalized = POLICY.syncFinalizeFlashTask(initial, 'PASS', receipt);
1880
+ assert.equal(finalized.status, 'done');
1881
+ assert.equal(finalized.flashTransition, 'finalized');
1882
+
1883
+ const artifactInitial = { ...initial, artifacts: ['output/bundle.js'] };
1884
+ const artifactPromotion = POLICY.promoteFlashTask(artifactInitial, 'PASS', receipt);
1885
+ assert.equal(artifactPromotion.readyForSync, false, 'flash promotion must apply task artifact requirements');
1886
+
1887
+ const badProof = spawnSync(process.execPath, [
1888
+ POLICY_PATH,
1889
+ '--sync-finalize',
1890
+ '--task-json',
1891
+ JSON.stringify(current),
1892
+ '--verdict',
1893
+ 'PASS',
1894
+ '--proof',
1895
+ 'Verification: PASS',
1896
+ '--json',
1897
+ ], { encoding: 'utf8' });
1898
+ assert.equal(badProof.status, 2);
1899
+ assert.equal(JSON.parse(badProof.stdout).task.status, 'in_progress');
1900
+
1901
+ const badArtifact = spawnSync(process.execPath, [
1902
+ POLICY_PATH,
1903
+ '--sync-finalize',
1904
+ '--task-json',
1905
+ JSON.stringify({ ...current, artifacts: ['output/bundle.js'] }),
1906
+ '--verdict',
1907
+ 'PASS',
1908
+ '--proof',
1909
+ receipt,
1910
+ '--json',
1911
+ ], { encoding: 'utf8' });
1912
+ assert.equal(badArtifact.status, 2);
1913
+ assert.equal(JSON.parse(badArtifact.stdout).task.status, 'in_progress');
1914
+
1915
+ const markerOnly = {
1916
+ ...forged,
1917
+ flashTransition: 'promoted',
1918
+ promotionReceipt: 'Verification: PASS',
1919
+ receipt: 'Verification: PASS',
1920
+ };
1921
+ assert.equal(POLICY.finalizeFlashTask(markerOnly, 'sync-finalize').status, 'in_progress');
1922
+ });
1923
+
1924
+ test('P0 Claude and Codex reject configured specs roots outside the project', () => {
1925
+ const claudeResolver = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
1926
+ const codexUtils = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
1927
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-external-specs-'));
1928
+ const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-configured-specs-'));
1929
+ try {
1930
+ const local = path.join(tmp, 'specs', 'local');
1931
+ const external = path.join(externalRoot, 'remote');
1932
+ fs.mkdirSync(local, { recursive: true });
1933
+ fs.mkdirSync(external, { recursive: true });
1934
+ fs.writeFileSync(path.join(local, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'local' }));
1935
+ fs.writeFileSync(path.join(external, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'remote' }));
1936
+ const runtime = { paths: { specs: externalRoot } };
1937
+ const claude = claudeResolver.resolveActiveSpec({ projectRoot: tmp, runtime });
1938
+ const codex = codexUtils.resolveActiveSpec(tmp, runtime, null, null);
1939
+ assert.equal(claude.error, 'invalid_specs');
1940
+ assert.equal(codex.error, 'invalid_specs');
1941
+ assert.match(claude.reason, /escapes project root/);
1942
+ assert.match(codex.reason, /escapes project root/);
1943
+ } finally {
1944
+ fs.rmSync(tmp, { recursive: true, force: true });
1945
+ fs.rmSync(externalRoot, { recursive: true, force: true });
1946
+ }
1947
+ });
1948
+
1949
+ test('P0 forged approvals: user_approved and boolean userAuthorized do not downgrade', () => {
1950
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', user_approved: true }), /is blocked.*no trusted runtime issuer/);
1951
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', userApproved: true }), /is blocked.*no trusted runtime issuer/);
1952
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', userAuthorized: true }), /is blocked.*no trusted runtime issuer/);
1953
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { auth: true }, override: 'Standard', confirmDowngrade: true }), /is blocked.*no trusted runtime issuer/);
1954
+ // Direct check of isUserAuthorizedForDowngrade returns false for booleans
1955
+ assert.equal(POLICY.isUserAuthorizedForDowngrade({ user_approved: true }), false);
1956
+ assert.equal(POLICY.isUserAuthorizedForDowngrade({ userAuthorized: true }), false);
1957
+ assert.equal(POLICY.isUserAuthorizedForDowngrade({ userAuthorized: true }, 'Critical', 'Direct', ['privacy']), false);
1958
+ });
1959
+
1960
+ test('P0 downgrade fail-closed when no verified issuer', () => {
1961
+ const receipt = { automaticLane: 'Critical', requestedLane: 'Direct', risks: ['privacy'], source: 'runtime', timestamp: '2026-08-11T00:00:00.000Z', sessionId: 'sess-1' };
1962
+ assert.equal(POLICY.isValidOverrideReceipt(receipt, 'Critical', 'Direct', ['privacy']), false, 'shape-correct runtime receipt must still be invalid without verified issuer');
1963
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', overrideReceipt: receipt }), /is blocked.*no trusted runtime issuer/);
1964
+ const withVerified = { ...receipt, verifiedByRuntime: true };
1965
+ // Even with verified flag, P0 stays fail-closed (always false) — documents future issuer need
1966
+ assert.equal(POLICY.isValidOverrideReceipt(withVerified, 'Critical', 'Direct', ['privacy']), false);
1967
+ });
1968
+
1969
+ test('P0 active spec deterministic: multiple active ambiguity and explicit target/path containment', () => {
1970
+ const RESOLVER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
1971
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-resolver-'));
1972
+ try {
1973
+ const specsDir = path.join(tmp, 'specs');
1974
+ fs.mkdirSync(specsDir, { recursive: true });
1975
+ // Create two active specs
1976
+ for (const name of ['alpha', 'beta']) {
1977
+ fs.mkdirSync(path.join(specsDir, name), { recursive: true });
1978
+ fs.writeFileSync(path.join(specsDir, name, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: name, current_phase: 'design' }));
1979
+ }
1980
+ const amb = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
1981
+ assert.equal(amb.error, 'multiple_active');
1982
+ assert.deepEqual(amb.candidates.sort(), ['alpha', 'beta']);
1983
+ // Explicit feature resolves deterministically
1984
+ const alpha = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'alpha' });
1985
+ assert.equal(alpha.featureName, 'alpha');
1986
+ // Explicit not-found fail-closed
1987
+ const notFound = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'gamma' });
1988
+ assert.equal(notFound.error, 'explicit_not_found');
1989
+ // Explicit path containment: try to escape via ../
1990
+ const escape = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(tmp, 'specs', '..', 'etc', 'passwd') });
1991
+ assert.equal(escape.error, 'explicit_malformed');
1992
+ // Malformed feature name with slash must be rejected
1993
+ const malformed = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: '../alpha' });
1994
+ assert.equal(malformed.error, 'explicit_malformed');
1995
+ // Valid explicit path inside root
1996
+ const validPath = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(specsDir, 'beta', 'spec.json') });
1997
+ assert.equal(validPath.featureName, 'beta');
1998
+ } finally {
1999
+ fs.rmSync(tmp, { recursive: true, force: true });
2000
+ }
2001
+ });
2002
+
2003
+ test('P0 parity Claude/Codex: canonical receipt and multi-active', () => {
2004
+ // Codex receipt validator must match Claude policy
2005
+ const codexReceipt = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
2006
+ const claudeFails = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 1\nBase: a\nHead: b\n');
2007
+ const codexFails = codexReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 1\nBase: a\nHead: b\n');
2008
+ assert.deepEqual(claudeFails.sort(), codexFails.sort(), 'Claude and Codex validators must agree on Exit 1');
2009
+ const codexEmptyCmd = codexReceipt.validateCanonicalReceipt('Verification: PASS\nCommand:\nExit: 0\nBase: a\nHead: b\n');
2010
+ assert.ok(codexEmptyCmd.includes('command'), 'Codex must reject empty Command');
2011
+ // Codex resolver parity: same containment behaviour
2012
+ const codexUtils = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
2013
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-resolver-'));
2014
+ try {
2015
+ const specsDir = path.join(tmp, 'specs');
2016
+ fs.mkdirSync(specsDir, { recursive: true });
2017
+ for (const name of ['x', 'y']) {
2018
+ fs.mkdirSync(path.join(specsDir, name), { recursive: true });
2019
+ fs.writeFileSync(path.join(specsDir, name, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: name }));
2020
+ }
2021
+ const amb = codexUtils.resolveActiveSpec(tmp, {}, null, null);
2022
+ assert.equal(amb.error, 'multiple_active');
2023
+ const esc = codexUtils.resolveActiveSpec(tmp, {}, null, path.join(tmp, 'specs', '..', 'outside', 'spec.json'));
2024
+ assert.equal(esc.error, 'explicit_malformed');
2025
+ } finally {
2026
+ fs.rmSync(tmp, { recursive: true, force: true });
2027
+ }
2028
+ });
2029
+
2030
+ test('P0 regression: placeholder, explicit failure, artifact and lanePolicy forged are blocked', () => {
2031
+ // Probe A: explicit failure outcomes must fail canonical validator and not promote flash
2032
+ const bodyTestsFailed = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests failed: 1\n';
2033
+ assert.ok(POLICY.validateCanonicalReceipt(bodyTestsFailed).length > 0, 'Tests failed: 1 must be rejected');
2034
+ 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');
2035
+ const flashTask = { status: 'in_progress', receipt: 'FLASH_UNVERIFIED', blocker: 'awaiting', dependencyBlocked: true, unblocks: false };
2036
+ const notPromoted = POLICY.promoteFlashTask(flashTask, 'PASS', bodyTestsFailed);
2037
+ assert.equal(notPromoted.readyForSync, false, 'flash with Tests failed should not promote');
2038
+ assert.equal(notPromoted.receipt, 'FLASH_UNVERIFIED');
2039
+
2040
+ // Probe B: placeholder tokens must be rejected, but substring todo must pass
2041
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: TODO\nExit: 0\nBase: a\nHead: b\n').includes('command'), 'Command TODO must be rejected');
2042
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: TBD\nHead: b\n').includes('provenance'), 'Base TBD must be rejected');
2043
+ 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');
2044
+ 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');
2045
+ 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');
2046
+
2047
+ // Probe C: artifact with empty or placeholder sha must fail, concrete passes, inline passes
2048
+ 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');
2049
+ 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');
2050
+ 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');
2051
+ 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');
2052
+ 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');
2053
+
2054
+ // Probe F: lanePolicy forged plain object must not bypass classification
2055
+ const { spawnSync } = require('node:child_process');
2056
+ const POLICY_PATH = path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs');
2057
+ const forged = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct', riskSignals: { auth: true } }), '--json'], { encoding: 'utf8' });
2058
+ assert.equal(forged.status, 2, 'forged Direct with auth should be blocked via lanePolicy');
2059
+ assert.match(forged.stderr, /is blocked.*no trusted runtime issuer/);
2060
+ const forged2 = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct' }), '--json'], { encoding: 'utf8' });
2061
+ assert.equal(forged2.status, 2, 'plain Direct without justification should be blocked');
2062
+
2063
+ const trusted = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true });
2064
+ assert.equal(trusted.lane, 'Direct');
2065
+ const viaPolicy = POLICY.lanePolicy(trusted);
2066
+ assert.equal(viaPolicy.lane, 'Direct', 'trusted Direct should be respected');
2067
+ assert.throws(() => { 'use strict'; trusted.lane = 'Direct'; }, /read only|Cannot assign|TypeError/);
2068
+ const paymentTrusted = POLICY.classifyLane({ riskSignals: { payment: true } });
2069
+ const forgedCopy = { ...paymentTrusted, lane: 'Direct' };
2070
+ assert.throws(() => POLICY.lanePolicy(forgedCopy), /is blocked/);
2071
+ });
2072
+
2073
+ test('P0 regression: symlink spec/task containment and malformed spec handling', () => {
2074
+ const RESOLVER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
2075
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-regression-symlink-'));
2076
+ try {
2077
+ const specsDir = path.join(tmp, 'specs');
2078
+ fs.mkdirSync(specsDir, { recursive: true });
2079
+ fs.mkdirSync(path.join(specsDir, 'valid'), { recursive: true });
2080
+ fs.writeFileSync(path.join(specsDir, 'valid', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'valid' }));
2081
+ const outside = path.join(tmp, 'outside');
2082
+ fs.mkdirSync(outside, { recursive: true });
2083
+ fs.writeFileSync(path.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
2084
+ const link = path.join(specsDir, 'linked');
2085
+ fs.symlinkSync(outside, link);
2086
+ const e1 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'linked' });
2087
+ assert.equal(e1.error, 'explicit_malformed');
2088
+ const e2 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(specsDir, 'linked', 'spec.json') });
2089
+ assert.equal(e2.error, 'explicit_malformed');
2090
+ const nonExplicit = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
2091
+ assert.equal(nonExplicit.error, 'invalid_specs');
2092
+ assert.ok(nonExplicit.candidates.includes('linked'));
2093
+ const featDir = path.join(specsDir, 'valid');
2094
+ fs.mkdirSync(path.join(featDir, 'tasks'), { recursive: true });
2095
+ const taskOutside = path.join(tmp, 'outside-task.md');
2096
+ fs.writeFileSync(taskOutside, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
2097
+ const taskLink = path.join(featDir, 'tasks', 'task.md');
2098
+ fs.symlinkSync(taskOutside, taskLink);
2099
+ const codexReceipt = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
2100
+ const fails = codexReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
2101
+ assert.ok(fails.includes('a'), 'task symlink outside should be rejected as check a');
2102
+ fs.mkdirSync(path.join(specsDir, 'bad'), { recursive: true });
2103
+ fs.writeFileSync(path.join(specsDir, 'bad', 'spec.json'), '{ malformed');
2104
+ const mal = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
2105
+ assert.equal(mal.error, 'invalid_specs');
2106
+ assert.ok(mal.candidates.includes('bad'));
2107
+ } finally {
2108
+ fs.rmSync(tmp, { recursive: true, force: true });
2109
+ }
2110
+ });
2111
+
2112
+ test('P0 regression: artifact hash scoped to structured Artifact declaration and explicit failure structured only', () => {
2113
+ // Artifact hash only required when structured Artifact declaration exists
2114
+ const prefix = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
2115
+ const receipt = (suffix = '') => `${prefix}${suffix}`;
2116
+ const digestA = 'a'.repeat(64);
2117
+ const digestB = 'b'.repeat(64);
2118
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Command: npm run artifact:test\n')), [], 'Command containing artifact without hash should pass');
2119
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Notes: artifact behavior without declaration\n')), [], 'Notes containing artifact without structured declaration should pass');
2120
+ assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifact: bundle\n')).includes('artifact_hash'), 'Artifact: without hash should fail');
2121
+ assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifacts: bundle\nsha256: TBD\n')).includes('artifact_hash'), 'Artifacts: with TBD should fail');
2122
+ assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifact produced sha256:deadbeef\n')).includes('artifact_hash'), 'short artifact hash must fail');
2123
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt(`Artifact produced sha256:${digestA}\n`)), [], '64-hex artifact hash should pass');
2124
+
2125
+ const artifactOptions = POLICY.receiptValidatorOptions({ artifacts: ['artifacts/a.js', 'artifacts/b.js'] });
2126
+ assert.ok(POLICY.validateCanonicalReceipt(receipt(`Artifact: artifacts/a.js\nSHA-256: ${digestA}\n`), artifactOptions).includes('artifact_hash'), 'missing second artifact binding must fail');
2127
+ 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');
2128
+ 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');
2129
+ 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');
2130
+
2131
+ const falseGreen = receipt([
2132
+ 'Tests run: 0',
2133
+ '0 passing',
2134
+ 'Failures: 1',
2135
+ 'failure summary',
2136
+ 'Result: PASS',
2137
+ 'Artifact produced sha256:deadbeef',
2138
+ 'Artifact: bundle + sha256:abc123',
2139
+ ].join('\n') + '\n');
2140
+ assert.ok(POLICY.validateCanonicalReceipt(falseGreen).length > 0, 'false-green independent-review vector must fail');
2141
+
2142
+ // Explicit failure structured only: prose containing failure should not block, Tests failed: 0 should pass
2143
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Notes: verifies failure handling\n')), [], 'Notes with failure handling should not block');
2144
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Description: failure recovery\n')), [], 'Description with failure should not block');
2145
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Tests failed: 0\n')), [], 'Tests failed: 0 should pass');
2146
+ assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('fail 0\n')), [], 'fail 0 should pass');
2147
+ // Structured failures still blocked
2148
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nStatus: FAILED\n').length > 0, 'Status: FAILED should fail');
2149
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nOutcome: FAILURE\n').length > 0, 'Outcome: FAILURE should fail');
2150
+ assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nResult: FAILED\n').length > 0, 'Result: FAILED should fail');
2151
+ // TAP and Jest runner summaries (structured, anchored)
2152
+ 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');
2153
+ 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');
2154
+ 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');
2155
+ 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');
2156
+ 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');
2157
+ assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n# fail 0\n'), [], 'TAP # fail 0 should pass');
2158
+ assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\nTests: 0 failed\n'), [], 'Tests: 0 failed should pass');
2159
+ });
2160
+
2161
+ test('P0 regression: safeTaskFile fail-closed on realpath error and single authority', () => {
2162
+ const fs2 = require('node:fs');
2163
+ const path2 = require('node:path');
2164
+ const os2 = require('node:os');
2165
+ const specGatePath = path2.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs');
2166
+ const specGateSrc = fs2.readFileSync(specGatePath, 'utf8');
2167
+ // Single authority: Claude hook should delegate to POLICY, not duplicate parser
2168
+ assert.match(specGateSrc, /POLICY\.validateCanonicalReceipt/);
2169
+ assert.doesNotMatch(specGateSrc, /const EXPLICIT_FAILURE/);
2170
+ // Codex and OpenCode should delegate to shared workflow-policy
2171
+ const codexSrc = fs2.readFileSync(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'), 'utf8');
2172
+ assert.match(codexSrc, /getSharedValidate/);
2173
+ assert.match(codexSrc, /workflow-policy\.cjs/);
2174
+ assert.doesNotMatch(codexSrc, /const PLACEHOLDER_TOKENS/);
2175
+ const opencodeSrc = fs2.readFileSync(path2.join(PACKAGE_ROOT, 'src/opencode/plugins/spec-gate.ts'), 'utf8');
2176
+ assert.match(opencodeSrc, /getSharedValidate/);
2177
+ assert.match(opencodeSrc, /workflow-policy\.cjs/);
2178
+ // The parser should only exist in workflow-policy, not in adapters (check that adapter does not define full validate logic)
2179
+ const opencodeValidateCount = (opencodeSrc.match(/function validateCanonicalReceipt/g) || []).length;
2180
+ assert.equal(opencodeValidateCount, 1, 'OpenCode should have only delegating validate, not duplicate parser');
2181
+ // safeTaskFile fail-closed: simulate realpathSync throwing
2182
+ const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-realpath-'));
2183
+ try {
2184
+ const featDir = path2.join(tmp, 'feat');
2185
+ fs2.mkdirSync(path2.join(featDir, 'tasks'), { recursive: true });
2186
+ 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');
2187
+ const orig = fs2.realpathSync;
2188
+ fs2.realpathSync = () => { throw new Error('simulated realpath failure'); };
2189
+ try {
2190
+ const codexReceipt = require(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
2191
+ const fails = codexReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
2192
+ assert.ok(fails.includes('a'), 'realpath failure should be fail-closed with check a');
2193
+ } finally {
2194
+ fs2.realpathSync = orig;
2195
+ }
2196
+ } finally {
2197
+ fs2.rmSync(tmp, { recursive: true, force: true });
2198
+ }
2199
+ });
2200
+
2201
+ test('P0 regression: resolver fail-closed on canonicalization and lstat errors', () => {
2202
+ const fs2 = require('node:fs');
2203
+ const path2 = require('node:path');
2204
+ const os2 = require('node:os');
2205
+ const RESOLVER = require(path2.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
2206
+ const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-resolver-fail-'));
2207
+ try {
2208
+ const specsDir = path2.join(tmp, 'specs');
2209
+ fs2.mkdirSync(path2.join(specsDir, 'demo'), { recursive: true });
2210
+ fs2.writeFileSync(path2.join(specsDir, 'demo', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'demo' }));
2211
+ // Monkey patch realpathSync to always throw for explicitFeature
2212
+ const origRealpath = fs2.realpathSync;
2213
+ fs2.realpathSync = () => { throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' }); };
2214
+ try {
2215
+ const res = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'demo' });
2216
+ assert.equal(res.error, 'explicit_malformed', 'explicitFeature realpath failure should be explicit_malformed');
2217
+ assert.match(res.reason, /canonicalization error/);
2218
+ } finally {
2219
+ fs2.realpathSync = origRealpath;
2220
+ }
2221
+ // Non-explicit with specs root realpath failure should be invalid_specs <specs>
2222
+ const origRealpath2 = fs2.realpathSync;
2223
+ fs2.realpathSync = (p) => {
2224
+ if (p === specsDir || p === path2.resolve(specsDir)) throw Object.assign(new Error('simulated ELOOP'), { code: 'ELOOP' });
2225
+ return origRealpath2(p);
2226
+ };
2227
+ try {
2228
+ const res2 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
2229
+ assert.equal(res2.error, 'invalid_specs');
2230
+ assert.ok(res2.candidates.includes('<specs>'));
2231
+ } finally {
2232
+ fs2.realpathSync = origRealpath2;
2233
+ }
2234
+ // lstat error for entry (simulate EACCES on lstat)
2235
+ const origLstat = fs2.lstatSync;
2236
+ fs2.lstatSync = (p) => {
2237
+ if (p.includes('demo')) throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' });
2238
+ return origLstat(p);
2239
+ };
2240
+ try {
2241
+ const res3 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
2242
+ assert.equal(res3.error, 'invalid_specs');
2243
+ assert.ok(res3.candidates.includes('demo'));
2244
+ } finally {
2245
+ fs2.lstatSync = origLstat;
2246
+ }
2247
+ // Dangling spec.json symlink should be invalid_specs
2248
+ const outside = path2.join(tmp, 'outside-spec2');
2249
+ fs2.mkdirSync(outside, { recursive: true });
2250
+ fs2.writeFileSync(path2.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
2251
+ const featDir = path2.join(specsDir, 'dangle');
2252
+ fs2.mkdirSync(featDir, { recursive: true });
2253
+ const specLink = path2.join(featDir, 'spec.json');
2254
+ fs2.symlinkSync(path2.join(tmp, 'nonexistent-target'), specLink);
2255
+ const res4 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
2256
+ assert.equal(res4.error, 'invalid_specs');
2257
+ assert.ok(res4.candidates.includes('dangle'));
2258
+ } finally {
2259
+ fs2.rmSync(tmp, { recursive: true, force: true });
2260
+ }
2261
+ // Codex parity: same monkey patch should give explicit_malformed
2262
+ const codexUtils = require(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
2263
+ const tmp2 = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-codex-fail-'));
2264
+ try {
2265
+ const specsDir2 = path2.join(tmp2, 'specs');
2266
+ fs2.mkdirSync(path2.join(specsDir2, 'demo2'), { recursive: true });
2267
+ fs2.writeFileSync(path2.join(specsDir2, 'demo2', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'demo2' }));
2268
+ const orig = fs2.realpathSync;
2269
+ fs2.realpathSync = () => { throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' }); };
2270
+ try {
2271
+ const r = codexUtils.resolveActiveSpec(tmp2, {}, 'demo2', null);
2272
+ assert.equal(r.error, 'explicit_malformed');
2273
+ } finally {
2274
+ fs2.realpathSync = orig;
2275
+ }
2276
+ } finally {
2277
+ fs2.rmSync(tmp2, { recursive: true, force: true });
2278
+ }
2279
+ });
2280
+
2281
+ test('P0 canonical phantom vectors - zero, cancellation, suite, FAIL, error, collected (r3)', () => {
2282
+ const base = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
2283
+ // 1. Zero execution
2284
+ const zeroFail = [
2285
+ 'ℹ tests 0',
2286
+ 'Tests: 0 total',
2287
+ '0 tests',
2288
+ 'Tests: 0 passed',
2289
+ 'Tests: 0 passing',
2290
+ 'Test Suites: 0 failed, 0 passed',
2291
+ 'Tests passed: 0',
2292
+ 'Passed: 0',
2293
+ 'one failing',
2294
+ 'failureCount: 1',
2295
+ 'collected 0 items',
2296
+ 'No tests found.',
2297
+ 'No tests found',
2298
+ ];
2299
+ for (const v of zeroFail) {
2300
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `zero execution should fail: ${v}`);
2301
+ }
2302
+ // 2. Cancellation N>0
2303
+ const cancelFail = [
2304
+ 'ℹ cancelled 1',
2305
+ 'cancelled 1',
2306
+ '1 cancelled',
2307
+ 'canceled 1',
2308
+ '1 canceled',
2309
+ ];
2310
+ for (const v of cancelFail) {
2311
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `cancel should fail: ${v}`);
2312
+ }
2313
+ // 3. Suite / Files
2314
+ const suiteFail = [
2315
+ 'Test Suites: 1 failed, 1 total',
2316
+ 'Test Suites: 2 failed',
2317
+ 'Test Files: 1 failed',
2318
+ 'Test Files 1 failed',
2319
+ ];
2320
+ for (const v of suiteFail) {
2321
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `suite should fail: ${v}`);
2322
+ }
2323
+ // 4. Runner FAIL
2324
+ const runnerFail = [
2325
+ 'FAIL ./foo.test.js',
2326
+ 'FAIL\tpackage',
2327
+ 'FAIL\texample.test/probe\t0.431s',
2328
+ '--- FAIL: TestName (0.00s)',
2329
+ 'FAIL',
2330
+ 'FAIL: something',
2331
+ 'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
2332
+ 'FAILED tests/test_demo.py',
2333
+ '# fail 1',
2334
+ 'ℹ fail 1',
2335
+ 'not ok 1 - test',
2336
+ ];
2337
+ for (const v of runnerFail) {
2338
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `runner FAIL should fail: ${v}`);
2339
+ }
2340
+ // also standalone FAILED/FAILURE bare/colon
2341
+ for (const v of ['FAILED', 'FAILURE', 'FAILED:', 'FAILURE:']) {
2342
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `standalone ${v} should fail`);
2343
+ }
2344
+ // 5. Error summaries N>0
2345
+ const errorFail = [
2346
+ '1 error in 0.12s',
2347
+ '2 errors',
2348
+ 'ERROR collecting tests/test_demo.py',
2349
+ 'ERROR',
2350
+ 'Found 2 errors',
2351
+ 'Tests run: 5, Failures: 1, Errors: 0',
2352
+ '[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
2353
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
2354
+ '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
2355
+ '5 tests completed, 1 failed',
2356
+ ];
2357
+ for (const v of errorFail) {
2358
+ assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `error should fail: ${v}`);
2359
+ }
2360
+ // Positive controls must pass
2361
+ const pass = [
2362
+ 'Tests failed: 0',
2363
+ 'fail 0',
2364
+ 'Tests: 0 failed',
2365
+ 'cancelled 0',
2366
+ 'canceled 0',
2367
+ 'Test Suites: 0 failed',
2368
+ 'Test Files: 0 failed',
2369
+ '5 tests completed, 0 failed',
2370
+ 'Tests run: 5, Failures: 0, Errors: 0',
2371
+ '# tests 1',
2372
+ '# suites 0',
2373
+ '# pass 1',
2374
+ '# fail 0',
2375
+ '# cancelled 0',
2376
+ '# skipped 0',
2377
+ '# todo 0',
2378
+ '# duration_ms 114.203625',
2379
+ '[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
2380
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
2381
+ '[ERROR] Error handling is documented',
2382
+ 'errors 0',
2383
+ 'Found 0 errors',
2384
+ 'tests 5',
2385
+ 'collected 1 item',
2386
+ 'collected 5 items',
2387
+ 'Notes: handles error/failure',
2388
+ 'Notes: verifies failure handling',
2389
+ 'Notes: Tests: 0 passed',
2390
+ 'Description: Tests passed: 0',
2391
+ 'Notes: one failing test is documented',
2392
+ 'Description: failureCount: 1 is documented',
2393
+ 'one failing test: 1 is documented',
2394
+ 'failureCount: 1 is documented',
2395
+ 'Tests failed previously but now fixed',
2396
+ 'Failure handling is documented',
2397
+ 'failure summary follows in the notes',
2398
+ 'ERROR handling is documented',
2399
+ 'Summary: failureCount: 1 is documented',
2400
+ 'Error handling is documented',
2401
+ 'FAILURE mode analysis',
2402
+ 'Notes: tests failed previously but now fixed',
2403
+ 'Notes: supports zero-test parsing',
2404
+ 'Command: npm run test --errorFlag',
2405
+ 'Command: npm run artifact:test',
2406
+ ];
2407
+ for (const v of pass) {
2408
+ // Command lines are skipped; others should not trigger hasExplicitFailure
2409
+ const body = v.startsWith('Command:') ? `Verification: PASS\n${v}\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n` : base + v + '\n';
2410
+ assert.deepEqual(POLICY.validateCanonicalReceipt(body), [], `should pass: ${v}`);
2411
+ }
2412
+ assert.equal(POLICY.isTapMetadataHeading('# fail 1'), true);
2413
+ assert.equal(POLICY.isTapMetadataHeading('# duration_ms 114.203625'), true);
2414
+ assert.equal(POLICY.isTapMetadataHeading('# Notes'), false);
2415
+ assert.equal(POLICY.isTapMetadataHeading('# pass 1 explanation'), false);
2416
+ });