@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
@@ -17,6 +17,7 @@ try {
17
17
 
18
18
  /** Read <runtime>/runtime.json */
19
19
  const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
20
+ const { projectRoot } = require('./lib/project-root.cjs');
20
21
  function readRuntime(cwd) {
21
22
  try {
22
23
  const p = runtimePath(cwd, 'runtime.json');
@@ -43,20 +44,21 @@ try {
43
44
 
44
45
  const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
45
46
  const payload = normalizeHookPayload(JSON.parse(stdin));
46
- // Use payload.cwd for monorepo support — subagent may run in a different dir
47
- const payloadCwd = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
48
- const agentCwd = payloadCwd || process.env.PROJECT_ROOT || process.cwd();
49
- const runtime = readRuntime(agentCwd);
47
+ const root = projectRoot(payload);
48
+ // An npx install always has runtime.json; under the plugin it is optional.
49
+ // Loaded only under the plugin, so npx installs need no extra hook file.
50
+ const plugin = process.env.CLAUDE_PLUGIN_ROOT ? require('./lib/plugin-options.cjs') : null;
51
+ const runtime = readRuntime(root) || (plugin ? {} : null);
50
52
  if (runtime === null) process.exit(0);
51
53
 
52
54
  // Language config from runtime.json
53
55
  const thinkLang = runtime.locale?.thinkingLanguage || '';
54
- const respondLang = runtime.locale?.responseLanguage || '';
56
+ const respondLang = runtime.locale?.responseLanguage || (plugin ? plugin.pluginOption('response_language') : '');
55
57
  // Default thinking to 'en' when only response language is set
56
58
  const effectThink = thinkLang || (respondLang ? 'en' : '');
57
59
 
58
60
  // Resolve paths from env (set by session.cjs) or runtime defaults
59
- const baseDir = agentCwd;
61
+ const baseDir = root;
60
62
  const plansPath = path.join(baseDir, runtime.paths?.plans || 'plans');
61
63
  const docsPath = path.join(baseDir, runtime.paths?.docs || 'docs');
62
64
  const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
@@ -74,10 +76,12 @@ try {
74
76
  }
75
77
 
76
78
  // Python venv (optional — if <skills>/.venv exists)
77
- const venv = resolveVenv(agentCwd);
79
+ const venv = resolveVenv(root);
78
80
 
79
81
  lines.push('## Rules');
80
82
  lines.push(`- Specs → ${specsPath}/ | Plans → ${plansPath}/ | Docs → ${docsPath}/`);
83
+ lines.push('- YAGNI · KISS · DRY');
84
+ lines.push('- Be concise. List unresolved questions at end.');
81
85
  if (venv) {
82
86
  lines.push(`- Python in ${skillsDir()}/: use \`${venv}\``);
83
87
  lines.push('- Never use global pip install');
@@ -15,50 +15,8 @@
15
15
 
16
16
  try {
17
17
  const fs = require('fs');
18
- const path = require('path');
19
- const { execSync } = require('child_process');
20
-
21
- // Directories that should never be read (too large / irrelevant to LLM)
22
- const BLOCKED_DIRS = [
23
- 'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
24
- '__pycache__', '.venv', 'venv',
25
- 'vendor', 'target',
26
- '.git', 'coverage', '.nyc_output',
27
- ];
28
-
29
- // Glob patterns too broad to be useful
30
- const BROAD_GLOB = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
31
-
32
- // Commands that are always allowed (build tools, package managers, venv)
33
- const ALLOWED_CMD = /^(npm|pnpm|yarn|bun|npx|pnpx|bunx|tsc|vite|esbuild|webpack|rollup|turbo|nx|jest|vitest|eslint|prettier|go|cargo|make|mvn|gradle|dotnet|docker|kubectl|helm|python3?|pip|uv|deno|bundle|rake|php|composer|ruby|mix)\b/;
34
- const VENV_EXEC = /(^|[\/\\])\.?venv[\/\\](bin|Scripts)[\/\\]/;
35
- const VENV_CREATE = /^(python3?|py)\s+.*-m\s+venv\s+|^uv\s+venv(\s|$)/;
36
-
37
- function isAllowedCommand(cmd) {
38
- const s = cmd.trim().replace(/^(\w+=\S+\s+)+/, '').replace(/^(sudo|env|time)\s+/, '').trim();
39
- return ALLOWED_CMD.test(s) || VENV_EXEC.test(s) || VENV_CREATE.test(s);
40
- }
41
-
42
- function isBlockedPath(p) {
43
- return p.replace(/\\/g, '/').split('/').some(seg => BLOCKED_DIRS.includes(seg));
44
- }
45
-
46
- function isBroadGlob(p) {
47
- return BROAD_GLOB.some(r => r.test(p.trim()));
48
- }
49
-
50
- function extractPaths(toolName, input) {
51
- const out = [];
52
- if (!input) return out;
53
- if (input.file_path) out.push(input.file_path);
54
- if (input.path) out.push(input.path);
55
- if (input.pattern) out.push(input.pattern);
56
- if (typeof input.command === 'string') {
57
- const m = input.command.match(/(?:cat|ls|find|head|tail)\s+(\S+)/g);
58
- if (m) m.forEach(s => out.push(s.trim().split(/\s+/).pop()));
59
- }
60
- return out.filter(Boolean);
61
- }
18
+ const { BLOCKED_DIRS, inspectInput } = require('./lib/inspect-paths.cjs');
19
+ const { projectRoot } = require('./lib/project-root.cjs');
62
20
 
63
21
  const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
64
22
  function readRuntime(cwd) {
@@ -97,37 +55,33 @@ function denyWithReason(reason) {
97
55
  const data = normalizeHookPayload(JSON.parse(stdin));
98
56
  const toolName = data.tool_name || '';
99
57
  const toolInput = data.tool_input || {};
100
- const cwd = data.cwd || process.cwd();
101
- const runtime = readRuntime(cwd);
102
-
103
- if (runtime.scout?.enabled === false || runtime.inspect?.enabled === false) process.exit(0);
104
-
105
- // Allow all-permitted bash commands immediately
106
- if (toolInput.command) {
107
- const cmds = toolInput.command.split(/\s*(?:&&|\|\||;)\s*/).filter(Boolean);
108
- if (cmds.every(c => isAllowedCommand(c))) process.exit(0);
109
- }
110
-
111
- // Broad glob check
112
- if (toolInput.pattern && isBroadGlob(toolInput.pattern)) {
58
+ let cwd = data.cwd || process.cwd();
59
+ try { cwd = fs.realpathSync(cwd); } catch { /* retain the payload cwd when unavailable */ }
60
+ const root = projectRoot(data);
61
+ const runtime = readRuntime(root);
62
+
63
+ // A plugin is trusted once for every repository, so a repository cannot switch it off.
64
+ const disabled = runtime.scout?.enabled === false || runtime.inspect?.enabled === false;
65
+ if (disabled && !require('./lib/plugin-options.cjs').isPlugin()) process.exit(0);
66
+
67
+ const result = inspectInput(toolName, toolInput, {
68
+ cwd, projectRoot: root
69
+ });
70
+ if (result.broadGlob) {
113
71
  denyWithReason(
114
72
  `SCOPE LIMIT EXCEEDED: Glob pattern is excessively broad\n` +
115
- `Requested Pattern: ${toolInput.pattern}\n\n` +
73
+ `Requested Pattern: ${result.broadGlob}\n\n` +
116
74
  `Please narrow your scope (e.g., src/**/*.ts rather than **/*.ts).`
117
75
  );
118
76
  }
119
77
 
120
- // Blocked directory check
121
- const paths = extractPaths(toolName, toolInput);
122
- for (const p of paths) {
123
- if (isBlockedPath(p)) {
124
- const blocked = p.replace(/\\/g, '/').split('/').find(s => BLOCKED_DIRS.includes(s));
78
+ if (result.blockedPath) {
79
+ const { requested, blocked } = result.blockedPath;
125
80
  denyWithReason(
126
81
  `SCOPE LIMIT EXCEEDED: Directory "${blocked}/" is explicitly forbidden\n` +
127
- `Requested Path: ${p}\n` +
128
- `Restricted zones: ${BLOCKED_DIRS.join(', ')}`
82
+ `Requested Path: ${requested}\n` +
83
+ `Restricted zones: ${[...BLOCKED_DIRS].join(', ')}`
129
84
  );
130
- }
131
85
  }
132
86
 
133
87
  process.exit(0);
@@ -29,11 +29,29 @@ function isSourceTree(dir) {
29
29
  return String(dir || '').includes(SOURCE_MARKER);
30
30
  }
31
31
 
32
+ /**
33
+ * State root for hooks running from the Claude Code plugin, or null outside it.
34
+ * The plugin root is replaced on every update and the project must stay clean,
35
+ * so plugin state lives in CLAUDE_PLUGIN_DATA, or the OS temp directory when the
36
+ * host gives no usable data directory.
37
+ */
38
+ function pluginStateRoot() {
39
+ if (!process.env.CLAUDE_PLUGIN_ROOT) return null;
40
+ const data = process.env.CLAUDE_PLUGIN_DATA;
41
+ if (data && path.isAbsolute(data)) return data;
42
+ // Per-user name so accounts sharing /tmp do not collide; this is a fallback
43
+ // only, since the host sets CLAUDE_PLUGIN_DATA for plugin hooks.
44
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
45
+ return path.join(os.tmpdir(), `cafekit-plugin-${uid}`);
46
+ }
47
+
32
48
  /** Absolute directory for hook-generated state; create it before writing. */
33
49
  function hookStateDir() {
50
+ const pluginRoot = pluginStateRoot();
51
+ if (pluginRoot) return path.join(pluginRoot, '.logs');
34
52
  return isSourceTree(HOOKS_DIR)
35
53
  ? path.join(os.tmpdir(), 'cafekit-hook-logs', require('./runtime-dir.cjs').runtimeDirName().slice(1))
36
54
  : path.join(HOOKS_DIR, '.logs');
37
55
  }
38
56
 
39
- module.exports = { hookStateDir, isSourceTree };
57
+ module.exports = { hookStateDir, isSourceTree, pluginStateRoot };
@@ -0,0 +1,304 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const BLOCKED_DIRS = new Set([
6
+ 'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
7
+ '__pycache__', '.venv', 'venv', 'vendor', 'target',
8
+ '.git', 'coverage', '.nyc_output'
9
+ ]);
10
+ const BROAD_GLOBS = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
11
+ const PATH_KEYS = /^(?:file_?paths?|paths?|search_?paths?|notebook_path|(?:source|target|destination)_?paths?|dirs?|director(?:y|ies)|folders?|cwd|root|locations?|uris?)$/i;
12
+ const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
13
+ const READERS = new Set(['cat', 'head', 'tail', 'nl', 'less', 'more', 'bat', 'ls', 'tree', 'find', 'rg', 'grep', 'sed', 'awk', 'wc', 'du', 'file', 'stat']);
14
+ const clean = (value) => String(value || '').trim();
15
+ const nameOf = (value) => String(value || '').replace(/\\/g, '/').split('/').pop();
16
+ function real(value) { try { return fs.realpathSync(value); } catch { return value; } }
17
+ function relativeInside(root, candidate) {
18
+ const relative = path.relative(root, candidate);
19
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? relative : null;
20
+ }
21
+ function blockedSegment(value, { cwd, projectRoot }) {
22
+ let candidate = clean(value).replace(/\\/g, '/');
23
+ if (candidate === '~' || candidate.startsWith('~/')) candidate = os.homedir() + candidate.slice(1);
24
+ if (path.isAbsolute(candidate)) {
25
+ // Prefer project root over a nested session cwd. Compare lexical and canonical
26
+ // roots so /var and /private/var (or a symlink install) have the same boundary.
27
+ for (const base of [projectRoot, cwd].filter(base => typeof base === 'string' && base)) {
28
+ const root = path.resolve(base);
29
+ const relative = relativeInside(root, candidate) ?? relativeInside(real(root), candidate);
30
+ if (relative !== null) { candidate = relative; break; }
31
+ }
32
+ } else candidate = path.normalize(candidate);
33
+ return candidate.split('/').find((segment) => BLOCKED_DIRS.has(segment));
34
+ }
35
+ function narrowPath(value, context) {
36
+ if (context.cwd !== undefined && typeof context.cwd !== 'string') return false;
37
+ let requested = clean(value).replace(/\\/g, '/');
38
+ if (!requested) return false;
39
+ if (requested === '~' || requested.startsWith('~/')) requested = os.homedir() + requested.slice(1);
40
+ const root = path.resolve(context.projectRoot || context.cwd || process.cwd());
41
+ const candidate = path.resolve(context.cwd || root, requested);
42
+ const relative = relativeInside(root, candidate) ?? relativeInside(real(root), candidate);
43
+ // Both boundaries must hold: a symlink cannot turn an outside read into a
44
+ // narrow scope, nor point a lexical child at the project root or beyond it.
45
+ const canonical = relative === null ? candidate : real(path.resolve(real(root), relative));
46
+ return !!relative && !!relativeInside(real(root), canonical)
47
+ && !blockedSegment(candidate, context) && !blockedSegment(canonical, context);
48
+ }
49
+ function collectStructured(value, key, toolName, paths, globs) {
50
+ if (typeof value === 'string') {
51
+ if (PATH_KEYS.test(key)) paths.push(value);
52
+ if ((toolName === 'Glob' && key === 'pattern') || /^(?:glob|globs)$/i.test(key)) globs.push(value);
53
+ } else if (Array.isArray(value)) {
54
+ for (const child of value) collectStructured(child, key, toolName, paths, globs);
55
+ } else if (value && typeof value === 'object') {
56
+ for (const [childKey, child] of Object.entries(value)) collectStructured(child, childKey, toolName, paths, globs);
57
+ }
58
+ }
59
+
60
+ // A small shell lexer, not an executor: quoted prose is data, while substitutions
61
+ // contribute their own command groups. Heredoc bodies are data, never commands.
62
+ function commandGroups(command) {
63
+ function scan(start, closing = '') {
64
+ const groups = []; let group = []; let token = ''; let quote = ''; let index = start;
65
+ const heredocs = [];
66
+ const flush = () => { if (token) group.push(token); token = ''; };
67
+ const boundary = () => { flush(); if (group.length) groups.push(group); group = []; };
68
+ for (; index < command.length; index += 1) {
69
+ const char = command[index];
70
+ if (!quote && closing && char === closing) { boundary(); return { groups, index }; }
71
+ if (char === '\\' && quote !== "'") {
72
+ if (command[index + 1] !== '\n') token += command[index + 1] || '';
73
+ index += 1; continue;
74
+ }
75
+ if (quote === "'") { if (char === "'") quote = ''; else token += char; continue; }
76
+ if (char === '$' && command[index + 1] === '(') {
77
+ const sub = scan(index + 2, ')'); groups.push(...sub.groups);
78
+ token += '__substitution__'; index = sub.index; continue;
79
+ }
80
+ if (char === '`') {
81
+ const sub = scan(index + 1, '`'); groups.push(...sub.groups);
82
+ token += '__substitution__'; index = sub.index; continue;
83
+ }
84
+ if (quote) { if (char === quote) quote = ''; else token += char; continue; }
85
+ if (char === '"' || char === "'") { quote = char; continue; }
86
+ if (char === '(') {
87
+ boundary(); const sub = scan(index + 1, ')');
88
+ groups.push(...sub.groups); index = sub.index; continue;
89
+ }
90
+ if (command.startsWith('<<', index) && !command.startsWith('<<<', index)) {
91
+ const match = command.slice(index).match(/^<<(-?)[ \t]*((?:'[^']*'|"[^"]*"|\\[^\n]|[^\s;|&<>'"\\])+)/);
92
+ if (match) {
93
+ const delimiter = match[2].replace(/'([^']*)'|"([^"]*)"|\\(.)/g, (_, single, double, escaped) => single ?? double ?? escaped);
94
+ flush(); heredocs.push({ delimiter, tabs: !!match[1] });
95
+ index += match[0].length - 1; continue;
96
+ }
97
+ }
98
+ if (char === '>' || char === '<') {
99
+ // Preserve operator identity (quoted > remains ordinary text). A numeric
100
+ // token touching the operator is its fd, not a positional argument.
101
+ if (/^\d+$/.test(token)) token = '';
102
+ flush();
103
+ const operator = command.slice(index).match(/^(?:>>|>&|>|<)/)[0];
104
+ group.push({ redirect: operator }); index += operator.length - 1; continue;
105
+ }
106
+ if (char === '\n') {
107
+ boundary();
108
+ for (const here of heredocs.splice(0)) {
109
+ while (index < command.length) {
110
+ const end = command.indexOf('\n', index + 1);
111
+ const stop = end < 0 ? command.length : end;
112
+ const line = command.slice(index + 1, stop);
113
+ index = stop;
114
+ if ((here.tabs ? line.replace(/^\t+/, '') : line) === here.delimiter) break;
115
+ }
116
+ }
117
+ } else if (';&|'.includes(char)) {
118
+ boundary();
119
+ if (command[index + 1] === char) index += 1;
120
+ } else if (/\s/.test(char)) flush();
121
+ else token += char;
122
+ }
123
+ boundary(); return { groups, index };
124
+ }
125
+ return scan(0).groups;
126
+ }
127
+
128
+ const WRAPPER_OPTIONS = {
129
+ sudo: ['-u', '-g', '-h', '-p', '-C', '-T', '-R', '-D', '--user', '--group', '--host', '--prompt', '--chdir', '--chroot'],
130
+ env: ['-u', '--unset', '-C', '--chdir', '-S', '--split-string'],
131
+ time: ['-f', '--format', '-o', '--output'], command: [], builtin: [],
132
+ nice: ['-n', '--adjustment'], nohup: [],
133
+ xargs: ['-I', '-i', '-n', '-L', '-P', '-s', '-d', '-E', '-a', '--replace', '--max-args', '--max-lines', '--max-procs', '--arg-file', '--delimiter', '--eof'],
134
+ npm: ['-p', '--package', '-c', '--call', '--prefix', '--workspace', '-w'],
135
+ npx: ['-p', '--package', '-c', '--call', '--cache'],
136
+ pnpm: ['--dir', '-C', '--filter'], pnpx: ['-p', '--package'],
137
+ yarn: ['--cwd'], bunx: ['-p', '--package'],
138
+ };
139
+ function unwrap(group, state) {
140
+ let index = 0;
141
+ while (index < group.length) {
142
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(group[index] || '')) index += 1;
143
+ const name = nameOf(group[index]);
144
+ if (!Object.hasOwn(WRAPPER_OPTIONS, name)) return group.slice(index);
145
+ if (name === 'xargs') state.unknownCwd = true;
146
+ index += 1;
147
+ // npm/pnpm/yarn only wrap a reader in exec mode; run/build remain opaque.
148
+ let exec = !['npm', 'pnpm', 'yarn'].includes(name);
149
+ while (index < group.length) {
150
+ const token = group[index];
151
+ const cwdOptions = { env: ['-C', '--chdir'], sudo: ['-D', '--chdir'],
152
+ npm: ['--prefix'], pnpm: ['-C', '--dir'], yarn: ['--cwd'] };
153
+ if (cwdOptions[name]?.some(option => String(token).split('=')[0] === option
154
+ || (option.length === 2 && String(token).startsWith(option)))) state.unknownCwd = true;
155
+ if (name === 'env' && /^-[^-]*C/.test(token)) state.unknownCwd = true;
156
+ if (token === 'exec' && !exec) { exec = true; index += 1; continue; }
157
+ if (token === '--') { index += 1; break; }
158
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { index += 1; continue; }
159
+ const commandOption = name === 'env' ? /^(?:-S|--split-string)(?:=|$)/ : ['npm', 'npx'].includes(name) ? /^(?:-c|--call)(?:=|$)/ : null;
160
+ if (commandOption?.test(token)) {
161
+ const equals = token.indexOf('=');
162
+ return { command: equals < 0 ? group[index + 1] || '' : token.slice(equals + 1),
163
+ rest: name === 'env' ? group.slice(index + (equals < 0 ? 2 : 1)) : [] };
164
+ }
165
+ if (!token.startsWith('-')) break;
166
+ index += WRAPPER_OPTIONS[name].includes(token) ? 2 : 1;
167
+ }
168
+ if (!exec) return [];
169
+ }
170
+ return [];
171
+ }
172
+ function positional(tokens, options) {
173
+ const values = []; let afterDash = false;
174
+ for (let index = 1; index < tokens.length; index += 1) {
175
+ const token = tokens[index];
176
+ if (!afterDash && token === '--') { afterDash = true; continue; }
177
+ if (!afterDash && options.has(token)) { index += 1; continue; }
178
+ if (afterDash || !token.startsWith('-') || token === '-') values.push(token);
179
+ }
180
+ return values;
181
+ }
182
+ function knownSearchOptions(tokens, options, name) {
183
+ const booleans = new Set(['-n', '-i', '-v', '-w', '-x', '-F', '-P', '-l', '-L', '-q', '-s', '-h', '-H', '-o', '-c', '-a', '-u', '-U', '-z',
184
+ '--line-number', '--ignore-case', '--invert-match', '--word-regexp', '--line-regexp', '--fixed-strings', '--pcre2',
185
+ '--files-with-matches', '--files-without-match', '--quiet', '--no-messages', '--no-filename', '--with-filename',
186
+ '--only-matching', '--count', '--text', '--multiline', '--null', '--hidden', '--files', '--stats']);
187
+ if (name === 'grep') for (const flag of ['-r', '-R', '--recursive', '--dereference-recursive']) booleans.add(flag);
188
+ if (name === 'rg') for (const flag of ['--json', '-S', '--smart-case', '-N', '--no-heading', '--heading',
189
+ '--column', '--vimgrep', '--follow', '--no-ignore', '--no-ignore-dot', '--no-ignore-exclude',
190
+ '--no-ignore-files', '--no-ignore-global', '--no-ignore-parent', '--no-ignore-vcs', '--no-ignore-messages',
191
+ '--no-ignore-file-case-insensitive', '--count-matches', '-b', '--trim']) booleans.add(flag);
192
+ for (let index = 1; index < tokens.length; index += 1) {
193
+ const token = tokens[index];
194
+ if (token === '--') return true;
195
+ if (!token.startsWith('-') || token === '-') continue;
196
+ if (options.has(token)) { index += 1; continue; }
197
+ if (/^-[ABCmMjdt]\S+/.test(token) && options.has(token.slice(0, 2))) continue;
198
+ if (token.startsWith('--') && options.has(token.split('=')[0]) && token.includes('=')) continue;
199
+ if (booleans.has(token)) continue;
200
+ if (!token.startsWith('--') && [...token.slice(1)].every(flag => booleans.has(`-${flag}`))) continue;
201
+ return false;
202
+ }
203
+ return true;
204
+ }
205
+ function commandPaths(group, globs, scopes, state) {
206
+ const inputs = []; const args = [];
207
+ for (let index = 0; index < group.length; index += 1) {
208
+ const token = group[index];
209
+ if (token.redirect) {
210
+ const operand = group[index += 1];
211
+ if (token.redirect === '<' && typeof operand === 'string') inputs.push(operand);
212
+ } else args.push(token);
213
+ }
214
+ return [...inputs, ...readerPaths(args, globs, scopes, state)];
215
+ }
216
+ function readerPaths(group, globs, scopes, state) {
217
+ const tokens = unwrap(group, state);
218
+ if (tokens.command !== undefined) {
219
+ const groups = commandGroups(tokens.command);
220
+ if (groups.length) groups[groups.length - 1].push(...tokens.rest);
221
+ return groups.flatMap((nested) => commandPaths(nested, globs, scopes, state));
222
+ }
223
+ const name = nameOf(tokens[0]);
224
+ if (['cd', 'pushd', 'popd', 'eval', 'source', '.'].includes(name)) state.unknownCwd = true;
225
+ if (!READERS.has(name)) return []; // Executables and non-reader arguments are never paths.
226
+ if (name === 'find') {
227
+ let index = 1;
228
+ while (['-H', '-L', '-P'].includes(tokens[index]) || /^-O\d+$/.test(tokens[index] || '')) index += 1;
229
+ if (tokens[index] === '-D') index += 2;
230
+ if (tokens[index] === '--') index += 1;
231
+ const roots = [];
232
+ for (; index < tokens.length && !/^(?:-|!|\(|\))/.test(tokens[index]); index += 1) roots.push(tokens[index]);
233
+ return roots; // Expressions such as -path ... -prune are not read roots.
234
+ }
235
+ if (['rg', 'grep'].includes(name)) {
236
+ const firstGlob = globs.length;
237
+ for (let index = 1; index < tokens.length; index += 1) {
238
+ const token = tokens[index];
239
+ if (['-g', '--glob', '--iglob'].includes(token)) globs.push(tokens[index + 1] || '');
240
+ else if (/^--i?glob=/.test(token)) globs.push(token.slice(token.indexOf('=') + 1));
241
+ }
242
+ const options = new Set(['-e', '--regexp', '-f', '--file', '-g', '--glob', '--iglob', '-t', '--type', '-T', '--type-not', '--exclude', '--exclude-dir', '--include', '-m', '--max-count', '-A', '-B', '-C', '--after-context', '--before-context', '--context', '--encoding', '--color',
243
+ '--max-depth', '-d', '-j', '--threads', '-M', '--max-columns', '--sort', '--sortr', '--replace', '--max-filesize', '--pre', '--path-separator', '--dfa-size-limit', '--regex-size-limit', '--directories', '-D', '--devices', '--label']);
244
+ // rg -r takes replacement text; grep -r is a recursive flag.
245
+ if (name === 'rg') options.add('-r');
246
+ const values = positional(tokens, options);
247
+ const explicit = tokens.some((token) => /^(?:-[ef]|--(?:regexp|file)(?:=|$))/.test(token)) || tokens.includes('--files');
248
+ const paths = explicit ? values : values.slice(1);
249
+ scopes.push({ paths: state.unknownCwd || !knownSearchOptions(tokens, options, name) ? [] : paths, globs: globs.slice(firstGlob) });
250
+ return paths;
251
+ }
252
+ if (['sed', 'awk'].includes(name)) {
253
+ const options = new Set(['-e', '--expression', '-f', '--file', '-F', '-v']);
254
+ const values = positional(tokens, options);
255
+ return tokens.some((token) => /^(?:-[ef]|--(?:expression|file)(?:=|$))/.test(token)) ? values : values.slice(1);
256
+ }
257
+ // A flag's meaning belongs to its reader: cat -n and wc -c are booleans,
258
+ // whereas head -n consumes a number. Never swallow the following read path.
259
+ const options = {
260
+ head: ['-n', '--lines', '-c', '--bytes'], tail: ['-n', '--lines', '-c', '--bytes', '--pid', '-s', '--sleep-interval'],
261
+ nl: ['-b', '-d', '-f', '-h', '-i', '-l', '-n', '-s', '-v', '-w'],
262
+ tree: ['-I', '-P', '-L', '-o', '--filelimit', '--sort'],
263
+ ls: ['-I', '--ignore', '--hide', '--format', '--sort', '--time', '--block-size', '--quoting-style', '-w', '--width'],
264
+ du: ['-d', '--max-depth', '--exclude', '-X', '--exclude-from', '-B', '--block-size'],
265
+ stat: ['-c', '--format', '--printf'], file: ['-m', '--magic-file', '-f', '--files-from', '-F', '--separator'],
266
+ bat: ['--color', '--style', '--language', '--paging', '--theme', '--line-range', '-r', '--tabs'],
267
+ less: ['-x', '--tabs', '-j', '-p', '-P', '-z'],
268
+ };
269
+ return positional(tokens, new Set(options[name] || []));
270
+ }
271
+ function broadGlobFor(paths, globs, context) {
272
+ if (paths.length > 0 && paths.every(requested => narrowPath(requested, context))) return undefined;
273
+ return globs.find(glob => !glob.startsWith('!') && BROAD_GLOBS.some(rule => rule.test(clean(glob))));
274
+ }
275
+ function inspectInput(toolName, toolInput, context = {}) {
276
+ if (WRITE_TOOLS.has(toolName)) return {};
277
+ const paths = []; const globs = [];
278
+ collectStructured(toolInput, '', toolName, paths, globs);
279
+ let broadGlob = broadGlobFor(paths, globs, context);
280
+ const scopes = [];
281
+ const commandContext = { ...context };
282
+ let unknownCwd = context.cwd !== undefined && typeof context.cwd !== 'string';
283
+ const overrides = ['cwd', 'workdir'].filter(key => Object.hasOwn(toolInput || {}, key));
284
+ for (const key of overrides) {
285
+ if (typeof toolInput[key] !== 'string' || !toolInput[key].trim()) unknownCwd = true;
286
+ else commandContext.cwd = path.resolve(typeof context.cwd === 'string' ? context.cwd : process.cwd(), toolInput[key]);
287
+ }
288
+ if (overrides.length > 1 && toolInput.cwd !== toolInput.workdir) unknownCwd = true;
289
+ for (const key of ['command', 'cmd']) {
290
+ if (typeof toolInput?.[key] === 'string') {
291
+ const state = { unknownCwd };
292
+ for (const group of commandGroups(toolInput[key])) paths.push(...commandPaths(group, globs, scopes, state));
293
+ }
294
+ }
295
+ const included = globs.filter((glob) => !glob.startsWith('!'));
296
+ for (const scope of scopes) broadGlob ||= broadGlobFor(scope.paths, scope.globs, commandContext);
297
+ paths.push(...included);
298
+ return {
299
+ broadGlob,
300
+ blockedPath: paths.map((requested) => ({ requested, blocked: blockedSegment(requested, context) })).find((result) => result.blocked),
301
+ };
302
+ }
303
+
304
+ module.exports = { BLOCKED_DIRS, inspectInput };
@@ -80,7 +80,7 @@ function processEntry(entry, toolMap, agentMap, latestTodos, result) {
80
80
  for (const block of content) {
81
81
  // Handle tool_use blocks
82
82
  if (block.type === 'tool_use' && block.id && block.name) {
83
- if (block.name === 'Task') {
83
+ if (block.name === 'Task' || block.name === 'Agent') {
84
84
  // Agent spawn
85
85
  agentMap.set(block.id, {
86
86
  id: block.id,
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Claude Code plugin options for the hooks.
5
+ *
6
+ * The plugin's userConfig values reach hooks as CLAUDE_PLUGIN_OPTION_<KEY> and are
7
+ * injected into model context verbatim, so each one is reduced to a single short
8
+ * line: no control, bidi, or zero-width characters or line breaks that could open a
9
+ * new section or disguise the text, and
10
+ * at most 64 characters so the core stays inside the host's context budget.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const MAX_OPTION_LENGTH = 64;
17
+
18
+ /**
19
+ * True when the hook runs from the Claude Code plugin rather than an npx install.
20
+ * The variable alone is not enough: an npx hook can inherit it from a parent process,
21
+ * so this file must really live under that plugin root.
22
+ */
23
+ function isPlugin() {
24
+ const declared = process.env.CLAUDE_PLUGIN_ROOT;
25
+ if (!declared) return false;
26
+ try {
27
+ return fs.realpathSync(declared) === fs.realpathSync(path.resolve(__dirname, '..', '..'));
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /** One short plain line: no control, bidi, or zero-width characters; at most 64 characters. */
34
+ function sanitizeLine(raw) {
35
+ if (typeof raw !== 'string') return '';
36
+ return [...raw.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]+/g, ' ').replace(/\s+/g, ' ').trim()]
37
+ .slice(0, MAX_OPTION_LENGTH)
38
+ .join('')
39
+ .trim();
40
+ }
41
+
42
+ /** Sanitized option value, or '' when unset. `key` is the userConfig key, e.g. `response_language`. */
43
+ function pluginOption(key) {
44
+ return sanitizeLine(process.env[`CLAUDE_PLUGIN_OPTION_${key.toUpperCase()}`]);
45
+ }
46
+
47
+ module.exports = { MAX_OPTION_LENGTH, isPlugin, pluginOption, sanitizeLine };