@voybio/ace-swarm 2.4.0 → 2.4.1
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/CHANGELOG.md +8 -0
- package/README.md +1 -0
- package/assets/.agents/ACE/agent-qa/instructions.md +11 -0
- package/assets/agent-state/MODULES/schemas/RUNTIME_TOOL_SPEC_REGISTRY.schema.json +43 -0
- package/assets/agent-state/runtime-tool-specs.json +70 -2
- package/assets/instructions/ACE_Coder.instructions.md +13 -0
- package/assets/instructions/ACE_UI.instructions.md +11 -0
- package/dist/ace-context.js +70 -11
- package/dist/ace-internal-tools.d.ts +3 -1
- package/dist/ace-internal-tools.js +10 -2
- package/dist/agent-runtime/role-adapters.d.ts +18 -1
- package/dist/agent-runtime/role-adapters.js +49 -5
- package/dist/astgrep-index.d.ts +48 -0
- package/dist/astgrep-index.js +126 -1
- package/dist/cli.js +205 -15
- package/dist/discovery-runtime-wrappers.d.ts +108 -0
- package/dist/discovery-runtime-wrappers.js +615 -0
- package/dist/helpers/bootstrap.js +1 -1
- package/dist/helpers/constants.d.ts +2 -2
- package/dist/helpers/constants.js +7 -0
- package/dist/helpers/path-utils.d.ts +8 -1
- package/dist/helpers/path-utils.js +27 -8
- package/dist/helpers/store-resolution.js +7 -3
- package/dist/job-scheduler.js +30 -4
- package/dist/json-sanitizer.d.ts +16 -0
- package/dist/json-sanitizer.js +26 -0
- package/dist/local-model-policy.d.ts +27 -0
- package/dist/local-model-policy.js +84 -0
- package/dist/local-model-runtime.d.ts +6 -0
- package/dist/local-model-runtime.js +21 -20
- package/dist/model-bridge.d.ts +6 -1
- package/dist/model-bridge.js +338 -21
- package/dist/orchestrator-supervisor.d.ts +42 -0
- package/dist/orchestrator-supervisor.js +110 -3
- package/dist/plan-proposal.d.ts +115 -0
- package/dist/plan-proposal.js +1073 -0
- package/dist/runtime-executor.d.ts +6 -1
- package/dist/runtime-executor.js +72 -5
- package/dist/runtime-tool-specs.d.ts +19 -1
- package/dist/runtime-tool-specs.js +67 -26
- package/dist/schemas.js +29 -1
- package/dist/server.js +51 -0
- package/dist/shared.d.ts +1 -0
- package/dist/shared.js +2 -0
- package/dist/store/bootstrap-store.d.ts +1 -0
- package/dist/store/bootstrap-store.js +8 -2
- package/dist/store/repositories/local-model-runtime-repository.d.ts +1 -1
- package/dist/store/repositories/local-model-runtime-repository.js +1 -1
- package/dist/store/repositories/vericify-repository.d.ts +1 -1
- package/dist/tools-agent.d.ts +20 -0
- package/dist/tools-agent.js +538 -28
- package/dist/tools-discovery.js +135 -0
- package/dist/tools-files.js +768 -66
- package/dist/tools-framework.js +80 -61
- package/dist/tui/index.js +10 -1
- package/dist/tui/ollama.d.ts +8 -1
- package/dist/tui/ollama.js +53 -12
- package/dist/tui/openai-compatible.d.ts +13 -0
- package/dist/tui/openai-compatible.js +305 -5
- package/dist/tui/provider-discovery.d.ts +1 -0
- package/dist/tui/provider-discovery.js +35 -11
- package/dist/vericify-bridge.d.ts +1 -1
- package/package.json +1 -1
package/dist/tools-files.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File operation tool registrations + new safe-edit and diff tools.
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
7
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
6
8
|
import { z } from "zod";
|
|
7
|
-
import { ACE_TASKS_ROOT_REL, normalizePathForValidation, safeRead, safeWriteAsync, safeWrite, resolveStoreFallbackKeysForPath, wsPath, WORKSPACE_ROOT, } from "./helpers.js";
|
|
9
|
+
import { ACE_TASKS_ROOT_REL, normalizePathForValidation, safeRead, safeWriteAsync, safeWrite, resolveWorkspaceWritePath, resolveStoreFallbackKeysForPath, wsPath, WORKSPACE_ROOT, } from "./helpers.js";
|
|
8
10
|
import { isInside, looksLikeSwarmHandoffPath, normalizeRelPath } from "./shared.js";
|
|
9
11
|
import { validateAgentStateHandoffPayload, validateArtifactManifestPayload, validateProvenanceLogContent, validateStatusEventsNdjsonContent, validateSwarmHandoffPayload, validateTealConfigContent, validateRuntimeExecutorSessionRegistryPayload, validateRuntimeToolSpecRegistryPayload, validateTrackerSnapshotPayload, validateVericifyBridgeSnapshotPayload, validateVericifyProcessPostLogPayload, validateWorkspaceSessionRegistryPayload, lintHandoffPayload, } from "./schemas.js";
|
|
10
12
|
import { syncTodoStateSafe } from "./todo-state.js";
|
|
@@ -12,8 +14,42 @@ import { shouldAutoRefreshKanbanForPath, refreshKanbanArtifacts, } from "./kanba
|
|
|
12
14
|
import { appendRunLedgerEntrySafe } from "./run-ledger.js";
|
|
13
15
|
import { appendStatusEventSafe } from "./status-events.js";
|
|
14
16
|
import { safeEditFile, diffContents, applyPatch } from "./safe-edit.js";
|
|
17
|
+
import { detectAstgrepCommand, locateAstgrepMatches, runAstgrepQuery, } from "./astgrep-index.js";
|
|
15
18
|
import { readRuntimeProfile, resolveEffectiveSurgicalReadBudget, validateRuntimeProfileContent, } from "./runtime-profile.js";
|
|
16
19
|
import { withLocalModelRuntimeRepository } from "./store/repositories/local-model-runtime-repository.js";
|
|
20
|
+
function reasonCodeFromError(error) {
|
|
21
|
+
return typeof error === "object" && error !== null && "reason_code" in error
|
|
22
|
+
? String(error.reason_code)
|
|
23
|
+
: undefined;
|
|
24
|
+
}
|
|
25
|
+
function errorMessage(error) {
|
|
26
|
+
return error instanceof Error ? error.message : String(error);
|
|
27
|
+
}
|
|
28
|
+
async function appendWriteWorkspaceRejection(input) {
|
|
29
|
+
await appendRunLedgerEntrySafe({
|
|
30
|
+
tool: "write_workspace_file",
|
|
31
|
+
category: "regression",
|
|
32
|
+
message: input.message,
|
|
33
|
+
artifacts: [],
|
|
34
|
+
metadata: {
|
|
35
|
+
reason_code: input.reason_code,
|
|
36
|
+
path: input.path,
|
|
37
|
+
workspace_path: input.workspace_path,
|
|
38
|
+
},
|
|
39
|
+
}).catch(() => undefined);
|
|
40
|
+
await appendStatusEventSafe({
|
|
41
|
+
source_module: "capability-safety",
|
|
42
|
+
event_type: "WORKSPACE_WRITE_REJECTED",
|
|
43
|
+
status: "blocked",
|
|
44
|
+
summary: input.message,
|
|
45
|
+
objective_id: "workspace-write-safety",
|
|
46
|
+
payload: {
|
|
47
|
+
reason_code: input.reason_code,
|
|
48
|
+
path: input.path,
|
|
49
|
+
workspace_path: input.workspace_path,
|
|
50
|
+
},
|
|
51
|
+
}).catch(() => undefined);
|
|
52
|
+
}
|
|
17
53
|
export function planAstgrepRewriteTargets(files) {
|
|
18
54
|
const affected = [...new Set(files.filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
19
55
|
if (affected.length > 1) {
|
|
@@ -50,6 +86,370 @@ function astgrepFileToWorkspaceRel(file) {
|
|
|
50
86
|
return undefined;
|
|
51
87
|
return normalizeRelPath(relative(WORKSPACE_ROOT, abs));
|
|
52
88
|
}
|
|
89
|
+
const STRUCTURAL_EDIT_ARTIFACTS_REL = "agent-state/structural-edits";
|
|
90
|
+
function contentSha256(content) {
|
|
91
|
+
return `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`;
|
|
92
|
+
}
|
|
93
|
+
function structuralEditArtifactRelPath(prefix, id) {
|
|
94
|
+
return `${STRUCTURAL_EDIT_ARTIFACTS_REL}/${prefix}-${id}.json`;
|
|
95
|
+
}
|
|
96
|
+
async function writeJsonArtifact(artifact) {
|
|
97
|
+
await safeWriteAsync(artifact.artifact_path, JSON.stringify(artifact, null, 2));
|
|
98
|
+
return artifact;
|
|
99
|
+
}
|
|
100
|
+
function readJsonArtifact(relPath) {
|
|
101
|
+
const raw = safeRead(relPath);
|
|
102
|
+
if (raw.startsWith("[FILE NOT FOUND]") || raw.startsWith("[ACCESS DENIED]"))
|
|
103
|
+
return undefined;
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(raw);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function jsonResponse(payload, isError = false) {
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
114
|
+
...(isError ? { isError: true } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async function storeLocateArtifact(input, result) {
|
|
118
|
+
const locatorId = randomUUID();
|
|
119
|
+
return writeJsonArtifact({
|
|
120
|
+
version: 1,
|
|
121
|
+
kind: "astgrep_locate",
|
|
122
|
+
locator_id: locatorId,
|
|
123
|
+
created_at: new Date().toISOString(),
|
|
124
|
+
artifact_path: structuralEditArtifactRelPath("locate", locatorId),
|
|
125
|
+
input,
|
|
126
|
+
astgrep_command: result.astgrep_command,
|
|
127
|
+
total_matches: result.total_matches,
|
|
128
|
+
matches: result.matches,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function listStructuralEditArtifacts(prefix) {
|
|
132
|
+
const dir = wsPath(STRUCTURAL_EDIT_ARTIFACTS_REL);
|
|
133
|
+
if (!existsSync(dir))
|
|
134
|
+
return [];
|
|
135
|
+
return readdirSync(dir)
|
|
136
|
+
.filter((name) => name.startsWith(`${prefix}-`) && name.endsWith(".json"))
|
|
137
|
+
.sort((a, b) => b.localeCompare(a))
|
|
138
|
+
.map((name) => normalizeRelPath(relative(WORKSPACE_ROOT, resolve(dir, name))));
|
|
139
|
+
}
|
|
140
|
+
function findStoredMatch(matchId) {
|
|
141
|
+
for (const relPath of listStructuralEditArtifacts("locate")) {
|
|
142
|
+
const artifact = readJsonArtifact(relPath);
|
|
143
|
+
if (!artifact)
|
|
144
|
+
continue;
|
|
145
|
+
const match = artifact.matches.find((candidate) => candidate.match_id === matchId);
|
|
146
|
+
if (match)
|
|
147
|
+
return { artifact, match };
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
function loadStructuralEditPlan(planId) {
|
|
152
|
+
return readJsonArtifact(structuralEditArtifactRelPath("plan", planId));
|
|
153
|
+
}
|
|
154
|
+
function planScopeFromMatches(matches) {
|
|
155
|
+
return [...new Set(matches.map((match) => match.file))].sort((a, b) => a.localeCompare(b));
|
|
156
|
+
}
|
|
157
|
+
function rewriteCaptureRefs(rewriteTemplate) {
|
|
158
|
+
const refs = new Set();
|
|
159
|
+
const regex = /\$([A-Z][A-Z0-9_]*)/g;
|
|
160
|
+
let match;
|
|
161
|
+
while ((match = regex.exec(rewriteTemplate)) !== null) {
|
|
162
|
+
refs.add(match[1]);
|
|
163
|
+
}
|
|
164
|
+
return [...refs].sort((a, b) => a.localeCompare(b));
|
|
165
|
+
}
|
|
166
|
+
function missingRewriteCaptures(rewriteTemplate, captures) {
|
|
167
|
+
const available = new Set(Object.keys(captures ?? {}));
|
|
168
|
+
return rewriteCaptureRefs(rewriteTemplate).filter((ref) => !available.has(ref));
|
|
169
|
+
}
|
|
170
|
+
async function compileStructuralEditPlan(input) {
|
|
171
|
+
const scopedMatches = input.selectedMatch
|
|
172
|
+
? input.locateArtifact.matches.filter((match) => match.file === input.selectedMatch?.file)
|
|
173
|
+
: input.locateArtifact.matches;
|
|
174
|
+
const targetFile = input.selectedMatch?.file ?? scopedMatches[0]?.file ?? "";
|
|
175
|
+
const selectedMatch = input.selectedMatch ?? scopedMatches.find((match) => match.file === targetFile) ?? scopedMatches[0];
|
|
176
|
+
const rewriteTemplate = input.rewriteTemplate ?? input.desiredChange;
|
|
177
|
+
const missingCaptures = missingRewriteCaptures(rewriteTemplate, selectedMatch?.captures);
|
|
178
|
+
if (missingCaptures.length > 0) {
|
|
179
|
+
throw new Error(`bad_capture: rewrite references missing capture(s): ${missingCaptures.join(", ")}`);
|
|
180
|
+
}
|
|
181
|
+
const planId = randomUUID();
|
|
182
|
+
return writeJsonArtifact({
|
|
183
|
+
version: 1,
|
|
184
|
+
kind: "astgrep_edit_plan",
|
|
185
|
+
plan_id: planId,
|
|
186
|
+
created_at: new Date().toISOString(),
|
|
187
|
+
artifact_path: structuralEditArtifactRelPath("plan", planId),
|
|
188
|
+
locator_artifact_path: input.locateArtifact.artifact_path,
|
|
189
|
+
locator: input.locator,
|
|
190
|
+
target: {
|
|
191
|
+
file: targetFile,
|
|
192
|
+
file_hash: selectedMatch?.file_hash ?? "sha256:none",
|
|
193
|
+
selected_match_id: selectedMatch?.match_id,
|
|
194
|
+
selected_range: selectedMatch?.range,
|
|
195
|
+
selected_text_preview: selectedMatch?.text_preview,
|
|
196
|
+
captures: selectedMatch?.captures,
|
|
197
|
+
node_kind: selectedMatch?.node_kind,
|
|
198
|
+
match_count_in_file: scopedMatches.length,
|
|
199
|
+
scope_match_count: input.locateArtifact.matches.length,
|
|
200
|
+
scope_affected_files: planScopeFromMatches(input.locateArtifact.matches),
|
|
201
|
+
},
|
|
202
|
+
rewrite: {
|
|
203
|
+
desired_change: input.desiredChange,
|
|
204
|
+
rewrite_template: rewriteTemplate,
|
|
205
|
+
rewrite_source: input.rewriteTemplate ? "rewrite_template" : "desired_change",
|
|
206
|
+
},
|
|
207
|
+
validation_command: input.validationCommand,
|
|
208
|
+
test_command: input.testCommand,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function previewDiffText(diffText) {
|
|
212
|
+
const lines = diffText.trimEnd().split("\n");
|
|
213
|
+
const limited = lines.slice(0, 80).join("\n");
|
|
214
|
+
const bounded = limited.slice(0, 4000);
|
|
215
|
+
return bounded.length < diffText.length ? `${bounded}\n…` : bounded;
|
|
216
|
+
}
|
|
217
|
+
function parseChangedRanges(diffText) {
|
|
218
|
+
const ranges = [];
|
|
219
|
+
const regex = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm;
|
|
220
|
+
let match;
|
|
221
|
+
while ((match = regex.exec(diffText)) !== null) {
|
|
222
|
+
const originalStart = Number(match[1] ?? "0");
|
|
223
|
+
const originalLength = Number(match[2] ?? "1");
|
|
224
|
+
const updatedStart = Number(match[3] ?? "0");
|
|
225
|
+
const updatedLength = Number(match[4] ?? "1");
|
|
226
|
+
ranges.push({
|
|
227
|
+
original_start_line: originalStart,
|
|
228
|
+
original_end_line: Math.max(originalStart, originalStart + Math.max(originalLength, 1) - 1),
|
|
229
|
+
updated_start_line: updatedStart,
|
|
230
|
+
updated_end_line: Math.max(updatedStart, updatedStart + Math.max(updatedLength, 1) - 1),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return ranges;
|
|
234
|
+
}
|
|
235
|
+
async function buildStructuralPreview(plan) {
|
|
236
|
+
const previewId = randomUUID();
|
|
237
|
+
const artifactPath = structuralEditArtifactRelPath("preview", previewId);
|
|
238
|
+
const finalize = (artifact) => writeJsonArtifact({
|
|
239
|
+
version: 1,
|
|
240
|
+
kind: "astgrep_preview",
|
|
241
|
+
preview_id: previewId,
|
|
242
|
+
created_at: new Date().toISOString(),
|
|
243
|
+
artifact_path: artifactPath,
|
|
244
|
+
plan_id: plan.plan_id,
|
|
245
|
+
...artifact,
|
|
246
|
+
});
|
|
247
|
+
const currentContent = safeRead(plan.target.file);
|
|
248
|
+
if (currentContent.startsWith("[FILE NOT FOUND]") || currentContent.startsWith("[ACCESS DENIED]")) {
|
|
249
|
+
return {
|
|
250
|
+
artifact: await finalize({
|
|
251
|
+
ok: false,
|
|
252
|
+
reason_code: "target_unreadable",
|
|
253
|
+
error: `Cannot read ${plan.target.file}`,
|
|
254
|
+
target_file: plan.target.file,
|
|
255
|
+
expected_file_hash: plan.target.file_hash,
|
|
256
|
+
matched_count: 0,
|
|
257
|
+
affected_file_count: 0,
|
|
258
|
+
changed_ranges: [],
|
|
259
|
+
diff_summary: "",
|
|
260
|
+
diff_preview: "",
|
|
261
|
+
promotable: false,
|
|
262
|
+
}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const currentFileHash = contentSha256(currentContent);
|
|
266
|
+
if (currentFileHash !== plan.target.file_hash) {
|
|
267
|
+
return {
|
|
268
|
+
artifact: await finalize({
|
|
269
|
+
ok: false,
|
|
270
|
+
reason_code: "stale_hash",
|
|
271
|
+
error: `Plan ${plan.plan_id} is stale for ${plan.target.file}`,
|
|
272
|
+
target_file: plan.target.file,
|
|
273
|
+
expected_file_hash: plan.target.file_hash,
|
|
274
|
+
current_file_hash: currentFileHash,
|
|
275
|
+
matched_count: 0,
|
|
276
|
+
affected_file_count: 0,
|
|
277
|
+
changed_ranges: [],
|
|
278
|
+
diff_summary: "",
|
|
279
|
+
diff_preview: "",
|
|
280
|
+
promotable: false,
|
|
281
|
+
}),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
let locateResult;
|
|
285
|
+
try {
|
|
286
|
+
locateResult = locateAstgrepMatches(plan.locator);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
return {
|
|
290
|
+
artifact: await finalize({
|
|
291
|
+
ok: false,
|
|
292
|
+
reason_code: "scope_escape",
|
|
293
|
+
error: error instanceof Error ? error.message : String(error),
|
|
294
|
+
target_file: plan.target.file,
|
|
295
|
+
expected_file_hash: plan.target.file_hash,
|
|
296
|
+
current_file_hash: currentFileHash,
|
|
297
|
+
matched_count: 0,
|
|
298
|
+
affected_file_count: 0,
|
|
299
|
+
changed_ranges: [],
|
|
300
|
+
diff_summary: "",
|
|
301
|
+
diff_preview: "",
|
|
302
|
+
promotable: false,
|
|
303
|
+
}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (!locateResult.ok) {
|
|
307
|
+
return {
|
|
308
|
+
artifact: await finalize({
|
|
309
|
+
ok: false,
|
|
310
|
+
reason_code: "locate_failed",
|
|
311
|
+
error: locateResult.error ?? "Failed to locate structural matches",
|
|
312
|
+
target_file: plan.target.file,
|
|
313
|
+
expected_file_hash: plan.target.file_hash,
|
|
314
|
+
current_file_hash: currentFileHash,
|
|
315
|
+
matched_count: 0,
|
|
316
|
+
affected_file_count: 0,
|
|
317
|
+
changed_ranges: [],
|
|
318
|
+
diff_summary: "",
|
|
319
|
+
diff_preview: "",
|
|
320
|
+
promotable: false,
|
|
321
|
+
}),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (locateResult.matches.length === 0) {
|
|
325
|
+
return {
|
|
326
|
+
artifact: await finalize({
|
|
327
|
+
ok: false,
|
|
328
|
+
reason_code: "no_matches",
|
|
329
|
+
error: `No matches found for plan ${plan.plan_id}`,
|
|
330
|
+
target_file: plan.target.file,
|
|
331
|
+
expected_file_hash: plan.target.file_hash,
|
|
332
|
+
current_file_hash: currentFileHash,
|
|
333
|
+
matched_count: 0,
|
|
334
|
+
affected_file_count: 0,
|
|
335
|
+
changed_ranges: [],
|
|
336
|
+
diff_summary: "",
|
|
337
|
+
diff_preview: "",
|
|
338
|
+
promotable: false,
|
|
339
|
+
}),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
const affectedFiles = planScopeFromMatches(locateResult.matches);
|
|
343
|
+
const targetPlan = planAstgrepRewriteTargets(affectedFiles);
|
|
344
|
+
if (!targetPlan.ok) {
|
|
345
|
+
return {
|
|
346
|
+
artifact: await finalize({
|
|
347
|
+
ok: false,
|
|
348
|
+
reason_code: "ambiguous_multi_file",
|
|
349
|
+
error: targetPlan.error ?? "astgrep_rewrite refused a multi-file preview",
|
|
350
|
+
target_file: plan.target.file,
|
|
351
|
+
expected_file_hash: plan.target.file_hash,
|
|
352
|
+
current_file_hash: currentFileHash,
|
|
353
|
+
matched_count: locateResult.matches.length,
|
|
354
|
+
affected_file_count: affectedFiles.length,
|
|
355
|
+
changed_ranges: [],
|
|
356
|
+
diff_summary: "",
|
|
357
|
+
diff_preview: "",
|
|
358
|
+
promotable: false,
|
|
359
|
+
}),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const targetMatches = locateResult.matches.filter((match) => match.file === plan.target.file);
|
|
363
|
+
if (targetMatches.length === 0) {
|
|
364
|
+
return {
|
|
365
|
+
artifact: await finalize({
|
|
366
|
+
ok: false,
|
|
367
|
+
reason_code: "target_mismatch",
|
|
368
|
+
error: `Plan ${plan.plan_id} no longer resolves ${plan.target.file}`,
|
|
369
|
+
target_file: plan.target.file,
|
|
370
|
+
expected_file_hash: plan.target.file_hash,
|
|
371
|
+
current_file_hash: currentFileHash,
|
|
372
|
+
matched_count: locateResult.matches.length,
|
|
373
|
+
affected_file_count: affectedFiles.length,
|
|
374
|
+
changed_ranges: [],
|
|
375
|
+
diff_summary: "",
|
|
376
|
+
diff_preview: "",
|
|
377
|
+
promotable: false,
|
|
378
|
+
}),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const astgrepCmd = detectAstgrepCommand();
|
|
382
|
+
if (!astgrepCmd) {
|
|
383
|
+
return {
|
|
384
|
+
artifact: await finalize({
|
|
385
|
+
ok: false,
|
|
386
|
+
reason_code: "astgrep_unavailable",
|
|
387
|
+
error: "ast-grep command not available",
|
|
388
|
+
target_file: plan.target.file,
|
|
389
|
+
expected_file_hash: plan.target.file_hash,
|
|
390
|
+
current_file_hash: currentFileHash,
|
|
391
|
+
matched_count: locateResult.matches.length,
|
|
392
|
+
affected_file_count: affectedFiles.length,
|
|
393
|
+
changed_ranges: [],
|
|
394
|
+
diff_summary: "",
|
|
395
|
+
diff_preview: "",
|
|
396
|
+
promotable: false,
|
|
397
|
+
}),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const stagingDir = wsPath(".ace-staging", `structural-edit-${plan.plan_id}-${Date.now()}`);
|
|
401
|
+
const stagedOriginal = resolve(stagingDir, `original__${plan.target.file.replace(/\//g, "__")}`);
|
|
402
|
+
const stagedRewrite = resolve(stagingDir, `rewrite__${plan.target.file.replace(/\//g, "__")}`);
|
|
403
|
+
mkdirSync(dirname(stagedOriginal), { recursive: true });
|
|
404
|
+
writeFileSync(stagedOriginal, currentContent, "utf-8");
|
|
405
|
+
writeFileSync(stagedRewrite, currentContent, "utf-8");
|
|
406
|
+
const rewriteResult = spawnSync(astgrepCmd, ["--pattern", plan.locator.pattern, "--rewrite", plan.rewrite.rewrite_template, "--lang", plan.locator.lang, stagedRewrite, "--update-all"], { encoding: "utf8", cwd: WORKSPACE_ROOT });
|
|
407
|
+
if (rewriteResult.status !== 0) {
|
|
408
|
+
return {
|
|
409
|
+
artifact: await finalize({
|
|
410
|
+
ok: false,
|
|
411
|
+
reason_code: "rewrite_failed",
|
|
412
|
+
error: rewriteResult.stderr || rewriteResult.stdout || "ast-grep rewrite failed",
|
|
413
|
+
target_file: plan.target.file,
|
|
414
|
+
expected_file_hash: plan.target.file_hash,
|
|
415
|
+
current_file_hash: currentFileHash,
|
|
416
|
+
matched_count: locateResult.matches.length,
|
|
417
|
+
affected_file_count: affectedFiles.length,
|
|
418
|
+
changed_ranges: [],
|
|
419
|
+
diff_summary: "",
|
|
420
|
+
diff_preview: "",
|
|
421
|
+
promotable: false,
|
|
422
|
+
staging_path: stagingDir,
|
|
423
|
+
}),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const rewrittenContent = readFileSync(stagedRewrite, "utf-8");
|
|
427
|
+
const rewriteHash = contentSha256(rewrittenContent);
|
|
428
|
+
const diff = diffContents(currentContent, rewrittenContent);
|
|
429
|
+
const diffProcess = spawnSync("diff", ["-u", "--label", `a/${plan.target.file}`, "--label", `b/${plan.target.file}`, stagedOriginal, stagedRewrite], { encoding: "utf8", cwd: WORKSPACE_ROOT });
|
|
430
|
+
const diffText = diffProcess.status === 1
|
|
431
|
+
? diffProcess.stdout
|
|
432
|
+
: diffProcess.status === 0
|
|
433
|
+
? ""
|
|
434
|
+
: diff.diff_summary;
|
|
435
|
+
return {
|
|
436
|
+
artifact: await finalize({
|
|
437
|
+
ok: true,
|
|
438
|
+
target_file: plan.target.file,
|
|
439
|
+
expected_file_hash: plan.target.file_hash,
|
|
440
|
+
current_file_hash: currentFileHash,
|
|
441
|
+
rewritten_file_hash: rewriteHash,
|
|
442
|
+
matched_count: targetMatches.length,
|
|
443
|
+
affected_file_count: affectedFiles.length,
|
|
444
|
+
changed_ranges: parseChangedRanges(diffText),
|
|
445
|
+
diff_summary: diff.diff_summary,
|
|
446
|
+
diff_preview: diffText ? previewDiffText(diffText) : diff.diff_summary,
|
|
447
|
+
promotable: diff.has_diff,
|
|
448
|
+
staging_path: stagingDir,
|
|
449
|
+
}),
|
|
450
|
+
rewritten_content: rewrittenContent,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
53
453
|
async function resolveReadFileLinesBudget() {
|
|
54
454
|
let modelClass;
|
|
55
455
|
const statuses = await withLocalModelRuntimeRepository(WORKSPACE_ROOT, async (repo) => repo.listRuntimeStatuses()).catch(() => undefined);
|
|
@@ -82,9 +482,62 @@ export function registerFileTools(server) {
|
|
|
82
482
|
server.tool("write_workspace_file", "Write or update a workspace file. Prefer apply_patch for targeted edits, astgrep_rewrite for structural rewrites. Cost: heavy.", {
|
|
83
483
|
path: z.string().describe("Relative path from workspace root"),
|
|
84
484
|
content: z.string().describe("File content"),
|
|
85
|
-
|
|
485
|
+
workspace_path: z
|
|
486
|
+
.string()
|
|
487
|
+
.optional()
|
|
488
|
+
.describe("Required workspace root for model/runtime writes"),
|
|
489
|
+
}, async ({ path, content, workspace_path }) => {
|
|
490
|
+
if (!workspace_path?.trim()) {
|
|
491
|
+
const message = "write_workspace_file requires workspace_path for workspace-root write safety.";
|
|
492
|
+
await appendWriteWorkspaceRejection({
|
|
493
|
+
reason_code: "missing_workspace_path",
|
|
494
|
+
message,
|
|
495
|
+
path,
|
|
496
|
+
});
|
|
497
|
+
return {
|
|
498
|
+
isError: true,
|
|
499
|
+
content: [
|
|
500
|
+
{
|
|
501
|
+
type: "text",
|
|
502
|
+
text: JSON.stringify({
|
|
503
|
+
ok: false,
|
|
504
|
+
reason_code: "missing_workspace_path",
|
|
505
|
+
message,
|
|
506
|
+
}, null, 2),
|
|
507
|
+
},
|
|
508
|
+
],
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
const workspaceRoot = resolve(workspace_path);
|
|
86
512
|
const normalizedPath = normalizePathForValidation(path);
|
|
87
513
|
const validationNotes = [];
|
|
514
|
+
let targetPath;
|
|
515
|
+
try {
|
|
516
|
+
targetPath = resolveWorkspaceWritePath(path, workspaceRoot);
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
const reasonCode = reasonCodeFromError(error) ?? "path_escape";
|
|
520
|
+
const message = errorMessage(error);
|
|
521
|
+
await appendWriteWorkspaceRejection({
|
|
522
|
+
reason_code: reasonCode,
|
|
523
|
+
message,
|
|
524
|
+
path,
|
|
525
|
+
workspace_path,
|
|
526
|
+
});
|
|
527
|
+
return {
|
|
528
|
+
isError: true,
|
|
529
|
+
content: [
|
|
530
|
+
{
|
|
531
|
+
type: "text",
|
|
532
|
+
text: JSON.stringify({
|
|
533
|
+
ok: false,
|
|
534
|
+
reason_code: reasonCode,
|
|
535
|
+
message,
|
|
536
|
+
}, null, 2),
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
};
|
|
540
|
+
}
|
|
88
541
|
// ── Append-only guard ───────────────────────────────────────────
|
|
89
542
|
if (APPEND_ONLY_PATHS.has(normalizedPath)) {
|
|
90
543
|
const existing = safeRead(path);
|
|
@@ -524,9 +977,20 @@ export function registerFileTools(server) {
|
|
|
524
977
|
todoStateSuffix = `\nTODO state synced: ${synced.path}`;
|
|
525
978
|
}
|
|
526
979
|
const storeFallbackKeys = resolveStoreFallbackKeysForPath(normalizedPath);
|
|
527
|
-
const useAsyncStoreAdmission = storeFallbackKeys.length > 0;
|
|
980
|
+
const useAsyncStoreAdmission = storeFallbackKeys.length > 0 && workspaceRoot === WORKSPACE_ROOT;
|
|
528
981
|
// Keep the file as a materialized projection after the canonical store update.
|
|
529
|
-
|
|
982
|
+
let abs;
|
|
983
|
+
if (useAsyncStoreAdmission) {
|
|
984
|
+
abs = await safeWriteAsync(path, content);
|
|
985
|
+
}
|
|
986
|
+
else if (workspaceRoot === WORKSPACE_ROOT) {
|
|
987
|
+
abs = safeWrite(path, content);
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
991
|
+
writeFileSync(targetPath, content, "utf-8");
|
|
992
|
+
abs = targetPath;
|
|
993
|
+
}
|
|
530
994
|
let kanbanSuffix = "";
|
|
531
995
|
const shouldRefreshKanban = shouldAutoRefreshKanbanForPath(normalizedPath);
|
|
532
996
|
if (shouldRefreshKanban) {
|
|
@@ -814,7 +1278,6 @@ export function registerFileTools(server) {
|
|
|
814
1278
|
scope: z.string().optional().describe("Workspace-relative directory to search (default: src)"),
|
|
815
1279
|
max_results: z.number().int().min(1).max(200).optional().describe("Max results to return (default: 50)"),
|
|
816
1280
|
}, async ({ pattern, lang, scope, max_results }) => {
|
|
817
|
-
const { runAstgrepQuery } = await import("./astgrep-index.js");
|
|
818
1281
|
const root = wsPath(scope ?? "src");
|
|
819
1282
|
const matches = runAstgrepQuery(pattern, lang, [root]);
|
|
820
1283
|
const limited = matches.slice(0, max_results ?? 50);
|
|
@@ -831,110 +1294,349 @@ export function registerFileTools(server) {
|
|
|
831
1294
|
}],
|
|
832
1295
|
};
|
|
833
1296
|
});
|
|
834
|
-
server.tool("
|
|
1297
|
+
server.tool("astgrep_locate", "Locate structural matches and persist a reusable locator artifact with match IDs, ranges, previews, captures, and file hashes. Cost: cheap.", {
|
|
835
1298
|
pattern: z.string().describe("ast-grep pattern to match"),
|
|
836
|
-
rewrite: z.string().describe("Replacement template (use $NAME etc. from pattern)"),
|
|
837
1299
|
lang: z.string().describe("Language: ts, py, rust, go, js"),
|
|
838
1300
|
scope: z.string().optional().describe("Workspace-relative directory (default: src)"),
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1301
|
+
symbol_hint: z.string().optional().describe("Optional symbol name or text hint used to narrow or prioritize matches"),
|
|
1302
|
+
max_results: z.number().int().min(1).max(200).optional().describe("Max results to persist/return (default: 50)"),
|
|
1303
|
+
}, async ({ pattern, lang, scope, symbol_hint, max_results }) => {
|
|
1304
|
+
const locatorInput = {
|
|
1305
|
+
pattern,
|
|
1306
|
+
lang,
|
|
1307
|
+
scope: scope ?? "src",
|
|
1308
|
+
symbol_hint,
|
|
1309
|
+
max_results,
|
|
1310
|
+
};
|
|
1311
|
+
try {
|
|
1312
|
+
const result = locateAstgrepMatches(locatorInput);
|
|
1313
|
+
const artifact = await storeLocateArtifact(locatorInput, result);
|
|
1314
|
+
return jsonResponse({
|
|
1315
|
+
ok: result.ok,
|
|
1316
|
+
locator_id: artifact.locator_id,
|
|
1317
|
+
artifact_path: artifact.artifact_path,
|
|
1318
|
+
pattern,
|
|
1319
|
+
lang,
|
|
1320
|
+
scope: locatorInput.scope,
|
|
1321
|
+
symbol_hint,
|
|
1322
|
+
total_matches: artifact.total_matches,
|
|
1323
|
+
matches: artifact.matches.map(({ matched_text: _matchedText, ...match }) => match),
|
|
1324
|
+
error: result.error,
|
|
1325
|
+
}, !result.ok);
|
|
851
1326
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1327
|
+
catch (error) {
|
|
1328
|
+
return jsonResponse({
|
|
1329
|
+
ok: false,
|
|
1330
|
+
reason_code: "scope_escape",
|
|
1331
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1332
|
+
pattern,
|
|
1333
|
+
lang,
|
|
1334
|
+
scope: locatorInput.scope,
|
|
1335
|
+
}, true);
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
server.tool("compile_structural_edit", "Compile a one-file structural edit plan from a selected match ID or locator input and persist the plan artifact without touching files. Cost: moderate.", {
|
|
1339
|
+
match_id: z.string().optional().describe("Previously returned astgrep_locate match ID"),
|
|
1340
|
+
pattern: z.string().optional().describe("ast-grep pattern to locate when no match_id is provided"),
|
|
1341
|
+
lang: z.string().optional().describe("Language: ts, py, rust, go, js when using locator input"),
|
|
1342
|
+
scope: z.string().optional().describe("Workspace-relative directory (default: src) when using locator input"),
|
|
1343
|
+
symbol_hint: z.string().optional().describe("Optional symbol name or text hint for locator input"),
|
|
1344
|
+
max_results: z.number().int().min(1).max(200).optional().describe("Max locator matches to keep (default: 50)"),
|
|
1345
|
+
desired_change: z.string().describe("Plain-language edit intent; used as rewrite_template when no explicit template is provided"),
|
|
1346
|
+
rewrite_template: z.string().optional().describe("Explicit ast-grep rewrite template; preferred when provided"),
|
|
1347
|
+
validation_command: z.string().optional().describe("Shell command to validate before promotion"),
|
|
1348
|
+
test_command: z.string().optional().describe("Shell command to run before promotion"),
|
|
1349
|
+
}, async ({ match_id, pattern, lang, scope, symbol_hint, max_results, desired_change, rewrite_template, validation_command, test_command, }) => {
|
|
1350
|
+
if (!match_id && (!pattern || !lang)) {
|
|
1351
|
+
return jsonResponse({
|
|
1352
|
+
ok: false,
|
|
1353
|
+
reason_code: "locator_required",
|
|
1354
|
+
error: "compile_structural_edit requires either match_id or pattern+lang locator input",
|
|
1355
|
+
}, true);
|
|
1356
|
+
}
|
|
1357
|
+
let locateArtifact;
|
|
1358
|
+
let selectedMatch;
|
|
1359
|
+
let locator;
|
|
1360
|
+
if (match_id) {
|
|
1361
|
+
const stored = findStoredMatch(match_id);
|
|
1362
|
+
if (!stored) {
|
|
1363
|
+
return jsonResponse({
|
|
1364
|
+
ok: false,
|
|
1365
|
+
reason_code: "match_not_found",
|
|
1366
|
+
error: `No persisted locator match found for ${match_id}`,
|
|
1367
|
+
match_id,
|
|
1368
|
+
}, true);
|
|
1369
|
+
}
|
|
1370
|
+
const narrowedMatches = stored.artifact.matches.filter((match) => match.file === stored.match.file);
|
|
1371
|
+
locator = {
|
|
1372
|
+
...stored.artifact.input,
|
|
1373
|
+
scope: stored.match.file,
|
|
863
1374
|
};
|
|
1375
|
+
locateArtifact = {
|
|
1376
|
+
...stored.artifact,
|
|
1377
|
+
input: locator,
|
|
1378
|
+
matches: narrowedMatches,
|
|
1379
|
+
total_matches: narrowedMatches.length,
|
|
1380
|
+
};
|
|
1381
|
+
selectedMatch = stored.match;
|
|
864
1382
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1383
|
+
else {
|
|
1384
|
+
locator = {
|
|
1385
|
+
pattern: pattern,
|
|
1386
|
+
lang: lang,
|
|
1387
|
+
scope: scope ?? "src",
|
|
1388
|
+
symbol_hint,
|
|
1389
|
+
max_results,
|
|
868
1390
|
};
|
|
1391
|
+
try {
|
|
1392
|
+
const locateResult = locateAstgrepMatches(locator);
|
|
1393
|
+
locateArtifact = await storeLocateArtifact(locator, locateResult);
|
|
1394
|
+
if (!locateResult.ok) {
|
|
1395
|
+
return jsonResponse({
|
|
1396
|
+
ok: false,
|
|
1397
|
+
reason_code: "locate_failed",
|
|
1398
|
+
error: locateResult.error ?? "Failed to locate structural matches",
|
|
1399
|
+
locator_id: locateArtifact.locator_id,
|
|
1400
|
+
artifact_path: locateArtifact.artifact_path,
|
|
1401
|
+
}, true);
|
|
1402
|
+
}
|
|
1403
|
+
if (locateArtifact.matches.length === 0) {
|
|
1404
|
+
return jsonResponse({
|
|
1405
|
+
ok: false,
|
|
1406
|
+
reason_code: "no_matches",
|
|
1407
|
+
error: "No matches found for locator input",
|
|
1408
|
+
locator_id: locateArtifact.locator_id,
|
|
1409
|
+
artifact_path: locateArtifact.artifact_path,
|
|
1410
|
+
}, true);
|
|
1411
|
+
}
|
|
1412
|
+
selectedMatch = locateArtifact.matches[0];
|
|
1413
|
+
}
|
|
1414
|
+
catch (error) {
|
|
1415
|
+
return jsonResponse({
|
|
1416
|
+
ok: false,
|
|
1417
|
+
reason_code: "scope_escape",
|
|
1418
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1419
|
+
}, true);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
if (!locateArtifact) {
|
|
1423
|
+
return jsonResponse({
|
|
1424
|
+
ok: false,
|
|
1425
|
+
reason_code: "locator_missing",
|
|
1426
|
+
error: "Structural locator artifact was not available",
|
|
1427
|
+
}, true);
|
|
1428
|
+
}
|
|
1429
|
+
const scopedMatches = selectedMatch
|
|
1430
|
+
? locateArtifact.matches.filter((match) => match.file === selectedMatch?.file)
|
|
1431
|
+
: locateArtifact.matches;
|
|
1432
|
+
const scopeFiles = planScopeFromMatches(locateArtifact.matches);
|
|
1433
|
+
if (scopeFiles.length > 1) {
|
|
1434
|
+
return jsonResponse({
|
|
1435
|
+
ok: false,
|
|
1436
|
+
reason_code: "ambiguous_multi_file",
|
|
1437
|
+
error: `compile_structural_edit refuses multi-file locators (${scopeFiles.length} files)`,
|
|
1438
|
+
locator_id: locateArtifact.locator_id,
|
|
1439
|
+
artifact_path: locateArtifact.artifact_path,
|
|
1440
|
+
affected_files: scopeFiles,
|
|
1441
|
+
}, true);
|
|
869
1442
|
}
|
|
870
|
-
|
|
871
|
-
|
|
1443
|
+
if (scopedMatches.length === 0) {
|
|
1444
|
+
return jsonResponse({
|
|
1445
|
+
ok: false,
|
|
1446
|
+
reason_code: "no_matches",
|
|
1447
|
+
error: "No matches remained in the selected file for plan compilation",
|
|
1448
|
+
locator_id: locateArtifact.locator_id,
|
|
1449
|
+
artifact_path: locateArtifact.artifact_path,
|
|
1450
|
+
}, true);
|
|
1451
|
+
}
|
|
1452
|
+
const plan = await compileStructuralEditPlan({
|
|
1453
|
+
locator,
|
|
1454
|
+
locateArtifact,
|
|
1455
|
+
selectedMatch,
|
|
1456
|
+
desiredChange: desired_change,
|
|
1457
|
+
rewriteTemplate: rewrite_template,
|
|
1458
|
+
validationCommand: validation_command,
|
|
1459
|
+
testCommand: test_command,
|
|
1460
|
+
}).catch((error) => {
|
|
1461
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
872
1462
|
return {
|
|
873
|
-
|
|
874
|
-
|
|
1463
|
+
error: message,
|
|
1464
|
+
reason_code: message.startsWith("bad_capture:") ? "bad_capture" : "plan_compile_failed",
|
|
875
1465
|
};
|
|
1466
|
+
});
|
|
1467
|
+
if ("error" in plan) {
|
|
1468
|
+
return jsonResponse({
|
|
1469
|
+
ok: false,
|
|
1470
|
+
reason_code: plan.reason_code,
|
|
1471
|
+
error: plan.error,
|
|
1472
|
+
locator_id: locateArtifact.locator_id,
|
|
1473
|
+
artifact_path: locateArtifact.artifact_path,
|
|
1474
|
+
}, true);
|
|
876
1475
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1476
|
+
return jsonResponse(plan);
|
|
1477
|
+
});
|
|
1478
|
+
server.tool("preview_structural_edit", "Stage a compiled structural edit plan, return bounded diff and changed ranges, and refuse stale-hash or multi-file previews. Cost: heavy.", {
|
|
1479
|
+
plan_id: z.string().describe("Compiled structural edit plan id"),
|
|
1480
|
+
}, async ({ plan_id }) => {
|
|
1481
|
+
const plan = loadStructuralEditPlan(plan_id);
|
|
1482
|
+
if (!plan) {
|
|
1483
|
+
return jsonResponse({
|
|
1484
|
+
ok: false,
|
|
1485
|
+
reason_code: "plan_not_found",
|
|
1486
|
+
error: `No structural edit plan found for ${plan_id}`,
|
|
1487
|
+
plan_id,
|
|
1488
|
+
}, true);
|
|
1489
|
+
}
|
|
1490
|
+
const preview = (await buildStructuralPreview(plan)).artifact;
|
|
1491
|
+
return jsonResponse(preview, !preview.ok);
|
|
1492
|
+
});
|
|
1493
|
+
server.tool("astgrep_rewrite", "Apply a structural rewrite to one workspace file using a compiled plan_id or direct ast-grep pattern + replacement. Multi-file rewrites are refused; staged through safe_edit_file. Emits transition-compatible result details. Cost: heavy.", {
|
|
1494
|
+
plan_id: z.string().optional().describe("Compiled structural edit plan id; preferred over direct pattern+rewrite"),
|
|
1495
|
+
pattern: z.string().optional().describe("ast-grep pattern to match"),
|
|
1496
|
+
rewrite: z.string().optional().describe("Replacement template (use $NAME etc. from pattern)"),
|
|
1497
|
+
lang: z.string().optional().describe("Language: ts, py, rust, go, js"),
|
|
1498
|
+
scope: z.string().optional().describe("Workspace-relative directory (default: src)"),
|
|
1499
|
+
confirm_multi_file: z.boolean().optional().describe("Deprecated; multi-file rewrites are always refused"),
|
|
1500
|
+
validation_command: z.string().optional().describe("Shell command to validate the staged rewrite before promotion"),
|
|
1501
|
+
test_command: z.string().optional().describe("Shell command to test the staged rewrite before promotion"),
|
|
1502
|
+
session_id: z.string().optional().describe("Optional runtime session id for transition-record emission"),
|
|
1503
|
+
}, async ({ plan_id, pattern, rewrite, lang, scope, validation_command, test_command, session_id }) => {
|
|
1504
|
+
let compiledPlan;
|
|
1505
|
+
if (plan_id) {
|
|
1506
|
+
compiledPlan = loadStructuralEditPlan(plan_id);
|
|
1507
|
+
if (!compiledPlan) {
|
|
1508
|
+
return {
|
|
1509
|
+
content: [{ type: "text", text: `astgrep_rewrite failed: no structural edit plan found for ${plan_id}` }],
|
|
1510
|
+
isError: true,
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
else {
|
|
1515
|
+
if (!pattern || !rewrite || !lang) {
|
|
1516
|
+
return {
|
|
1517
|
+
content: [{ type: "text", text: "astgrep_rewrite requires plan_id or direct pattern+rewrite+lang input." }],
|
|
1518
|
+
isError: true,
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
const locatorInput = {
|
|
1522
|
+
pattern,
|
|
1523
|
+
lang,
|
|
1524
|
+
scope: scope ?? "src",
|
|
1525
|
+
max_results: 200,
|
|
882
1526
|
};
|
|
1527
|
+
let locateResult;
|
|
1528
|
+
try {
|
|
1529
|
+
locateResult = locateAstgrepMatches(locatorInput);
|
|
1530
|
+
}
|
|
1531
|
+
catch {
|
|
1532
|
+
return {
|
|
1533
|
+
content: [{ type: "text", text: `astgrep_rewrite failed: scope escapes workspace root (${scope})` }],
|
|
1534
|
+
isError: true,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
const locateArtifact = await storeLocateArtifact(locatorInput, locateResult);
|
|
1538
|
+
if (!locateResult.ok) {
|
|
1539
|
+
return {
|
|
1540
|
+
content: [{ type: "text", text: `astgrep_rewrite failed: ${locateResult.error ?? "locator failed"}` }],
|
|
1541
|
+
isError: true,
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
if (locateArtifact.matches.length === 0) {
|
|
1545
|
+
return {
|
|
1546
|
+
content: [{ type: "text", text: "No matches found for pattern. No files modified." }],
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
const scopeFiles = planScopeFromMatches(locateArtifact.matches);
|
|
1550
|
+
const targetPlan = planAstgrepRewriteTargets(scopeFiles);
|
|
1551
|
+
if (!targetPlan.ok) {
|
|
1552
|
+
await appendAstgrepRewriteTransition(session_id, "refused", targetPlan.error ?? "astgrep_rewrite refused the requested rewrite", [locateArtifact.artifact_path, ...targetPlan.affected_files]);
|
|
1553
|
+
return {
|
|
1554
|
+
content: [{
|
|
1555
|
+
type: "text",
|
|
1556
|
+
text: targetPlan.error ?? "astgrep_rewrite refused the requested rewrite",
|
|
1557
|
+
}],
|
|
1558
|
+
isError: true,
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
const directPlan = await compileStructuralEditPlan({
|
|
1562
|
+
locator: locatorInput,
|
|
1563
|
+
locateArtifact,
|
|
1564
|
+
selectedMatch: locateArtifact.matches[0],
|
|
1565
|
+
desiredChange: rewrite,
|
|
1566
|
+
rewriteTemplate: rewrite,
|
|
1567
|
+
validationCommand: validation_command,
|
|
1568
|
+
testCommand: test_command,
|
|
1569
|
+
}).catch((error) => ({
|
|
1570
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1571
|
+
}));
|
|
1572
|
+
if ("error" in directPlan) {
|
|
1573
|
+
return {
|
|
1574
|
+
content: [{ type: "text", text: `astgrep_rewrite failed: ${directPlan.error}` }],
|
|
1575
|
+
isError: true,
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
compiledPlan = directPlan;
|
|
883
1579
|
}
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
writeFileSync(stagedFile, originalContent, "utf-8");
|
|
888
|
-
const { spawnSync } = await import("node:child_process");
|
|
889
|
-
const result = spawnSync("ast-grep", ["--pattern", pattern, "--rewrite", rewrite, "--lang", lang, stagedFile, "--update-all"], { encoding: "utf8", cwd: WORKSPACE_ROOT });
|
|
890
|
-
if (result.status !== 0) {
|
|
891
|
-
await appendAstgrepRewriteTransition(session_id, "failed", `astgrep_rewrite failed in staging: ${result.stderr || result.stdout || "unknown error"}`, [targetRel]);
|
|
1580
|
+
const preview = await buildStructuralPreview(compiledPlan);
|
|
1581
|
+
if (!preview.artifact.ok || !preview.artifact.promotable || !preview.rewritten_content) {
|
|
1582
|
+
await appendAstgrepRewriteTransition(session_id, "refused", preview.artifact.error ?? "astgrep_rewrite preview refused promotion", [compiledPlan.artifact_path, preview.artifact.artifact_path, compiledPlan.target.file]);
|
|
892
1583
|
return {
|
|
893
|
-
content: [{
|
|
1584
|
+
content: [{
|
|
1585
|
+
type: "text",
|
|
1586
|
+
text: [
|
|
1587
|
+
"astgrep_rewrite failed before promotion",
|
|
1588
|
+
`Plan: ${compiledPlan.artifact_path}`,
|
|
1589
|
+
`Preview: ${preview.artifact.artifact_path}`,
|
|
1590
|
+
`Path: ${compiledPlan.target.file}`,
|
|
1591
|
+
`Reason: ${preview.artifact.error ?? "preview was not promotable"}`,
|
|
1592
|
+
].join("\n"),
|
|
1593
|
+
}],
|
|
894
1594
|
isError: true,
|
|
895
1595
|
};
|
|
896
1596
|
}
|
|
897
|
-
const rewrittenContent = readFileSync(stagedFile, "utf-8");
|
|
898
1597
|
const safeResult = safeEditFile({
|
|
899
|
-
path:
|
|
900
|
-
content:
|
|
901
|
-
validation_command,
|
|
902
|
-
test_command,
|
|
1598
|
+
path: compiledPlan.target.file,
|
|
1599
|
+
content: preview.rewritten_content,
|
|
1600
|
+
validation_command: validation_command ?? compiledPlan.validation_command,
|
|
1601
|
+
test_command: test_command ?? compiledPlan.test_command,
|
|
903
1602
|
});
|
|
904
1603
|
if (!safeResult.ok) {
|
|
905
|
-
await appendAstgrepRewriteTransition(session_id, "failed", `astgrep_rewrite failed before promotion for ${
|
|
1604
|
+
await appendAstgrepRewriteTransition(session_id, "failed", `astgrep_rewrite failed before promotion for ${compiledPlan.target.file}: ${safeResult.error ?? "unknown error"}`, [compiledPlan.artifact_path, preview.artifact.artifact_path, compiledPlan.target.file]);
|
|
906
1605
|
return {
|
|
907
1606
|
content: [{
|
|
908
1607
|
type: "text",
|
|
909
1608
|
text: [
|
|
910
1609
|
`astgrep_rewrite failed before promotion`,
|
|
911
|
-
`
|
|
1610
|
+
`Plan: ${compiledPlan.artifact_path}`,
|
|
1611
|
+
`Preview: ${preview.artifact.artifact_path}`,
|
|
1612
|
+
`Path: ${compiledPlan.target.file}`,
|
|
912
1613
|
`Error: ${safeResult.error ?? "unknown error"}`,
|
|
913
1614
|
safeResult.validation_passed !== undefined ? `Validation: ${safeResult.validation_passed ? "passed" : "FAILED"}` : "Validation: not requested",
|
|
914
1615
|
safeResult.validation_output ? `Validation output:\n${safeResult.validation_output}` : "",
|
|
915
1616
|
safeResult.test_passed !== undefined ? `Tests: ${safeResult.test_passed ? "passed" : "FAILED"}` : "Tests: not requested",
|
|
916
1617
|
safeResult.test_output ? `Test output:\n${safeResult.test_output}` : "",
|
|
917
|
-
`Staging: ${safeResult.staging_path ||
|
|
1618
|
+
`Staging: ${safeResult.staging_path || preview.artifact.staging_path || ""}`,
|
|
918
1619
|
].filter(Boolean).join("\n"),
|
|
919
1620
|
}],
|
|
920
1621
|
isError: true,
|
|
921
1622
|
};
|
|
922
1623
|
}
|
|
923
|
-
await appendAstgrepRewriteTransition(session_id, "promoted", `astgrep_rewrite promoted staged rewrite for ${
|
|
1624
|
+
await appendAstgrepRewriteTransition(session_id, "promoted", `astgrep_rewrite promoted staged rewrite for ${compiledPlan.target.file}`, [compiledPlan.artifact_path, preview.artifact.artifact_path, compiledPlan.target.file]);
|
|
924
1625
|
return {
|
|
925
1626
|
content: [{
|
|
926
1627
|
type: "text",
|
|
927
1628
|
text: [
|
|
928
1629
|
`# astgrep_rewrite completed`,
|
|
929
|
-
`
|
|
930
|
-
`
|
|
931
|
-
`
|
|
932
|
-
`
|
|
1630
|
+
`Plan: ${compiledPlan.artifact_path}`,
|
|
1631
|
+
`Preview: ${preview.artifact.artifact_path}`,
|
|
1632
|
+
`Pattern: ${compiledPlan.locator.pattern}`,
|
|
1633
|
+
`Rewrite: ${compiledPlan.rewrite.rewrite_template}`,
|
|
1634
|
+
`Files affected: ${preview.artifact.affected_file_count}`,
|
|
1635
|
+
`Path: ${compiledPlan.target.file}`,
|
|
933
1636
|
`Hash: ${safeResult.original_hash} → ${safeResult.new_hash}`,
|
|
934
1637
|
safeResult.validation_passed !== undefined ? `Validation: ${safeResult.validation_passed ? "passed" : "failed"}` : "Validation: not requested",
|
|
935
1638
|
safeResult.test_passed !== undefined ? `Tests: ${safeResult.test_passed ? "passed" : "failed"}` : "Tests: not requested",
|
|
936
1639
|
`Staging: ${safeResult.staging_path}`,
|
|
937
|
-
result.stdout ? `\nOutput:\n${result.stdout}` : "",
|
|
938
1640
|
].filter(Boolean).join("\n"),
|
|
939
1641
|
}],
|
|
940
1642
|
};
|