@mammothb/pi-hashline 0.2.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/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mammothb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
15
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
16
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
18
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
package/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Hashline anchoring for pi — content-addressed read/edit with stale-edit
3
+ * protection.
4
+ *
5
+ * To activate, load this extension:
6
+ * pi -e ./index.ts
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+
13
+ import { createEditTool } from "./src/edit";
14
+ import { createGrepTool } from "./src/grep";
15
+ import { injectPrompt } from "./src/prompt";
16
+ import { createReadTool } from "./src/read";
17
+ import { InMemorySnapshotStore } from "./src/snapshots";
18
+ import { createWriteTool } from "./src/write";
19
+
20
+ export * from "./src/format";
21
+ export * from "./src/snapshots";
22
+ export * from "./src/types";
23
+
24
+ export default function (pi: ExtensionAPI) {
25
+ const snapshots = new InMemorySnapshotStore();
26
+
27
+ pi.registerTool(createReadTool(snapshots));
28
+ pi.registerTool(createEditTool(snapshots));
29
+ pi.registerTool(createWriteTool(snapshots));
30
+ pi.registerTool(createGrepTool(snapshots));
31
+
32
+ // Inject the hashline grammar prompt before each agent turn.
33
+ pi.on("context", (event, _ctx) => {
34
+ injectPrompt(event.messages);
35
+ });
36
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@mammothb/pi-hashline",
3
+ "version": "0.2.0",
4
+ "description": "Hashline anchoring for pi — content-addressed read/edit with stale-edit protection",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "license": "MIT",
9
+ "files": [
10
+ "index.ts",
11
+ "src"
12
+ ],
13
+ "pi": {
14
+ "extensions": [
15
+ "./index.ts"
16
+ ]
17
+ },
18
+ "peerDependencies": {
19
+ "@earendil-works/pi-ai": "*",
20
+ "@earendil-works/pi-coding-agent": "*",
21
+ "@earendil-works/pi-tui": "*",
22
+ "typebox": "*"
23
+ },
24
+ "dependencies": {
25
+ "diff": "^8.0.0"
26
+ }
27
+ }
package/src/apply.ts ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Apply a parsed list of {@link Edit}s to a text body.
3
+ *
4
+ * Pure function — no filesystem, no tag validation. Handles insert ordering
5
+ * so that deletes don't shift insert anchors (all anchors are pre-edit line
6
+ * numbers). Deletes are applied back-to-front; inserts within each anchor
7
+ * line preserve order (before-anchor → replacement → after-anchor).
8
+ */
9
+
10
+ import { cloneCursor } from "./tokenizer";
11
+ import type { Anchor, ApplyResult, Edit } from "./types";
12
+
13
+ // ─── Internal types ──────────────────────────────────────────────────
14
+
15
+ type InsertEdit = Extract<Edit, { kind: "insert" }>;
16
+ type DeleteEdit = Extract<Edit, { kind: "delete" }>;
17
+ type AppliedEdit = InsertEdit | DeleteEdit;
18
+
19
+ interface IndexedEdit {
20
+ edit: AppliedEdit;
21
+ idx: number;
22
+ }
23
+
24
+ // ─── Helpers ─────────────────────────────────────────────────────────
25
+
26
+ function isReplacementInsert(
27
+ edit: Edit,
28
+ ): edit is InsertEdit & { mode: "replacement" } {
29
+ return edit.kind === "insert" && edit.mode === "replacement";
30
+ }
31
+
32
+ /** Clone an edit so the applier owns its own copies. */
33
+ function cloneAppliedEdit(edit: AppliedEdit, index: number): AppliedEdit {
34
+ if (edit.kind === "delete") {
35
+ return { ...edit, anchor: { ...edit.anchor }, index };
36
+ }
37
+ return { ...edit, cursor: cloneCursor(edit.cursor), index };
38
+ }
39
+
40
+ /** Collect anchors from an edit for line-bound validation. */
41
+ function getEditAnchors(edit: AppliedEdit): Anchor[] {
42
+ if (edit.kind === "delete") {
43
+ return [edit.anchor];
44
+ }
45
+ if (
46
+ edit.cursor.kind === "before_anchor" ||
47
+ edit.cursor.kind === "after_anchor"
48
+ ) {
49
+ return [edit.cursor.anchor];
50
+ }
51
+ return [];
52
+ }
53
+
54
+ // ─── Validation ──────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Verify every anchored edit points at an existing line.
58
+ * bof/eof inserts are exempt — they don't target a line number.
59
+ */
60
+ function validateLineBounds(edits: AppliedEdit[], totalLines: number): void {
61
+ for (const edit of edits) {
62
+ for (const anchor of getEditAnchors(edit)) {
63
+ if (anchor.line < 1 || anchor.line > totalLines) {
64
+ throw new Error(
65
+ `Line ${anchor.line} does not exist (file has ${totalLines} lines)`,
66
+ );
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ // ─── Insert-at-boundary helpers ──────────────────────────────────────
73
+
74
+ /**
75
+ * Insert lines at the start of the file.
76
+ * Handles the edge case where the file is just `[""]` (empty).
77
+ */
78
+ function insertAtStart(
79
+ fileLines: string[],
80
+ lines: string[],
81
+ ): number | undefined {
82
+ if (lines.length === 0) {
83
+ return undefined;
84
+ }
85
+ if (fileLines.length === 1 && fileLines[0] === "") {
86
+ fileLines.splice(0, 1, ...lines);
87
+ return 1;
88
+ }
89
+ fileLines.splice(0, 0, ...lines);
90
+ return 1;
91
+ }
92
+
93
+ /**
94
+ * Insert lines at the end of the file.
95
+ * Handles the trailing-newline convention (last element is `""`).
96
+ * Returns the first changed line (1-indexed).
97
+ */
98
+ function insertAtEnd(fileLines: string[], lines: string[]): number | undefined {
99
+ if (lines.length === 0) {
100
+ return undefined;
101
+ }
102
+ if (fileLines.length === 1 && fileLines[0] === "") {
103
+ fileLines.splice(0, 1, ...lines);
104
+ return 1;
105
+ }
106
+ // If file ends with `""` (trailing newline), insert before it.
107
+ const insertIdx =
108
+ fileLines.length > 0 && fileLines[fileLines.length - 1] === ""
109
+ ? fileLines.length - 1
110
+ : fileLines.length;
111
+ fileLines.splice(insertIdx, 0, ...lines);
112
+ return insertIdx + 1;
113
+ }
114
+
115
+ // ─── Bucket edits by anchor line ─────────────────────────────────────
116
+
117
+ function bucketByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
118
+ const byLine = new Map<number, IndexedEdit[]>();
119
+ for (const entry of edits) {
120
+ const line =
121
+ entry.edit.kind === "delete"
122
+ ? entry.edit.anchor.line
123
+ : entry.edit.cursor.kind === "before_anchor" ||
124
+ entry.edit.cursor.kind === "after_anchor"
125
+ ? entry.edit.cursor.anchor.line
126
+ : 0; // won't be used (bof/eof are partitioned out)
127
+ const bucket = byLine.get(line);
128
+ if (bucket) bucket.push(entry);
129
+ else byLine.set(line, [entry]);
130
+ }
131
+ return byLine;
132
+ }
133
+
134
+ // ─── Core applier ────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Apply a parsed list of edits to a text body.
138
+ *
139
+ * All line numbers are relative to the pre-edit file. Deletes are applied
140
+ * back-to-front so earlier indices stay valid; inserts within a single anchor
141
+ * line are ordered: before-anchor → replacement → current line (if not
142
+ * deleted) → after-anchor.
143
+ *
144
+ * Throws if a block edit reaches here unresolved (should have been expanded
145
+ * by `resolveBlockEdits` before calling).
146
+ */
147
+ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
148
+ if (edits.length === 0) {
149
+ return { text };
150
+ }
151
+ // Block edits must be resolved before reaching the applier.
152
+ for (const edit of edits) {
153
+ if (edit.kind === "block") {
154
+ throw new Error(
155
+ "internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run).",
156
+ );
157
+ }
158
+ }
159
+
160
+ const fileLines = text.split("\n");
161
+ let firstChangedLine: number | undefined;
162
+
163
+ const track = (line: number) => {
164
+ if (firstChangedLine === undefined || line < firstChangedLine) {
165
+ firstChangedLine = line;
166
+ }
167
+ };
168
+
169
+ // Clone edits so we own the objects.
170
+ const cloned = (edits as readonly AppliedEdit[]).map((edit, index) =>
171
+ cloneAppliedEdit(edit, index),
172
+ );
173
+
174
+ // Partition: bof, eof, anchored.
175
+ const bofLines: string[] = [];
176
+ const eofLines: string[] = [];
177
+ const anchorEdits: IndexedEdit[] = [];
178
+
179
+ for (const edit of cloned) {
180
+ if (edit.kind === "insert" && edit.cursor.kind === "bof") {
181
+ bofLines.push(edit.text);
182
+ } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
183
+ eofLines.push(edit.text);
184
+ } else {
185
+ anchorEdits.push({ edit, idx: edit.index });
186
+ }
187
+ }
188
+
189
+ // Validate anchored edits point at existing lines.
190
+ validateLineBounds(
191
+ anchorEdits.map((e) => e.edit),
192
+ fileLines.length,
193
+ );
194
+
195
+ // Group anchored edits by target line, process back-to-front.
196
+ const byLine = bucketByLine(anchorEdits);
197
+
198
+ for (const line of [...byLine.keys()].sort((a, b) => b - a)) {
199
+ const bucket = byLine.get(line);
200
+ if (!bucket) {
201
+ continue;
202
+ }
203
+ // Stable sort by original edit index within the bucket.
204
+ bucket.sort((a, b) => a.idx - b.idx);
205
+
206
+ const before: string[] = [];
207
+ const replacement: string[] = [];
208
+ const after: string[] = [];
209
+ let deleted = false;
210
+
211
+ for (const { edit } of bucket) {
212
+ if (isReplacementInsert(edit)) {
213
+ replacement.push(edit.text);
214
+ } else if (
215
+ edit.kind === "insert" &&
216
+ edit.cursor.kind === "after_anchor"
217
+ ) {
218
+ after.push(edit.text);
219
+ } else if (edit.kind === "insert") {
220
+ before.push(edit.text);
221
+ } else if (edit.kind === "delete") {
222
+ deleted = true;
223
+ }
224
+ }
225
+
226
+ // No-op bucket (shouldn't happen after validation).
227
+ if (
228
+ before.length === 0 &&
229
+ replacement.length === 0 &&
230
+ after.length === 0 &&
231
+ !deleted
232
+ ) {
233
+ continue;
234
+ }
235
+
236
+ const idx = line - 1;
237
+ const currentLine = fileLines[idx] ?? "";
238
+
239
+ const newContent = deleted
240
+ ? [...before, ...replacement, ...after]
241
+ : [...before, ...replacement, currentLine, ...after];
242
+
243
+ fileLines.splice(idx, 1, ...newContent);
244
+ track(line);
245
+ }
246
+
247
+ // Apply boundary inserts.
248
+ const bofChanged = insertAtStart(fileLines, bofLines);
249
+ if (bofChanged !== undefined) track(bofChanged);
250
+
251
+ const eofChanged = insertAtEnd(fileLines, eofLines);
252
+ if (eofChanged !== undefined) track(eofChanged);
253
+
254
+ return { text: fileLines.join("\n"), firstChangedLine };
255
+ }
package/src/edit.ts ADDED
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Hashline edit tool override.
3
+ *
4
+ * Overrides the built-in `edit` tool to use hashline anchoring. Every edit
5
+ * must include a `¶PATH#TAG` header copied from the most recent `read`
6
+ * output. The tag is validated against the live file before any writes
7
+ * happen — stale tags are rejected with a {@link MismatchError}.
8
+ *
9
+ * Multi-section edits (multiple files in one call) are atomic: all sections
10
+ * are preflighted before any file is written.
11
+ */
12
+
13
+ import { constants } from "node:fs";
14
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
15
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
16
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
17
+ import { Type } from "typebox";
18
+
19
+ import { applyEdits } from "./apply";
20
+ import {
21
+ computeFileHash,
22
+ formatHashlineHeader,
23
+ formatNumberedLines,
24
+ } from "./format";
25
+ import { Patch, type PatchSection } from "./input";
26
+ import {
27
+ HEADTAIL_DRIFT_WARNING,
28
+ MismatchError,
29
+ missingTagMessage,
30
+ nonExistentFileMessage,
31
+ unrecognizedHashMessage,
32
+ } from "./messages";
33
+ import {
34
+ detectLineEnding,
35
+ normalizeToLF,
36
+ restoreLineEndings,
37
+ } from "./normalize";
38
+ import { tryRecover } from "./recovery";
39
+ import type { SnapshotStore } from "./snapshots";
40
+
41
+ // ─── Schema ──────────────────────────────────────────────────────────
42
+
43
+ const EditSchema = Type.Object({
44
+ edits: Type.String({
45
+ description:
46
+ "Hashline patch text: one or more ¶PATH#TAG sections followed by edit operations " +
47
+ "(replace N..M:, delete N..M, insert before|after|head|tail:). Copy the ¶PATH#TAG " +
48
+ "header from the read tool output.",
49
+ }),
50
+ });
51
+
52
+ // ─── Details type ────────────────────────────────────────────────────
53
+
54
+ export interface EditToolDetails {
55
+ /** Per-file results. */
56
+ files: EditFileResult[];
57
+ /** Whether any files were changed. */
58
+ changed: boolean;
59
+ }
60
+
61
+ export interface EditFileResult {
62
+ /** Display-relative path. */
63
+ path: string;
64
+ /** New snapshot tag after the edit. */
65
+ fileHash: string;
66
+ /** Hashline header for the new version. */
67
+ header: string;
68
+ /** First changed line (1-indexed), or undefined for no-op. */
69
+ firstChangedLine?: number;
70
+ /** Warnings from parsing or drift. */
71
+ warnings?: string[];
72
+ /** Numbered preview lines around the change. */
73
+ preview: string;
74
+ }
75
+
76
+ // ─── Helpers ─────────────────────────────────────────────────────────
77
+
78
+ function resolveDisplayPath(rawPath: string, cwd: string): string {
79
+ const resolved = resolve(cwd, rawPath);
80
+ try {
81
+ const rel = relative(cwd, resolved);
82
+ if (!rel.startsWith("..") && !isAbsolute(rel)) {
83
+ return rel || ".";
84
+ }
85
+ } catch {
86
+ // Fall through.
87
+ }
88
+ return resolved;
89
+ }
90
+
91
+ /** Show up to 20 lines around the first changed line. */
92
+ function formatPreview(text: string, firstChangedLine?: number): string {
93
+ if (firstChangedLine === undefined) {
94
+ return formatNumberedLines(text.split("\n").slice(0, 10).join("\n"), 1);
95
+ }
96
+ const lines = text.split("\n");
97
+ const start = Math.max(0, firstChangedLine - 1 - 5);
98
+ const end = Math.min(lines.length, firstChangedLine - 1 + 15);
99
+ return formatNumberedLines(lines.slice(start, end).join("\n"), start + 1);
100
+ }
101
+
102
+ // ─── Preflight result ────────────────────────────────────────────────
103
+
104
+ interface PreflightEntry {
105
+ section: PatchSection;
106
+ absPath: string;
107
+ displayPath: string;
108
+ normalized: string;
109
+ lineEnding: "\r\n" | "\n";
110
+ liveHash: string;
111
+ }
112
+
113
+ // ─── Tool creator ────────────────────────────────────────────────────
114
+
115
+ export function createEditTool(
116
+ snapshots: SnapshotStore,
117
+ ): ToolDefinition<typeof EditSchema, EditToolDetails> {
118
+ return {
119
+ name: "edit",
120
+ label: "Edit",
121
+ description:
122
+ "Edit files using hashline anchoring. Copy the ¶PATH#TAG header from the read " +
123
+ "tool output and write edit operations (replace, delete, insert) below it. " +
124
+ "The tag validates you're editing the version you read — stale tags are rejected " +
125
+ "so you must re-read the file if it changed.",
126
+ promptSnippet:
127
+ "Edit files with hashline anchoring — copy ¶PATH#TAG from read/grep/write output",
128
+ promptGuidelines: [
129
+ "Use edit to modify existing files. Copy the ¶PATH#TAG header from your most recent read, grep, or write output — the tag is REQUIRED. Use the write tool to create new files.",
130
+ "After every edit, the file gets a new tag and renumbered lines. Always take the next edit's ¶PATH#TAG and line numbers from the edit response or a fresh read — never reuse old tags.",
131
+ "On a stale-tag rejection (file changed between read and edit), STOP and re-read the file. Never stack more edits onto stale numbers.",
132
+ "Use replace N..M: for changes, delete N..M for removal, insert before/after/head/tail: for additions. Body rows are +TEXT only — no -old rows or bare context lines.",
133
+ ],
134
+ parameters: EditSchema,
135
+
136
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
137
+ const { edits: patchText } = params;
138
+
139
+ // 1. Parse the patch input.
140
+ let patch: Patch;
141
+ try {
142
+ patch = Patch.parse(patchText, { cwd: ctx.cwd });
143
+ } catch (err: unknown) {
144
+ const message =
145
+ err instanceof Error ? err.message : "invalid edit input";
146
+ return {
147
+ content: [{ type: "text", text: `Edit parse error: ${message}` }],
148
+ details: { files: [], changed: false },
149
+ };
150
+ }
151
+
152
+ // 2. Preflight: read all files, validate tags.
153
+ const preflight: PreflightEntry[] = [];
154
+ const warnings: string[] = [];
155
+
156
+ for (const section of patch.sections) {
157
+ const absPath = resolve(ctx.cwd, section.path);
158
+ const displayPath = resolveDisplayPath(section.path, ctx.cwd);
159
+
160
+ // Check file exists.
161
+ let rawContent: string;
162
+ try {
163
+ await access(absPath, constants.R_OK);
164
+ rawContent = await readFile(absPath, "utf-8");
165
+ } catch {
166
+ return {
167
+ content: [
168
+ { type: "text", text: nonExistentFileMessage(displayPath) },
169
+ ],
170
+ details: { files: [], changed: false },
171
+ };
172
+ }
173
+
174
+ // Normalize and hash live content.
175
+ const lineEnding = detectLineEnding(rawContent);
176
+ const normalized = normalizeToLF(rawContent);
177
+ const liveHash = computeFileHash(normalized);
178
+
179
+ // Validate tag.
180
+ if (section.fileHash === undefined) {
181
+ return {
182
+ content: [{ type: "text", text: missingTagMessage(displayPath) }],
183
+ details: { files: [], changed: false },
184
+ };
185
+ }
186
+
187
+ if (section.fileHash !== liveHash) {
188
+ // Head/tail-only inserts on stale tag: apply with warning.
189
+ if (!section.hasAnchoredEdit) {
190
+ warnings.push(HEADTAIL_DRIFT_WARNING);
191
+ preflight.push({
192
+ section,
193
+ absPath,
194
+ displayPath,
195
+ normalized,
196
+ lineEnding,
197
+ liveHash,
198
+ });
199
+ continue;
200
+ }
201
+
202
+ // Anchored edits on stale tag: try recovery first.
203
+ const anchorLines = section.collectAnchorLines();
204
+ const recovered = tryRecover(
205
+ snapshots,
206
+ absPath,
207
+ normalized,
208
+ section.fileHash,
209
+ section.edits,
210
+ anchorLines,
211
+ );
212
+
213
+ if (recovered !== null) {
214
+ // Recovery succeeded — use recovered text as the base.
215
+ warnings.push(recovered.warning);
216
+ preflight.push({
217
+ section,
218
+ absPath,
219
+ displayPath,
220
+ normalized: recovered.text,
221
+ lineEnding,
222
+ liveHash: computeFileHash(recovered.text),
223
+ });
224
+ continue;
225
+ }
226
+
227
+ // Recovery failed.
228
+ const snapshot = snapshots.byHash(absPath, section.fileHash);
229
+ if (snapshot === null) {
230
+ return {
231
+ content: [
232
+ {
233
+ type: "text",
234
+ text: unrecognizedHashMessage(section.fileHash),
235
+ },
236
+ ],
237
+ details: { files: [], changed: false },
238
+ };
239
+ }
240
+
241
+ throw new MismatchError(
242
+ displayPath,
243
+ section.fileHash,
244
+ liveHash,
245
+ normalized,
246
+ anchorLines,
247
+ );
248
+ }
249
+
250
+ preflight.push({
251
+ section,
252
+ absPath,
253
+ displayPath,
254
+ normalized,
255
+ lineEnding,
256
+ liveHash,
257
+ });
258
+ }
259
+
260
+ // 3. Apply all edits (atomic — preflight passed for all).
261
+ const fileResults: EditFileResult[] = [];
262
+
263
+ for (const pf of preflight) {
264
+ const {
265
+ text: newText,
266
+ firstChangedLine,
267
+ warnings: applyWarnings,
268
+ } = applyEdits(pf.normalized, pf.section.edits);
269
+
270
+ // Restore line endings and write.
271
+ const output = restoreLineEndings(newText, pf.lineEnding);
272
+ await mkdir(dirname(pf.absPath), { recursive: true });
273
+ await writeFile(pf.absPath, output, "utf-8");
274
+
275
+ // Record fresh snapshot.
276
+ const newHash = snapshots.record(pf.absPath, newText);
277
+ const header = formatHashlineHeader(pf.displayPath, newHash);
278
+ const preview = formatPreview(newText, firstChangedLine);
279
+
280
+ const fileWarnings = [
281
+ ...(pf.section.fileHash !== pf.liveHash ? warnings : []),
282
+ ...(applyWarnings ?? []),
283
+ ];
284
+
285
+ fileResults.push({
286
+ path: pf.displayPath,
287
+ fileHash: newHash,
288
+ header,
289
+ firstChangedLine,
290
+ warnings: fileWarnings.length > 0 ? fileWarnings : undefined,
291
+ preview,
292
+ });
293
+ }
294
+
295
+ // 4. Format the output.
296
+ const outputParts = fileResults.map((fr) => {
297
+ let block = `${fr.header}\n${fr.preview}`;
298
+ if (fr.warnings && fr.warnings.length > 0) {
299
+ block += `\n\nWarnings:\n${fr.warnings.map((w) => `- ${w}`).join("\n")}`;
300
+ }
301
+ return block;
302
+ });
303
+
304
+ return {
305
+ content: [{ type: "text", text: outputParts.join("\n\n") }],
306
+ details: {
307
+ files: fileResults,
308
+ changed: fileResults.some((fr) => fr.firstChangedLine !== undefined),
309
+ },
310
+ };
311
+ },
312
+ };
313
+ }