@haposoft/cafekit 0.18.0 → 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 (162) 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 +938 -237
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +222 -149
  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 +102 -1362
  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-authority-decoupled.test.js +14 -19
  18. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  19. package/bin/__tests__/omp-bridge.test.js +66 -12
  20. package/bin/__tests__/omp-hooks.test.js +11 -5
  21. package/bin/__tests__/orca-session.test.js +7 -0
  22. package/bin/__tests__/package-inventory.test.js +1 -395
  23. package/bin/__tests__/privacy-paths.test.js +317 -0
  24. package/bin/__tests__/spec-narrowing.test.js +30 -134
  25. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
  26. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  27. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  28. package/bin/__tests__/validator-grounding.test.js +17 -2929
  29. package/bin/install.js +25 -0
  30. package/bin/lib/codex-install.js +134 -12
  31. package/bin/lib/context.js +1 -1
  32. package/bin/lib/managed-writer.js +2 -3
  33. package/bin/phases/claude-runtime.js +91 -25
  34. package/bin/phases/claude-settings.js +22 -1
  35. package/bin/phases/codex-hooks.js +96 -33
  36. package/bin/phases/codex-runtime.js +45 -49
  37. package/bin/phases/copy-payload.js +11 -28
  38. package/bin/phases/omp-runtime.js +6 -3
  39. package/package.json +2 -3
  40. package/src/claude/CLAUDE.md +4 -0
  41. package/src/claude/agents/code-auditor.md +9 -6
  42. package/src/claude/agents/debugger.md +3 -9
  43. package/src/claude/agents/docs-keeper.md +4 -7
  44. package/src/claude/agents/git-ops.md +2 -4
  45. package/src/claude/agents/implementer.md +6 -7
  46. package/src/claude/agents/inspector.md +2 -0
  47. package/src/claude/agents/project-manager.md +2 -2
  48. package/src/claude/agents/spec-maker.md +4 -4
  49. package/src/claude/agents/test-runner.md +8 -11
  50. package/src/claude/agents/ui-ux-designer.md +0 -1
  51. package/src/claude/gitignore +3 -0
  52. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  53. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  54. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  55. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  56. package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
  57. package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
  58. package/src/claude/hooks/__tests__/state.test.js +207 -7
  59. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  60. package/src/claude/hooks/agent.cjs +7 -6
  61. package/src/claude/hooks/inspect-block.cjs +15 -63
  62. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  63. package/src/claude/hooks/lib/parser.cjs +1 -1
  64. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  65. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  66. package/src/claude/hooks/lib/project-root.cjs +15 -0
  67. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  68. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  69. package/src/claude/hooks/lib/update-check.cjs +77 -0
  70. package/src/claude/hooks/precompact.cjs +8 -15
  71. package/src/claude/hooks/privacy-block.cjs +23 -205
  72. package/src/claude/hooks/rules.cjs +6 -28
  73. package/src/claude/hooks/session.cjs +34 -117
  74. package/src/claude/hooks/spec-gate.cjs +17 -89
  75. package/src/claude/hooks/spec-state.cjs +89 -96
  76. package/src/claude/hooks/state.cjs +96 -51
  77. package/src/claude/migration-manifest.json +20 -8
  78. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  79. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  80. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  81. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  82. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  83. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  84. package/src/claude/rules/ai-dev-rules.md +1 -2
  85. package/src/claude/rules/hook-protocols.md +1 -1
  86. package/src/claude/rules/workflow.md +1 -1
  87. package/src/claude/runtime.json +0 -3
  88. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  89. package/src/claude/scripts/provenance.cjs +14 -0
  90. package/src/claude/scripts/spec-receipt.cjs +0 -114
  91. package/src/claude/scripts/spec-resolver.cjs +41 -457
  92. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  93. package/src/claude/settings/settings.json +1 -1
  94. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  95. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  96. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  97. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  98. package/src/claude/skills/code-review/SKILL.md +25 -108
  99. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
  100. package/src/claude/skills/develop/SKILL.md +22 -70
  101. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  102. package/src/claude/skills/git/SKILL.md +1 -1
  103. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  104. package/src/claude/skills/research/SKILL.md +24 -55
  105. package/src/claude/skills/sync/SKILL.md +12 -34
  106. package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
  107. package/src/claude/skills/test/SKILL.md +39 -123
  108. package/src/claude/skills/test/references/execution-strategy.md +11 -22
  109. package/src/claude/skills/ui-ux/SKILL.md +1 -1
  110. package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
  111. package/src/claude/skills/web-testing/SKILL.md +2 -20
  112. package/src/codex/AGENTS.md +8 -0
  113. package/src/codex/hooks/ask-guard.cjs +54 -0
  114. package/src/codex/hooks/inspect-block.cjs +11 -7
  115. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  116. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  117. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  118. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  119. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  120. package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
  121. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  122. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  123. package/src/codex/hooks/lib/spec-utils.cjs +6 -45
  124. package/src/codex/hooks/lib/update-check.cjs +1 -0
  125. package/src/codex/hooks/precompact.cjs +39 -0
  126. package/src/codex/hooks/privacy-approval.cjs +28 -17
  127. package/src/codex/hooks/privacy-block.cjs +9 -215
  128. package/src/codex/hooks/rules.cjs +4 -30
  129. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  130. package/src/codex/hooks/session.cjs +47 -11
  131. package/src/codex/hooks/spec-gate.cjs +32 -74
  132. package/src/codex/hooks/spec-state.cjs +68 -46
  133. package/src/codex/hooks/state.cjs +25 -4
  134. package/src/codex/hooks.json +39 -7
  135. package/src/codex/rules/hook-protocols.md +13 -6
  136. package/src/common/AGENTS.md +4 -10
  137. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  138. package/src/omp/hooks/privacy-block.cjs +24 -206
  139. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  140. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  141. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  142. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  143. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  144. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  145. package/src/claude/hooks/lib/context.cjs +0 -527
  146. package/src/claude/hooks/lib/detect.cjs +0 -474
  147. package/src/claude/scripts/change-firewall.cjs +0 -1748
  148. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  149. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  150. package/src/claude/scripts/spec-final-state.cjs +0 -290
  151. package/src/claude/scripts/spec-ground.cjs +0 -650
  152. package/src/claude/scripts/spec-readiness.cjs +0 -425
  153. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  154. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  155. package/src/claude/scripts/validate-spec-output.cjs +0 -3914
  156. package/src/claude/skills/code-review/references/verification-gate.md +0 -41
  157. package/src/claude/skills/specs/templates/design.md +0 -82
  158. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  159. package/src/claude/skills/specs/templates/requirements.md +0 -42
  160. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  161. package/src/claude/skills/specs/templates/task.md +0 -47
  162. package/src/codex/rules/state-sync.md +0 -60
@@ -5,9 +5,84 @@ const crypto = require('node:crypto');
5
5
  const fs = require('node:fs');
6
6
  const os = require('node:os');
7
7
  const path = require('node:path');
8
- const { spawnSync } = require('node:child_process');
8
+ const { spawnSync, spawn } = require('node:child_process');
9
9
  const { test } = require('node:test');
10
10
 
11
+ const OLD_INSPECTION_MATCHER = 'Bash|exec_command|apply_patch|Edit|Write|Read|Glob|Grep|mcp__.*|shell';
12
+ const NEW_INSPECTION_MATCHER = 'Bash|exec_command|shell|apply_patch|mcp__.*|collaborationspawn_agent|collaborationwait_agent';
13
+ function matcherFixture(run) {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-matcher-'));
15
+ const previous = process.cwd();
16
+ const config = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../src/codex/hooks.json'), 'utf8'));
17
+ const messages = [], warnings = [];
18
+ const ctx = { dryRun: false, results: { updated: 0, unchanged: 0, errors: 0 },
19
+ manifest: require('../../src/claude/migration-manifest.json'),
20
+ ui: { detail: value => messages.push(value), warn: value => warnings.push(value) } };
21
+ const file = path.join(root, '.codex/hooks.json');
22
+ try {
23
+ fs.mkdirSync(path.dirname(file), { recursive: true }); process.chdir(root);
24
+ const merge = require('../phases/codex-hooks.js').mergeCodexHooks;
25
+ run({ root, config, ctx, file, messages, warnings, merge: () => merge(ctx, 'codex', root),
26
+ save: () => fs.writeFileSync(file, JSON.stringify(config)), read: () => JSON.parse(fs.readFileSync(file, 'utf8')) });
27
+ } finally { process.chdir(previous); fs.rmSync(root, { recursive: true, force: true }); }
28
+ }
29
+ test('upgrade refreshes a former managed inspection matcher and reports it', () => matcherFixture(f => {
30
+ f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER; f.save();
31
+ const before = fs.readFileSync(f.file, 'utf8');
32
+ f.ctx.dryRun = true; f.merge();
33
+ assert.equal(fs.readFileSync(f.file, 'utf8'), before);
34
+ assert.ok(f.messages.some(line => /↻.*matcher updated/.test(line)));
35
+ f.ctx.dryRun = false; f.messages.length = 0; f.merge();
36
+ assert.equal(f.read().hooks.PreToolUse[0].matcher, NEW_INSPECTION_MATCHER);
37
+ assert.ok(f.messages.some(line => /↻.*matcher updated/.test(line)));
38
+ assert.ok(!f.messages.some(line => /already registered/.test(line)));
39
+ }));
40
+ test('upgrade preserves a mixed user group and warns', () => matcherFixture(f => {
41
+ f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER;
42
+ f.config.hooks.PreToolUse[0].hooks.push({ type: 'command', command: 'echo user-owned' }); f.save(); f.merge();
43
+ assert.equal(f.read().hooks.PreToolUse[0].matcher, OLD_INSPECTION_MATCHER);
44
+ assert.ok(f.read().hooks.PreToolUse[0].hooks.some(h => h.command === 'echo user-owned'));
45
+ assert.ok(f.warnings.some(line => /matcher.*preserv/i.test(line)));
46
+ }));
47
+ test('reinstall keeps purely user groups unchanged without matcher warnings', () => matcherFixture(f => {
48
+ const userPre = { matcher: 'Bash', hooks: [{ command: 'my-linter' }] };
49
+ const userPost = { hooks: [{ command: 'my-logger' }] };
50
+ f.config.hooks = { PreToolUse: [userPre], PostToolUse: [userPost] }; f.save();
51
+ const installs = Array.from({ length: 2 }, () => {
52
+ const result = spawnSync(process.execPath, [path.resolve(__dirname, '../install.js'), '--platform', 'codex', '-y'], {
53
+ cwd: f.root, encoding: 'utf8',
54
+ });
55
+ return { result, config: f.read() };
56
+ });
57
+ for (const { result, config } of installs) {
58
+ assert.equal(result.status, 0, result.stderr);
59
+ for (const [event, original] of [['PreToolUse', userPre], ['PostToolUse', userPost]]) {
60
+ const group = config.hooks[event].find(g => g.hooks.some(h => h.command === original.hooks[0].command));
61
+ assert.equal(JSON.stringify(group), JSON.stringify(original));
62
+ }
63
+ assert.doesNotMatch(result.stdout + result.stderr, /matcher preserved/);
64
+ }
65
+ }));
66
+ test('fresh hook registration uses every template matcher', () => matcherFixture(f => {
67
+ f.merge();
68
+ for (const [event, groups] of Object.entries(f.config.hooks)) {
69
+ assert.deepEqual(f.read().hooks[event].map(g => g.matcher), groups.map(g => g.matcher));
70
+ }
71
+ }));
72
+ test('upgrade preserves a user-selected managed matcher and warns', () => matcherFixture(f => {
73
+ f.config.hooks.PreToolUse[0].matcher = 'Read|Grep'; f.save(); f.merge();
74
+ assert.equal(f.read().hooks.PreToolUse[0].matcher, 'Read|Grep');
75
+ assert.ok(f.warnings.some(line => /matcher.*preserv/i.test(line)));
76
+ }));
77
+ test('upgrade refreshes the surviving group after retired hooks are pruned', () => matcherFixture(f => {
78
+ f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER;
79
+ f.config.hooks.PreToolUse[0].hooks.push({ type: 'command', command: 'node ".codex/hooks/task-scaffold-guard.cjs"' });
80
+ f.save(); f.merge();
81
+ assert.equal(f.read().hooks.PreToolUse[0].matcher, NEW_INSPECTION_MATCHER);
82
+ assert.ok(!JSON.stringify(f.read()).includes('task-scaffold-guard'));
83
+ assert.ok(f.messages.some(line => /matcher updated/.test(line)));
84
+ }));
85
+
11
86
  const PACKAGE_ROOT = path.join(__dirname, '../..');
12
87
  const CODEX_HOOKS = path.join(PACKAGE_ROOT, 'src/codex/hooks');
13
88
  const PROVENANCE = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'));
@@ -19,10 +94,6 @@ const { stateDir: codexStateDir } = require(
19
94
  );
20
95
 
21
96
  function runHook(file, cwd, payload) {
22
- if (path.basename(file) === 'spec-gate.cjs') {
23
- const rootResult = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
24
- if (rootResult.status === 0) installFeatureReceipts(rootResult.stdout.trim(), payload.session_id || 'session-a');
25
- }
26
97
  return spawnSync(process.execPath, [file], {
27
98
  cwd,
28
99
  input: JSON.stringify(payload),
@@ -34,7 +105,7 @@ function runtimeContext(root, feature = 'auth', session = 'session-a') {
34
105
  return PROVENANCE.deriveRuntimeContext({
35
106
  projectRoot: root,
36
107
  specsRoot: path.join(root, 'specs'),
37
- specFile: path.join(root, 'specs', feature, 'spec.json'),
108
+ specFile: path.join(root, 'specs', feature, 'plan.md'),
38
109
  featureName: feature,
39
110
  runtimeSession: session,
40
111
  });
@@ -55,45 +126,41 @@ function workflowRuntimeContext(root, feature = 'auth', session = 'session-a') {
55
126
  });
56
127
  }
57
128
 
58
- function installFeatureReceipts(root, session = 'session-a') {
59
- const specsRoot = path.join(root, 'specs');
60
- if (!fs.existsSync(specsRoot)) return;
61
- for (const feature of fs.readdirSync(specsRoot)) {
62
- const featureDir = path.join(specsRoot, feature);
63
- const specFile = path.join(featureDir, 'spec.json');
64
- if (!fs.existsSync(specFile)) continue;
65
- let spec;
66
- try { spec = JSON.parse(fs.readFileSync(specFile, 'utf8')); } catch { continue; }
67
- const lifecyclePhase = spec.current_phase || spec.phase;
68
- const explicitCloseout = ['completed', 'complete'].includes(spec.status)
69
- || ['closeout', 'completion', 'completed', 'complete'].includes(lifecyclePhase);
70
- if (!explicitCloseout) continue;
71
- const tasks = Object.values(spec.task_registry || {});
72
- if (tasks.length === 0 || tasks.some((task) => task.status !== 'done')) continue;
73
- const context = runtimeContext(root, feature, session);
74
- fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), [
75
- `Feature: ${feature}`,
76
- 'Expected: final integration verification passes',
77
- 'Observed: final integration verification passed',
78
- 'Verification: PASS',
79
- 'Command: node --test',
80
- 'Exit: 0',
81
- `Base: ${context.base}`,
82
- `Head: ${context.head}`,
83
- '',
84
- ].join('\n'));
85
- }
129
+ /** A process-first packet with one flat task whose Verification Plan runs `command`. */
130
+ function writeProcessTask(featureDir, status, receiptLines = null, command = 'pnpm test', taskName = 'task-01-auth.md') {
131
+ fs.mkdirSync(featureDir, { recursive: true });
132
+ fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
133
+ const lines = [
134
+ '# Task 01', '', `Status: ${status}`, '',
135
+ '## Dependencies', '', '- none', '',
136
+ '## Verification Plan', '', `- Command: ${command}`, '',
137
+ ];
138
+ if (receiptLines) lines.push('## Receipt', '', ...receiptLines, '');
139
+ fs.writeFileSync(path.join(featureDir, taskName), lines.join('\n'));
140
+ return path.join(featureDir, taskName);
86
141
  }
87
142
 
88
143
  function inHookFixture(run, options = {}) {
89
144
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-hooks-'));
90
145
  const hooks = path.join(root, '.codex', 'hooks');
146
+ let deferred = false;
91
147
  try {
92
148
  fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
149
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/spec-touch.cjs'), path.join(hooks, 'lib/spec-touch.cjs'));
150
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/secret-keywords.cjs'), path.join(hooks, 'lib/secret-keywords.cjs'));
151
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/update-check.cjs'), path.join(hooks, 'lib/update-check.cjs'));
152
+ fs.copyFileSync(
153
+ path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/inspect-paths.cjs'),
154
+ path.join(hooks, 'lib/inspect-paths.cjs'),
155
+ );
93
156
  fs.copyFileSync(
94
157
  path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-command-analysis.cjs'),
95
158
  path.join(hooks, 'lib/privacy-command-analysis.cjs'),
96
159
  );
160
+ fs.copyFileSync(
161
+ path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-paths.cjs'),
162
+ path.join(hooks, 'lib/privacy-paths.cjs'),
163
+ );
97
164
  fs.copyFileSync(
98
165
  path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
99
166
  path.join(hooks, 'lib/runtime-path-safety.cjs'),
@@ -121,20 +188,19 @@ function inHookFixture(run, options = {}) {
121
188
  path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
122
189
  path.join(root, '.codex', 'scripts', 'spec-resolver.cjs'),
123
190
  );
124
- for (const file of ['validate-spec-output.cjs', 'spec-ground.cjs', 'spec-semantic-model.cjs', 'spec-final-state.cjs']) {
125
- fs.copyFileSync(
126
- path.join(PACKAGE_ROOT, 'src/claude/scripts', file),
127
- path.join(root, '.codex', 'scripts', file),
128
- );
129
- }
130
191
  fs.writeFileSync(path.join(root, '.codex', 'scripts', 'spec-scaffold.cjs'), '');
131
192
  for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
132
193
  const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
133
194
  assert.equal(result.status, 0, result.stderr);
134
195
  }
135
- return run(root, hooks);
196
+ const result = run(root, hooks);
197
+ if (result && typeof result.then === 'function') {
198
+ deferred = true;
199
+ return result.finally(() => fs.rmSync(root, { recursive: true, force: true }));
200
+ }
201
+ return result;
136
202
  } finally {
137
- fs.rmSync(root, { recursive: true, force: true });
203
+ if (!deferred) fs.rmSync(root, { recursive: true, force: true });
138
204
  }
139
205
  }
140
206
 
@@ -146,6 +212,116 @@ function processTaskContent(title, status, dependency = 'none') {
146
212
  ].join('\n');
147
213
  }
148
214
 
215
+ function inNoCommitCodexFixture(run) {
216
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-no-commit-'));
217
+ const hooks = path.join(root, '.codex/hooks');
218
+ try {
219
+ fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
220
+ for (const name of ['privacy-paths.cjs', 'privacy-command-analysis.cjs']) {
221
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib', name), path.join(hooks, 'lib', name));
222
+ }
223
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/spec-touch.cjs'), path.join(hooks, 'lib/spec-touch.cjs'));
224
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/secret-keywords.cjs'), path.join(hooks, 'lib/secret-keywords.cjs'));
225
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/update-check.cjs'), path.join(hooks, 'lib/update-check.cjs'));
226
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
227
+ path.join(hooks, 'lib/runtime-path-safety.cjs'));
228
+ const scripts = path.join(root, '.codex/scripts');
229
+ fs.mkdirSync(scripts, { recursive: true });
230
+ for (const name of ['workflow-policy.cjs', 'provenance.cjs', 'spec-receipt.cjs', 'spec-resolver.cjs']) {
231
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts', name), path.join(scripts, name));
232
+ }
233
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test']]) {
234
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
235
+ assert.equal(result.status, 0, result.stderr);
236
+ }
237
+ const writePacket = (feature, status) => {
238
+ const dir = path.join(root, 'specs', feature);
239
+ fs.mkdirSync(dir, { recursive: true });
240
+ fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
241
+ fs.writeFileSync(path.join(dir, 'task-01-work.md'), processTaskContent('Task 01: work', status)
242
+ + (status === 'done' ? '\n## Receipt\n\nVerification: PASS\n' : ''));
243
+ };
244
+ const invoke = (hook, event) => runHook(path.join(hooks, `${hook}.cjs`), root, {
245
+ cwd: root, session_id: 'no-commit-codex', hook_event_name: event, prompt: 'Continue specs/auth',
246
+ });
247
+ run({ writePacket, gate: () => invoke('spec-gate', 'Stop'), state: () => invoke('spec-state', 'UserPromptSubmit') });
248
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
249
+ }
250
+
251
+ test('Codex collaborationspawn_agent appears as the latest tracked tool on resume', () => {
252
+ inHookFixture((root, hooks) => {
253
+ const state = path.join(hooks, 'state.cjs');
254
+ const session = { cwd: root, session_id: 'spawn-tracking' };
255
+ const tracked = runHook(state, root, {
256
+ ...session, hook_event_name: 'PostToolUse', tool_name: 'collaborationspawn_agent',
257
+ tool_input: { message: 'Inspect the parser behavior' },
258
+ });
259
+ assert.equal(tracked.status, 0, tracked.stderr);
260
+ const resumed = runHook(state, root, { ...session, hook_event_name: 'SessionStart', source: 'resume' });
261
+ assert.equal(resumed.status, 0, resumed.stderr);
262
+ assert.match(resumed.stdout, /Latest Tracked Tool/);
263
+ assert.match(resumed.stdout, /collaborationspawn_agent/);
264
+ });
265
+ });
266
+
267
+ test('Codex collaborationspawn_agent input does not create a pending todo', () => {
268
+ inHookFixture((root, hooks) => {
269
+ const state = path.join(hooks, 'state.cjs');
270
+ const session = { cwd: root, session_id: 'spawn-no-todo' };
271
+ const title = 'Inspect parser without creating a todo';
272
+ const tracked = runHook(state, root, {
273
+ ...session, hook_event_name: 'PostToolUse', tool_name: 'collaborationspawn_agent',
274
+ tool_input: { id: 'spawn-child', title },
275
+ });
276
+ assert.equal(tracked.status, 0, tracked.stderr);
277
+ const resumed = runHook(state, root, { ...session, hook_event_name: 'SessionStart', source: 'resume' });
278
+ assert.equal(resumed.status, 0, resumed.stderr);
279
+ assert.match(resumed.stdout, /Latest Tracked Tool/);
280
+ assert.match(resumed.stdout, /collaborationspawn_agent/);
281
+ const pending = resumed.stdout.match(/## What's Left\n([\s\S]*?)(?=\n## |$)/);
282
+ assert.ok(pending, 'the pending task section must be present');
283
+ assert.ok(!pending[1].includes(title), 'spawn metadata must not become a pending task');
284
+ });
285
+ });
286
+
287
+ test('Codex no commits allows Stop without done tasks and prints one reminder', () => {
288
+ inNoCommitCodexFixture(({ writePacket, gate, state }) => {
289
+ writePacket('auth', 'in_progress');
290
+ const stopped = gate();
291
+ assert.equal(stopped.status, 0, stopped.stderr);
292
+ assert.equal(stopped.stdout.trim(), '');
293
+ const prompted = state();
294
+ assert.equal(prompted.status, 0, prompted.stderr);
295
+ assert.match(prompted.stdout, /no commits/);
296
+ assert.equal(prompted.stdout.trim().split('\n').length, 1);
297
+ assert.doesNotMatch(prompted.stdout, /Spec tollgate unavailable/);
298
+ });
299
+ });
300
+
301
+ test('Codex no commits blocks a done task with first commit guidance', () => {
302
+ inNoCommitCodexFixture(({ writePacket, gate }) => {
303
+ writePacket('auth', 'done');
304
+ const result = gate();
305
+ assert.equal(result.status, 0, result.stderr);
306
+ const body = JSON.parse(result.stdout);
307
+ assert.equal(body.decision, 'block');
308
+ assert.match(body.reason, /first commit/);
309
+ assert.doesNotMatch(body.reason, /controlled failure/);
310
+ });
311
+ });
312
+
313
+ test('Codex no commits blocks two completed packets with first commit guidance', () => {
314
+ inNoCommitCodexFixture(({ writePacket, gate }) => {
315
+ for (const feature of ['auth', 'billing']) writePacket(feature, 'done');
316
+ const result = gate();
317
+ assert.equal(result.status, 0, result.stderr);
318
+ const body = JSON.parse(result.stdout);
319
+ assert.equal(body.decision, 'block');
320
+ assert.match(body.reason, /first commit/);
321
+ assert.doesNotMatch(body.reason, /controlled failure/);
322
+ });
323
+ });
324
+
149
325
  function canonicalProcessTask(root, title, dependency = 'none') {
150
326
  const context = workflowRuntimeContext(root, 'auth', 'session-auth-state');
151
327
  return [
@@ -245,6 +421,138 @@ function inInstalledProcessSpecStateFixture(run) {
245
421
  });
246
422
  }
247
423
 
424
+ function writeCodexLegacyPacket(root, name = 'old') {
425
+ const dir = path.join(root, 'specs', name);
426
+ fs.mkdirSync(path.join(dir, 'tasks'), { recursive: true });
427
+ fs.writeFileSync(path.join(dir, 'spec.json'), JSON.stringify({
428
+ feature_name: name,
429
+ status: 'in_progress',
430
+ task_registry: { 'tasks/task-R1-01-x.md': { status: 'done' } },
431
+ }));
432
+ fs.writeFileSync(path.join(dir, 'tasks', 'task-R1-01-x.md'), '# Task\n\n**Status:** done\n');
433
+ }
434
+
435
+ const CODEX_LEGACY_NOTICE = /`specs\/old` is a legacy Specs packet without plan\.md; CafeKit no longer reads it/g;
436
+
437
+ test('Codex spec-state notices a leftover legacy packet once per session, also when it is the only packet', () => {
438
+ inHookFixture((root, hooks) => {
439
+ writeCodexLegacyPacket(root);
440
+ const prompt = (session) => runHook(path.join(hooks, 'spec-state.cjs'), root, {
441
+ cwd: root, session_id: session, hook_event_name: 'UserPromptSubmit', prompt: 'Continue',
442
+ });
443
+ const first = prompt('session-a');
444
+ assert.equal(first.status, 0, first.stderr);
445
+ assert.equal((first.stdout.match(CODEX_LEGACY_NOTICE) || []).length, 1);
446
+ assert.equal((prompt('session-a').stdout.match(CODEX_LEGACY_NOTICE) || []).length, 0, 'the same session is not told twice');
447
+ assert.equal((prompt('session-b').stdout.match(CODEX_LEGACY_NOTICE) || []).length, 1, 'a new session is told once');
448
+ });
449
+ });
450
+
451
+ test('Codex Stop ignores a leftover legacy packet and still blocks a malformed process-first packet', () => {
452
+ inHookFixture((root, hooks) => {
453
+ const gate = path.join(hooks, 'spec-gate.cjs');
454
+ const payload = { cwd: root, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
455
+ writeCodexLegacyPacket(root);
456
+ const legacyOnly = runHook(gate, root, payload);
457
+ assert.equal(legacyOnly.status, 0, legacyOnly.stderr);
458
+ assert.equal(legacyOnly.stdout, '', 'a legacy packet alone never blocks');
459
+
460
+ const shared = path.join(root, 'specs', '_shared');
461
+ fs.mkdirSync(shared, { recursive: true });
462
+ fs.writeFileSync(path.join(shared, 'active-feature.json'), '{"featureName": "old"}\n');
463
+ assert.equal(runHook(gate, root, payload).stdout, '', 'a recorded legacy packet never blocks');
464
+ fs.rmSync(shared, { recursive: true, force: true });
465
+
466
+ const feature = path.join(root, 'specs', 'cycle');
467
+ writeProcessTask(feature, 'done', null, 'pnpm test', 'task-01-a.md');
468
+ writeProcessTask(feature, 'pending', null, 'pnpm test', 'task-02-b.md');
469
+ for (const [name, dependency] of [['task-01-a.md', 'task-02-b.md'], ['task-02-b.md', 'task-01-a.md']]) {
470
+ const file = path.join(feature, name);
471
+ fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('- none', `- ${dependency}`));
472
+ }
473
+ const cycle = runHook(gate, root, payload);
474
+ assert.equal(cycle.status, 0, cycle.stderr);
475
+ const block = JSON.parse(cycle.stdout);
476
+ assert.equal(block.decision, 'block');
477
+ assert.match(block.reason, /invalid workflow packet/);
478
+ });
479
+ });
480
+
481
+ test('Codex Stop still blocks a recorded missing packet and reads a plan.md + spec.json packet as process-first', () => {
482
+ inHookFixture((root, hooks) => {
483
+ const gate = path.join(hooks, 'spec-gate.cjs');
484
+ const payload = { cwd: root, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
485
+ const feature = path.join(root, 'specs', 'auth');
486
+ writeProcessTask(feature, 'done');
487
+ fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ feature_name: 'auth' }));
488
+ const hybrid = runHook(gate, root, payload);
489
+ assert.equal(hybrid.status, 0, hybrid.stderr);
490
+ const hybridBlock = JSON.parse(hybrid.stdout);
491
+ assert.equal(hybridBlock.decision, 'block');
492
+ assert.match(hybridBlock.reason, /task-01-auth\.md/);
493
+
494
+ const shared = path.join(root, 'specs', '_shared');
495
+ fs.mkdirSync(shared, { recursive: true });
496
+ fs.writeFileSync(path.join(shared, 'active-feature.json'), '{"featureName": "nope"}\n');
497
+ const missing = runHook(gate, root, payload);
498
+ assert.equal(missing.status, 0, missing.stderr);
499
+ const missingBlock = JSON.parse(missing.stdout);
500
+ assert.equal(missingBlock.decision, 'block');
501
+ assert.match(missingBlock.reason, /explicit_not_found/);
502
+ });
503
+ });
504
+
505
+ test('Codex installed spec-state uses one snapshot for done task receipts', (t) => {
506
+ inInstalledProcessSpecStateFixture(({ feature, hooks, root, writeTask }) => {
507
+ const trace = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-snapshot-count-'));
508
+ try {
509
+ const tasks = ['task-01-work.md', 'task-02-work.md'];
510
+ for (const name of tasks) writeTask(name, name, 'done');
511
+ writeTask('task-03-work.md', 'Task 03: work', 'in_progress');
512
+ const scripts = path.join(root, '.codex/scripts');
513
+ const provenance = require(path.join(scripts, 'provenance.cjs'));
514
+ const policy = require(path.join(scripts, 'workflow-policy.cjs'));
515
+ const receipt = require(path.join(scripts, 'spec-receipt.cjs'));
516
+ const context = provenance.deriveRuntimeContext({ projectRoot: root,
517
+ specsRoot: path.join(root, 'specs'), specFile: path.join(feature, 'plan.md'),
518
+ featureName: 'auth', runtimeSession: 'one-snapshot' });
519
+ for (const name of tasks) {
520
+ fs.appendFileSync(path.join(feature, name), [
521
+ '\n## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
522
+ `Base: ${context.base}`, `Head: ${context.head}`, '```text', '1 test passed', '```', '',
523
+ ].join('\n'));
524
+ assert.deepEqual(receipt.checkWorkflowTaskReceipt(feature, name, context, policy).failures, []);
525
+ }
526
+ const countFile = path.join(trace, 'git-status.txt');
527
+ const preload = path.join(trace, 'count.cjs');
528
+ fs.writeFileSync(preload, `
529
+ const cp = require('node:child_process');
530
+ const fs = require('node:fs');
531
+ const path = require('node:path');
532
+ for (const method of ['spawnSync', 'execFileSync']) {
533
+ const original = cp[method];
534
+ cp[method] = function(command, args, ...rest) {
535
+ if (path.basename(String(command)) === 'git' && Array.isArray(args) && args.includes('status'))
536
+ fs.appendFileSync(${JSON.stringify(countFile)}, 'status\\n');
537
+ return original.call(this, command, args, ...rest);
538
+ };
539
+ }
540
+ `);
541
+ const result = spawnSync(process.execPath, [path.join(hooks, 'spec-state.cjs')], {
542
+ cwd: root, encoding: 'utf8',
543
+ env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --require ${JSON.stringify(preload)}` },
544
+ input: JSON.stringify({ cwd: root, featureName: 'auth', hook_event_name: 'UserPromptSubmit',
545
+ session_id: 'one-snapshot', prompt: 'Continue specs/auth' }),
546
+ });
547
+ assert.equal(result.status, 0, result.stderr);
548
+ assert.match(result.stdout, /Spec state changed/);
549
+ const count = fs.readFileSync(countFile, 'utf8').trim().split('\n').length;
550
+ t.diagnostic(`Codex git status calls: ${count}`);
551
+ assert.ok(count > 0 && count <= 2, `git status calls: ${count}; expected at most 2`);
552
+ } finally { fs.rmSync(trace, { recursive: true, force: true }); }
553
+ });
554
+ });
555
+
248
556
  test('Codex installed spec-state re-evaluates a standalone task when blocked becomes pending', () => {
249
557
  inInstalledProcessSpecStateFixture(({ findCacheFile, hooks, runState, writeTask }) => {
250
558
  writeTask('task-01-auth.md', 'Task 01: auth', 'blocked');
@@ -401,6 +709,503 @@ test('Codex resolver accepts unique legacy task numbers, rejects ambiguity, and
401
709
  });
402
710
  });
403
711
 
712
+ test('Codex inspect still denies blocked reads when the configured specs path escapes', () => {
713
+ inHookFixture((root, hooks) => {
714
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
715
+ const result = runHook(path.join(hooks, 'inspect-block.cjs'), root, {
716
+ cwd: root, hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: 'dist/index.html' },
717
+ });
718
+ assert.equal(result.status, 2);
719
+ assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
720
+ });
721
+ });
722
+
723
+ test('Codex rules still inject when the configured specs path escapes', () => {
724
+ inHookFixture((root, hooks) => {
725
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
726
+ const result = runHook(path.join(hooks, 'rules.cjs'), root, {
727
+ cwd: root, hook_event_name: 'UserPromptSubmit', session_id: 'invalid-specs-rules',
728
+ });
729
+ assert.equal(result.status, 0);
730
+ assert.match(result.stdout, /## Rules/);
731
+ assert.ok(result.stdout.includes(`${fs.realpathSync(root)}/specs/`));
732
+ });
733
+ });
734
+
735
+ test('Codex rules wait for runtime before reserving the same session', () => {
736
+ inHookFixture((root, hooks) => {
737
+ const file = path.join(root, '.codex/runtime.json');
738
+ const payload = { cwd: root, hook_event_name: 'UserPromptSubmit', session_id: 'waiting-for-runtime' };
739
+ const hook = path.join(hooks, 'rules.cjs');
740
+ assert.equal(fs.existsSync(file), false);
741
+ const absent = runHook(hook, root, payload);
742
+ assert.equal(absent.status, 0);
743
+ assert.equal(absent.stdout, '');
744
+ fs.writeFileSync(file, JSON.stringify({ paths: { specs: 'specs' } }));
745
+ const present = runHook(hook, root, payload);
746
+ assert.equal(present.status, 0);
747
+ assert.match(present.stdout, /## Rules/);
748
+ assert.equal(runHook(hook, root, payload).stdout, '');
749
+ });
750
+ });
751
+
752
+ test('Codex secret guardrail runs from source with its shared keyword shim', () => {
753
+ const env = { ...process.env };
754
+ delete env.CLAUDE_PROJECT_DIR;
755
+ delete env.PROJECT_ROOT;
756
+ const result = spawnSync(process.execPath, [path.join(CODEX_HOOKS, 'secret-output-guardrail.cjs')], {
757
+ cwd: PACKAGE_ROOT, env, encoding: 'utf8', input: JSON.stringify({ prompt: 'Explain secret handling.' }),
758
+ });
759
+ assert.equal(result.status, 0);
760
+ assert.equal(result.stderr, '');
761
+ assert.match(result.stdout, /## Secret handling/);
762
+ });
763
+
764
+ test('Codex secret guardrail fails open without a keyword library or a stack', () => {
765
+ inHookFixture((root, hooks) => {
766
+ fs.unlinkSync(path.join(hooks, 'lib/secret-keywords.cjs'));
767
+ const result = runHook(path.join(hooks, 'secret-output-guardrail.cjs'), root, { prompt: 'Explain secret handling.' });
768
+ assert.equal(result.status, 0);
769
+ assert.equal(result.stdout, '');
770
+ assert.equal(result.stderr, '');
771
+ const rows = fs.readFileSync(path.join(hooks, '.logs/hook-log.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
772
+ assert.equal(rows.length, 1);
773
+ assert.equal(rows[0].hook, 'secret-output-guardrail');
774
+ });
775
+ });
776
+
777
+ for (const invalidSpecs of [false, true]) {
778
+ test(`Codex compact warns about pending confirmations${invalidSpecs ? ' with an invalid specs path' : ''}`, () => {
779
+ inHookFixture((root, hooks) => {
780
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: invalidSpecs ? '../outside' : 'specs' } }));
781
+ const result = runHook(path.join(hooks, 'session.cjs'), root, {
782
+ cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session',
783
+ });
784
+ assert.equal(result.status, 0);
785
+ assert.match(result.stdout, /Pending.*(?:confirmation|approval)/i);
786
+ assert.match(result.stdout, /(?:request|ask).*?(?:fresh|again|user)/i);
787
+ });
788
+ });
789
+ }
790
+
791
+ test('Codex compact clears its pending privacy request even with an invalid specs path', () => {
792
+ inHookFixture((root, hooks) => {
793
+ const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
794
+ privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'compact-session', filePaths: ['.env'] });
795
+ const dir = path.join(hooks, '.privacy');
796
+ const pending = () => fs.readdirSync(dir).filter(name => name.startsWith('pending-'));
797
+ assert.equal(pending().length, 1);
798
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
799
+ const result = runHook(path.join(hooks, 'session.cjs'), root, {
800
+ cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session',
801
+ });
802
+ assert.equal(result.status, 0);
803
+ assert.equal(pending().length, 0);
804
+ });
805
+ });
806
+
807
+ test('Codex compact releases only its own rules reservation', () => {
808
+ inHookFixture((root, hooks) => {
809
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), '{}');
810
+ const prompt = session_id => runHook(path.join(hooks, 'rules.cjs'), root, { cwd: root, hook_event_name: 'UserPromptSubmit', session_id });
811
+ for (const id of ['session-s', 'session-t']) {
812
+ assert.match(prompt(id).stdout, /## Rules/);
813
+ assert.equal(prompt(id).stdout, '');
814
+ }
815
+ const compact = runHook(path.join(hooks, 'session.cjs'), root, {
816
+ cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-s',
817
+ });
818
+ assert.equal(compact.status, 0);
819
+ assert.match(prompt('session-s').stdout, /## Rules/);
820
+ assert.equal(prompt('session-t').stdout, '');
821
+ });
822
+ });
823
+
824
+ test('Codex precompact ignores empty stdin without recording a crash', () => {
825
+ inHookFixture((root, hooks) => {
826
+ const result = spawnSync(process.execPath, [path.join(hooks, 'precompact.cjs')], {
827
+ cwd: root, input: '', encoding: 'utf8',
828
+ });
829
+ assert.equal(result.status, 0);
830
+ assert.equal(result.stderr, '');
831
+ const file = path.join(hooks, '.logs/hook-log.jsonl');
832
+ const rows = fs.existsSync(file)
833
+ ? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
834
+ : [];
835
+ assert.equal(rows.filter(row => row.hook === 'precompact' && row.status === 'crash').length, 0);
836
+ });
837
+ });
838
+
839
+ test('Codex precompact anchors are restored only to the matching session', () => {
840
+ inHookFixture((root, hooks) => {
841
+ const head = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim();
842
+ const status = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
843
+ assert.equal(status.status, 0);
844
+ const dirty = status.stdout.trim().split('\n').filter(Boolean).length;
845
+ const capture = runHook(path.join(hooks, 'precompact.cjs'), root, {
846
+ cwd: root, hook_event_name: 'PreCompact', trigger: 'auto', session_id: 'session-s',
847
+ });
848
+ assert.equal(capture.status, 0, capture.stderr);
849
+ const restart = session_id => runHook(path.join(hooks, 'session.cjs'), root, {
850
+ cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id,
851
+ });
852
+ const own = restart('session-s');
853
+ assert.equal(own.status, 0);
854
+ assert.match(own.stdout, /State before compaction/);
855
+ assert.ok(own.stdout.includes(`HEAD ${head}`));
856
+ assert.ok(own.stdout.includes(`${dirty} uncommitted path(s)`));
857
+ const other = restart('session-t');
858
+ assert.equal(other.status, 0);
859
+ assert.match(other.stdout, /Pending/i);
860
+ assert.doesNotMatch(other.stdout, /State before compaction/);
861
+ });
862
+ });
863
+
864
+ function sessionWithoutUpdateNetwork(root, hooks, session_id) {
865
+ const marker = path.join(root, 'update-network-attempted');
866
+ const preload = path.join(root, 'block-update-network.cjs');
867
+ fs.writeFileSync(preload, `require('https').get = () => {
868
+ require('fs').writeFileSync(${JSON.stringify(marker)}, 'attempted');
869
+ throw new Error('network disabled in update test');
870
+ };\n`);
871
+ const result = spawnSync(process.execPath, ['--require', preload, path.join(hooks, 'session.cjs')], {
872
+ cwd: root, encoding: 'utf8', input: JSON.stringify({ cwd: root, hook_event_name: 'SessionStart', source: 'startup', session_id }),
873
+ });
874
+ assert.equal(fs.existsSync(marker), false, 'the cached update check must not attempt a network request');
875
+ return result;
876
+ }
877
+
878
+ test('Codex SessionStart prints a newer cached CafeKit version', () => {
879
+ inHookFixture((root, hooks) => {
880
+ fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
881
+ fs.writeFileSync(path.join(root, '.codex/.cafekit-update-cache.json'), JSON.stringify({ ts: Date.now(), latest: '1.3.0' }));
882
+ const result = sessionWithoutUpdateNetwork(root, hooks, 'cached-update');
883
+ assert.equal(result.status, 0);
884
+ assert.ok(result.stdout.includes('\n⚡ CafeKit update available: 1.2.3 → 1.3.0\n Run: npx @haposoft/cafekit\n'));
885
+ });
886
+ });
887
+
888
+ test('Codex SessionStart stays silent about an equal cached CafeKit version', () => {
889
+ inHookFixture((root, hooks) => {
890
+ fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
891
+ fs.writeFileSync(path.join(root, '.codex/.cafekit-update-cache.json'), JSON.stringify({ ts: Date.now(), latest: '1.2.3' }));
892
+ const result = sessionWithoutUpdateNetwork(root, hooks, 'equal-update');
893
+ assert.equal(result.status, 0);
894
+ assert.doesNotMatch(result.stdout, /CafeKit update available/);
895
+ });
896
+ });
897
+
898
+ test('Codex update failure still clears pending privacy for the session', () => {
899
+ inHookFixture((root, hooks) => {
900
+ const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
901
+ privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'failed-update', filePaths: ['.env'] });
902
+ fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
903
+ fs.writeFileSync(path.join(hooks, 'lib/update-check.cjs'), "module.exports = { checkCafeKitUpdate() { throw new Error('synthetic update failure'); } };\n");
904
+ const result = sessionWithoutUpdateNetwork(root, hooks, 'failed-update');
905
+ assert.equal(result.status, 0);
906
+ assert.equal(result.stderr, '');
907
+ const pending = fs.readdirSync(path.join(hooks, '.privacy')).filter(name => name.startsWith('pending-'));
908
+ assert.equal(pending.length, 0);
909
+ });
910
+ });
911
+
912
+ test('Codex Stop describes an escaping specs root as an invalid workflow packet', () => {
913
+ inHookFixture((root, hooks) => {
914
+ fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
915
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
916
+ cwd: root, session_id: 'invalid-root-message', hook_event_name: 'Stop', stop_hook_active: false,
917
+ });
918
+ assert.equal(result.status, 0);
919
+ const output = JSON.parse(result.stdout);
920
+ assert.equal(output.decision, 'block');
921
+ assert.match(output.reason, /^Completion gate: invalid workflow packet detected \(<specs>\):/);
922
+ assert.doesNotMatch(output.reason, /controlled failure/);
923
+ });
924
+ });
925
+
926
+ test('Codex spec-state shows detailed task status counts on its Phase line', () => {
927
+ inInstalledProcessSpecStateFixture(({ writeTask, runState }) => {
928
+ for (const [name, status] of [['task-01-active.md', 'in_progress'], ['task-02-next.md', 'pending'],
929
+ ['task-03-blocked.md', 'blocked'], ['task-04-later.md', 'pending']]) {
930
+ writeTask(name, name, status);
931
+ }
932
+ const result = runState();
933
+ assert.equal(result.status, 0, result.stderr);
934
+ assert.match(result.stdout, /^- Phase: .* \| Tasks: 0 done \/ 4 total \(1 in_progress, 2 pending, 1 blocked\)$/m);
935
+ });
936
+ });
937
+
938
+ function shortPrivacyFixture(check) {
939
+ return inHookFixture((root, hooks) => {
940
+ fs.writeFileSync(path.join(root, '.env'), 'fixture\n');
941
+ fs.writeFileSync(path.join(root, 'credentials.json'), '{}\n');
942
+ const dir = path.join(hooks, '.privacy');
943
+ const session = 'short-approval-session';
944
+ const payload = (file = '.env', session_id = session) => ({ cwd: root, session_id,
945
+ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: `cat ${file}` } });
946
+ const block = (file, id) => runHook(path.join(hooks, 'privacy-block.cjs'), root, payload(file, id));
947
+ const submit = (prompt, session_id = session, env = process.env) => spawnSync(process.execPath,
948
+ [path.join(hooks, 'privacy-approval.cjs')], { cwd: root, encoding: 'utf8', env,
949
+ input: JSON.stringify({ cwd: root, session_id, hook_event_name: 'UserPromptSubmit', prompt }) });
950
+ const files = prefix => fs.existsSync(dir) ? fs.readdirSync(dir).filter(name => name.startsWith(prefix)) : [];
951
+ const id = result => {
952
+ assert.equal(result.status, 0, result.stderr);
953
+ const reason = JSON.parse(result.stdout).hookSpecificOutput.permissionDecisionReason;
954
+ const match = reason.match(/APPROVE CAFEKIT PRIVACY ([a-f0-9]{24})/);
955
+ assert.ok(match, reason);
956
+ return match[1];
957
+ };
958
+ return check({ root, hooks, dir, session, payload, block, submit, files, id });
959
+ });
960
+ }
961
+
962
+ function asyncPrivacyBlock(fixture, file) {
963
+ return new Promise((resolve, reject) => {
964
+ const child = spawn(process.execPath, [path.join(fixture.hooks, 'privacy-block.cjs')], { cwd: fixture.root });
965
+ let stdout = '', stderr = '';
966
+ child.stdout.on('data', chunk => { stdout += chunk; });
967
+ child.stderr.on('data', chunk => { stderr += chunk; });
968
+ child.on('error', reject);
969
+ child.on('close', status => resolve({ status, stdout, stderr }));
970
+ child.stdin.end(JSON.stringify(fixture.payload(file)));
971
+ });
972
+ }
973
+
974
+ for (const [label, prompt] of [['y', 'y'], ['yes', 'yes'], ['title-case Vietnamese', 'Có'],
975
+ ['padded y', ' y '], ['decomposed Vietnamese', 'co\u0301'], ['uppercase Vietnamese', 'CÓ'], ['terminal newline', 'Y\r\n']]) {
976
+ test(`Codex short privacy approval accepts ${label} and reports granted paths`, () => shortPrivacyFixture(f => {
977
+ const denied = f.block();
978
+ const requestId = f.id(denied);
979
+ const sessionHash = crypto.createHash('sha256').update(f.session).digest('hex');
980
+ assert.deepEqual(f.files('short-'), [`short-${sessionHash}-${requestId}.json`]);
981
+ assert.match(denied.stdout, /y \(or yes \/ có\)/);
982
+ const result = f.submit(prompt);
983
+ assert.equal(result.status, 0, result.stderr);
984
+ assert.match(result.stdout, /User approved/);
985
+ assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
986
+ assert.equal(f.files('token-').length, 1);
987
+ assert.equal(f.files('short-').length, 0);
988
+ assert.equal(f.block().stdout, '');
989
+ assert.equal(f.block().stdout, '', 'the one-hour grant remains reusable');
990
+ }));
991
+ }
992
+
993
+ test('Codex short privacy approval is cancelled by an intervening prompt', () => shortPrivacyFixture(f => {
994
+ f.id(f.block());
995
+ assert.equal(f.submit('ok làm đi').status, 0);
996
+ const result = f.submit('y');
997
+ assert.equal(result.status, 0, result.stderr);
998
+ assert.doesNotMatch(result.stdout, /User approved/);
999
+ assert.equal(f.files('token-').length, 0);
1000
+ assert.equal(f.files('short-').length, 0);
1001
+ assert.match(f.block().stdout, /"permissionDecision":"deny"/);
1002
+ }));
1003
+
1004
+ test('Codex short privacy approval stays in its session and requires pending state', () => shortPrivacyFixture(f => {
1005
+ assert.equal(f.submit('y').status, 0);
1006
+ assert.equal(f.files('token-').length, 0);
1007
+ f.id(f.block());
1008
+ assert.doesNotMatch(f.submit('y', 'another-session').stdout, /User approved/);
1009
+ assert.equal(f.files('token-').length, 0);
1010
+ assert.match(f.submit('y').stdout, /User approved/);
1011
+ }));
1012
+
1013
+ for (const [label, prompt] of [['extra Vietnamese text', 'y nhé'], ['extra English text', 'yes please'],
1014
+ ['repeated letter', 'yy'], ['fullwidth letter', 'y'], ['zero-width suffix', 'y\u200b']]) {
1015
+ test(`Codex short privacy approval rejects ${label}`, () => shortPrivacyFixture(f => {
1016
+ f.id(f.block());
1017
+ const result = f.submit(prompt);
1018
+ assert.equal(result.status, 0, result.stderr);
1019
+ assert.doesNotMatch(result.stdout, /User approved/);
1020
+ assert.equal(f.files('token-').length, 0);
1021
+ assert.equal(f.files('short-').length, 0);
1022
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1023
+ }));
1024
+ }
1025
+
1026
+ test('Codex short privacy approval refuses two requests while the long phrase selects one', () => shortPrivacyFixture(f => {
1027
+ const first = f.id(f.block());
1028
+ f.id(f.block('credentials.json'));
1029
+ const result = f.submit('y');
1030
+ assert.equal(result.status, 0, result.stderr);
1031
+ assert.doesNotMatch(result.stdout, /User approved/);
1032
+ assert.equal(f.files('token-').length, 0);
1033
+ assert.equal(f.files('short-').length, 0);
1034
+ const exact = f.submit(`APPROVE CAFEKIT PRIVACY ${first}`);
1035
+ assert.match(exact.stdout, /User approved/);
1036
+ assert.match(JSON.parse(exact.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
1037
+ assert.equal(f.block().stdout, '');
1038
+ assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
1039
+ }));
1040
+
1041
+ test('parallel Codex privacy blocks preserve both markers and cannot be approved by y', () => shortPrivacyFixture(async f => {
1042
+ const results = await Promise.all([asyncPrivacyBlock(f, '.env'), asyncPrivacyBlock(f, 'credentials.json')]);
1043
+ assert.notEqual(f.id(results[0]), f.id(results[1]));
1044
+ assert.equal(f.files('short-').length, 2);
1045
+ assert.equal(f.submit('y').status, 0);
1046
+ assert.equal(f.files('token-').length, 0);
1047
+ assert.equal(f.files('short-').length, 0);
1048
+ assert.match(f.block().stdout, /"permissionDecision":"deny"/);
1049
+ assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
1050
+ }));
1051
+
1052
+ test('repeated Codex blocks for the same path set reuse the request and short marker', () => shortPrivacyFixture(f => {
1053
+ const first = f.id(f.block('.env credentials.json'));
1054
+ assert.equal(f.id(f.block('credentials.json .env')), first);
1055
+ assert.equal(f.files('pending-').length, 1);
1056
+ assert.equal(f.files('short-').length, 1);
1057
+ assert.match(f.submit('y').stdout, /User approved/);
1058
+ assert.equal(f.block('.env credentials.json').stdout, '');
1059
+ assert.match(f.block().stdout, /"permissionDecision":"deny"/);
1060
+ }));
1061
+
1062
+ test('a later Codex block restores the short marker for the same pending request', () => shortPrivacyFixture(f => {
1063
+ const requestId = f.id(f.block());
1064
+ assert.equal(f.submit('ok làm đi').status, 0);
1065
+ assert.equal(f.files('short-').length, 0);
1066
+ assert.equal(f.id(f.block()), requestId);
1067
+ assert.equal(f.files('pending-').length, 1);
1068
+ assert.equal(f.files('short-').length, 1);
1069
+ assert.match(f.submit('y').stdout, /User approved/);
1070
+ assert.equal(f.block().stdout, '');
1071
+ assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
1072
+ }));
1073
+
1074
+ test('an immediate repeated Codex block keeps one short marker without an exclusive-write error', () => shortPrivacyFixture(f => {
1075
+ const requestId = f.id(f.block());
1076
+ assert.equal(f.id(f.block()), requestId);
1077
+ assert.equal(f.files('pending-').length, 1);
1078
+ assert.equal(f.files('short-').length, 1);
1079
+ assert.match(f.submit('y').stdout, /User approved/);
1080
+ assert.equal(f.block().stdout, '');
1081
+ }));
1082
+
1083
+ test('a Codex prompt error after claiming markers cancels the short approval', () => shortPrivacyFixture(f => {
1084
+ f.id(f.block());
1085
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-fault-'));
1086
+ try {
1087
+ const preload = path.join(outside, 'fault.cjs');
1088
+ fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.readdirSync; let calls = 0;\nfs.readdirSync = function(dir, ...args) { if (String(dir) === ${JSON.stringify(fs.realpathSync(f.dir))} && ++calls === 2) throw new Error('injected state read error'); return original.call(this, dir, ...args); };\n`);
1089
+ const failed = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
1090
+ assert.equal(failed.status, 0, failed.stderr);
1091
+ assert.match(failed.stdout, /failed safely/);
1092
+ assert.equal(f.files('short-').length, 0);
1093
+ assert.equal(f.files('token-').length, 0);
1094
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1095
+ } finally { fs.rmSync(outside, { recursive: true, force: true }); }
1096
+ }));
1097
+
1098
+ test('a Codex post-claim error cancels a marker created during prompt processing', () => shortPrivacyFixture(f => {
1099
+ f.id(f.block());
1100
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-post-claim-fault-'));
1101
+ try {
1102
+ const preload = path.join(outside, 'fault.cjs');
1103
+ fs.writeFileSync(preload, `const assert = require('node:assert/strict');
1104
+ const state = require(${JSON.stringify(path.join(f.hooks, 'lib/privacy-state.cjs'))});
1105
+ const claim = state.claimShortMarkers;
1106
+ let claimed = false;
1107
+ state.claimShortMarkers = (...args) => {
1108
+ const result = claim(...args);
1109
+ if (!claimed) { assert.equal(result.ok, true); assert.equal(result.requestIds.length, 1); claimed = true; }
1110
+ return result;
1111
+ };
1112
+ state.cleanup = () => {
1113
+ assert.equal(claimed, true);
1114
+ state.createPending(${JSON.stringify({ projectRoot: f.root, sessionCwd: f.root, sessionId: f.session, filePath: 'credentials.json' })});
1115
+ throw new Error('injected processing error after marker claim');
1116
+ };\n`);
1117
+ const failed = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
1118
+ assert.equal(failed.status, 0, failed.stderr);
1119
+ assert.match(failed.stdout, /failed safely/);
1120
+ assert.equal(f.files('pending-').length, 2);
1121
+ assert.equal(f.files('short-').length, 0);
1122
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1123
+ assert.equal(f.files('token-').length, 0);
1124
+ } finally { fs.rmSync(outside, { recursive: true, force: true }); }
1125
+ }));
1126
+
1127
+ test('a corrupt Codex short marker cannot grant permission even with valid pending state', () => shortPrivacyFixture(f => {
1128
+ f.id(f.block());
1129
+ const markers = f.files('short-');
1130
+ assert.equal(markers.length, 1);
1131
+ fs.writeFileSync(path.join(f.dir, markers[0]), '{');
1132
+ const result = f.submit('y');
1133
+ assert.equal(result.status, 0, result.stderr);
1134
+ assert.doesNotMatch(result.stdout, /User approved/);
1135
+ assert.equal(f.files('token-').length, 0);
1136
+ assert.equal(f.files('short-').length, 0);
1137
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1138
+ }));
1139
+
1140
+ for (const unlinkFailure of [false, true]) {
1141
+ test(`an unclaimable Codex short marker is cancelled after ${unlinkFailure ? 'rename and unlink errors' : 'a rename error'}`, () => shortPrivacyFixture(f => {
1142
+ f.id(f.block());
1143
+ assert.equal(f.files('short-').length, 1);
1144
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-rename-fault-'));
1145
+ try {
1146
+ const preload = path.join(outside, 'fault.cjs');
1147
+ const prefix = JSON.stringify(path.join(fs.realpathSync(f.dir), 'short-'));
1148
+ fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.renameSync;\nfs.renameSync = function(from, to) { if (String(from).startsWith(${prefix})) throw new Error('injected rename failure'); return original.call(this, from, to); };\n`
1149
+ + (unlinkFailure ? `const unlink = fs.unlinkSync; fs.unlinkSync = function(file) { if (String(file).startsWith(${prefix})) throw new Error('injected unlink failure'); return unlink.call(this, file); };\n` : ''));
1150
+ const result = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
1151
+ assert.equal(result.status, 0, result.stderr);
1152
+ assert.doesNotMatch(result.stdout, /User approved/);
1153
+ assert.equal(f.files('token-').length, 0);
1154
+ } finally { fs.rmSync(outside, { recursive: true, force: true }); }
1155
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1156
+ assert.equal(f.files('token-').length, 0);
1157
+ assert.equal(f.files('short-').length, 0);
1158
+ }));
1159
+ }
1160
+
1161
+ test('Codex compact clears pending requests and short markers only for its session', () => shortPrivacyFixture(f => {
1162
+ f.id(f.block());
1163
+ f.id(f.block('credentials.json', 'other-session'));
1164
+ const result = runHook(path.join(f.hooks, 'session.cjs'), f.root,
1165
+ { source: 'compact', session_id: f.session, cwd: f.root, hook_event_name: 'SessionStart' });
1166
+ assert.equal(result.status, 0, result.stderr);
1167
+ const prefix = `short-${crypto.createHash('sha256').update(f.session).digest('hex')}-`;
1168
+ assert.equal(f.files(prefix).length, 0);
1169
+ assert.equal(f.files('short-').length, 1);
1170
+ assert.doesNotMatch(f.submit('y').stdout, /User approved/);
1171
+ assert.equal(f.files('token-').length, 0);
1172
+ assert.match(f.submit('y', 'other-session').stdout, /User approved/);
1173
+ }));
1174
+
1175
+ test('Codex short approval cannot grant an expired pending request', () => shortPrivacyFixture(f => {
1176
+ const id = f.id(f.block());
1177
+ const file = path.join(f.dir, `pending-${id}.json`);
1178
+ const pending = JSON.parse(fs.readFileSync(file, 'utf8'));
1179
+ pending.expiresAt = Date.now() - 1;
1180
+ fs.writeFileSync(file, JSON.stringify(pending));
1181
+ assert.equal(f.submit('y').status, 0);
1182
+ assert.equal(f.files('token-').length, 0);
1183
+ assert.equal(f.files('short-').length, 0);
1184
+ }));
1185
+
1186
+ test('Codex cleanup removes expired short markers', () => shortPrivacyFixture(f => {
1187
+ f.id(f.block());
1188
+ const markers = f.files('short-');
1189
+ assert.equal(markers.length, 1);
1190
+ const file = path.join(f.dir, markers[0]);
1191
+ const marker = JSON.parse(fs.readFileSync(file, 'utf8'));
1192
+ marker.expiresAt = Date.now() - 1;
1193
+ fs.writeFileSync(file, JSON.stringify(marker));
1194
+ require(path.join(f.hooks, 'lib/privacy-state.cjs')).cleanup(f.root);
1195
+ assert.equal(f.files('short-').length, 0);
1196
+ }));
1197
+
1198
+ test('Codex long privacy approval still works after an intervening prompt and reports paths', () => shortPrivacyFixture(f => {
1199
+ const id = f.id(f.block());
1200
+ assert.equal(f.submit('ok làm đi').status, 0);
1201
+ assert.equal(f.files('short-').length, 0);
1202
+ const result = f.submit(`APPROVE CAFEKIT PRIVACY ${id}`);
1203
+ assert.equal(result.status, 0, result.stderr);
1204
+ assert.match(result.stdout, /User approved/);
1205
+ assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
1206
+ assert.equal(f.block().stdout, '');
1207
+ }));
1208
+
404
1209
  test('Codex privacy approval is exact, session-bound, and reusable within its window', () => {
405
1210
  inHookFixture((root, hooks) => {
406
1211
  const nested = path.join(root, 'packages', 'app');
@@ -592,15 +1397,17 @@ test('Codex inspect hook scans native shell commands and structured paths', () =
592
1397
  for (const command of [
593
1398
  'rg secret node_modules',
594
1399
  "sed -n '1,20p' dist/bundle.js",
595
- "rg --glob '**/*' symbol src"
1400
+ "rg --glob '**/*' symbol ."
596
1401
  ]) {
597
1402
  const result = runHook(inspect, root, payload(command));
598
1403
  assert.equal(result.status, 2, command);
599
1404
  assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
600
1405
  }
601
1406
 
602
- const normal = runHook(inspect, root, payload('rg symbol src packages'));
603
- assert.equal(normal.status, 0);
1407
+ for (const command of ['rg symbol src packages', "rg --glob '**/*' symbol src"]) {
1408
+ const normal = runHook(inspect, root, payload(command));
1409
+ assert.equal(normal.status, 0);
1410
+ }
604
1411
 
605
1412
  const structured = runHook(inspect, root, {
606
1413
  ...payload(''),
@@ -615,18 +1422,8 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
615
1422
  inHookFixture((root, hooks) => {
616
1423
  const nested = path.join(root, 'packages', 'app');
617
1424
  const feature = path.join(root, 'specs', 'auth');
618
- const taskPath = 'tasks/task-R0-01-auth.md';
619
- fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
620
1425
  fs.mkdirSync(nested, { recursive: true });
621
- const spec = {
622
- status: 'in_progress',
623
- feature_name: 'auth',
624
- task_registry: {
625
- [taskPath]: { status: 'pending', completed_at: null }
626
- }
627
- };
628
- fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
629
- fs.writeFileSync(path.join(feature, taskPath), 'Status: pending\n');
1426
+ writeProcessTask(feature, 'pending', null, 'node --test');
630
1427
  const gate = path.join(hooks, 'spec-gate.cjs');
631
1428
  const payload = {
632
1429
  cwd: nested,
@@ -640,32 +1437,19 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
640
1437
  assert.equal(seeded.status, 0);
641
1438
  assert.equal(seeded.stdout, '');
642
1439
 
643
- spec.task_registry[taskPath] = {
644
- status: 'done',
645
- completed_at: '2026-07-29T10:00:00.000Z'
646
- };
647
- fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
648
- fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
649
-
650
- const blockedBeforeCloseout = runHook(gate, nested, payload);
651
- assert.equal(blockedBeforeCloseout.status, 0);
652
- assert.equal(JSON.parse(blockedBeforeCloseout.stdout).decision, 'block');
653
- assert.match(JSON.parse(blockedBeforeCloseout.stdout).reason, /verification receipt/);
654
-
655
- fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
656
- 'Status: done', '', '## Evidence', '', 'Verification: PASS',
657
- 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
658
- ].join('\n'), 'auth'));
659
- const validBeforeCloseout = runHook(gate, nested, payload);
660
- assert.equal(validBeforeCloseout.status, 0);
661
- assert.equal(validBeforeCloseout.stdout, '');
662
- assert.equal(fs.existsSync(path.join(feature, 'feature-receipt.md')), false);
663
-
664
- spec.current_phase = 'closeout';
665
- fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
666
- const closeout = runHook(gate, nested, payload);
667
- assert.equal(closeout.status, 0);
668
- assert.equal(closeout.stdout, '');
1440
+ writeProcessTask(feature, 'done', null, 'node --test');
1441
+ const blocked = runHook(gate, nested, payload);
1442
+ assert.equal(blocked.status, 0);
1443
+ assert.equal(JSON.parse(blocked.stdout).decision, 'block');
1444
+ assert.match(JSON.parse(blocked.stdout).reason, /verification receipt/);
1445
+
1446
+ writeProcessTask(feature, 'done', bindReceipt(root, [
1447
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
1448
+ '```', 'node --test', 'ok 1', '```',
1449
+ ].join('\n')).split('\n'), 'node --test');
1450
+ const valid = runHook(gate, nested, payload);
1451
+ assert.equal(valid.status, 0);
1452
+ assert.equal(valid.stdout, '');
669
1453
  });
670
1454
  });
671
1455
 
@@ -765,20 +1549,15 @@ test('Codex process-v3 Receipt command must match the exact Verification Plan co
765
1549
  });
766
1550
 
767
1551
  test('Codex recorded active feature turns the identity block into receipt validation', () => {
768
- // Issue #79 end to end on this runtime. Two legacy packets both claiming closeout make
769
- // the gate answer a missing-receipt violation with an identity complaint whose stated
770
- // remedy nothing wrote. Naming the feature resolves it and the violation surfaces.
1552
+ // Issue #79 end to end on this runtime. Two unfinished packets make the gate answer a
1553
+ // missing-receipt violation with an identity complaint; naming the feature resolves it
1554
+ // and the violation surfaces.
771
1555
  inHookFixture((root, hooks) => {
772
1556
  for (const name of ['alpha', 'beta']) {
773
1557
  const feature = path.join(root, 'specs', name);
774
- fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
775
- fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify({
776
- feature_name: name,
777
- status: 'done',
778
- current_phase: 'closeout',
779
- task_registry: { 'tasks/task-R0-01-x.md': { status: 'done', completed_at: '2026-01-01T00:00:00.000Z' } },
780
- }, null, 2)}\n`);
781
- fs.writeFileSync(path.join(feature, 'tasks', 'task-R0-01-x.md'), '# Task\n\nStatus: done\n');
1558
+ writeProcessTask(feature, 'done', null, 'node --test', 'task-01-x.md');
1559
+ fs.writeFileSync(path.join(feature, 'task-02-y.md'),
1560
+ '# Task 02\n\nStatus: pending\n\n## Dependencies\n\n- none\n\n## Verification Plan\n\n- Command: node --test\n');
782
1561
  }
783
1562
  const payload = { cwd: root, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
784
1563
 
@@ -880,16 +1659,7 @@ test('empty or forged Codex hook authority fails closed', () => {
880
1659
  assert.match(block.reason, /hook payload (is empty|must be a JSON object)/i);
881
1660
  }
882
1661
 
883
- const feature = path.join(root, 'specs', 'auth');
884
- const taskPath = 'tasks/task.md';
885
- fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
886
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
887
- status: 'in_progress',
888
- current_phase: 'closeout',
889
- feature_name: 'auth',
890
- task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
891
- }));
892
- fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
1662
+ writeProcessTask(path.join(root, 'specs', 'auth'), 'done');
893
1663
  fs.writeFileSync(path.join(root, '.codex', 'runtime.json'), JSON.stringify({
894
1664
  spec: { completion_gate: false },
895
1665
  }));
@@ -911,37 +1681,19 @@ test('empty or forged Codex hook authority fails closed', () => {
911
1681
  });
912
1682
  });
913
1683
 
914
- test('explicit Strict Codex completion ignores worker-writable proof strings and remains blocked', () => {
1684
+ test('Codex Stop omits secret-related assistant messages from saved state', () => {
915
1685
  inHookFixture((root, hooks) => {
916
- const feature = path.join(root, 'specs', 'auth');
917
- const taskPath = 'tasks/task.md';
918
- fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
919
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
920
- status: 'in_progress',
921
- current_phase: 'closeout',
922
- feature_name: 'auth',
923
- workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' }),
924
- proofs: {
925
- needsInspection: 'inspection completed',
926
- needsIndependentAudit: 'PASS',
927
- needsResearchGrounding: 'research completed',
928
- },
929
- task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
930
- }));
931
- fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
932
- 'Status: done', '', '## Evidence', '', 'Verification: PASS',
933
- 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
934
- ].join('\n'), 'auth'));
935
- const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
936
- cwd: root,
937
- session_id: 'session-a',
938
- hook_event_name: 'Stop',
939
- stop_hook_active: false,
1686
+ const message = 'API key handling was discussed in this synthetic test.';
1687
+ const result = runHook(path.join(hooks, 'state.cjs'), root, {
1688
+ hook_event_name: 'Stop', session_id: 'secret-summary', last_assistant_message: message,
940
1689
  });
941
- assert.equal(result.status, 0, result.stderr);
942
- const block = JSON.parse(result.stdout);
943
- assert.equal(block.decision, 'block');
944
- assert.match(block.reason, /needsInspection|needsIndependentAudit|needsResearchGrounding/);
1690
+ assert.equal(result.status, 0);
1691
+ const dir = codexStateDir(root, 'secret-summary');
1692
+ for (const name of ['data.json', 'latest.md']) {
1693
+ const content = fs.readFileSync(path.join(dir, name), 'utf8');
1694
+ assert.ok(content.includes('[omitted: possible secret]'));
1695
+ assert.ok(!content.includes(message));
1696
+ }
945
1697
  });
946
1698
  });
947
1699
 
@@ -1053,61 +1805,55 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
1053
1805
  // Integration via spec-gate hook: only Base blocks, both passes
1054
1806
  inHookFixture((root, hooks) => {
1055
1807
  const feature = path.join(root, 'specs', 'auth');
1056
- const taskPath = 'tasks/task-R0-01-auth.md';
1057
- fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
1058
1808
  const gate = path.join(hooks, 'spec-gate.cjs');
1059
1809
  const payload = { cwd: path.join(root, 'packages', 'app'), session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
1810
+ const output = ['```', 'pnpm test', 'ok 1', '```'];
1811
+ fs.mkdirSync(payload.cwd, { recursive: true });
1060
1812
 
1061
1813
  // only Base -> block
1062
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', current_phase: 'closeout', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
1063
- fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
1064
- fs.mkdirSync(payload.cwd, { recursive: true });
1814
+ writeProcessTask(feature, 'done', ['Verification: PASS', 'Command: pnpm test', 'Exit: 0', 'Base: abc', ...output]);
1065
1815
  const blocked = runHook(gate, payload.cwd, payload);
1066
1816
  assert.equal(JSON.parse(blocked.stdout).decision, 'block');
1067
1817
  assert.match(JSON.parse(blocked.stdout).reason, /\bprovenance\b/);
1068
1818
 
1069
1819
  // both -> no block
1070
- 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`));
1820
+ writeProcessTask(feature, 'done', bindReceipt(root, ['Verification: PASS', 'Command: pnpm test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`, ...output].join('\n')).split('\n'));
1071
1821
  const passed = runHook(gate, payload.cwd, payload);
1072
1822
  assert.equal(passed.stdout, '');
1073
1823
 
1074
1824
  // empty Base: should block provenance — reset gate cache so re-checked as newly-done
1075
1825
  try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
1076
- fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
1826
+ writeProcessTask(feature, 'done', ['Verification: PASS', 'Command: pnpm test', 'Exit: 0', 'Base:', 'Head: def', ...output]);
1077
1827
  const blockedEmpty = runHook(gate, payload.cwd, payload);
1078
1828
  assert.equal(JSON.parse(blockedEmpty.stdout).decision, 'block');
1079
1829
  assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bprovenance\b/);
1080
1830
 
1081
1831
  // bare base_sha without colon should block — reset cache again
1082
1832
  try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
1083
- fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha head_sha\n');
1833
+ writeProcessTask(feature, 'done', ['Verification: PASS', 'Command: pnpm test', 'Exit: 0', 'base_sha head_sha', ...output]);
1084
1834
  const blockedBare = runHook(gate, payload.cwd, payload);
1085
1835
  assert.equal(JSON.parse(blockedBare.stdout).decision, 'block');
1086
1836
  assert.match(JSON.parse(blockedBare.stdout).reason, /\bprovenance\b/);
1087
1837
  });
1088
1838
  });
1089
1839
 
1090
- test('Codex cache hardening: every done re-validated — mutation, deletion, malformed cache, unchanged valid', () => {
1840
+ test('Codex cache hardening: every done re-validated — mutation, malformed cache, unchanged valid', () => {
1091
1841
  inHookFixture((root, hooks) => {
1092
1842
  const feature = path.join(root, 'specs', 'auth');
1093
- const taskPath = 'tasks/task-R0-01-auth.md';
1094
- fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
1843
+ const taskPath = 'task-01-auth.md';
1095
1844
  const gate = path.join(hooks, 'spec-gate.cjs');
1096
1845
  const appCwd = path.join(root, 'packages', 'app');
1097
1846
  fs.mkdirSync(appCwd, { recursive: true });
1098
1847
  const payload = { cwd: appCwd, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
1099
1848
  const cacheFile = path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json');
1100
- const validBodyTemplate = [
1101
- 'Status: done', '', '## Evidence', '', 'Verification: PASS',
1102
- 'Command: pnpm test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
1103
- '```', 'pass', '```', '',
1104
- ].join('\n');
1849
+ const output = ['```', 'pass', '```'];
1850
+ const writeValid = () => writeProcessTask(feature, 'done', (writeProcessTask(feature, 'done'), bindReceipt(root, [
1851
+ 'Verification: PASS', 'Command: pnpm test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`, ...output,
1852
+ ].join('\n'))).split('\n'));
1105
1853
 
1106
1854
  // first-run (no cache) with valid receipt → no block, cache seeded
1107
1855
  try { fs.rmSync(cacheFile, { force: true }); } catch {}
1108
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', current_phase: 'closeout', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
1109
- const validBody = bindReceipt(root, validBodyTemplate);
1110
- fs.writeFileSync(path.join(feature, taskPath), validBody);
1856
+ writeValid();
1111
1857
  let res = runHook(gate, appCwd, payload);
1112
1858
  assert.equal(res.stdout, '', 'first-run valid should not block');
1113
1859
  assert.ok(fs.existsSync(cacheFile), 'cache file must be created on first valid run');
@@ -1119,7 +1865,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
1119
1865
  assert.equal(res.stdout, '', 'cache-hit unchanged valid must not block');
1120
1866
 
1121
1867
  // mutation: remove Command/Verification → must block even though cache says done
1122
- fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n');
1868
+ writeProcessTask(feature, 'done', ['Exit: 0', 'Base: abc123', 'Head: def456', ...output]);
1123
1869
  res = runHook(gate, appCwd, payload);
1124
1870
  assert.equal(JSON.parse(res.stdout).decision, 'block', 'mutated receipt missing Verification/Command should block on cache-hit');
1125
1871
  assert.match(JSON.parse(res.stdout).reason, /\bverification_state\b|\bcommand\b/);
@@ -1128,33 +1874,21 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
1128
1874
  assert.equal(JSON.parse(res.stdout).decision, 'block', 'second hit after mutation still blocks');
1129
1875
 
1130
1876
  // restore valid, then mutate provenance (remove Head) → block
1131
- fs.writeFileSync(path.join(feature, taskPath), validBody);
1877
+ writeValid();
1132
1878
  assert.equal(runHook(gate, appCwd, payload).stdout, '', 'restored valid should pass again');
1133
- fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\n```\npass\n```\n');
1879
+ writeProcessTask(feature, 'done', ['Verification: PASS', 'Command: pnpm test', 'Exit: 0', 'Base: abc123', ...output]);
1134
1880
  res = runHook(gate, appCwd, payload);
1135
1881
  assert.equal(JSON.parse(res.stdout).decision, 'block');
1136
1882
  assert.match(JSON.parse(res.stdout).reason, /\bprovenance\b/);
1137
1883
 
1138
- // deletion → block with check a
1139
- fs.writeFileSync(path.join(feature, taskPath), validBody);
1140
- assert.equal(runHook(gate, appCwd, payload).stdout, '');
1141
- fs.unlinkSync(path.join(feature, taskPath));
1142
- res = runHook(gate, appCwd, payload);
1143
- assert.equal(JSON.parse(res.stdout).decision, 'block');
1144
- assert.match(JSON.parse(res.stdout).reason, /\ba\b/);
1145
- // restore for next sub-test
1146
- fs.writeFileSync(path.join(feature, taskPath), validBody);
1147
- try { fs.rmSync(cacheFile, { force: true }); } catch {}
1148
- assert.equal(runHook(gate, appCwd, payload).stdout, '');
1149
-
1150
1884
  // malformed cache with valid receipt → must still pass (cache parse fail-open)
1885
+ writeValid();
1151
1886
  fs.writeFileSync(cacheFile, '{ malformed');
1152
- fs.writeFileSync(path.join(feature, taskPath), validBody);
1153
1887
  res = runHook(gate, appCwd, payload);
1154
1888
  assert.equal(res.stdout, '', 'malformed cache with valid receipt must not block');
1155
1889
  // malformed cache with invalid receipt → must still block
1156
1890
  fs.writeFileSync(cacheFile, '{ malformed again');
1157
- fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
1891
+ writeProcessTask(feature, 'done');
1158
1892
  res = runHook(gate, appCwd, payload);
1159
1893
  assert.equal(JSON.parse(res.stdout).decision, 'block', 'malformed cache with invalid receipt must block');
1160
1894
  });
@@ -1164,18 +1898,11 @@ test('Codex cache identity separates same-feature receipts from different roots'
1164
1898
  const contexts = [];
1165
1899
  for (let index = 0; index < 2; index += 1) {
1166
1900
  const context = inHookFixture((root, hooks) => {
1167
- const feature = path.join(root, 'specs', 'auth');
1168
- const taskPath = 'tasks/task.md';
1169
- fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
1170
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
1171
- status: 'in_progress',
1172
- feature_name: 'auth',
1173
- task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
1174
- }));
1175
- fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
1176
- 'Status: done', '', '## Evidence', '', 'Verification: PASS',
1177
- 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
1178
- ].join('\n')));
1901
+ writeProcessTask(path.join(root, 'specs', 'auth'), 'done', null, 'node --test');
1902
+ writeProcessTask(path.join(root, 'specs', 'auth'), 'done', bindReceipt(root, [
1903
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
1904
+ '```', 'node --test', 'ok 1', '```',
1905
+ ].join('\n')).split('\n'), 'node --test');
1179
1906
  const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
1180
1907
  cwd: root,
1181
1908
  session_id: 'session-a',
@@ -1212,33 +1939,32 @@ test('Codex P0 regression: placeholder, explicit failure, artifact, symlink and
1212
1939
  assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: TBD\n').includes('artifact_hash'));
1213
1940
  assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: \n').includes('artifact_hash'));
1214
1941
  assert.deepEqual(specReceipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\nArtifact: bundle\nsha256: ${'a'.repeat(64)}\n`), []);
1215
- // Probe D/E: symlink spec/task
1942
+ // Probe D/E: symlink packet and task
1216
1943
  const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-codex-reg-'));
1217
1944
  try {
1218
1945
  const specsDir = path2.join(tmp, 'specs');
1219
- fs2.mkdirSync(specsDir, { recursive: true });
1220
- fs2.mkdirSync(path2.join(specsDir, 'valid'), { recursive: true });
1221
- fs2.writeFileSync(path2.join(specsDir, 'valid', 'spec.json'), JSON.stringify({ status: 'in_progress' }));
1946
+ writeProcessTask(path2.join(specsDir, 'valid'), 'pending');
1222
1947
  const outside = path2.join(tmp, 'outside');
1223
- fs2.mkdirSync(outside, { recursive: true });
1224
- fs2.writeFileSync(path2.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
1225
- const link = path2.join(specsDir, 'linked');
1226
- fs2.symlinkSync(outside, link);
1227
- assert.equal(specUtils.resolveActiveSpec(tmp, {}, 'linked', null).error, 'explicit_malformed');
1228
- assert.equal(specUtils.resolveActiveSpec(tmp, {}, null, path2.join(specsDir, 'linked', 'spec.json')).error, 'explicit_malformed');
1229
- // task symlink
1948
+ writeProcessTask(outside, 'pending');
1949
+ fs2.symlinkSync(outside, path2.join(specsDir, 'linked'));
1950
+ assert.equal(specUtils.resolveWorkflowCandidate(tmp, {}, 'linked', null).error, 'explicit_malformed');
1951
+ assert.equal(specUtils.resolveWorkflowCandidate(tmp, {}, null, path2.join(specsDir, 'linked', 'plan.md')).error, 'explicit_malformed');
1952
+ fs2.rmSync(path2.join(specsDir, 'linked'));
1953
+ // task symlink to a file outside the packet
1230
1954
  const featDir = path2.join(specsDir, 'valid');
1231
- fs2.mkdirSync(path2.join(featDir, 'tasks'), { recursive: true });
1232
1955
  const outsideTask = path2.join(tmp, 'outside-task.md');
1233
- fs2.writeFileSync(outsideTask, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
1234
- const taskLink = path2.join(featDir, 'tasks', 'task.md');
1235
- fs2.symlinkSync(outsideTask, taskLink);
1236
- const fails = specReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
1237
- assert.ok(fails.includes('a'));
1238
- // malformed spec
1239
- fs2.mkdirSync(path2.join(specsDir, 'bad'), { recursive: true });
1240
- fs2.writeFileSync(path2.join(specsDir, 'bad', 'spec.json'), '{ bad');
1241
- const mal = specUtils.resolveActiveSpec(tmp, {}, null, null);
1956
+ fs2.copyFileSync(path2.join(featDir, 'task-01-auth.md'), outsideTask);
1957
+ fs2.symlinkSync(outsideTask, path2.join(featDir, 'task-02-link.md'));
1958
+ const proof = specReceipt.checkWorkflowReceiptDetails(featDir, 'task-02-link.md', null);
1959
+ assert.ok(proof.failures.includes('unsafe_path'), `task symlink must be unsafe, got ${proof.failures}`);
1960
+ assert.equal(specUtils.resolveWorkflowCandidate(tmp, {}, null, null).error, 'invalid_specs');
1961
+ fs2.rmSync(path2.join(featDir, 'task-02-link.md'));
1962
+ // malformed packet: dependency on a missing task
1963
+ const bad = path2.join(specsDir, 'bad');
1964
+ writeProcessTask(bad, 'pending');
1965
+ fs2.writeFileSync(path2.join(bad, 'task-01-auth.md'),
1966
+ fs2.readFileSync(path2.join(bad, 'task-01-auth.md'), 'utf8').replace('- none', '- task-09-missing.md'));
1967
+ const mal = specUtils.resolveWorkflowCandidate(tmp, {}, null, null);
1242
1968
  assert.equal(mal.error, 'invalid_specs');
1243
1969
  } finally {
1244
1970
  fs2.rmSync(tmp, { recursive: true, force: true });
@@ -1289,17 +2015,10 @@ test('Codex adapter parity - phantom vectors via shared validator (r3)', () => {
1289
2015
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-tap-heading-'));
1290
2016
  try {
1291
2017
  const featureDir = path.join(tmp, 'feature');
1292
- const taskPath = 'tasks/task.md';
1293
- fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
1294
- const taskPrefix = ['# Task', '', '**Status:** done', '', '## Evidence', '', base.trim()];
1295
- fs.writeFileSync(path.join(featureDir, taskPath), [...taskPrefix, '# fail 1', ''].join('\n'));
1296
- const tapFailure = receipt.checkReceipt(featureDir, taskPath, { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
1297
- assert.ok(tapFailure.includes('c'), 'unindented TAP # fail 1 must reach the shared validator');
1298
-
1299
- fs.writeFileSync(path.join(featureDir, taskPath), [...taskPrefix, '# Notes', 'FAILED tests/test_demo.py', ''].join('\n'));
1300
- const markdownBody = receipt.evidenceBody(fs.readFileSync(path.join(featureDir, taskPath), 'utf8'));
1301
- assert.doesNotMatch(markdownBody, /FAILED tests\/test_demo\.py/);
1302
- assert.deepEqual(receipt.validateCanonicalReceipt(markdownBody), [], 'a real Markdown heading must still end Evidence');
2018
+ const receiptFields = base.trim().split('\n');
2019
+ writeProcessTask(featureDir, 'done', [...receiptFields, '```', 'pnpm test', '# fail 1', '```']);
2020
+ const tapFailure = receipt.checkWorkflowReceiptDetails(featureDir, 'task-01-auth.md', null);
2021
+ assert.ok(tapFailure.failures.includes('verification_state'), `unindented TAP # fail 1 must reach the shared validator, got ${tapFailure.failures}`);
1303
2022
  } finally {
1304
2023
  fs.rmSync(tmp, { recursive: true, force: true });
1305
2024
  }
@@ -1329,38 +2048,20 @@ test('Codex adapter enforces declared task artifact SHA-256 with missing, invali
1329
2048
  const digest = crypto.createHash('sha256').update(artifact).digest('hex');
1330
2049
  const cases = [
1331
2050
  ['', true],
1332
- ['sha256: abc123\n', true],
1333
- [`sha256: ${'0'.repeat(64)}\n`, true],
1334
- [`sha256: ${digest}\n`, false],
2051
+ ['sha256: abc123', true],
2052
+ [`sha256: ${'0'.repeat(64)}`, true],
2053
+ [`sha256: ${digest}`, false],
1335
2054
  ];
1336
2055
  for (const [hashLine, shouldBlock] of cases) {
1337
2056
  inHookFixture((root, hooks) => {
1338
2057
  const feature = path.join(root, 'specs', 'artifact-demo');
1339
- const taskPath = 'tasks/task.md';
1340
- fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
1341
2058
  fs.mkdirSync(path.join(root, 'output'), { recursive: true });
1342
2059
  fs.writeFileSync(path.join(root, 'output', 'bundle.js'), artifact);
1343
- fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
1344
- status: 'in_progress',
1345
- current_phase: 'closeout',
1346
- feature_name: 'artifact-demo',
1347
- task_registry: {
1348
- [taskPath]: {
1349
- status: 'done',
1350
- completed_at: '2026-08-11T00:00:00.000Z',
1351
- artifacts: ['output/bundle.js'],
1352
- },
1353
- },
1354
- }));
1355
- fs.writeFileSync(path.join(feature, taskPath), [
1356
- '# Task', '', '**Status:** done', '', '## Evidence', '',
2060
+ writeProcessTask(feature, 'done', null, 'node --test');
2061
+ writeProcessTask(feature, 'done', bindReceipt(root, [
1357
2062
  'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
1358
- 'Artifact: output/bundle.js', hashLine,
1359
- ].join('\n'));
1360
- fs.writeFileSync(
1361
- path.join(feature, taskPath),
1362
- bindReceipt(root, fs.readFileSync(path.join(feature, taskPath), 'utf8'), 'artifact-demo'),
1363
- );
2063
+ 'Artifact: output/bundle.js', hashLine, '```', 'node --test', 'ok 1', '```',
2064
+ ].join('\n'), 'artifact-demo').split('\n'), 'node --test');
1364
2065
  const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
1365
2066
  cwd: root,
1366
2067
  session_id: 'session-a',