@haposoft/cafekit 0.18.0 → 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 (162) 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 +938 -237
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +222 -149
  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 +102 -1362
  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-authority-decoupled.test.js +14 -19
  18. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  19. package/bin/__tests__/omp-bridge.test.js +66 -12
  20. package/bin/__tests__/omp-hooks.test.js +11 -5
  21. package/bin/__tests__/orca-session.test.js +7 -0
  22. package/bin/__tests__/package-inventory.test.js +1 -395
  23. package/bin/__tests__/privacy-paths.test.js +317 -0
  24. package/bin/__tests__/spec-narrowing.test.js +30 -134
  25. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
  26. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  27. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  28. package/bin/__tests__/validator-grounding.test.js +17 -2929
  29. package/bin/install.js +25 -0
  30. package/bin/lib/codex-install.js +134 -12
  31. package/bin/lib/context.js +1 -1
  32. package/bin/lib/managed-writer.js +2 -3
  33. package/bin/phases/claude-runtime.js +91 -25
  34. package/bin/phases/claude-settings.js +22 -1
  35. package/bin/phases/codex-hooks.js +96 -33
  36. package/bin/phases/codex-runtime.js +45 -49
  37. package/bin/phases/copy-payload.js +11 -28
  38. package/bin/phases/omp-runtime.js +6 -3
  39. package/package.json +2 -3
  40. package/src/claude/CLAUDE.md +4 -0
  41. package/src/claude/agents/code-auditor.md +9 -6
  42. package/src/claude/agents/debugger.md +3 -9
  43. package/src/claude/agents/docs-keeper.md +4 -7
  44. package/src/claude/agents/git-ops.md +2 -4
  45. package/src/claude/agents/implementer.md +6 -7
  46. package/src/claude/agents/inspector.md +2 -0
  47. package/src/claude/agents/project-manager.md +2 -2
  48. package/src/claude/agents/spec-maker.md +4 -4
  49. package/src/claude/agents/test-runner.md +8 -11
  50. package/src/claude/agents/ui-ux-designer.md +0 -1
  51. package/src/claude/gitignore +3 -0
  52. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  53. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  54. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  55. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  56. package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
  57. package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
  58. package/src/claude/hooks/__tests__/state.test.js +207 -7
  59. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  60. package/src/claude/hooks/agent.cjs +7 -6
  61. package/src/claude/hooks/inspect-block.cjs +15 -63
  62. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  63. package/src/claude/hooks/lib/parser.cjs +1 -1
  64. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  65. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  66. package/src/claude/hooks/lib/project-root.cjs +15 -0
  67. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  68. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  69. package/src/claude/hooks/lib/update-check.cjs +77 -0
  70. package/src/claude/hooks/precompact.cjs +8 -15
  71. package/src/claude/hooks/privacy-block.cjs +23 -205
  72. package/src/claude/hooks/rules.cjs +6 -28
  73. package/src/claude/hooks/session.cjs +34 -117
  74. package/src/claude/hooks/spec-gate.cjs +17 -89
  75. package/src/claude/hooks/spec-state.cjs +89 -96
  76. package/src/claude/hooks/state.cjs +96 -51
  77. package/src/claude/migration-manifest.json +20 -8
  78. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  79. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  80. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  81. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  82. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  83. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  84. package/src/claude/rules/ai-dev-rules.md +1 -2
  85. package/src/claude/rules/hook-protocols.md +1 -1
  86. package/src/claude/rules/workflow.md +1 -1
  87. package/src/claude/runtime.json +0 -3
  88. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  89. package/src/claude/scripts/provenance.cjs +14 -0
  90. package/src/claude/scripts/spec-receipt.cjs +0 -114
  91. package/src/claude/scripts/spec-resolver.cjs +41 -457
  92. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  93. package/src/claude/settings/settings.json +1 -1
  94. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  95. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  96. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  97. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  98. package/src/claude/skills/code-review/SKILL.md +25 -108
  99. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
  100. package/src/claude/skills/develop/SKILL.md +22 -70
  101. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  102. package/src/claude/skills/git/SKILL.md +1 -1
  103. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  104. package/src/claude/skills/research/SKILL.md +24 -55
  105. package/src/claude/skills/sync/SKILL.md +12 -34
  106. package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
  107. package/src/claude/skills/test/SKILL.md +39 -123
  108. package/src/claude/skills/test/references/execution-strategy.md +11 -22
  109. package/src/claude/skills/ui-ux/SKILL.md +1 -1
  110. package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
  111. package/src/claude/skills/web-testing/SKILL.md +2 -20
  112. package/src/codex/AGENTS.md +8 -0
  113. package/src/codex/hooks/ask-guard.cjs +54 -0
  114. package/src/codex/hooks/inspect-block.cjs +11 -7
  115. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  116. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  117. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  118. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  119. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  120. package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
  121. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  122. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  123. package/src/codex/hooks/lib/spec-utils.cjs +6 -45
  124. package/src/codex/hooks/lib/update-check.cjs +1 -0
  125. package/src/codex/hooks/precompact.cjs +39 -0
  126. package/src/codex/hooks/privacy-approval.cjs +28 -17
  127. package/src/codex/hooks/privacy-block.cjs +9 -215
  128. package/src/codex/hooks/rules.cjs +4 -30
  129. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  130. package/src/codex/hooks/session.cjs +47 -11
  131. package/src/codex/hooks/spec-gate.cjs +32 -74
  132. package/src/codex/hooks/spec-state.cjs +68 -46
  133. package/src/codex/hooks/state.cjs +25 -4
  134. package/src/codex/hooks.json +39 -7
  135. package/src/codex/rules/hook-protocols.md +13 -6
  136. package/src/common/AGENTS.md +4 -10
  137. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  138. package/src/omp/hooks/privacy-block.cjs +24 -206
  139. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  140. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  141. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  142. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  143. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  144. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  145. package/src/claude/hooks/lib/context.cjs +0 -527
  146. package/src/claude/hooks/lib/detect.cjs +0 -474
  147. package/src/claude/scripts/change-firewall.cjs +0 -1748
  148. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  149. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  150. package/src/claude/scripts/spec-final-state.cjs +0 -290
  151. package/src/claude/scripts/spec-ground.cjs +0 -650
  152. package/src/claude/scripts/spec-readiness.cjs +0 -425
  153. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  154. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  155. package/src/claude/scripts/validate-spec-output.cjs +0 -3914
  156. package/src/claude/skills/code-review/references/verification-gate.md +0 -41
  157. package/src/claude/skills/specs/templates/design.md +0 -82
  158. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  159. package/src/claude/skills/specs/templates/requirements.md +0 -42
  160. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  161. package/src/claude/skills/specs/templates/task.md +0 -47
  162. 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,10 +44,8 @@ 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
+ const runtime = readRuntime(root);
50
49
  if (runtime === null) process.exit(0);
51
50
 
52
51
  // Language config from runtime.json
@@ -56,7 +55,7 @@ try {
56
55
  const effectThink = thinkLang || (respondLang ? 'en' : '');
57
56
 
58
57
  // Resolve paths from env (set by session.cjs) or runtime defaults
59
- const baseDir = agentCwd;
58
+ const baseDir = root;
60
59
  const plansPath = path.join(baseDir, runtime.paths?.plans || 'plans');
61
60
  const docsPath = path.join(baseDir, runtime.paths?.docs || 'docs');
62
61
  const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
@@ -74,10 +73,12 @@ try {
74
73
  }
75
74
 
76
75
  // Python venv (optional — if <skills>/.venv exists)
77
- const venv = resolveVenv(agentCwd);
76
+ const venv = resolveVenv(root);
78
77
 
79
78
  lines.push('## Rules');
80
79
  lines.push(`- Specs → ${specsPath}/ | Plans → ${plansPath}/ | Docs → ${docsPath}/`);
80
+ lines.push('- YAGNI · KISS · DRY');
81
+ lines.push('- Be concise. List unresolved questions at end.');
81
82
  if (venv) {
82
83
  lines.push(`- Python in ${skillsDir()}/: use \`${venv}\``);
83
84
  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,31 @@ 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);
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);
102
62
 
103
63
  if (runtime.scout?.enabled === false || runtime.inspect?.enabled === false) process.exit(0);
104
64
 
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)) {
65
+ const result = inspectInput(toolName, toolInput, {
66
+ cwd, projectRoot: root
67
+ });
68
+ if (result.broadGlob) {
113
69
  denyWithReason(
114
70
  `SCOPE LIMIT EXCEEDED: Glob pattern is excessively broad\n` +
115
- `Requested Pattern: ${toolInput.pattern}\n\n` +
71
+ `Requested Pattern: ${result.broadGlob}\n\n` +
116
72
  `Please narrow your scope (e.g., src/**/*.ts rather than **/*.ts).`
117
73
  );
118
74
  }
119
75
 
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));
76
+ if (result.blockedPath) {
77
+ const { requested, blocked } = result.blockedPath;
125
78
  denyWithReason(
126
79
  `SCOPE LIMIT EXCEEDED: Directory "${blocked}/" is explicitly forbidden\n` +
127
- `Requested Path: ${p}\n` +
128
- `Restricted zones: ${BLOCKED_DIRS.join(', ')}`
80
+ `Requested Path: ${requested}\n` +
81
+ `Restricted zones: ${[...BLOCKED_DIRS].join(', ')}`
129
82
  );
130
- }
131
83
  }
132
84
 
133
85
  process.exit(0);
@@ -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,