@agile-vibe-coding/avc 0.3.4 → 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/seed-processor.js +31 -0
- package/cli/worktree-runner.js +268 -26
- 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 +101 -2
- package/kanban/server/workers/run-task-worker.js +60 -11
- 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,33 +131,36 @@ 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
|
-
//
|
|
145
|
-
progressCallback?.('Committing
|
|
146
|
+
// 6. Commit in worktree (do NOT merge — leave for human review)
|
|
147
|
+
progressCallback?.('Committing changes in worktree...');
|
|
146
148
|
if (cancelledCheck?.()) throw new Error('CANCELLED');
|
|
147
|
-
this.
|
|
149
|
+
this.commitInWorktree();
|
|
148
150
|
|
|
149
|
-
//
|
|
150
|
-
progressCallback?.(
|
|
151
|
-
this.
|
|
151
|
+
// 5. Leave worktree in place for review
|
|
152
|
+
progressCallback?.(`Code ready for review in worktree: ${this.worktreePath}`);
|
|
153
|
+
progressCallback?.(`Branch: ${this.branchName}`);
|
|
152
154
|
|
|
153
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
success: true,
|
|
157
|
+
review: true,
|
|
158
|
+
worktreePath: this.worktreePath,
|
|
159
|
+
branchName: this.branchName,
|
|
160
|
+
};
|
|
154
161
|
} catch (err) {
|
|
162
|
+
// Finalize token tracking even on failure (tokens were spent)
|
|
163
|
+
try { this.tokenTracker.finalizeRun('run'); } catch {}
|
|
155
164
|
// Always cleanup on failure
|
|
156
165
|
try { this.cleanup(); } catch {}
|
|
157
166
|
|
|
@@ -162,6 +171,212 @@ export class WorktreeRunner {
|
|
|
162
171
|
}
|
|
163
172
|
}
|
|
164
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
|
+
|
|
165
380
|
/**
|
|
166
381
|
* Create a new git worktree for the task.
|
|
167
382
|
*/
|
|
@@ -196,6 +411,17 @@ export class WorktreeRunner {
|
|
|
196
411
|
|
|
197
412
|
// Create new worktree with branch
|
|
198
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
|
+
|
|
199
425
|
this.debug('Worktree created', { path: this.worktreePath, branch: this.branchName });
|
|
200
426
|
}
|
|
201
427
|
|
|
@@ -294,7 +520,14 @@ export class WorktreeRunner {
|
|
|
294
520
|
const provider = stageConfig.provider || this._defaultProvider;
|
|
295
521
|
const model = stageConfig.model || this._defaultModel;
|
|
296
522
|
|
|
297
|
-
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));
|
|
298
531
|
this._stageProviders[key] = instance;
|
|
299
532
|
return instance;
|
|
300
533
|
}
|
|
@@ -583,28 +816,35 @@ export class WorktreeRunner {
|
|
|
583
816
|
/**
|
|
584
817
|
* Commit changes in the worktree and merge to the main branch.
|
|
585
818
|
*/
|
|
586
|
-
|
|
587
|
-
|
|
819
|
+
/**
|
|
820
|
+
* Commit all changes in the worktree (does NOT merge to main).
|
|
821
|
+
* Called after tests pass — the worktree stays in place for human review.
|
|
822
|
+
*/
|
|
823
|
+
commitInWorktree() {
|
|
588
824
|
git(['add', '-A'], this.worktreePath);
|
|
589
825
|
|
|
590
|
-
// Check if there's anything to commit
|
|
591
826
|
const status = git(['status', '--porcelain'], this.worktreePath);
|
|
592
827
|
if (!status) {
|
|
593
828
|
this.debug('No changes to commit');
|
|
594
829
|
return;
|
|
595
830
|
}
|
|
596
831
|
|
|
597
|
-
// Commit
|
|
598
832
|
const commitMsg = `feat(${this.taskId}): implement task\n\nGenerated by AVC WorktreeRunner`;
|
|
599
833
|
git(['commit', '-m', commitMsg], this.worktreePath);
|
|
600
834
|
this.debug('Committed in worktree');
|
|
835
|
+
}
|
|
601
836
|
|
|
837
|
+
/**
|
|
838
|
+
* Merge the worktree branch into main and clean up.
|
|
839
|
+
* Called AFTER human review approves the code (status: implemented → completed).
|
|
840
|
+
* Can be invoked via API or CLI.
|
|
841
|
+
*/
|
|
842
|
+
mergeAndCleanup() {
|
|
602
843
|
// Determine the main branch name
|
|
603
844
|
let mainBranch = 'main';
|
|
604
845
|
try {
|
|
605
846
|
mainBranch = git(['symbolic-ref', '--short', 'HEAD'], this.projectRoot);
|
|
606
847
|
} catch {
|
|
607
|
-
// If HEAD is detached, try common branch names
|
|
608
848
|
try { git(['rev-parse', '--verify', 'main'], this.projectRoot); mainBranch = 'main'; } catch {
|
|
609
849
|
try { git(['rev-parse', '--verify', 'master'], this.projectRoot); mainBranch = 'master'; } catch {}
|
|
610
850
|
}
|
|
@@ -615,10 +855,12 @@ export class WorktreeRunner {
|
|
|
615
855
|
git(['merge', '--no-ff', this.branchName, '-m', `Merge ${this.branchName}: implement ${this.taskId}`], this.projectRoot);
|
|
616
856
|
this.debug('Merged to main', { mainBranch });
|
|
617
857
|
} catch (err) {
|
|
618
|
-
// Merge conflict — abort and report
|
|
619
858
|
try { git(['merge', '--abort'], this.projectRoot); } catch {}
|
|
620
859
|
throw new Error(`Merge conflict: ${err.message}`);
|
|
621
860
|
}
|
|
861
|
+
|
|
862
|
+
// Cleanup worktree and branch
|
|
863
|
+
this.cleanup();
|
|
622
864
|
}
|
|
623
865
|
|
|
624
866
|
/**
|
|
@@ -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
|
+
}
|