@occasiolabs/occasio 0.8.3 → 0.8.5

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 (52) hide show
  1. package/README.md +1 -0
  2. package/docs/ADAPTER-STAGE-2-MIGRATION.md +59 -0
  3. package/docs/ARCHITECTURE.md +171 -0
  4. package/docs/STAGE-2-STEP-5-SHELL-PLAN.md +107 -0
  5. package/docs/THREAT-MODEL.md +195 -0
  6. package/docs/edr-calibration.md +29 -0
  7. package/package.json +12 -2
  8. package/src/adapters/claude-code.js +1 -2
  9. package/src/adapters/computer-use.js +1 -1
  10. package/src/anomaly/cli.js +4 -1
  11. package/src/anomaly/detectors/deny-rate.js +2 -1
  12. package/src/anomaly/detectors/file-read-volume.js +2 -1
  13. package/src/anomaly/index.js +5 -0
  14. package/src/attest/check-summary.js +1 -1
  15. package/src/attest/index.js +14 -1
  16. package/src/audit/jsonl-auditor.js +180 -14
  17. package/src/audit/repair.js +118 -0
  18. package/src/audit/verifier.js +36 -2
  19. package/src/boundary.js +1 -1
  20. package/src/classifier.js +1 -1
  21. package/src/cli/clear.js +55 -0
  22. package/src/cli/help.js +102 -0
  23. package/src/cli/register.js +90 -0
  24. package/src/cli/status.js +94 -0
  25. package/src/cost/prices.js +106 -0
  26. package/src/dashboard.js +2 -3
  27. package/src/distiller.js +1 -1
  28. package/src/executor/dispatcher.js +2 -2
  29. package/src/executor/native-handlers/glob.js +173 -0
  30. package/src/executor/native-handlers/grep.js +258 -0
  31. package/src/executor/native-handlers/read.js +99 -0
  32. package/src/executor/native-handlers/todo.js +56 -0
  33. package/src/harness.js +8 -10
  34. package/src/index.js +26 -283
  35. package/src/inspect.js +1 -1
  36. package/src/interceptor.js +9 -29
  37. package/src/ledger.js +2 -3
  38. package/src/mcp-experiment.js +4 -4
  39. package/src/mcp-server.js +3 -3
  40. package/src/policy/doctor.js +2 -2
  41. package/src/policy/engine.js +0 -1
  42. package/src/policy/init.js +1 -1
  43. package/src/policy/loader.js +3 -3
  44. package/src/policy/show.js +1 -2
  45. package/src/preflight/cli.js +0 -1
  46. package/src/preflight/miner.js +3 -6
  47. package/src/redteam.js +1 -2
  48. package/src/replay.js +1 -1
  49. package/src/report/index.js +0 -4
  50. package/src/runtime.js +42 -444
  51. package/src/selftest.js +1 -1
  52. package/src/session.js +1 -1
@@ -264,7 +264,7 @@ function normalize(parsed) {
264
264
  try {
265
265
  const regex = new RegExp(rawPattern);
266
266
  patterns.push(Object.freeze({ label, regex }));
267
- } catch (e) {
267
+ } catch {
268
268
  process.stderr.write(`[Occasio] policy.yml: deny_patterns.${label} — invalid RegExp "${rawPattern}", entry skipped\n`);
269
269
  }
270
270
  }
@@ -276,7 +276,7 @@ function normalize(parsed) {
276
276
  // module load (loader.js is imported by other code paths that don't
277
277
  // need the registry).
278
278
  let toolNames;
279
- try { toolNames = require('../core/tool-names'); } catch {}
279
+ try { toolNames = require('../core/tool-names'); } catch { /* ignore */ }
280
280
  const tools = {};
281
281
  for (const name of Object.keys(parsed.tools)) {
282
282
  const entry = normalizeToolEntry(parsed.tools[name]);
@@ -351,7 +351,7 @@ function _firePolicyChange(filePath, policy, hash, fileWasPresent) {
351
351
  });
352
352
  } catch (e) {
353
353
  // Listener crash must not break the proxy — surface to stderr only.
354
- try { process.stderr.write(`[occasio] policy-change listener threw: ${e.message}\n`); } catch {}
354
+ try { process.stderr.write(`[occasio] policy-change listener threw: ${e.message}\n`); } catch { /* ignore */ }
355
355
  }
356
356
  }
357
357
  }
@@ -12,7 +12,6 @@
12
12
  */
13
13
 
14
14
  const fs = require('fs');
15
- const path = require('path');
16
15
 
17
16
  // Transforms currently implemented in the dispatcher.
18
17
  const KNOWN_TRANSFORMS = new Set(['redact-secrets', 'distill-output']);
@@ -88,7 +87,7 @@ function runPolicyCli(args, opts = {}) {
88
87
  const text = fs.readFileSync(filePath, 'utf8');
89
88
  fileExists = true;
90
89
  userParsed = loader.parse(text);
91
- } catch {}
90
+ } catch { /* ignore */ }
92
91
 
93
92
  const active = loader.load();
94
93
  const defaults = loader.DEFAULT_POLICY;
@@ -11,7 +11,6 @@
11
11
  */
12
12
 
13
13
  const os = require('os');
14
- const path = require('path');
15
14
  const {
16
15
  mine,
17
16
  getGitRoot,
@@ -122,10 +122,10 @@ function readRecentEntries(days, logsDir) {
122
122
  for (const line of raw.split('\n')) {
123
123
  const trimmed = line.trim();
124
124
  if (!trimmed) continue;
125
- try { entries.push(JSON.parse(trimmed)); } catch {}
125
+ try { entries.push(JSON.parse(trimmed)); } catch { /* ignore */ }
126
126
  }
127
127
  }
128
- } catch {}
128
+ } catch { /* ignore */ }
129
129
  return entries;
130
130
  }
131
131
 
@@ -231,14 +231,11 @@ function mine(opts) {
231
231
 
232
232
  // project_root → { projectRoot, sessions: Set, toolCounts: Map, legacySessions: number }
233
233
  const projects = new Map();
234
- let totalLegacy = 0;
235
234
 
236
235
  for (const [, runEntries] of runsMap) {
237
236
  const cwd = runCwd(runEntries);
238
237
  if (!cwd) {
239
- // Pre-schema run; count it if it falls under the filter project
240
- // but we can't know which project it belongs to — just count globally.
241
- totalLegacy++;
238
+ // Pre-schema run with no cwd can't attribute to a project, skip.
242
239
  continue;
243
240
  }
244
241
 
package/src/redteam.js CHANGED
@@ -33,7 +33,6 @@
33
33
  */
34
34
 
35
35
  const fs = require('fs');
36
- const path = require('path');
37
36
  const harness = require('./harness');
38
37
 
39
38
  const C = (() => {
@@ -411,7 +410,7 @@ async function runRedteamCli(args = []) {
411
410
  return result;
412
411
  } finally {
413
412
  if (!keepScratch && !process.env.OCC_REDTEAM_KEEP) {
414
- try { fs.rmSync(ctx.workspace, { recursive: true, force: true }); } catch {}
413
+ try { fs.rmSync(ctx.workspace, { recursive: true, force: true }); } catch { /* ignore */ }
415
414
  }
416
415
  }
417
416
  }
package/src/replay.js CHANGED
@@ -386,7 +386,7 @@ function runReplayCli(args) {
386
386
  for (const raw of lines) {
387
387
  const line = raw.trim();
388
388
  if (!line) continue;
389
- try { entries.push(JSON.parse(line)); } catch {}
389
+ try { entries.push(JSON.parse(line)); } catch { /* ignore */ }
390
390
  }
391
391
  }
392
392
 
@@ -25,10 +25,6 @@ const LOGS_DIR = path.join(LOG_DIR, 'logs');
25
25
 
26
26
  // ── Helpers ───────────────────────────────────────────────────────────────────
27
27
 
28
- function todayStr(d) {
29
- return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
30
- }
31
-
32
28
  function cutoffMs(days) {
33
29
  const d = new Date();
34
30
  d.setDate(d.getDate() - days);
package/src/runtime.js CHANGED
@@ -17,460 +17,58 @@
17
17
  * mcp-server.js — for executeLocalTool (hardened execution wrapper)
18
18
  */
19
19
 
20
- const fs = require('fs');
21
- const path = require('path');
22
20
  const { distill } = require('./distiller');
23
21
  const { estimateTokens, scanSecrets } = require('./analyzer');
24
22
 
25
- // ── Shared constants ───────────────────────────────────────────────────────────
26
-
27
- const MAX_OUTPUT = 512 * 1024; // 512 KB — same cap as exec maxBuffer
28
-
29
- // File extensions the native Read handler cannot serve correctly.
30
- // PDFs and images need structured rendering (base64, page extraction) that we
31
- // cannot replicate; Jupyter notebooks need cell-by-cell parsing. All others
32
- // are treated as UTF-8 text and handled natively.
33
- const READ_SKIP_EXTENSIONS = new Set([
34
- '.pdf', '.ipynb',
35
- '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico',
36
- '.zip', '.gz', '.tar', '.bz2', '.xz', '.7z', '.rar',
37
- '.exe', '.dll', '.so', '.dylib',
38
- ]);
39
-
40
- // ── Shared helper ──────────────────────────────────────────────────────────────
41
-
42
- function readFileNative(absPath) {
43
- const buf = fs.readFileSync(absPath);
44
- if (buf.length > MAX_OUTPUT) {
45
- return buf.slice(0, MAX_OUTPUT).toString('utf8') + '\n[truncated — file too large]';
46
- }
47
- return buf.toString('utf8');
48
- }
49
-
50
23
  // ── Read tool support ──────────────────────────────────────────────────────────
51
-
52
- /**
53
- * Returns true when this Read input can be served natively.
54
- * Falls back for PDFs/images (need structured rendering), Jupyter notebooks,
55
- * malformed input, or the `pages` parameter (implies PDF range extraction).
56
- */
57
- function isReadHandleable(input) {
58
- if (!input || typeof input !== 'object') return false;
59
- const fp = input.file_path;
60
- if (!fp || typeof fp !== 'string' || !fp.trim()) return false;
61
- if (input.pages != null) return false;
62
- const ext = path.extname(fp).toLowerCase();
63
- return !READ_SKIP_EXTENSIONS.has(ext);
64
- }
65
-
66
- /**
67
- * Read a file natively and return content formatted like `cat -n` (1-based line
68
- * numbers), honouring the optional offset (0-based line index) and limit fields
69
- * that the Claude Code Read tool sends for partial reads.
70
- */
71
- function handleReadTool(input) {
72
- const fp = (input?.file_path || '').trim();
73
- if (!fp) return { output: '(no file_path provided)', exitCode: 1 };
74
-
75
- const abs = path.resolve(process.cwd(), fp);
76
- try {
77
- const content = readFileNative(abs); // already caps at MAX_OUTPUT
78
- const lines = content.split('\n');
79
- const offset = (typeof input.offset === 'number' && input.offset >= 0) ? input.offset : 0;
80
- const limit = (typeof input.limit === 'number' && input.limit > 0) ? input.limit : lines.length;
81
- const slice = lines.slice(offset, offset + limit);
82
- // Line numbers reflect position in the file (not the slice), matching cat -n.
83
- const formatted = slice.map((l, i) => `${String(offset + i + 1).padStart(6)}\t${l}`).join('\n');
84
- return { output: formatted, exitCode: 0 };
85
- } catch (e) {
86
- const msg = e.code === 'ENOENT'
87
- ? `${fp}: No such file or directory`
88
- : `${fp}: ${e.message}`;
89
- return { output: `Read: ${msg}`, exitCode: 1 };
90
- }
91
- }
24
+ // Moved to src/executor/native-handlers/read.js as Stage-2 of the executor
25
+ // migration (see docs/ADAPTER-STAGE-2-MIGRATION.md). Re-exported here so
26
+ // existing consumers (interceptor, MCP server, tests) keep working unchanged.
27
+ // MAX_OUTPUT and readFileNative are also consumed by the Grep code below.
28
+ const {
29
+ MAX_OUTPUT,
30
+ READ_SKIP_EXTENSIONS,
31
+ readFileNative,
32
+ isReadHandleable,
33
+ handleReadTool,
34
+ } = require('./executor/native-handlers/read');
92
35
 
93
36
  // ── Glob tool support ──────────────────────────────────────────────────────────
94
-
95
- // Characters that indicate shell injection in a glob pattern.
96
- // We reject patterns containing these so handleGlobTool stays read-only.
97
- const GLOB_INJECTION_RE = /[;&|`$<>!]/;
98
-
99
- // Directories skipped during recursive glob walks.
100
- const GLOB_SKIP = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build', '__pycache__', '.venv', 'venv']);
101
-
102
- // Maximum number of matches returned to avoid overwhelming the model context.
103
- const GLOB_MAX = 500;
104
-
105
- function isGlobHandleable(input) {
106
- if (!input || typeof input !== 'object') return false;
107
- const pattern = input.pattern;
108
- if (!pattern || typeof pattern !== 'string' || !pattern.trim()) return false;
109
- if (GLOB_INJECTION_RE.test(pattern)) return false;
110
- if (input.path != null && typeof input.path !== 'string') return false;
111
- return true;
112
- }
113
-
114
- // Escape regex metacharacters in a literal string segment.
115
- function escapeRegexChars(s) {
116
- return s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
117
- }
118
-
119
- /**
120
- * Convert a glob pattern to a RegExp.
121
- * Supports: ** (any path depth), * (single segment), ? (single char),
122
- * {ts,tsx} (alternation), [abc] (character classes).
123
- * Exported for unit testing.
124
- */
125
- function globToRegex(pattern) {
126
- // Normalise Windows separators in the pattern.
127
- const p = pattern.replace(/\\/g, '/');
128
-
129
- let re = '';
130
- let i = 0;
131
- while (i < p.length) {
132
- // ** — match any path segments (including none), consuming the trailing /
133
- if (p[i] === '*' && p[i + 1] === '*') {
134
- re += '.*';
135
- i += 2;
136
- if (p[i] === '/') i++; // consume separator after **
137
- continue;
138
- }
139
- // * — match within a single path segment
140
- if (p[i] === '*') { re += '[^/]*'; i++; continue; }
141
- // ? — match a single character within a segment
142
- if (p[i] === '?') { re += '[^/]'; i++; continue; }
143
- // {a,b,c} — alternation
144
- if (p[i] === '{') {
145
- const end = p.indexOf('}', i);
146
- if (end !== -1) {
147
- const alts = p.slice(i + 1, end).split(',').map(escapeRegexChars);
148
- re += `(?:${alts.join('|')})`;
149
- i = end + 1;
150
- continue;
151
- }
152
- }
153
- // [abc] / [^abc] — pass character classes through verbatim
154
- if (p[i] === '[') {
155
- const end = p.indexOf(']', i);
156
- if (end !== -1) { re += p.slice(i, end + 1); i = end + 1; continue; }
157
- }
158
- re += escapeRegexChars(p[i]);
159
- i++;
160
- }
161
-
162
- // On Windows, matching is case-insensitive; on POSIX it's case-sensitive.
163
- const flags = process.platform === 'win32' ? 'i' : '';
164
- return new RegExp(`^${re}$`, flags);
165
- }
166
-
167
- /**
168
- * Walk `dir` recursively, collecting paths that match `regex`.
169
- * Results are relative to `baseDir`.
170
- */
171
- function walkGlob(dir, baseDir, regex, results) {
172
- if (results.length >= GLOB_MAX) return;
173
- let entries;
174
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
175
- catch { return; }
176
-
177
- for (const entry of entries) {
178
- if (results.length >= GLOB_MAX) break;
179
- if (GLOB_SKIP.has(entry.name)) continue;
180
- const abs = path.join(dir, entry.name);
181
- // Normalise to forward slashes for matching (consistent on all platforms).
182
- const rel = path.relative(baseDir, abs).replace(/\\/g, '/');
183
- if (entry.isDirectory()) {
184
- walkGlob(abs, baseDir, regex, results);
185
- } else if (regex.test(rel)) {
186
- results.push(rel);
187
- }
188
- }
189
- }
190
-
191
- /**
192
- * Resolve glob pattern + optional base path to a sorted list of matching paths,
193
- * relative to CWD. Returns { output, exitCode, matchCount }.
194
- */
195
- function handleGlobTool(input) {
196
- const pattern = (input?.pattern || '').trim();
197
- if (!pattern) return { output: '(no pattern provided)', exitCode: 1, matchCount: 0 };
198
-
199
- const baseDir = input?.path
200
- ? path.resolve(process.cwd(), input.path)
201
- : process.cwd();
202
-
203
- // Reject if base path escapes CWD for safety.
204
- const cwd = process.cwd();
205
- if (!baseDir.startsWith(cwd) && baseDir !== cwd) {
206
- // Allow absolute paths outside CWD — Glob is read-only and safe.
207
- }
208
-
209
- let regex;
210
- try { regex = globToRegex(pattern); }
211
- catch (e) { return { output: `Glob: invalid pattern: ${e.message}`, exitCode: 1, matchCount: 0 }; }
212
-
213
- const results = [];
214
- walkGlob(baseDir, baseDir, regex, results);
215
- results.sort();
216
-
217
- const truncated = results.length >= GLOB_MAX;
218
- const lines = results.map(r => path.join(baseDir !== cwd ? baseDir : '', r).replace(/\\/g, '/'));
219
- const output = lines.join('\n') + (truncated ? `\n(truncated at ${GLOB_MAX} results)` : '');
220
- return { output: output || '(no matches)', exitCode: 0, matchCount: results.length };
221
- }
37
+ // Moved to src/executor/native-handlers/glob.js as Stage-2 Step 3 of the
38
+ // executor migration. GLOB_SKIP and globToRegex are also consumed by the Grep
39
+ // code below.
40
+ const {
41
+ GLOB_INJECTION_RE,
42
+ GLOB_SKIP,
43
+ GLOB_MAX,
44
+ isGlobHandleable,
45
+ globToRegex,
46
+ walkGlob,
47
+ handleGlobTool,
48
+ } = require('./executor/native-handlers/glob');
222
49
 
223
50
  // ── Grep tool support ──────────────────────────────────────────────────────────
224
-
225
- const GREP_MAX_RESULTS = 250; // default output cap — matches Claude Code head_limit default
226
- const GREP_FILE_CAP = 10_000; // safety limit on files walked before stopping
227
-
228
- // File-type → extension mapping, matching ripgrep's --type names.
229
- const GREP_TYPE_EXTS = new Map([
230
- ['js', ['.js', '.mjs', '.cjs']],
231
- ['ts', ['.ts', '.tsx', '.mts', '.cts']],
232
- ['py', ['.py', '.pyi']],
233
- ['rust', ['.rs']],
234
- ['go', ['.go']],
235
- ['java', ['.java']],
236
- ['rb', ['.rb']],
237
- ['css', ['.css', '.scss', '.sass', '.less']],
238
- ['html', ['.html', '.htm']],
239
- ['json', ['.json', '.jsonc']],
240
- ['md', ['.md', '.mdx']],
241
- ['yaml', ['.yaml', '.yml']],
242
- ['sh', ['.sh', '.bash', '.zsh']],
243
- ['c', ['.c', '.h']],
244
- ['cpp', ['.cpp', '.cc', '.cxx', '.hpp', '.hh']],
245
- ]);
246
-
247
- const VALID_GREP_MODES = new Set(['content', 'files_with_matches', 'count']);
248
-
249
- function isGrepHandleable(input) {
250
- if (!input || typeof input !== 'object') return false;
251
- const pattern = input.pattern;
252
- if (!pattern || typeof pattern !== 'string' || !pattern.trim()) return false;
253
- // Optional fields must be the right type when present.
254
- if (input.path != null && typeof input.path !== 'string') return false;
255
- if (input.glob != null && typeof input.glob !== 'string') return false;
256
- if (input.type != null && typeof input.type !== 'string') return false;
257
- if (input.output_mode != null && !VALID_GREP_MODES.has(input.output_mode)) return false;
258
- // Cross-line matching (rg -U) requires full-file regex — not supported natively.
259
- if (input.multiline === true) return false;
260
- return true;
261
- }
262
-
263
- // Read a file for grep: returns null for binary files or on read error.
264
- function tryReadGrep(absPath) {
265
- try {
266
- const buf = fs.readFileSync(absPath);
267
- if (buf.slice(0, 512).includes(0)) return null; // binary file — skip
268
- return (buf.length > MAX_OUTPUT ? buf.slice(0, MAX_OUTPUT) : buf).toString('utf8');
269
- } catch { return null; }
270
- }
271
-
272
- // Walk directory collecting absolute file paths, honouring glob and type filters.
273
- function walkGrepFiles(dir, baseDir, globRegex, globHasDir, typeExts, results) {
274
- if (results.length >= GREP_FILE_CAP) return;
275
- let entries;
276
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
277
- catch { return; }
278
- for (const entry of entries) {
279
- if (results.length >= GREP_FILE_CAP) break;
280
- if (GLOB_SKIP.has(entry.name)) continue;
281
- const abs = path.join(dir, entry.name);
282
- if (entry.isDirectory()) {
283
- walkGrepFiles(abs, baseDir, globRegex, globHasDir, typeExts, results);
284
- } else {
285
- if (typeExts && !typeExts.includes(path.extname(abs).toLowerCase())) continue;
286
- if (globRegex) {
287
- // Glob patterns with path separators match against the relative path;
288
- // plain filename globs (e.g. "*.ts") match against the basename only.
289
- const testStr = globHasDir
290
- ? path.relative(baseDir, abs).replace(/\\/g, '/')
291
- : path.basename(abs);
292
- if (!globRegex.test(testStr)) continue;
293
- }
294
- results.push(abs);
295
- }
296
- }
297
- }
298
-
299
- /**
300
- * Execute a structured Grep tool call locally.
301
- *
302
- * Supports: pattern, path, glob, type, output_mode (files_with_matches | content | count),
303
- * -i (case-insensitive), -C / context / -A / -B (context lines), head_limit, offset.
304
- *
305
- * Does NOT support multiline (cross-line regex) — isGrepHandleable rejects those.
306
- */
307
- function handleGrepTool(input) {
308
- const pattern = (input?.pattern || '').trim();
309
- if (!pattern) return { output: '(no pattern provided)', exitCode: 1, matchCount: 0 };
310
-
311
- const searchRoot = input?.path
312
- ? path.resolve(process.cwd(), input.path)
313
- : process.cwd();
314
-
315
- const outputMode = input?.output_mode || 'files_with_matches';
316
- const caseInsens = input?.['-i'] === true;
317
- const contextN = typeof input?.['-C'] === 'number' ? input['-C'] :
318
- typeof input?.context === 'number' ? input.context : 0;
319
- const linesBefore = typeof input?.['-B'] === 'number' ? input['-B'] : contextN;
320
- const linesAfter = typeof input?.['-A'] === 'number' ? input['-A'] : contextN;
321
- const headLimit = typeof input?.head_limit === 'number' && input.head_limit > 0
322
- ? Math.min(input.head_limit, GREP_MAX_RESULTS)
323
- : GREP_MAX_RESULTS;
324
- const skipLines = typeof input?.offset === 'number' && input.offset > 0 ? input.offset : 0;
325
-
326
- let regex;
327
- try {
328
- regex = new RegExp(pattern, 'g' + (caseInsens ? 'i' : ''));
329
- } catch (e) {
330
- return { output: `Grep: invalid pattern: ${e.message}`, exitCode: 1, matchCount: 0 };
331
- }
332
-
333
- // Build type extension filter.
334
- let typeExts = null;
335
- if (input?.type) {
336
- const t = input.type.toLowerCase();
337
- typeExts = GREP_TYPE_EXTS.get(t) || [t.startsWith('.') ? t : `.${t}`];
338
- }
339
-
340
- // Build glob file filter.
341
- let globRegex = null;
342
- let globHasDir = false;
343
- if (input?.glob) {
344
- try {
345
- globRegex = globToRegex(input.glob);
346
- globHasDir = input.glob.includes('/') || input.glob.includes('**');
347
- } catch { /* ignore invalid glob — no filter applied */ }
348
- }
349
-
350
- // Collect candidate files.
351
- let files = [];
352
- try {
353
- const stat = fs.statSync(searchRoot);
354
- if (stat.isFile()) {
355
- files.push(searchRoot);
356
- } else {
357
- walkGrepFiles(searchRoot, searchRoot, globRegex, globHasDir, typeExts, files);
358
- files.sort();
359
- }
360
- } catch (e) {
361
- return { output: `Grep: cannot access path: ${e.message}`, exitCode: 1, matchCount: 0 };
362
- }
363
-
364
- const outputLines = [];
365
- let totalMatches = 0;
366
- let truncated = false;
367
- const wantMore = () => outputLines.length < skipLines + headLimit;
368
- const relOf = abs => path.relative(searchRoot, abs).replace(/\\/g, '/') || path.basename(abs);
369
-
370
- if (outputMode === 'files_with_matches') {
371
- for (const absFile of files) {
372
- if (!wantMore()) { truncated = true; break; }
373
- const content = tryReadGrep(absFile);
374
- if (!content) continue;
375
- regex.lastIndex = 0;
376
- if (regex.test(content)) { totalMatches++; outputLines.push(relOf(absFile)); }
377
- }
378
-
379
- } else if (outputMode === 'count') {
380
- for (const absFile of files) {
381
- if (!wantMore()) { truncated = true; break; }
382
- const content = tryReadGrep(absFile);
383
- if (!content) continue;
384
- let count = 0;
385
- for (const line of content.split('\n')) { regex.lastIndex = 0; if (regex.test(line)) count++; }
386
- if (count > 0) { totalMatches += count; outputLines.push(`${relOf(absFile)}:${count}`); }
387
- }
388
-
389
- } else { // content
390
- for (const absFile of files) {
391
- if (!wantMore()) { truncated = true; break; }
392
- const content = tryReadGrep(absFile);
393
- if (!content) continue;
394
- const fileLabel = relOf(absFile);
395
- const fileLines = content.split('\n');
396
- const matchSet = new Set();
397
- for (let i = 0; i < fileLines.length; i++) {
398
- regex.lastIndex = 0;
399
- if (regex.test(fileLines[i])) matchSet.add(i);
400
- }
401
- if (!matchSet.size) continue;
402
- totalMatches += matchSet.size;
403
-
404
- // Merge context windows into non-overlapping groups.
405
- const sorted = [...matchSet].sort((a, b) => a - b);
406
- const groups = [];
407
- let gs = -1, ge = -1;
408
- for (const idx of sorted) {
409
- const s = Math.max(0, idx - linesBefore);
410
- const e = Math.min(fileLines.length - 1, idx + linesAfter);
411
- if (gs === -1) { gs = s; ge = e; }
412
- else if (s <= ge + 1) { ge = Math.max(ge, e); }
413
- else { groups.push([gs, ge]); gs = s; ge = e; }
414
- }
415
- if (gs !== -1) groups.push([gs, ge]);
416
-
417
- let firstGroup = true;
418
- for (const [gStart, gEnd] of groups) {
419
- if (!wantMore()) { truncated = true; break; }
420
- if (!firstGroup) outputLines.push('--');
421
- firstGroup = false;
422
- for (let i = gStart; i <= gEnd && wantMore(); i++) {
423
- const sep = matchSet.has(i) ? ':' : '-';
424
- outputLines.push(`${fileLabel}${sep}${i + 1}${sep}${fileLines[i]}`);
425
- }
426
- }
427
- }
428
- }
429
-
430
- const sliced = outputLines.slice(skipLines, skipLines + headLimit);
431
- const text = sliced.join('\n') || '(no matches)';
432
- const suffix = truncated ? '\n(truncated — use head_limit/offset to paginate)' : '';
433
- return { output: text + suffix, exitCode: 0, matchCount: totalMatches };
434
- }
51
+ // Moved to src/executor/native-handlers/grep.js as Stage-2 Step 4.
52
+ const {
53
+ GREP_MAX_RESULTS,
54
+ GREP_FILE_CAP,
55
+ GREP_TYPE_EXTS,
56
+ VALID_GREP_MODES,
57
+ isGrepHandleable,
58
+ tryReadGrep,
59
+ walkGrepFiles,
60
+ handleGrepTool,
61
+ } = require('./executor/native-handlers/grep');
435
62
 
436
63
  // ── Todo tool support ──────────────────────────────────────────────────────────
437
-
438
- /**
439
- * Returns true when this TodoWrite/TodoRead call can be served natively.
440
- * TodoRead: always handleable — no required inputs.
441
- * TodoWrite: requires input.todos to be an array.
442
- */
443
- function isTodoHandleable(input, toolName) {
444
- if (toolName === 'TodoRead') return true;
445
- if (toolName === 'TodoWrite') {
446
- if (!input || typeof input !== 'object') return false;
447
- return Array.isArray(input.todos);
448
- }
449
- return false;
450
- }
451
-
452
- /**
453
- * Handle a TodoWrite call: replace the session todo list with input.todos.
454
- * Returns { output: '', exitCode: 0, taskCount: N } on success.
455
- * Claude Code expects an empty-string response from write tools.
456
- */
457
- function handleTodoWriteTool(input, todoStore) {
458
- const todos = input?.todos;
459
- if (!Array.isArray(todos)) {
460
- return { output: 'TodoWrite: todos must be an array', exitCode: 1, taskCount: 0 };
461
- }
462
- todoStore.splice(0, todoStore.length, ...todos);
463
- return { output: '', exitCode: 0, taskCount: todos.length };
464
- }
465
-
466
- /**
467
- * Handle a TodoRead call: return the session todo list as a JSON string.
468
- * Returns { output: string, exitCode: 0, taskCount: N }.
469
- */
470
- function handleTodoReadTool(todoStore) {
471
- const output = JSON.stringify(todoStore, null, 2);
472
- return { output, exitCode: 0, taskCount: todoStore.length };
473
- }
64
+ // Moved to src/executor/native-handlers/todo.js as Stage-2 of the executor
65
+ // migration (see docs/ADAPTER-STAGE-2-MIGRATION.md). Re-exported here so
66
+ // existing consumers (interceptor, tests) keep working unchanged.
67
+ const {
68
+ isTodoHandleable,
69
+ handleTodoWriteTool,
70
+ handleTodoReadTool,
71
+ } = require('./executor/native-handlers/todo');
474
72
 
475
73
  // ── MCP execution wrapper ──────────────────────────────────────────────────────
476
74
 
package/src/selftest.js CHANGED
@@ -171,7 +171,7 @@ async function runSelfTest(opts = {}) {
171
171
 
172
172
  } finally {
173
173
  loader._resetCache();
174
- try { fs.rmSync(fixtures.root, { recursive: true, force: true }); } catch {}
174
+ try { fs.rmSync(fixtures.root, { recursive: true, force: true }); } catch { /* ignore */ }
175
175
  }
176
176
 
177
177
  const failed = scenarios.filter(s => s.status === 'fail').length;
package/src/session.js CHANGED
@@ -30,7 +30,7 @@ function incrementSessionMcpCount(n = 1) {
30
30
  if (!s || !s.run_id) return; // no active proxy session — don't create a stale entry
31
31
  s.tools_mcp_count = (s.tools_mcp_count || 0) + n;
32
32
  fs.writeFileSync(SESSION_FILE, JSON.stringify(s));
33
- } catch {}
33
+ } catch { /* ignore */ }
34
34
  }
35
35
 
36
36
  module.exports = { SESSION_FILE, incrementSessionMcpCount };