@agile-vibe-coding/avc 0.3.5 → 0.4.0

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.
@@ -14,6 +14,8 @@ import path from 'path';
14
14
  import { execSync, execFileSync } from 'child_process';
15
15
  import { LLMProvider } from './llm-provider.js';
16
16
  import { loadAgent } from './agent-loader.js';
17
+ import { buildToolDefinitions, executeTool, summarizeToolCall, FileTracker } from './worktree-tools.js';
18
+ import { TokenTracker } from './token-tracker.js';
17
19
  import { fileURLToPath } from 'url';
18
20
 
19
21
  const __filename = fileURLToPath(import.meta.url);
@@ -66,6 +68,10 @@ export class WorktreeRunner {
66
68
  this._maxIterations = ceremony?.maxValidationIterations ?? 3;
67
69
  this._acceptanceThreshold = ceremony?.acceptanceThreshold ?? 80;
68
70
  this._stageProviders = {};
71
+
72
+ // Token tracking for cost dashboard
73
+ this.tokenTracker = new TokenTracker(this.avcPath);
74
+ this.tokenTracker.init();
69
75
  }
70
76
 
71
77
  /**
@@ -125,28 +131,24 @@ export class WorktreeRunner {
125
131
  if (cancelledCheck?.()) throw new Error('CANCELLED');
126
132
  const context = this.readDocChain();
127
133
 
128
- // 3. Implement code via LLM
129
- progressCallback?.('Implementing code with AI agent...');
134
+ // 3. Run agentic tool-calling loop
135
+ progressCallback?.('Starting agentic implementation loop...');
130
136
  if (cancelledCheck?.()) throw new Error('CANCELLED');
131
- await this.implementCode(context, progressCallback);
137
+ const fileTracker = await this.executeAgentLoop(context, progressCallback, cancelledCheck);
132
138
 
133
- // 4. Run tests
134
- progressCallback?.('Running tests...');
135
- if (cancelledCheck?.()) throw new Error('CANCELLED');
136
- const testResult = this.runTests();
139
+ // 4. Write file registry and test results
140
+ this._updateFileRegistry(fileTracker);
141
+ this._updateTestResults(fileTracker);
137
142
 
138
- if (!testResult.passed) {
139
- progressCallback?.(`Tests failed: ${testResult.summary}`);
140
- this.cleanup();
141
- return { success: false, error: `Tests failed: ${testResult.summary}` };
142
- }
143
+ // 5. Finalize token tracking
144
+ try { this.tokenTracker.finalizeRun('run'); } catch {}
143
145
 
144
- // 5. Commit in worktree (do NOT merge — leave for human review)
146
+ // 6. Commit in worktree (do NOT merge — leave for human review)
145
147
  progressCallback?.('Committing changes in worktree...');
146
148
  if (cancelledCheck?.()) throw new Error('CANCELLED');
147
149
  this.commitInWorktree();
148
150
 
149
- // 6. Leave worktree in place for review — do NOT cleanup or merge
151
+ // 5. Leave worktree in place for review
150
152
  progressCallback?.(`Code ready for review in worktree: ${this.worktreePath}`);
151
153
  progressCallback?.(`Branch: ${this.branchName}`);
152
154
 
@@ -157,6 +159,8 @@ export class WorktreeRunner {
157
159
  branchName: this.branchName,
158
160
  };
159
161
  } catch (err) {
162
+ // Finalize token tracking even on failure (tokens were spent)
163
+ try { this.tokenTracker.finalizeRun('run'); } catch {}
160
164
  // Always cleanup on failure
161
165
  try { this.cleanup(); } catch {}
162
166
 
@@ -167,6 +171,212 @@ export class WorktreeRunner {
167
171
  }
168
172
  }
169
173
 
174
+ /**
175
+ * Agentic tool-calling loop — the model calls tools iteratively until it decides it's done.
176
+ * Follows the Claude Code pattern: while(tool_calls) → execute → feed back → repeat.
177
+ */
178
+ async executeAgentLoop(context, progressCallback, cancelledCheck = null) {
179
+ const provider = await this._getStageProvider('code-generation');
180
+ const agentInstructions = loadAgent('code-implementer.md');
181
+ const tools = buildToolDefinitions();
182
+ const acceptance = this._readAcceptanceCriteria();
183
+ const tracker = new FileTracker();
184
+
185
+ const userPrompt = [
186
+ `## Hierarchy Prefix\n${this.prefix}`,
187
+ `## Task ID\n${this.taskId}`,
188
+ `## Acceptance Criteria\n${acceptance.map((ac, i) => `${i + 1}. ${ac}`).join('\n')}`,
189
+ `## Documentation Chain\n\n${context}`,
190
+ ].join('\n\n');
191
+
192
+ const messages = [
193
+ { role: 'system', content: agentInstructions },
194
+ { role: 'user', content: userPrompt },
195
+ ];
196
+
197
+ const MAX_ITERATIONS = 50;
198
+ let iteration = 0;
199
+
200
+ while (iteration < MAX_ITERATIONS) {
201
+ iteration++;
202
+ if (cancelledCheck?.()) throw new Error('CANCELLED');
203
+
204
+ this.debug(`Agent loop iteration ${iteration}/${MAX_ITERATIONS}`);
205
+ const response = await provider.chat(messages, { tools });
206
+
207
+ // If the model returned text, show it
208
+ if (response.content) {
209
+ progressCallback?.(`Agent: ${response.content.slice(0, 200)}`);
210
+ }
211
+
212
+ // If no tool calls, the agent is done
213
+ if (!response.toolCalls || response.toolCalls.length === 0) {
214
+ this.debug(`Agent loop complete after ${iteration} iterations (model stopped)`);
215
+ progressCallback?.(`Implementation complete after ${iteration} iterations.`);
216
+ break;
217
+ }
218
+
219
+ // Add assistant message with tool calls to history
220
+ messages.push({
221
+ role: 'assistant',
222
+ content: response.content || '',
223
+ toolCalls: response.toolCalls,
224
+ });
225
+
226
+ // Execute each tool call and add results to history
227
+ for (const call of response.toolCalls) {
228
+ if (cancelledCheck?.()) throw new Error('CANCELLED');
229
+
230
+ const summary = summarizeToolCall(call.name, call.arguments);
231
+ progressCallback?.(`[${call.name}] ${summary}`);
232
+
233
+ const result = executeTool(this.worktreePath, call.name, call.arguments, 120_000, tracker);
234
+
235
+ messages.push({
236
+ role: 'tool',
237
+ toolCallId: call.id,
238
+ toolName: call.name, // needed for Gemini
239
+ content: typeof result === 'string' ? result : JSON.stringify(result),
240
+ });
241
+
242
+ this.debug(`Tool: ${call.name}(${summary}) → ${String(result).slice(0, 200)}`);
243
+ }
244
+ }
245
+
246
+ if (iteration >= MAX_ITERATIONS) {
247
+ progressCallback?.(`Warning: agent loop hit max iterations (${MAX_ITERATIONS}).`);
248
+ }
249
+
250
+ return tracker;
251
+ }
252
+
253
+ /**
254
+ * Update the centralized file registry and the task's work.json with file tracking data.
255
+ * Registry: .avc/project/file-registry.json — bidirectional mapping of files ↔ tasks.
256
+ * work.json: adds a `files` object with created/edited/deleted arrays.
257
+ */
258
+ _updateFileRegistry(tracker) {
259
+ if (!tracker || tracker.operations.length === 0) return;
260
+
261
+ const byAction = tracker.getByAction();
262
+ const allFiles = [...byAction.created, ...byAction.edited];
263
+
264
+ // 1. Update task work.json with files summary
265
+ try {
266
+ const taskWorkJsonPath = this._findWorkJsonPath(this.taskId);
267
+ if (taskWorkJsonPath && fs.existsSync(taskWorkJsonPath)) {
268
+ const workJson = JSON.parse(fs.readFileSync(taskWorkJsonPath, 'utf8'));
269
+ workJson.files = byAction;
270
+ fs.writeFileSync(taskWorkJsonPath, JSON.stringify(workJson, null, 2), 'utf8');
271
+ this.debug('Task work.json updated with files', byAction);
272
+ }
273
+ } catch (err) {
274
+ this.debug('Failed to update task work.json with files', { error: err.message });
275
+ }
276
+
277
+ // 2. Update centralized file registry
278
+ try {
279
+ const registryPath = path.join(this.avcPath, 'project', 'file-registry.json');
280
+ let registry = { version: '1.0', files: {}, tasks: {} };
281
+ if (fs.existsSync(registryPath)) {
282
+ try { registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch {}
283
+ }
284
+
285
+ // Update files → tasks mapping
286
+ for (const op of tracker.getSummary()) {
287
+ if (!registry.files[op.path]) {
288
+ registry.files[op.path] = { createdBy: null, tasks: [], operations: [] };
289
+ }
290
+ const entry = registry.files[op.path];
291
+ if (op.firstAction === 'created' && !entry.createdBy) {
292
+ entry.createdBy = this.taskId;
293
+ }
294
+ if (!entry.tasks.includes(this.taskId)) {
295
+ entry.tasks.push(this.taskId);
296
+ }
297
+ entry.operations.push({
298
+ taskId: this.taskId,
299
+ action: op.lastAction,
300
+ timestamp: op.lastTimestamp,
301
+ });
302
+ }
303
+
304
+ // Update tasks → files mapping
305
+ registry.tasks[this.taskId] = {
306
+ files: allFiles,
307
+ ...byAction,
308
+ completedAt: new Date().toISOString(),
309
+ };
310
+
311
+ fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf8');
312
+ this.debug('File registry updated', { files: allFiles.length, operations: tracker.operations.length });
313
+ } catch (err) {
314
+ this.debug('Failed to update file registry', { error: err.message });
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Find work.json path for a task ID (tries flat first, then nested).
320
+ */
321
+ _findWorkJsonPath(taskId) {
322
+ // Flat path (tasks written by seed-processor)
323
+ const flatPath = path.join(this.avcPath, 'project', taskId, 'work.json');
324
+ if (fs.existsSync(flatPath)) return flatPath;
325
+
326
+ // Nested path (epics/stories)
327
+ const idParts = taskId.replace('context-', '').split('-');
328
+ let dir = path.join(this.avcPath, 'project');
329
+ let current = 'context';
330
+ for (const part of idParts) {
331
+ current += `-${part}`;
332
+ dir = path.join(dir, current);
333
+ }
334
+ const nestedPath = path.join(dir, 'work.json');
335
+ if (fs.existsSync(nestedPath)) return nestedPath;
336
+
337
+ return null;
338
+ }
339
+
340
+ /**
341
+ * Store test results and mark acceptance criteria as passed/failed in work.json.
342
+ * Reads the last command output from the tracker (typically the final `npm test` run)
343
+ * and stores it alongside AC pass status for the kanban card review display.
344
+ */
345
+ _updateTestResults(tracker) {
346
+ try {
347
+ const taskWorkJsonPath = this._findWorkJsonPath(this.taskId);
348
+ if (!taskWorkJsonPath || !fs.existsSync(taskWorkJsonPath)) return;
349
+
350
+ const workJson = JSON.parse(fs.readFileSync(taskWorkJsonPath, 'utf8'));
351
+ const lastTest = tracker.getLastCommandOutput();
352
+
353
+ // Store test output for review display
354
+ workJson.testResults = {
355
+ passed: lastTest ? lastTest.exitCode === 0 : false,
356
+ command: lastTest?.command || null,
357
+ output: lastTest?.output?.slice(-2000) || null, // last 2KB for display
358
+ timestamp: lastTest?.timestamp || new Date().toISOString(),
359
+ };
360
+
361
+ // Mark all ACs as passed if tests passed (the agent verified them all before stopping)
362
+ if (lastTest?.exitCode === 0 && Array.isArray(workJson.acceptance)) {
363
+ workJson.acceptanceStatus = workJson.acceptance.map((ac, i) => ({
364
+ criterion: ac,
365
+ passed: true,
366
+ index: i + 1,
367
+ }));
368
+ }
369
+
370
+ fs.writeFileSync(taskWorkJsonPath, JSON.stringify(workJson, null, 2), 'utf8');
371
+ this.debug('Test results written to work.json', {
372
+ passed: workJson.testResults.passed,
373
+ acsPassed: workJson.acceptanceStatus?.filter(a => a.passed).length ?? 0,
374
+ });
375
+ } catch (err) {
376
+ this.debug('Failed to update test results', { error: err.message });
377
+ }
378
+ }
379
+
170
380
  /**
171
381
  * Create a new git worktree for the task.
172
382
  */
@@ -201,6 +411,17 @@ export class WorktreeRunner {
201
411
 
202
412
  // Create new worktree with branch
203
413
  git(['worktree', 'add', this.worktreePath, '-b', this.branchName], this.projectRoot);
414
+
415
+ // Remove directories that should not be in the worktree
416
+ const excludeDirs = ['.avc', 'node_modules', '.env'];
417
+ for (const dir of excludeDirs) {
418
+ const dirPath = path.join(this.worktreePath, dir);
419
+ if (fs.existsSync(dirPath)) {
420
+ fs.rmSync(dirPath, { recursive: true, force: true });
421
+ this.debug(`Removed ${dir} from worktree`);
422
+ }
423
+ }
424
+
204
425
  this.debug('Worktree created', { path: this.worktreePath, branch: this.branchName });
205
426
  }
206
427
 
@@ -299,7 +520,14 @@ export class WorktreeRunner {
299
520
  const provider = stageConfig.provider || this._defaultProvider;
300
521
  const model = stageConfig.model || this._defaultModel;
301
522
 
302
- const instance = await LLMProvider.create(provider, model);
523
+ const resolved = await LLMProvider.resolveAvailableProvider(provider, model);
524
+ if (resolved.fellBack) {
525
+ this.debug(`Provider fallback for ${stageName}: ${provider}→${resolved.provider}`);
526
+ }
527
+
528
+ const instance = await LLMProvider.create(resolved.provider, resolved.model);
529
+ // Register token tracking callback
530
+ instance.onCall((delta) => this.tokenTracker.addIncremental(`run-${stageName}`, delta));
303
531
  this._stageProviders[key] = instance;
304
532
  return instance;
305
533
  }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * worktree-tools.js — Sandboxed tool definitions and executors for the agentic Run ceremony.
3
+ *
4
+ * All file operations are restricted to the worktree path.
5
+ * Shell commands execute with cwd set to the worktree.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { execSync } from 'child_process';
11
+ import { globSync } from 'glob';
12
+
13
+ /**
14
+ * File operation tracker — records all write/edit/delete operations for the file registry.
15
+ * Caller creates one per agent loop and reads it after the loop completes.
16
+ */
17
+ export class FileTracker {
18
+ constructor() {
19
+ this.operations = []; // { action: 'created'|'edited'|'deleted', path, timestamp }
20
+ }
21
+
22
+ record(action, filePath) {
23
+ this.operations.push({ action, path: filePath, timestamp: new Date().toISOString() });
24
+ }
25
+
26
+ /** Get the last run_command output (typically the final test run). */
27
+ getLastCommandOutput() {
28
+ return this._lastCommandOutput || null;
29
+ }
30
+
31
+ /** Record a command output (called by executeTool for run_command). */
32
+ recordCommandOutput(command, output, exitCode) {
33
+ this._lastCommandOutput = { command, output, exitCode, timestamp: new Date().toISOString() };
34
+ }
35
+
36
+ /** Get deduplicated file list with final action per file. */
37
+ getSummary() {
38
+ const fileMap = {};
39
+ for (const op of this.operations) {
40
+ if (!fileMap[op.path]) {
41
+ fileMap[op.path] = { path: op.path, actions: [], firstAction: op.action, firstTimestamp: op.timestamp };
42
+ }
43
+ fileMap[op.path].actions.push(op.action);
44
+ fileMap[op.path].lastAction = op.action;
45
+ fileMap[op.path].lastTimestamp = op.timestamp;
46
+ }
47
+ return Object.values(fileMap);
48
+ }
49
+
50
+ /** Get arrays grouped by action type. */
51
+ getByAction() {
52
+ const created = [], edited = [], deleted = [];
53
+ const seen = new Set();
54
+ // Walk in reverse to get final state per file
55
+ for (let i = this.operations.length - 1; i >= 0; i--) {
56
+ const op = this.operations[i];
57
+ if (seen.has(op.path)) continue;
58
+ seen.add(op.path);
59
+ if (op.action === 'created') created.push(op.path);
60
+ else if (op.action === 'edited') edited.push(op.path);
61
+ else if (op.action === 'deleted') deleted.push(op.path);
62
+ }
63
+ return { created: created.sort(), edited: edited.sort(), deleted: deleted.sort() };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Validate that a file path resolves inside the worktree (no escaping via ../ or symlinks).
69
+ */
70
+ function safePath(worktreePath, filePath) {
71
+ const resolved = path.resolve(worktreePath, filePath);
72
+ if (!resolved.startsWith(path.resolve(worktreePath) + path.sep) && resolved !== path.resolve(worktreePath)) {
73
+ throw new Error(`Path "${filePath}" escapes worktree boundary`);
74
+ }
75
+ return resolved;
76
+ }
77
+
78
+ /**
79
+ * Build the tool definitions array in OpenAI-compatible function-calling schema.
80
+ * These are sent to the LLM so it knows what tools are available.
81
+ */
82
+ export function buildToolDefinitions() {
83
+ return [
84
+ {
85
+ type: 'function',
86
+ function: {
87
+ name: 'read_file',
88
+ description: 'Read the contents of a file in the worktree. Returns the file content as a string.',
89
+ parameters: {
90
+ type: 'object',
91
+ properties: {
92
+ path: { type: 'string', description: 'Relative file path from worktree root' },
93
+ },
94
+ required: ['path'],
95
+ },
96
+ },
97
+ },
98
+ {
99
+ type: 'function',
100
+ function: {
101
+ name: 'write_file',
102
+ description: 'Create or overwrite a file in the worktree with the given content.',
103
+ parameters: {
104
+ type: 'object',
105
+ properties: {
106
+ path: { type: 'string', description: 'Relative file path from worktree root' },
107
+ content: { type: 'string', description: 'Full file content to write' },
108
+ },
109
+ required: ['path', 'content'],
110
+ },
111
+ },
112
+ },
113
+ {
114
+ type: 'function',
115
+ function: {
116
+ name: 'edit_file',
117
+ description: 'Replace a specific string in a file. The old_string must match exactly (including whitespace). Use for targeted edits to existing files.',
118
+ parameters: {
119
+ type: 'object',
120
+ properties: {
121
+ path: { type: 'string', description: 'Relative file path from worktree root' },
122
+ old_string: { type: 'string', description: 'Exact string to find (must be unique in the file)' },
123
+ new_string: { type: 'string', description: 'Replacement string' },
124
+ },
125
+ required: ['path', 'old_string', 'new_string'],
126
+ },
127
+ },
128
+ },
129
+ {
130
+ type: 'function',
131
+ function: {
132
+ name: 'delete_file',
133
+ description: 'Delete a file from the worktree.',
134
+ parameters: {
135
+ type: 'object',
136
+ properties: {
137
+ path: { type: 'string', description: 'Relative file path from worktree root' },
138
+ },
139
+ required: ['path'],
140
+ },
141
+ },
142
+ },
143
+ {
144
+ type: 'function',
145
+ function: {
146
+ name: 'list_files',
147
+ description: 'List files in the worktree matching a glob pattern. Returns newline-separated file paths.',
148
+ parameters: {
149
+ type: 'object',
150
+ properties: {
151
+ pattern: { type: 'string', description: 'Glob pattern (e.g., "src/**/*.js", "*.json"). Defaults to "**/*" if omitted.' },
152
+ },
153
+ },
154
+ },
155
+ },
156
+ {
157
+ type: 'function',
158
+ function: {
159
+ name: 'run_command',
160
+ description: 'Execute a shell command in the worktree directory. Returns stdout+stderr. Use for running tests, installing packages, compiling, etc.',
161
+ parameters: {
162
+ type: 'object',
163
+ properties: {
164
+ command: { type: 'string', description: 'Shell command to execute' },
165
+ },
166
+ required: ['command'],
167
+ },
168
+ },
169
+ },
170
+ {
171
+ type: 'function',
172
+ function: {
173
+ name: 'search_code',
174
+ description: 'Search for a regex pattern in worktree files. Returns matching lines with file paths and line numbers.',
175
+ parameters: {
176
+ type: 'object',
177
+ properties: {
178
+ pattern: { type: 'string', description: 'Regex pattern to search for' },
179
+ glob: { type: 'string', description: 'File glob to restrict search (e.g., "*.js"). Defaults to all files.' },
180
+ },
181
+ required: ['pattern'],
182
+ },
183
+ },
184
+ },
185
+ ];
186
+ }
187
+
188
+ /**
189
+ * Execute a tool call within the worktree sandbox.
190
+ * @param {string} worktreePath - Absolute path to the worktree root
191
+ * @param {string} toolName - Tool name
192
+ * @param {object} args - Tool arguments
193
+ * @param {number} commandTimeout - Timeout for shell commands in ms (default 120s)
194
+ * @returns {string} Tool result as a string
195
+ */
196
+ export function executeTool(worktreePath, toolName, args, commandTimeout = 120_000, tracker = null) {
197
+ try {
198
+ switch (toolName) {
199
+ case 'read_file': {
200
+ const filePath = safePath(worktreePath, args.path);
201
+ if (!fs.existsSync(filePath)) return `Error: file not found: ${args.path}`;
202
+ const stat = fs.statSync(filePath);
203
+ if (stat.size > 500_000) return `Error: file too large (${stat.size} bytes, max 500KB)`;
204
+ return fs.readFileSync(filePath, 'utf8');
205
+ }
206
+
207
+ case 'write_file': {
208
+ const filePath = safePath(worktreePath, args.path);
209
+ const existed = fs.existsSync(filePath);
210
+ const dir = path.dirname(filePath);
211
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
212
+ fs.writeFileSync(filePath, args.content, 'utf8');
213
+ tracker?.record(existed ? 'edited' : 'created', args.path);
214
+ return `Written: ${args.path} (${args.content.length} bytes)`;
215
+ }
216
+
217
+ case 'edit_file': {
218
+ const filePath = safePath(worktreePath, args.path);
219
+ if (!fs.existsSync(filePath)) return `Error: file not found: ${args.path}`;
220
+ const content = fs.readFileSync(filePath, 'utf8');
221
+ if (!content.includes(args.old_string)) {
222
+ return `Error: old_string not found in ${args.path}. Make sure it matches exactly (including whitespace).`;
223
+ }
224
+ const occurrences = content.split(args.old_string).length - 1;
225
+ if (occurrences > 1) {
226
+ return `Error: old_string found ${occurrences} times in ${args.path}. It must be unique. Provide more surrounding context.`;
227
+ }
228
+ const newContent = content.replace(args.old_string, args.new_string);
229
+ fs.writeFileSync(filePath, newContent, 'utf8');
230
+ tracker?.record('edited', args.path);
231
+ return `Edited: ${args.path}`;
232
+ }
233
+
234
+ case 'delete_file': {
235
+ const filePath = safePath(worktreePath, args.path);
236
+ if (!fs.existsSync(filePath)) return `Error: file not found: ${args.path}`;
237
+ fs.unlinkSync(filePath);
238
+ tracker?.record('deleted', args.path);
239
+ return `Deleted: ${args.path}`;
240
+ }
241
+
242
+ case 'list_files': {
243
+ const pattern = args.pattern || '**/*';
244
+ const files = globSync(pattern, {
245
+ cwd: worktreePath,
246
+ nodir: true,
247
+ ignore: ['node_modules/**', '.git/**'],
248
+ });
249
+ if (files.length === 0) return '(no files match)';
250
+ return files.sort().join('\n');
251
+ }
252
+
253
+ case 'run_command': {
254
+ try {
255
+ const output = execSync(args.command, {
256
+ cwd: worktreePath,
257
+ encoding: 'utf8',
258
+ timeout: commandTimeout,
259
+ maxBuffer: 1024 * 1024,
260
+ stdio: ['pipe', 'pipe', 'pipe'],
261
+ });
262
+ tracker?.recordCommandOutput(args.command, output || '(no output)', 0);
263
+ return output || '(no output)';
264
+ } catch (err) {
265
+ const stdout = err.stdout || '';
266
+ const stderr = err.stderr || '';
267
+ const combined = `Exit code ${err.status ?? 1}\n${stdout}\n${stderr}`.trim();
268
+ tracker?.recordCommandOutput(args.command, combined, err.status ?? 1);
269
+ return combined;
270
+ }
271
+ }
272
+
273
+ case 'search_code': {
274
+ const fileGlob = args.glob || '**/*';
275
+ const files = globSync(fileGlob, {
276
+ cwd: worktreePath,
277
+ nodir: true,
278
+ ignore: ['node_modules/**', '.git/**'],
279
+ });
280
+ const regex = new RegExp(args.pattern, 'gm');
281
+ const results = [];
282
+ for (const file of files) {
283
+ const filePath = path.join(worktreePath, file);
284
+ try {
285
+ const content = fs.readFileSync(filePath, 'utf8');
286
+ const lines = content.split('\n');
287
+ for (let i = 0; i < lines.length; i++) {
288
+ if (regex.test(lines[i])) {
289
+ results.push(`${file}:${i + 1}: ${lines[i]}`);
290
+ }
291
+ regex.lastIndex = 0; // reset for global regex
292
+ }
293
+ } catch { /* skip binary/unreadable files */ }
294
+ }
295
+ if (results.length === 0) return '(no matches)';
296
+ if (results.length > 100) return results.slice(0, 100).join('\n') + `\n... (${results.length - 100} more matches)`;
297
+ return results.join('\n');
298
+ }
299
+
300
+ default:
301
+ return `Error: unknown tool "${toolName}"`;
302
+ }
303
+ } catch (err) {
304
+ return `Error: ${err.message}`;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Summarize a tool call for progress logging (one-line description).
310
+ */
311
+ export function summarizeToolCall(toolName, args) {
312
+ switch (toolName) {
313
+ case 'read_file': return args.path;
314
+ case 'write_file': return `${args.path} (${args.content?.length ?? 0} bytes)`;
315
+ case 'edit_file': return args.path;
316
+ case 'delete_file': return args.path;
317
+ case 'list_files': return args.pattern || '**/*';
318
+ case 'run_command': return args.command?.slice(0, 80);
319
+ case 'search_code': return `/${args.pattern}/ in ${args.glob || '*'}`;
320
+ default: return toolName;
321
+ }
322
+ }