@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,125 @@
1
+ 'use strict';
2
+
3
+ const { test, before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { spawnSync } = require('node:child_process');
9
+
10
+ const BUILD = path.resolve(__dirname, '../../../../scripts/build-claude-plugin.mjs');
11
+ const SAMPLE_PAYLOADS = {
12
+ SessionStart: { source: 'startup' },
13
+ UserPromptSubmit: { prompt: 'hello' },
14
+ PreToolUse: { tool_name: 'Read', tool_input: { file_path: 'README.md' } },
15
+ PostToolUse: { tool_name: 'Edit', tool_input: { file_path: 'README.md' }, tool_response: {} },
16
+ Stop: { stop_hook_active: false },
17
+ SubagentStart: { agent_type: 'cf:debugger' },
18
+ SubagentStop: { agent_type: 'cf:debugger', stop_hook_active: false, last_assistant_message: 'done' },
19
+ PreCompact: { trigger: 'manual' },
20
+ };
21
+
22
+ let work;
23
+ let tree;
24
+
25
+ function walk(root) {
26
+ if (!fs.existsSync(root)) return [];
27
+ return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
28
+ const full = path.join(root, entry.name);
29
+ return entry.isDirectory() ? [`${full}/`, ...walk(full)] : [full];
30
+ }).sort();
31
+ }
32
+
33
+ function makeRepo(name, withDotClaude) {
34
+ const repo = path.join(work, name);
35
+ fs.mkdirSync(repo, { recursive: true });
36
+ spawnSync('git', ['init', '-q'], { cwd: repo });
37
+ fs.writeFileSync(path.join(repo, 'README.md'), '# demo\n');
38
+ if (withDotClaude) {
39
+ fs.mkdirSync(path.join(repo, '.claude'));
40
+ fs.writeFileSync(path.join(repo, '.claude/settings.local.json'), '{}\n');
41
+ }
42
+ return repo;
43
+ }
44
+
45
+ // Runs every hook the plugin registers, the way Claude Code would, and returns
46
+ // the SessionStart output so callers can inspect it.
47
+ function runAllHooks(repo, env) {
48
+ const { hooks } = JSON.parse(fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8'));
49
+ let sessionOutput = '';
50
+ for (const [event, entries] of Object.entries(hooks)) {
51
+ for (const entry of entries) {
52
+ for (const { command } of entry.hooks) {
53
+ const file = command.match(/hooks\/([^"]+)"$/)[1];
54
+ const payload = { session_id: 'isolation-test', hook_event_name: event, cwd: repo, transcript_path: path.join(repo, 'none.jsonl'), ...SAMPLE_PAYLOADS[event] };
55
+ const run = spawnSync('node', [path.join(tree, 'hooks', file)], {
56
+ cwd: repo, input: JSON.stringify(payload), encoding: 'utf8', timeout: 20000,
57
+ env: { ...process.env, HOME: path.join(work, 'home'), CLAUDE_PLUGIN_ROOT: tree, CLAUDE_PROJECT_DIR: repo, ...env },
58
+ });
59
+ assert.equal(run.status, 0, `${event} ${file}: ${run.stderr}`);
60
+ if (event === 'SessionStart') sessionOutput += run.stdout;
61
+ }
62
+ }
63
+ }
64
+ return sessionOutput;
65
+ }
66
+
67
+ before(() => {
68
+ work = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-plugin-isolation-'));
69
+ tree = path.join(work, 'cf');
70
+ const built = spawnSync('node', [BUILD, '--out', tree], { encoding: 'utf8' });
71
+ assert.equal(built.status, 0, built.stderr);
72
+ });
73
+
74
+ after(() => fs.rmSync(work, { recursive: true, force: true }));
75
+
76
+ for (const [label, withDotClaude] of [['empty-repo', false], ['repo-with-dot-claude', true]]) {
77
+ test(`${label}: hooks write only under CLAUDE_PLUGIN_DATA`, () => {
78
+ const repo = makeRepo(label, withDotClaude);
79
+ const data = fs.mkdtempSync(path.join(work, `data-${label}-`));
80
+ const repoBefore = walk(repo);
81
+ const treeBefore = walk(tree);
82
+ runAllHooks(repo, { CLAUDE_PLUGIN_DATA: data });
83
+ assert.deepEqual(walk(repo), repoBefore, 'the project gained files');
84
+ assert.deepEqual(walk(tree), treeBefore, 'the plugin tree gained files');
85
+ assert.ok(walk(data).some((p) => p.endsWith('hook-log.jsonl') || p.includes('session-states')), 'no state under CLAUDE_PLUGIN_DATA');
86
+ });
87
+ }
88
+
89
+ test('no-data-env-uses-tmp: without CLAUDE_PLUGIN_DATA state goes to the OS temp directory', () => {
90
+ const repo = makeRepo('no-data', true);
91
+ const tmp = fs.mkdtempSync(path.join(work, 'tmp-'));
92
+ const repoBefore = walk(repo);
93
+ const treeBefore = walk(tree);
94
+ runAllHooks(repo, { CLAUDE_PLUGIN_DATA: '', TMPDIR: tmp });
95
+ assert.deepEqual(walk(repo), repoBefore, 'the project gained files');
96
+ assert.deepEqual(walk(tree), treeBefore, 'the plugin tree gained files');
97
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
98
+ assert.ok(walk(path.join(tmp, `cafekit-plugin-${uid}`)).length > 0, 'no state under the temp directory');
99
+ });
100
+
101
+ test('no-npx-notice: the plugin session hook prints no npx update notice and writes no update cache', () => {
102
+ const repo = makeRepo('npx-notice', true);
103
+ // An old npx install left version metadata behind; the plugin must ignore it.
104
+ fs.writeFileSync(path.join(repo, '.claude/cafekit.json'), JSON.stringify({ version: '0.0.1' }));
105
+ // A fresh cached "latest" makes the npx branch print its notice without any
106
+ // network access, so this test cannot pass merely because the registry is unreachable.
107
+ const cache = path.join(repo, '.claude/.cafekit-update-cache.json');
108
+ const cacheBytes = JSON.stringify({ ts: Date.now(), latest: '99.0.0' });
109
+ fs.writeFileSync(cache, cacheBytes);
110
+ const data = fs.mkdtempSync(path.join(work, 'data-notice-'));
111
+ const output = runAllHooks(repo, { CLAUDE_PLUGIN_DATA: data });
112
+ assert.doesNotMatch(output, /npx @haposoft\/cafekit/);
113
+ assert.equal(fs.readFileSync(cache, 'utf8'), cacheBytes, 'update cache rewritten');
114
+ });
115
+
116
+ test('npx-branch-unchanged: outside a plugin, source-tree hooks keep the temp log directory', () => {
117
+ const script = `delete process.env.CLAUDE_PLUGIN_ROOT; delete process.env.CLAUDE_PLUGIN_DATA;
118
+ process.stdout.write(require(${JSON.stringify(path.resolve(__dirname, '../lib/hook-state-dir.cjs'))}).hookStateDir());`;
119
+ const env = { ...process.env };
120
+ delete env.CLAUDE_PLUGIN_ROOT;
121
+ delete env.CLAUDE_PLUGIN_DATA;
122
+ const run = spawnSync('node', ['-e', script], { encoding: 'utf8', env });
123
+ assert.equal(run.status, 0, run.stderr);
124
+ assert.equal(run.stdout, path.join(os.tmpdir(), 'cafekit-hook-logs', 'claude'));
125
+ });
@@ -5,17 +5,33 @@
5
5
  // fixture; the capture is asserted from the file it writes and from what the
6
6
  // next session actually prints.
7
7
 
8
- const { test } = require('node:test');
8
+ const { test, after } = require('node:test');
9
9
  const assert = require('node:assert');
10
10
  const { spawnSync } = require('node:child_process');
11
11
  const fs = require('node:fs');
12
12
  const os = require('node:os');
13
13
  const path = require('node:path');
14
+ const crypto = require('node:crypto');
15
+
16
+ // Hooks run from source keep state under os.tmpdir(); a private one per test process
17
+ // keeps a parallel run of this file from reading or clearing this run's state.
18
+ const PRIVATE_TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-precompact-tmp-'));
19
+ for (const key of ['TMPDIR', 'TMP', 'TEMP']) process.env[key] = PRIVATE_TMP;
20
+ after(() => { fs.rmSync(PRIVATE_TMP, { recursive: true, force: true }); });
14
21
 
15
22
  const PRECOMPACT = path.join(__dirname, '..', 'precompact.cjs');
16
23
  const SESSION = path.join(__dirname, '..', 'session.cjs');
17
24
  const { hookStateDir } = require(path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'));
18
- const RECOVERY = path.join(hookStateDir(), 'compact-recovery.json');
25
+ const CAPTURES = new Set([path.join(hookStateDir(), 'compact-recovery.json')]);
26
+ function recoveryPath(session = 'compact-session') {
27
+ const key = crypto.createHash('sha256').update(session).digest('hex');
28
+ const file = path.join(hookStateDir(), `compact-recovery-${key}.json`);
29
+ CAPTURES.add(file);
30
+ return file;
31
+ }
32
+ const HOOK_ENV = { ...process.env };
33
+ delete HOOK_ENV.CLAUDE_PROJECT_DIR;
34
+ delete HOOK_ENV.PROJECT_ROOT;
19
35
 
20
36
  function fixture() {
21
37
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-precompact-'));
@@ -36,21 +52,24 @@ function fixture() {
36
52
  }
37
53
 
38
54
  function run(hook, dir, payload) {
55
+ if (typeof payload?.session_id === 'string' && payload.session_id.trim()) recoveryPath(payload.session_id);
39
56
  const result = spawnSync(process.execPath, [hook], {
40
57
  cwd: dir,
41
58
  input: JSON.stringify(payload),
42
59
  encoding: 'utf8',
43
- env: { ...process.env, CLAUDE_PROJECT_DIR: dir },
60
+ env: HOOK_ENV,
44
61
  });
45
62
  return { code: result.status, stdout: result.stdout || '', stderr: result.stderr || '' };
46
63
  }
47
64
 
48
65
  function clearCapture() {
49
- try { fs.unlinkSync(RECOVERY); } catch { /* absent */ }
66
+ for (const file of CAPTURES) {
67
+ try { fs.unlinkSync(file); } catch { /* absent */ }
68
+ }
50
69
  }
51
70
 
52
- function readCapture() {
53
- return JSON.parse(fs.readFileSync(RECOVERY, 'utf8'));
71
+ function readCapture(session) {
72
+ return JSON.parse(fs.readFileSync(recoveryPath(session), 'utf8'));
54
73
  }
55
74
 
56
75
  test('PreCompact records branch, HEAD, dirty count and the in-flight task', () => {
@@ -93,8 +112,8 @@ test('a compact restart prints the captured anchors alongside the authorization
93
112
  const dir = fixture();
94
113
  try {
95
114
  clearCapture();
96
- assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', trigger: 'manual', cwd: dir }).code, 0);
97
- const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', cwd: dir });
115
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', trigger: 'manual', session_id: 'compact-session', cwd: dir }).code, 0);
116
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session', cwd: dir });
98
117
  assert.strictEqual(restart.code, 0, restart.stderr);
99
118
  assert.match(restart.stdout, /VERIFY PENDING AUTHORIZATIONS/, 'the existing warning must survive');
100
119
  assert.match(restart.stdout, /State before compaction/);
@@ -119,6 +138,53 @@ test('a compact restart without a capture still prints the warning and nothing i
119
138
  }
120
139
  });
121
140
 
141
+ test('a compact restart in another session prints the warning without foreign anchors', () => {
142
+ const dir = fixture();
143
+ try {
144
+ clearCapture();
145
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'session-a', cwd: dir }).code, 0);
146
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-b', cwd: dir });
147
+ assert.strictEqual(restart.code, 0, restart.stderr);
148
+ assert.match(restart.stdout, /SESSION COMPRESSED/);
149
+ assert.doesNotMatch(restart.stdout, /State before compaction/);
150
+ } finally {
151
+ fs.rmSync(dir, { recursive: true, force: true });
152
+ clearCapture();
153
+ }
154
+ });
155
+
156
+ test('a compact restart in the same session restores its captured anchors', () => {
157
+ const dir = fixture();
158
+ try {
159
+ clearCapture();
160
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'session-a', cwd: dir }).code, 0);
161
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-a', cwd: dir });
162
+ assert.strictEqual(restart.code, 0, restart.stderr);
163
+ assert.match(restart.stdout, /SESSION COMPRESSED/);
164
+ assert.match(restart.stdout, /State before compaction[\s\S]*branch work/);
165
+ } finally {
166
+ fs.rmSync(dir, { recursive: true, force: true });
167
+ clearCapture();
168
+ }
169
+ });
170
+
171
+ for (const [captureId, restartId] of [[undefined, 'session-a'], ['session-a', undefined], [undefined, undefined]]) {
172
+ test(`a compact restart without ${captureId ? 'restart' : restartId ? 'capture' : 'either'} session identity keeps only the warning`, () => {
173
+ const dir = fixture();
174
+ try {
175
+ clearCapture();
176
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: captureId, cwd: dir }).code, 0);
177
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: restartId, cwd: dir });
178
+ assert.strictEqual(restart.code, 0, restart.stderr);
179
+ assert.match(restart.stdout, /SESSION COMPRESSED/);
180
+ assert.doesNotMatch(restart.stdout, /State before compaction/);
181
+ } finally {
182
+ fs.rmSync(dir, { recursive: true, force: true });
183
+ clearCapture();
184
+ }
185
+ });
186
+ }
187
+
122
188
  test('a normal session start prints no compaction anchors', () => {
123
189
  const dir = fixture();
124
190
  try {
@@ -138,7 +204,7 @@ test('PreCompact fails open outside a repository and on malformed input', () =>
138
204
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-precompact-bare-'));
139
205
  try {
140
206
  clearCapture();
141
- const noGit = run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', cwd: dir });
207
+ const noGit = run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'compact-session', cwd: dir });
142
208
  assert.strictEqual(noGit.code, 0, 'a directory outside git must not fail the hook');
143
209
  const record = readCapture();
144
210
  assert.strictEqual(record.branch, null, 'unavailable git facts are recorded as null, never guessed');
@@ -147,12 +213,148 @@ test('PreCompact fails open outside a repository and on malformed input', () =>
147
213
 
148
214
  clearCapture();
149
215
  const malformed = spawnSync(process.execPath, [PRECOMPACT], {
150
- cwd: dir, input: 'not json', encoding: 'utf8', env: { ...process.env, CLAUDE_PROJECT_DIR: dir },
216
+ cwd: dir, input: 'not json', encoding: 'utf8', env: HOOK_ENV,
151
217
  });
152
218
  assert.strictEqual(malformed.status, 0, 'malformed payload must fail open');
153
- assert.strictEqual(fs.existsSync(RECOVERY), false, 'a malformed payload writes no capture');
219
+ assert.strictEqual(fs.existsSync(recoveryPath()), false, 'a malformed payload writes no capture');
154
220
  } finally {
155
221
  fs.rmSync(dir, { recursive: true, force: true });
156
222
  clearCapture();
157
223
  }
158
224
  });
225
+
226
+ function installedFixture(callback) {
227
+ const root = fixture();
228
+ const source = path.resolve(__dirname, '../..');
229
+ const manifest = require('../../migration-manifest.json');
230
+ const sessions = new Set();
231
+ try {
232
+ for (const relative of manifest.runtime.files) {
233
+ const target = path.join(root, '.claude', relative);
234
+ fs.mkdirSync(path.dirname(target), { recursive: true });
235
+ fs.copyFileSync(path.join(source, relative), target);
236
+ }
237
+ for (const relative of manifest.scripts.required) {
238
+ const target = path.join(root, '.claude/scripts', relative);
239
+ fs.mkdirSync(path.dirname(target), { recursive: true });
240
+ fs.copyFileSync(path.join(source, 'scripts', relative), target);
241
+ }
242
+ const hooks = path.join(root, '.claude/hooks');
243
+ const invoke = (name, payload, overrides = {}) => {
244
+ if (typeof payload?.session_id === 'string' && payload.session_id.trim()) sessions.add(payload.session_id);
245
+ return spawnSync(process.execPath, [path.join(hooks, `${name}.cjs`)], {
246
+ cwd: root, env: HOOK_ENV, encoding: 'utf8', timeout: 10000,
247
+ input: typeof payload === 'string' ? payload : JSON.stringify(payload), ...overrides,
248
+ });
249
+ };
250
+ callback({ root: fs.realpathSync(root), hooks, invoke });
251
+ } finally {
252
+ for (const session of sessions) {
253
+ const key = crypto.createHash('sha256').update(session).digest('hex').slice(0, 16);
254
+ fs.rmSync(path.join(os.tmpdir(), `cafekit-rules-${key}.json`), { force: true });
255
+ }
256
+ fs.rmSync(root, { recursive: true, force: true });
257
+ }
258
+ }
259
+
260
+ test('two compacting sessions keep separate anchors and restore only their own branch', () => {
261
+ installedFixture(({ root, hooks, invoke }) => {
262
+ const sessions = [crypto.randomUUID(), crypto.randomUUID()];
263
+ const files = [];
264
+ for (const [index, session_id] of sessions.entries()) {
265
+ const branch = `anchor-${index === 0 ? 'first' : 'second'}`;
266
+ const checkout = spawnSync('git', ['-C', root, 'checkout', '-qb', branch], { encoding: 'utf8' });
267
+ assert.strictEqual(checkout.status, 0, checkout.stderr);
268
+ const capture = invoke('precompact', { hook_event_name: 'PreCompact', session_id, cwd: root });
269
+ assert.strictEqual(capture.status, 0, capture.stderr);
270
+ const hash = crypto.createHash('sha256').update(session_id).digest('hex');
271
+ files.push(path.join(hooks, '.logs', `compact-recovery-${hash}.json`));
272
+ }
273
+ for (const [index, file] of files.entries()) {
274
+ assert.ok(fs.existsSync(file), 'both session captures must survive');
275
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
276
+ assert.strictEqual(record.session_id, sessions[index]);
277
+ assert.strictEqual(record.branch, `anchor-${index === 0 ? 'first' : 'second'}`);
278
+ }
279
+ for (const [index, session_id] of sessions.entries()) {
280
+ const compact = invoke('session', { hook_event_name: 'SessionStart', source: 'compact', session_id, cwd: root });
281
+ assert.strictEqual(compact.status, 0, compact.stderr);
282
+ assert.match(compact.stdout, /SESSION COMPRESSED/);
283
+ assert.match(compact.stdout, /State before compaction/);
284
+ assert.ok(compact.stdout.includes(`branch anchor-${index === 0 ? 'first' : 'second'}`));
285
+ assert.ok(!compact.stdout.includes(`branch anchor-${index === 0 ? 'second' : 'first'}`));
286
+ }
287
+ });
288
+ });
289
+
290
+ test('PreCompact without a usable session identity creates no anchor or state directory', () => {
291
+ installedFixture(({ root, hooks, invoke }) => {
292
+ for (const session_id of [undefined, null, '', 42, ' ']) {
293
+ const capture = invoke('precompact', { hook_event_name: 'PreCompact', session_id, cwd: root });
294
+ assert.strictEqual(capture.status, 0, capture.stderr);
295
+ assert.strictEqual(fs.existsSync(path.join(hooks, '.logs')), false);
296
+ }
297
+ });
298
+ });
299
+
300
+ test('compact reinjects rules only for its own session even without matching anchors', () => {
301
+ installedFixture(({ root, hooks, invoke }) => {
302
+ const session = crypto.randomUUID();
303
+ const other = crypto.randomUUID();
304
+ const prompt = id => invoke('rules', { hook_event_name: 'UserPromptSubmit', session_id: id, cwd: root });
305
+ for (const id of [session, other]) {
306
+ const first = prompt(id);
307
+ assert.strictEqual(first.status, 0, first.stderr);
308
+ assert.match(first.stdout, /## Rules/);
309
+ assert.strictEqual(prompt(id).stdout, '');
310
+ }
311
+ fs.mkdirSync(path.join(hooks, '.logs'), { recursive: true });
312
+ const otherHash = crypto.createHash('sha256').update(other).digest('hex');
313
+ fs.writeFileSync(path.join(hooks, '.logs', `compact-recovery-${otherHash}.json`), JSON.stringify({ session_id: other }));
314
+ const compact = invoke('session', { hook_event_name: 'SessionStart', source: 'compact', session_id: session, cwd: root });
315
+ assert.strictEqual(compact.status, 0, compact.stderr);
316
+ assert.match(compact.stdout, /SESSION COMPRESSED/);
317
+ assert.doesNotMatch(compact.stdout, /State before compaction/);
318
+ const again = prompt(session);
319
+ assert.strictEqual(again.status, 0, again.stderr);
320
+ assert.match(again.stdout, /## Rules/);
321
+ assert.strictEqual(prompt(other).stdout, '');
322
+ });
323
+ });
324
+
325
+ test('malformed session input fails open without a stack and logs its crash', () => {
326
+ installedFixture(({ hooks, invoke }) => {
327
+ const result = invoke('session', '{');
328
+ assert.strictEqual(result.status, 0);
329
+ assert.strictEqual(result.stderr, '');
330
+ const rows = fs.readFileSync(path.join(hooks, '.logs/hook-log.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
331
+ assert.strictEqual(rows.length, 1);
332
+ assert.strictEqual(rows[0].hook, 'session');
333
+ assert.strictEqual(rows[0].status, 'crash');
334
+ });
335
+ });
336
+
337
+ test('installed session explains a nested cwd but omits the root line without a cwd', () => {
338
+ installedFixture(({ root, invoke }) => {
339
+ const cwd = path.join(root, 'sub/dir');
340
+ fs.mkdirSync(cwd, { recursive: true });
341
+ const nested = invoke('session', { source: 'startup', cwd }, { cwd });
342
+ assert.strictEqual(nested.status, 0, nested.stderr);
343
+ assert.ok(nested.stdout.includes(`CafeKit project root: ${root} (session working directory: ${cwd}).`));
344
+ const missing = invoke('session', { source: 'startup' }, { cwd });
345
+ assert.strictEqual(missing.status, 0, missing.stderr);
346
+ assert.doesNotMatch(missing.stdout, /CafeKit project root:/);
347
+ });
348
+ });
349
+
350
+ test('PreCompact bounds slow git calls and finishes within ten seconds', { timeout: 15000 }, () => {
351
+ installedFixture(({ root, invoke }) => {
352
+ const bin = path.join(root, 'fake-bin');
353
+ fs.mkdirSync(bin);
354
+ fs.writeFileSync(path.join(bin, 'git'), '#!/bin/sh\nexec /bin/sleep 30\n', { mode: 0o755 });
355
+ const start = Date.now();
356
+ const result = invoke('precompact', { hook_event_name: 'PreCompact', session_id: crypto.randomUUID(), cwd: root }, { env: { ...HOOK_ENV, PATH: bin } });
357
+ assert.strictEqual(result.status, 0, 'slow git must not exhaust the hook timeout');
358
+ assert.ok(Date.now() - start < 10000);
359
+ });
360
+ });
@@ -11,12 +11,16 @@ const os = require('node:os');
11
11
  const path = require('node:path');
12
12
 
13
13
  const HOOK = path.join(__dirname, '..', 'privacy-block.cjs');
14
+ const HOOK_ENV = { ...process.env };
15
+ delete HOOK_ENV.CLAUDE_PROJECT_DIR;
16
+ delete HOOK_ENV.PROJECT_ROOT;
14
17
 
15
18
  /** Run the hook with a tool payload; return process output. */
16
19
  function runHook(payload, cwd) {
17
20
  const res = spawnSync(process.execPath, [HOOK], {
18
21
  input: JSON.stringify({ ...payload, cwd: cwd || payload.cwd }),
19
22
  encoding: 'utf8',
23
+ env: HOOK_ENV,
20
24
  });
21
25
  return { code: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
22
26
  }
@@ -30,6 +34,29 @@ function permissionDecision(stdout) {
30
34
  return JSON.parse(stdout).hookSpecificOutput?.permissionDecision || null;
31
35
  }
32
36
 
37
+ test('sensitive reads list sorted distinct basenames and the remaining count', () => {
38
+ const dir = tmpDir();
39
+ try {
40
+ const command = 'cat token.json nested/.env secrets.json credentials.json .env';
41
+ const { code, stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
42
+ assert.strictEqual(code, 0);
43
+ const output = JSON.parse(stdout).hookSpecificOutput;
44
+ assert.strictEqual(output.permissionDecision, 'ask');
45
+ assert.strictEqual(output.permissionDecisionReason,
46
+ 'Sensitive file access requires approval: .env, credentials.json, secrets.json (+1 more)');
47
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
48
+ });
49
+
50
+ test('empty stdin asks because sensitive access cannot be evaluated safely', () => {
51
+ const result = spawnSync(process.execPath, [HOOK], { input: '', encoding: 'utf8', env: HOOK_ENV });
52
+ assert.strictEqual(result.status, 0);
53
+ assert.strictEqual(result.stderr, '');
54
+ assert.deepStrictEqual(JSON.parse(result.stdout).hookSpecificOutput, {
55
+ hookEventName: 'PreToolUse', permissionDecision: 'ask',
56
+ permissionDecisionReason: 'Sensitive access could not be evaluated safely.',
57
+ });
58
+ });
59
+
33
60
  test('asks natively for a direct Read of .env', () => {
34
61
  const dir = tmpDir();
35
62
  try {
@@ -183,20 +210,17 @@ for (const command of [
183
210
  }
184
211
 
185
212
  // --- Accepted gap: pure indirection ---
186
- // The target of `$FILE` is unknowable without running the shell. Treating every
213
+ // The target of a bare `$FILE` is unknowable without running the shell. Treating every
187
214
  // unresolved expansion as sensitive asked on `cat ~/.zshrc`, `wc -l *.cjs` and
188
- // `du -sh */` too, so these are allowed by design. `less "$HOME/.env"` is the
189
- // edge of that decision: a literal secret joined to a variable goes through.
215
+ // `du -sh */` too, so these are allowed by design. A secret's name spelled out next to
216
+ // the expansion -- `"$HOME/.env"`, `$(echo .env)` -- is not indirection and asks below.
190
217
  for (const command of [
191
218
  'cat $FILE',
192
219
  'cat "$FILE"',
193
220
  'cat ${FILE}',
194
221
  'cat $1',
195
- 'cat $(echo .env)',
196
- 'cat `echo .env`',
197
222
  'head $SECRET',
198
223
  'tail ${MY_FILE}',
199
- 'less "$HOME/.env"',
200
224
  'cat README.md $EXTRA',
201
225
  'echo ok; cat README.md $FILE',
202
226
  'cat README.md | head $FILE',
@@ -288,14 +312,29 @@ for (const command of [
288
312
  });
289
313
  }
290
314
 
291
- test('an assignment naming a secret does not ask, with or without a later read', () => {
315
+ for (const command of ['cat $(echo .env)', 'cat `echo .env`', 'less "$HOME/.env"']) {
316
+ test(`asks when a secret's name is spelled out next to an expansion: ${command}`, () => {
317
+ const dir = tmpDir();
318
+ try {
319
+ const result = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
320
+ assert.strictEqual(permissionDecision(result.stdout), 'ask');
321
+ } finally {
322
+ fs.rmSync(dir, { recursive: true, force: true });
323
+ }
324
+ });
325
+ }
326
+
327
+ test('an assignment naming a secret asks only when the same command reads it through the variable', () => {
292
328
  const dir = tmpDir();
293
329
  try {
294
330
  const assigned = runHook({ tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat README.md' } }, dir);
295
331
  assert.strictEqual(permissionDecision(assigned.stdout), null);
296
- // Pure indirection: the hook cannot resolve $FILE without running the shell.
332
+ // A literal assigned earlier in the same command is resolved, so reading it through the variable asks.
297
333
  const read = runHook({ tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat "$FILE"' } }, dir);
298
- assert.strictEqual(permissionDecision(read.stdout), null);
334
+ assert.strictEqual(permissionDecision(read.stdout), 'ask');
335
+ // A prefix assignment does not reach the expansion: the shell expands $FILE first.
336
+ const prefix = runHook({ tool_name: 'Bash', tool_input: { command: 'FILE=.env cat "$FILE"' } }, dir);
337
+ assert.strictEqual(permissionDecision(prefix.stdout), null);
299
338
  // Spelling the target out again does ask.
300
339
  const literal = runHook({ tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat .env' } }, dir);
301
340
  assert.strictEqual(permissionDecision(literal.stdout), 'ask');
@@ -371,3 +410,15 @@ test('fails closed on malformed/looping symlink (hop limit)', () => {
371
410
  fs.rmSync(dir, { recursive: true, force: true });
372
411
  }
373
412
  });
413
+
414
+ test('a Claude Read of an ordinary path is not affected by omp selector handling', () => {
415
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-privacy-selector-'));
416
+ try {
417
+ fs.writeFileSync(path.join(root, 'notes.md'), 'hi\n');
418
+ const result = spawnSync(process.execPath, [HOOK], {
419
+ input: JSON.stringify({ tool_name: 'Read', tool_input: { file_path: path.join(root, 'notes.md') }, cwd: root }),
420
+ encoding: 'utf8', env: HOOK_ENV,
421
+ });
422
+ assert.equal(result.stdout.trim(), '');
423
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
424
+ });
@@ -17,6 +17,31 @@ const path = require('node:path');
17
17
 
18
18
  const HOOK = path.join(__dirname, '..', 'session.cjs');
19
19
 
20
+ test('session.cjs does not export GIT_URL or remote userinfo', () => {
21
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-session-remote-'));
22
+ const envFile = path.join(dir, 'env.sh');
23
+ try {
24
+ git(['init', '-q'], dir);
25
+ const userinfo = 'fixture-user:fixture-placeholder';
26
+ const remote = `https://${userinfo}@example.invalid/r.git`;
27
+ const configured = spawnSync('git', ['remote', 'add', 'origin', remote], { cwd: dir, encoding: 'utf8' });
28
+ assert.strictEqual(configured.status, 0, 'fake remote setup must succeed');
29
+ const result = spawnSync(process.execPath, [HOOK], {
30
+ cwd: dir, input: JSON.stringify({ source: 'startup', cwd: dir }), encoding: 'utf8',
31
+ env: { ...process.env, CLAUDE_ENV_FILE: envFile },
32
+ });
33
+ assert.strictEqual(result.status, 0, 'session hook must exit successfully');
34
+ assert.ok(fs.existsSync(envFile), 'session env file must exist');
35
+ const content = fs.readFileSync(envFile, 'utf8');
36
+ assert.deepStrictEqual({
37
+ exportsGitUrl: /^\s*(?:export\s+)?GIT_URL=/m.test(content),
38
+ containsUserInfo: content.includes(userinfo),
39
+ }, { exportsGitUrl: false, containsUserInfo: false });
40
+ } finally {
41
+ fs.rmSync(dir, { recursive: true, force: true });
42
+ }
43
+ });
44
+
20
45
  function git(args, cwd) {
21
46
  const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
22
47
  if (res.status !== 0) {