@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,36 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { hookStateDir } = require('./hook-state-dir.cjs');
7
+
8
+ function reservationFile(sessionId) {
9
+ if (!sessionId) return null;
10
+ const key = crypto.createHash('sha256').update(String(sessionId)).digest('hex');
11
+ return path.join(hookStateDir(), `rules-${key}.json`);
12
+ }
13
+
14
+ /** Reserve one injection slot per session. Returns null on reservation errors. */
15
+ function reserveSession(sessionId) {
16
+ try {
17
+ const file = reservationFile(sessionId);
18
+ if (!file) return null;
19
+ fs.mkdirSync(path.dirname(file), { recursive: true });
20
+ fs.writeFileSync(file, JSON.stringify({ sessionId: String(sessionId) }), {
21
+ encoding: 'utf8', flag: 'wx', mode: 0o600,
22
+ });
23
+ return true;
24
+ } catch (error) {
25
+ return error.code === 'EEXIST' ? false : null;
26
+ }
27
+ }
28
+
29
+ function clearSessionReservation(sessionId) {
30
+ try {
31
+ const file = reservationFile(sessionId);
32
+ if (file) fs.rmSync(file, { force: true });
33
+ } catch { /* fail-open */ }
34
+ }
35
+
36
+ module.exports = { reserveSession, clearSessionReservation };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // Deleted/new paths still resolve through their deepest existing ancestor. This also
7
+ // prevents an innocent lexical prefix from hiding a symlink outside the specs root.
8
+ function canonicalTarget(value) {
9
+ const missing = [];
10
+ let current = path.resolve(value);
11
+ while (true) {
12
+ try { return path.join(fs.realpathSync(current), ...missing); } catch (error) {
13
+ if (error.code !== 'ENOENT') return null;
14
+ const parent = path.dirname(current);
15
+ if (parent === current) return null;
16
+ missing.unshift(path.basename(current));
17
+ current = parent;
18
+ }
19
+ }
20
+ }
21
+
22
+ function packetAt(value, specsRoot, cwd) {
23
+ if (typeof value !== 'string' || !value.trim()) return null;
24
+ const root = canonicalTarget(specsRoot);
25
+ const target = canonicalTarget(path.resolve(cwd, value));
26
+ if (!root || !target) return null;
27
+ const rel = path.relative(root, target);
28
+ if (!rel || path.isAbsolute(rel) || rel === '..' || rel.startsWith(`..${path.sep}`)) return null;
29
+ const segments = rel.split(path.sep);
30
+ return segments[0] === '_shared' ? null : segments[0];
31
+ }
32
+
33
+ function namedPackets(prompt, names, specsRoot, projectRoot) {
34
+ if (typeof prompt !== 'string') return [];
35
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ const prefix = path.relative(projectRoot, specsRoot).split(path.sep).join('/');
37
+ const named = names.filter((name) => {
38
+ const n = escape(name);
39
+ const absolute = specsRoot.split(path.sep).join('/');
40
+ return new RegExp(`(^|[^\\w])${escape(absolute)}/${n}(?![\\w-])`).test(prompt)
41
+ || new RegExp(`(^|[^\\w/-])${escape(prefix)}/${n}(?![\\w-])`).test(prompt)
42
+ || new RegExp(`(?:\\$cf-[\\w-]+|/cf:[\\w-]+)\\s+(?:${escape(prefix)}/)?${n}(?![\\w-])`).test(prompt)
43
+ || (/[-_0-9]/.test(name) && new RegExp(`(^|[^\\w/-])${n}(?![\\w-])`).test(prompt));
44
+ });
45
+ // Absolute prompt references may use another realpath alias (notably /var on macOS).
46
+ const paths = [...prompt.matchAll(/(?:^|[\s"'`])((?:\/|[A-Za-z]:[\\/])[^\s"'`]+)/g)].map((match) => match[1]);
47
+ for (const match of prompt.matchAll(/["']([^"']+)["']/g)) {
48
+ if (path.isAbsolute(match[1])) paths.push(match[1]);
49
+ }
50
+ for (const value of paths) {
51
+ const name = packetAt(value, specsRoot, projectRoot);
52
+ if (names.includes(name)) named.push(name);
53
+ }
54
+ return [...new Set(named)];
55
+ }
56
+
57
+ function explicitFailure(payload) {
58
+ const failed = (value) => {
59
+ if (!value || typeof value !== 'object') return false;
60
+ if (value.success === false || value.is_error === true || value.isError === true
61
+ || value.ok === false || value.error || ['error', 'failed', 'failure'].includes(value.status)) return true;
62
+ return ['exit_code', 'exitCode'].some((key) => value[key] !== undefined && value[key] !== 0);
63
+ };
64
+ return payload.success === false || failed(payload.tool_response);
65
+ }
66
+
67
+ /**
68
+ * Every file a write tool reports touching: the Claude `file_path`, omp's `path`/`paths`
69
+ * (omp derives them from an edit's file headers), and the files an omp result says it
70
+ * changed. A glob names no file, so it is skipped.
71
+ */
72
+ function touchedPaths(payload) {
73
+ const input = payload.tool_input || {};
74
+ const details = (payload.tool_response && payload.tool_response.details) || {};
75
+ const values = [input.file_path, input.path, ...(Array.isArray(input.paths) ? input.paths : []), details.path,
76
+ ...[details.perFileResults, details.fileChanges].flatMap((list) => (Array.isArray(list) ? list.map((entry) => entry && entry.path) : []))];
77
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.trim() && !/[*?[\]{}]/.test(value)))];
78
+ }
79
+
80
+ module.exports = { canonicalTarget, packetAt, namedPackets, explicitFailure, touchedPaths };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const VERSION = /^\d+\.\d+\.\d+$/;
6
+ const CACHE_TTL = 12 * 60 * 60 * 1000;
7
+
8
+ function fetchLatestVersion() {
9
+ try {
10
+ const https = require('https');
11
+ return new Promise((resolve) => {
12
+ const req = https.get(
13
+ 'https://registry.npmjs.org/@haposoft/cafekit/latest',
14
+ { timeout: 3000, headers: { Accept: 'application/json' } },
15
+ (res) => {
16
+ let body = '';
17
+ res.on('error', () => resolve(null));
18
+ res.on('aborted', () => resolve(null));
19
+ res.on('data', (chunk) => { body += chunk; });
20
+ res.on('end', () => {
21
+ try { resolve(JSON.parse(body).version || null); } catch { resolve(null); }
22
+ });
23
+ }
24
+ );
25
+ req.on('error', () => resolve(null));
26
+ req.on('timeout', () => { req.destroy(); resolve(null); });
27
+ });
28
+ } catch { return Promise.resolve(null); }
29
+ }
30
+
31
+ function isNewer(a, b) {
32
+ const n = (v) => String(v).split('.').map((x) => parseInt(x, 10) || 0);
33
+ const [aN, bN] = [n(a), n(b)];
34
+ for (let i = 0; i < 3; i++) {
35
+ if (bN[i] > aN[i]) return true;
36
+ if (bN[i] < aN[i]) return false;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ function getInstalledVersion(runtimeDirectory) {
42
+ try {
43
+ const meta = JSON.parse(fs.readFileSync(path.join(runtimeDirectory, 'cafekit.json'), 'utf8'));
44
+ return typeof meta.version === 'string' ? meta.version : null;
45
+ } catch { return null; }
46
+ }
47
+
48
+ function noticeFor(installed, latest) {
49
+ return isNewer(installed, latest)
50
+ ? `\n⚡ CafeKit update available: ${installed} → ${latest}\n Run: npx @haposoft/cafekit\n`
51
+ : '';
52
+ }
53
+
54
+ async function checkCafeKitUpdate(runtimeDirectory, fetchVersion = fetchLatestVersion) {
55
+ const installed = getInstalledVersion(runtimeDirectory);
56
+ if (!installed) return '';
57
+ const cachePath = path.join(runtimeDirectory, '.cafekit-update-cache.json');
58
+ const now = Date.now();
59
+ try {
60
+ const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
61
+ if (typeof cache.ts === 'number' && Number.isFinite(cache.ts) && cache.ts <= now
62
+ && now - cache.ts < CACHE_TTL && typeof cache.latest === 'string' && VERSION.test(cache.latest)) {
63
+ return noticeFor(installed, cache.latest);
64
+ }
65
+ } catch {
66
+ // Missing, corrupt or legacy cache requires a fresh version fetch.
67
+ }
68
+ let latest;
69
+ try { latest = await fetchVersion(); } catch { return ''; }
70
+ if (typeof latest !== 'string' || !VERSION.test(latest)) return '';
71
+ try {
72
+ fs.writeFileSync(cachePath, JSON.stringify({ ts: Date.now(), latest }), 'utf8');
73
+ } catch { /* cache persistence is optional */ }
74
+ return noticeFor(installed, latest);
75
+ }
76
+
77
+ module.exports = { fetchLatestVersion, isNewer, getInstalledVersion, checkCafeKitUpdate };
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copyright (c) 2026 Haposoft. MIT License.
4
+ *
5
+ * SessionStart Hook — plugin-context.cjs (Claude Code plugin only)
6
+ *
7
+ * A plugin cannot ship CLAUDE.md or rules files as always-on context, so this
8
+ * hook injects the CafeKit core instructions as SessionStart additionalContext.
9
+ * The host caps that text at 10,000 characters; plugin/core.md is sized for it
10
+ * and points to the detailed rules files by absolute path.
11
+ *
12
+ * Registered only in the plugin's hooks.json; the npx install never runs it.
13
+ * Exit: 0 always (fail-open)
14
+ */
15
+
16
+ try {
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const os = require('os');
21
+
22
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
23
+ if (!pluginRoot) process.exit(0);
24
+
25
+ function writeAtomic(file, text) {
26
+ const temp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ fs.writeFileSync(temp, text);
29
+ fs.renameSync(temp, file);
30
+ }
31
+
32
+ // A user statusLine cannot use ${CLAUDE_PLUGIN_ROOT}, which moves on every
33
+ // update, so keep a stable launcher in the data directory pointing at the
34
+ // current version, and record where it is for /cf:setup-statusline.
35
+ function writeStatuslineLauncher(root) {
36
+ const data = process.env.CLAUDE_PLUGIN_DATA;
37
+ if (!data || !path.isAbsolute(data)) return;
38
+ try {
39
+ const target = JSON.stringify(path.join(root, 'status.cjs'));
40
+ writeAtomic(path.join(data, 'status.cjs'), [
41
+ '#!/usr/bin/env node',
42
+ '// Written by the CafeKit plugin at session start; runs the installed version\'s statusline.',
43
+ '// A removed or replaced version prints nothing; a broken one still reports its error.',
44
+ `if (!require('fs').existsSync(${target})) process.exit(0);`,
45
+ `require(${target});`,
46
+ '',
47
+ ].join('\n'));
48
+ writeAtomic(path.join(os.homedir(), '.claude', 'cafekit-plugin-data'), `${data}\n`);
49
+ } catch { /* the statusline is optional; never fail the session for it */ }
50
+ }
51
+
52
+ try { fs.readFileSync(0, 'utf8'); } catch { /* the payload is not needed */ }
53
+
54
+ let core = fs.readFileSync(path.join(__dirname, '..', 'plugin', 'core.md'), 'utf8')
55
+ // A function replacement keeps `$&`-style sequences in the path literal.
56
+ .replaceAll('${CLAUDE_PLUGIN_ROOT}', () => pluginRoot);
57
+
58
+ // The npx install writes this section into CLAUDE.md; the plugin takes it from its option.
59
+ const address = require('./lib/plugin-options.cjs').pluginOption('address');
60
+ if (address) {
61
+ core += `\n## Addressing (Context Overflow Indicator)\n\nAlways address the user as ${JSON.stringify(address)} throughout the conversation. If you stop doing so, the context has been compacted or truncated; tell the user to consider \`/clear\`.\n`;
62
+ }
63
+
64
+ writeStatuslineLauncher(pluginRoot);
65
+
66
+ process.stdout.write(JSON.stringify({
67
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: core },
68
+ }));
69
+ process.exit(0);
70
+ } catch (e) {
71
+ try {
72
+ const fs = require('fs');
73
+ const p = require('path');
74
+ const d = require('./lib/hook-state-dir.cjs').hookStateDir();
75
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
76
+ fs.appendFileSync(p.join(d, 'hook-log.jsonl'),
77
+ JSON.stringify({ ts: new Date().toISOString(), hook: 'plugin-context', status: 'crash', error: e.message }) + '\n');
78
+ } catch (_) { /* logging must not fail the session */ }
79
+ process.exit(0);
80
+ }
@@ -23,27 +23,16 @@
23
23
 
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
+ const crypto = require('crypto');
26
27
  const { spawnSync } = require('child_process');
27
28
 
28
- const RECOVERY_FILE = 'compact-recovery.json';
29
-
30
29
  function gitLine(root, args) {
31
- const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
30
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', timeout: 3000 });
32
31
  if (result.error || result.status !== 0) return null;
33
32
  const value = String(result.stdout || '').trim();
34
33
  return value === '' ? null : value;
35
34
  }
36
35
 
37
- function projectRoot(payload) {
38
- const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string' ? process.env.CLAUDE_PROJECT_DIR.trim() : '';
39
- const candidates = [configured, typeof payload.cwd === 'string' ? payload.cwd.trim() : '', process.cwd()];
40
- for (const candidate of candidates) {
41
- if (!candidate) continue;
42
- try { return fs.realpathSync(path.resolve(candidate)); } catch { /* next */ }
43
- }
44
- return process.cwd();
45
- }
46
-
47
36
  /** The one task the packet currently says is in flight, if any. */
48
37
  function activeTask(root) {
49
38
  try {
@@ -72,13 +61,16 @@ try {
72
61
  const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
73
62
  const payload = stdin ? normalizeHookPayload(JSON.parse(stdin)) : {};
74
63
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) process.exit(0);
64
+ const sessionId = payload.session_id;
65
+ if (typeof sessionId !== 'string' || !sessionId.trim()) process.exit(0);
75
66
 
67
+ const { projectRoot } = require('./lib/project-root.cjs');
76
68
  const root = projectRoot(payload);
77
69
  const status = gitLine(root, ['status', '--porcelain']);
78
70
  const record = {
79
71
  captured_at: new Date().toISOString(),
80
72
  trigger: typeof payload.trigger === 'string' ? payload.trigger : null,
81
- session_id: typeof payload.session_id === 'string' ? payload.session_id : null,
73
+ session_id: sessionId,
82
74
  project_root: root,
83
75
  branch: gitLine(root, ['rev-parse', '--abbrev-ref', 'HEAD']),
84
76
  head: gitLine(root, ['rev-parse', '--short', 'HEAD']),
@@ -88,7 +80,8 @@ try {
88
80
 
89
81
  const dir = require('./lib/hook-state-dir.cjs').hookStateDir();
90
82
  fs.mkdirSync(dir, { recursive: true });
91
- const target = path.join(dir, RECOVERY_FILE);
83
+ const key = crypto.createHash('sha256').update(sessionId).digest('hex');
84
+ const target = path.join(dir, `compact-recovery-${key}.json`);
92
85
  const temp = `${target}.${process.pid}.tmp`;
93
86
  fs.writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`);
94
87
  fs.renameSync(temp, target);
@@ -14,32 +14,22 @@
14
14
  * Exit: 0 (the JSON hook output carries the permission decision)
15
15
  */
16
16
 
17
- try {
18
- const fs = require('fs');
19
- const path = require('path');
20
- const { commandAccess } = require('./lib/privacy-command-analysis.cjs');
17
+ const EVALUATION_FAILURE = 'Sensitive access could not be evaluated safely.';
21
18
 
22
- const RESTRICTED_PATTERNS = [
23
- /^\.env(?:[.\[*?{]|$)/i,
24
- /^credentials/i,
25
- /secrets?\.(ya?ml|json)$/i,
26
- /\.pem$/i,
27
- /\.key$/i,
28
- /\.p12$/i,
29
- /\.pfx$/i,
30
- /^id_(rsa|ed25519|ecdsa|dsa)$/i,
31
- /\.netrc$/i,
32
- /\.pgpass$/i,
33
- /kubeconfig/i,
34
- /\.keystore$/i,
35
- /\.jks$/i,
36
- /auth\.json$/i,
37
- /token(s)?\.json$/i
38
- ];
19
+ function askForPermission(reason) {
20
+ process.stdout.write(JSON.stringify({
21
+ hookSpecificOutput: {
22
+ hookEventName: 'PreToolUse',
23
+ permissionDecision: 'ask',
24
+ permissionDecisionReason: reason
25
+ }
26
+ }) + '\n');
27
+ }
39
28
 
40
- const ALLOWED_EXEMPTIONS = [
41
- /\.env\.(example|sample|template|test)$/i
42
- ];
29
+ try {
30
+ const fs = require('fs');
31
+ const { sensitivePaths, displayNames } = require('./lib/privacy-paths.cjs');
32
+ const { projectRoot: resolveProjectRoot } = require('./lib/project-root.cjs');
43
33
 
44
34
  const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
45
35
  function readRuntime(cwd) {
@@ -51,199 +41,28 @@ try {
51
41
  }
52
42
  }
53
43
 
54
- function isSafe(filePath) {
55
- const base = path.basename(filePath);
56
- return ALLOWED_EXEMPTIONS.some((rule) => rule.test(base) || rule.test(filePath));
57
- }
58
-
59
- function isSensitive(filePath) {
60
- const base = path.basename(filePath);
61
- return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
62
- }
63
-
64
- /**
65
- * Resolve a symlink to its real target so a harmless-looking name that points
66
- * at a sensitive file cannot slip through the basename check. Returns null
67
- * when the path cannot be resolved (missing file, broken link) — fail-open to
68
- * the original-path check in that case. See isSymlinkToSensitive for dangling handling.
69
- */
70
- function resolveTarget(filePath, cwd) {
71
- try {
72
- const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
73
- return fs.realpathSync(requested);
74
- } catch {
75
- return null;
76
- }
77
- }
78
-
79
- /**
80
- * When realpath fails, check whether the requested path is a symlink (or chain)
81
- * whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed ask);
82
- * dangling benign link or link to allowed example/template -> false (allow).
83
- * Limit hops to avoid loops; malformed/looping -> fail closed.
84
- * TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
85
- * This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
86
- */
87
- function isSymlinkToSensitive(filePath, cwd) {
88
- try {
89
- const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
90
- let current = requested;
91
- let hops = 0;
92
- const MAX_HOPS = 8;
93
- while (hops < MAX_HOPS) {
94
- let stat;
95
- try {
96
- stat = fs.lstatSync(current);
97
- } catch {
98
- return false;
99
- }
100
- if (!stat.isSymbolicLink()) return false;
101
- let linkTarget;
102
- try {
103
- linkTarget = fs.readlinkSync(current);
104
- } catch {
105
- return true;
106
- }
107
- if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
108
- if (isSafe(linkTarget)) return false;
109
- const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
110
- if (next === current) return true;
111
- try {
112
- const real = fs.realpathSync(next);
113
- if (isSensitive(real) && !isSafe(real)) return true;
114
- if (isSafe(real)) return false;
115
- return false;
116
- } catch {
117
- current = next;
118
- hops += 1;
119
- continue;
120
- }
121
- }
122
- return true;
123
- } catch {
124
- return true;
125
- }
126
- }
127
-
128
- function extractBashPaths(command) {
129
- // The analyzer parses the command and returns the operands of every reading
130
- // command it recognises, nested shells and `find -exec` included. Treating
131
- // every unresolved expansion as sensitive, as this did before, asked on
132
- // `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike. Noise is what trains
133
- // people to click through the prompt that matters.
134
- // A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell
135
- // expands nothing inside it, so no word in the body is ever read. The
136
- // analyzer has no notion of heredocs and parses the body as shell, so prose
137
- // that quotes a command -- a PR body carrying `cat .env` in backticks --
138
- // came back as a real command substitution. Blank those bodies out first.
139
- // An unquoted delimiter (<<BODY) does expand, so it is left to the
140
- // analyzer.
141
- const scrubbed = String(command).replace(
142
- /(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3\b/g,
143
- '$1$2$3$2'
144
- );
145
-
146
- const paths = new Set(commandAccess(scrubbed).paths);
147
-
148
- // Two shapes the analyzer cannot return. It drops any word carrying an
149
- // expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched
150
- // by name. And it only walks commands it recognises, so an unknown tool
151
- // handed a secret through a file-shaped option, such as
152
- // `docker compose --env-file .env`, is invisible to it.
153
- const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
154
- const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
155
- for (let index = 0; index < tokens.length; index += 1) {
156
- const token = tokens[index];
157
- const shaped = /^~|[*?[{]/.test(token);
158
- const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
159
- if (!shaped && !optionFed) continue;
160
- const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
161
- if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
162
- }
163
-
164
- return [...paths];
165
- }
166
-
167
- function extractPaths(toolName, input) {
168
- const paths = [];
169
- if (!input) return paths;
170
-
171
- for (const key of ['file_path', 'path']) {
172
- if (typeof input[key] === 'string' && input[key].trim()) {
173
- paths.push(input[key].trim());
174
- }
175
- }
176
-
177
- for (const key of ['paths', 'search_paths']) {
178
- if (Array.isArray(input[key])) {
179
- paths.push(...input[key].filter(Boolean));
180
- }
181
- }
182
-
183
- if (toolName === 'Bash' && typeof input.command === 'string') {
184
- paths.push(...extractBashPaths(input.command));
185
- }
186
-
187
- return paths.filter(Boolean);
188
- }
189
-
190
- function askForPermission(filePath) {
191
- const basename = path.basename(filePath);
192
- process.stdout.write(JSON.stringify({
193
- hookSpecificOutput: {
194
- hookEventName: 'PreToolUse',
195
- permissionDecision: 'ask',
196
- permissionDecisionReason: `Sensitive file access requires approval: ${basename}`
197
- }
198
- }) + '\n');
199
- }
200
-
201
44
  const stdin = fs.readFileSync(0, 'utf8').trim();
202
- if (!stdin) process.exit(0);
45
+ if (!stdin) {
46
+ askForPermission(EVALUATION_FAILURE);
47
+ process.exit(0);
48
+ }
203
49
 
204
50
  const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
205
51
  const data = normalizeHookPayload(JSON.parse(stdin));
206
52
  const toolName = data.tool_name || '';
207
53
  const toolInput = data.tool_input || {};
208
54
  const cwd = typeof data.cwd === 'string' && data.cwd.trim() ? data.cwd : process.cwd();
209
- const runtime = readRuntime(cwd);
210
-
211
- if (runtime.privacyBlock === false) process.exit(0);
212
-
213
- const paths = extractPaths(toolName, toolInput);
214
- if (!paths.length) process.exit(0);
55
+ const projectRoot = resolveProjectRoot(data);
56
+ const runtime = readRuntime(projectRoot);
215
57
 
216
- for (const filePath of paths) {
217
- const target = resolveTarget(filePath, cwd);
58
+ // A plugin is trusted once for every repository, so a repository cannot switch it off.
59
+ if (runtime.privacyBlock === false && !require('./lib/plugin-options.cjs').isPlugin()) process.exit(0);
218
60
 
219
- // The real target is authoritative: an exempt-looking alias must not hide
220
- // a sensitive target, while a real .env.example target remains safe.
221
- if (target) {
222
- if (isSensitive(target) && !isSafe(target)) {
223
- askForPermission(filePath);
224
- process.exit(0);
225
- }
226
- if (isSafe(target)) continue;
227
- } else {
228
- if (isSymlinkToSensitive(filePath, cwd)) {
229
- askForPermission(filePath);
230
- process.exit(0);
231
- }
232
- }
233
-
234
- if (isSafe(filePath) || !isSensitive(filePath)) continue;
235
- askForPermission(filePath);
236
- process.exit(0);
237
- }
61
+ const paths = sensitivePaths(toolName, toolInput, cwd);
62
+ if (paths.length) askForPermission(`Sensitive file access requires approval: ${displayNames(paths)}`);
238
63
 
239
64
  process.exit(0);
240
65
  } catch (_) {
241
- process.stdout.write(JSON.stringify({
242
- hookSpecificOutput: {
243
- hookEventName: 'PreToolUse',
244
- permissionDecision: 'ask',
245
- permissionDecisionReason: 'Sensitive access could not be evaluated safely.'
246
- }
247
- }) + '\n');
66
+ askForPermission(EVALUATION_FAILURE);
248
67
  process.exit(0);
249
68
  }
@@ -11,12 +11,12 @@
11
11
  */
12
12
 
13
13
  try {
14
- const crypto = require('crypto');
15
14
  const fs = require('fs');
16
- const os = require('os');
17
15
  const path = require('path');
18
16
 
19
17
  const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
18
+ const { projectRoot } = require('./lib/project-root.cjs');
19
+ const { reserveSession } = require('./lib/rules-reservation.cjs');
20
20
  function readRuntime(cwd) {
21
21
  try {
22
22
  const p = runtimePath(cwd, 'runtime.json');
@@ -24,26 +24,6 @@ try {
24
24
  } catch { return null; }
25
25
  }
26
26
 
27
- /** Reserve one injection slot per session. Returns null on reservation errors. */
28
- function reserveSession(sessionId) {
29
- if (!sessionId) return null;
30
- try {
31
- const key = crypto.createHash('sha256')
32
- .update(String(sessionId))
33
- .digest('hex')
34
- .slice(0, 16);
35
- const file = path.join(os.tmpdir(), `cafekit-rules-${key}.json`);
36
- fs.writeFileSync(file, JSON.stringify({ sessionId: String(sessionId) }), {
37
- encoding: 'utf8',
38
- flag: 'wx',
39
- mode: 0o600
40
- });
41
- return true;
42
- } catch (error) {
43
- return error.code === 'EEXIST' ? false : null;
44
- }
45
- }
46
-
47
27
  // ── Main ──────────────────────────────────────────────────────────────────
48
28
 
49
29
  const stdin = fs.readFileSync(0, 'utf8').trim();
@@ -52,22 +32,23 @@ try {
52
32
  const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
53
33
  const payload = normalizeHookPayload(JSON.parse(stdin));
54
34
  const sessionId = payload.session_id || null;
55
- const payloadCwd = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
56
- const cwd = payloadCwd || process.env.PROJECT_ROOT || process.cwd();
35
+ const root = projectRoot(payload);
36
+ // An npx install always has runtime.json; under the plugin it is optional.
37
+ // Loaded only under the plugin, so npx installs need no extra hook file.
38
+ const plugin = process.env.CLAUDE_PLUGIN_ROOT ? require('./lib/plugin-options.cjs') : null;
39
+ const runtime = readRuntime(root) || (plugin ? {} : null);
40
+ if (runtime === null) process.exit(0);
57
41
 
58
42
  // No session identity or reservation means no injection. Fail open.
59
43
  if (reserveSession(sessionId) !== true) process.exit(0);
60
44
 
61
- const runtime = readRuntime(cwd);
62
- if (runtime === null) process.exit(0);
63
-
64
45
  // Language
65
46
  const thinkLang = runtime.locale?.thinkingLanguage || '';
66
- const respondLang = runtime.locale?.responseLanguage || '';
47
+ const respondLang = runtime.locale?.responseLanguage || (plugin ? plugin.pluginOption('response_language') : '');
67
48
  const effectThink = thinkLang || (respondLang ? 'en' : '');
68
49
 
69
50
  // Paths
70
- const baseDir = cwd;
51
+ const baseDir = root;
71
52
  const plansPath = path.join(baseDir, runtime.paths?.plans || 'plans');
72
53
  const docsPath = path.join(baseDir, runtime.paths?.docs || 'docs');
73
54
  const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');