@haposoft/cafekit 0.18.1 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +774 -6
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +192 -59
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +1 -1
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  18. package/bin/__tests__/omp-bridge.test.js +66 -12
  19. package/bin/__tests__/omp-hooks.test.js +11 -5
  20. package/bin/__tests__/orca-session.test.js +7 -0
  21. package/bin/__tests__/privacy-paths.test.js +317 -0
  22. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  23. package/bin/install.js +25 -0
  24. package/bin/lib/codex-install.js +134 -12
  25. package/bin/lib/context.js +1 -1
  26. package/bin/phases/claude-runtime.js +91 -25
  27. package/bin/phases/claude-settings.js +22 -1
  28. package/bin/phases/codex-hooks.js +96 -33
  29. package/bin/phases/codex-runtime.js +45 -52
  30. package/bin/phases/copy-payload.js +1 -3
  31. package/package.json +1 -1
  32. package/src/claude/CLAUDE.md +4 -0
  33. package/src/claude/gitignore +3 -0
  34. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  35. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  36. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  37. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  38. package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
  39. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  40. package/src/claude/hooks/__tests__/state.test.js +207 -7
  41. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  42. package/src/claude/hooks/agent.cjs +7 -6
  43. package/src/claude/hooks/inspect-block.cjs +15 -63
  44. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  45. package/src/claude/hooks/lib/parser.cjs +1 -1
  46. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  47. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  48. package/src/claude/hooks/lib/project-root.cjs +15 -0
  49. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  50. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  51. package/src/claude/hooks/lib/update-check.cjs +77 -0
  52. package/src/claude/hooks/precompact.cjs +8 -15
  53. package/src/claude/hooks/privacy-block.cjs +23 -205
  54. package/src/claude/hooks/rules.cjs +6 -28
  55. package/src/claude/hooks/session.cjs +34 -117
  56. package/src/claude/hooks/spec-gate.cjs +9 -26
  57. package/src/claude/hooks/spec-state.cjs +43 -59
  58. package/src/claude/hooks/state.cjs +96 -51
  59. package/src/claude/migration-manifest.json +8 -1
  60. package/src/claude/rules/hook-protocols.md +1 -1
  61. package/src/claude/runtime.json +0 -3
  62. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  63. package/src/claude/scripts/provenance.cjs +14 -0
  64. package/src/claude/settings/settings.json +1 -1
  65. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  66. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  67. package/src/claude/skills/git/SKILL.md +1 -1
  68. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  69. package/src/codex/AGENTS.md +8 -0
  70. package/src/codex/hooks/ask-guard.cjs +54 -0
  71. package/src/codex/hooks/inspect-block.cjs +11 -7
  72. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  73. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  74. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  75. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  76. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  77. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  78. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  79. package/src/codex/hooks/lib/update-check.cjs +1 -0
  80. package/src/codex/hooks/precompact.cjs +39 -0
  81. package/src/codex/hooks/privacy-approval.cjs +28 -17
  82. package/src/codex/hooks/privacy-block.cjs +9 -215
  83. package/src/codex/hooks/rules.cjs +4 -30
  84. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  85. package/src/codex/hooks/session.cjs +47 -11
  86. package/src/codex/hooks/spec-gate.cjs +21 -6
  87. package/src/codex/hooks/spec-state.cjs +32 -6
  88. package/src/codex/hooks/state.cjs +25 -4
  89. package/src/codex/hooks.json +39 -7
  90. package/src/codex/rules/hook-protocols.md +13 -6
  91. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  92. package/src/omp/hooks/privacy-block.cjs +24 -206
  93. package/src/codex/rules/state-sync.md +0 -60
@@ -5,7 +5,8 @@ const path = require('path');
5
5
  const { PLATFORMS } = require('../lib/context');
6
6
  const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
7
7
  const { report, treeAction } = require('./report');
8
- const { copyRulesDirectory, copyOutputStylesDirectory } = require('./claude-runtime');
8
+ const { isGeneratedArtifact } = require('../lib/copy-utils');
9
+ const { copyRulesDirectory, copyOutputStylesDirectory, removeObsoleteRuntimeFiles } = require('./claude-runtime');
9
10
  const {
10
11
  managedRange,
11
12
  upsertManagedCodexBlock
@@ -23,30 +24,9 @@ const CODEX_OWN_RUNTIME = [
23
24
  ['runtime.json', 'runtime.json']
24
25
  ];
25
26
 
26
- /**
27
- * Delete hooks, scripts and skill files the manifest marks obsolete. Codex tracker keys are project-relative
28
- * (`.codex/hooks/…`), so the record is pruned through keyFor, and always: a record whose
29
- * file is already gone must still leave the manifest.
30
- */
27
+ /** Use the shared ownership and symlink checks for Codex runtime cleanup. */
31
28
  function removeObsoleteCodexHooks(ctx, platformKey) {
32
- const platform = PLATFORMS[platformKey];
33
- const tracker = ctx.trackers && ctx.trackers[platformKey];
34
- const obsoleteHooks = (ctx.manifest?.obsolete?.runtimeFiles || []).filter((rel) => rel.startsWith('hooks/') || rel.startsWith('scripts/') || rel.startsWith('skills/'));
35
- for (const rel of obsoleteHooks) {
36
- // Skill files live in the shared skills directory, not under the runtime folder.
37
- const target = rel.startsWith('skills/')
38
- ? path.join(platform.skillsDir, rel.slice('skills/'.length))
39
- : path.join(platform.folder, rel);
40
- const exists = fs.existsSync(target);
41
- if (!ctx.dryRun) {
42
- if (exists) fs.rmSync(target, { force: true, recursive: fs.statSync(target).isDirectory() });
43
- if (tracker) tracker.prune(tracker.keyFor(target));
44
- }
45
- if (exists) {
46
- ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete Codex runtime: ${rel}`);
47
- ctx.results.updated++;
48
- }
49
- }
29
+ removeObsoleteRuntimeFiles(ctx, platformKey);
50
30
  }
51
31
 
52
32
  function writeSourceFile(ctx, platformKey, src, dest, label) {
@@ -82,13 +62,32 @@ function installRuntimeFiles(ctx, platformKey) {
82
62
 
83
63
  mergeCodexHooks(ctx, platformKey);
84
64
 
85
- const agg = copyManagedTree({
86
- src: path.join(CODEX_SRC, 'hooks'),
87
- dest: path.join(platform.folder, 'hooks'),
88
- platformFolder: platform.folder,
89
- ctx,
90
- tracker: ctx.trackers[platformKey]
65
+ const hooksSource = path.join(CODEX_SRC, 'hooks');
66
+ const libsSource = path.join(hooksSource, 'lib');
67
+ const shimPattern = /^(?:['"]use strict['"];?\s*)?module\.exports\s*=\s*require\(['"]\.\.\/\.\.\/\.\.\/claude\/hooks\/lib\/([^'"/]+)['"]\);?\s*$/;
68
+ const sharedLibraries = fs.readdirSync(libsSource).filter(name => {
69
+ const file = path.join(libsSource, name);
70
+ if (!fs.lstatSync(file).isFile()) return false;
71
+ const match = fs.readFileSync(file, 'utf8').match(shimPattern);
72
+ return match && match[1] === name;
91
73
  });
74
+ const agg = { created: 0, updated: 0, unchanged: 0, preserved: 0, missing: 0 };
75
+ const copyHookEntry = relative => {
76
+ if (isGeneratedArtifact(path.basename(relative))) return;
77
+ const child = copyManagedTree({
78
+ src: path.join(hooksSource, relative),
79
+ dest: path.join(platform.folder, 'hooks', relative),
80
+ sourceRoot: hooksSource,
81
+ platformFolder: platform.folder, ctx, tracker: ctx.trackers[platformKey]
82
+ });
83
+ for (const key of Object.keys(agg)) agg[key] += child[key];
84
+ };
85
+ for (const name of fs.readdirSync(hooksSource)) {
86
+ if (name !== 'lib') copyHookEntry(name);
87
+ else for (const lib of fs.readdirSync(libsSource)) {
88
+ if (!sharedLibraries.includes(lib)) copyHookEntry(path.join('lib', lib));
89
+ }
90
+ }
92
91
  report(ctx, treeAction(agg), 'Codex native hooks');
93
92
  removeObsoleteCodexHooks(ctx, platformKey);
94
93
  // Keep one canonical command analyzer in the Claude source tree, then
@@ -102,27 +101,21 @@ function installRuntimeFiles(ctx, platformKey) {
102
101
  path.join(platform.folder, 'runtime.schema.json'),
103
102
  'Codex runtime schema'
104
103
  );
105
- writeSourceFile(
106
- ctx,
107
- platformKey,
108
- path.join(SRC, 'claude', 'hooks', 'lib', 'privacy-command-analysis.cjs'),
109
- path.join(platform.folder, 'hooks', 'lib', 'privacy-command-analysis.cjs'),
110
- 'Codex privacy command analyzer'
111
- );
112
- writeSourceFile(
113
- ctx,
114
- platformKey,
115
- path.join(SRC, 'claude', 'hooks', 'lib', 'secret-keywords.cjs'),
116
- path.join(platform.folder, 'hooks', 'lib', 'secret-keywords.cjs'),
117
- 'Codex secret keyword matcher'
118
- );
119
- writeSourceFile(
120
- ctx,
121
- platformKey,
122
- path.join(SRC, 'claude', 'hooks', 'lib', 'runtime-path-safety.cjs'),
123
- path.join(platform.folder, 'hooks', 'lib', 'runtime-path-safety.cjs'),
124
- 'Codex runtime path safety'
125
- );
104
+ const helperLabels = {
105
+ 'privacy-command-analysis.cjs': 'Codex privacy command analyzer',
106
+ 'privacy-paths.cjs': 'Codex privacy path analyzer',
107
+ 'secret-keywords.cjs': 'Codex secret keyword matcher',
108
+ 'runtime-path-safety.cjs': 'Codex runtime path safety',
109
+ 'inspect-paths.cjs': 'Codex inspection path analyzer',
110
+ 'spec-touch.cjs': 'Codex spec touch helpers',
111
+ 'update-check.cjs': 'Codex update check'
112
+ };
113
+ for (const name of sharedLibraries) {
114
+ writeSourceFile(ctx, platformKey,
115
+ path.join(SRC, 'claude', 'hooks', 'lib', name),
116
+ path.join(platform.folder, 'hooks', 'lib', name),
117
+ helperLabels[name] || `Codex hook helper: ${name}`);
118
+ }
126
119
  }
127
120
 
128
121
  function installNativeRuleOverrides(ctx, platformKey) {
@@ -170,7 +163,7 @@ function installManagedAgentsMd(ctx) {
170
163
  function installCodexRuntime(ctx, platformKey) {
171
164
  if (platformKey !== 'codex') return ctx;
172
165
  installRuntimeFiles(ctx, platformKey);
173
- copyRulesDirectory(ctx, platformKey);
166
+ copyRulesDirectory(ctx, platformKey, new Set(fs.readdirSync(path.join(CODEX_SRC, 'rules'))));
174
167
  copyOutputStylesDirectory(ctx, platformKey);
175
168
  installNativeRuleOverrides(ctx, platformKey);
176
169
  installManagedAgentsMd(ctx);
@@ -97,9 +97,7 @@ function copyPlatformFiles(ctx, platformKey) {
97
97
  // The specs skill tree above carries its templates; remove the ones retired
98
98
  // with the spec.json planning flow from earlier installs.
99
99
  if (platformKey === 'claude' || platformKey === 'codex') {
100
- const retiredTemplates = platformKey === 'claude'
101
- ? ['init.json', 'spec-state.json', 'requirements-init.md', 'requirements.md', 'design.md', 'task.md']
102
- : ['spec-state.json', 'requirements-init.md', 'requirements.md', 'design.md', 'task.md'];
100
+ const retiredTemplates = ['init.json', 'spec-state.json', 'requirements-init.md', 'requirements.md', 'design.md', 'task.md'];
103
101
  for (const fileName of retiredTemplates) {
104
102
  const retired = path.join(platform.skillsDir, 'specs', 'templates', fileName);
105
103
  if (!fs.existsSync(retired)) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "description": "Native spec-driven workflows for Claude Code and Codex CLI. Bundles CafeKit skills, runtime hooks, agents, and installer scaffolding.",
5
5
  "author": "Haposoft <nghialt@haposoft.com>",
6
6
  "license": "MIT",
@@ -7,6 +7,10 @@
7
7
 
8
8
  ## Claude Code runtime
9
9
 
10
+ Project notes (commands, files to leave unchanged, slow or expensive operations)
11
+ belong in the root AGENTS.md after the CAFEKIT CORE END marker; Claude Code reads
12
+ them through `@AGENTS.md`.
13
+
10
14
  - Edit project skills under `.claude/skills/`, not `~/.claude/skills`.
11
15
  - Run Python skill scripts with the skill venv:
12
16
  - macOS/Linux: `.claude/skills/.venv/bin/python3 scripts/<script>.py`
@@ -6,6 +6,8 @@
6
6
  .env
7
7
  .env.*
8
8
  !.env.example
9
+ !.env.sample
10
+ !.env.template
9
11
 
10
12
  # Skill dependency installs (Python venv + npm)
11
13
  skills/.venv/
@@ -25,3 +27,4 @@ agent-memory/
25
27
  settings.bak.json
26
28
  .mcp.json
27
29
  .cafekit-update-cache.json
30
+ *.tmp
@@ -0,0 +1,111 @@
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 crypto = require('node:crypto');
8
+ const { spawnSync } = require('node:child_process');
9
+ const { test } = require('node:test');
10
+
11
+ const CLAUDE = path.resolve(__dirname, '../..');
12
+ const MANIFEST = require('../../migration-manifest.json');
13
+ const RUNTIME = { paths: { specs: 'work-specs', plans: 'work-plans', docs: 'work-docs' }, inspect: { enabled: false } };
14
+
15
+ function fixture(run, withRuntime = true) {
16
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-installed-root-'));
17
+ const sessions = new Set();
18
+ try {
19
+ for (const relative of MANIFEST.runtime.files) {
20
+ const target = path.join(root, '.claude', relative);
21
+ fs.mkdirSync(path.dirname(target), { recursive: true });
22
+ fs.copyFileSync(path.join(CLAUDE, relative), target);
23
+ }
24
+ for (const relative of MANIFEST.scripts.required) {
25
+ const target = path.join(root, '.claude/scripts', relative);
26
+ fs.mkdirSync(path.dirname(target), { recursive: true });
27
+ fs.copyFileSync(path.join(CLAUDE, 'scripts', relative), target);
28
+ }
29
+ const runtimeFile = path.join(root, '.claude/runtime.json');
30
+ const writeRuntime = () => fs.writeFileSync(runtimeFile, JSON.stringify(RUNTIME));
31
+ if (withRuntime) writeRuntime();
32
+ else fs.unlinkSync(runtimeFile);
33
+ const cwd = path.join(root, 'sub/dir');
34
+ fs.mkdirSync(cwd, { recursive: true });
35
+ const env = { ...process.env };
36
+ delete env.CLAUDE_PROJECT_DIR;
37
+ delete env.PROJECT_ROOT;
38
+ const invoke = (name, payload) => {
39
+ if (payload.session_id) sessions.add(payload.session_id);
40
+ return spawnSync(process.execPath, [path.join(root, '.claude/hooks', `${name}.cjs`)], {
41
+ cwd, env, encoding: 'utf8', input: JSON.stringify({ cwd, ...payload }),
42
+ });
43
+ };
44
+ run({ root: fs.realpathSync(root), cwd, invoke, writeRuntime });
45
+ } finally {
46
+ for (const session of sessions) {
47
+ const key = crypto.createHash('sha256').update(session).digest('hex').slice(0, 16);
48
+ fs.rmSync(path.join(os.tmpdir(), `cafekit-rules-${key}.json`), { force: true });
49
+ }
50
+ fs.rmSync(root, { recursive: true, force: true });
51
+ }
52
+ }
53
+
54
+ test('installed rules read the project runtime from a nested session cwd', () => {
55
+ fixture(({ root, invoke }) => {
56
+ const result = invoke('rules', { hook_event_name: 'UserPromptSubmit', session_id: crypto.randomUUID() });
57
+ assert.equal(result.status, 0, result.stderr);
58
+ assert.match(result.stdout, /## Rules/);
59
+ assert.ok(result.stdout.includes(`${root}/work-specs/`));
60
+ });
61
+ });
62
+
63
+ test('rules wait for runtime before reserving the session injection', () => {
64
+ fixture(({ invoke, writeRuntime }) => {
65
+ const payload = { hook_event_name: 'UserPromptSubmit', session_id: crypto.randomUUID() };
66
+ const absent = invoke('rules', payload);
67
+ assert.equal(absent.status, 0, absent.stderr);
68
+ assert.equal(absent.stdout, '');
69
+ writeRuntime();
70
+ const present = invoke('rules', payload);
71
+ assert.equal(present.status, 0, present.stderr);
72
+ assert.match(present.stdout, /## Rules/);
73
+ }, false);
74
+ });
75
+
76
+ test('installed inspect respects a disabled project runtime from a nested cwd', () => {
77
+ fixture(({ invoke }) => {
78
+ const result = invoke('inspect-block', { hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: 'node_modules/pkg/index.js' } });
79
+ assert.equal(result.status, 0, result.stderr);
80
+ });
81
+ });
82
+
83
+ test('installed agent context uses project paths from a nested session cwd', () => {
84
+ fixture(({ root, invoke }) => {
85
+ const result = invoke('agent', { hook_event_name: 'SubagentStart', agent_id: 'nested-agent' });
86
+ assert.equal(result.status, 0, result.stderr);
87
+ assert.match(result.stdout, /additionalContext/);
88
+ const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
89
+ for (const relative of ['work-specs', 'work-plans', 'work-docs']) assert.ok(context.includes(`${root}/${relative}/`));
90
+ });
91
+ });
92
+
93
+ test('installed spec gate checks root packet receipts from a nested session cwd', () => {
94
+ fixture(({ root, invoke }) => {
95
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
96
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
97
+ assert.equal(result.status, 0, result.stderr);
98
+ }
99
+ const packet = path.join(root, 'work-specs/demo');
100
+ fs.mkdirSync(packet, { recursive: true });
101
+ fs.writeFileSync(path.join(packet, 'plan.md'), '# Demo\nSpecs-Contract: process-first-ready-v1\n');
102
+ fs.writeFileSync(path.join(packet, 'task-01-work.md'), '# Work\n\nStatus: done\n\n## Dependencies\n\n- none\n\n## Verification Plan\n\n- Command: node --test\n');
103
+ const result = invoke('spec-gate', { hook_event_name: 'Stop', session_id: crypto.randomUUID() });
104
+ assert.equal(result.status, 0, result.stderr);
105
+ const block = JSON.parse(result.stdout);
106
+ assert.equal(block.decision, 'block');
107
+ assert.match(block.reason, /receipt/i);
108
+ assert.match(block.reason, /task-01-work\.md/);
109
+ assert.doesNotMatch(block.reason, /unavailable|controlled failure/i);
110
+ });
111
+ });
@@ -11,11 +11,21 @@ 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');
14
15
 
15
16
  const PRECOMPACT = path.join(__dirname, '..', 'precompact.cjs');
16
17
  const SESSION = path.join(__dirname, '..', 'session.cjs');
17
18
  const { hookStateDir } = require(path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'));
18
- const RECOVERY = path.join(hookStateDir(), 'compact-recovery.json');
19
+ const CAPTURES = new Set([path.join(hookStateDir(), 'compact-recovery.json')]);
20
+ function recoveryPath(session = 'compact-session') {
21
+ const key = crypto.createHash('sha256').update(session).digest('hex');
22
+ const file = path.join(hookStateDir(), `compact-recovery-${key}.json`);
23
+ CAPTURES.add(file);
24
+ return file;
25
+ }
26
+ const HOOK_ENV = { ...process.env };
27
+ delete HOOK_ENV.CLAUDE_PROJECT_DIR;
28
+ delete HOOK_ENV.PROJECT_ROOT;
19
29
 
20
30
  function fixture() {
21
31
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-precompact-'));
@@ -36,21 +46,24 @@ function fixture() {
36
46
  }
37
47
 
38
48
  function run(hook, dir, payload) {
49
+ if (typeof payload?.session_id === 'string' && payload.session_id.trim()) recoveryPath(payload.session_id);
39
50
  const result = spawnSync(process.execPath, [hook], {
40
51
  cwd: dir,
41
52
  input: JSON.stringify(payload),
42
53
  encoding: 'utf8',
43
- env: { ...process.env, CLAUDE_PROJECT_DIR: dir },
54
+ env: HOOK_ENV,
44
55
  });
45
56
  return { code: result.status, stdout: result.stdout || '', stderr: result.stderr || '' };
46
57
  }
47
58
 
48
59
  function clearCapture() {
49
- try { fs.unlinkSync(RECOVERY); } catch { /* absent */ }
60
+ for (const file of CAPTURES) {
61
+ try { fs.unlinkSync(file); } catch { /* absent */ }
62
+ }
50
63
  }
51
64
 
52
- function readCapture() {
53
- return JSON.parse(fs.readFileSync(RECOVERY, 'utf8'));
65
+ function readCapture(session) {
66
+ return JSON.parse(fs.readFileSync(recoveryPath(session), 'utf8'));
54
67
  }
55
68
 
56
69
  test('PreCompact records branch, HEAD, dirty count and the in-flight task', () => {
@@ -93,8 +106,8 @@ test('a compact restart prints the captured anchors alongside the authorization
93
106
  const dir = fixture();
94
107
  try {
95
108
  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 });
109
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', trigger: 'manual', session_id: 'compact-session', cwd: dir }).code, 0);
110
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session', cwd: dir });
98
111
  assert.strictEqual(restart.code, 0, restart.stderr);
99
112
  assert.match(restart.stdout, /VERIFY PENDING AUTHORIZATIONS/, 'the existing warning must survive');
100
113
  assert.match(restart.stdout, /State before compaction/);
@@ -119,6 +132,53 @@ test('a compact restart without a capture still prints the warning and nothing i
119
132
  }
120
133
  });
121
134
 
135
+ test('a compact restart in another session prints the warning without foreign anchors', () => {
136
+ const dir = fixture();
137
+ try {
138
+ clearCapture();
139
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'session-a', cwd: dir }).code, 0);
140
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-b', cwd: dir });
141
+ assert.strictEqual(restart.code, 0, restart.stderr);
142
+ assert.match(restart.stdout, /SESSION COMPRESSED/);
143
+ assert.doesNotMatch(restart.stdout, /State before compaction/);
144
+ } finally {
145
+ fs.rmSync(dir, { recursive: true, force: true });
146
+ clearCapture();
147
+ }
148
+ });
149
+
150
+ test('a compact restart in the same session restores its captured anchors', () => {
151
+ const dir = fixture();
152
+ try {
153
+ clearCapture();
154
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'session-a', cwd: dir }).code, 0);
155
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-a', cwd: dir });
156
+ assert.strictEqual(restart.code, 0, restart.stderr);
157
+ assert.match(restart.stdout, /SESSION COMPRESSED/);
158
+ assert.match(restart.stdout, /State before compaction[\s\S]*branch work/);
159
+ } finally {
160
+ fs.rmSync(dir, { recursive: true, force: true });
161
+ clearCapture();
162
+ }
163
+ });
164
+
165
+ for (const [captureId, restartId] of [[undefined, 'session-a'], ['session-a', undefined], [undefined, undefined]]) {
166
+ test(`a compact restart without ${captureId ? 'restart' : restartId ? 'capture' : 'either'} session identity keeps only the warning`, () => {
167
+ const dir = fixture();
168
+ try {
169
+ clearCapture();
170
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: captureId, cwd: dir }).code, 0);
171
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: restartId, cwd: dir });
172
+ assert.strictEqual(restart.code, 0, restart.stderr);
173
+ assert.match(restart.stdout, /SESSION COMPRESSED/);
174
+ assert.doesNotMatch(restart.stdout, /State before compaction/);
175
+ } finally {
176
+ fs.rmSync(dir, { recursive: true, force: true });
177
+ clearCapture();
178
+ }
179
+ });
180
+ }
181
+
122
182
  test('a normal session start prints no compaction anchors', () => {
123
183
  const dir = fixture();
124
184
  try {
@@ -138,7 +198,7 @@ test('PreCompact fails open outside a repository and on malformed input', () =>
138
198
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-precompact-bare-'));
139
199
  try {
140
200
  clearCapture();
141
- const noGit = run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', cwd: dir });
201
+ const noGit = run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'compact-session', cwd: dir });
142
202
  assert.strictEqual(noGit.code, 0, 'a directory outside git must not fail the hook');
143
203
  const record = readCapture();
144
204
  assert.strictEqual(record.branch, null, 'unavailable git facts are recorded as null, never guessed');
@@ -147,12 +207,148 @@ test('PreCompact fails open outside a repository and on malformed input', () =>
147
207
 
148
208
  clearCapture();
149
209
  const malformed = spawnSync(process.execPath, [PRECOMPACT], {
150
- cwd: dir, input: 'not json', encoding: 'utf8', env: { ...process.env, CLAUDE_PROJECT_DIR: dir },
210
+ cwd: dir, input: 'not json', encoding: 'utf8', env: HOOK_ENV,
151
211
  });
152
212
  assert.strictEqual(malformed.status, 0, 'malformed payload must fail open');
153
- assert.strictEqual(fs.existsSync(RECOVERY), false, 'a malformed payload writes no capture');
213
+ assert.strictEqual(fs.existsSync(recoveryPath()), false, 'a malformed payload writes no capture');
154
214
  } finally {
155
215
  fs.rmSync(dir, { recursive: true, force: true });
156
216
  clearCapture();
157
217
  }
158
218
  });
219
+
220
+ function installedFixture(callback) {
221
+ const root = fixture();
222
+ const source = path.resolve(__dirname, '../..');
223
+ const manifest = require('../../migration-manifest.json');
224
+ const sessions = new Set();
225
+ try {
226
+ for (const relative of manifest.runtime.files) {
227
+ const target = path.join(root, '.claude', relative);
228
+ fs.mkdirSync(path.dirname(target), { recursive: true });
229
+ fs.copyFileSync(path.join(source, relative), target);
230
+ }
231
+ for (const relative of manifest.scripts.required) {
232
+ const target = path.join(root, '.claude/scripts', relative);
233
+ fs.mkdirSync(path.dirname(target), { recursive: true });
234
+ fs.copyFileSync(path.join(source, 'scripts', relative), target);
235
+ }
236
+ const hooks = path.join(root, '.claude/hooks');
237
+ const invoke = (name, payload, overrides = {}) => {
238
+ if (typeof payload?.session_id === 'string' && payload.session_id.trim()) sessions.add(payload.session_id);
239
+ return spawnSync(process.execPath, [path.join(hooks, `${name}.cjs`)], {
240
+ cwd: root, env: HOOK_ENV, encoding: 'utf8', timeout: 10000,
241
+ input: typeof payload === 'string' ? payload : JSON.stringify(payload), ...overrides,
242
+ });
243
+ };
244
+ callback({ root: fs.realpathSync(root), hooks, invoke });
245
+ } finally {
246
+ for (const session of sessions) {
247
+ const key = crypto.createHash('sha256').update(session).digest('hex').slice(0, 16);
248
+ fs.rmSync(path.join(os.tmpdir(), `cafekit-rules-${key}.json`), { force: true });
249
+ }
250
+ fs.rmSync(root, { recursive: true, force: true });
251
+ }
252
+ }
253
+
254
+ test('two compacting sessions keep separate anchors and restore only their own branch', () => {
255
+ installedFixture(({ root, hooks, invoke }) => {
256
+ const sessions = [crypto.randomUUID(), crypto.randomUUID()];
257
+ const files = [];
258
+ for (const [index, session_id] of sessions.entries()) {
259
+ const branch = `anchor-${index === 0 ? 'first' : 'second'}`;
260
+ const checkout = spawnSync('git', ['-C', root, 'checkout', '-qb', branch], { encoding: 'utf8' });
261
+ assert.strictEqual(checkout.status, 0, checkout.stderr);
262
+ const capture = invoke('precompact', { hook_event_name: 'PreCompact', session_id, cwd: root });
263
+ assert.strictEqual(capture.status, 0, capture.stderr);
264
+ const hash = crypto.createHash('sha256').update(session_id).digest('hex');
265
+ files.push(path.join(hooks, '.logs', `compact-recovery-${hash}.json`));
266
+ }
267
+ for (const [index, file] of files.entries()) {
268
+ assert.ok(fs.existsSync(file), 'both session captures must survive');
269
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
270
+ assert.strictEqual(record.session_id, sessions[index]);
271
+ assert.strictEqual(record.branch, `anchor-${index === 0 ? 'first' : 'second'}`);
272
+ }
273
+ for (const [index, session_id] of sessions.entries()) {
274
+ const compact = invoke('session', { hook_event_name: 'SessionStart', source: 'compact', session_id, cwd: root });
275
+ assert.strictEqual(compact.status, 0, compact.stderr);
276
+ assert.match(compact.stdout, /SESSION COMPRESSED/);
277
+ assert.match(compact.stdout, /State before compaction/);
278
+ assert.ok(compact.stdout.includes(`branch anchor-${index === 0 ? 'first' : 'second'}`));
279
+ assert.ok(!compact.stdout.includes(`branch anchor-${index === 0 ? 'second' : 'first'}`));
280
+ }
281
+ });
282
+ });
283
+
284
+ test('PreCompact without a usable session identity creates no anchor or state directory', () => {
285
+ installedFixture(({ root, hooks, invoke }) => {
286
+ for (const session_id of [undefined, null, '', 42, ' ']) {
287
+ const capture = invoke('precompact', { hook_event_name: 'PreCompact', session_id, cwd: root });
288
+ assert.strictEqual(capture.status, 0, capture.stderr);
289
+ assert.strictEqual(fs.existsSync(path.join(hooks, '.logs')), false);
290
+ }
291
+ });
292
+ });
293
+
294
+ test('compact reinjects rules only for its own session even without matching anchors', () => {
295
+ installedFixture(({ root, hooks, invoke }) => {
296
+ const session = crypto.randomUUID();
297
+ const other = crypto.randomUUID();
298
+ const prompt = id => invoke('rules', { hook_event_name: 'UserPromptSubmit', session_id: id, cwd: root });
299
+ for (const id of [session, other]) {
300
+ const first = prompt(id);
301
+ assert.strictEqual(first.status, 0, first.stderr);
302
+ assert.match(first.stdout, /## Rules/);
303
+ assert.strictEqual(prompt(id).stdout, '');
304
+ }
305
+ fs.mkdirSync(path.join(hooks, '.logs'), { recursive: true });
306
+ const otherHash = crypto.createHash('sha256').update(other).digest('hex');
307
+ fs.writeFileSync(path.join(hooks, '.logs', `compact-recovery-${otherHash}.json`), JSON.stringify({ session_id: other }));
308
+ const compact = invoke('session', { hook_event_name: 'SessionStart', source: 'compact', session_id: session, cwd: root });
309
+ assert.strictEqual(compact.status, 0, compact.stderr);
310
+ assert.match(compact.stdout, /SESSION COMPRESSED/);
311
+ assert.doesNotMatch(compact.stdout, /State before compaction/);
312
+ const again = prompt(session);
313
+ assert.strictEqual(again.status, 0, again.stderr);
314
+ assert.match(again.stdout, /## Rules/);
315
+ assert.strictEqual(prompt(other).stdout, '');
316
+ });
317
+ });
318
+
319
+ test('malformed session input fails open without a stack and logs its crash', () => {
320
+ installedFixture(({ hooks, invoke }) => {
321
+ const result = invoke('session', '{');
322
+ assert.strictEqual(result.status, 0);
323
+ assert.strictEqual(result.stderr, '');
324
+ const rows = fs.readFileSync(path.join(hooks, '.logs/hook-log.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
325
+ assert.strictEqual(rows.length, 1);
326
+ assert.strictEqual(rows[0].hook, 'session');
327
+ assert.strictEqual(rows[0].status, 'crash');
328
+ });
329
+ });
330
+
331
+ test('installed session explains a nested cwd but omits the root line without a cwd', () => {
332
+ installedFixture(({ root, invoke }) => {
333
+ const cwd = path.join(root, 'sub/dir');
334
+ fs.mkdirSync(cwd, { recursive: true });
335
+ const nested = invoke('session', { source: 'startup', cwd }, { cwd });
336
+ assert.strictEqual(nested.status, 0, nested.stderr);
337
+ assert.ok(nested.stdout.includes(`CafeKit project root: ${root} (session working directory: ${cwd}).`));
338
+ const missing = invoke('session', { source: 'startup' }, { cwd });
339
+ assert.strictEqual(missing.status, 0, missing.stderr);
340
+ assert.doesNotMatch(missing.stdout, /CafeKit project root:/);
341
+ });
342
+ });
343
+
344
+ test('PreCompact bounds slow git calls and finishes within ten seconds', { timeout: 15000 }, () => {
345
+ installedFixture(({ root, invoke }) => {
346
+ const bin = path.join(root, 'fake-bin');
347
+ fs.mkdirSync(bin);
348
+ fs.writeFileSync(path.join(bin, 'git'), '#!/bin/sh\nexec /bin/sleep 30\n', { mode: 0o755 });
349
+ const start = Date.now();
350
+ const result = invoke('precompact', { hook_event_name: 'PreCompact', session_id: crypto.randomUUID(), cwd: root }, { env: { ...HOOK_ENV, PATH: bin } });
351
+ assert.strictEqual(result.status, 0, 'slow git must not exhaust the hook timeout');
352
+ assert.ok(Date.now() - start < 10000);
353
+ });
354
+ });
@@ -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 {
@@ -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) {