@haposoft/cafekit 0.18.1 → 0.18.2

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 (93) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +774 -6
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +192 -59
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +1 -1
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  18. package/bin/__tests__/omp-bridge.test.js +66 -12
  19. package/bin/__tests__/omp-hooks.test.js +11 -5
  20. package/bin/__tests__/orca-session.test.js +7 -0
  21. package/bin/__tests__/privacy-paths.test.js +317 -0
  22. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  23. package/bin/install.js +25 -0
  24. package/bin/lib/codex-install.js +134 -12
  25. package/bin/lib/context.js +1 -1
  26. package/bin/phases/claude-runtime.js +91 -25
  27. package/bin/phases/claude-settings.js +22 -1
  28. package/bin/phases/codex-hooks.js +96 -33
  29. package/bin/phases/codex-runtime.js +45 -52
  30. package/bin/phases/copy-payload.js +1 -3
  31. package/package.json +1 -1
  32. package/src/claude/CLAUDE.md +4 -0
  33. package/src/claude/gitignore +3 -0
  34. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  35. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  36. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  37. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  38. package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
  39. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  40. package/src/claude/hooks/__tests__/state.test.js +207 -7
  41. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  42. package/src/claude/hooks/agent.cjs +7 -6
  43. package/src/claude/hooks/inspect-block.cjs +15 -63
  44. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  45. package/src/claude/hooks/lib/parser.cjs +1 -1
  46. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  47. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  48. package/src/claude/hooks/lib/project-root.cjs +15 -0
  49. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  50. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  51. package/src/claude/hooks/lib/update-check.cjs +77 -0
  52. package/src/claude/hooks/precompact.cjs +8 -15
  53. package/src/claude/hooks/privacy-block.cjs +23 -205
  54. package/src/claude/hooks/rules.cjs +6 -28
  55. package/src/claude/hooks/session.cjs +34 -117
  56. package/src/claude/hooks/spec-gate.cjs +9 -26
  57. package/src/claude/hooks/spec-state.cjs +43 -59
  58. package/src/claude/hooks/state.cjs +96 -51
  59. package/src/claude/migration-manifest.json +8 -1
  60. package/src/claude/rules/hook-protocols.md +1 -1
  61. package/src/claude/runtime.json +0 -3
  62. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  63. package/src/claude/scripts/provenance.cjs +14 -0
  64. package/src/claude/settings/settings.json +1 -1
  65. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  66. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  67. package/src/claude/skills/git/SKILL.md +1 -1
  68. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  69. package/src/codex/AGENTS.md +8 -0
  70. package/src/codex/hooks/ask-guard.cjs +54 -0
  71. package/src/codex/hooks/inspect-block.cjs +11 -7
  72. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  73. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  74. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  75. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  76. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  77. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  78. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  79. package/src/codex/hooks/lib/update-check.cjs +1 -0
  80. package/src/codex/hooks/precompact.cjs +39 -0
  81. package/src/codex/hooks/privacy-approval.cjs +28 -17
  82. package/src/codex/hooks/privacy-block.cjs +9 -215
  83. package/src/codex/hooks/rules.cjs +4 -30
  84. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  85. package/src/codex/hooks/session.cjs +47 -11
  86. package/src/codex/hooks/spec-gate.cjs +21 -6
  87. package/src/codex/hooks/spec-state.cjs +32 -6
  88. package/src/codex/hooks/state.cjs +25 -4
  89. package/src/codex/hooks.json +39 -7
  90. package/src/codex/rules/hook-protocols.md +13 -6
  91. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  92. package/src/omp/hooks/privacy-block.cjs +24 -206
  93. package/src/codex/rules/state-sync.md +0 -60
@@ -29,9 +29,10 @@ const FIXTURE_ARTIFACT_DIGEST = crypto.createHash('sha256').update(FIXTURE_ARTIF
29
29
  function runHook(payload, cwd) {
30
30
  const projectRoot = cwd || payload.cwd;
31
31
  const specsRoot = path.join(projectRoot, 'specs');
32
- // PROJECT_ROOT wins over cwd in the hook; pin it to the fixture so a host
33
- // monorepo's real specs/ cannot leak into the test (e.g. active rtk-* specs).
34
- const env = { ...process.env, PROJECT_ROOT: projectRoot };
32
+ // Source hooks use payload.cwd without inherited root overrides.
33
+ const env = { ...process.env };
34
+ delete env.CLAUDE_PROJECT_DIR;
35
+ delete env.PROJECT_ROOT;
35
36
  const res = spawnSync(process.execPath, [HOOK], {
36
37
  input: JSON.stringify({
37
38
  hook_event_name: 'Stop',
@@ -235,6 +236,8 @@ function installClaudeGate(root, policyMode = 'valid') {
235
236
  fs.copyFileSync(HOOK, path.join(hooks, 'spec-gate.cjs'));
236
237
  fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
237
238
  fs.copyFileSync(path.join(__dirname, '..', 'lib', 'runtime-dir.cjs'), path.join(hooks, 'lib', 'runtime-dir.cjs'));
239
+ fs.copyFileSync(path.join(__dirname, '..', 'lib', 'project-root.cjs'), path.join(hooks, 'lib', 'project-root.cjs'));
240
+ fs.copyFileSync(path.join(__dirname, '..', 'lib', 'spec-touch.cjs'), path.join(hooks, 'lib', 'spec-touch.cjs'));
238
241
  fs.copyFileSync(path.join(__dirname, '..', 'lib', 'hook-payload.cjs'), path.join(hooks, 'lib', 'hook-payload.cjs'));
239
242
  fs.copyFileSync(
240
243
  path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'),
@@ -1264,11 +1267,14 @@ function boundReceipt(base = VALID_BASE, head = VALID_HEAD) {
1264
1267
  }
1265
1268
 
1266
1269
  function runHookFrom(dir, cwd) {
1270
+ const env = { ...process.env };
1271
+ delete env.CLAUDE_PROJECT_DIR;
1272
+ delete env.PROJECT_ROOT;
1267
1273
  const res = spawnSync(process.execPath, [HOOK], {
1268
1274
  cwd,
1269
1275
  input: JSON.stringify({ hook_event_name: 'Stop', session_id: 'test', transcript_path: '/tmp/t', stop_hook_active: false, cwd: dir }),
1270
1276
  encoding: 'utf8',
1271
- env: { ...process.env, PROJECT_ROOT: dir },
1277
+ env,
1272
1278
  });
1273
1279
  return { code: res.status, stdout: (res.stdout || '').trim(), stderr: res.stderr || '' };
1274
1280
  }
@@ -1371,17 +1377,142 @@ test('41. unborn HEAD fails closed before any receipt check', () => {
1371
1377
  clearCache();
1372
1378
  const body = parseBlock(runHook({}, dir).stdout);
1373
1379
  assert.ok(body && body.decision === 'block', 'an unborn HEAD must block');
1374
- // The block arrives from provenance derivation, which cannot resolve a base
1375
- // commit here, so the receipt check is never reached. Asserting the reason
1376
- // keeps this case honest: it guards fail-closed behavior on an unborn HEAD,
1377
- // not the binding-mode selector. The selector's own HEAD-unreadable branch
1378
- // is guarded by cases 38 and 39.
1379
- assert.match(body.reason, /controlled failure/);
1380
+ // A done task still blocks before receipt validation, with guidance to
1381
+ // create the first commit before recording its Base/Head.
1382
+ assert.match(body.reason, /first commit/);
1380
1383
  } finally {
1381
1384
  fs.rmSync(dir, { recursive: true, force: true });
1382
1385
  }
1383
1386
  });
1384
1387
 
1388
+ function noHistoryFixture() {
1389
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-no-history-'));
1390
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test']]) {
1391
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
1392
+ assert.strictEqual(result.status, 0, result.stderr);
1393
+ }
1394
+ installClaudeGate(dir);
1395
+ fs.copyFileSync(path.join(__dirname, '..', 'spec-state.cjs'), path.join(dir, '.claude/hooks/spec-state.cjs'));
1396
+ return dir;
1397
+ }
1398
+
1399
+ test('Stop still blocks multiple active specs for an untouched session', () => {
1400
+ const dir = tmpDir();
1401
+ try {
1402
+ const gate = installClaudeGate(dir);
1403
+ for (const feature of ['demo', 'other']) writeNoHistoryPacket(dir, feature, 'in_progress');
1404
+ const result = spawnSync(process.execPath, [gate], {
1405
+ cwd: dir, encoding: 'utf8', env: { ...process.env, PROJECT_ROOT: dir, CLAUDE_PROJECT_DIR: dir },
1406
+ input: JSON.stringify({ cwd: dir, session_id: 'untouched-session', hook_event_name: 'Stop', prompt: 'Continue' }),
1407
+ });
1408
+ assert.strictEqual(result.status, 0, result.stderr);
1409
+ const body = parseBlock(result.stdout);
1410
+ assert.strictEqual(body.decision, 'block');
1411
+ assert.match(body.reason, /multiple active specs/);
1412
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1413
+ });
1414
+
1415
+ function writeNoHistoryPacket(dir, feature, status) {
1416
+ const packet = path.join(dir, 'specs', feature);
1417
+ fs.mkdirSync(packet, { recursive: true });
1418
+ fs.writeFileSync(path.join(packet, 'plan.md'), `# ${feature}\nSpecs-Contract: process-first-ready-v1\n`);
1419
+ fs.writeFileSync(path.join(packet, 'task-01-work.md'), [
1420
+ '# Task 01: work', '', `Status: ${status}`, '', '## Dependencies', '', '- none', '',
1421
+ '## Verification Plan', '', '- Command: node --test', '',
1422
+ ...(status === 'done' ? ['## Receipt', '', 'Verification: PASS', ''] : []),
1423
+ ].join('\n'));
1424
+ }
1425
+
1426
+ function runInstalledHistoryHook(dir, hook = 'spec-gate', extraEnv = {}) {
1427
+ return spawnSync(process.execPath, [path.join(dir, '.claude/hooks', `${hook}.cjs`)], {
1428
+ cwd: dir, encoding: 'utf8',
1429
+ env: { ...process.env, PROJECT_ROOT: dir, CLAUDE_PROJECT_DIR: dir, ...extraEnv },
1430
+ input: JSON.stringify({ cwd: dir, session_id: 'no-history',
1431
+ hook_event_name: hook === 'spec-state' ? 'UserPromptSubmit' : 'Stop', prompt: 'Continue specs/demo' }),
1432
+ });
1433
+ }
1434
+
1435
+ test('no commits allows Stop without done tasks and prints one reminder', () => {
1436
+ const dir = noHistoryFixture();
1437
+ try {
1438
+ writeNoHistoryPacket(dir, 'demo', 'in_progress');
1439
+ const gate = runInstalledHistoryHook(dir);
1440
+ assert.strictEqual(gate.status, 0, gate.stderr);
1441
+ assert.strictEqual(gate.stdout.trim(), '');
1442
+ const state = runInstalledHistoryHook(dir, 'spec-state');
1443
+ assert.strictEqual(state.status, 0, state.stderr);
1444
+ assert.match(state.stdout, /no commits/);
1445
+ assert.strictEqual(state.stdout.trim().split('\n').length, 1);
1446
+ assert.doesNotMatch(state.stdout, /Spec tollgate unavailable/);
1447
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1448
+ });
1449
+
1450
+ test('no commits blocks a done task with first commit guidance', () => {
1451
+ const dir = noHistoryFixture();
1452
+ try {
1453
+ writeNoHistoryPacket(dir, 'demo', 'done');
1454
+ const result = runInstalledHistoryHook(dir);
1455
+ assert.strictEqual(result.status, 0, result.stderr);
1456
+ const body = parseBlock(result.stdout);
1457
+ assert.strictEqual(body.decision, 'block');
1458
+ assert.match(body.reason, /first commit/);
1459
+ assert.doesNotMatch(body.reason, /controlled failure/);
1460
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1461
+ });
1462
+
1463
+ test('no commits blocks two completed packets with first commit guidance', () => {
1464
+ const dir = noHistoryFixture();
1465
+ try {
1466
+ for (const feature of ['demo', 'other']) writeNoHistoryPacket(dir, feature, 'done');
1467
+ const result = runInstalledHistoryHook(dir);
1468
+ assert.strictEqual(result.status, 0, result.stderr);
1469
+ const body = parseBlock(result.stdout);
1470
+ assert.strictEqual(body.decision, 'block');
1471
+ assert.match(body.reason, /first commit/);
1472
+ assert.doesNotMatch(body.reason, /controlled failure/);
1473
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1474
+ });
1475
+
1476
+ test('a corrupt current branch ref remains a controlled failure', () => {
1477
+ const dir = tmpDir();
1478
+ try {
1479
+ installClaudeGate(dir);
1480
+ writeNoHistoryPacket(dir, 'demo', 'in_progress');
1481
+ const ref = spawnSync('git', ['-C', dir, 'symbolic-ref', '-q', 'HEAD'], { encoding: 'utf8' });
1482
+ assert.strictEqual(ref.status, 0, ref.stderr);
1483
+ fs.writeFileSync(path.join(dir, '.git', ref.stdout.trim()), 'corrupt-ref\n');
1484
+ const result = runInstalledHistoryHook(dir);
1485
+ assert.strictEqual(result.status, 0, result.stderr);
1486
+ const body = parseBlock(result.stdout);
1487
+ assert.strictEqual(body.decision, 'block');
1488
+ assert.match(body.reason, /controlled failure/);
1489
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1490
+ });
1491
+
1492
+ test('unavailable git remains a controlled failure in a repo without commits', () => {
1493
+ const dir = noHistoryFixture();
1494
+ try {
1495
+ writeNoHistoryPacket(dir, 'demo', 'in_progress');
1496
+ const emptyPath = path.join(dir, 'empty-path');
1497
+ fs.mkdirSync(emptyPath);
1498
+ const result = runInstalledHistoryHook(dir, 'spec-gate', { PATH: emptyPath });
1499
+ assert.strictEqual(result.status, 0, result.stderr);
1500
+ const body = parseBlock(result.stdout);
1501
+ assert.strictEqual(body.decision, 'block');
1502
+ assert.match(body.reason, /controlled failure/);
1503
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1504
+ });
1505
+
1506
+ test('a committed repo still accepts a valid done task receipt', () => {
1507
+ const dir = makeFixture({ evidence: 'valid' });
1508
+ try {
1509
+ installClaudeGate(dir);
1510
+ const result = runInstalledHistoryHook(dir);
1511
+ assert.strictEqual(result.status, 0, result.stderr);
1512
+ assert.strictEqual(result.stdout.trim(), '');
1513
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1514
+ });
1515
+
1385
1516
  test('42. a dirty tree outside specs does not reopen a committed receipt', () => {
1386
1517
  // Contract change. This used to require a block. `Base` is the last commit
1387
1518
  // touching anything outside the specs root, so a receipt written before any
@@ -2,8 +2,8 @@
2
2
 
3
3
  // spec-state injects the active packet's tollgate on every prompt. With several sessions in
4
4
  // one checkout, a Claude session kept seeing a packet another session was working on. On
5
- // Claude the hook now speaks only about packets this session touched; omp, grok and
6
- // payloads without a Claude session id keep the old behaviour. Each case names the failure
5
+ // Claude and omp the hook now speaks only about packets this session touched; grok and
6
+ // payloads without a session id keep the old behaviour. Each case names the failure
7
7
  // it prevents.
8
8
 
9
9
  const { test } = require('node:test');
@@ -12,6 +12,7 @@ const { spawnSync } = require('node:child_process');
12
12
  const fs = require('node:fs');
13
13
  const os = require('node:os');
14
14
  const path = require('node:path');
15
+ const crypto = require('node:crypto');
15
16
 
16
17
  const CLAUDE_ROOT = path.join(__dirname, '..', '..');
17
18
 
@@ -36,7 +37,7 @@ function fixture(packets, runtimeDir = '.claude') {
36
37
  fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
37
38
  fs.mkdirSync(scripts, { recursive: true });
38
39
  fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'spec-state.cjs'), path.join(hooks, 'spec-state.cjs'));
39
- for (const lib of ['runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs']) {
40
+ for (const lib of ['runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs', 'project-root.cjs', 'spec-touch.cjs']) {
40
41
  fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', lib), path.join(hooks, 'lib', lib));
41
42
  }
42
43
  for (const file of ['provenance.cjs', 'spec-receipt.cjs', 'spec-resolver.cjs', 'workflow-policy.cjs']) {
@@ -48,23 +49,61 @@ function fixture(packets, runtimeDir = '.claude') {
48
49
  const git = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
49
50
  assert.strictEqual(git.status, 0, git.stderr);
50
51
  }
52
+ let lastSession = null;
51
53
  const run = (payload) => {
54
+ lastSession = payload.session_id;
55
+ const env = { ...process.env };
56
+ delete env.CLAUDE_PROJECT_DIR;
57
+ delete env.PROJECT_ROOT;
52
58
  const result = spawnSync(process.execPath, [path.join(hooks, 'spec-state.cjs')], {
53
- cwd: root, env: { ...process.env, PROJECT_ROOT: root },
59
+ cwd: root, env,
54
60
  input: JSON.stringify({ cwd: root, ...payload }), encoding: 'utf8',
55
61
  });
56
62
  assert.strictEqual(result.status, 0, result.stderr);
57
63
  return result.stdout.trim();
58
64
  };
59
65
  const prompt = (session, text) => run({ hook_event_name: 'UserPromptSubmit', session_id: session, prompt: text });
60
- const cacheFile = path.join(hooks, '.logs', 'tollgate-last.txt');
61
- return { root, hooks, run, prompt, cacheFile, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) };
66
+ return {
67
+ root, hooks, run, prompt,
68
+ get cacheFile() {
69
+ const name = ['.claude', '.omp'].includes(runtimeDir) && typeof lastSession === 'string' && lastSession
70
+ ? `tollgate-last-${crypto.createHash('sha256').update(lastSession).digest('hex').slice(0, 16)}.txt`
71
+ : 'tollgate-last.txt';
72
+ return path.join(hooks, '.logs', name);
73
+ },
74
+ cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
75
+ };
62
76
  }
63
77
 
64
78
  function within(fx, fn) {
65
79
  try { return fn(fx); } finally { fx.cleanup(); }
66
80
  }
67
81
 
82
+ test('Claude spec-state says receipt revalidation never grants approval', () => {
83
+ within(fixture(['auth-flow']), fx => {
84
+ assert.match(fx.prompt('message-session', 'specs/auth-flow'), /^- Hooks revalidate receipt bytes but never grant approval\.$/m);
85
+ });
86
+ });
87
+
88
+ test('Claude subagent rules include the shared simplicity and reporting instructions', () => {
89
+ within(fixture([]), fx => {
90
+ fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks/agent.cjs'), path.join(fx.hooks, 'agent.cjs'));
91
+ fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), '{}');
92
+ const env = { ...process.env };
93
+ delete env.CLAUDE_PROJECT_DIR;
94
+ delete env.PROJECT_ROOT;
95
+ const result = spawnSync(process.execPath, [path.join(fx.hooks, 'agent.cjs')], {
96
+ cwd: fx.root, env, encoding: 'utf8', input: JSON.stringify({ cwd: fx.root, hook_event_name: 'SubagentStart', agent_type: 'checker' }),
97
+ });
98
+ assert.strictEqual(result.status, 0, result.stderr);
99
+ const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
100
+ const rules = context.split('## Rules\n')[1];
101
+ assert.ok(rules, 'the rules section must be present');
102
+ assert.match(rules, /^- YAGNI · KISS · DRY$/m);
103
+ assert.match(rules, /^- Be concise\. List unresolved questions at end\.$/m);
104
+ });
105
+ });
106
+
68
107
  test('an untouched packet prints nothing on Claude', () => {
69
108
  within(fixture(['auth-flow']), (fx) => {
70
109
  assert.strictEqual(fx.prompt('s1', 'Continue'), '');
@@ -72,6 +111,58 @@ test('an untouched packet prints nothing on Claude', () => {
72
111
  });
73
112
  });
74
113
 
114
+ test('a touched packet respects the project tollgate toggle from a nested cwd', () => {
115
+ within(fixture(['auth-flow']), fx => {
116
+ assert.match(fx.prompt('s1', 'specs/auth-flow'), /Spec state changed/);
117
+ fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), JSON.stringify({ spec: { tollgate: false } }));
118
+ const cwd = path.join(fx.root, 'sub/dir');
119
+ fs.mkdirSync(cwd, { recursive: true });
120
+ assert.strictEqual(fx.run({ cwd, session_id: 's1', hook_event_name: 'UserPromptSubmit', prompt: 'Continue' }), '');
121
+ });
122
+ });
123
+
124
+ test('an explicitly failed edit does not touch its packet', () => {
125
+ within(fixture(['auth-flow']), fx => {
126
+ fx.run({ session_id: 's1', hook_event_name: 'PostToolUse', tool_name: 'Edit',
127
+ tool_input: { file_path: path.join(fx.root, 'specs/auth-flow/plan.md') }, tool_response: { is_error: true } });
128
+ assert.strictEqual(fx.prompt('s1', 'Continue'), '');
129
+ });
130
+ });
131
+
132
+ test('written content mentioning failure still touches its packet', () => {
133
+ within(fixture(['auth-flow']), fx => {
134
+ fx.run({ session_id: 's1', hook_event_name: 'PostToolUse', tool_name: 'Write',
135
+ tool_input: { file_path: path.join(fx.root, 'specs/auth-flow/plan.md') }, tool_response: { content: 'Failed x' } });
136
+ assert.match(fx.prompt('s1', 'Continue'), /Spec state changed/);
137
+ });
138
+ });
139
+
140
+ test('a configured specs prefix names a plain lowercase packet', () => {
141
+ within(fixture(['auth']), fx => {
142
+ fs.renameSync(path.join(fx.root, 'specs'), path.join(fx.root, 'planning'));
143
+ fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), JSON.stringify({ paths: { specs: 'planning' } }));
144
+ assert.match(fx.prompt('s1', 'Continue planning/auth'), /Spec state changed: `auth`/);
145
+ });
146
+ });
147
+
148
+ test('an explicit packet path records the session touch', () => {
149
+ within(fixture(['auth']), fx => {
150
+ assert.match(fx.run({ session_id: 's1', hook_event_name: 'UserPromptSubmit',
151
+ specPath: path.join(fs.realpathSync(fx.root), 'specs/auth'), prompt: 'Continue' }), /Spec state changed: `auth`/);
152
+ assert.match(fx.prompt('s1', 'Continue'), /Tollgate active/);
153
+ });
154
+ });
155
+
156
+ test('another session does not displace the compact tollgate cache', () => {
157
+ within(fixture(['auth-flow']), fx => {
158
+ assert.match(fx.prompt('session-a', 'specs/auth-flow'), /Spec state changed/);
159
+ assert.match(fx.prompt('session-b', 'specs/auth-flow'), /Spec state changed/);
160
+ const again = fx.prompt('session-a', 'Continue');
161
+ assert.match(again, /Tollgate active/);
162
+ assert.doesNotMatch(again, /Spec state changed/);
163
+ });
164
+ });
165
+
75
166
  test('naming the packet in the prompt touches it and keeps the full block', () => {
76
167
  within(fixture(['auth-flow']), (fx) => {
77
168
  assert.strictEqual(fx.prompt('s1', 'Continue'), '');
@@ -113,10 +204,15 @@ test("another session's touch does not leak", () => {
113
204
  });
114
205
  });
115
206
 
116
- test('omp, grok and session-less payloads keep the old behaviour', () => {
207
+ test('omp filters by touch like Claude', () => {
117
208
  within(fixture(['auth-flow'], '.omp'), (fx) => {
118
- assert.match(fx.prompt('s5', 'Continue'), /Spec state changed/, 'omp is unchanged');
209
+ assert.strictEqual(fx.prompt('s5', 'Continue'), '', 'an untouched packet is silent under omp');
210
+ assert.match(fx.prompt('s5', 'specs/auth-flow'), /Spec state changed/);
211
+ assert.strictEqual(fx.prompt('s5b', 'Continue'), '', "another omp session's touch does not leak");
119
212
  });
213
+ });
214
+
215
+ test('grok and session-less payloads keep the old behaviour', () => {
120
216
  within(fixture(['auth-flow']), (fx) => {
121
217
  assert.match(fx.run({ hook_event_name: 'UserPromptSubmit', sessionId: 's6', prompt: 'Continue' }), /Spec/, 'grok sends sessionId');
122
218
  });
@@ -133,6 +229,53 @@ test('a prompt touch is remembered even when two packets are active', () => {
133
229
  });
134
230
  });
135
231
 
232
+ test('an untouched session stays quiet with two active packets', () => {
233
+ within(fixture(['auth-flow', 'billing-2']), (fx) => {
234
+ assert.strictEqual(fx.prompt('s1', 'Continue'), '');
235
+ });
236
+ });
237
+
238
+ function invalidateTask(root, feature) {
239
+ fs.writeFileSync(path.join(root, 'specs', feature, 'task-01-work.md'),
240
+ '# Task 01: broken\n\n## Verification Plan\n\n- Command: node --test\n');
241
+ }
242
+
243
+ test('an untouched session stays quiet when a packet is invalid', () => {
244
+ within(fixture(['broken-flow']), (fx) => {
245
+ invalidateTask(fx.root, 'broken-flow');
246
+ assert.strictEqual(fx.prompt('s1', 'Continue'), '');
247
+ });
248
+ });
249
+
250
+ test('touching an invalid packet still prints its warning', () => {
251
+ within(fixture(['broken-flow']), (fx) => {
252
+ invalidateTask(fx.root, 'broken-flow');
253
+ assert.match(fx.prompt('s1', 'Continue specs/broken-flow'), /Invalid workflow packet/);
254
+ });
255
+ });
256
+
257
+ test('a session-less prompt still warns about two active packets', () => {
258
+ within(fixture(['auth-flow', 'billing-2']), (fx) => {
259
+ assert.match(fx.run({ hook_event_name: 'UserPromptSubmit', prompt: 'Continue' }), /Multiple active specs/);
260
+ });
261
+ });
262
+
263
+ test('a touched valid packet prints its tollgate when another packet is invalid', () => {
264
+ within(fixture(['auth-flow', 'broken-flow']), (fx) => {
265
+ invalidateTask(fx.root, 'broken-flow');
266
+ const output = fx.prompt('s1', 'Continue specs/auth-flow');
267
+ assert.match(output, /Spec state changed: `auth-flow`/);
268
+ assert.doesNotMatch(output, /Invalid workflow packet/);
269
+ });
270
+ });
271
+
272
+ test('an invalid specs root still warns an untouched session', () => {
273
+ within(fixture(['auth-flow']), (fx) => {
274
+ fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
275
+ assert.match(fx.prompt('s1', 'Continue'), /Invalid workflow packet detected: <specs>/);
276
+ });
277
+ });
278
+
136
279
  function writeLegacyPacket(root, name) {
137
280
  const dir = path.join(root, 'specs', name);
138
281
  fs.mkdirSync(path.join(dir, 'tasks'), { recursive: true });
@@ -158,3 +301,56 @@ test('a legacy packet is noticed even when it is the only packet', () => {
158
301
  assert.match(fx.prompt('s1', 'Continue'), LEGACY_NOTICE);
159
302
  });
160
303
  });
304
+
305
+ test('Claude spec-state uses one snapshot for done task receipts', (t) => {
306
+ within(fixture(['auth-flow']), (fx) => {
307
+ const trace = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-snapshot-count-'));
308
+ try {
309
+ const feature = path.join(fx.root, 'specs/auth-flow');
310
+ const tasks = ['task-01-work.md', 'task-02-work.md'];
311
+ for (const name of tasks) fs.writeFileSync(path.join(feature, name), taskContent('done'));
312
+ fs.writeFileSync(path.join(feature, 'task-03-work.md'), taskContent('in_progress'));
313
+ const scripts = path.join(fx.root, '.claude/scripts');
314
+ const provenance = require(path.join(scripts, 'provenance.cjs'));
315
+ const policy = require(path.join(scripts, 'workflow-policy.cjs'));
316
+ const receipt = require(path.join(scripts, 'spec-receipt.cjs'));
317
+ const context = provenance.deriveRuntimeContext({ projectRoot: fx.root,
318
+ specsRoot: path.join(fx.root, 'specs'), specFile: path.join(feature, 'plan.md'),
319
+ featureName: 'auth-flow', runtimeSession: 'one-snapshot' });
320
+ for (const name of tasks) {
321
+ fs.appendFileSync(path.join(feature, name), [
322
+ '\n## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
323
+ `Base: ${context.base}`, `Head: ${context.head}`, '```text', '1 test passed', '```', '',
324
+ ].join('\n'));
325
+ assert.deepStrictEqual(receipt.checkWorkflowTaskReceipt(feature, name, context, policy).failures, []);
326
+ }
327
+ const countFile = path.join(trace, 'git-status.txt');
328
+ const preload = path.join(trace, 'count.cjs');
329
+ fs.writeFileSync(preload, `
330
+ const cp = require('node:child_process');
331
+ const fs = require('node:fs');
332
+ const path = require('node:path');
333
+ for (const method of ['spawnSync', 'execFileSync']) {
334
+ const original = cp[method];
335
+ cp[method] = function(command, args, ...rest) {
336
+ if (path.basename(String(command)) === 'git' && Array.isArray(args) && args.includes('status'))
337
+ fs.appendFileSync(${JSON.stringify(countFile)}, 'status\\n');
338
+ return original.call(this, command, args, ...rest);
339
+ };
340
+ }
341
+ `);
342
+ const result = spawnSync(process.execPath, [path.join(fx.hooks, 'spec-state.cjs')], {
343
+ cwd: fx.root, encoding: 'utf8',
344
+ env: { ...process.env, PROJECT_ROOT: fx.root,
345
+ NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --require ${JSON.stringify(preload)}` },
346
+ input: JSON.stringify({ cwd: fx.root, hook_event_name: 'UserPromptSubmit',
347
+ session_id: 'one-snapshot', prompt: 'Continue specs/auth-flow' }),
348
+ });
349
+ assert.strictEqual(result.status, 0, result.stderr);
350
+ assert.match(result.stdout, /Spec state changed/);
351
+ const count = fs.readFileSync(countFile, 'utf8').trim().split('\n').length;
352
+ t.diagnostic(`Claude git status calls: ${count}`);
353
+ assert.ok(count > 0 && count <= 2, `git status calls: ${count}; expected at most 2`);
354
+ } finally { fs.rmSync(trace, { recursive: true, force: true }); }
355
+ });
356
+ });