@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
@@ -0,0 +1,317 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { spawnSync } = require('node:child_process');
8
+ const { test, after } = require('node:test');
9
+ const SOURCE = path.resolve(__dirname, '../../src');
10
+ const roots = [];
11
+
12
+ function fixture(host) {
13
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-privacy-paths-'));
14
+ roots.push(root);
15
+ if (host === 'codex') {
16
+ assert.equal(spawnSync('git', ['init', '-q', root]).status, 0);
17
+ const install = spawnSync(process.execPath, [path.resolve(__dirname, '../install.js'), '--platform', 'codex', '-y'], {
18
+ cwd: root, encoding: 'utf8',
19
+ });
20
+ assert.equal(install.status, 0, install.stderr);
21
+ } else {
22
+ fs.cpSync(path.join(SOURCE, 'claude/hooks'), path.join(root, '.claude/hooks'), { recursive: true });
23
+ }
24
+ return { root, hooks: path.join(root, `.${host}/hooks`) };
25
+ }
26
+
27
+ function invoke(f, tool_name, tool_input, spawnOptions = {}) {
28
+ const env = { ...process.env };
29
+ delete env.CLAUDE_PROJECT_DIR;
30
+ const result = spawnSync(process.execPath, [path.join(f.hooks, 'privacy-block.cjs')], {
31
+ cwd: f.cwd || f.root, encoding: 'utf8', env,
32
+ input: JSON.stringify({ tool_name, tool_input, cwd: f.cwd || f.root, session_id: 'privacy-paths-probe' }),
33
+ ...spawnOptions,
34
+ });
35
+ assert.equal(result.status, 0, result.stderr);
36
+ assert.equal(result.stderr, '', 'hook must not emit a stack trace');
37
+ return result.stdout.trim() ? JSON.parse(result.stdout).hookSpecificOutput : null;
38
+ }
39
+
40
+ after(() => {
41
+ for (const root of roots) {
42
+ const locked = path.join(root, 'locked');
43
+ if (fs.existsSync(locked)) fs.chmodSync(locked, 0o700);
44
+ fs.rmSync(root, { recursive: true, force: true });
45
+ }
46
+ });
47
+
48
+ const cases = [
49
+ ['Read sensitive', 'Read', { file_path: '.env' }, true],
50
+ ['Grep sensitive glob', 'Grep', { pattern: 'x', glob: '.env*' }, true],
51
+ ['NotebookEdit sensitive notebook_path', 'NotebookEdit', { notebook_path: '.env' }, true],
52
+ ['MCP path', 'mcp__fs__read_file', { path: '.env' }, true],
53
+ ['MCP nested file_path', 'mcp__x__y', { args: { file_path: 'certs/a.pem' } }, true],
54
+ ['MCP file uri', 'mcp__fs__read', root => ({ uri: `file://${root}/.env` }), true],
55
+ ['MCP source destination', 'mcp__fs__move_file', { source: '.env', destination: 'x' }, true],
56
+ ['MCP relative_path', 'mcp__s__read', { relative_path: '.env' }, true],
57
+ ['Bash sensitive operand', 'Bash', { command: 'cat .env' }, true],
58
+ ['Bash env-file option', 'Bash', { command: 'docker compose --env-file .env up' }, true],
59
+ ['shell argv bash -lc', 'shell', { command: ['bash', '-lc', 'cat .env'] }, true],
60
+ ['patch Update File', 'apply_patch', { command: '*** Begin Patch\n*** Update File: .env\n*** End Patch' }, true],
61
+ ['patch Move to', 'apply_patch', { command: '*** Begin Patch\n*** Update File: a.txt\n*** Move to: .env\n*** End Patch' }, true],
62
+ ['Read ordinary file', 'Read', { file_path: 'app.ts' }, false],
63
+ ['Read exempt .env.example', 'Read', { file_path: '.env.example' }, false],
64
+ ['Grep ordinary glob', 'Grep', { pattern: 'API_KEY', glob: '*.ts' }, false],
65
+ ['Bash ls', 'Bash', { command: 'ls' }, false],
66
+ ];
67
+
68
+ for (const host of ['claude', 'codex']) {
69
+ const f = fixture(host);
70
+ for (const [name, tool, input, sensitive] of cases) {
71
+ test(`privacy-paths ${host}: ${name}`, () => {
72
+ const output = invoke(f, tool, typeof input === 'function' ? input(f.root) : input);
73
+ if (!sensitive) return assert.equal(output, null);
74
+ assert.equal(output?.permissionDecision, host === 'claude' ? 'ask' : 'deny');
75
+ if (host === 'codex') {
76
+ assert.match(output.permissionDecisionReason, /APPROVE CAFEKIT PRIVACY /);
77
+ assert.match(output.permissionDecisionReason, /y \(or yes \/ có\)/);
78
+ }
79
+ });
80
+ }
81
+ }
82
+
83
+ test('privacy-paths codex: missing shared lib denies even Bash ls', () => {
84
+ const f = fixture('codex');
85
+ fs.rmSync(path.join(f.hooks, 'lib/privacy-paths.cjs'), { force: true });
86
+ const output = invoke(f, 'Bash', { command: 'ls' });
87
+ assert.equal(output?.permissionDecision, 'deny');
88
+ assert.match(output.permissionDecisionReason, /could not be evaluated safely/);
89
+ });
90
+
91
+ test('privacy-paths: unavailable glob matcher keeps sensitive children', () => {
92
+ const f = fixture('claude');
93
+ fs.writeFileSync(path.join(f.root, 'credentials.json'), '');
94
+ const { sensitivePaths } = require(path.join(SOURCE, 'claude/hooks/lib/privacy-paths.cjs'));
95
+ const original = path.matchesGlob;
96
+ try {
97
+ path.matchesGlob = undefined;
98
+ let paths;
99
+ assert.doesNotThrow(() => { paths = sensitivePaths('Grep', { glob: '*.ts' }, f.root); });
100
+ assert.ok(paths.includes(path.join(f.root, 'credentials.json')));
101
+ } finally {
102
+ path.matchesGlob = original;
103
+ }
104
+ });
105
+
106
+ const runtimeWrites = [
107
+ ['Write runtime', 'Write', { file_path: '.claude/runtime.json' }],
108
+ ['Edit runtime', 'Edit', { file_path: '.codex/runtime.json' }],
109
+ ['NotebookEdit runtime', 'NotebookEdit', { notebook_path: '.omp/runtime.json' }],
110
+ ['patch approval directory', 'apply_patch', { command: '*** Begin Patch\n*** Add File: .codex/hooks/.privacy/g.json\n*** End Patch' }],
111
+ ['MCP runtime', 'mcp__fs__write_file', { path: '.claude/runtime.json' }],
112
+ ['MultiEdit runtime', 'MultiEdit', { edits: [{ file_path: '.claude/runtime.json' }] }],
113
+ ['Write backslash runtime', 'Write', { file_path: '.CLAUDE\\RUNTIME.JSON' }],
114
+ ['symlink directory Write', 'Write', { file_path: 'c/runtime.json' }],
115
+ ['symlink directory shell', 'Bash', { command: "echo '{}' > c/runtime.json" }],
116
+ ['dangling approval symlink', 'Write', { file_path: 'links/lnk2' }],
117
+ ['dangling runtime symlink', 'Write', { file_path: 'links/lnk-runtime' }],
118
+ ['chained approval symlink', 'Bash', { command: 'echo x > links/t1' }],
119
+ ['chained runtime symlink', 'Bash', { command: 'echo x > links/r1' }],
120
+ ['looping write symlink', 'Bash', { command: 'echo x > links/loop1' }],
121
+ ...[
122
+ 'cp -r evil/.claude .', 'mv evil/.codex .', 'cp -r evil/.omp .', 'mv evil/.privacy .',
123
+ ].map(command => [`runtime directory source ${command}`, 'Bash', { command }]),
124
+ ['clobber redirect', 'Bash', { command: 'echo x >| .claude/runtime.json' }],
125
+ ['quoted heredoc header tail', 'Bash', { command: "cat <<'EOF' > .claude/runtime.json\n{}\nEOF" }],
126
+ ...[
127
+ 'cd "$PWD/.claude" && echo \'{}\' > runtime.json',
128
+ 'cd $(pwd)/.claude && cp /tmp/r runtime.json',
129
+ ].map(command => [`dynamic directory ${command}`, 'Bash', { command }]),
130
+ ...[
131
+ 'cp /tmp/r.json .claude/runtime.json > /dev/null',
132
+ 'cp /tmp/r.json .claude/runtime.json 2>&1',
133
+ 'mv r .codex/runtime.json 2>/dev/null',
134
+ 'install r .omp/runtime.json > /dev/null',
135
+ 'ln r .claude/runtime.json 2>&1',
136
+ 'cp /tmp/r.json .claude/runtime.json < /dev/null',
137
+ 'cp /tmp/r.json .claude/runtime.json <<< x',
138
+ 'cp a .claude/runtime.json 0</dev/null',
139
+ 'cp a .claude/runtime.json 0< /dev/null',
140
+ 'cp a .claude/runtime.json 3</dev/null',
141
+ ].map(command => [`redirect operands ${command}`, 'Bash', { command }]),
142
+ ...[
143
+ "echo '{}' > .claude/runtime.json", "echo '{}' > .CLAUDE/RUNTIME.JSON",
144
+ 'echo x >> .claude/./runtime.json', 'tee .codex//runtime.json',
145
+ 'sed -i s/a/b/ .omp/runtime.json', 'cp r.json .claude/',
146
+ 'mv r.json .codex/runtime.json', 'cp t .codex/hooks/./.privacy/',
147
+ 'cd .claude && cp /tmp/r.json runtime.json', 'echo x > sub/.claude/runtime.json',
148
+ 'install r.json .omp/', 'ln r.json .codex/runtime.json',
149
+ 'pushd .codex && echo x > runtime.json',
150
+ "sed -ni 's/a/b/p' .claude/runtime.json", 'cp -t.claude r.json',
151
+ 'cd .claude && echo "$(echo x > runtime.json)"',
152
+ ].map(command => [command, 'Bash', { command }]),
153
+ ];
154
+ const runtimeAllowed = [
155
+ ['Read runtime', 'Read', { file_path: '.claude/runtime.json' }],
156
+ ['cat .codex/runtime.json', 'Bash', { command: 'cat .codex/runtime.json' }],
157
+ ['jq .paths .claude/runtime.json', 'Bash', { command: 'jq .paths .claude/runtime.json' }],
158
+ ['Write ordinary runtime', 'Write', { file_path: 'docs/runtime.json' }],
159
+ ['Write resolved ordinary runtime', 'Write', { file_path: '.claude/../docs/runtime.json' }],
160
+ ];
161
+
162
+ for (const host of ['claude', 'codex']) {
163
+ const f = fixture(host);
164
+ fs.mkdirSync(path.join(f.root, '.claude'), { recursive: true });
165
+ fs.symlinkSync('.claude', path.join(f.root, 'c'), 'dir');
166
+ fs.mkdirSync(path.join(f.root, 'links'));
167
+ fs.symlinkSync('../.codex/hooks/.privacy/g.json', path.join(f.root, 'links/lnk2'));
168
+ fs.symlinkSync('../.claude/runtime.json', path.join(f.root, 'links/lnk-runtime'));
169
+ fs.symlinkSync('t2', path.join(f.root, 'links/t1'));
170
+ fs.symlinkSync('../.codex/hooks/.privacy/g.json', path.join(f.root, 'links/t2'));
171
+ fs.symlinkSync('r2', path.join(f.root, 'links/r1'));
172
+ fs.symlinkSync('../.claude/runtime.json', path.join(f.root, 'links/r2'));
173
+ fs.symlinkSync('loop2', path.join(f.root, 'links/loop1'));
174
+ fs.symlinkSync('loop1', path.join(f.root, 'links/loop2'));
175
+ for (const name of ['.claude', '.codex', '.omp', '.privacy']) {
176
+ const directory = path.join(f.root, 'evil', name);
177
+ fs.mkdirSync(directory, { recursive: true });
178
+ fs.writeFileSync(path.join(directory, 'runtime.json'), '{}');
179
+ }
180
+ for (const [name, tool, input] of runtimeWrites) {
181
+ test(`privacy-paths ${host}: runtime writes ${name}`, () => {
182
+ const output = invoke(f, tool, input);
183
+ assert.equal(output?.permissionDecision, host === 'claude' ? 'ask' : 'deny');
184
+ assert.doesNotMatch(output.permissionDecisionReason, /could not be evaluated safely/);
185
+ if (host === 'codex') {
186
+ assert.match(output.permissionDecisionReason, /APPROVE CAFEKIT PRIVACY /);
187
+ assert.match(output.permissionDecisionReason, /y \(or yes \/ có\)/);
188
+ }
189
+ });
190
+ }
191
+ for (const [name, tool, input] of runtimeAllowed) {
192
+ test(`privacy-paths ${host}: runtime allows ${name}`, () => assert.equal(invoke(f, tool, input), null));
193
+ }
194
+ }
195
+
196
+ test('privacy-paths claude: subdirectory switch cannot disable root privacy', () => {
197
+ const f = fixture('claude');
198
+ const cwd = path.join(f.root, 'sub');
199
+ fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true });
200
+ fs.writeFileSync(path.join(cwd, '.claude/runtime.json'), JSON.stringify({ privacyBlock: false }));
201
+ fs.writeFileSync(path.join(f.root, '.env'), '');
202
+ assert.equal(invoke({ ...f, cwd }, 'Read', { file_path: '../.env' })?.permissionDecision, 'ask');
203
+ });
204
+
205
+ test('privacy-paths claude: root switch still allows a manual opt-out', () => {
206
+ const f = fixture('claude');
207
+ fs.writeFileSync(path.join(f.root, '.claude/runtime.json'), JSON.stringify({ privacyBlock: false }));
208
+ fs.writeFileSync(path.join(f.root, '.env'), '');
209
+ assert.equal(invoke(f, 'Read', { file_path: '.env' }), null);
210
+ });
211
+
212
+ const recursiveSensitive = [
213
+ 'grep -r API_KEY .', 'grep -rn API_KEY .', 'grep -Rin key .', 'grep -rl KEY .',
214
+ 'grep --recursive KEY .', 'grep -d recurse KEY .', 'grep -R KEY', 'grep -r KEY src .',
215
+ "bash -c 'grep -rn KEY .'", 'echo . | xargs grep -r KEY',
216
+ 'rg KEY .', 'rg --hidden KEY .', 'rg -u -u KEY .',
217
+ "rg --hidden -g '!*.ts' KEY .", "grep -r --include='*' KEY .",
218
+ 'grep -r --include .env KEY .', "grep -r --include='.e*' KEY .",
219
+ 'grep -R KEY linked', 'ag KEY .', 'rg -uu KEY .', 'rg -. KEY .',
220
+ 'grep --dereference-recursive KEY .', 'grep --directories=recurse KEY .',
221
+ "find src -exec grep -rn KEY . \\;",
222
+ "rg --iglob 'CREDENTIALS.JSON' KEY .", "rg --glob-case-insensitive -g 'CREDENTIALS.JSON' KEY .",
223
+ "rg -g '*.ts' --iglob 'CREDENTIALS.JSON' KEY .", "rg -uug '*' KEY hidden",
224
+ ];
225
+ const recursiveAllowed = [
226
+ 'grep -r KEY src', 'grep -r --include=*.ts KEY .', "rg -g '*.ts' KEY .",
227
+ 'grep -r KEY app.ts', 'grep -r KEY nope/', 'grep KEY .', 'grep -r KEY examples',
228
+ 'rg --files src', 'grep -reKEY src',
229
+ ];
230
+ const recursiveGrep = [
231
+ { pattern: 'KEY' }, { pattern: 'KEY', path: '.' },
232
+ { pattern: 'KEY', glob: '*' }, { pattern: 'KEY', glob: '.*' },
233
+ { pattern: 'KEY', glob: '*.{env,json}' },
234
+ ];
235
+
236
+ for (const host of ['claude', 'codex']) {
237
+ const f = fixture(host);
238
+ for (const name of ['.env', 'credentials.json', '.env.example', 'app.ts']) {
239
+ fs.writeFileSync(path.join(f.root, name), '');
240
+ }
241
+ for (const dir of ['src', 'linked', 'examples', 'locked', 'hidden', 'a']) fs.mkdirSync(path.join(f.root, dir));
242
+ fs.symlinkSync('../.env', path.join(f.root, 'linked/notes.txt'));
243
+ fs.writeFileSync(path.join(f.root, 'examples/.env.example'), '');
244
+ fs.writeFileSync(path.join(f.root, 'hidden/.env'), '');
245
+ fs.writeFileSync(path.join(f.root, 'a/.env'), '');
246
+ fs.writeFileSync(path.join(f.root, 'aws-production-service-account-credentials-backup-2026.json'), '');
247
+ const wildcardDirectory = path.join(f.root, 'wildcard-count');
248
+ fs.mkdirSync(wildcardDirectory);
249
+ fs.writeFileSync(path.join(wildcardDirectory, `credentials${'a'.repeat(200)}b.json`), '');
250
+ fs.writeFileSync(path.join(f.root, 'locked/credentials.json'), '');
251
+ const locked = path.join(f.root, 'locked');
252
+ fs.chmodSync(locked, 0);
253
+ const probe = (name, tool, input, sensitive, options = {}) => test(`privacy-paths ${host}: recursive ${name}`, options, () => {
254
+ const output = invoke(f, tool, input, options.timeout
255
+ ? { timeout: options.timeout - 1000, killSignal: 'SIGKILL' } : {});
256
+ if (!sensitive) return assert.equal(output, null);
257
+ assert.equal(output?.permissionDecision, host === 'claude' ? 'ask' : 'deny');
258
+ if (host === 'codex') {
259
+ assert.match(output.permissionDecisionReason, /APPROVE CAFEKIT PRIVACY /);
260
+ assert.match(output.permissionDecisionReason, /y \(or yes \/ có\)/);
261
+ }
262
+ });
263
+ for (const command of recursiveSensitive) probe(command, 'Bash', { command }, true);
264
+ for (const input of recursiveGrep) probe(`Grep ${JSON.stringify(input)}`, 'Grep', input, true);
265
+ for (const command of ["rg -g '/credentials.json' KEY", "rg -g '/*.json' KEY", "rg --hidden -g '/.e*' KEY"]) {
266
+ probe(`anchored ${command}`, 'Bash', { command }, true);
267
+ }
268
+ probe('anchored Grep glob', 'Grep', { pattern: 'KEY', glob: '/*.json' }, true);
269
+ probe('bounded glob shell range', 'Bash', { command: "rg -g '{1..100000}' -g '*' KEY ." }, true, { timeout: 10000 });
270
+ probe('bounded glob Grep range', 'Grep', { glob: '{1..100000}' }, true, { timeout: 10000 });
271
+ probe('bounded glob Grep length', 'Grep', { glob: 'x'.repeat(257) }, true, { timeout: 10000 });
272
+ probe('bounded combinations shell', 'Bash', {
273
+ command: `rg -g '${'{a,b}'.repeat(20)}' -g '*' KEY .`,
274
+ }, true, { timeout: 10000 });
275
+ probe('bounded combinations nested Grep', 'Grep', {
276
+ glob: '{a,{b,{c,{d,e}}}}'.repeat(12),
277
+ }, true, { timeout: 10000 });
278
+ probe('bounded combinations narrow Grep', 'Grep', {
279
+ path: '.', glob: '*.{ts,tsx}',
280
+ }, false, { timeout: 10000 });
281
+ probe('narrow grammar escaped braces shell', 'Bash', {
282
+ command: `rg -g '${'\\{a,b\\}'.repeat(20)}' -g '*' KEY .`,
283
+ }, true, { timeout: 10000 });
284
+ probe('narrow grammar repeated wildcards Grep', 'Grep', {
285
+ path: '.', globs: [`${'*?'.repeat(20)}z`, '*'],
286
+ }, true, { timeout: 10000 });
287
+ probe('narrow grammar extglob Grep', 'Grep', {
288
+ glob: '+(a|*)'.repeat(10),
289
+ }, true, { timeout: 10000 });
290
+ probe('total wildcard count in brace Grep', 'Grep', {
291
+ path: 'wildcard-count', globs: ['{*a*a*a*a*az,q}', '*'],
292
+ }, true, { timeout: 10000 });
293
+ for (const command of ['rg --unrestricted --unrestricted KEY a', 'rg -.n KEY a', 'ag -u KEY a', 'ag --unrestricted KEY a']) {
294
+ probe(`hidden flags ${command}`, 'Bash', { command }, true);
295
+ }
296
+ for (const input of [{ path: 1 }, { glob: ['*'] }, { globs: 5 }]) {
297
+ test(`privacy-paths ${host}: malformed Grep ${JSON.stringify(input)}`, () => {
298
+ const { sensitivePaths } = require(path.join(SOURCE, 'claude/hooks/lib/privacy-paths.cjs'));
299
+ let paths;
300
+ assert.doesNotThrow(() => { paths = sensitivePaths('Grep', input, f.root); });
301
+ assert.ok(paths.includes(path.join(f.root, 'credentials.json')));
302
+ const output = invoke(f, 'Grep', input);
303
+ assert.equal(output?.permissionDecision, host === 'claude' ? 'ask' : 'deny');
304
+ assert.doesNotMatch(output.permissionDecisionReason, /could not be evaluated safely/);
305
+ if (host === 'codex') {
306
+ assert.match(output.permissionDecisionReason, /APPROVE CAFEKIT PRIVACY /);
307
+ assert.match(output.permissionDecisionReason, /y \(or yes \/ có\)/);
308
+ }
309
+ });
310
+ }
311
+ for (const command of recursiveAllowed) probe(command, 'Bash', { command }, false);
312
+ probe('Grep narrow glob', 'Grep', { pattern: 'KEY', path: '.', glob: '*.ts' }, false);
313
+ probe('Grep narrow recursive glob', 'Grep', { pattern: 'KEY', path: '.', glob: '**/*.ts' }, false);
314
+ probe('grep -r KEY locked/', 'Bash', { command: 'grep -r KEY locked/' }, false, {
315
+ skip: typeof process.getuid === 'function' && process.getuid() === 0,
316
+ });
317
+ }
@@ -1,11 +1,9 @@
1
1
  'use strict';
2
2
 
3
- // Two Stop hooks ask two different questions about the same repository, so they narrow an
4
- // ambiguous scan differently. The gate asks which packet still has unfinished work, so it
5
- // can validate that packet's receipts. Closeout approval asks which packet is claiming
6
- // closeout, so it can demand a one-time approval for it. Counting finished history as
7
- // ambiguity is what left a repository of accumulated features permanently blocked, with
8
- // the documented escape — an explicit feature target — unavailable.
3
+ // The Stop gate narrows an ambiguous scan by asking which packet still has unfinished
4
+ // work, so it can validate that packet's receipts. Counting finished history as ambiguity
5
+ // is what left a repository of accumulated features permanently blocked, with the
6
+ // documented escape — an explicit feature target — unavailable.
9
7
 
10
8
  const assert = require('node:assert/strict');
11
9
  const { spawnSync } = require('node:child_process');
@@ -38,7 +36,7 @@ function legacyPacket(root, name, { status, phase, taskStatus = 'done' } = {}) {
38
36
  }
39
37
 
40
38
  /** A process-first packet: plan.md plus one flat task file. */
41
- function workflowPacket(root, name, taskStatus) {
39
+ function workflowPacket(root, name, taskStatus, nextStatus = null) {
42
40
  const dir = path.join(root, 'specs', name);
43
41
  fs.mkdirSync(dir, { recursive: true });
44
42
  fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
@@ -46,6 +44,12 @@ function workflowPacket(root, name, taskStatus) {
46
44
  path.join(dir, 'task-01-x.md'),
47
45
  `# Task 01\n\nStatus: ${taskStatus}\n\n## Dependencies\n- none\n`
48
46
  );
47
+ if (nextStatus) {
48
+ fs.writeFileSync(
49
+ path.join(dir, 'task-02-y.md'),
50
+ `# Task 02\n\nStatus: ${nextStatus}\n\n## Dependencies\n- none\n`
51
+ );
52
+ }
49
53
  }
50
54
 
51
55
  function recordActiveFeature(root, contents) {
@@ -63,34 +67,19 @@ function gateResolution(root) {
63
67
 
64
68
  // ── The gate: which packet still has work ───────────────────────────────────────────
65
69
 
66
- test('a legacy repository resolves its one unfinished packet', () => {
70
+ test('a leftover legacy packet creates no ambiguity for the gate', () => {
67
71
  withProject((root) => {
68
72
  for (let index = 1; index <= 3; index += 1) {
69
- legacyPacket(root, `done-${index}`, { status: 'done', phase: 'closeout' });
73
+ legacyPacket(root, `history-${index}`, { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
70
74
  }
71
- legacyPacket(root, 'active-now', { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
75
+ workflowPacket(root, 'active-now', 'pending');
72
76
  const resolved = gateResolution(root);
73
77
  assert.equal(resolved.error, undefined, `expected a resolution, got ${resolved.error}`);
74
78
  assert.equal(resolved.featureName, 'active-now');
79
+ assert.deepEqual(RESOLVER.findLegacyPackets(root, {}), ['history-1', 'history-2', 'history-3']);
75
80
  });
76
81
  });
77
82
 
78
- test('a legacy registry written with the older vocabulary counts as finished', () => {
79
- // Issue #79's repository was upgraded from an older release whose task_registry used
80
- // `completed`. Today's validator allows only pending/in_progress/blocked/done, so
81
- // narrowing read those packets as unfinished and the block survived the fix that was
82
- // supposed to clear it. Measured on the reported shape before this: 18 candidates.
83
- for (const status of ['done', 'completed', 'complete']) {
84
- withProject((root) => {
85
- for (let index = 1; index <= 3; index += 1) {
86
- legacyPacket(root, `history-${index}`, { taskStatus: status });
87
- }
88
- legacyPacket(root, 'active-now', { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
89
- assert.equal(gateResolution(root).featureName, 'active-now', `task status ${status} must read as finished`);
90
- });
91
- }
92
- });
93
-
94
83
  test('a process-first packet keeps the strict task vocabulary', () => {
95
84
  // The widened set is legacy-only. A process-first task saying `completed` is malformed,
96
85
  // not finished, and must not be silently read as done.
@@ -102,20 +91,6 @@ test('a process-first packet keeps the strict task vocabulary', () => {
102
91
  });
103
92
  });
104
93
 
105
- test('a legacy candidate never reaches the bulk branch', () => {
106
- // The bulk branch exits before the semantic-digest, FLASH_UNVERIFIED, feature-receipt
107
- // and completion-policy layers, so a legacy packet reaching it would turn a block into
108
- // a silent pass. With every legacy packet finished there is nothing to narrow to, and
109
- // the existing ambiguity must stand rather than the set being audited.
110
- withProject((root) => {
111
- legacyPacket(root, 'done-1', { status: 'done', phase: 'closeout' });
112
- legacyPacket(root, 'done-2', { status: 'done', phase: 'closeout' });
113
- const resolved = gateResolution(root);
114
- assert.notEqual(resolved.layoutKind, 'process-v3-completed-set');
115
- assert.ok(resolved.error, 'an all-legacy finished set must stay ambiguous');
116
- });
117
- });
118
-
119
94
  test('a process-first set still reaches the bulk branch', () => {
120
95
  withProject((root) => {
121
96
  workflowPacket(root, 'done-1', 'done');
@@ -132,101 +107,21 @@ test('one unfinished process-first packet still narrows', () => {
132
107
  });
133
108
  });
134
109
 
135
- // ── Closeout approval: which packet is claiming closeout ────────────────────────────
136
-
137
- test('one packet claiming closeout among finished ones resolves', () => {
138
- withProject((root) => {
139
- legacyPacket(root, 'shipped', { status: 'in_progress', phase: 'execution', taskStatus: 'done' });
140
- legacyPacket(root, 'closing', { status: 'done', phase: 'closeout' });
141
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
142
- assert.equal(resolved.error, undefined);
143
- assert.equal(resolved.featureName, 'closing');
144
- });
145
- });
146
-
147
- test('a lone finished packet still resolves', () => {
148
- // Four existing cases depend on this: closeout approval is claimed against a finished
149
- // spec, so a single packet must resolve whatever its status.
150
- for (const status of ['done', 'completed']) {
151
- withProject((root) => {
152
- legacyPacket(root, 'only', { status });
153
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
154
- assert.equal(resolved.featureName, 'only', `status ${status} must still resolve`);
155
- });
156
- }
157
- });
158
-
159
- test('a lone packet mid-execution still resolves', () => {
160
- // The closeout filter would drop this one, so the single-candidate return above it is
161
- // what keeps a repository holding exactly one in-flight packet resolvable. Without it
162
- // the hook goes silent for that repository instead of checking the packet.
163
- withProject((root) => {
164
- legacyPacket(root, 'solo', { status: 'in_progress', phase: 'implementation', taskStatus: 'pending' });
165
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
166
- assert.equal(resolved.featureName, 'solo');
167
- });
168
- });
169
-
170
- test('no packet claiming closeout resolves to null', () => {
171
- // null means nothing is in flight and the hook stays silent; an error would keep
172
- // blocking a repository where no closeout is being claimed at all.
173
- withProject((root) => {
174
- legacyPacket(root, 'a', { status: 'in_progress', phase: 'execution' });
175
- legacyPacket(root, 'b', { status: 'in_progress', phase: 'execution' });
176
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }), null);
177
- });
178
- });
179
-
180
- test('several packets claiming closeout stay ambiguous and name only those', () => {
181
- withProject((root) => {
182
- legacyPacket(root, 'closing-a', { status: 'done', phase: 'closeout' });
183
- legacyPacket(root, 'closing-b', { status: 'done', phase: 'closeout' });
184
- legacyPacket(root, 'working', { status: 'in_progress', phase: 'execution' });
185
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
186
- assert.equal(resolved.error, 'multiple_persisted');
187
- assert.deepEqual(resolved.candidates, ['closing-a', 'closing-b']);
188
- });
189
- });
190
-
191
110
  // ── The recorded active feature: the escape the block told users to take ────────────
192
111
 
193
- test('a recorded active feature resolves two packets claiming closeout', () => {
194
- withProject((root) => {
195
- legacyPacket(root, 'closing-a', { status: 'done', phase: 'closeout' });
196
- legacyPacket(root, 'closing-b', { status: 'done', phase: 'closeout' });
197
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).error, 'multiple_persisted');
198
- recordActiveFeature(root, '{"featureName": "closing-b"}\n');
199
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
200
- assert.equal(resolved.error, undefined, `expected a resolution, got ${resolved.error}`);
201
- assert.equal(resolved.featureName, 'closing-b');
202
- });
203
- });
204
-
205
- test('a payload target beats the recorded file', () => {
206
- withProject((root) => {
207
- legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
208
- legacyPacket(root, 'beta', { status: 'done', phase: 'closeout' });
209
- recordActiveFeature(root, '{"featureName": "alpha"}\n');
210
- const resolved = RESOLVER.resolvePersistedSpec({
211
- projectRoot: root, runtime: {}, target: { featureName: 'beta' },
212
- });
213
- assert.equal(resolved.featureName, 'beta', 'the caller must win');
214
- });
215
- });
216
-
217
112
  test('a blank recorded feature is ignored rather than malformed', () => {
218
113
  // explicitTargetValue turns a blank into null, and a null target becomes an
219
114
  // explicit_malformed error that blocks. The reader must return null instead, or every
220
115
  // project holding a stray file would be blocked by the very escape hatch.
221
116
  for (const contents of ['{"featureName": " "}', '{"featureName": 42}', '{}', 'not json', '[]']) {
222
117
  withProject((root) => {
223
- legacyPacket(root, 'only', { status: 'done', phase: 'closeout' });
118
+ workflowPacket(root, 'only', 'pending');
224
119
  recordActiveFeature(root, contents);
225
120
  assert.equal(
226
121
  RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} }), null,
227
122
  `${contents} must be invisible`
228
123
  );
229
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).featureName, 'only');
124
+ assert.equal(RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {} }).featureName, 'only');
230
125
  });
231
126
  }
232
127
  });
@@ -234,11 +129,12 @@ test('a blank recorded feature is ignored rather than malformed', () => {
234
129
  test('a recorded feature cannot escape the specs root', () => {
235
130
  // The reader adds no trust: the value goes through the resolver's existing checks.
236
131
  withProject((root) => {
237
- legacyPacket(root, 'a', { status: 'done', phase: 'closeout' });
238
- legacyPacket(root, 'b', { status: 'done', phase: 'closeout' });
132
+ workflowPacket(root, 'a', 'pending');
133
+ workflowPacket(root, 'b', 'pending');
239
134
  recordActiveFeature(root, '{"featureName": "../../etc"}');
240
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
241
- assert.ok(resolved.error, 'an escaping name must produce the existing error, not a resolution');
135
+ const target = RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} });
136
+ const resolved = RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {}, target });
137
+ assert.equal(resolved.error, 'explicit_malformed', 'an escaping name must produce the existing error, not a resolution');
242
138
  assert.notEqual(resolved.featureName, '../../etc');
243
139
  });
244
140
  });
@@ -248,7 +144,7 @@ test('a symlinked recorded feature is refused', () => {
248
144
  // project does not contain. The repository already refuses symlinked runtime
249
145
  // dependencies elsewhere; this reader follows the same rule.
250
146
  withProject((root) => {
251
- legacyPacket(root, 'only', { status: 'done', phase: 'closeout' });
147
+ workflowPacket(root, 'only', 'pending');
252
148
  const outside = path.join(root, 'outside.json');
253
149
  fs.writeFileSync(outside, '{"featureName": "only"}');
254
150
  const dir = path.join(root, 'specs', '_shared');
@@ -260,9 +156,9 @@ test('a symlinked recorded feature is refused', () => {
260
156
 
261
157
  test('an absent file leaves every project exactly as it was', () => {
262
158
  withProject((root) => {
263
- legacyPacket(root, 'only', { status: 'in_progress', phase: 'execution' });
159
+ workflowPacket(root, 'only', 'pending');
264
160
  assert.equal(RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} }), null);
265
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).featureName, 'only');
161
+ assert.equal(RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {} }).featureName, 'only');
266
162
  });
267
163
  });
268
164
 
@@ -272,8 +168,8 @@ test('the shared workflow resolver ignores the recorded file', () => {
272
168
  // so the two gates apply it at their own call sites instead. Neither of those suites
273
169
  // writes the file, so nothing else pins this containment.
274
170
  withProject((root) => {
275
- legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
276
- legacyPacket(root, 'beta', { status: 'done', phase: 'closeout' });
171
+ workflowPacket(root, 'alpha', 'done');
172
+ workflowPacket(root, 'beta', 'done');
277
173
  recordActiveFeature(root, '{"featureName": "beta"}');
278
174
  const resolved = RESOLVER.resolveWorkflowCandidate({
279
175
  projectRoot: root, runtime: {}, target: null, includeCompleted: true,
@@ -288,7 +184,7 @@ test('the codex gate consults the file only after its payload', () => {
288
184
  assert.equal(typeof utils.readActiveFeatureTarget, 'function', 'the wrapper must be exported');
289
185
  assert.equal(typeof utils.extractExplicitTarget, 'function', 'the gate needs the payload extractor');
290
186
  withProject((root) => {
291
- legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
187
+ workflowPacket(root, 'alpha', 'done');
292
188
  recordActiveFeature(root, '{"featureName": "alpha"}');
293
189
  assert.deepEqual(
294
190
  utils.extractExplicitTarget({ featureName: 'beta' }) || utils.readActiveFeatureTarget({ projectRoot: root, runtime: {} }),
@@ -338,8 +234,8 @@ for (const [runtimeName, hook] of [['claude', 'src/claude/hooks/spec-gate.cjs']]
338
234
  // identity complaint whose stated remedy nothing could supply; naming the feature
339
235
  // makes the gate resolve it and report the violation that was there all along.
340
236
  gitProject((dir) => {
341
- legacyPacket(dir, 'alpha', { status: 'done', phase: 'closeout' });
342
- legacyPacket(dir, 'beta', { status: 'done', phase: 'closeout' });
237
+ workflowPacket(dir, 'alpha', 'done', 'pending');
238
+ workflowPacket(dir, 'beta', 'done', 'pending');
343
239
 
344
240
  const blocked = runGate(hook, dir);
345
241
  assert.match(blocked, /multiple active specs detected/, 'the reported block must reproduce');