deepflow 0.1.107 → 0.1.109

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.
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deepflow worktree-deps
4
+ * Symlinks node_modules from the main repo into a worktree so that
5
+ * TypeScript / LSP / builds resolve dependencies without a full install.
6
+ *
7
+ * Usage: node bin/worktree-deps.js --source /path/to/repo --worktree /path/to/worktree
8
+ *
9
+ * Walks the source repo looking for node_modules directories (max depth 2)
10
+ * and creates corresponding symlinks in the worktree.
11
+ *
12
+ * Exit codes: 0=OK, 1=ERROR
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Args
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function parseArgs() {
25
+ const args = process.argv.slice(2);
26
+ const opts = {};
27
+ for (let i = 0; i < args.length; i++) {
28
+ if (args[i] === '--source' && args[i + 1]) opts.source = args[++i];
29
+ else if (args[i] === '--worktree' && args[i + 1]) opts.worktree = args[++i];
30
+ }
31
+ if (!opts.source || !opts.worktree) {
32
+ console.error('Usage: node bin/worktree-deps.js --source <repo> --worktree <worktree>');
33
+ process.exit(1);
34
+ }
35
+ return opts;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Find node_modules directories (depth 0 and 1 level of nesting)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function findNodeModules(root) {
43
+ const results = [];
44
+
45
+ // Root node_modules
46
+ const rootNM = path.join(root, 'node_modules');
47
+ if (fs.existsSync(rootNM)) {
48
+ results.push('node_modules');
49
+ }
50
+
51
+ // Scan common monorepo directory patterns for nested node_modules
52
+ const monorepoPatterns = ['packages', 'apps', 'libs', 'services', 'modules', 'plugins'];
53
+
54
+ for (const dir of monorepoPatterns) {
55
+ const dirPath = path.join(root, dir);
56
+ if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) continue;
57
+
58
+ let entries;
59
+ try {
60
+ entries = fs.readdirSync(dirPath);
61
+ } catch (_) {
62
+ continue;
63
+ }
64
+
65
+ for (const entry of entries) {
66
+ const entryPath = path.join(dirPath, entry);
67
+ if (!fs.statSync(entryPath).isDirectory()) continue;
68
+
69
+ const nm = path.join(entryPath, 'node_modules');
70
+ if (fs.existsSync(nm)) {
71
+ results.push(path.join(dir, entry, 'node_modules'));
72
+ }
73
+ }
74
+ }
75
+
76
+ return results;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Create symlinks
81
+ // ---------------------------------------------------------------------------
82
+
83
+ function symlinkDeps(source, worktree) {
84
+ const nodeModulesPaths = findNodeModules(source);
85
+
86
+ if (nodeModulesPaths.length === 0) {
87
+ console.log('{"linked":0,"message":"no node_modules found in source"}');
88
+ return;
89
+ }
90
+
91
+ let linked = 0;
92
+ const errors = [];
93
+
94
+ for (const relPath of nodeModulesPaths) {
95
+ const srcAbs = path.join(source, relPath);
96
+ const dstAbs = path.join(worktree, relPath);
97
+
98
+ // Skip if already exists (symlink or directory)
99
+ if (fs.existsSync(dstAbs)) {
100
+ continue;
101
+ }
102
+
103
+ // Ensure parent directory exists in worktree
104
+ const parent = path.dirname(dstAbs);
105
+ if (!fs.existsSync(parent)) {
106
+ fs.mkdirSync(parent, { recursive: true });
107
+ }
108
+
109
+ try {
110
+ fs.symlinkSync(srcAbs, dstAbs, 'dir');
111
+ linked++;
112
+ } catch (err) {
113
+ errors.push({ path: relPath, error: err.message });
114
+ }
115
+ }
116
+
117
+ const result = { linked, total: nodeModulesPaths.length };
118
+ if (errors.length > 0) result.errors = errors;
119
+ console.log(JSON.stringify(result));
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Main
124
+ // ---------------------------------------------------------------------------
125
+
126
+ const opts = parseArgs();
127
+ symlinkDeps(opts.source, opts.worktree);
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deepflow AC coverage checker
4
+ * Standalone script called by the orchestrator after ratchet checks.
5
+ *
6
+ * Usage:
7
+ * node ac-coverage.js --spec <path> --output <text> --status <pass|fail|revert>
8
+ * node ac-coverage.js --spec <path> --output-file <path> --status <pass|fail|revert>
9
+ *
10
+ * Exit codes:
11
+ * 0 — all ACs covered, no ACs in spec, or input status was fail/revert
12
+ * 2 — SALVAGEABLE: missed ACs detected and input status was pass
13
+ * 1 — script error only
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+
20
+ // ── Argument parsing ────────────────────────────────────────────────────────
21
+
22
+ function parseArgs(argv) {
23
+ const args = {};
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ if (a === '--spec' && argv[i + 1]) { args.spec = argv[++i]; }
27
+ else if (a === '--output' && argv[i + 1]) { args.output = argv[++i]; }
28
+ else if (a === '--output-file' && argv[i + 1]) { args.outputFile = argv[++i]; }
29
+ else if (a === '--status' && argv[i + 1]) { args.status = argv[++i]; }
30
+ }
31
+ return args;
32
+ }
33
+
34
+ // ── Section extraction ──────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Extract content of `## Acceptance Criteria` section (up to next ## or EOF).
38
+ * Returns null if the section is absent.
39
+ */
40
+ function extractACSection(content) {
41
+ const lines = content.split('\n');
42
+ let capturing = false;
43
+ const captured = [];
44
+
45
+ for (const line of lines) {
46
+ const headerMatch = line.match(/^##\s+(.+)/);
47
+ if (headerMatch) {
48
+ if (capturing) break; // next section — stop
49
+ if (/^acceptance criteria$/i.test(headerMatch[1].trim())) {
50
+ capturing = true;
51
+ }
52
+ continue;
53
+ }
54
+ if (capturing) {
55
+ captured.push(line);
56
+ }
57
+ }
58
+
59
+ return capturing ? captured.join('\n') : null;
60
+ }
61
+
62
+ // ── AC extraction from spec ─────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Extract canonical AC-N identifiers from the spec's Acceptance Criteria section.
66
+ * Returns null if no section found, empty array if section exists but has no AC-\d+ patterns.
67
+ */
68
+ function extractSpecACs(specContent) {
69
+ const section = extractACSection(specContent);
70
+ if (section === null) return null;
71
+
72
+ const ids = new Set();
73
+ const pattern = /\bAC-(\d+)\b/g;
74
+ let m;
75
+ while ((m = pattern.exec(section)) !== null) {
76
+ ids.add(`AC-${m[1]}`);
77
+ }
78
+ return [...ids].sort((a, b) => {
79
+ const na = parseInt(a.replace('AC-', ''), 10);
80
+ const nb = parseInt(b.replace('AC-', ''), 10);
81
+ return na - nb;
82
+ });
83
+ }
84
+
85
+ // ── AC parsing from agent output ────────────────────────────────────────────
86
+
87
+ /**
88
+ * Parse AC_COVERAGE block from agent output.
89
+ * Returns a Map of AC-N → { status: 'done'|'skip', reason: string|null }
90
+ */
91
+ function parseACCoverage(outputText) {
92
+ const map = new Map();
93
+
94
+ const blockMatch = outputText.match(/AC_COVERAGE:([\s\S]*?)AC_COVERAGE_END/);
95
+ if (!blockMatch) return map;
96
+
97
+ const block = blockMatch[1];
98
+ const linePattern = /^(AC-\d+):(done|skip)(?::(.+))?$/gm;
99
+ let m;
100
+ while ((m = linePattern.exec(block)) !== null) {
101
+ map.set(m[1], {
102
+ status: m[2],
103
+ reason: m[3] ? m[3].trim() : null,
104
+ });
105
+ }
106
+ return map;
107
+ }
108
+
109
+ // ── Main logic ──────────────────────────────────────────────────────────────
110
+
111
+ function run(args) {
112
+ if (!args.spec) {
113
+ console.error('[ac-coverage] Error: --spec is required');
114
+ process.exit(1);
115
+ }
116
+ if (!args.status) {
117
+ console.error('[ac-coverage] Error: --status is required');
118
+ process.exit(1);
119
+ }
120
+
121
+ // Read spec
122
+ let specContent;
123
+ try {
124
+ specContent = fs.readFileSync(args.spec, 'utf8');
125
+ } catch (e) {
126
+ console.error(`[ac-coverage] Error reading spec: ${e.message}`);
127
+ process.exit(1);
128
+ }
129
+
130
+ // Extract canonical ACs (AC-2, AC-7, AC-8)
131
+ const specACs = extractSpecACs(specContent);
132
+
133
+ // AC-7: no Acceptance Criteria section → silent exit
134
+ if (specACs === null) {
135
+ process.exit(0);
136
+ }
137
+
138
+ // AC-8: section exists but no AC-\d+ patterns → silent exit
139
+ if (specACs.length === 0) {
140
+ process.exit(0);
141
+ }
142
+
143
+ // Read agent output
144
+ let outputText = '';
145
+ if (args.outputFile) {
146
+ try {
147
+ outputText = fs.readFileSync(args.outputFile, 'utf8');
148
+ } catch (e) {
149
+ console.error(`[ac-coverage] Error reading output file: ${e.message}`);
150
+ process.exit(1);
151
+ }
152
+ } else if (args.output !== undefined) {
153
+ outputText = args.output;
154
+ }
155
+
156
+ // AC-3: parse AC_COVERAGE block from agent output
157
+ const reported = parseACCoverage(outputText);
158
+
159
+ // AC-3: diff — identify missed ACs (not reported as done)
160
+ const missed = [];
161
+ const skipped = [];
162
+ let coveredCount = 0;
163
+
164
+ for (const id of specACs) {
165
+ const entry = reported.get(id);
166
+ if (entry && entry.status === 'done') {
167
+ coveredCount++;
168
+ } else if (entry && entry.status === 'skip') {
169
+ skipped.push({ id, reason: entry.reason });
170
+ } else {
171
+ missed.push(id);
172
+ }
173
+ }
174
+
175
+ const totalACs = specACs.length;
176
+ const reportedDoneCount = coveredCount;
177
+
178
+ // AC-6: summary line
179
+ const summaryParts = [];
180
+ if (missed.length > 0) {
181
+ summaryParts.push(`missed: ${missed.join(', ')}`);
182
+ }
183
+ if (skipped.length > 0) {
184
+ const skipDesc = skipped.map(s => s.reason ? `${s.id} (${s.reason})` : s.id).join(', ');
185
+ summaryParts.push(`skipped: ${skipDesc}`);
186
+ }
187
+
188
+ const summaryDetail = summaryParts.length > 0 ? ` — ${summaryParts.join('; ')}` : '';
189
+ console.log(`[ac-coverage] ${reportedDoneCount}/${totalACs} ACs covered${summaryDetail}`);
190
+
191
+ // AC-4 / AC-5: status override
192
+ const inputStatus = args.status;
193
+ const hasMissed = missed.length > 0;
194
+
195
+ if (hasMissed && inputStatus === 'pass') {
196
+ // AC-4: override to SALVAGEABLE
197
+ console.log('OVERRIDE:SALVAGEABLE');
198
+ process.exit(2);
199
+ } else {
200
+ // AC-5: all done, or status was already fail/revert — no override
201
+ console.log('OVERRIDE:none');
202
+ process.exit(0);
203
+ }
204
+ }
205
+
206
+ // ── Entry point ─────────────────────────────────────────────────────────────
207
+
208
+ if (require.main === module) {
209
+ const args = parseArgs(process.argv.slice(2));
210
+ run(args);
211
+ }
212
+
213
+ module.exports = { extractSpecACs, parseACCoverage, extractACSection };
@@ -10,6 +10,10 @@
10
10
  * 1. {cwd}/templates/explore-protocol.md (repo checkout)
11
11
  * 2. ~/.claude/templates/explore-protocol.md (installed copy)
12
12
  *
13
+ * Phase 1: globs source files and extracts symbols via inline regex — no subprocess,
14
+ * no model calls. Results are filtered to strip noise paths and injected as
15
+ * structured context for Phase 2.
16
+ *
13
17
  * Exits silently (code 0) on all errors — never blocks tool execution (REQ-8).
14
18
  */
15
19
 
@@ -20,6 +24,135 @@ const path = require('path');
20
24
  const os = require('os');
21
25
  const { readStdinIfMain } = require('./lib/hook-stdin');
22
26
 
27
+ /**
28
+ * Regex patterns per language extension for symbol extraction.
29
+ * Spike-validated against JS/TS/Python/Go/Rust codebases.
30
+ */
31
+ const PATTERNS = {
32
+ '.js': /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|interface|type|enum)\s+(\w+)/gm,
33
+ '.ts': /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|interface|type|enum)\s+(\w+)/gm,
34
+ '.py': /^(?:async\s+)?(?:def|class)\s+(\w+)/gm,
35
+ '.go': /^(?:func(?:\s*\([^)]*\))?\s+(\w+)|type\s+(\w+)\s+(?:struct|interface|func))/gm,
36
+ '.rs': /^(?:pub\s+)?(?:async\s+)?(?:fn|struct|enum|trait|impl|type|mod)\s+(\w+)/gm,
37
+ };
38
+
39
+ /**
40
+ * Generic fallback pattern for unknown extensions.
41
+ * Covers the most common declaration keywords across languages.
42
+ */
43
+ const PATTERN_GENERIC = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|def|fn|struct|enum|trait|type|interface|mod)\s+(\w+)/gm;
44
+
45
+ /**
46
+ * Map extensions that share a pattern with a canonical key.
47
+ */
48
+ const EXTENSION_MAP = {
49
+ '.jsx': '.js',
50
+ '.tsx': '.ts',
51
+ '.mjs': '.js',
52
+ '.cjs': '.js',
53
+ };
54
+
55
+ /**
56
+ * Path filter — returns true if the filepath should be excluded.
57
+ * Strips node_modules, .claude/worktrees, dist, .git, vendor,
58
+ * __pycache__, .next, and build directories.
59
+ */
60
+ function isNoisePath(filepath) {
61
+ return /(node_modules|\.claude\/worktrees|\/dist\/|\.git\/|\/vendor\/|__pycache__|\/\.next\/|\/build\/)/.test(filepath);
62
+ }
63
+
64
+ /**
65
+ * Recursively walk a directory, yielding absolute file paths.
66
+ * Silently skips unreadable directories.
67
+ */
68
+ function* walkDir(dir) {
69
+ let entries;
70
+ try {
71
+ entries = fs.readdirSync(dir, { withFileTypes: true });
72
+ } catch (_) {
73
+ return;
74
+ }
75
+ for (const entry of entries) {
76
+ const full = path.join(dir, entry.name);
77
+ if (entry.isDirectory()) {
78
+ if (!isNoisePath(full + '/')) yield* walkDir(full);
79
+ } else if (entry.isFile()) {
80
+ yield full;
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Run Phase 1: glob source files and extract symbols via inline regex.
87
+ * Returns { symbols: [{name, kind, line, filepath}], hit: boolean }.
88
+ * No subprocess, no model calls — pure Node.js (AC-1).
89
+ *
90
+ * @param {string} query - The explore prompt used for substring filtering (AC-15).
91
+ * @param {string} cwd - Project root to walk.
92
+ */
93
+ function runPhase1(query, cwd) {
94
+ const queryLower = query.toLowerCase();
95
+ const symbols = [];
96
+
97
+ try {
98
+ for (const filepath of walkDir(cwd)) {
99
+ if (isNoisePath(filepath)) continue;
100
+
101
+ const ext = path.extname(filepath).toLowerCase();
102
+ const canonExt = EXTENSION_MAP[ext] || ext;
103
+ const pattern = PATTERNS[canonExt] || PATTERN_GENERIC;
104
+
105
+ // AC-15: filter by substring match on file path
106
+ const filepathLower = filepath.toLowerCase();
107
+ const pathMatches = filepathLower.includes(queryLower);
108
+
109
+ let content;
110
+ try {
111
+ content = fs.readFileSync(filepath, 'utf8');
112
+ } catch (_) {
113
+ continue;
114
+ }
115
+
116
+ const lines = content.split('\n');
117
+ // Reset lastIndex before each use of the shared regex
118
+ const re = new RegExp(pattern.source, pattern.flags);
119
+ let m;
120
+ while ((m = re.exec(content)) !== null) {
121
+ // Capture group 1 is always the symbol name (Go uses group 2 for type decls)
122
+ const name = m[1] || m[2];
123
+ if (!name) continue;
124
+
125
+ // AC-15: filter by substring match on symbol name or file path
126
+ const nameLower = name.toLowerCase();
127
+ if (!nameLower.includes(queryLower) && !pathMatches) continue;
128
+
129
+ // Compute 1-based line number from the match offset
130
+ const before = content.slice(0, m.index);
131
+ const line = before.split('\n').length;
132
+
133
+ // Derive kind from the matched keyword
134
+ const matchedText = m[0];
135
+ let kind = 'symbol';
136
+ if (/\bclass\b/.test(matchedText)) kind = 'class';
137
+ else if (/\bfunction\b|\bfn\b|\bdef\b|\bfunc\b/.test(matchedText)) kind = 'function';
138
+ else if (/\binterface\b/.test(matchedText)) kind = 'interface';
139
+ else if (/\btype\b/.test(matchedText)) kind = 'type';
140
+ else if (/\benum\b/.test(matchedText)) kind = 'enum';
141
+ else if (/\bstruct\b/.test(matchedText)) kind = 'struct';
142
+ else if (/\btrait\b/.test(matchedText)) kind = 'trait';
143
+ else if (/\bmod\b/.test(matchedText)) kind = 'module';
144
+ else if (/\bimpl\b/.test(matchedText)) kind = 'impl';
145
+
146
+ symbols.push({ name, kind, line, filepath });
147
+ }
148
+ }
149
+ } catch (_) {
150
+ // Fail-open: return whatever was gathered so far
151
+ }
152
+
153
+ return { symbols, hit: symbols.length > 0 };
154
+ }
155
+
23
156
  /**
24
157
  * Locate the explore-protocol.md template.
25
158
  * Prefers project-local copy, falls back to installed global copy.
@@ -36,39 +169,105 @@ function findProtocol(cwd) {
36
169
  }
37
170
 
38
171
  readStdinIfMain(module, (payload) => {
39
- const { tool_name, tool_input, cwd } = payload;
172
+ try {
173
+ const { tool_name, tool_input, cwd } = payload;
40
174
 
41
- // Only intercept Agent calls with subagent_type "Explore"
42
- if (tool_name !== 'Agent') {
43
- return;
44
- }
45
- const subagentType = (tool_input.subagent_type || '').toLowerCase();
46
- if (subagentType !== 'explore') {
47
- return;
48
- }
175
+ // Only intercept Agent calls with subagent_type "Explore"
176
+ if (tool_name !== 'Agent') {
177
+ return;
178
+ }
179
+ const subagentType = (tool_input.subagent_type || '').toLowerCase();
180
+ if (subagentType !== 'explore') {
181
+ return;
182
+ }
49
183
 
50
- const protocolPath = findProtocol(cwd || process.cwd());
51
- if (!protocolPath) {
52
- // No template found allow without modification
53
- return;
54
- }
184
+ const effectiveCwd = cwd || process.cwd();
185
+
186
+ // --- Deduplication guard (AC-8) ---
187
+ // If the prompt already carries injected markers, skip re-injection entirely.
188
+ const existingPrompt = tool_input.prompt || '';
189
+ if (
190
+ existingPrompt.includes('Search Protocol (auto-injected') ||
191
+ existingPrompt.includes('LSP Phase')
192
+ ) {
193
+ return;
194
+ }
195
+
196
+ const protocolPath = findProtocol(effectiveCwd);
197
+ const originalPrompt = existingPrompt;
55
198
 
56
- const protocol = fs.readFileSync(protocolPath, 'utf8').trim();
57
- const originalPrompt = tool_input.prompt || '';
199
+ // --- Phase 1: inline regex symbol extraction (AC-1, AC-7, AC-9) ---
200
+ const { symbols, hit: phase1Hit } = runPhase1(originalPrompt, effectiveCwd);
58
201
 
59
- // Append protocol as a system-level suffix the agent must follow
60
- const updatedPrompt = `${originalPrompt}\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\n${protocol}`;
202
+ let updatedPrompt;
61
203
 
62
- const result = {
63
- hookSpecificOutput: {
64
- hookEventName: 'PreToolUse',
65
- permissionDecision: 'allow',
66
- updatedInput: {
67
- ...tool_input,
68
- prompt: updatedPrompt,
204
+ if (phase1Hit) {
205
+ // Phase 1 succeeded — inject symbol locations + protocol (requires template)
206
+ if (!protocolPath) {
207
+ // No template found and Phase 1 succeeded — allow without modification
208
+ return;
209
+ }
210
+ const protocol = fs.readFileSync(protocolPath, 'utf8').trim();
211
+
212
+ // AC-3: Format each symbol as `filepath:line -- name (kind)`
213
+ const locationLines = symbols
214
+ .map((s) => `${s.filepath}:${s.line} -- ${s.name} (${s.kind})`)
215
+ .join('\n');
216
+ const phase1Block =
217
+ '\n\n---\n## [LSP Phase -- locations found]\n\n' +
218
+ locationLines +
219
+ '\n\nRead ONLY these ranges. Do not use Grep, Glob, or Bash.';
220
+
221
+ updatedPrompt =
222
+ `${originalPrompt}${phase1Block}\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\n${protocol}`;
223
+ } else {
224
+ // Phase 1 empty — fall back to static template injection (AC-5)
225
+ if (!protocolPath) {
226
+ // AC-6: no template and regex found nothing — exit silently with no modification
227
+ return;
228
+ }
229
+ const protocol = fs.readFileSync(protocolPath, 'utf8').trim();
230
+
231
+ // Inject static template only, with auto-injected marker so dedup guard fires next time
232
+ updatedPrompt =
233
+ `${originalPrompt}\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\n${protocol}`;
234
+ }
235
+
236
+ const result = {
237
+ hookSpecificOutput: {
238
+ hookEventName: 'PreToolUse',
239
+ permissionDecision: 'allow',
240
+ updatedInput: {
241
+ ...tool_input,
242
+ prompt: updatedPrompt,
243
+ },
69
244
  },
70
- },
71
- };
245
+ };
246
+
247
+ // --- Metrics logging (fail-open) ---
248
+ // Log phase 1 hit rate to explore-metrics.jsonl.
249
+ // Wraps in try/catch so metrics failures never block hook execution.
250
+ try {
251
+ const metricsDir = path.join(effectiveCwd, '.deepflow');
252
+ const metricsPath = path.join(metricsDir, 'explore-metrics.jsonl');
253
+ if (!fs.existsSync(metricsDir)) {
254
+ fs.mkdirSync(metricsDir, { recursive: true });
255
+ }
256
+ const metricsEntry = {
257
+ timestamp: new Date().toISOString(),
258
+ query: originalPrompt,
259
+ phase1_hit: phase1Hit,
260
+ // tool_calls intentionally omitted: PreToolUse hooks fire before tool execution,
261
+ // so actual tool call counts are not observable here without a PostToolUse hook.
262
+ };
263
+ fs.appendFileSync(metricsPath, JSON.stringify(metricsEntry) + '\n', 'utf8');
264
+ } catch (_) {
265
+ // Metrics logging failure is silent — never blocks execution (REQ-8).
266
+ }
72
267
 
73
- process.stdout.write(JSON.stringify(result));
268
+ process.stdout.write(JSON.stringify(result));
269
+ } catch (_) {
270
+ // AC-10: catch ALL errors — malformed JSON, missing tool_input, filesystem errors, etc.
271
+ // Always exit 0; never block tool execution (REQ-8).
272
+ }
74
273
  });