@occasiolabs/occasio 0.8.4 → 0.8.6

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 (47) hide show
  1. package/README.md +4 -3
  2. package/docs/ADAPTER-STAGE-2-MIGRATION.md +59 -0
  3. package/docs/STAGE-2-STEP-5-SHELL-PLAN.md +107 -0
  4. package/docs/THREAT-MODEL.md +195 -0
  5. package/docs/edr-calibration.md +29 -0
  6. package/package.json +8 -3
  7. package/src/adapters/claude-code.js +1 -2
  8. package/src/adapters/computer-use.js +1 -1
  9. package/src/anomaly/cli.js +4 -1
  10. package/src/anomaly/detectors/deny-rate.js +2 -1
  11. package/src/anomaly/detectors/file-read-volume.js +2 -1
  12. package/src/anomaly/index.js +5 -0
  13. package/src/boundary.js +1 -1
  14. package/src/classifier.js +1 -1
  15. package/src/cli/clear.js +4 -4
  16. package/src/cli/conversation.js +121 -0
  17. package/src/cli/help.js +62 -38
  18. package/src/cli/recap.js +367 -0
  19. package/src/cli/status.js +1 -1
  20. package/src/dashboard.js +2 -3
  21. package/src/demo/audit-demo.js +330 -0
  22. package/src/distiller.js +1 -1
  23. package/src/executor/dispatcher.js +2 -2
  24. package/src/executor/native-handlers/glob.js +173 -0
  25. package/src/executor/native-handlers/grep.js +258 -0
  26. package/src/executor/native-handlers/read.js +99 -0
  27. package/src/executor/native-handlers/todo.js +56 -0
  28. package/src/harness.js +8 -10
  29. package/src/index.js +118 -30
  30. package/src/inspect.js +1 -1
  31. package/src/interceptor.js +9 -29
  32. package/src/ledger.js +2 -3
  33. package/src/mcp-experiment.js +4 -4
  34. package/src/mcp-server.js +3 -3
  35. package/src/policy/doctor.js +2 -2
  36. package/src/policy/engine.js +0 -1
  37. package/src/policy/init.js +1 -1
  38. package/src/policy/loader.js +3 -3
  39. package/src/policy/show.js +1 -2
  40. package/src/preflight/cli.js +0 -1
  41. package/src/preflight/miner.js +3 -6
  42. package/src/redteam.js +1 -2
  43. package/src/replay.js +1 -1
  44. package/src/report/index.js +0 -4
  45. package/src/runtime.js +42 -444
  46. package/src/selftest.js +1 -1
  47. package/src/session.js +1 -1
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 };