@haposoft/cafekit 0.16.0-rc.1 → 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 (51) 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/code-auditor.md +8 -1
  10. package/src/claude/agents/spec-maker.md +107 -200
  11. package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
  12. package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
  13. package/src/claude/hooks/completion-authority-check.cjs +307 -0
  14. package/src/claude/hooks/completion-authority-state.cjs +525 -0
  15. package/src/claude/hooks/completion-authority.cjs +148 -0
  16. package/src/claude/hooks/session.cjs +33 -19
  17. package/src/claude/hooks/spec-gate.cjs +173 -61
  18. package/src/claude/hooks/spec-state.cjs +104 -45
  19. package/src/claude/migration-manifest.json +6 -1
  20. package/src/claude/scripts/provenance.cjs +315 -0
  21. package/src/claude/scripts/spec-resolver.cjs +450 -0
  22. package/src/claude/scripts/spec-scaffold.cjs +716 -41
  23. package/src/claude/scripts/validate-spec-output.cjs +355 -53
  24. package/src/claude/scripts/workflow-policy.cjs +1529 -162
  25. package/src/claude/settings/settings.json +8 -0
  26. package/src/claude/skills/backend-development/references/backend-security.md +52 -0
  27. package/src/claude/skills/code-review/SKILL.md +85 -66
  28. package/src/claude/skills/develop/SKILL.md +153 -285
  29. package/src/claude/skills/develop/references/quality-gate.md +112 -124
  30. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  31. package/src/claude/skills/specs/SKILL.md +170 -381
  32. package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
  33. package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
  34. package/src/claude/skills/specs/templates/spec-state.json +23 -2
  35. package/src/claude/skills/specs/templates/task.md +20 -0
  36. package/src/claude/skills/sync/SKILL.md +15 -8
  37. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  38. package/src/claude/skills/test/SKILL.md +141 -167
  39. package/src/codex/hooks/completion-authority-check.cjs +260 -0
  40. package/src/codex/hooks/completion-authority-state.cjs +525 -0
  41. package/src/codex/hooks/completion-authority.cjs +100 -0
  42. package/src/codex/hooks/lib/hook-context.cjs +42 -2
  43. package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
  44. package/src/codex/hooks/lib/spec-utils.cjs +53 -27
  45. package/src/codex/hooks/session.cjs +2 -0
  46. package/src/codex/hooks/spec-gate.cjs +132 -20
  47. package/src/codex/hooks/spec-state.cjs +56 -10
  48. package/src/codex/hooks.json +10 -0
  49. package/src/opencode/AGENTS.md +4 -2
  50. package/src/opencode/plugins/spec-gate.ts +296 -122
  51. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -8,14 +8,21 @@
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;
@@ -42,7 +49,27 @@ function runHook(payload, cwd) {
42
49
  }
43
50
 
44
51
  function tmpDir() {
45
- return fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-'));
52
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-'));
53
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
54
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
55
+ assert.strictEqual(result.status, 0, result.stderr);
56
+ }
57
+ return dir;
58
+ }
59
+
60
+ function runtimeContext(dir) {
61
+ return PROVENANCE.deriveRuntimeContext({
62
+ projectRoot: dir,
63
+ specsRoot: path.join(dir, 'specs'),
64
+ specFile: path.join(dir, 'specs', FEATURE, 'spec.json'),
65
+ featureName: FEATURE,
66
+ runtimeSession: 'test',
67
+ });
68
+ }
69
+
70
+ function bindFixtureReceipt(dir, value) {
71
+ const context = runtimeContext(dir);
72
+ return value.replaceAll(VALID_BASE, context.base).replaceAll(VALID_HEAD, context.head);
46
73
  }
47
74
 
48
75
  function clearCache() {
@@ -66,6 +93,7 @@ function readCache() {
66
93
  * @param {string} [opts.mdStatus='done']
67
94
  * @param {'valid'|'legacy-valid'|'failed'|'fence-only'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
68
95
  * @param {object|null} [opts.runtime] — if set, write .claude/runtime.json
96
+ * @param {object|null} [opts.workflowPolicy] — if set, persist completion obligations
69
97
  */
70
98
  function makeFixture(opts = {}) {
71
99
  const {
@@ -74,6 +102,7 @@ function makeFixture(opts = {}) {
74
102
  mdStatus = 'done',
75
103
  evidence = 'valid',
76
104
  runtime = null,
105
+ workflowPolicy = null,
77
106
  } = opts;
78
107
  const dir = tmpDir();
79
108
  const featureDir = path.join(dir, 'specs', FEATURE);
@@ -83,14 +112,14 @@ function makeFixture(opts = {}) {
83
112
  const entry = { status: taskStatus };
84
113
  if (completed_at !== null) entry.completed_at = completed_at;
85
114
 
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
- );
115
+ const spec = {
116
+ status: 'in_progress',
117
+ feature_name: FEATURE,
118
+ current_phase: 'implementation',
119
+ task_registry: { [TASK_REL]: entry },
120
+ };
121
+ if (workflowPolicy) spec.workflow_policy = workflowPolicy;
122
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify(spec));
94
123
 
95
124
  let evidenceBlock = '';
96
125
  if (evidence === 'valid') {
@@ -101,9 +130,9 @@ function makeFixture(opts = {}) {
101
130
  'Command: npm test',
102
131
  'Exit: 0',
103
132
  'Result: PASS',
104
- 'Base: abc123',
105
- 'Head: def456',
106
- 'Artifact: dist/bundle.js (sha256:deadbeef)',
133
+ `Base: ${VALID_BASE}`,
134
+ `Head: ${VALID_HEAD}`,
135
+ `Artifact: output/bundle.js (sha256:${FIXTURE_ARTIFACT_DIGEST})`,
107
136
  '```',
108
137
  'npm test',
109
138
  'PASS: 10 tests',
@@ -111,7 +140,7 @@ function makeFixture(opts = {}) {
111
140
  '',
112
141
  ].join('\n');
113
142
  } 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';
143
+ evidenceBlock = `## Evidence\n\nVerification: PASS\nCommand: npm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\nnpm test — passed, exit code 0\n`;
115
144
  } else if (evidence === 'failed') {
116
145
  evidenceBlock = '## Evidence\n\nVerification: PASS\n\nFAIL: tests failed, exit code 1\n';
117
146
  } else if (evidence === 'fence-only') {
@@ -149,6 +178,15 @@ function makeFixture(opts = {}) {
149
178
  );
150
179
  }
151
180
 
181
+ if (evidence === 'valid') {
182
+ fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
183
+ fs.writeFileSync(path.join(dir, 'output', 'bundle.js'), FIXTURE_ARTIFACT);
184
+ }
185
+ if (evidence === 'valid' || evidence === 'legacy-valid') {
186
+ const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
187
+ fs.writeFileSync(taskFile, bindFixtureReceipt(dir, fs.readFileSync(taskFile, 'utf8')));
188
+ }
189
+
152
190
  return dir;
153
191
  }
154
192
 
@@ -164,6 +202,52 @@ function parseBlock(stdout) {
164
202
  }
165
203
  }
166
204
 
205
+ function installClaudeGate(root, policyMode = 'valid') {
206
+ const hooks = path.join(root, '.claude', 'hooks');
207
+ const scripts = path.join(root, '.claude', 'scripts');
208
+ fs.mkdirSync(hooks, { recursive: true });
209
+ fs.mkdirSync(scripts, { recursive: true });
210
+ fs.copyFileSync(HOOK, path.join(hooks, 'spec-gate.cjs'));
211
+ fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', 'spec-resolver.cjs'), path.join(scripts, 'spec-resolver.cjs'));
212
+ if (policyMode === 'valid') {
213
+ fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', 'workflow-policy.cjs'), path.join(scripts, 'workflow-policy.cjs'));
214
+ fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', 'provenance.cjs'), path.join(scripts, 'provenance.cjs'));
215
+ } else if (policyMode === 'malformed') {
216
+ fs.writeFileSync(path.join(scripts, 'workflow-policy.cjs'), 'module.exports = {\n');
217
+ }
218
+ return path.join(hooks, 'spec-gate.cjs');
219
+ }
220
+
221
+ test('CLAUDE_PROJECT_DIR wins over malicious payload.cwd and stale process cwd', () => {
222
+ const target = makeFixture({ evidence: 'missing-evidence' });
223
+ const stale = tmpDir();
224
+ try {
225
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
226
+ const result = spawnSync(process.execPath, [HOOK], {
227
+ cwd: stale,
228
+ env: {
229
+ ...process.env,
230
+ CLAUDE_PROJECT_DIR: target,
231
+ PROJECT_ROOT: stale,
232
+ },
233
+ input: JSON.stringify({
234
+ hook_event_name: 'Stop',
235
+ session_id: 'test',
236
+ stop_hook_active: false,
237
+ cwd: stale,
238
+ }),
239
+ encoding: 'utf8',
240
+ });
241
+ assert.equal(result.status, 0, result.stderr);
242
+ const body = parseBlock(result.stdout.trim());
243
+ assert.equal(body?.decision, 'block');
244
+ assert.match(body.reason, new RegExp(TASK_REL.replace('/', '\\/')));
245
+ } finally {
246
+ fs.rmSync(target, { recursive: true, force: true });
247
+ fs.rmSync(stale, { recursive: true, force: true });
248
+ }
249
+ });
250
+
167
251
  beforeEach(() => {
168
252
  clearCache();
169
253
  });
@@ -193,6 +277,131 @@ test('1. newly-done task WITHOUT receipt → block, reason names task path', ()
193
277
  }
194
278
  });
195
279
 
280
+ test('installed Claude gate fails closed with a controlled decision when policy is missing or malformed', () => {
281
+ for (const policyMode of ['missing', 'malformed']) {
282
+ const dir = tmpDir();
283
+ const gate = installClaudeGate(dir, policyMode);
284
+ try {
285
+ const result = spawnSync(process.execPath, [gate], {
286
+ cwd: dir,
287
+ env: { ...process.env, PROJECT_ROOT: dir },
288
+ input: JSON.stringify({ cwd: dir, session_id: 'test', stop_hook_active: false }),
289
+ encoding: 'utf8',
290
+ });
291
+ assert.equal(result.status, 0, `${policyMode}: ${result.stderr}`);
292
+ assert.equal(result.stderr, '', `${policyMode} must not leak a stack trace`);
293
+ const block = parseBlock(result.stdout);
294
+ assert.equal(block?.decision, 'block', `${policyMode} must emit a controlled block`);
295
+ assert.match(block.reason, /workflow policy could not be loaded/i);
296
+ assert.ok(fs.existsSync(path.join(dir, '.claude', 'hooks', '.logs', 'hook-log.jsonl')));
297
+ } finally {
298
+ fs.rmSync(dir, { recursive: true, force: true });
299
+ }
300
+ }
301
+ });
302
+
303
+ test('Claude adapter requires canonical SHA-256 only for a declared task artifact', () => {
304
+ const digest = FIXTURE_ARTIFACT_DIGEST;
305
+ const bodies = [
306
+ { suffix: '', expected: true, label: 'missing hash' },
307
+ { suffix: 'sha256: abc123\n', expected: true, label: 'invalid hash' },
308
+ { suffix: `sha256: ${'0'.repeat(64)}\n`, expected: true, label: 'mismatched hash' },
309
+ { suffix: `sha256: ${digest}\n`, expected: false, label: 'valid hash' },
310
+ ];
311
+ for (const { suffix, expected, label } of bodies) {
312
+ const dir = tmpDir();
313
+ const gate = installClaudeGate(dir);
314
+ const featureDir = path.join(dir, 'specs', FEATURE);
315
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
316
+ fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
317
+ fs.writeFileSync(path.join(dir, 'output', 'bundle.js'), FIXTURE_ARTIFACT);
318
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
319
+ status: 'in_progress',
320
+ feature_name: FEATURE,
321
+ task_registry: {
322
+ [TASK_REL]: {
323
+ status: 'done',
324
+ completed_at: '2026-08-11T00:00:00.000Z',
325
+ artifacts: ['output/bundle.js'],
326
+ },
327
+ },
328
+ }));
329
+ fs.writeFileSync(path.join(featureDir, TASK_REL), [
330
+ '# Task', '', '**Status:** done', '', '## Evidence', '',
331
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
332
+ 'Artifact: output/bundle.js', suffix,
333
+ ].join('\n'));
334
+ fs.writeFileSync(
335
+ path.join(featureDir, TASK_REL),
336
+ bindFixtureReceipt(dir, fs.readFileSync(path.join(featureDir, TASK_REL), 'utf8')),
337
+ );
338
+ try {
339
+ const result = spawnSync(process.execPath, [gate], {
340
+ cwd: dir,
341
+ env: { ...process.env, PROJECT_ROOT: dir },
342
+ input: JSON.stringify({ cwd: dir, session_id: 'test', stop_hook_active: false }),
343
+ encoding: 'utf8',
344
+ });
345
+ assert.equal(result.status, 0, `${label}: ${result.stderr}`);
346
+ if (expected) {
347
+ const block = parseBlock(result.stdout);
348
+ assert.equal(block?.decision, 'block', `${label} must block`);
349
+ assert.match(block.reason, /\bh\b/);
350
+ } else {
351
+ assert.equal(result.stdout, '', `${label} must pass`);
352
+ }
353
+ } finally {
354
+ fs.rmSync(dir, { recursive: true, force: true });
355
+ }
356
+ }
357
+ });
358
+
359
+ test('Claude artifact verification rejects traversal and symlink paths', () => {
360
+ const cases = [
361
+ { artifactPath: '../outside.js', symlink: false },
362
+ { artifactPath: 'output/link.js', symlink: true },
363
+ ];
364
+ for (const { artifactPath, symlink } of cases) {
365
+ const dir = tmpDir();
366
+ const featureDir = path.join(dir, 'specs', FEATURE);
367
+ const artifactBytes = Buffer.from(`artifact-${artifactPath}\n`);
368
+ const digest = crypto.createHash('sha256').update(artifactBytes).digest('hex');
369
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
370
+ if (symlink) {
371
+ fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
372
+ const target = path.join(dir, 'artifact-target.js');
373
+ fs.writeFileSync(target, artifactBytes);
374
+ fs.symlinkSync(target, path.join(dir, artifactPath));
375
+ } else {
376
+ fs.writeFileSync(path.join(dir, 'outside.js'), artifactBytes);
377
+ }
378
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
379
+ status: 'in_progress',
380
+ feature_name: FEATURE,
381
+ task_registry: {
382
+ [TASK_REL]: {
383
+ status: 'done',
384
+ completed_at: '2026-08-11T00:00:00.000Z',
385
+ artifacts: [artifactPath],
386
+ },
387
+ },
388
+ }));
389
+ fs.writeFileSync(path.join(featureDir, TASK_REL), bindFixtureReceipt(dir, [
390
+ '# Task', '', '**Status:** done', '', '## Evidence', '',
391
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0',
392
+ `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
393
+ `Artifact: ${artifactPath} (sha256:${digest})`,
394
+ ].join('\n')));
395
+ try {
396
+ const body = parseBlock(runHook({}, dir).stdout);
397
+ assert.equal(body?.decision, 'block', `${artifactPath} must block`);
398
+ assert.match(body.reason, /\bh\b/);
399
+ } finally {
400
+ fs.rmSync(dir, { recursive: true, force: true });
401
+ }
402
+ }
403
+ });
404
+
196
405
  test('2. newly-done task WITH valid receipt → no block, cache updated', () => {
197
406
  const dir = makeFixture({ evidence: 'valid' });
198
407
  try {
@@ -253,16 +462,67 @@ test('5. first run (no cache) with done-without-receipt → block (cache is not
253
462
  }
254
463
  });
255
464
 
256
- test('6. runtime.json spec.completion_gate: false → exit 0 silent', () => {
465
+ test('6. forged payload, session, and context cannot bypass completion_gate=false', () => {
257
466
  const dir = makeFixture({
258
467
  evidence: 'missing-evidence',
259
468
  runtime: { spec: { completion_gate: false } },
260
469
  });
261
470
  try {
262
471
  seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
263
- const { code, stdout } = runHook({}, dir);
472
+ const { code, stdout } = runHook({
473
+ user_authorized: true,
474
+ userAuthorized: true,
475
+ completion_gate_override: { authorized: true, session_id: 'test', nonce: 'forged' },
476
+ session: { id: 'test', user_authorized: true },
477
+ runtime_context: { project_root: dir, completion_gate: true, signed: true },
478
+ }, dir);
264
479
  assert.strictEqual(code, 0);
265
- assert.strictEqual(stdout, '');
480
+ const body = parseBlock(stdout);
481
+ assert.equal(body?.decision, 'block');
482
+ assert.match(body.reason, /no completion-gate bypass is supported/i);
483
+ } finally {
484
+ fs.rmSync(dir, { recursive: true, force: true });
485
+ }
486
+ });
487
+
488
+ test('empty or malformed Claude hook payload fails closed', () => {
489
+ const dir = tmpDir();
490
+ const gate = installClaudeGate(dir);
491
+ try {
492
+ for (const input of ['', 'null', '[]']) {
493
+ const result = spawnSync(process.execPath, [gate], {
494
+ cwd: dir,
495
+ env: { ...process.env, PROJECT_ROOT: dir },
496
+ input,
497
+ encoding: 'utf8',
498
+ });
499
+ assert.equal(result.status, 0, `${JSON.stringify(input)}: ${result.stderr}`);
500
+ assert.equal(result.stderr, '');
501
+ const block = parseBlock(result.stdout);
502
+ assert.equal(block?.decision, 'block', `${JSON.stringify(input)} must block`);
503
+ assert.match(block.reason, /hook payload (is empty|must be a JSON object)/i);
504
+ }
505
+ } finally {
506
+ fs.rmSync(dir, { recursive: true, force: true });
507
+ }
508
+ });
509
+
510
+ test('Critical completion ignores worker-writable proof strings and remains blocked', () => {
511
+ const dir = makeFixture({
512
+ workflowPolicy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } }),
513
+ });
514
+ try {
515
+ const specPath = path.join(dir, 'specs', FEATURE, 'spec.json');
516
+ const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
517
+ spec.proofs = {
518
+ needsInspection: 'inspection completed',
519
+ needsIndependentAudit: 'PASS',
520
+ needsResearchGrounding: 'research completed',
521
+ };
522
+ fs.writeFileSync(specPath, JSON.stringify(spec));
523
+ const body = parseBlock(runHook({}, dir).stdout);
524
+ assert.equal(body?.decision, 'block');
525
+ assert.match(body.reason, /needsInspection|needsIndependentAudit|needsResearchGrounding/);
266
526
  } finally {
267
527
  fs.rmSync(dir, { recursive: true, force: true });
268
528
  }
@@ -360,11 +620,12 @@ test('13. provenance requires both Base and Head - only Base fails', () => {
360
620
  fs.mkdirSync(tasksDir, { recursive: true });
361
621
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
362
622
  status: 'in_progress',
623
+ feature_name: FEATURE,
363
624
  current_phase: 'implementation',
364
625
  task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
365
626
  }));
366
627
  // Only Base, missing Head
367
- fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
628
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir, [
368
629
  '# Task',
369
630
  '',
370
631
  '**Status:** done',
@@ -374,11 +635,11 @@ test('13. provenance requires both Base and Head - only Base fails', () => {
374
635
  'Verification: PASS',
375
636
  'Command: npm test',
376
637
  'Exit: 0',
377
- 'Base: abc123',
638
+ `Base: ${VALID_BASE}`,
378
639
  '```',
379
640
  'npm test PASS',
380
641
  '```',
381
- ].join('\n'));
642
+ ].join('\n')));
382
643
  try {
383
644
  seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
384
645
  const body = parseBlock(runHook({}, dir).stdout);
@@ -397,6 +658,7 @@ test('14. provenance requires both Base and Head - only Head fails', () => {
397
658
  fs.mkdirSync(tasksDir, { recursive: true });
398
659
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
399
660
  status: 'in_progress',
661
+ feature_name: FEATURE,
400
662
  current_phase: 'implementation',
401
663
  task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
402
664
  }));
@@ -411,7 +673,7 @@ test('14. provenance requires both Base and Head - only Head fails', () => {
411
673
  'Verification: PASS',
412
674
  'Command: npm test',
413
675
  'Exit: 0',
414
- 'Head: def456',
676
+ `Head: ${VALID_HEAD}`,
415
677
  '```',
416
678
  'npm test PASS',
417
679
  '```',
@@ -434,10 +696,11 @@ test('15. provenance with both Base and Head passes', () => {
434
696
  fs.mkdirSync(tasksDir, { recursive: true });
435
697
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
436
698
  status: 'in_progress',
699
+ feature_name: FEATURE,
437
700
  current_phase: 'implementation',
438
701
  task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
439
702
  }));
440
- fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
703
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir, [
441
704
  '# Task',
442
705
  '',
443
706
  '**Status:** done',
@@ -447,12 +710,12 @@ test('15. provenance with both Base and Head passes', () => {
447
710
  'Verification: PASS',
448
711
  'Command: npm test',
449
712
  'Exit: 0',
450
- 'Base: abc123',
451
- 'Head: def456',
713
+ `Base: ${VALID_BASE}`,
714
+ `Head: ${VALID_HEAD}`,
452
715
  '```',
453
716
  'npm test PASS',
454
717
  '```',
455
- ].join('\n'));
718
+ ].join('\n')));
456
719
  try {
457
720
  seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
458
721
  const { stdout } = runHook({}, dir);
@@ -481,8 +744,8 @@ test('16. registry task path with ../ escape or sibling-prefix is rejected (chec
481
744
  'Verification: PASS',
482
745
  'Command: npm test',
483
746
  'Exit: 0',
484
- 'Base: abc123',
485
- 'Head: def456',
747
+ `Base: ${VALID_BASE}`,
748
+ `Head: ${VALID_HEAD}`,
486
749
  '```',
487
750
  'pass',
488
751
  '```',
@@ -491,6 +754,7 @@ test('16. registry task path with ../ escape or sibling-prefix is rejected (chec
491
754
  const siblingPrefixRel = `tasks/../../${FEATURE}-evil/tasks/task.md`;
492
755
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
493
756
  status: 'in_progress',
757
+ feature_name: FEATURE,
494
758
  current_phase: 'implementation',
495
759
  task_registry: {
496
760
  [maliciousRel]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
@@ -527,8 +791,9 @@ test('17. empty or bare provenance fails — Base:/Head: require non-empty same-
527
791
  const featureDir = path.join(dir, 'specs', FEATURE);
528
792
  fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
529
793
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
530
- status: 'in_progress',
531
- current_phase: 'implementation',
794
+ status: 'in_progress',
795
+ feature_name: FEATURE,
796
+ current_phase: 'implementation',
532
797
  task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
533
798
  }));
534
799
  fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
@@ -543,23 +808,26 @@ test('17. empty or bare provenance fails — Base:/Head: require non-empty same-
543
808
 
544
809
  test('18. valid non-empty Base: value and base_sha: value pass', () => {
545
810
  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'),
811
+ { evidence: ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`].join('\n'), shouldPass: true },
812
+ { evidence: ['Verification: PASS','Command: npm test','Exit: 0',`base_sha: ${VALID_BASE}`,`head_sha: ${VALID_HEAD}`].join('\n'), shouldPass: true },
813
+ { evidence: ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${'a'.repeat(40)}`,`Head: ${'b'.repeat(40)}`].join('\n'), shouldPass: false },
549
814
  ];
550
- for (const evidence of validCases) {
815
+ for (const { evidence, shouldPass } of validCases) {
551
816
  const dir = tmpDir();
552
817
  const featureDir = path.join(dir, 'specs', FEATURE);
553
818
  fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
554
819
  fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
555
820
  status: 'in_progress',
821
+ feature_name: FEATURE,
556
822
  current_phase: 'implementation',
557
823
  task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
558
824
  }));
559
- fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
825
+ const boundEvidence = shouldPass ? bindFixtureReceipt(dir, evidence) : evidence;
826
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + boundEvidence,'```','pass','```'].join('\n'));
560
827
  seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
561
- const { stdout } = runHook({}, dir);
562
- assert.strictEqual(stdout, '', `valid provenance should not block: ${evidence.slice(0,40)}`);
828
+ const result = runHook({}, dir);
829
+ if (shouldPass) assert.strictEqual(result.stdout, '', `valid provenance should not block: ${evidence.slice(0,40)}`);
830
+ else assert.match(parseBlock(result.stdout).reason, /\bg\b/, 'arbitrary valid-length SHA values must block');
563
831
  fs.rmSync(dir, { recursive: true, force: true });
564
832
  }
565
833
  });
@@ -594,7 +862,7 @@ test('20. cache-hit: receipt mutation (removed Verification/Command) blocks even
594
862
  fs.writeFileSync(taskFile, [
595
863
  '# Task', '', '**Status:** done', '',
596
864
  '## Evidence', '',
597
- 'Exit: 0', 'Base: abc123', 'Head: def456',
865
+ 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
598
866
  '```', 'pass', '```',
599
867
  ].join('\n'));
600
868
  const blocked = parseBlock(runHook({}, dir).stdout);
@@ -618,7 +886,7 @@ test('21. cache-hit: provenance mutation (removed Head / changed to stale) block
618
886
  const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
619
887
  fs.writeFileSync(taskFile, [
620
888
  '# Task', '', '**Status:** done', '',
621
- '## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', 'Base: abc123',
889
+ '## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', `Base: ${VALID_BASE}`,
622
890
  '```', 'pass', '```',
623
891
  ].join('\n'));
624
892
  let body = parseBlock(runHook({}, dir).stdout);
@@ -627,7 +895,7 @@ test('21. cache-hit: provenance mutation (removed Head / changed to stale) block
627
895
  // mutate back to include both but with empty Head value (stale)
628
896
  fs.writeFileSync(taskFile, [
629
897
  '# Task', '', '**Status:** done', '',
630
- '## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', 'Base: abc123', 'Head:',
898
+ '## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', `Base: ${VALID_BASE}`, 'Head:',
631
899
  '```', 'pass', '```',
632
900
  ].join('\n'));
633
901
  body = parseBlock(runHook({}, dir).stdout);
@@ -693,3 +961,208 @@ test('24. first-run (no cache file) with valid receipt passes and seeds cache',
693
961
  fs.rmSync(dir, { recursive: true, force: true });
694
962
  }
695
963
  });
964
+
965
+ test('25. placeholder tokens Command: TODO / Base: TBD / artifact sha256 empty must block', () => {
966
+ const cases = [
967
+ { evidence: ['Verification: PASS','Command: TODO','Exit: 0','Base: a','Head: b'].join('\n'), check: /e\b/, desc: 'Command TODO' },
968
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: TBD','Head: b'].join('\n'), check: /g\b/, desc: 'Base TBD' },
969
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: N/A'].join('\n'), check: /g\b/, desc: 'Head N/A' },
970
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','base_sha: pending','head_sha: unknown'].join('\n'), check: /g\b/, desc: 'base_sha pending' },
971
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Artifact: bundle','sha256: '].join('\n'), check: /h\b/, desc: 'artifact empty sha' },
972
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Artifact: bundle','sha256: TBD'].join('\n'), check: /h\b/, desc: 'artifact TBD sha' },
973
+ ];
974
+ for (const { evidence, check, desc } of cases) {
975
+ const dir = tmpDir();
976
+ const featureDir = path.join(dir, 'specs', FEATURE);
977
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
978
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'implementation', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
979
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
980
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
981
+ const body = parseBlock(runHook({}, dir).stdout);
982
+ assert.ok(body, `placeholder case ${desc} should block`);
983
+ assert.strictEqual(body.decision, 'block', desc);
984
+ assert.match(body.reason, check, `check for ${desc}`);
985
+ fs.rmSync(dir, { recursive: true, force: true });
986
+ }
987
+ // Ensure normal todo substring does NOT block
988
+ const dir2 = tmpDir();
989
+ const featureDir2 = path.join(dir2, 'specs', FEATURE);
990
+ fs.mkdirSync(path.join(featureDir2, 'tasks'), { recursive: true });
991
+ fs.writeFileSync(path.join(featureDir2, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'implementation', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
992
+ 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')));
993
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
994
+ assert.strictEqual(runHook({}, dir2).stdout, '', 'command containing todo substring should not block');
995
+ fs.rmSync(dir2, { recursive: true, force: true });
996
+ });
997
+
998
+ test('26. explicit failure outcomes Tests failed / Result FAIL and multiple Results must block', () => {
999
+ const cases = [
1000
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Tests failed: 1'].join('\n'), desc: 'Tests failed: 1' },
1001
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Result: FAIL','Result: PASS'].join('\n'), desc: 'Result FAIL then PASS' },
1002
+ { evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','0 tests passed'].join('\n'), desc: '0 tests' },
1003
+ ];
1004
+ for (const { evidence, desc } of cases) {
1005
+ const dir = tmpDir();
1006
+ const featureDir = path.join(dir, 'specs', FEATURE);
1007
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
1008
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'implementation', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
1009
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
1010
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
1011
+ const body = parseBlock(runHook({}, dir).stdout);
1012
+ assert.ok(body, `explicit failure ${desc} should block`);
1013
+ assert.strictEqual(body.decision, 'block');
1014
+ fs.rmSync(dir, { recursive: true, force: true });
1015
+ }
1016
+ });
1017
+
1018
+ test('27. task markdown symlink outside feature must be rejected (check a)', () => {
1019
+ const dir = tmpDir();
1020
+ const featureDir = path.join(dir, 'specs', FEATURE);
1021
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
1022
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'implementation', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
1023
+ const outside = path.join(dir, 'outside.md');
1024
+ fs.writeFileSync(outside, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
1025
+ const taskPath = path.join(dir, 'specs', FEATURE, TASK_REL);
1026
+ try { fs.unlinkSync(taskPath); } catch {}
1027
+ fs.symlinkSync(outside, taskPath);
1028
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
1029
+ const body = parseBlock(runHook({}, dir).stdout);
1030
+ assert.ok(body, 'symlink task outside should block');
1031
+ assert.strictEqual(body.decision, 'block');
1032
+ assert.match(body.reason, /\ba\b/);
1033
+ fs.rmSync(dir, { recursive: true, force: true });
1034
+ });
1035
+
1036
+ test('28. malformed spec.json must block with invalid_specs', () => {
1037
+ const dir = tmpDir();
1038
+ fs.mkdirSync(path.join(dir, 'specs', 'good'), { recursive: true });
1039
+ fs.mkdirSync(path.join(dir, 'specs', 'bad'), { recursive: true });
1040
+ fs.writeFileSync(path.join(dir, 'specs', 'good', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'good', task_registry: {} }));
1041
+ fs.writeFileSync(path.join(dir, 'specs', 'bad', 'spec.json'), '{ malformed');
1042
+ seedCache({});
1043
+ const { stdout } = runHook({}, dir);
1044
+ const body = parseBlock(stdout);
1045
+ assert.ok(body, 'malformed spec should block');
1046
+ assert.strictEqual(body.decision, 'block');
1047
+ assert.match(body.reason, /invalid spec/i);
1048
+ assert.match(body.reason, /bad/);
1049
+ fs.rmSync(dir, { recursive: true, force: true });
1050
+ });
1051
+
1052
+ test('29. specs/linked symlink outside must be explicit_malformed and non-explicit scan must not count it', () => {
1053
+ const dir = tmpDir();
1054
+ const specsDir = path.join(dir, 'specs');
1055
+ fs.mkdirSync(specsDir, { recursive: true });
1056
+ const outside = path.join(dir, 'outside-feat');
1057
+ fs.mkdirSync(outside, { recursive: true });
1058
+ fs.writeFileSync(path.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
1059
+ const link = path.join(specsDir, 'linked');
1060
+ fs.symlinkSync(outside, link);
1061
+ // non-explicit should block with invalid_specs (fail-closed, not phantom no-active)
1062
+ seedCache({});
1063
+ const nonExplicit = parseBlock(runHook({}, dir).stdout);
1064
+ assert.ok(nonExplicit, 'non-explicit symlink outside should block with invalid_specs');
1065
+ assert.strictEqual(nonExplicit.decision, 'block');
1066
+ assert.match(nonExplicit.reason, /invalid spec/i);
1067
+ assert.match(nonExplicit.reason, /linked/);
1068
+ // explicit should block explicit_malformed
1069
+ const body = parseBlock(runHook({ featureName: 'linked' }, dir).stdout);
1070
+ assert.ok(body);
1071
+ assert.strictEqual(body.decision, 'block');
1072
+ assert.match(body.reason, /explicit_malformed/);
1073
+ fs.rmSync(dir, { recursive: true, force: true });
1074
+ });
1075
+
1076
+ test('30. phantom vectors via gate evidence must block with check c (r3)', () => {
1077
+ const vectors = [
1078
+ 'ℹ tests 0',
1079
+ 'Tests: 0 total',
1080
+ '0 tests',
1081
+ 'collected 0 items',
1082
+ 'No tests found.',
1083
+ 'ℹ cancelled 1',
1084
+ 'cancelled 1',
1085
+ '1 cancelled',
1086
+ 'Test Suites: 1 failed, 1 total',
1087
+ 'FAIL ./foo.test.js',
1088
+ 'FAIL\tpackage',
1089
+ 'FAIL\texample.test/probe\t0.431s',
1090
+ '--- FAIL: TestName (0.00s)',
1091
+ 'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
1092
+ 'FAILED tests/test_demo.py',
1093
+ '# fail 1',
1094
+ 'ℹ fail 1',
1095
+ 'not ok 1 - test',
1096
+ '1 error in 0.12s',
1097
+ 'ERROR collecting tests/test_demo.py',
1098
+ 'Found 2 errors',
1099
+ 'Tests run: 5, Failures: 1, Errors: 0',
1100
+ '[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
1101
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
1102
+ '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
1103
+ '5 tests completed, 1 failed',
1104
+ ];
1105
+ for (const v of vectors) {
1106
+ const dir = tmpDir();
1107
+ const featureDir = path.join(dir, 'specs', FEATURE);
1108
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
1109
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
1110
+ status: 'in_progress',
1111
+ feature_name: FEATURE,
1112
+ current_phase: 'implementation',
1113
+ task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
1114
+ }));
1115
+ const evidence = bindFixtureReceipt(dir, ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`, v].join('\n'));
1116
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
1117
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
1118
+ const body = parseBlock(runHook({}, dir).stdout);
1119
+ assert.ok(body, `vector should block: ${v}`);
1120
+ assert.strictEqual(body.decision, 'block', `vector ${v} should block`);
1121
+ assert.match(body.reason, /\bc\b/, `vector ${v} should fail check c`);
1122
+ fs.rmSync(dir, { recursive: true, force: true });
1123
+ }
1124
+ // positive controls must not block
1125
+ const passes = [
1126
+ 'Tests failed: 0',
1127
+ 'fail 0',
1128
+ 'Tests: 0 failed',
1129
+ 'cancelled 0',
1130
+ 'Test Suites: 0 failed',
1131
+ '5 tests completed, 0 failed',
1132
+ 'errors 0',
1133
+ 'Found 0 errors',
1134
+ 'collected 1 item',
1135
+ '[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
1136
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
1137
+ '[ERROR] Error handling is documented',
1138
+ 'Error handling is documented',
1139
+ 'FAILURE mode analysis',
1140
+ 'Notes: tests failed previously but now fixed',
1141
+ 'Notes: supports zero-test parsing',
1142
+ '# tests 1',
1143
+ '# suites 0',
1144
+ '# pass 1',
1145
+ '# fail 0',
1146
+ '# cancelled 0',
1147
+ '# skipped 0',
1148
+ '# todo 0',
1149
+ '# duration_ms 114.203625',
1150
+ ];
1151
+ for (const v of passes) {
1152
+ const dir = tmpDir();
1153
+ const featureDir = path.join(dir, 'specs', FEATURE);
1154
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
1155
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
1156
+ status: 'in_progress',
1157
+ feature_name: FEATURE,
1158
+ current_phase: 'implementation',
1159
+ task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
1160
+ }));
1161
+ const evidence = bindFixtureReceipt(dir, ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`, v].join('\n'));
1162
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
1163
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
1164
+ const { stdout } = runHook({}, dir);
1165
+ assert.strictEqual(stdout, '', `positive control should not block: ${v}`);
1166
+ fs.rmSync(dir, { recursive: true, force: true });
1167
+ }
1168
+ });