@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
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const assert = require('node:assert/strict');
4
+ const crypto = require('node:crypto');
4
5
  const fs = require('node:fs');
5
6
  const os = require('node:os');
6
7
  const path = require('node:path');
@@ -9,6 +10,10 @@ const { test } = require('node:test');
9
10
 
10
11
  const PACKAGE_ROOT = path.join(__dirname, '../..');
11
12
  const CODEX_HOOKS = path.join(PACKAGE_ROOT, 'src/codex/hooks');
13
+ const PROVENANCE = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'));
14
+ const POLICY = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs'));
15
+ const VALID_BASE = '0123456789abcdef0123456789abcdef01234567';
16
+ const VALID_HEAD = '89abcdef0123456789abcdef0123456789abcdef';
12
17
  const { stateDir: codexStateDir } = require(
13
18
  path.join(CODEX_HOOKS, 'lib', 'state-store.cjs')
14
19
  );
@@ -21,17 +26,50 @@ function runHook(file, cwd, payload) {
21
26
  });
22
27
  }
23
28
 
24
- function inHookFixture(run) {
29
+ function runtimeContext(root, feature = 'auth', session = 'session-a') {
30
+ return PROVENANCE.deriveRuntimeContext({
31
+ projectRoot: root,
32
+ specsRoot: path.join(root, 'specs'),
33
+ specFile: path.join(root, 'specs', feature, 'spec.json'),
34
+ featureName: feature,
35
+ runtimeSession: session,
36
+ });
37
+ }
38
+
39
+ function bindReceipt(root, value, feature = 'auth', session = 'session-a') {
40
+ const context = runtimeContext(root, feature, session);
41
+ return value.replaceAll(VALID_BASE, context.base).replaceAll(VALID_HEAD, context.head);
42
+ }
43
+
44
+ function inHookFixture(run, options = {}) {
25
45
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-hooks-'));
26
46
  const hooks = path.join(root, '.codex', 'hooks');
27
47
  try {
28
48
  fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
29
49
  fs.mkdirSync(path.join(root, '.codex', 'scripts'), { recursive: true });
50
+ if (options.policy !== 'missing') {
51
+ if (options.policy === 'malformed') {
52
+ fs.writeFileSync(path.join(root, '.codex', 'scripts', 'workflow-policy.cjs'), 'module.exports = {\n');
53
+ } else {
54
+ fs.copyFileSync(
55
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs'),
56
+ path.join(root, '.codex', 'scripts', 'workflow-policy.cjs'),
57
+ );
58
+ fs.copyFileSync(
59
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'),
60
+ path.join(root, '.codex', 'scripts', 'provenance.cjs'),
61
+ );
62
+ }
63
+ }
30
64
  fs.copyFileSync(
31
- path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs'),
32
- path.join(root, '.codex', 'scripts', 'workflow-policy.cjs'),
65
+ path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
66
+ path.join(root, '.codex', 'scripts', 'spec-resolver.cjs'),
33
67
  );
34
68
  fs.writeFileSync(path.join(root, '.codex', 'scripts', 'spec-scaffold.cjs'), '');
69
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
70
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
71
+ assert.equal(result.status, 0, result.stderr);
72
+ }
35
73
  return run(root, hooks);
36
74
  } finally {
37
75
  fs.rmSync(root, { recursive: true, force: true });
@@ -228,6 +266,7 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
228
266
  fs.mkdirSync(nested, { recursive: true });
229
267
  const spec = {
230
268
  status: 'in_progress',
269
+ feature_name: 'auth',
231
270
  task_registry: {
232
271
  [taskPath]: { status: 'pending', completed_at: null }
233
272
  }
@@ -261,6 +300,86 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
261
300
  });
262
301
  });
263
302
 
303
+ test('empty or forged Codex hook authority fails closed', () => {
304
+ inHookFixture((root, hooks) => {
305
+ const gate = path.join(hooks, 'spec-gate.cjs');
306
+ for (const input of ['', 'null', '[]']) {
307
+ const result = spawnSync(process.execPath, [gate], {
308
+ cwd: root,
309
+ env: { ...process.env },
310
+ input,
311
+ encoding: 'utf8',
312
+ });
313
+ assert.equal(result.status, 0, `${JSON.stringify(input)}: ${result.stderr}`);
314
+ assert.equal(result.stderr, '');
315
+ const block = JSON.parse(result.stdout);
316
+ assert.equal(block.decision, 'block');
317
+ assert.match(block.reason, /hook payload (is empty|must be a JSON object)/i);
318
+ }
319
+
320
+ const feature = path.join(root, 'specs', 'auth');
321
+ const taskPath = 'tasks/task.md';
322
+ fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
323
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
324
+ status: 'in_progress',
325
+ feature_name: 'auth',
326
+ task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
327
+ }));
328
+ fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
329
+ fs.writeFileSync(path.join(root, '.codex', 'runtime.json'), JSON.stringify({
330
+ spec: { completion_gate: false },
331
+ }));
332
+ const forged = runHook(gate, root, {
333
+ cwd: root,
334
+ session_id: 'session-a',
335
+ hook_event_name: 'Stop',
336
+ stop_hook_active: false,
337
+ user_authorized: true,
338
+ userAuthorized: true,
339
+ completion_gate_override: { authorized: true, session_id: 'session-a', nonce: 'forged' },
340
+ session: { id: 'session-a', user_authorized: true },
341
+ runtime_context: { project_root: root, completion_gate: true, signed: true },
342
+ });
343
+ assert.equal(forged.status, 0);
344
+ const block = JSON.parse(forged.stdout);
345
+ assert.equal(block.decision, 'block');
346
+ assert.match(block.reason, /no completion-gate bypass is supported/i);
347
+ });
348
+ });
349
+
350
+ test('Critical Codex completion ignores worker-writable proof strings and remains blocked', () => {
351
+ inHookFixture((root, hooks) => {
352
+ const feature = path.join(root, 'specs', 'auth');
353
+ const taskPath = 'tasks/task.md';
354
+ fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
355
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
356
+ status: 'in_progress',
357
+ feature_name: 'auth',
358
+ workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } }),
359
+ proofs: {
360
+ needsInspection: 'inspection completed',
361
+ needsIndependentAudit: 'PASS',
362
+ needsResearchGrounding: 'research completed',
363
+ },
364
+ task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
365
+ }));
366
+ fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
367
+ 'Status: done', '', '## Evidence', '', 'Verification: PASS',
368
+ 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
369
+ ].join('\n'), 'auth'));
370
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
371
+ cwd: root,
372
+ session_id: 'session-a',
373
+ hook_event_name: 'Stop',
374
+ stop_hook_active: false,
375
+ });
376
+ assert.equal(result.status, 0, result.stderr);
377
+ const block = JSON.parse(result.stdout);
378
+ assert.equal(block.decision, 'block');
379
+ assert.match(block.reason, /needsInspection|needsIndependentAudit|needsResearchGrounding/);
380
+ });
381
+ });
382
+
264
383
  test('Codex state hook persists direct native event fields at project root', () => {
265
384
  inHookFixture((root, hooks) => {
266
385
  const nested = path.join(root, 'packages', 'app');
@@ -340,11 +459,11 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
340
459
  assert.ok(onlyBase.includes('provenance'), 'only Base should fail');
341
460
  const onlyHead = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: def\n');
342
461
  assert.ok(onlyHead.includes('provenance'), 'only Head should fail');
343
- const both = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\nHead: def\n');
462
+ const both = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`);
344
463
  assert.deepEqual(both, [], 'both Base and Head should pass');
345
464
  const onlyBaseSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc\n');
346
465
  assert.ok(onlyBaseSha.includes('provenance'));
347
- const bothSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc\nhead_sha: def\n');
466
+ const bothSha = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: ${VALID_BASE}\nhead_sha: ${VALID_HEAD}\n`);
348
467
  assert.deepEqual(bothSha, [], 'both base_sha and head_sha should pass');
349
468
  // Empty / bare provenance must fail — non-empty same-line value required
350
469
  const emptyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
@@ -361,9 +480,9 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
361
480
  assert.ok(spacesBaseSha.includes('provenance'), 'base_sha spaces only should fail');
362
481
  const bareBoth = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha head_sha\n');
363
482
  assert.ok(bareBoth.includes('provenance'), 'bare base_sha head_sha without colon should fail');
364
- const validBaseHead = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n');
483
+ const validBaseHead = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`);
365
484
  assert.deepEqual(validBaseHead, [], 'valid Base+Head with values should pass');
366
- const validSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc123\nhead_sha: def456\n');
485
+ const validSha = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: ${VALID_BASE}\nhead_sha: ${VALID_HEAD}\n`);
367
486
  assert.deepEqual(validSha, [], 'valid base_sha+head_sha with values should pass');
368
487
 
369
488
  // Integration via spec-gate hook: only Base blocks, both passes
@@ -375,7 +494,7 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
375
494
  const payload = { cwd: path.join(root, 'packages', 'app'), session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
376
495
 
377
496
  // only Base -> block
378
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
497
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
379
498
  fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
380
499
  fs.mkdirSync(payload.cwd, { recursive: true });
381
500
  const blocked = runHook(gate, payload.cwd, payload);
@@ -383,7 +502,7 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
383
502
  assert.match(JSON.parse(blocked.stdout).reason, /\bg\b|\bprovenance\b/);
384
503
 
385
504
  // both -> no block
386
- fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\nHead: def\n');
505
+ fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, `Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`));
387
506
  const passed = runHook(gate, payload.cwd, payload);
388
507
  assert.equal(passed.stdout, '');
389
508
 
@@ -413,16 +532,22 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
413
532
  fs.mkdirSync(appCwd, { recursive: true });
414
533
  const payload = { cwd: appCwd, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
415
534
  const cacheFile = path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json');
416
- const validBody = 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n';
535
+ const validBodyTemplate = [
536
+ 'Status: done', '', '## Evidence', '', 'Verification: PASS',
537
+ 'Command: pnpm test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
538
+ '```', 'pass', '```', '',
539
+ ].join('\n');
417
540
 
418
541
  // first-run (no cache) with valid receipt → no block, cache seeded
419
542
  try { fs.rmSync(cacheFile, { force: true }); } catch {}
420
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
543
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
544
+ const validBody = bindReceipt(root, validBodyTemplate);
421
545
  fs.writeFileSync(path.join(feature, taskPath), validBody);
422
546
  let res = runHook(gate, appCwd, payload);
423
547
  assert.equal(res.stdout, '', 'first-run valid should not block');
424
548
  assert.ok(fs.existsSync(cacheFile), 'cache file must be created on first valid run');
425
- assert.equal(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).auth[taskPath], 'done');
549
+ const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
550
+ assert.equal(cache.entries[runtimeContext(root).context_id].tasks[taskPath], 'done');
426
551
 
427
552
  // cache-hit unchanged valid → still no block (revalidation passes)
428
553
  res = runHook(gate, appCwd, payload);
@@ -469,3 +594,221 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
469
594
  assert.equal(JSON.parse(res.stdout).decision, 'block', 'malformed cache with invalid receipt must block');
470
595
  });
471
596
  });
597
+
598
+ test('Codex cache identity separates same-feature receipts from different roots', () => {
599
+ const contexts = [];
600
+ for (let index = 0; index < 2; index += 1) {
601
+ const context = inHookFixture((root, hooks) => {
602
+ const feature = path.join(root, 'specs', 'auth');
603
+ const taskPath = 'tasks/task.md';
604
+ fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
605
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
606
+ status: 'in_progress',
607
+ feature_name: 'auth',
608
+ task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
609
+ }));
610
+ fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
611
+ 'Status: done', '', '## Evidence', '', 'Verification: PASS',
612
+ 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
613
+ ].join('\n')));
614
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
615
+ cwd: root,
616
+ session_id: 'session-a',
617
+ hook_event_name: 'Stop',
618
+ stop_hook_active: false,
619
+ });
620
+ assert.equal(result.stdout, '');
621
+ const cache = JSON.parse(fs.readFileSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), 'utf8'));
622
+ const ids = Object.keys(cache.entries);
623
+ assert.equal(ids.length, 1);
624
+ assert.equal(cache.entries[ids[0]].identity.feature_name, 'auth');
625
+ return runtimeContext(root);
626
+ });
627
+ contexts.push(context);
628
+ }
629
+ assert.notEqual(contexts[0].context_id, contexts[1].context_id);
630
+ });
631
+
632
+ test('Codex P0 regression: placeholder, explicit failure, artifact, symlink and invalid_specs', () => {
633
+ const path2 = require('node:path');
634
+ const specReceipt = require(path2.join(__dirname, '../../src/codex/hooks/lib/spec-receipt.cjs'));
635
+ const specUtils = require(path2.join(__dirname, '../../src/codex/hooks/lib/spec-utils.cjs'));
636
+ const fs2 = require('node:fs');
637
+ const os2 = require('node:os');
638
+ // Probe A: Tests failed and multiple Results
639
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests failed: 1\n').length > 0);
640
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nResult: FAIL\nResult: PASS\n').includes('exit_result'));
641
+ // Probe B: placeholder tokens
642
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: TODO\nExit: 0\nBase: a\nHead: b\n').includes('command'));
643
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: TBD\nHead: b\n').includes('provenance'));
644
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: pending\nhead_sha: unknown\n').includes('provenance'));
645
+ assert.deepEqual(specReceipt.validateCanonicalReceipt(`Verification: PASS\nCommand: npm run todo:test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`), []);
646
+ // Probe C: artifact
647
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: TBD\n').includes('artifact_hash'));
648
+ assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: \n').includes('artifact_hash'));
649
+ assert.deepEqual(specReceipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\nArtifact: bundle\nsha256: ${'a'.repeat(64)}\n`), []);
650
+ // Probe D/E: symlink spec/task
651
+ const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-codex-reg-'));
652
+ try {
653
+ const specsDir = path2.join(tmp, 'specs');
654
+ fs2.mkdirSync(specsDir, { recursive: true });
655
+ fs2.mkdirSync(path2.join(specsDir, 'valid'), { recursive: true });
656
+ fs2.writeFileSync(path2.join(specsDir, 'valid', 'spec.json'), JSON.stringify({ status: 'in_progress' }));
657
+ const outside = path2.join(tmp, 'outside');
658
+ fs2.mkdirSync(outside, { recursive: true });
659
+ fs2.writeFileSync(path2.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
660
+ const link = path2.join(specsDir, 'linked');
661
+ fs2.symlinkSync(outside, link);
662
+ assert.equal(specUtils.resolveActiveSpec(tmp, {}, 'linked', null).error, 'explicit_malformed');
663
+ assert.equal(specUtils.resolveActiveSpec(tmp, {}, null, path2.join(specsDir, 'linked', 'spec.json')).error, 'explicit_malformed');
664
+ // task symlink
665
+ const featDir = path2.join(specsDir, 'valid');
666
+ fs2.mkdirSync(path2.join(featDir, 'tasks'), { recursive: true });
667
+ const outsideTask = path2.join(tmp, 'outside-task.md');
668
+ fs2.writeFileSync(outsideTask, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
669
+ const taskLink = path2.join(featDir, 'tasks', 'task.md');
670
+ fs2.symlinkSync(outsideTask, taskLink);
671
+ const fails = specReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
672
+ assert.ok(fails.includes('a'));
673
+ // malformed spec
674
+ fs2.mkdirSync(path2.join(specsDir, 'bad'), { recursive: true });
675
+ fs2.writeFileSync(path2.join(specsDir, 'bad', 'spec.json'), '{ bad');
676
+ const mal = specUtils.resolveActiveSpec(tmp, {}, null, null);
677
+ assert.equal(mal.error, 'invalid_specs');
678
+ } finally {
679
+ fs2.rmSync(tmp, { recursive: true, force: true });
680
+ }
681
+ });
682
+
683
+ test('Codex adapter parity - phantom vectors via shared validator (r3)', () => {
684
+ const receipt = require(path.join(__dirname, '../../src/codex/hooks/lib/spec-receipt.cjs'));
685
+ const base = `Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`;
686
+ const rejects = [
687
+ 'Tests: 0 total',
688
+ 'Test Suites: 1 failed, 1 total',
689
+ 'FAIL ./foo.test.js',
690
+ 'FAIL\tpackage',
691
+ 'FAIL\texample.test/probe\t0.431s',
692
+ '--- FAIL: TestName (0.00s)',
693
+ 'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
694
+ 'FAILED tests/test_demo.py',
695
+ 'ERROR collecting tests/test_demo.py',
696
+ '# fail 1',
697
+ 'ℹ fail 1',
698
+ 'not ok 1 - test',
699
+ '[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
700
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
701
+ '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
702
+ 'ℹ tests 0',
703
+ 'collected 0 items',
704
+ '1 error in 0.12s',
705
+ 'ℹ cancelled 1',
706
+ ];
707
+ for (const vector of rejects) {
708
+ assert.ok(receipt.validateCanonicalReceipt(base + vector + '\n').length > 0, `should reject via Codex: ${vector}`);
709
+ }
710
+ const passes = [
711
+ 'Notes: handles error/failure',
712
+ 'collected 1 item',
713
+ 'Test Suites: 0 failed',
714
+ '[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
715
+ '[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
716
+ '[ERROR] Error handling is documented',
717
+ 'FAILURE mode analysis',
718
+ 'Notes: tests failed previously but now fixed',
719
+ ];
720
+ for (const vector of passes) {
721
+ assert.deepEqual(receipt.validateCanonicalReceipt(base + vector + '\n'), [], `should pass via Codex: ${vector}`);
722
+ }
723
+
724
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-tap-heading-'));
725
+ try {
726
+ const featureDir = path.join(tmp, 'feature');
727
+ const taskPath = 'tasks/task.md';
728
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
729
+ const taskPrefix = ['# Task', '', '**Status:** done', '', '## Evidence', '', base.trim()];
730
+ fs.writeFileSync(path.join(featureDir, taskPath), [...taskPrefix, '# fail 1', ''].join('\n'));
731
+ const tapFailure = receipt.checkReceipt(featureDir, taskPath, { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
732
+ assert.ok(tapFailure.includes('c'), 'unindented TAP # fail 1 must reach the shared validator');
733
+
734
+ fs.writeFileSync(path.join(featureDir, taskPath), [...taskPrefix, '# Notes', 'FAILED tests/test_demo.py', ''].join('\n'));
735
+ const markdownBody = receipt.evidenceBody(fs.readFileSync(path.join(featureDir, taskPath), 'utf8'));
736
+ assert.doesNotMatch(markdownBody, /FAILED tests\/test_demo\.py/);
737
+ assert.deepEqual(receipt.validateCanonicalReceipt(markdownBody), [], 'a real Markdown heading must still end Evidence');
738
+ } finally {
739
+ fs.rmSync(tmp, { recursive: true, force: true });
740
+ }
741
+ });
742
+
743
+ test('installed Codex gate fails closed with a controlled decision when policy is missing or malformed', () => {
744
+ for (const policy of ['missing', 'malformed']) {
745
+ inHookFixture((root, hooks) => {
746
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
747
+ cwd: root,
748
+ session_id: 'session-a',
749
+ hook_event_name: 'Stop',
750
+ stop_hook_active: false,
751
+ });
752
+ assert.equal(result.status, 0, `${policy}: ${result.stderr}`);
753
+ assert.equal(result.stderr, '', `${policy} must not leak a stack trace`);
754
+ const block = JSON.parse(result.stdout);
755
+ assert.equal(block.decision, 'block');
756
+ assert.match(block.reason, /workflow policy could not be loaded/i);
757
+ assert.ok(fs.existsSync(path.join(hooks, '.logs', 'hook-log.jsonl')));
758
+ }, { policy });
759
+ }
760
+ });
761
+
762
+ test('Codex adapter enforces declared task artifact SHA-256 with missing, invalid, and valid evidence', () => {
763
+ const artifact = Buffer.from('cafekit codex fixture artifact\n');
764
+ const digest = crypto.createHash('sha256').update(artifact).digest('hex');
765
+ const cases = [
766
+ ['', true],
767
+ ['sha256: abc123\n', true],
768
+ [`sha256: ${'0'.repeat(64)}\n`, true],
769
+ [`sha256: ${digest}\n`, false],
770
+ ];
771
+ for (const [hashLine, shouldBlock] of cases) {
772
+ inHookFixture((root, hooks) => {
773
+ const feature = path.join(root, 'specs', 'artifact-demo');
774
+ const taskPath = 'tasks/task.md';
775
+ fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
776
+ fs.mkdirSync(path.join(root, 'output'), { recursive: true });
777
+ fs.writeFileSync(path.join(root, 'output', 'bundle.js'), artifact);
778
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
779
+ status: 'in_progress',
780
+ feature_name: 'artifact-demo',
781
+ task_registry: {
782
+ [taskPath]: {
783
+ status: 'done',
784
+ completed_at: '2026-08-11T00:00:00.000Z',
785
+ artifacts: ['output/bundle.js'],
786
+ },
787
+ },
788
+ }));
789
+ fs.writeFileSync(path.join(feature, taskPath), [
790
+ '# Task', '', '**Status:** done', '', '## Evidence', '',
791
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
792
+ 'Artifact: output/bundle.js', hashLine,
793
+ ].join('\n'));
794
+ fs.writeFileSync(
795
+ path.join(feature, taskPath),
796
+ bindReceipt(root, fs.readFileSync(path.join(feature, taskPath), 'utf8'), 'artifact-demo'),
797
+ );
798
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
799
+ cwd: root,
800
+ session_id: 'session-a',
801
+ hook_event_name: 'Stop',
802
+ stop_hook_active: false,
803
+ });
804
+ assert.equal(result.status, 0, result.stderr);
805
+ if (shouldBlock) {
806
+ const block = JSON.parse(result.stdout);
807
+ assert.equal(block.decision, 'block');
808
+ assert.match(block.reason, /\bh\b/);
809
+ } else {
810
+ assert.equal(result.stdout, '');
811
+ }
812
+ });
813
+ }
814
+ });
@@ -442,7 +442,7 @@ test('Codex Windows hook launchers stay project-bound without Git from nested cw
442
442
  fs.readFileSync(path.join(projectRoot, '.codex', 'hooks.json'), 'utf8')
443
443
  );
444
444
  const handlers = allHookHandlers(config);
445
- assert.equal(handlers.length, 14);
445
+ assert.equal(handlers.length, 16);
446
446
  for (const handler of handlers) {
447
447
  assert.doesNotMatch(handler.commandWindows, /\$\(/);
448
448
  assert.doesNotMatch(handler.commandWindows, /\bgit\b/i);