pi-mono-all 1.0.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.
Files changed (161) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENCE.md +7 -0
  3. package/node_modules/pi-common/package.json +22 -0
  4. package/node_modules/pi-common/src/auth-config.ts +290 -0
  5. package/node_modules/pi-common/src/auth.ts +63 -0
  6. package/node_modules/pi-common/src/cache.ts +60 -0
  7. package/node_modules/pi-common/src/errors.ts +47 -0
  8. package/node_modules/pi-common/src/http-client.ts +118 -0
  9. package/node_modules/pi-common/src/index.ts +7 -0
  10. package/node_modules/pi-common/src/rate-limiter.ts +32 -0
  11. package/node_modules/pi-common/src/tool-result.ts +27 -0
  12. package/node_modules/pi-mono-ask-user-question/CHANGELOG.md +185 -0
  13. package/node_modules/pi-mono-ask-user-question/README.md +226 -0
  14. package/node_modules/pi-mono-ask-user-question/index.ts +923 -0
  15. package/node_modules/pi-mono-ask-user-question/package.json +29 -0
  16. package/node_modules/pi-mono-auto-fix/CHANGELOG.md +59 -0
  17. package/node_modules/pi-mono-auto-fix/README.md +77 -0
  18. package/node_modules/pi-mono-auto-fix/index.ts +488 -0
  19. package/node_modules/pi-mono-auto-fix/package.json +23 -0
  20. package/node_modules/pi-mono-btw/CHANGELOG.md +180 -0
  21. package/node_modules/pi-mono-btw/README.md +24 -0
  22. package/node_modules/pi-mono-btw/index.ts +499 -0
  23. package/node_modules/pi-mono-btw/package.json +29 -0
  24. package/node_modules/pi-mono-clear/CHANGELOG.md +180 -0
  25. package/node_modules/pi-mono-clear/README.md +40 -0
  26. package/node_modules/pi-mono-clear/index.ts +45 -0
  27. package/node_modules/pi-mono-clear/package.json +29 -0
  28. package/node_modules/pi-mono-context/CHANGELOG.md +12 -0
  29. package/node_modules/pi-mono-context/README.md +74 -0
  30. package/node_modules/pi-mono-context/index.ts +641 -0
  31. package/node_modules/pi-mono-context/package.json +29 -0
  32. package/node_modules/pi-mono-context-guard/CHANGELOG.md +195 -0
  33. package/node_modules/pi-mono-context-guard/README.md +81 -0
  34. package/node_modules/pi-mono-context-guard/index.ts +212 -0
  35. package/node_modules/pi-mono-context-guard/package.json +23 -0
  36. package/node_modules/pi-mono-figma/CHANGELOG.md +59 -0
  37. package/node_modules/pi-mono-figma/README.md +236 -0
  38. package/node_modules/pi-mono-figma/__tests__/code-connect.test.ts +32 -0
  39. package/node_modules/pi-mono-figma/__tests__/figma-assets.test.ts +38 -0
  40. package/node_modules/pi-mono-figma/__tests__/figma-component-hints.test.ts +23 -0
  41. package/node_modules/pi-mono-figma/__tests__/figma-implementation-layout.test.ts +47 -0
  42. package/node_modules/pi-mono-figma/__tests__/figma-search.test.ts +51 -0
  43. package/node_modules/pi-mono-figma/__tests__/figma-summarizer.test.ts +65 -0
  44. package/node_modules/pi-mono-figma/__tests__/fixtures/complex-auto-layout.json +115 -0
  45. package/node_modules/pi-mono-figma/__tests__/fixtures/component-instance.json +50 -0
  46. package/node_modules/pi-mono-figma/__tests__/fixtures/hidden-and-vectors.json +28 -0
  47. package/node_modules/pi-mono-figma/__tests__/fixtures/variables-and-styles.json +40 -0
  48. package/node_modules/pi-mono-figma/docs/live-selection-bridge.md +16 -0
  49. package/node_modules/pi-mono-figma/index.ts +6 -0
  50. package/node_modules/pi-mono-figma/package.json +33 -0
  51. package/node_modules/pi-mono-figma/skills/figma/SKILL.md +143 -0
  52. package/node_modules/pi-mono-figma/src/code-connect.ts +110 -0
  53. package/node_modules/pi-mono-figma/src/figma-assets.ts +146 -0
  54. package/node_modules/pi-mono-figma/src/figma-cache.ts +6 -0
  55. package/node_modules/pi-mono-figma/src/figma-client.ts +471 -0
  56. package/node_modules/pi-mono-figma/src/figma-component-hints.ts +87 -0
  57. package/node_modules/pi-mono-figma/src/figma-implementation.ts +264 -0
  58. package/node_modules/pi-mono-figma/src/figma-schemas.ts +139 -0
  59. package/node_modules/pi-mono-figma/src/figma-search.ts +195 -0
  60. package/node_modules/pi-mono-figma/src/figma-summarizer.ts +673 -0
  61. package/node_modules/pi-mono-figma/src/figma-tokens.ts +57 -0
  62. package/node_modules/pi-mono-figma/src/figma-tools.ts +352 -0
  63. package/node_modules/pi-mono-linear/CHANGELOG.md +44 -0
  64. package/node_modules/pi-mono-linear/README.md +159 -0
  65. package/node_modules/pi-mono-linear/index.ts +6 -0
  66. package/node_modules/pi-mono-linear/package.json +30 -0
  67. package/node_modules/pi-mono-linear/skills/linear/SKILL.md +107 -0
  68. package/node_modules/pi-mono-linear/src/linear-client.ts +339 -0
  69. package/node_modules/pi-mono-linear/src/linear-queries.ts +101 -0
  70. package/node_modules/pi-mono-linear/src/linear-schemas.ts +90 -0
  71. package/node_modules/pi-mono-linear/src/linear-tools.ts +362 -0
  72. package/node_modules/pi-mono-loop/CHANGELOG.md +163 -0
  73. package/node_modules/pi-mono-loop/README.md +54 -0
  74. package/node_modules/pi-mono-loop/index.ts +291 -0
  75. package/node_modules/pi-mono-loop/package.json +26 -0
  76. package/node_modules/pi-mono-multi-edit/CHANGELOG.md +232 -0
  77. package/node_modules/pi-mono-multi-edit/README.md +244 -0
  78. package/node_modules/pi-mono-multi-edit/__tests__/classic.test.ts +277 -0
  79. package/node_modules/pi-mono-multi-edit/__tests__/diff.test.ts +77 -0
  80. package/node_modules/pi-mono-multi-edit/__tests__/patch.test.ts +287 -0
  81. package/node_modules/pi-mono-multi-edit/benchmark-edits.ts +966 -0
  82. package/node_modules/pi-mono-multi-edit/classic.ts +435 -0
  83. package/node_modules/pi-mono-multi-edit/diff.ts +143 -0
  84. package/node_modules/pi-mono-multi-edit/index.ts +266 -0
  85. package/node_modules/pi-mono-multi-edit/package.json +37 -0
  86. package/node_modules/pi-mono-multi-edit/patch.ts +463 -0
  87. package/node_modules/pi-mono-multi-edit/types.ts +53 -0
  88. package/node_modules/pi-mono-multi-edit/workspace.ts +85 -0
  89. package/node_modules/pi-mono-review/CHANGELOG.md +190 -0
  90. package/node_modules/pi-mono-review/README.md +30 -0
  91. package/node_modules/pi-mono-review/common.ts +930 -0
  92. package/node_modules/pi-mono-review/index.ts +8 -0
  93. package/node_modules/pi-mono-review/package.json +29 -0
  94. package/node_modules/pi-mono-review/review-tui.ts +194 -0
  95. package/node_modules/pi-mono-review/review.ts +119 -0
  96. package/node_modules/pi-mono-review/reviewer.ts +339 -0
  97. package/node_modules/pi-mono-sentinel/CHANGELOG.md +158 -0
  98. package/node_modules/pi-mono-sentinel/README.md +87 -0
  99. package/node_modules/pi-mono-sentinel/__tests__/output-scanner.test.ts +109 -0
  100. package/node_modules/pi-mono-sentinel/__tests__/permissions.test.ts +202 -0
  101. package/node_modules/pi-mono-sentinel/__tests__/whitelist.test.ts +59 -0
  102. package/node_modules/pi-mono-sentinel/guards/execution-tracker.ts +281 -0
  103. package/node_modules/pi-mono-sentinel/guards/output-scanner.ts +232 -0
  104. package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +170 -0
  105. package/node_modules/pi-mono-sentinel/index.ts +43 -0
  106. package/node_modules/pi-mono-sentinel/package.json +26 -0
  107. package/node_modules/pi-mono-sentinel/patterns/permissions.ts +175 -0
  108. package/node_modules/pi-mono-sentinel/patterns/read-targets.ts +104 -0
  109. package/node_modules/pi-mono-sentinel/patterns/secrets.ts +143 -0
  110. package/node_modules/pi-mono-sentinel/session.ts +95 -0
  111. package/node_modules/pi-mono-sentinel/specs/2026/04/sentinel/001-permission-gate.md +145 -0
  112. package/node_modules/pi-mono-sentinel/types.ts +39 -0
  113. package/node_modules/pi-mono-sentinel/whitelist.ts +86 -0
  114. package/node_modules/pi-mono-simplify/CHANGELOG.md +163 -0
  115. package/node_modules/pi-mono-simplify/README.md +56 -0
  116. package/node_modules/pi-mono-simplify/index.ts +78 -0
  117. package/node_modules/pi-mono-simplify/package.json +29 -0
  118. package/node_modules/pi-mono-status-line/CHANGELOG.md +180 -0
  119. package/node_modules/pi-mono-status-line/README.md +96 -0
  120. package/node_modules/pi-mono-status-line/basic.ts +89 -0
  121. package/node_modules/pi-mono-status-line/expert.ts +689 -0
  122. package/node_modules/pi-mono-status-line/index.ts +54 -0
  123. package/node_modules/pi-mono-status-line/package.json +29 -0
  124. package/node_modules/pi-mono-team-mode/CHANGELOG.md +278 -0
  125. package/node_modules/pi-mono-team-mode/README.md +246 -0
  126. package/node_modules/pi-mono-team-mode/__tests__/agent-manager-transient.test.ts +75 -0
  127. package/node_modules/pi-mono-team-mode/__tests__/delegation-manager.test.ts +118 -0
  128. package/node_modules/pi-mono-team-mode/__tests__/formatters.test.ts +104 -0
  129. package/node_modules/pi-mono-team-mode/__tests__/model-config.test.ts +272 -0
  130. package/node_modules/pi-mono-team-mode/__tests__/notification-box.test.ts +34 -0
  131. package/node_modules/pi-mono-team-mode/__tests__/parallel-utils.test.ts +32 -0
  132. package/node_modules/pi-mono-team-mode/__tests__/pi-stream-parser.test.ts +64 -0
  133. package/node_modules/pi-mono-team-mode/__tests__/prompts.test.ts +106 -0
  134. package/node_modules/pi-mono-team-mode/__tests__/store.test.ts +164 -0
  135. package/node_modules/pi-mono-team-mode/__tests__/tasks.test.ts +267 -0
  136. package/node_modules/pi-mono-team-mode/__tests__/teammate-specs.test.ts +114 -0
  137. package/node_modules/pi-mono-team-mode/__tests__/widget.test.ts +41 -0
  138. package/node_modules/pi-mono-team-mode/__tests__/worktree.test.ts +78 -0
  139. package/node_modules/pi-mono-team-mode/core/chain-utils.ts +90 -0
  140. package/node_modules/pi-mono-team-mode/core/fs-utils.ts +44 -0
  141. package/node_modules/pi-mono-team-mode/core/model-config.ts +432 -0
  142. package/node_modules/pi-mono-team-mode/core/parallel-utils.ts +48 -0
  143. package/node_modules/pi-mono-team-mode/core/prompts.ts +158 -0
  144. package/node_modules/pi-mono-team-mode/core/store.ts +156 -0
  145. package/node_modules/pi-mono-team-mode/core/tasks.ts +99 -0
  146. package/node_modules/pi-mono-team-mode/core/teammate-specs.ts +124 -0
  147. package/node_modules/pi-mono-team-mode/core/types.ts +160 -0
  148. package/node_modules/pi-mono-team-mode/index.ts +825 -0
  149. package/node_modules/pi-mono-team-mode/managers/agent-manager.ts +654 -0
  150. package/node_modules/pi-mono-team-mode/managers/delegation-manager.ts +211 -0
  151. package/node_modules/pi-mono-team-mode/managers/task-manager.ts +238 -0
  152. package/node_modules/pi-mono-team-mode/managers/team-manager.ts +59 -0
  153. package/node_modules/pi-mono-team-mode/package.json +33 -0
  154. package/node_modules/pi-mono-team-mode/runtime/pi-stream-parser.ts +194 -0
  155. package/node_modules/pi-mono-team-mode/runtime/subprocess.ts +183 -0
  156. package/node_modules/pi-mono-team-mode/runtime/transient-session.ts +196 -0
  157. package/node_modules/pi-mono-team-mode/runtime/worktree.ts +90 -0
  158. package/node_modules/pi-mono-team-mode/ui/formatters.ts +149 -0
  159. package/node_modules/pi-mono-team-mode/ui/notification-box.ts +55 -0
  160. package/node_modules/pi-mono-team-mode/ui/widget.ts +94 -0
  161. package/package.json +76 -0
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Classic edit engine — (path, oldText, newText) triples applied against a
3
+ * Workspace, with positional same-file ordering, curly-quote fallback, and
4
+ * atomic multi-file rollback.
5
+ *
6
+ * The core loop groups edits by their absolute path so all hits against a
7
+ * file happen in one read/mutate/write cycle. Within a group, entries are
8
+ * sorted by the position of their `oldText` in the original content, so a
9
+ * model that lists edits bottom-up still applies them top-down.
10
+ */
11
+
12
+ import { isAbsolute, resolve as resolvePath } from "path";
13
+
14
+ import { generateDiffString } from "./diff.ts";
15
+ import type { EditItem, EditResult, Workspace } from "./types.ts";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Text matching
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const normalizeCurlyQuotes = (s: string): string =>
22
+ s
23
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
24
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"');
25
+
26
+ const trimTrailingPerLine = (s: string): string =>
27
+ s
28
+ .split("\n")
29
+ .map((l) => l.trimEnd())
30
+ .join("\n");
31
+
32
+ /**
33
+ * Ordered list of passes `findActualString` tries when matching `oldText`
34
+ * inside file content. Each pass applies a normalizer to *both* `oldText`
35
+ * and `content`; the first one that locates the transformed string wins.
36
+ *
37
+ * The array is the extension point: add a new pass here to gain tolerance
38
+ * for a new class of model/file mismatch (e.g. dash variants, NBSP).
39
+ */
40
+ const MATCH_PASSES: readonly ((s: string) => string)[] = [
41
+ (s) => s, // exact
42
+ normalizeCurlyQuotes, // curly → straight quotes
43
+ trimTrailingPerLine, // trailing-whitespace tolerance per line
44
+ ];
45
+
46
+ /**
47
+ * Locate `oldText` inside `content` starting at `offset`. Falls back through
48
+ * `MATCH_PASSES` when the exact search fails — most commonly when the model
49
+ * wrote curly quotes but the file has straight ASCII.
50
+ *
51
+ * Returns `{ pos, actualOldText }` on match, `undefined` otherwise. Callers
52
+ * must use `actualOldText.length` (not the original oldText length) when
53
+ * splicing, since the matched region may differ from the requested text
54
+ * after normalization.
55
+ */
56
+ export function findActualString(
57
+ content: string,
58
+ oldText: string,
59
+ offset: number,
60
+ ): { pos: number; actualOldText: string } | undefined {
61
+ // Fast path: exact match with no normalization.
62
+ const exact = content.indexOf(oldText, offset);
63
+ if (exact !== -1) return { pos: exact, actualOldText: oldText };
64
+
65
+ // Slower passes: normalize both sides and map the position back to the
66
+ // original content. We search in the normalized content but return the
67
+ // position and length in the *original* so the caller can splice correctly.
68
+ const triedOld = new Set<string>([oldText]);
69
+ const triedContent = new Set<string>([content]);
70
+
71
+ for (let i = 1; i < MATCH_PASSES.length; i++) {
72
+ const transform = MATCH_PASSES[i];
73
+ const normOld = transform(oldText);
74
+ const normContent = transform(content);
75
+
76
+ if (triedOld.has(normOld) && triedContent.has(normContent)) continue;
77
+ triedOld.add(normOld);
78
+ triedContent.add(normContent);
79
+
80
+ const pos = normContent.indexOf(normOld, offset);
81
+ if (pos !== -1) {
82
+ // Map back: the character at `pos` in normalised content corresponds
83
+ // to the same index in the original (our normalizers preserve length
84
+ // for all passes except trimTrailingPerLine). For trimEnd we need the
85
+ // actual substring from original content that matches.
86
+ const actualOld = content.slice(pos, pos + normOld.length);
87
+ // Verify the mapped slice actually normalizes to the same thing.
88
+ if (transform(actualOld) === normOld) {
89
+ return { pos, actualOldText: actualOld };
90
+ }
91
+ // If the lengths shifted (trimEnd can shrink lines), fall back to a
92
+ // line-aligned search: find the lines in the original content.
93
+ const match = findByNormalizedLines(content, oldText, offset, transform);
94
+ if (match) return match;
95
+ }
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /**
101
+ * Line-by-line normalized search. Used when a normalizer changes string
102
+ * length (e.g. trimEnd) so character offsets between original and normalized
103
+ * content no longer align 1:1.
104
+ */
105
+ function findByNormalizedLines(
106
+ content: string,
107
+ oldText: string,
108
+ offset: number,
109
+ normalize: (s: string) => string,
110
+ ): { pos: number; actualOldText: string } | undefined {
111
+ const contentLines = content.split("\n");
112
+ const oldLines = oldText.split("\n");
113
+ if (oldLines.length === 0) return undefined;
114
+
115
+ const normOldLines = oldLines.map((l) => normalize(l));
116
+
117
+ // Character offset → line index.
118
+ let charCount = 0;
119
+ let startLine = 0;
120
+ for (let i = 0; i < contentLines.length; i++) {
121
+ if (charCount + contentLines[i].length >= offset) {
122
+ startLine = i;
123
+ break;
124
+ }
125
+ charCount += contentLines[i].length + 1; // +1 for \n
126
+ }
127
+
128
+ for (let i = startLine; i <= contentLines.length - oldLines.length; i++) {
129
+ let match = true;
130
+ for (let j = 0; j < normOldLines.length; j++) {
131
+ if (normalize(contentLines[i + j]) !== normOldLines[j]) {
132
+ match = false;
133
+ break;
134
+ }
135
+ }
136
+ if (match) {
137
+ // Compute character position and actual substring from original.
138
+ let pos = 0;
139
+ for (let k = 0; k < i; k++) pos += contentLines[k].length + 1;
140
+ const endLine = i + oldLines.length - 1;
141
+ let endPos = 0;
142
+ for (let k = 0; k <= endLine; k++) endPos += contentLines[k].length + 1;
143
+ endPos--; // don't include the final \n after last matched line
144
+ // If oldText ended with \n, include it.
145
+ if (oldText.endsWith("\n") && endPos + 1 <= content.length) endPos++;
146
+ const actualOldText = content.slice(pos, endPos);
147
+ return { pos, actualOldText };
148
+ }
149
+ }
150
+
151
+ return undefined;
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Grouping helpers
156
+ // ---------------------------------------------------------------------------
157
+
158
+ interface IndexedEdit {
159
+ index: number;
160
+ edit: EditItem;
161
+ }
162
+
163
+ function toAbsolute(path: string, cwd: string): string {
164
+ return isAbsolute(path) ? resolvePath(path) : resolvePath(cwd, path);
165
+ }
166
+
167
+ /**
168
+ * Bucket a flat edit list by its resolved absolute path. The returned Map
169
+ * preserves insertion order, which is the order files are processed in the
170
+ * apply loop — making the first-seen file also the first to be mutated on
171
+ * disk.
172
+ */
173
+ function groupEditsByPath(
174
+ edits: EditItem[],
175
+ cwd: string,
176
+ ): Map<string, IndexedEdit[]> {
177
+ const groups = new Map<string, IndexedEdit[]>();
178
+ for (let i = 0; i < edits.length; i++) {
179
+ const abs = toAbsolute(edits[i].path, cwd);
180
+ const bucket = groups.get(abs);
181
+ if (bucket) {
182
+ bucket.push({ index: i, edit: edits[i] });
183
+ } else {
184
+ groups.set(abs, [{ index: i, edit: edits[i] }]);
185
+ }
186
+ }
187
+ return groups;
188
+ }
189
+
190
+ /**
191
+ * Sort same-file edits by the position of their `oldText` inside the
192
+ * original content. Edits whose oldText can't be located slide to the end
193
+ * and surface the error through the regular apply loop.
194
+ */
195
+ function sortGroupByPosition(
196
+ group: IndexedEdit[],
197
+ originalContent: string,
198
+ ): void {
199
+ if (group.length < 2) return;
200
+ const positions = new Map<IndexedEdit, number>();
201
+ for (const entry of group) {
202
+ const match = findActualString(originalContent, entry.edit.oldText, 0);
203
+ positions.set(
204
+ entry,
205
+ match === undefined ? Number.MAX_SAFE_INTEGER : match.pos,
206
+ );
207
+ }
208
+ group.sort((a, b) => positions.get(a)! - positions.get(b)!);
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Core apply loop
213
+ // ---------------------------------------------------------------------------
214
+
215
+ interface ApplyOptions {
216
+ collectDiff?: boolean;
217
+ rollbackOnError?: boolean;
218
+ /** When true, failed edits are recorded but the batch continues with the
219
+ * remaining edits instead of aborting the entire group. */
220
+ continueOnError?: boolean;
221
+ }
222
+
223
+ /**
224
+ * Apply a list of classic edits sequentially through a Workspace.
225
+ *
226
+ * Within each file the applier advances a `searchOffset` cursor after every
227
+ * replacement so duplicate oldText snippets are disambiguated positionally.
228
+ * Same-file edits are reordered by the position of their oldText in the
229
+ * original content so the cursor always moves forward.
230
+ *
231
+ * When `rollbackOnError` is set, any file already written in this batch is
232
+ * restored to its pre-edit snapshot if a later file fails — producing an
233
+ * atomic multi-file edit on the real filesystem.
234
+ */
235
+ export async function applyClassicEdits(
236
+ edits: EditItem[],
237
+ workspace: Workspace,
238
+ cwd: string,
239
+ signal?: AbortSignal,
240
+ options: ApplyOptions = {},
241
+ ): Promise<EditResult[]> {
242
+ const {
243
+ collectDiff = false,
244
+ rollbackOnError = false,
245
+ continueOnError = false,
246
+ } = options;
247
+
248
+ const fileGroups = groupEditsByPath(edits, cwd);
249
+ const results: EditResult[] = new Array(edits.length);
250
+
251
+ // Fail fast on any unwritable target so we don't partially mutate the FS.
252
+ await Promise.all(
253
+ Array.from(fileGroups.keys(), (absPath) =>
254
+ workspace.checkWriteAccess(absPath),
255
+ ),
256
+ );
257
+
258
+ // Pre-edit snapshots keyed by absolute path — populated as each file is
259
+ // successfully written, consumed on failure for rollback.
260
+ const snapshots = new Map<string, string>();
261
+
262
+ try {
263
+ for (const [absPath, group] of fileGroups) {
264
+ throwIfAborted(signal);
265
+
266
+ const originalContent = await workspace.readText(absPath);
267
+ sortGroupByPosition(group, originalContent);
268
+
269
+ let updatedContent: string;
270
+ try {
271
+ updatedContent = applyGroupToContent(
272
+ group,
273
+ originalContent,
274
+ results,
275
+ edits.length,
276
+ signal,
277
+ continueOnError,
278
+ );
279
+ } catch (err) {
280
+ if (continueOnError) continue;
281
+ throw err;
282
+ }
283
+
284
+ if (updatedContent === originalContent) continue;
285
+
286
+ snapshots.set(absPath, originalContent);
287
+ await workspace.writeText(absPath, updatedContent);
288
+
289
+ if (collectDiff) {
290
+ const { diff, firstChangedLine } = generateDiffString(
291
+ originalContent,
292
+ updatedContent,
293
+ );
294
+ const firstSuccessIdx = group.find(
295
+ (e) => results[e.index]?.success,
296
+ )?.index;
297
+ if (firstSuccessIdx !== undefined) {
298
+ results[firstSuccessIdx].diff = diff;
299
+ results[firstSuccessIdx].firstChangedLine = firstChangedLine;
300
+ }
301
+ }
302
+ }
303
+ } catch (err) {
304
+ if (rollbackOnError) {
305
+ await rollbackSnapshots(snapshots, workspace);
306
+ }
307
+ throw err;
308
+ }
309
+
310
+ return results;
311
+ }
312
+
313
+ /**
314
+ * Apply every edit in a same-file group against `originalContent`, writing
315
+ * per-edit outcomes into the shared `results` slot array. Returns the final
316
+ * mutated content for the file, or throws with a formatted error if a hunk
317
+ * can't be located.
318
+ */
319
+ function applyGroupToContent(
320
+ group: IndexedEdit[],
321
+ originalContent: string,
322
+ results: EditResult[],
323
+ totalEdits: number,
324
+ signal: AbortSignal | undefined,
325
+ continueOnError = false,
326
+ ): string {
327
+ let content = originalContent;
328
+ let searchOffset = 0;
329
+
330
+ // Track which oldText→newText pairs already landed in this file so we
331
+ // can skip a redundant duplicate gracefully instead of failing the batch.
332
+ const appliedPairs = new Set<string>();
333
+ const pairKey = (edit: EditItem) => `${edit.oldText}\0${edit.newText}`;
334
+
335
+ for (const { index, edit } of group) {
336
+ throwIfAborted(signal);
337
+
338
+ const match = findActualString(content, edit.oldText, searchOffset);
339
+
340
+ if (match === undefined) {
341
+ if (appliedPairs.has(pairKey(edit))) {
342
+ results[index] = {
343
+ path: edit.path,
344
+ success: true,
345
+ message: `Skipped redundant edit in ${edit.path} (already replaced all occurrences).`,
346
+ };
347
+ continue;
348
+ }
349
+
350
+ results[index] = {
351
+ path: edit.path,
352
+ success: false,
353
+ message: `Could not find the exact text in ${edit.path}. The old text must match exactly including all whitespace and newlines.`,
354
+ };
355
+
356
+ if (continueOnError) continue;
357
+
358
+ markRemainingSkipped(group, index, results);
359
+ throw new Error(formatResults(results.filter(Boolean), totalEdits));
360
+ }
361
+
362
+ const { pos, actualOldText } = match;
363
+ content =
364
+ content.slice(0, pos) +
365
+ edit.newText +
366
+ content.slice(pos + actualOldText.length);
367
+ searchOffset = pos + edit.newText.length;
368
+ appliedPairs.add(pairKey(edit));
369
+
370
+ results[index] = {
371
+ path: edit.path,
372
+ success: true,
373
+ message: `Edited ${edit.path}.`,
374
+ };
375
+ }
376
+
377
+ return content;
378
+ }
379
+
380
+ function markRemainingSkipped(
381
+ group: IndexedEdit[],
382
+ failedIndex: number,
383
+ results: EditResult[],
384
+ ): void {
385
+ const failedPos = group.findIndex((e) => e.index === failedIndex);
386
+ for (let i = failedPos + 1; i < group.length; i++) {
387
+ const pending = group[i];
388
+ results[pending.index] = {
389
+ path: pending.edit.path,
390
+ success: false,
391
+ message: `Skipped (earlier edit in ${pending.edit.path} failed).`,
392
+ };
393
+ }
394
+ }
395
+
396
+ function throwIfAborted(signal: AbortSignal | undefined): void {
397
+ if (signal?.aborted) {
398
+ throw new Error("Operation aborted");
399
+ }
400
+ }
401
+
402
+ async function rollbackSnapshots(
403
+ snapshots: Map<string, string>,
404
+ workspace: Workspace,
405
+ ): Promise<void> {
406
+ // Best-effort restore — surface the original failure regardless of per-file
407
+ // rollback failures.
408
+ await Promise.all(
409
+ Array.from(snapshots, ([absPath, original]) =>
410
+ workspace.writeText(absPath, original).catch(() => {}),
411
+ ),
412
+ );
413
+ }
414
+
415
+ export function formatResults(
416
+ results: EditResult[],
417
+ totalEdits: number,
418
+ ): string {
419
+ const lines: string[] = [];
420
+
421
+ for (let i = 0; i < results.length; i++) {
422
+ const r = results[i];
423
+ const status = r.success ? "✓" : "✗";
424
+ lines.push(
425
+ `${status} Edit ${i + 1}/${totalEdits} (${r.path}): ${r.message}`,
426
+ );
427
+ }
428
+
429
+ const remaining = totalEdits - results.length;
430
+ if (remaining > 0) {
431
+ lines.push(`⊘ ${remaining} remaining edit(s) skipped due to error.`);
432
+ }
433
+
434
+ return lines.join("\n");
435
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Two-pass diff renderer on top of `diff.diffLines`.
3
+ *
4
+ * Pass 1 — `expand`: flatten the `diffLines` change parts into a typed
5
+ * `Entry[]` stream where every entry carries its own 1-indexed old/new line
6
+ * numbers. No rendering decisions happen here.
7
+ *
8
+ * Pass 2 — `render`: walk the entries, buffer each unchanged run, and decide
9
+ * per run whether to show it whole, collapse it with a `...` marker, or
10
+ * discard it (when the run sits outside any change's context window).
11
+ *
12
+ * Output contract (stable, tested in __tests__/diff.test.ts):
13
+ * - Added lines: `+NN text`
14
+ * - Removed lines: `-NN text`
15
+ * - Context lines: ` NN text`
16
+ * - Collapsed run: ` __ ...` (blank gutter + ellipsis)
17
+ *
18
+ * Line numbers are right-padded to the width of the longest 1-indexed line
19
+ * number across both files so the gutter stays aligned.
20
+ */
21
+
22
+ import * as Diff from "diff";
23
+
24
+ type ContextEntry = { kind: "context"; oldLine: number; newLine: number; text: string };
25
+ type AddedEntry = { kind: "added"; newLine: number; text: string };
26
+ type RemovedEntry = { kind: "removed"; oldLine: number; text: string };
27
+ type Entry = ContextEntry | AddedEntry | RemovedEntry;
28
+
29
+ /**
30
+ * Walk `diff.diffLines` parts and yield a flat entry list plus the 1-indexed
31
+ * new-file line number of the first change. `firstChangedLine` is captured
32
+ * eagerly during expansion so callers don't have to re-scan the entries.
33
+ */
34
+ function expand(parts: Diff.Change[]): { entries: Entry[]; firstChangedLine: number | undefined } {
35
+ const entries: Entry[] = [];
36
+ let oldNum = 1;
37
+ let newNum = 1;
38
+ let firstChangedLine: number | undefined;
39
+
40
+ for (const part of parts) {
41
+ const lines = part.value.split("\n");
42
+ // `diff.diffLines` always terminates with an empty string from the trailing
43
+ // newline — drop it so we don't emit a ghost row per part.
44
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
45
+ lines.pop();
46
+ }
47
+
48
+ for (const text of lines) {
49
+ if (part.added) {
50
+ if (firstChangedLine === undefined) firstChangedLine = newNum;
51
+ entries.push({ kind: "added", newLine: newNum, text });
52
+ newNum++;
53
+ } else if (part.removed) {
54
+ if (firstChangedLine === undefined) firstChangedLine = newNum;
55
+ entries.push({ kind: "removed", oldLine: oldNum, text });
56
+ oldNum++;
57
+ } else {
58
+ entries.push({ kind: "context", oldLine: oldNum, newLine: newNum, text });
59
+ oldNum++;
60
+ newNum++;
61
+ }
62
+ }
63
+ }
64
+
65
+ return { entries, firstChangedLine };
66
+ }
67
+
68
+ /**
69
+ * Render an expanded entry stream into gutter-formatted output lines. Context
70
+ * runs are collapsed when their length exceeds the visible head + tail window.
71
+ */
72
+ function render(entries: Entry[], contextLines: number, lineNumWidth: number): string[] {
73
+ const pad = (n: number) => String(n).padStart(lineNumWidth, " ");
74
+ const blankGutter = " ".repeat(lineNumWidth);
75
+ const out: string[] = [];
76
+
77
+ let i = 0;
78
+ while (i < entries.length) {
79
+ const entry = entries[i];
80
+
81
+ if (entry.kind === "added") {
82
+ out.push(`+${pad(entry.newLine)} ${entry.text}`);
83
+ i++;
84
+ continue;
85
+ }
86
+ if (entry.kind === "removed") {
87
+ out.push(`-${pad(entry.oldLine)} ${entry.text}`);
88
+ i++;
89
+ continue;
90
+ }
91
+
92
+ // entry.kind === "context" — buffer the whole run before deciding.
93
+ const runStart = i;
94
+ while (i < entries.length && entries[i].kind === "context") {
95
+ i++;
96
+ }
97
+ const runEnd = i;
98
+ const runLen = runEnd - runStart;
99
+
100
+ const hasChangeBefore = runStart > 0;
101
+ const hasChangeAfter = runEnd < entries.length;
102
+
103
+ // Context that isn't adjacent to any change is dead weight — drop it.
104
+ if (!hasChangeBefore && !hasChangeAfter) continue;
105
+
106
+ const head = hasChangeBefore ? contextLines : 0;
107
+ const tail = hasChangeAfter ? contextLines : 0;
108
+
109
+ const writeAt = (idx: number) => {
110
+ const e = entries[idx] as ContextEntry;
111
+ out.push(` ${pad(e.oldLine)} ${e.text}`);
112
+ };
113
+
114
+ if (runLen <= head + tail) {
115
+ for (let j = runStart; j < runEnd; j++) writeAt(j);
116
+ continue;
117
+ }
118
+
119
+ for (let j = 0; j < head; j++) writeAt(runStart + j);
120
+ out.push(` ${blankGutter} ...`);
121
+ for (let j = tail; j > 0; j--) writeAt(runEnd - j);
122
+ }
123
+
124
+ return out;
125
+ }
126
+
127
+ export function generateDiffString(
128
+ oldContent: string,
129
+ newContent: string,
130
+ contextLines = 4,
131
+ ): { diff: string; firstChangedLine: number | undefined } {
132
+ const parts = Diff.diffLines(oldContent, newContent);
133
+ const { entries, firstChangedLine } = expand(parts);
134
+
135
+ // Gutter width: pad to the widest 1-indexed line number that can appear.
136
+ const oldLineCount = oldContent.split("\n").length;
137
+ const newLineCount = newContent.split("\n").length;
138
+ const lineNumWidth = String(Math.max(oldLineCount, newLineCount)).length;
139
+
140
+ const lines = render(entries, contextLines, lineNumWidth);
141
+
142
+ return { diff: lines.join("\n"), firstChangedLine };
143
+ }