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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/bin/__tests__/codex-hooks.test.js +794 -21
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +1838 -112
  4. package/bin/__tests__/installer-safety.test.js +82 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +799 -0
  6. package/bin/__tests__/package-inventory.test.js +841 -10
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +2469 -71
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +153 -202
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
  22. package/src/claude/hooks/completion-authority-check.cjs +56 -0
  23. package/src/claude/hooks/completion-authority-state.cjs +527 -0
  24. package/src/claude/hooks/completion-authority.cjs +154 -0
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/session.cjs +33 -19
  31. package/src/claude/hooks/spec-gate.cjs +181 -139
  32. package/src/claude/hooks/spec-state.cjs +112 -47
  33. package/src/claude/migration-manifest.json +15 -2
  34. package/src/claude/rules/state-sync.md +52 -21
  35. package/src/claude/rules/workflow.md +17 -9
  36. package/src/claude/scripts/provenance.cjs +315 -0
  37. package/src/claude/scripts/spec-final-state.cjs +244 -0
  38. package/src/claude/scripts/spec-ground.cjs +535 -173
  39. package/src/claude/scripts/spec-readiness.cjs +118 -0
  40. package/src/claude/scripts/spec-receipt.cjs +162 -0
  41. package/src/claude/scripts/spec-resolver.cjs +501 -0
  42. package/src/claude/scripts/spec-scaffold.cjs +1945 -55
  43. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  44. package/src/claude/scripts/validate-spec-output.cjs +3204 -262
  45. package/src/claude/scripts/workflow-policy.cjs +1888 -203
  46. package/src/claude/settings/settings.json +12 -0
  47. package/src/claude/skills/code-review/SKILL.md +94 -66
  48. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  49. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  50. package/src/claude/skills/develop/SKILL.md +138 -283
  51. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  52. package/src/claude/skills/develop/references/quality-gate.md +123 -148
  53. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  54. package/src/claude/skills/specs/SKILL.md +192 -397
  55. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  56. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  57. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  58. package/src/claude/skills/specs/references/review.md +200 -260
  59. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  60. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  61. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  62. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  63. package/src/claude/skills/specs/rules/design-review.md +42 -97
  64. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  65. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  66. package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
  67. package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
  68. package/src/claude/skills/specs/templates/design.md +49 -263
  69. package/src/claude/skills/specs/templates/requirements.md +26 -36
  70. package/src/claude/skills/specs/templates/research.md +9 -98
  71. package/src/claude/skills/specs/templates/spec-state.json +26 -67
  72. package/src/claude/skills/specs/templates/task.md +30 -82
  73. package/src/claude/skills/sync/SKILL.md +45 -15
  74. package/src/claude/skills/sync/references/sync-protocols.md +34 -16
  75. package/src/claude/skills/test/SKILL.md +151 -167
  76. package/src/codex/AGENTS.md +6 -0
  77. package/src/codex/hooks/completion-authority-check.cjs +63 -0
  78. package/src/codex/hooks/completion-authority-state.cjs +527 -0
  79. package/src/codex/hooks/completion-authority.cjs +106 -0
  80. package/src/codex/hooks/lib/hook-context.cjs +58 -4
  81. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  82. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  83. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  84. package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
  85. package/src/codex/hooks/lib/spec-utils.cjs +104 -27
  86. package/src/codex/hooks/privacy-approval.cjs +1 -1
  87. package/src/codex/hooks/privacy-block.cjs +67 -11
  88. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  89. package/src/codex/hooks/session.cjs +2 -0
  90. package/src/codex/hooks/spec-gate.cjs +151 -24
  91. package/src/codex/hooks/spec-state.cjs +65 -12
  92. package/src/codex/hooks/state.cjs +2 -0
  93. package/src/codex/hooks.json +16 -1
  94. package/src/codex/rules/state-sync.md +47 -27
  95. package/src/common/AGENTS.md +1 -1
  96. package/src/opencode/AGENTS.md +4 -2
  97. package/src/opencode/plugins/spec-gate.ts +296 -122
  98. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -8,17 +8,30 @@
8
8
  const { test, beforeEach, after } = require('node:test');
9
9
  const assert = require('node:assert');
10
10
  const { spawnSync } = require('node:child_process');
11
+ const crypto = require('node:crypto');
11
12
  const fs = require('node:fs');
12
13
  const os = require('node:os');
13
14
  const path = require('node:path');
14
15
 
15
16
  const HOOK = path.join(__dirname, '..', 'spec-gate.cjs');
16
17
  const CACHE = path.join(__dirname, '..', '.logs', 'spec-gate-last.json');
18
+ const PROVENANCE = require(path.join(__dirname, '..', '..', 'scripts', 'provenance.cjs'));
19
+ const POLICY = require(path.join(__dirname, '..', '..', 'scripts', 'workflow-policy.cjs'));
17
20
  const FEATURE = 'demo';
18
21
  const TASK_REL = 'tasks/task-R0-01-x.md';
22
+ const VALID_BASE = '0123456789abcdef0123456789abcdef01234567';
23
+ const VALID_HEAD = '89abcdef0123456789abcdef0123456789abcdef';
24
+ const FIXTURE_ARTIFACT = Buffer.from('cafekit claude fixture artifact\n');
25
+ const FIXTURE_ARTIFACT_DIGEST = crypto.createHash('sha256').update(FIXTURE_ARTIFACT).digest('hex');
19
26
 
20
27
  function runHook(payload, cwd) {
21
28
  const projectRoot = cwd || payload.cwd;
29
+ const specsRoot = path.join(projectRoot, 'specs');
30
+ if (fs.existsSync(specsRoot)) {
31
+ for (const featureName of fs.readdirSync(specsRoot)) {
32
+ installFeatureReceipt(projectRoot, featureName, payload.session_id || 'test');
33
+ }
34
+ }
22
35
  // PROJECT_ROOT wins over cwd in the hook; pin it to the fixture so a host
23
36
  // monorepo's real specs/ cannot leak into the test (e.g. active rtk-* specs).
24
37
  const env = { ...process.env, PROJECT_ROOT: projectRoot };
@@ -42,7 +55,59 @@ function runHook(payload, cwd) {
42
55
  }
43
56
 
44
57
  function tmpDir() {
45
- return fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-'));
58
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-'));
59
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
60
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
61
+ assert.strictEqual(result.status, 0, result.stderr);
62
+ }
63
+ return dir;
64
+ }
65
+
66
+ function runtimeContext(dir) {
67
+ return PROVENANCE.deriveRuntimeContext({
68
+ projectRoot: dir,
69
+ specsRoot: path.join(dir, 'specs'),
70
+ specFile: path.join(dir, 'specs', FEATURE, 'spec.json'),
71
+ featureName: FEATURE,
72
+ runtimeSession: 'test',
73
+ });
74
+ }
75
+
76
+ function bindFixtureReceipt(dir, value) {
77
+ const context = runtimeContext(dir);
78
+ return value.replaceAll(VALID_BASE, context.base).replaceAll(VALID_HEAD, context.head);
79
+ }
80
+
81
+ function installFeatureReceipt(dir, featureName = FEATURE, session = 'test') {
82
+ const featureDir = path.join(dir, 'specs', featureName);
83
+ const specFile = path.join(featureDir, 'spec.json');
84
+ if (!fs.existsSync(specFile)) return;
85
+ let spec;
86
+ try { spec = JSON.parse(fs.readFileSync(specFile, 'utf8')); } catch { return; }
87
+ const lifecyclePhase = spec.current_phase || spec.phase;
88
+ const explicitCloseout = ['completed', 'complete'].includes(spec.status)
89
+ || ['closeout', 'completion', 'completed', 'complete'].includes(lifecyclePhase);
90
+ if (!explicitCloseout) return;
91
+ const tasks = Object.values(spec.task_registry || {});
92
+ if (tasks.length === 0 || tasks.some((task) => task.status !== 'done')) return;
93
+ const context = PROVENANCE.deriveRuntimeContext({
94
+ projectRoot: dir,
95
+ specsRoot: path.join(dir, 'specs'),
96
+ specFile,
97
+ featureName,
98
+ runtimeSession: session,
99
+ });
100
+ fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), [
101
+ `Feature: ${featureName}`,
102
+ 'Expected: final integration verification passes',
103
+ 'Observed: final integration verification passed',
104
+ 'Verification: PASS',
105
+ 'Command: node --test',
106
+ 'Exit: 0',
107
+ `Base: ${context.base}`,
108
+ `Head: ${context.head}`,
109
+ '',
110
+ ].join('\n'));
46
111
  }
47
112
 
48
113
  function clearCache() {
@@ -66,6 +131,8 @@ function readCache() {
66
131
  * @param {string} [opts.mdStatus='done']
67
132
  * @param {'valid'|'legacy-valid'|'failed'|'fence-only'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
68
133
  * @param {object|null} [opts.runtime] — if set, write .claude/runtime.json
134
+ * @param {object|null} [opts.workflowPolicy] — if set, persist completion obligations
135
+ * @param {string} [opts.phase='closeout'] — persisted lifecycle boundary
69
136
  */
70
137
  function makeFixture(opts = {}) {
71
138
  const {
@@ -74,6 +141,8 @@ function makeFixture(opts = {}) {
74
141
  mdStatus = 'done',
75
142
  evidence = 'valid',
76
143
  runtime = null,
144
+ workflowPolicy = null,
145
+ phase = 'closeout',
77
146
  } = opts;
78
147
  const dir = tmpDir();
79
148
  const featureDir = path.join(dir, 'specs', FEATURE);
@@ -83,14 +152,14 @@ function makeFixture(opts = {}) {
83
152
  const entry = { status: taskStatus };
84
153
  if (completed_at !== null) entry.completed_at = completed_at;
85
154
 
86
- fs.writeFileSync(
87
- path.join(featureDir, 'spec.json'),
88
- JSON.stringify({
89
- status: 'in_progress',
90
- current_phase: 'implementation',
91
- task_registry: { [TASK_REL]: entry },
92
- }),
93
- );
155
+ const spec = {
156
+ status: 'in_progress',
157
+ feature_name: FEATURE,
158
+ current_phase: phase,
159
+ task_registry: { [TASK_REL]: entry },
160
+ };
161
+ if (workflowPolicy) spec.workflow_policy = workflowPolicy;
162
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify(spec));
94
163
 
95
164
  let evidenceBlock = '';
96
165
  if (evidence === 'valid') {
@@ -101,9 +170,9 @@ function makeFixture(opts = {}) {
101
170
  'Command: npm test',
102
171
  'Exit: 0',
103
172
  'Result: PASS',
104
- 'Base: abc123',
105
- 'Head: def456',
106
- 'Artifact: dist/bundle.js (sha256:deadbeef)',
173
+ `Base: ${VALID_BASE}`,
174
+ `Head: ${VALID_HEAD}`,
175
+ `Artifact: output/bundle.js (sha256:${FIXTURE_ARTIFACT_DIGEST})`,
107
176
  '```',
108
177
  'npm test',
109
178
  'PASS: 10 tests',
@@ -111,7 +180,7 @@ function makeFixture(opts = {}) {
111
180
  '',
112
181
  ].join('\n');
113
182
  } else if (evidence === 'legacy-valid') {
114
- evidenceBlock = '## Evidence\n\nVerification: PASS\nCommand: npm test\nExit: 0\nBase: abc123\nHead: def456\nnpm test — passed, exit code 0\n';
183
+ evidenceBlock = `## Evidence\n\nVerification: PASS\nCommand: npm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\nnpm test — passed, exit code 0\n`;
115
184
  } else if (evidence === 'failed') {
116
185
  evidenceBlock = '## Evidence\n\nVerification: PASS\n\nFAIL: tests failed, exit code 1\n';
117
186
  } else if (evidence === 'fence-only') {
@@ -149,6 +218,15 @@ function makeFixture(opts = {}) {
149
218
  );
150
219
  }
151
220
 
221
+ if (evidence === 'valid') {
222
+ fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
223
+ fs.writeFileSync(path.join(dir, 'output', 'bundle.js'), FIXTURE_ARTIFACT);
224
+ }
225
+ if (evidence === 'valid' || evidence === 'legacy-valid') {
226
+ const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
227
+ fs.writeFileSync(taskFile, bindFixtureReceipt(dir, fs.readFileSync(taskFile, 'utf8')));
228
+ }
229
+
152
230
  return dir;
153
231
  }
154
232
 
@@ -164,6 +242,57 @@ function parseBlock(stdout) {
164
242
  }
165
243
  }
166
244
 
245
+ function installClaudeGate(root, policyMode = 'valid') {
246
+ const hooks = path.join(root, '.claude', 'hooks');
247
+ const scripts = path.join(root, '.claude', 'scripts');
248
+ fs.mkdirSync(hooks, { recursive: true });
249
+ fs.mkdirSync(scripts, { recursive: true });
250
+ fs.copyFileSync(HOOK, path.join(hooks, 'spec-gate.cjs'));
251
+ for (const name of [
252
+ 'completion-authority-check.cjs', 'completion-authority-state.cjs', 'semantic-review-authority.cjs',
253
+ ]) fs.copyFileSync(path.join(__dirname, '..', name), path.join(hooks, name));
254
+ for (const name of ['spec-resolver.cjs', 'spec-receipt.cjs', 'validate-spec-output.cjs', 'spec-ground.cjs', 'spec-semantic-model.cjs', 'spec-final-state.cjs']) {
255
+ fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', name), path.join(scripts, name));
256
+ }
257
+ if (policyMode === 'valid') {
258
+ fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', 'workflow-policy.cjs'), path.join(scripts, 'workflow-policy.cjs'));
259
+ fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', 'provenance.cjs'), path.join(scripts, 'provenance.cjs'));
260
+ } else if (policyMode === 'malformed') {
261
+ fs.writeFileSync(path.join(scripts, 'workflow-policy.cjs'), 'module.exports = {\n');
262
+ }
263
+ return path.join(hooks, 'spec-gate.cjs');
264
+ }
265
+
266
+ test('CLAUDE_PROJECT_DIR wins over malicious payload.cwd and stale process cwd', () => {
267
+ const target = makeFixture({ evidence: 'missing-evidence' });
268
+ const stale = tmpDir();
269
+ try {
270
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
271
+ const result = spawnSync(process.execPath, [HOOK], {
272
+ cwd: stale,
273
+ env: {
274
+ ...process.env,
275
+ CLAUDE_PROJECT_DIR: target,
276
+ PROJECT_ROOT: stale,
277
+ },
278
+ input: JSON.stringify({
279
+ hook_event_name: 'Stop',
280
+ session_id: 'test',
281
+ stop_hook_active: false,
282
+ cwd: stale,
283
+ }),
284
+ encoding: 'utf8',
285
+ });
286
+ assert.equal(result.status, 0, result.stderr);
287
+ const body = parseBlock(result.stdout.trim());
288
+ assert.equal(body?.decision, 'block');
289
+ assert.match(body.reason, new RegExp(TASK_REL.replace('/', '\\/')));
290
+ } finally {
291
+ fs.rmSync(target, { recursive: true, force: true });
292
+ fs.rmSync(stale, { recursive: true, force: true });
293
+ }
294
+ });
295
+
167
296
  beforeEach(() => {
168
297
  clearCache();
169
298
  });
@@ -193,6 +322,134 @@ test('1. newly-done task WITHOUT receipt → block, reason names task path', ()
193
322
  }
194
323
  });
195
324
 
325
+ test('installed Claude gate fails closed with a controlled decision when policy is missing or malformed', () => {
326
+ for (const policyMode of ['missing', 'malformed']) {
327
+ const dir = tmpDir();
328
+ const gate = installClaudeGate(dir, policyMode);
329
+ try {
330
+ const result = spawnSync(process.execPath, [gate], {
331
+ cwd: dir,
332
+ env: { ...process.env, PROJECT_ROOT: dir },
333
+ input: JSON.stringify({ cwd: dir, session_id: 'test', stop_hook_active: false }),
334
+ encoding: 'utf8',
335
+ });
336
+ assert.equal(result.status, 0, `${policyMode}: ${result.stderr}`);
337
+ assert.equal(result.stderr, '', `${policyMode} must not leak a stack trace`);
338
+ const block = parseBlock(result.stdout);
339
+ assert.equal(block?.decision, 'block', `${policyMode} must emit a controlled block`);
340
+ assert.match(block.reason, /workflow policy could not be loaded/i);
341
+ assert.ok(fs.existsSync(path.join(dir, '.claude', 'hooks', '.logs', 'hook-log.jsonl')));
342
+ } finally {
343
+ fs.rmSync(dir, { recursive: true, force: true });
344
+ }
345
+ }
346
+ });
347
+
348
+ test('Claude adapter requires canonical SHA-256 only for a declared task artifact', () => {
349
+ const digest = FIXTURE_ARTIFACT_DIGEST;
350
+ const bodies = [
351
+ { suffix: '', expected: true, label: 'missing hash' },
352
+ { suffix: 'sha256: abc123\n', expected: true, label: 'invalid hash' },
353
+ { suffix: `sha256: ${'0'.repeat(64)}\n`, expected: true, label: 'mismatched hash' },
354
+ { suffix: `sha256: ${digest}\n`, expected: false, label: 'valid hash' },
355
+ ];
356
+ for (const { suffix, expected, label } of bodies) {
357
+ const dir = tmpDir();
358
+ const gate = installClaudeGate(dir);
359
+ const featureDir = path.join(dir, 'specs', FEATURE);
360
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
361
+ fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
362
+ fs.writeFileSync(path.join(dir, 'output', 'bundle.js'), FIXTURE_ARTIFACT);
363
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
364
+ status: 'in_progress',
365
+ current_phase: 'closeout',
366
+ feature_name: FEATURE,
367
+ task_registry: {
368
+ [TASK_REL]: {
369
+ status: 'done',
370
+ completed_at: '2026-08-11T00:00:00.000Z',
371
+ artifacts: ['output/bundle.js'],
372
+ },
373
+ },
374
+ }));
375
+ fs.writeFileSync(path.join(featureDir, TASK_REL), [
376
+ '# Task', '', '**Status:** done', '', '## Evidence', '',
377
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
378
+ 'Artifact: output/bundle.js', suffix,
379
+ ].join('\n'));
380
+ fs.writeFileSync(
381
+ path.join(featureDir, TASK_REL),
382
+ bindFixtureReceipt(dir, fs.readFileSync(path.join(featureDir, TASK_REL), 'utf8')),
383
+ );
384
+ installFeatureReceipt(dir);
385
+ try {
386
+ const result = spawnSync(process.execPath, [gate], {
387
+ cwd: dir,
388
+ env: { ...process.env, PROJECT_ROOT: dir },
389
+ input: JSON.stringify({ cwd: dir, session_id: 'test', stop_hook_active: false }),
390
+ encoding: 'utf8',
391
+ });
392
+ assert.equal(result.status, 0, `${label}: ${result.stderr}`);
393
+ if (expected) {
394
+ const block = parseBlock(result.stdout);
395
+ assert.equal(block?.decision, 'block', `${label} must block`);
396
+ assert.match(block.reason, /\bartifact_hash\b/);
397
+ } else {
398
+ assert.equal(result.stdout, '', `${label} must pass`);
399
+ }
400
+ } finally {
401
+ fs.rmSync(dir, { recursive: true, force: true });
402
+ }
403
+ }
404
+ });
405
+
406
+ test('Claude artifact verification rejects traversal and symlink paths', () => {
407
+ const cases = [
408
+ { artifactPath: '../outside.js', symlink: false },
409
+ { artifactPath: 'output/link.js', symlink: true },
410
+ ];
411
+ for (const { artifactPath, symlink } of cases) {
412
+ const dir = tmpDir();
413
+ const featureDir = path.join(dir, 'specs', FEATURE);
414
+ const artifactBytes = Buffer.from(`artifact-${artifactPath}\n`);
415
+ const digest = crypto.createHash('sha256').update(artifactBytes).digest('hex');
416
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
417
+ if (symlink) {
418
+ fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
419
+ const target = path.join(dir, 'artifact-target.js');
420
+ fs.writeFileSync(target, artifactBytes);
421
+ fs.symlinkSync(target, path.join(dir, artifactPath));
422
+ } else {
423
+ fs.writeFileSync(path.join(dir, 'outside.js'), artifactBytes);
424
+ }
425
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
426
+ status: 'in_progress',
427
+ current_phase: 'closeout',
428
+ feature_name: FEATURE,
429
+ task_registry: {
430
+ [TASK_REL]: {
431
+ status: 'done',
432
+ completed_at: '2026-08-11T00:00:00.000Z',
433
+ artifacts: [artifactPath],
434
+ },
435
+ },
436
+ }));
437
+ fs.writeFileSync(path.join(featureDir, TASK_REL), bindFixtureReceipt(dir, [
438
+ '# Task', '', '**Status:** done', '', '## Evidence', '',
439
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0',
440
+ `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
441
+ `Artifact: ${artifactPath} (sha256:${digest})`,
442
+ ].join('\n')));
443
+ try {
444
+ const body = parseBlock(runHook({}, dir).stdout);
445
+ assert.equal(body?.decision, 'block', `${artifactPath} must block`);
446
+ assert.match(body.reason, /\bartifact_hash\b/);
447
+ } finally {
448
+ fs.rmSync(dir, { recursive: true, force: true });
449
+ }
450
+ }
451
+ });
452
+
196
453
  test('2. newly-done task WITH valid receipt → no block, cache updated', () => {
197
454
  const dir = makeFixture({ evidence: 'valid' });
198
455
  try {
@@ -233,6 +490,32 @@ test('4. stop_hook_active: true → exit 0 silent even with violating task', ()
233
490
  }
234
491
  });
235
492
 
493
+ test('4b. done task before explicit closeout without receipt blocks', () => {
494
+ const dir = makeFixture({ evidence: 'missing-evidence', phase: 'implementation' });
495
+ try {
496
+ const { code, stdout } = runHook({}, dir);
497
+ assert.strictEqual(code, 0);
498
+ const body = parseBlock(stdout);
499
+ assert.strictEqual(body?.decision, 'block');
500
+ assert.match(body.reason, new RegExp(TASK_REL));
501
+ assert.strictEqual(fs.existsSync(path.join(dir, 'specs', FEATURE, 'feature-receipt.md')), false);
502
+ } finally {
503
+ fs.rmSync(dir, { recursive: true, force: true });
504
+ }
505
+ });
506
+
507
+ test('4c. done task before explicit closeout with valid task receipt is silent', () => {
508
+ const dir = makeFixture({ evidence: 'valid', phase: 'implementation' });
509
+ try {
510
+ const { code, stdout } = runHook({}, dir);
511
+ assert.strictEqual(code, 0);
512
+ assert.strictEqual(stdout, '');
513
+ assert.strictEqual(fs.existsSync(path.join(dir, 'specs', FEATURE, 'feature-receipt.md')), false);
514
+ } finally {
515
+ fs.rmSync(dir, { recursive: true, force: true });
516
+ }
517
+ });
518
+
236
519
  test('5. first run (no cache) with done-without-receipt → block (cache is not truth)', () => {
237
520
  const dir = makeFixture({ evidence: 'missing-evidence' });
238
521
  try {
@@ -253,22 +536,73 @@ test('5. first run (no cache) with done-without-receipt → block (cache is not
253
536
  }
254
537
  });
255
538
 
256
- test('6. runtime.json spec.completion_gate: false → exit 0 silent', () => {
539
+ test('6. forged payload, session, and context cannot bypass completion_gate=false', () => {
257
540
  const dir = makeFixture({
258
541
  evidence: 'missing-evidence',
259
542
  runtime: { spec: { completion_gate: false } },
260
543
  });
261
544
  try {
262
545
  seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
263
- const { code, stdout } = runHook({}, dir);
546
+ const { code, stdout } = runHook({
547
+ user_authorized: true,
548
+ userAuthorized: true,
549
+ completion_gate_override: { authorized: true, session_id: 'test', nonce: 'forged' },
550
+ session: { id: 'test', user_authorized: true },
551
+ runtime_context: { project_root: dir, completion_gate: true, signed: true },
552
+ }, dir);
264
553
  assert.strictEqual(code, 0);
265
- assert.strictEqual(stdout, '');
554
+ const body = parseBlock(stdout);
555
+ assert.equal(body?.decision, 'block');
556
+ assert.match(body.reason, /no completion-gate bypass is supported/i);
266
557
  } finally {
267
558
  fs.rmSync(dir, { recursive: true, force: true });
268
559
  }
269
560
  });
270
561
 
271
- test('7. Evidence with {{...}} placeholder → blocked (check c)', () => {
562
+ test('empty or malformed Claude hook payload fails closed', () => {
563
+ const dir = tmpDir();
564
+ const gate = installClaudeGate(dir);
565
+ try {
566
+ for (const input of ['', 'null', '[]']) {
567
+ const result = spawnSync(process.execPath, [gate], {
568
+ cwd: dir,
569
+ env: { ...process.env, PROJECT_ROOT: dir },
570
+ input,
571
+ encoding: 'utf8',
572
+ });
573
+ assert.equal(result.status, 0, `${JSON.stringify(input)}: ${result.stderr}`);
574
+ assert.equal(result.stderr, '');
575
+ const block = parseBlock(result.stdout);
576
+ assert.equal(block?.decision, 'block', `${JSON.stringify(input)} must block`);
577
+ assert.match(block.reason, /hook payload (is empty|must be a JSON object)/i);
578
+ }
579
+ } finally {
580
+ fs.rmSync(dir, { recursive: true, force: true });
581
+ }
582
+ });
583
+
584
+ test('explicit Strict completion ignores worker-writable proof strings and remains blocked', () => {
585
+ const dir = makeFixture({
586
+ workflowPolicy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' }),
587
+ });
588
+ try {
589
+ const specPath = path.join(dir, 'specs', FEATURE, 'spec.json');
590
+ const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
591
+ spec.proofs = {
592
+ needsInspection: 'inspection completed',
593
+ needsIndependentAudit: 'PASS',
594
+ needsResearchGrounding: 'research completed',
595
+ };
596
+ fs.writeFileSync(specPath, JSON.stringify(spec));
597
+ const body = parseBlock(runHook({}, dir).stdout);
598
+ assert.equal(body?.decision, 'block');
599
+ assert.match(body.reason, /needsInspection|needsIndependentAudit|needsResearchGrounding/);
600
+ } finally {
601
+ fs.rmSync(dir, { recursive: true, force: true });
602
+ }
603
+ });
604
+
605
+ test('7. Evidence with {{...}} placeholder → blocked (placeholder)', () => {
272
606
  const dir = makeFixture({ evidence: 'placeholder' });
273
607
  try {
274
608
  seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
@@ -278,8 +612,8 @@ test('7. Evidence with {{...}} placeholder → blocked (check c)', () => {
278
612
  assert.ok(body, 'expected block JSON on stdout');
279
613
  assert.strictEqual(body.decision, 'block');
280
614
  assert.ok(
281
- body.reason.includes(TASK_REL) && /\bc\b/.test(body.reason),
282
- `reason must cite task path and check c; got: ${body.reason}`,
615
+ body.reason.includes(TASK_REL) && /\bplaceholder\b/.test(body.reason),
616
+ `reason must cite task path and placeholder; got: ${body.reason}`,
283
617
  );
284
618
  } finally {
285
619
  fs.rmSync(dir, { recursive: true, force: true });
@@ -292,7 +626,7 @@ test('8. explicit failure cannot pass even when PASS text is present', () => {
292
626
  seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
293
627
  const body = parseBlock(runHook({}, dir).stdout);
294
628
  assert.ok(body);
295
- assert.match(body.reason, /\bc\b/);
629
+ assert.match(body.reason, /\bverification_state\b/);
296
630
  } finally {
297
631
  fs.rmSync(dir, { recursive: true, force: true });
298
632
  }
@@ -314,14 +648,14 @@ test('10. completed_at must be a valid ISO timestamp', () => {
314
648
  seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
315
649
  const body = parseBlock(runHook({}, dir).stdout);
316
650
  assert.ok(body);
317
- assert.match(body.reason, /\bd\b/);
651
+ assert.match(body.reason, /\bcompleted_at\b/);
318
652
  } finally {
319
653
  fs.rmSync(dir, { recursive: true, force: true });
320
654
  }
321
655
  });
322
656
 
323
- test('11. done → pending cache transition is persisted and can be gated again', () => {
324
- const dir = makeFixture({ taskStatus: 'pending', mdStatus: 'pending' });
657
+ test('11. done → pending cache transition is persisted and explicit closeout gates re-completion', () => {
658
+ const dir = makeFixture({ taskStatus: 'pending', mdStatus: 'pending', phase: 'implementation' });
325
659
  try {
326
660
  seedCache({ [FEATURE]: { [TASK_REL]: 'done' } });
327
661
  assert.strictEqual(runHook({}, dir).stdout, '');
@@ -333,6 +667,7 @@ test('11. done → pending cache transition is persisted and can be gated again'
333
667
  status: 'done',
334
668
  completed_at: '2026-07-01T00:00:00Z',
335
669
  };
670
+ spec.current_phase = 'closeout';
336
671
  fs.writeFileSync(specFile, JSON.stringify(spec));
337
672
  const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
338
673
  fs.writeFileSync(taskFile, '# Task\n\n**Status:** done\n\n## Evidence\n\nFAIL: tests failed\n');
@@ -360,11 +695,12 @@ test('13. provenance requires both Base and Head - only Base fails', () => {
360
695
  fs.mkdirSync(tasksDir, { recursive: true });
361
696
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
362
697
  status: 'in_progress',
363
- current_phase: 'implementation',
698
+ feature_name: FEATURE,
699
+ current_phase: 'closeout',
364
700
  task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
365
701
  }));
366
702
  // Only Base, missing Head
367
- fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
703
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir, [
368
704
  '# Task',
369
705
  '',
370
706
  '**Status:** done',
@@ -374,17 +710,17 @@ test('13. provenance requires both Base and Head - only Base fails', () => {
374
710
  'Verification: PASS',
375
711
  'Command: npm test',
376
712
  'Exit: 0',
377
- 'Base: abc123',
713
+ `Base: ${VALID_BASE}`,
378
714
  '```',
379
715
  'npm test PASS',
380
716
  '```',
381
- ].join('\n'));
717
+ ].join('\n')));
382
718
  try {
383
719
  seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
384
720
  const body = parseBlock(runHook({}, dir).stdout);
385
721
  assert.ok(body, 'only Base should block');
386
722
  assert.strictEqual(body.decision, 'block');
387
- assert.match(body.reason, /\bg\b/);
723
+ assert.match(body.reason, /\bprovenance\b/);
388
724
  } finally {
389
725
  fs.rmSync(dir, { recursive: true, force: true });
390
726
  }
@@ -397,7 +733,8 @@ test('14. provenance requires both Base and Head - only Head fails', () => {
397
733
  fs.mkdirSync(tasksDir, { recursive: true });
398
734
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
399
735
  status: 'in_progress',
400
- current_phase: 'implementation',
736
+ feature_name: FEATURE,
737
+ current_phase: 'closeout',
401
738
  task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
402
739
  }));
403
740
  // Only Head, missing Base
@@ -411,7 +748,7 @@ test('14. provenance requires both Base and Head - only Head fails', () => {
411
748
  'Verification: PASS',
412
749
  'Command: npm test',
413
750
  'Exit: 0',
414
- 'Head: def456',
751
+ `Head: ${VALID_HEAD}`,
415
752
  '```',
416
753
  'npm test PASS',
417
754
  '```',
@@ -421,7 +758,7 @@ test('14. provenance requires both Base and Head - only Head fails', () => {
421
758
  const body = parseBlock(runHook({}, dir).stdout);
422
759
  assert.ok(body, 'only Head should block');
423
760
  assert.strictEqual(body.decision, 'block');
424
- assert.match(body.reason, /\bg\b/);
761
+ assert.match(body.reason, /\bprovenance\b/);
425
762
  } finally {
426
763
  fs.rmSync(dir, { recursive: true, force: true });
427
764
  }
@@ -434,10 +771,11 @@ test('15. provenance with both Base and Head passes', () => {
434
771
  fs.mkdirSync(tasksDir, { recursive: true });
435
772
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
436
773
  status: 'in_progress',
437
- current_phase: 'implementation',
774
+ feature_name: FEATURE,
775
+ current_phase: 'closeout',
438
776
  task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
439
777
  }));
440
- fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
778
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir, [
441
779
  '# Task',
442
780
  '',
443
781
  '**Status:** done',
@@ -447,12 +785,12 @@ test('15. provenance with both Base and Head passes', () => {
447
785
  'Verification: PASS',
448
786
  'Command: npm test',
449
787
  'Exit: 0',
450
- 'Base: abc123',
451
- 'Head: def456',
788
+ `Base: ${VALID_BASE}`,
789
+ `Head: ${VALID_HEAD}`,
452
790
  '```',
453
791
  'npm test PASS',
454
792
  '```',
455
- ].join('\n'));
793
+ ].join('\n')));
456
794
  try {
457
795
  seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
458
796
  const { stdout } = runHook({}, dir);
@@ -481,8 +819,8 @@ test('16. registry task path with ../ escape or sibling-prefix is rejected (chec
481
819
  'Verification: PASS',
482
820
  'Command: npm test',
483
821
  'Exit: 0',
484
- 'Base: abc123',
485
- 'Head: def456',
822
+ `Base: ${VALID_BASE}`,
823
+ `Head: ${VALID_HEAD}`,
486
824
  '```',
487
825
  'pass',
488
826
  '```',
@@ -491,7 +829,8 @@ test('16. registry task path with ../ escape or sibling-prefix is rejected (chec
491
829
  const siblingPrefixRel = `tasks/../../${FEATURE}-evil/tasks/task.md`;
492
830
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
493
831
  status: 'in_progress',
494
- current_phase: 'implementation',
832
+ feature_name: FEATURE,
833
+ current_phase: 'closeout',
495
834
  task_registry: {
496
835
  [maliciousRel]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
497
836
  [siblingPrefixRel]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
@@ -527,8 +866,9 @@ test('17. empty or bare provenance fails — Base:/Head: require non-empty same-
527
866
  const featureDir = path.join(dir, 'specs', FEATURE);
528
867
  fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
529
868
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
530
- status: 'in_progress',
531
- current_phase: 'implementation',
869
+ status: 'in_progress',
870
+ feature_name: FEATURE,
871
+ current_phase: 'closeout',
532
872
  task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
533
873
  }));
534
874
  fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
@@ -536,30 +876,33 @@ test('17. empty or bare provenance fails — Base:/Head: require non-empty same-
536
876
  const body = parseBlock(runHook({}, dir).stdout);
537
877
  assert.ok(body, `empty/bare provenance should block for ${desc}`);
538
878
  assert.strictEqual(body.decision, 'block', `empty/bare should block: ${desc}`);
539
- assert.match(body.reason, /\bg\b/, `should fail provenance g for ${desc}`);
879
+ assert.match(body.reason, /\bprovenance\b/, `should fail provenance for ${desc}`);
540
880
  fs.rmSync(dir, { recursive: true, force: true });
541
881
  }
542
882
  });
543
883
 
544
884
  test('18. valid non-empty Base: value and base_sha: value pass', () => {
545
885
  const validCases = [
546
- ['Verification: PASS','Command: npm test','Exit: 0','Base: abc123','Head: def456'].join('\n'),
547
- ['Verification: PASS','Command: npm test','Exit: 0','base_sha: abc123','head_sha: def456'].join('\n'),
548
- ['Verification: PASS','Command: npm test','Exit: 0','Base: a1b2c3','Head: d4e5f6'].join('\n'),
886
+ { evidence: ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`].join('\n'), shouldPass: true },
887
+ { evidence: ['Verification: PASS','Command: npm test','Exit: 0',`base_sha: ${VALID_BASE}`,`head_sha: ${VALID_HEAD}`].join('\n'), shouldPass: true },
888
+ { evidence: ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${'a'.repeat(40)}`,`Head: ${'b'.repeat(40)}`].join('\n'), shouldPass: false },
549
889
  ];
550
- for (const evidence of validCases) {
890
+ for (const { evidence, shouldPass } of validCases) {
551
891
  const dir = tmpDir();
552
892
  const featureDir = path.join(dir, 'specs', FEATURE);
553
893
  fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
554
894
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
555
895
  status: 'in_progress',
556
- current_phase: 'implementation',
896
+ feature_name: FEATURE,
897
+ current_phase: 'closeout',
557
898
  task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
558
899
  }));
559
- fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
900
+ const boundEvidence = shouldPass ? bindFixtureReceipt(dir, evidence) : evidence;
901
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + boundEvidence,'```','pass','```'].join('\n'));
560
902
  seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
561
- const { stdout } = runHook({}, dir);
562
- assert.strictEqual(stdout, '', `valid provenance should not block: ${evidence.slice(0,40)}`);
903
+ const result = runHook({}, dir);
904
+ if (shouldPass) assert.strictEqual(result.stdout, '', `valid provenance should not block: ${evidence.slice(0,40)}`);
905
+ else assert.match(parseBlock(result.stdout).reason, /\bprovenance\b/, 'arbitrary valid-length SHA values must block');
563
906
  fs.rmSync(dir, { recursive: true, force: true });
564
907
  }
565
908
  });
@@ -594,13 +937,13 @@ test('20. cache-hit: receipt mutation (removed Verification/Command) blocks even
594
937
  fs.writeFileSync(taskFile, [
595
938
  '# Task', '', '**Status:** done', '',
596
939
  '## Evidence', '',
597
- 'Exit: 0', 'Base: abc123', 'Head: def456',
940
+ 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
598
941
  '```', 'pass', '```',
599
942
  ].join('\n'));
600
943
  const blocked = parseBlock(runHook({}, dir).stdout);
601
944
  assert.ok(blocked, 'mutated receipt missing Verification/Command should block on cache-hit');
602
945
  assert.strictEqual(blocked.decision, 'block');
603
- assert.match(blocked.reason, /\bc\b|\be\b/);
946
+ assert.match(blocked.reason, /\bverification_state\b|\bcommand\b/);
604
947
  // cache must not have been promoted to still-valid done; next run must still block
605
948
  const secondBlocked = parseBlock(runHook({}, dir).stdout);
606
949
  assert.ok(secondBlocked, 'second hit after mutation must still block');
@@ -618,21 +961,21 @@ test('21. cache-hit: provenance mutation (removed Head / changed to stale) block
618
961
  const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
619
962
  fs.writeFileSync(taskFile, [
620
963
  '# Task', '', '**Status:** done', '',
621
- '## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', 'Base: abc123',
964
+ '## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', `Base: ${VALID_BASE}`,
622
965
  '```', 'pass', '```',
623
966
  ].join('\n'));
624
967
  let body = parseBlock(runHook({}, dir).stdout);
625
968
  assert.ok(body, 'missing Head provenance should block on cache-hit');
626
- assert.match(body.reason, /\bg\b/);
969
+ assert.match(body.reason, /\bprovenance\b/);
627
970
  // mutate back to include both but with empty Head value (stale)
628
971
  fs.writeFileSync(taskFile, [
629
972
  '# Task', '', '**Status:** done', '',
630
- '## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', 'Base: abc123', 'Head:',
973
+ '## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', `Base: ${VALID_BASE}`, 'Head:',
631
974
  '```', 'pass', '```',
632
975
  ].join('\n'));
633
976
  body = parseBlock(runHook({}, dir).stdout);
634
977
  assert.ok(body, 'empty Head should block as stale provenance');
635
- assert.match(body.reason, /\bg\b/);
978
+ assert.match(body.reason, /\bprovenance\b/);
636
979
  } finally {
637
980
  fs.rmSync(dir, { recursive: true, force: true });
638
981
  }
@@ -693,3 +1036,208 @@ test('24. first-run (no cache file) with valid receipt passes and seeds cache',
693
1036
  fs.rmSync(dir, { recursive: true, force: true });
694
1037
  }
695
1038
  });
1039
+
1040
+ test('25. placeholder tokens Command: TODO / Base: TBD / artifact sha256 empty must block', () => {
1041
+ const cases = [
1042
+ { evidence: ['Verification: PASS','Command: TODO','Exit: 0','Base: a','Head: b'].join('\n'), check: /\bcommand\b/, desc: 'Command TODO' },
1043
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: TBD','Head: b'].join('\n'), check: /\bprovenance\b/, desc: 'Base TBD' },
1044
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: N/A'].join('\n'), check: /\bprovenance\b/, desc: 'Head N/A' },
1045
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','base_sha: pending','head_sha: unknown'].join('\n'), check: /\bprovenance\b/, desc: 'base_sha pending' },
1046
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Artifact: bundle','sha256: '].join('\n'), check: /\bartifact_hash\b/, desc: 'artifact empty sha' },
1047
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Artifact: bundle','sha256: TBD'].join('\n'), check: /\bartifact_hash\b/, desc: 'artifact TBD sha' },
1048
+ ];
1049
+ for (const { evidence, check, desc } of cases) {
1050
+ const dir = tmpDir();
1051
+ const featureDir = path.join(dir, 'specs', FEATURE);
1052
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
1053
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'closeout', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
1054
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
1055
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
1056
+ const body = parseBlock(runHook({}, dir).stdout);
1057
+ assert.ok(body, `placeholder case ${desc} should block`);
1058
+ assert.strictEqual(body.decision, 'block', desc);
1059
+ assert.match(body.reason, check, `check for ${desc}`);
1060
+ fs.rmSync(dir, { recursive: true, force: true });
1061
+ }
1062
+ // Ensure normal todo substring does NOT block
1063
+ const dir2 = tmpDir();
1064
+ const featureDir2 = path.join(dir2, 'specs', FEATURE);
1065
+ fs.mkdirSync(path.join(featureDir2, 'tasks'), { recursive: true });
1066
+ fs.writeFileSync(path.join(featureDir2, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'closeout', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
1067
+ fs.writeFileSync(path.join(dir2, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir2, ['# Task','','**Status:** done','','## Evidence','','Verification: PASS','Command: npm run todo:test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`,'```','pass','```'].join('\n')));
1068
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
1069
+ assert.strictEqual(runHook({}, dir2).stdout, '', 'command containing todo substring should not block');
1070
+ fs.rmSync(dir2, { recursive: true, force: true });
1071
+ });
1072
+
1073
+ test('26. explicit failure outcomes Tests failed / Result FAIL and multiple Results must block', () => {
1074
+ const cases = [
1075
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Tests failed: 1'].join('\n'), desc: 'Tests failed: 1' },
1076
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Result: FAIL','Result: PASS'].join('\n'), desc: 'Result FAIL then PASS' },
1077
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','0 tests passed'].join('\n'), desc: '0 tests' },
1078
+ ];
1079
+ for (const { evidence, desc } of cases) {
1080
+ const dir = tmpDir();
1081
+ const featureDir = path.join(dir, 'specs', FEATURE);
1082
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
1083
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'closeout', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
1084
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
1085
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
1086
+ const body = parseBlock(runHook({}, dir).stdout);
1087
+ assert.ok(body, `explicit failure ${desc} should block`);
1088
+ assert.strictEqual(body.decision, 'block');
1089
+ fs.rmSync(dir, { recursive: true, force: true });
1090
+ }
1091
+ });
1092
+
1093
+ test('27. task markdown symlink outside feature must be rejected (check a)', () => {
1094
+ const dir = tmpDir();
1095
+ const featureDir = path.join(dir, 'specs', FEATURE);
1096
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
1097
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'closeout', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
1098
+ const outside = path.join(dir, 'outside.md');
1099
+ fs.writeFileSync(outside, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
1100
+ const taskPath = path.join(dir, 'specs', FEATURE, TASK_REL);
1101
+ try { fs.unlinkSync(taskPath); } catch {}
1102
+ fs.symlinkSync(outside, taskPath);
1103
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
1104
+ const body = parseBlock(runHook({}, dir).stdout);
1105
+ assert.ok(body, 'symlink task outside should block');
1106
+ assert.strictEqual(body.decision, 'block');
1107
+ assert.match(body.reason, /\ba\b/);
1108
+ fs.rmSync(dir, { recursive: true, force: true });
1109
+ });
1110
+
1111
+ test('28. malformed spec.json must block with invalid_specs', () => {
1112
+ const dir = tmpDir();
1113
+ fs.mkdirSync(path.join(dir, 'specs', 'good'), { recursive: true });
1114
+ fs.mkdirSync(path.join(dir, 'specs', 'bad'), { recursive: true });
1115
+ fs.writeFileSync(path.join(dir, 'specs', 'good', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'good', task_registry: {} }));
1116
+ fs.writeFileSync(path.join(dir, 'specs', 'bad', 'spec.json'), '{ malformed');
1117
+ seedCache({});
1118
+ const { stdout } = runHook({}, dir);
1119
+ const body = parseBlock(stdout);
1120
+ assert.ok(body, 'malformed spec should block');
1121
+ assert.strictEqual(body.decision, 'block');
1122
+ assert.match(body.reason, /invalid spec/i);
1123
+ assert.match(body.reason, /bad/);
1124
+ fs.rmSync(dir, { recursive: true, force: true });
1125
+ });
1126
+
1127
+ test('29. specs/linked symlink outside must be explicit_malformed and non-explicit scan must not count it', () => {
1128
+ const dir = tmpDir();
1129
+ const specsDir = path.join(dir, 'specs');
1130
+ fs.mkdirSync(specsDir, { recursive: true });
1131
+ const outside = path.join(dir, 'outside-feat');
1132
+ fs.mkdirSync(outside, { recursive: true });
1133
+ fs.writeFileSync(path.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
1134
+ const link = path.join(specsDir, 'linked');
1135
+ fs.symlinkSync(outside, link);
1136
+ // non-explicit should block with invalid_specs (fail-closed, not phantom no-active)
1137
+ seedCache({});
1138
+ const nonExplicit = parseBlock(runHook({}, dir).stdout);
1139
+ assert.ok(nonExplicit, 'non-explicit symlink outside should block with invalid_specs');
1140
+ assert.strictEqual(nonExplicit.decision, 'block');
1141
+ assert.match(nonExplicit.reason, /invalid spec/i);
1142
+ assert.match(nonExplicit.reason, /linked/);
1143
+ // explicit should block explicit_malformed
1144
+ const body = parseBlock(runHook({ featureName: 'linked' }, dir).stdout);
1145
+ assert.ok(body);
1146
+ assert.strictEqual(body.decision, 'block');
1147
+ assert.match(body.reason, /explicit_malformed/);
1148
+ fs.rmSync(dir, { recursive: true, force: true });
1149
+ });
1150
+
1151
+ test('30. phantom vectors via gate evidence must block with verification_state (r3)', () => {
1152
+ const vectors = [
1153
+ 'ℹ tests 0',
1154
+ 'Tests: 0 total',
1155
+ '0 tests',
1156
+ 'collected 0 items',
1157
+ 'No tests found.',
1158
+ 'ℹ cancelled 1',
1159
+ 'cancelled 1',
1160
+ '1 cancelled',
1161
+ 'Test Suites: 1 failed, 1 total',
1162
+ 'FAIL ./foo.test.js',
1163
+ 'FAIL\tpackage',
1164
+ 'FAIL\texample.test/probe\t0.431s',
1165
+ '--- FAIL: TestName (0.00s)',
1166
+ 'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
1167
+ 'FAILED tests/test_demo.py',
1168
+ '# fail 1',
1169
+ 'ℹ fail 1',
1170
+ 'not ok 1 - test',
1171
+ '1 error in 0.12s',
1172
+ 'ERROR collecting tests/test_demo.py',
1173
+ 'Found 2 errors',
1174
+ 'Tests run: 5, Failures: 1, Errors: 0',
1175
+ '[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
1176
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
1177
+ '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
1178
+ '5 tests completed, 1 failed',
1179
+ ];
1180
+ for (const v of vectors) {
1181
+ const dir = tmpDir();
1182
+ const featureDir = path.join(dir, 'specs', FEATURE);
1183
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
1184
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
1185
+ status: 'in_progress',
1186
+ feature_name: FEATURE,
1187
+ current_phase: 'closeout',
1188
+ task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
1189
+ }));
1190
+ const evidence = bindFixtureReceipt(dir, ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`, v].join('\n'));
1191
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
1192
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
1193
+ const body = parseBlock(runHook({}, dir).stdout);
1194
+ assert.ok(body, `vector should block: ${v}`);
1195
+ assert.strictEqual(body.decision, 'block', `vector ${v} should block`);
1196
+ assert.match(body.reason, /\bverification_state\b/, `vector ${v} should fail verification_state`);
1197
+ fs.rmSync(dir, { recursive: true, force: true });
1198
+ }
1199
+ // positive controls must not block
1200
+ const passes = [
1201
+ 'Tests failed: 0',
1202
+ 'fail 0',
1203
+ 'Tests: 0 failed',
1204
+ 'cancelled 0',
1205
+ 'Test Suites: 0 failed',
1206
+ '5 tests completed, 0 failed',
1207
+ 'errors 0',
1208
+ 'Found 0 errors',
1209
+ 'collected 1 item',
1210
+ '[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
1211
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
1212
+ '[ERROR] Error handling is documented',
1213
+ 'Error handling is documented',
1214
+ 'FAILURE mode analysis',
1215
+ 'Notes: tests failed previously but now fixed',
1216
+ 'Notes: supports zero-test parsing',
1217
+ '# tests 1',
1218
+ '# suites 0',
1219
+ '# pass 1',
1220
+ '# fail 0',
1221
+ '# cancelled 0',
1222
+ '# skipped 0',
1223
+ '# todo 0',
1224
+ '# duration_ms 114.203625',
1225
+ ];
1226
+ for (const v of passes) {
1227
+ const dir = tmpDir();
1228
+ const featureDir = path.join(dir, 'specs', FEATURE);
1229
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
1230
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
1231
+ status: 'in_progress',
1232
+ feature_name: FEATURE,
1233
+ current_phase: 'closeout',
1234
+ task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
1235
+ }));
1236
+ const evidence = bindFixtureReceipt(dir, ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`, v].join('\n'));
1237
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
1238
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
1239
+ const { stdout } = runHook({}, dir);
1240
+ assert.strictEqual(stdout, '', `positive control should not block: ${v}`);
1241
+ fs.rmSync(dir, { recursive: true, force: true });
1242
+ }
1243
+ });