@haposoft/cafekit 0.18.1 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/adv-skill.test.js +371 -0
  3. package/bin/__tests__/agent-authority.test.js +38 -0
  4. package/bin/__tests__/agent-instructions.test.js +54 -0
  5. package/bin/__tests__/claude-plugin-build.test.js +151 -0
  6. package/bin/__tests__/claude-settings.test.js +123 -0
  7. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  8. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  9. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  10. package/bin/__tests__/codex-hooks.test.js +812 -12
  11. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  12. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  13. package/bin/__tests__/codex-native.test.js +198 -58
  14. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  15. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  16. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  17. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  18. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  19. package/bin/__tests__/codex-research-projection.test.js +70 -0
  20. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  21. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  22. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  23. package/bin/__tests__/develop-contract.test.js +1 -1
  24. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  25. package/bin/__tests__/inspect-paths.test.js +183 -0
  26. package/bin/__tests__/installer-safety.test.js +335 -0
  27. package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
  28. package/bin/__tests__/migrate-apply.test.js +373 -0
  29. package/bin/__tests__/migrate-cli.test.js +152 -0
  30. package/bin/__tests__/migrate-merged.test.js +269 -0
  31. package/bin/__tests__/migrate-plan.test.js +271 -0
  32. package/bin/__tests__/omp-bridge.test.js +147 -17
  33. package/bin/__tests__/omp-hooks.test.js +35 -5
  34. package/bin/__tests__/orca-session.test.js +7 -0
  35. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  36. package/bin/__tests__/privacy-paths.test.js +501 -0
  37. package/bin/__tests__/usage-hook-retired.test.js +29 -9
  38. package/bin/__tests__/vibe-run.test.js +215 -0
  39. package/bin/__tests__/vibe-skill.test.js +549 -0
  40. package/bin/install.js +124 -1
  41. package/bin/lib/backup.js +43 -24
  42. package/bin/lib/codex-install.js +141 -12
  43. package/bin/lib/context.js +6 -1
  44. package/bin/lib/lock.js +98 -18
  45. package/bin/lib/managed-writer.js +16 -10
  46. package/bin/lib/migrate-apply.js +202 -0
  47. package/bin/lib/migrate-merged.js +342 -0
  48. package/bin/lib/migrate-plan.js +419 -0
  49. package/bin/lib/plugin-inventory.js +33 -0
  50. package/bin/phases/claude-runtime.js +93 -25
  51. package/bin/phases/claude-settings.js +22 -1
  52. package/bin/phases/codex-hooks.js +134 -33
  53. package/bin/phases/codex-runtime.js +110 -85
  54. package/bin/phases/copy-payload.js +50 -4
  55. package/bin/phases/omp-runtime.js +4 -26
  56. package/bin/phases/root-config.js +5 -2
  57. package/package.json +1 -1
  58. package/src/claude/CLAUDE.md +5 -1
  59. package/src/claude/agents/advisor.md +55 -0
  60. package/src/claude/agents/debugger.md +2 -3
  61. package/src/claude/agents/deployer.md +2 -1
  62. package/src/claude/agents/docs-keeper.md +12 -0
  63. package/src/claude/agents/inspector.md +1 -1
  64. package/src/claude/agents/project-manager.md +2 -2
  65. package/src/claude/agents/researcher.md +0 -1
  66. package/src/claude/agents/spec-maker.md +4 -1
  67. package/src/claude/gitignore +3 -0
  68. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  69. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  70. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  71. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  72. package/src/claude/hooks/__tests__/precompact.test.js +213 -11
  73. package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
  74. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  75. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  76. package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
  77. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  78. package/src/claude/hooks/__tests__/state.test.js +207 -7
  79. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  80. package/src/claude/hooks/agent.cjs +11 -7
  81. package/src/claude/hooks/inspect-block.cjs +20 -66
  82. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  83. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  84. package/src/claude/hooks/lib/parser.cjs +1 -1
  85. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  86. package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
  87. package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
  88. package/src/claude/hooks/lib/project-root.cjs +15 -0
  89. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  90. package/src/claude/hooks/lib/spec-touch.cjs +80 -0
  91. package/src/claude/hooks/lib/update-check.cjs +77 -0
  92. package/src/claude/hooks/plugin-context.cjs +80 -0
  93. package/src/claude/hooks/precompact.cjs +8 -15
  94. package/src/claude/hooks/privacy-block.cjs +25 -206
  95. package/src/claude/hooks/rules.cjs +10 -29
  96. package/src/claude/hooks/session.cjs +37 -117
  97. package/src/claude/hooks/spec-gate.cjs +9 -26
  98. package/src/claude/hooks/spec-state.cjs +44 -59
  99. package/src/claude/hooks/state.cjs +101 -52
  100. package/src/claude/migration-manifest.json +14 -2
  101. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  102. package/src/claude/plugin/core.md +71 -0
  103. package/src/claude/rules/hook-protocols.md +1 -1
  104. package/src/claude/rules/manage-docs.md +1 -2
  105. package/src/claude/rules/review-audit-self-decision.md +1 -1
  106. package/src/claude/rules/skill-workflow-routing.md +1 -1
  107. package/src/claude/rules/state-sync.md +14 -0
  108. package/src/claude/rules/workflow.md +1 -1
  109. package/src/claude/runtime.json +0 -3
  110. package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
  111. package/src/claude/scripts/provenance.cjs +54 -16
  112. package/src/claude/scripts/setup-statusline.cjs +88 -0
  113. package/src/claude/settings/settings.json +1 -1
  114. package/src/claude/skills/adv/SKILL.md +139 -0
  115. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  116. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  117. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  118. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  119. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  120. package/src/claude/skills/code-review/SKILL.md +4 -1
  121. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  122. package/src/claude/skills/develop/SKILL.md +5 -3
  123. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  124. package/src/claude/skills/fix/SKILL.md +2 -2
  125. package/src/claude/skills/git/SKILL.md +5 -3
  126. package/src/claude/skills/git/references/merge-pr.md +125 -0
  127. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  128. package/src/claude/skills/specs/SKILL.md +2 -2
  129. package/src/claude/skills/specs/references/review.md +1 -1
  130. package/src/claude/skills/specs/references/templates.md +2 -0
  131. package/src/claude/skills/vibe/SKILL.md +69 -0
  132. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  133. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  134. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  135. package/src/codex/AGENTS.md +9 -1
  136. package/src/codex/hooks/ask-guard.cjs +54 -0
  137. package/src/codex/hooks/inspect-block.cjs +15 -8
  138. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  139. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  140. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  141. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  142. package/src/codex/hooks/lib/privacy-state.cjs +116 -17
  143. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  144. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  145. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  146. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  147. package/src/codex/hooks/lib/state-store.cjs +5 -4
  148. package/src/codex/hooks/lib/update-check.cjs +1 -0
  149. package/src/codex/hooks/precompact.cjs +40 -0
  150. package/src/codex/hooks/privacy-approval.cjs +28 -17
  151. package/src/codex/hooks/privacy-block.cjs +11 -216
  152. package/src/codex/hooks/rules.cjs +14 -34
  153. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  154. package/src/codex/hooks/session.cjs +59 -11
  155. package/src/codex/hooks/spec-gate.cjs +21 -6
  156. package/src/codex/hooks/spec-state.cjs +32 -6
  157. package/src/codex/hooks/state.cjs +25 -4
  158. package/src/codex/hooks.json +39 -7
  159. package/src/codex/plugin/core.md +76 -0
  160. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  161. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  162. package/src/codex/rules/hook-protocols.md +13 -6
  163. package/src/codex/scripts/setup-agents.cjs +124 -0
  164. package/src/common/AGENTS.md +1 -1
  165. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
  166. package/src/omp/hooks/privacy-block.cjs +26 -207
  167. package/src/codex/rules/state-sync.md +0 -60
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ const { test, before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const crypto = require('node:crypto');
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+ const { spawnSync } = require('node:child_process');
10
+
11
+ const BUILD = path.resolve(__dirname, '../../../../scripts/build-claude-plugin.mjs');
12
+ let work;
13
+ let tree;
14
+
15
+ before(() => {
16
+ work = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-statusline-setup-'));
17
+ tree = path.join(work, "plug'in", 'cf');
18
+ const built = spawnSync('node', [BUILD, '--out', tree], { encoding: 'utf8' });
19
+ assert.equal(built.status, 0, built.stderr);
20
+ });
21
+
22
+ after(() => fs.rmSync(work, { recursive: true, force: true }));
23
+
24
+ const sha = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
25
+
26
+ // A private HOME per case: these tests must never touch the real ~/.claude.
27
+ function home() {
28
+ const dir = fs.mkdtempSync(path.join(work, 'home-'));
29
+ fs.mkdirSync(path.join(dir, '.claude'));
30
+ return { dir, data: path.join(dir, 'plugin-data') };
31
+ }
32
+
33
+ function sessionStart(h, root = tree) {
34
+ const run = spawnSync('node', [path.join(root, 'hooks/plugin-context.cjs')], {
35
+ input: '{}', encoding: 'utf8',
36
+ env: { ...process.env, HOME: h.dir, CLAUDE_PLUGIN_ROOT: root, CLAUDE_PLUGIN_DATA: h.data },
37
+ });
38
+ assert.equal(run.status, 0, run.stderr);
39
+ }
40
+
41
+ function setup(h, ...args) {
42
+ return spawnSync('node', [path.join(tree, 'scripts/setup-statusline.cjs'), ...args], {
43
+ encoding: 'utf8', env: { ...process.env, HOME: h.dir },
44
+ });
45
+ }
46
+
47
+ const launcher = (h) => path.join(h.data, 'status.cjs');
48
+ const settingsFile = (h) => path.join(h.dir, '.claude/settings.json');
49
+
50
+ test('launcher-written: SessionStart writes a launcher and a data pointer that run the statusline', () => {
51
+ const h = home();
52
+ sessionStart(h);
53
+ assert.equal(fs.readFileSync(path.join(h.dir, '.claude/cafekit-plugin-data'), 'utf8').trim(), h.data);
54
+ const run = spawnSync('node', [launcher(h)], { input: JSON.stringify({ model: { display_name: 'Test' }, workspace: { current_dir: work } }), encoding: 'utf8' });
55
+ assert.equal(run.status, 0, run.stderr);
56
+ assert.notEqual(run.stdout.trim(), '');
57
+ });
58
+
59
+ test('quote-in-path: a plugin root containing a quote still loads', () => {
60
+ assert.ok(tree.includes("'"));
61
+ const h = home();
62
+ sessionStart(h);
63
+ assert.equal(spawnSync('node', ['--check', launcher(h)]).status, 0);
64
+ });
65
+
66
+ test('launcher-missing-root-silent: a removed plugin version makes the launcher exit 0 silently', () => {
67
+ const h = home();
68
+ const gone = path.join(work, 'gone', 'cf');
69
+ spawnSync('node', [BUILD, '--out', gone]);
70
+ sessionStart(h, gone);
71
+ fs.rmSync(path.dirname(gone), { recursive: true, force: true });
72
+ const run = spawnSync('node', [launcher(h)], { input: '{}', encoding: 'utf8' });
73
+ assert.equal(run.status, 0);
74
+ assert.equal(run.stdout, '');
75
+ });
76
+
77
+ test('set-when-absent: setup adds statusLine and keeps every other key', () => {
78
+ const h = home();
79
+ sessionStart(h);
80
+ fs.writeFileSync(settingsFile(h), JSON.stringify({ theme: 'dark', permissions: { allow: ['Bash(ls:*)'] } }, null, 2));
81
+ const run = setup(h);
82
+ assert.equal(run.status, 0, run.stderr);
83
+ const settings = JSON.parse(fs.readFileSync(settingsFile(h), 'utf8'));
84
+ assert.equal(settings.theme, 'dark');
85
+ assert.deepEqual(settings.permissions, { allow: ['Bash(ls:*)'] });
86
+ assert.equal(settings.statusLine.type, 'command');
87
+ assert.ok(settings.statusLine.command.includes(launcher(h)));
88
+ });
89
+
90
+ test('replace-own-launcher: setup refreshes a statusLine that already points to the launcher', () => {
91
+ const h = home();
92
+ sessionStart(h);
93
+ fs.writeFileSync(settingsFile(h), JSON.stringify({ statusLine: { type: 'command', command: `node ${launcher(h)}`, padding: 4 } }));
94
+ assert.equal(setup(h).status, 0);
95
+ const { statusLine } = JSON.parse(fs.readFileSync(settingsFile(h), 'utf8'));
96
+ assert.equal(statusLine.command, `node ${JSON.stringify(launcher(h))}`);
97
+ assert.equal(statusLine.padding, 0);
98
+ });
99
+
100
+ test('keep-extended-launcher-command: a user command that wraps the launcher is not replaced', () => {
101
+ const h = home();
102
+ sessionStart(h);
103
+ for (const statusLine of [
104
+ { type: 'command', command: `node "${launcher(h)}" | tee /tmp/status.log` },
105
+ { type: 'command' },
106
+ 'not-an-object',
107
+ ]) {
108
+ fs.writeFileSync(settingsFile(h), JSON.stringify({ statusLine }));
109
+ const before = sha(settingsFile(h));
110
+ assert.equal(setup(h).status, 0);
111
+ assert.equal(sha(settingsFile(h)), before, `replaced ${JSON.stringify(statusLine)}`);
112
+ }
113
+ });
114
+
115
+ test('mode-preserved: a 0600 settings file stays 0600 after setup', () => {
116
+ const h = home();
117
+ sessionStart(h);
118
+ fs.writeFileSync(settingsFile(h), JSON.stringify({ env: { EXAMPLE_KEY: 'placeholder' } }), { mode: 0o600 });
119
+ fs.chmodSync(settingsFile(h), 0o600);
120
+ assert.equal(setup(h).status, 0);
121
+ assert.equal(fs.statSync(settingsFile(h)).mode & 0o777, 0o600);
122
+ assert.ok(JSON.parse(fs.readFileSync(settingsFile(h), 'utf8')).statusLine);
123
+ });
124
+
125
+ test('launcher-broken-version-reports: a present but broken statusline is not silenced', () => {
126
+ const h = home();
127
+ const broken = path.join(work, 'broken', 'cf');
128
+ spawnSync('node', [BUILD, '--out', broken]);
129
+ sessionStart(h, broken);
130
+ fs.rmSync(path.join(broken, 'hooks/lib/color.cjs'));
131
+ const run = spawnSync('node', [launcher(h)], { input: '{}', encoding: 'utf8' });
132
+ assert.notEqual(run.status, 0);
133
+ assert.match(run.stderr, /color\.cjs/);
134
+ });
135
+
136
+ test('keep-foreign-status-cjs-byte-identical: another statusLine named status.cjs is left alone', () => {
137
+ const h = home();
138
+ sessionStart(h);
139
+ fs.writeFileSync(settingsFile(h), JSON.stringify({ statusLine: { type: 'command', command: 'node ~/tools/status.cjs' } }));
140
+ const before = sha(settingsFile(h));
141
+ const run = setup(h);
142
+ assert.equal(run.status, 0);
143
+ assert.equal(sha(settingsFile(h)), before);
144
+ assert.match(run.stdout + run.stderr, /already has a statusLine/);
145
+ });
146
+
147
+ test('malformed-exit-1: unparsable settings stay byte-identical and exit 1', () => {
148
+ const h = home();
149
+ sessionStart(h);
150
+ fs.writeFileSync(settingsFile(h), '{ "theme": "dark", // comment\n}');
151
+ const before = sha(settingsFile(h));
152
+ const run = setup(h);
153
+ assert.equal(run.status, 1);
154
+ assert.equal(sha(settingsFile(h)), before);
155
+ });
156
+
157
+ test('symlink-preserved: a symlinked settings file is updated through its target', () => {
158
+ const h = home();
159
+ sessionStart(h);
160
+ const target = path.join(h.dir, 'dotfiles-settings.json');
161
+ fs.writeFileSync(target, '{}');
162
+ fs.symlinkSync(target, settingsFile(h));
163
+ assert.equal(setup(h).status, 0);
164
+ assert.ok(fs.lstatSync(settingsFile(h)).isSymbolicLink());
165
+ assert.ok(JSON.parse(fs.readFileSync(target, 'utf8')).statusLine);
166
+ });
167
+
168
+ test('missing-pointer-exit-1: setup before any plugin session writes nothing', () => {
169
+ const h = home();
170
+ fs.writeFileSync(settingsFile(h), '{}');
171
+ const run = setup(h);
172
+ assert.equal(run.status, 1);
173
+ assert.equal(fs.readFileSync(settingsFile(h), 'utf8'), '{}');
174
+ });
175
+
176
+ test('command-registered: the plugin ships /cf:setup-statusline calling the script', () => {
177
+ const command = fs.readFileSync(path.join(tree, 'commands/setup-statusline.md'), 'utf8');
178
+ assert.match(command, /\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/setup-statusline\.cjs/);
179
+ });
@@ -4,7 +4,7 @@
4
4
  // a real subprocess: a Stop payload is piped to stdin; we assert exit code and
5
5
  // whether stdout carries {"decision":"block",...}. The hook resolves its own
6
6
  // state directory; running from source that is a temp directory rather than the
7
- // source tree, and it is shared, so each test seeds/clears it deliberately.
7
+ // source tree, so each test seeds/clears it deliberately.
8
8
 
9
9
  const { test, beforeEach, after } = require('node:test');
10
10
  const assert = require('node:assert');
@@ -14,6 +14,11 @@ const fs = require('node:fs');
14
14
  const os = require('node:os');
15
15
  const path = require('node:path');
16
16
 
17
+ // Hooks run from source keep state under os.tmpdir(); a private one per test process
18
+ // keeps a parallel run of this file from reading or clearing this run's state.
19
+ const PRIVATE_TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-tmp-'));
20
+ for (const key of ['TMPDIR', 'TMP', 'TEMP']) process.env[key] = PRIVATE_TMP;
21
+
17
22
  const HOOK = path.join(__dirname, '..', 'spec-gate.cjs');
18
23
  const { hookStateDir } = require(path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'));
19
24
  const CACHE = path.join(hookStateDir(), 'spec-gate-last.json');
@@ -29,9 +34,10 @@ const FIXTURE_ARTIFACT_DIGEST = crypto.createHash('sha256').update(FIXTURE_ARTIF
29
34
  function runHook(payload, cwd) {
30
35
  const projectRoot = cwd || payload.cwd;
31
36
  const specsRoot = path.join(projectRoot, 'specs');
32
- // PROJECT_ROOT wins over cwd in the hook; pin it to the fixture so a host
33
- // monorepo's real specs/ cannot leak into the test (e.g. active rtk-* specs).
34
- const env = { ...process.env, PROJECT_ROOT: projectRoot };
37
+ // Source hooks use payload.cwd without inherited root overrides.
38
+ const env = { ...process.env };
39
+ delete env.CLAUDE_PROJECT_DIR;
40
+ delete env.PROJECT_ROOT;
35
41
  const res = spawnSync(process.execPath, [HOOK], {
36
42
  input: JSON.stringify({
37
43
  hook_event_name: 'Stop',
@@ -235,6 +241,8 @@ function installClaudeGate(root, policyMode = 'valid') {
235
241
  fs.copyFileSync(HOOK, path.join(hooks, 'spec-gate.cjs'));
236
242
  fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
237
243
  fs.copyFileSync(path.join(__dirname, '..', 'lib', 'runtime-dir.cjs'), path.join(hooks, 'lib', 'runtime-dir.cjs'));
244
+ fs.copyFileSync(path.join(__dirname, '..', 'lib', 'project-root.cjs'), path.join(hooks, 'lib', 'project-root.cjs'));
245
+ fs.copyFileSync(path.join(__dirname, '..', 'lib', 'spec-touch.cjs'), path.join(hooks, 'lib', 'spec-touch.cjs'));
238
246
  fs.copyFileSync(path.join(__dirname, '..', 'lib', 'hook-payload.cjs'), path.join(hooks, 'lib', 'hook-payload.cjs'));
239
247
  fs.copyFileSync(
240
248
  path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'),
@@ -288,6 +296,7 @@ beforeEach(() => {
288
296
 
289
297
  after(() => {
290
298
  clearCache();
299
+ fs.rmSync(PRIVATE_TMP, { recursive: true, force: true });
291
300
  });
292
301
 
293
302
  test('1. newly-done task WITHOUT receipt → block, reason names task path', () => {
@@ -1264,11 +1273,14 @@ function boundReceipt(base = VALID_BASE, head = VALID_HEAD) {
1264
1273
  }
1265
1274
 
1266
1275
  function runHookFrom(dir, cwd) {
1276
+ const env = { ...process.env };
1277
+ delete env.CLAUDE_PROJECT_DIR;
1278
+ delete env.PROJECT_ROOT;
1267
1279
  const res = spawnSync(process.execPath, [HOOK], {
1268
1280
  cwd,
1269
1281
  input: JSON.stringify({ hook_event_name: 'Stop', session_id: 'test', transcript_path: '/tmp/t', stop_hook_active: false, cwd: dir }),
1270
1282
  encoding: 'utf8',
1271
- env: { ...process.env, PROJECT_ROOT: dir },
1283
+ env,
1272
1284
  });
1273
1285
  return { code: res.status, stdout: (res.stdout || '').trim(), stderr: res.stderr || '' };
1274
1286
  }
@@ -1371,17 +1383,142 @@ test('41. unborn HEAD fails closed before any receipt check', () => {
1371
1383
  clearCache();
1372
1384
  const body = parseBlock(runHook({}, dir).stdout);
1373
1385
  assert.ok(body && body.decision === 'block', 'an unborn HEAD must block');
1374
- // The block arrives from provenance derivation, which cannot resolve a base
1375
- // commit here, so the receipt check is never reached. Asserting the reason
1376
- // keeps this case honest: it guards fail-closed behavior on an unborn HEAD,
1377
- // not the binding-mode selector. The selector's own HEAD-unreadable branch
1378
- // is guarded by cases 38 and 39.
1379
- assert.match(body.reason, /controlled failure/);
1386
+ // A done task still blocks before receipt validation, with guidance to
1387
+ // create the first commit before recording its Base/Head.
1388
+ assert.match(body.reason, /first commit/);
1380
1389
  } finally {
1381
1390
  fs.rmSync(dir, { recursive: true, force: true });
1382
1391
  }
1383
1392
  });
1384
1393
 
1394
+ function noHistoryFixture() {
1395
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-no-history-'));
1396
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test']]) {
1397
+ const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
1398
+ assert.strictEqual(result.status, 0, result.stderr);
1399
+ }
1400
+ installClaudeGate(dir);
1401
+ fs.copyFileSync(path.join(__dirname, '..', 'spec-state.cjs'), path.join(dir, '.claude/hooks/spec-state.cjs'));
1402
+ return dir;
1403
+ }
1404
+
1405
+ test('Stop still blocks multiple active specs for an untouched session', () => {
1406
+ const dir = tmpDir();
1407
+ try {
1408
+ const gate = installClaudeGate(dir);
1409
+ for (const feature of ['demo', 'other']) writeNoHistoryPacket(dir, feature, 'in_progress');
1410
+ const result = spawnSync(process.execPath, [gate], {
1411
+ cwd: dir, encoding: 'utf8', env: { ...process.env, PROJECT_ROOT: dir, CLAUDE_PROJECT_DIR: dir },
1412
+ input: JSON.stringify({ cwd: dir, session_id: 'untouched-session', hook_event_name: 'Stop', prompt: 'Continue' }),
1413
+ });
1414
+ assert.strictEqual(result.status, 0, result.stderr);
1415
+ const body = parseBlock(result.stdout);
1416
+ assert.strictEqual(body.decision, 'block');
1417
+ assert.match(body.reason, /multiple active specs/);
1418
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1419
+ });
1420
+
1421
+ function writeNoHistoryPacket(dir, feature, status) {
1422
+ const packet = path.join(dir, 'specs', feature);
1423
+ fs.mkdirSync(packet, { recursive: true });
1424
+ fs.writeFileSync(path.join(packet, 'plan.md'), `# ${feature}\nSpecs-Contract: process-first-ready-v1\n`);
1425
+ fs.writeFileSync(path.join(packet, 'task-01-work.md'), [
1426
+ '# Task 01: work', '', `Status: ${status}`, '', '## Dependencies', '', '- none', '',
1427
+ '## Verification Plan', '', '- Command: node --test', '',
1428
+ ...(status === 'done' ? ['## Receipt', '', 'Verification: PASS', ''] : []),
1429
+ ].join('\n'));
1430
+ }
1431
+
1432
+ function runInstalledHistoryHook(dir, hook = 'spec-gate', extraEnv = {}) {
1433
+ return spawnSync(process.execPath, [path.join(dir, '.claude/hooks', `${hook}.cjs`)], {
1434
+ cwd: dir, encoding: 'utf8',
1435
+ env: { ...process.env, PROJECT_ROOT: dir, CLAUDE_PROJECT_DIR: dir, ...extraEnv },
1436
+ input: JSON.stringify({ cwd: dir, session_id: 'no-history',
1437
+ hook_event_name: hook === 'spec-state' ? 'UserPromptSubmit' : 'Stop', prompt: 'Continue specs/demo' }),
1438
+ });
1439
+ }
1440
+
1441
+ test('no commits allows Stop without done tasks and prints one reminder', () => {
1442
+ const dir = noHistoryFixture();
1443
+ try {
1444
+ writeNoHistoryPacket(dir, 'demo', 'in_progress');
1445
+ const gate = runInstalledHistoryHook(dir);
1446
+ assert.strictEqual(gate.status, 0, gate.stderr);
1447
+ assert.strictEqual(gate.stdout.trim(), '');
1448
+ const state = runInstalledHistoryHook(dir, 'spec-state');
1449
+ assert.strictEqual(state.status, 0, state.stderr);
1450
+ assert.match(state.stdout, /no commits/);
1451
+ assert.strictEqual(state.stdout.trim().split('\n').length, 1);
1452
+ assert.doesNotMatch(state.stdout, /Spec tollgate unavailable/);
1453
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1454
+ });
1455
+
1456
+ test('no commits blocks a done task with first commit guidance', () => {
1457
+ const dir = noHistoryFixture();
1458
+ try {
1459
+ writeNoHistoryPacket(dir, 'demo', 'done');
1460
+ const result = runInstalledHistoryHook(dir);
1461
+ assert.strictEqual(result.status, 0, result.stderr);
1462
+ const body = parseBlock(result.stdout);
1463
+ assert.strictEqual(body.decision, 'block');
1464
+ assert.match(body.reason, /first commit/);
1465
+ assert.doesNotMatch(body.reason, /controlled failure/);
1466
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1467
+ });
1468
+
1469
+ test('no commits blocks two completed packets with first commit guidance', () => {
1470
+ const dir = noHistoryFixture();
1471
+ try {
1472
+ for (const feature of ['demo', 'other']) writeNoHistoryPacket(dir, feature, 'done');
1473
+ const result = runInstalledHistoryHook(dir);
1474
+ assert.strictEqual(result.status, 0, result.stderr);
1475
+ const body = parseBlock(result.stdout);
1476
+ assert.strictEqual(body.decision, 'block');
1477
+ assert.match(body.reason, /first commit/);
1478
+ assert.doesNotMatch(body.reason, /controlled failure/);
1479
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1480
+ });
1481
+
1482
+ test('a corrupt current branch ref remains a controlled failure', () => {
1483
+ const dir = tmpDir();
1484
+ try {
1485
+ installClaudeGate(dir);
1486
+ writeNoHistoryPacket(dir, 'demo', 'in_progress');
1487
+ const ref = spawnSync('git', ['-C', dir, 'symbolic-ref', '-q', 'HEAD'], { encoding: 'utf8' });
1488
+ assert.strictEqual(ref.status, 0, ref.stderr);
1489
+ fs.writeFileSync(path.join(dir, '.git', ref.stdout.trim()), 'corrupt-ref\n');
1490
+ const result = runInstalledHistoryHook(dir);
1491
+ assert.strictEqual(result.status, 0, result.stderr);
1492
+ const body = parseBlock(result.stdout);
1493
+ assert.strictEqual(body.decision, 'block');
1494
+ assert.match(body.reason, /controlled failure/);
1495
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1496
+ });
1497
+
1498
+ test('unavailable git remains a controlled failure in a repo without commits', () => {
1499
+ const dir = noHistoryFixture();
1500
+ try {
1501
+ writeNoHistoryPacket(dir, 'demo', 'in_progress');
1502
+ const emptyPath = path.join(dir, 'empty-path');
1503
+ fs.mkdirSync(emptyPath);
1504
+ const result = runInstalledHistoryHook(dir, 'spec-gate', { PATH: emptyPath });
1505
+ assert.strictEqual(result.status, 0, result.stderr);
1506
+ const body = parseBlock(result.stdout);
1507
+ assert.strictEqual(body.decision, 'block');
1508
+ assert.match(body.reason, /controlled failure/);
1509
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1510
+ });
1511
+
1512
+ test('a committed repo still accepts a valid done task receipt', () => {
1513
+ const dir = makeFixture({ evidence: 'valid' });
1514
+ try {
1515
+ installClaudeGate(dir);
1516
+ const result = runInstalledHistoryHook(dir);
1517
+ assert.strictEqual(result.status, 0, result.stderr);
1518
+ assert.strictEqual(result.stdout.trim(), '');
1519
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
1520
+ });
1521
+
1385
1522
  test('42. a dirty tree outside specs does not reopen a committed receipt', () => {
1386
1523
  // Contract change. This used to require a block. `Base` is the last commit
1387
1524
  // touching anything outside the specs root, so a receipt written before any
@@ -1866,3 +2003,82 @@ test('a packet holding both plan.md and spec.json is read as process-first and i
1866
2003
  fs.rmSync(dir, { recursive: true, force: true });
1867
2004
  }
1868
2005
  });
2006
+
2007
+ // Head is sha256 over one JSON line per path with the file bytes as base64. It is now streamed instead of built, and
2008
+ // must stay byte-identical: every receipt already written carries a Head computed the old way.
2009
+ function referenceHead(dir) {
2010
+ const git = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'buffer' }).stdout;
2011
+ const split = (buffer) => buffer.toString('utf8').split('\0').filter(Boolean);
2012
+ const raw = split(git('status', '--porcelain=v1', '-z', '--untracked-files=all'));
2013
+ const statuses = new Map();
2014
+ for (let index = 0; index < raw.length; index += 1) {
2015
+ const xy = raw[index].slice(0, 2);
2016
+ const first = raw[index].slice(3);
2017
+ if (xy.includes('R') || xy.includes('C')) {
2018
+ const next = raw[++index];
2019
+ statuses.set(first, { xy, role: 'rename-source', counterpart: next });
2020
+ statuses.set(next, { xy, role: 'rename-target', counterpart: first });
2021
+ } else statuses.set(first, { xy, role: xy === '??' || xy === '!!' ? 'untracked' : 'tracked' });
2022
+ }
2023
+ const paths = [...new Set([...split(git('ls-files', '-z', '--cached')), ...split(git('ls-tree', '-r', '-z', '--name-only', 'HEAD')), ...statuses.keys()])]
2024
+ .filter((relative) => !relative.startsWith('specs/') && !PROVENANCE.RUNTIME_STATE_ROOTS.some((root) => relative === root || relative.startsWith(`${root}/`)))
2025
+ .sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
2026
+ const hash = crypto.createHash('sha256').update('cafekit-provenance\0');
2027
+ for (const relative of paths) {
2028
+ const absolute = path.join(dir, relative);
2029
+ const status = statuses.get(relative) || null;
2030
+ let stat;
2031
+ try { stat = fs.lstatSync(absolute); } catch {
2032
+ hash.update(`${JSON.stringify({ path: relative, type: 'deleted', mode: '0000', status, content: null })}\n`);
2033
+ continue;
2034
+ }
2035
+ const type = stat.isSymbolicLink() ? 'symlink' : stat.isFile() ? 'file' : 'directory';
2036
+ const content = type === 'symlink' ? Buffer.from(fs.readlinkSync(absolute), 'utf8') : type === 'file' ? fs.readFileSync(absolute) : Buffer.alloc(0);
2037
+ const mode = (stat.mode & 0o7777).toString(8).padStart(4, '0');
2038
+ hash.update(`${JSON.stringify({ path: relative, type, mode, status, content: content.toString('base64') })}\n`);
2039
+ }
2040
+ return hash.digest('hex');
2041
+ }
2042
+
2043
+ function provenanceFixture() {
2044
+ const dir = fs.realpathSync(tmpDir());
2045
+ fs.mkdirSync(path.join(dir, 'specs', FEATURE), { recursive: true });
2046
+ fs.writeFileSync(path.join(dir, 'specs', FEATURE, 'plan.md'), '# Demo plan\n');
2047
+ return dir;
2048
+ }
2049
+
2050
+ test('Head is unchanged by streaming: files around the 3 MB chunk edge, symlink, rename, deletion, quoted names', () => {
2051
+ const dir = provenanceFixture();
2052
+ try {
2053
+ const chunk = 3 * 1024 * 1024;
2054
+ for (const size of [0, 1, 2, 4, chunk - 1, chunk, chunk + 1, 2 * chunk + 2]) {
2055
+ fs.writeFileSync(path.join(dir, `bin-${size}.dat`), crypto.randomBytes(size));
2056
+ }
2057
+ fs.writeFileSync(path.join(dir, 'tiếng "việt" \\ name.txt'), 'x');
2058
+ fs.writeFileSync(path.join(dir, 'run.sh'), '#!/bin/sh\n');
2059
+ fs.chmodSync(path.join(dir, 'run.sh'), 0o755);
2060
+ fs.writeFileSync(path.join(dir, 'gone.txt'), 'g');
2061
+ fs.writeFileSync(path.join(dir, 'old-name.txt'), 'r');
2062
+ for (const args of [['add', '-A'], ['commit', '-qm', 'files']]) assert.strictEqual(spawnSync('git', ['-C', dir, ...args]).status, 0);
2063
+ fs.unlinkSync(path.join(dir, 'gone.txt'));
2064
+ assert.strictEqual(spawnSync('git', ['-C', dir, 'mv', 'old-name.txt', 'new-name.txt']).status, 0);
2065
+ fs.symlinkSync('run.sh', path.join(dir, 'link'));
2066
+ fs.writeFileSync(path.join(dir, 'untracked.bin'), crypto.randomBytes(1000));
2067
+ assert.strictEqual(workflowRuntimeContext(dir).head, referenceHead(dir));
2068
+ } finally {
2069
+ fs.rmSync(dir, { recursive: true, force: true });
2070
+ }
2071
+ });
2072
+
2073
+ test('a checkout whose files exceed the longest string V8 can hold still yields a Head', () => {
2074
+ const dir = provenanceFixture();
2075
+ try {
2076
+ // Sparse: 600 MB of zeros on disk costs nothing, but its base64 alone (800M characters) is past V8's string limit.
2077
+ const descriptor = fs.openSync(path.join(dir, 'large-asset.bin'), 'w');
2078
+ fs.ftruncateSync(descriptor, 600 * 1024 * 1024);
2079
+ fs.closeSync(descriptor);
2080
+ assert.match(workflowRuntimeContext(dir).head, /^[0-9a-f]{64}$/);
2081
+ } finally {
2082
+ fs.rmSync(dir, { recursive: true, force: true });
2083
+ }
2084
+ });