@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.
- package/README.md +86 -12
- package/cli/agents/code-implementer.md +33 -46
- package/cli/init.js +4 -3
- package/cli/llm-claude.js +72 -0
- package/cli/llm-gemini.js +76 -0
- package/cli/llm-local.js +52 -0
- package/cli/llm-openai.js +52 -0
- package/cli/llm-provider.js +12 -0
- package/cli/llm-xiaomi.js +51 -0
- package/cli/worktree-runner.js +243 -15
- package/cli/worktree-tools.js +322 -0
- package/kanban/client/dist/assets/index-BSm2Zo5j.js +380 -0
- package/kanban/client/dist/assets/index-BevZLADh.css +1 -0
- package/kanban/client/dist/index.html +2 -2
- package/kanban/client/src/App.jsx +37 -5
- package/kanban/client/src/components/ceremony/RunModal.jsx +329 -0
- package/kanban/client/src/components/ceremony/SeedModal.jsx +2 -2
- package/kanban/client/src/components/kanban/CardDetailModal.jsx +95 -21
- package/kanban/client/src/components/kanban/RunButton.jsx +34 -153
- package/kanban/client/src/components/process/ProcessMonitorBar.jsx +4 -0
- package/kanban/client/src/lib/api.js +10 -0
- package/kanban/client/src/store/filterStore.js +10 -3
- package/kanban/client/src/store/runStore.js +103 -0
- package/kanban/server/routes/work-items.js +38 -0
- package/kanban/server/workers/run-task-worker.js +51 -9
- package/package.json +1 -1
- package/kanban/client/dist/assets/index-BfLDUxPS.js +0 -353
- package/kanban/client/dist/assets/index-C7W_e4ik.css +0 -1
package/cli/worktree-runner.js
CHANGED
|
@@ -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.
|
|
129
|
-
progressCallback?.('
|
|
134
|
+
// 3. Run agentic tool-calling loop
|
|
135
|
+
progressCallback?.('Starting agentic implementation loop...');
|
|
130
136
|
if (cancelledCheck?.()) throw new Error('CANCELLED');
|
|
131
|
-
await this.
|
|
137
|
+
const fileTracker = await this.executeAgentLoop(context, progressCallback, cancelledCheck);
|
|
132
138
|
|
|
133
|
-
// 4.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const testResult = this.runTests();
|
|
139
|
+
// 4. Write file registry and test results
|
|
140
|
+
this._updateFileRegistry(fileTracker);
|
|
141
|
+
this._updateTestResults(fileTracker);
|
|
137
142
|
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
+
}
|