@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -0
  3. package/assets/.agents/ACE/agent-qa/instructions.md +11 -0
  4. package/assets/agent-state/MODULES/schemas/RUNTIME_TOOL_SPEC_REGISTRY.schema.json +43 -0
  5. package/assets/agent-state/runtime-tool-specs.json +70 -2
  6. package/assets/instructions/ACE_Coder.instructions.md +13 -0
  7. package/assets/instructions/ACE_UI.instructions.md +11 -0
  8. package/dist/ace-context.js +70 -11
  9. package/dist/ace-internal-tools.d.ts +3 -1
  10. package/dist/ace-internal-tools.js +10 -2
  11. package/dist/agent-runtime/role-adapters.d.ts +18 -1
  12. package/dist/agent-runtime/role-adapters.js +49 -5
  13. package/dist/astgrep-index.d.ts +48 -0
  14. package/dist/astgrep-index.js +126 -1
  15. package/dist/cli.js +205 -15
  16. package/dist/discovery-runtime-wrappers.d.ts +108 -0
  17. package/dist/discovery-runtime-wrappers.js +615 -0
  18. package/dist/helpers/bootstrap.js +1 -1
  19. package/dist/helpers/constants.d.ts +2 -2
  20. package/dist/helpers/constants.js +7 -0
  21. package/dist/helpers/path-utils.d.ts +8 -1
  22. package/dist/helpers/path-utils.js +27 -8
  23. package/dist/helpers/store-resolution.js +7 -3
  24. package/dist/job-scheduler.js +30 -4
  25. package/dist/json-sanitizer.d.ts +16 -0
  26. package/dist/json-sanitizer.js +26 -0
  27. package/dist/local-model-policy.d.ts +27 -0
  28. package/dist/local-model-policy.js +84 -0
  29. package/dist/local-model-runtime.d.ts +6 -0
  30. package/dist/local-model-runtime.js +21 -20
  31. package/dist/model-bridge.d.ts +6 -1
  32. package/dist/model-bridge.js +338 -21
  33. package/dist/orchestrator-supervisor.d.ts +42 -0
  34. package/dist/orchestrator-supervisor.js +110 -3
  35. package/dist/plan-proposal.d.ts +115 -0
  36. package/dist/plan-proposal.js +1073 -0
  37. package/dist/runtime-executor.d.ts +6 -1
  38. package/dist/runtime-executor.js +72 -5
  39. package/dist/runtime-tool-specs.d.ts +19 -1
  40. package/dist/runtime-tool-specs.js +67 -26
  41. package/dist/schemas.js +29 -1
  42. package/dist/server.js +51 -0
  43. package/dist/shared.d.ts +1 -0
  44. package/dist/shared.js +2 -0
  45. package/dist/store/bootstrap-store.d.ts +1 -0
  46. package/dist/store/bootstrap-store.js +8 -2
  47. package/dist/store/repositories/local-model-runtime-repository.d.ts +1 -1
  48. package/dist/store/repositories/local-model-runtime-repository.js +1 -1
  49. package/dist/store/repositories/vericify-repository.d.ts +1 -1
  50. package/dist/tools-agent.d.ts +20 -0
  51. package/dist/tools-agent.js +538 -28
  52. package/dist/tools-discovery.js +135 -0
  53. package/dist/tools-files.js +768 -66
  54. package/dist/tools-framework.js +80 -61
  55. package/dist/tui/index.js +10 -1
  56. package/dist/tui/ollama.d.ts +8 -1
  57. package/dist/tui/ollama.js +53 -12
  58. package/dist/tui/openai-compatible.d.ts +13 -0
  59. package/dist/tui/openai-compatible.js +305 -5
  60. package/dist/tui/provider-discovery.d.ts +1 -0
  61. package/dist/tui/provider-discovery.js +35 -11
  62. package/dist/vericify-bridge.d.ts +1 -1
  63. package/package.json +1 -1
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * File operation tool registrations + new safe-edit and diff tools.
3
3
  */
4
- import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
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
- }, async ({ path, content }) => {
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
- const abs = useAsyncStoreAdmission ? await safeWriteAsync(path, content) : safeWrite(path, content);
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("astgrep_rewrite", "Apply a structural rewrite to one workspace file using ast-grep pattern + replacement. Multi-file rewrites are refused; staged through safe_edit_file. Emits transition-compatible result details. Cost: heavy.", {
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
- confirm_multi_file: z.boolean().optional().describe("Deprecated; multi-file rewrites are always refused"),
840
- validation_command: z.string().optional().describe("Shell command to validate the staged rewrite before promotion"),
841
- test_command: z.string().optional().describe("Shell command to test the staged rewrite before promotion"),
842
- session_id: z.string().optional().describe("Optional runtime session id for transition-record emission"),
843
- }, async ({ pattern, rewrite, lang, scope, validation_command, test_command, session_id }) => {
844
- const { runAstgrepQuery } = await import("./astgrep-index.js");
845
- const root = wsPath(scope ?? "src");
846
- if (!isInside(WORKSPACE_ROOT, root)) {
847
- return {
848
- content: [{ type: "text", text: `astgrep_rewrite failed: scope escapes workspace root (${scope})` }],
849
- isError: true,
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
- const matches = runAstgrepQuery(pattern, lang, [root]);
853
- const affectedFiles = [...new Set(matches.map(m => m.file))];
854
- const plan = planAstgrepRewriteTargets(affectedFiles);
855
- if (!plan.ok) {
856
- await appendAstgrepRewriteTransition(session_id, "refused", plan.error ?? "astgrep_rewrite refused the requested rewrite", plan.affected_files);
857
- return {
858
- content: [{
859
- type: "text",
860
- text: plan.error ?? "astgrep_rewrite refused the requested rewrite",
861
- }],
862
- isError: true,
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
- if (matches.length === 0) {
866
- return {
867
- content: [{ type: "text", text: "No matches found for pattern. No files modified." }],
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
- const targetRel = astgrepFileToWorkspaceRel(plan.target_file ?? "");
871
- if (!targetRel) {
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
- content: [{ type: "text", text: `astgrep_rewrite failed: matched path escapes workspace root (${plan.target_file ?? ""})` }],
874
- isError: true,
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
- const originalContent = safeRead(targetRel);
878
- if (originalContent.startsWith("[FILE NOT FOUND]") || originalContent.startsWith("[ACCESS DENIED]")) {
879
- return {
880
- content: [{ type: "text", text: `astgrep_rewrite failed: cannot read ${targetRel}\n${originalContent}` }],
881
- isError: true,
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 stagingDir = wsPath(".ace-staging", `astgrep-rewrite-${Date.now()}`);
885
- const stagedFile = resolve(stagingDir, targetRel);
886
- mkdirSync(dirname(stagedFile), { recursive: true });
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: [{ type: "text", text: `astgrep rewrite failed:\n${result.stderr}` }],
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: targetRel,
900
- content: rewrittenContent,
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 ${targetRel}: ${safeResult.error ?? "unknown error"}`, [targetRel]);
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
- `Path: ${targetRel}`,
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 || stagingDir}`,
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 ${targetRel}`, [targetRel]);
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
- `Pattern: ${pattern}`,
930
- `Rewrite: ${rewrite}`,
931
- `Files affected: ${affectedFiles.length}`,
932
- `Path: ${targetRel}`,
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
  };