pi-hashline-edit-pro 4.0.1 → 4.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.
@@ -1,4 +1,4 @@
1
- import { abortIf, splitLines } from "../utils";
1
+ import { abortIf, clipLine, splitLines } from "../utils";
2
2
  import { _lineHashesPure } from "./hash";
3
3
  import {
4
4
  valEdit,
@@ -52,7 +52,7 @@ type NoopSpan = {
52
52
  loc: string;
53
53
  currentContent: string;
54
54
  };
55
- function assertNotEmpty(originalContent: string, result: string): void {
55
+ export function assertNotEmpty(originalContent: string, result: string): void {
56
56
  if (originalContent.length > 0 && result.length === 0) {
57
57
  throw new Error(
58
58
  "[E_WOULD_EMPTY] A replace cannot empty a non-empty file. Use `write` to clear the file."
@@ -148,6 +148,122 @@ function assemble(
148
148
  return content.slice(0, span.start) + span.replacement + content.slice(span.end);
149
149
  }
150
150
 
151
+ export interface PlannedEdit {
152
+ resolved: RHEdit;
153
+ warnings: string[];
154
+ autoFixes?: AutoFix[];
155
+ }
156
+
157
+
158
+ function throwStrictBoundaryDedup(edit: HEdit, boundaryDups: BDup[], resolved: RHEdit): never {
159
+ const startLine = resolved.hash_bounds[0].line;
160
+ const endLine = resolved.hash_bounds[1].line;
161
+ const range = startLine === endLine ? `line ${startLine}` : `lines ${startLine}-${endLine}`;
162
+ const seen = new Set<number>();
163
+ for (const dup of boundaryDups) seen.add(dup.replacementLineIndex);
164
+ const removed = [...seen].sort((a, b) => a - b).map((index) => `replacement_lines line ${index + 1}: ${clipLine(edit.content_lines[index] ?? "")}`);
165
+ throw new Error(`[E_BOUNDARY_STRICT] Strict boundary dedup rejects this edit: ${seen.size} replacement line(s) re-include edge lines (${range}). ${removed.join("; ")}. Resend without those lines.`);
166
+ }
167
+ export function planEdit(
168
+ content: string,
169
+ edit: HEdit,
170
+ precomputedHashes: string[],
171
+ options?: {
172
+ filePath?: string;
173
+ servedHashes?: ReadonlyMap<string, string>;
174
+ skipBoundaryDedup?: boolean;
175
+ strictBoundaryDedup?: boolean;
176
+ signal?: AbortSignal;
177
+ },
178
+ ): PlannedEdit {
179
+ const signal = options?.signal;
180
+ abortIf(signal);
181
+ const lineIndex = buildIdx(content);
182
+ const fileHashes = precomputedHashes;
183
+ const warnings: string[] = [];
184
+
185
+ const rangeFixed = swapReversedRanges(edit, fileHashes, warnings);
186
+ const prefixFixed = stripDiffPrefixes(
187
+ stripBarePrefixes(rangeFixed, fileHashes, warnings),
188
+ warnings,
189
+ );
190
+
191
+ const { resolved: initialResolved, mismatches, boundaryDups } = valEdit(
192
+ prefixFixed,
193
+ lineIndex.fileLines,
194
+ fileHashes,
195
+ warnings,
196
+ signal,
197
+ );
198
+ if (mismatches.length || !initialResolved) {
199
+ const feedback = fmtMismatchWithHashes(
200
+ mismatches,
201
+ lineIndex.fileLines,
202
+ fileHashes,
203
+ options?.filePath,
204
+ );
205
+ throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
206
+ }
207
+
208
+ warnUnicodeEsc(prefixFixed, warnings);
209
+
210
+ if (options?.strictBoundaryDedup === true && boundaryDups.length > 0) {
211
+ throwStrictBoundaryDedup(prefixFixed, boundaryDups, initialResolved);
212
+ }
213
+ let resolved = initialResolved;
214
+ let autoFixes: AutoFix[] | undefined;
215
+ if (boundaryDups.length > 0 && !(options?.skipBoundaryDedup === true)) {
216
+ autoFixes = [];
217
+ const correctedEdit: HEdit = {
218
+ ...prefixFixed,
219
+ content_lines: [...prefixFixed.content_lines],
220
+ };
221
+ const seen = new Set<number>();
222
+ const uniqueDups: BDup[] = [];
223
+ for (const dup of boundaryDups) {
224
+ if (seen.has(dup.replacementLineIndex)) continue;
225
+ seen.add(dup.replacementLineIndex);
226
+ uniqueDups.push(dup);
227
+ }
228
+ const dupsByIndex = uniqueDups.sort(
229
+ (a, b) => b.replacementLineIndex - a.replacementLineIndex,
230
+ );
231
+ for (const dup of dupsByIndex) {
232
+ const idx = dup.replacementLineIndex;
233
+ if (idx < 0 || idx >= correctedEdit.content_lines.length) continue;
234
+ const removed = correctedEdit.content_lines.splice(idx, 1)[0];
235
+ autoFixes.push({ kind: dup.kind, removedLine: removed, removedLineIndex: idx });
236
+ }
237
+ const correctedResult = valEdit(
238
+ correctedEdit,
239
+ lineIndex.fileLines,
240
+ fileHashes,
241
+ warnings,
242
+ signal,
243
+ );
244
+ if (correctedResult.mismatches.length || !correctedResult.resolved) {
245
+ const feedback = fmtMismatchWithHashes(
246
+ correctedResult.mismatches,
247
+ lineIndex.fileLines,
248
+ fileHashes,
249
+ options?.filePath,
250
+ );
251
+ throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
252
+ }
253
+ resolved = correctedResult.resolved;
254
+ }
255
+
256
+ if (options?.servedHashes) {
257
+ abortIf(signal);
258
+ assertRangeServed(resolved, lineIndex.fileLines, fileHashes, options.servedHashes, options?.filePath);
259
+ }
260
+
261
+ return {
262
+ resolved,
263
+ warnings,
264
+ ...(autoFixes ? { autoFixes } : {}),
265
+ };
266
+ }
151
267
  export function applyEdit(
152
268
  content: string,
153
269
  edit: HEdit,
@@ -156,6 +272,7 @@ export function applyEdit(
156
272
  filePath?: string,
157
273
  servedHashes?: ReadonlyMap<string, string>,
158
274
  skipBoundaryDedup?: boolean,
275
+ strictBoundaryDedup?: boolean,
159
276
  ): {
160
277
  content: string;
161
278
  firstChangedLine: number | undefined;
@@ -164,88 +281,15 @@ export function applyEdit(
164
281
  noopEdit?: NEdit;
165
282
  autoFixes?: AutoFix[];
166
283
  } {
167
- abortIf(signal);
168
-
169
- const lineIndex = buildIdx(content);
170
- if (precomputedHashes === undefined) {
171
- throw new Error("[E_BAD_SHAPE] applyEdit requires the file's allocated anchors; derive them via lineHashes(content, path) first.");
172
- }
173
- const fileHashes = precomputedHashes;
174
- const warnings: string[] = [];
175
-
176
- const rangeFixed = swapReversedRanges(edit, fileHashes, warnings);
177
- const prefixFixed = stripDiffPrefixes(
178
- stripBarePrefixes(rangeFixed, fileHashes, warnings),
179
- warnings,
180
- );
181
-
182
- const { resolved: initialResolved, mismatches, boundaryDups } = valEdit(
183
- prefixFixed,
184
- lineIndex.fileLines,
185
- fileHashes,
186
- warnings,
187
- signal,
188
- );
189
- if (mismatches.length || !initialResolved) {
190
- const feedback = fmtMismatchWithHashes(
191
- mismatches,
192
- lineIndex.fileLines,
193
- fileHashes,
194
- filePath,
195
- );
196
- throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
197
- }
198
-
199
- warnUnicodeEsc(prefixFixed, warnings);
200
-
201
- let resolved = initialResolved;
202
- let autoFixes: AutoFix[] | undefined;
203
- if (boundaryDups.length > 0 && !skipBoundaryDedup) {
204
- autoFixes = [];
205
- const correctedEdit: HEdit = {
206
- ...prefixFixed,
207
- content_lines: [...prefixFixed.content_lines],
208
- };
209
- const seen = new Set<number>();
210
- const uniqueDups: BDup[] = [];
211
- for (const dup of boundaryDups) {
212
- if (seen.has(dup.replacementLineIndex)) continue;
213
- seen.add(dup.replacementLineIndex);
214
- uniqueDups.push(dup);
215
- }
216
- const dupsByIndex = uniqueDups.sort(
217
- (a, b) => b.replacementLineIndex - a.replacementLineIndex,
218
- );
219
- for (const dup of dupsByIndex) {
220
- const idx = dup.replacementLineIndex;
221
- if (idx < 0 || idx >= correctedEdit.content_lines.length) continue;
222
- const removed = correctedEdit.content_lines.splice(idx, 1)[0];
223
- autoFixes.push({ kind: dup.kind, removedLine: removed, removedLineIndex: idx });
224
- }
225
- const correctedResult = valEdit(
226
- correctedEdit,
227
- lineIndex.fileLines,
228
- fileHashes,
229
- warnings,
230
- signal,
231
- );
232
- if (correctedResult.mismatches.length || !correctedResult.resolved) {
233
- const feedback = fmtMismatchWithHashes(
234
- correctedResult.mismatches,
235
- lineIndex.fileLines,
236
- fileHashes,
237
- filePath,
238
- );
239
- throw new AnchorMismatchError(feedback.text, feedback.hashes, feedback.servedMap);
240
- }
241
- resolved = correctedResult.resolved;
242
- }
243
-
244
- if (servedHashes) {
245
- abortIf(signal);
246
- assertRangeServed(resolved, lineIndex.fileLines, fileHashes, servedHashes, filePath);
247
- }
248
-
284
+ abortIf(signal);
285
+ if (precomputedHashes === undefined) {
286
+ throw new Error("[E_BAD_SHAPE] applyEdit requires the file's allocated anchors; derive them via lineHashes(content, path) first.");
287
+ }
288
+ const planned = planEdit(content, edit, precomputedHashes, { filePath, servedHashes, skipBoundaryDedup, strictBoundaryDedup, signal });
289
+ const lineIndex = buildIdx(content);
290
+ const warnings = planned.warnings;
291
+ const resolved = planned.resolved;
292
+ const autoFixes = planned.autoFixes;
249
293
  const spanResult = resToSpan(resolved, content, lineIndex);
250
294
  if (spanResult.kind === "noop") {
251
295
  return {
@@ -43,7 +43,10 @@ export {
43
43
  export {
44
44
  buildIdx,
45
45
  applyEdit,
46
+ planEdit,
47
+ type PlannedEdit,
46
48
  fmtRegion,
47
49
  fmtRow,
48
50
  changedRange,
51
+ assertNotEmpty,
49
52
  } from "./apply";
@@ -1,6 +1,7 @@
1
1
  import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine, getCached } from "../utils";
2
- import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
3
2
  import { parseHashRef, parseText, type Anchor } from "./parse";
3
+ import { HASH_SEP, stripRowPrefix, canon } from "./hash";
4
+ import { HASH_RUN } from "./alphabet";
4
5
  import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
5
6
  import { contentChecksum } from "./hasher";
6
7
  import { hashSource } from "./hash";
@@ -97,7 +98,7 @@ export function fmtMismatchWithHashes(
97
98
  if (notFound.length > 0) {
98
99
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
99
100
  out.push(
100
- `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file changed since read. Call read() for fresh anchors.`
101
+ `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file changed since read. Call read()${filePath ? ` on ${filePath}` : ""} for fresh anchors.`
101
102
  );
102
103
  for (const m of notFound) {
103
104
  const ctx = m.context;
@@ -536,8 +537,8 @@ export function assertRangeServed(
536
537
  : `${mismatchLines.length} of ${rangeLength} line(s) in the replaced range (lines ${startLine}-${endLine})${location} do not match`;
537
538
  const capHint =
538
539
  rangeLength > shownLength
539
- ? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read() with offset=${startLine + shownLength} to see the rest.]`
540
- : "";
540
+ ? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read()${filePath ? ` on ${filePath}` : ""} with offset=${startLine + shownLength} to see the rest.]`
541
+ : "\n\nRetry with the fresh anchors above without a read.";
541
542
  const message =
542
543
  `[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
543
544
  throw new RangeStaleError(message, shownHashes, shownMap);
package/src/insert.ts CHANGED
@@ -3,76 +3,72 @@ import { Type } from "typebox";
3
3
  import { constants } from "fs";
4
4
  import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
5
5
  import { commitEdit } from "./commit";
6
+ import { batchMemberFor, executeBatchMember, noteBatchFailure } from "./batch";
6
7
  import { readNormFile, type NormFile } from "./file-reader";
7
- import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
8
+ import { MAX_HASH_LINES, parseHashRef, resEdit, resolveAnchorLine, type Anchor, type HEdit } from "./hashline";
8
9
  import { stripAnchorRow } from "./hashline/resolve";
9
10
  import { loadP, loadGuide } from "./prompts";
10
- import { normReq } from "./payload-contract";
11
- import { decodeStringArray, isRec, rejectUnknownFields, splitLines } from "./utils";
11
+ import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
12
+ import { decodeStringArray, isRec, splitLines } from "./utils";
12
13
  import { clearBoundaryBypass } from "./boundary-bypass";
13
- import { resolveEditTarget } from "./edit-common";
14
+ import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
14
15
  import type { RPreview, RRState } from "./replace-render";
15
- import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
16
+ export { assertInsertReq, type InsertReq };
16
17
 
17
- const INSERT_KS = new Set(["anchor", "direction", "lines"]);
18
+ const insertAnchorSchema = Type.String({
19
+ description:
20
+ 'Bare 4-char anchor from a read row (the text before the `│` separator), never the row content. A pasted diff row or `anchor│` prefix is stripped with a warning. The anchor line is preserved; lines go after or before it.',
21
+ });
18
22
 
19
- export interface InsertReq {
20
- anchor: string;
21
- direction: "before" | "after";
22
- lines: string[];
23
- }
23
+ const insertDirectionSchema = Type.Union(
24
+ [Type.Literal("after"), Type.Literal("before")],
25
+ { description: '"after" or "before"' },
26
+ );
24
27
 
25
- export function assertInsertReq(request: unknown): asserts request is InsertReq {
26
- if (!isRec(request)) {
27
- throw new Error("[E_BAD_SHAPE] Insert request must be an object.");
28
- }
29
- rejectUnknownFields(request, INSERT_KS, "Insert request");
30
- if (typeof request.anchor !== "string" || request.anchor.length === 0) {
31
- throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
32
- }
33
- if (request.direction !== "before" && request.direction !== "after") {
34
- throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
35
- }
36
- if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
37
- throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
28
+ const insertLinesSchema = Type.Array(
29
+ Type.String({
30
+ description: "One line to insert; never embed \\n inside an element.",
31
+ }),
32
+ {
33
+ description: 'One string per line; [""] is a blank line; never include the anchor line.',
38
34
  }
39
- }
35
+ );
36
+
37
+ const insertPathRequiredSchema = Type.String({
38
+ description:
39
+ "Path to the file the anchor was served for; required and must match anchor ownership. The anchor still resolves the target.",
40
+ });
40
41
 
41
42
  const insertToolSchema = Type.Object(
42
43
  {
43
- anchor: Type.String({
44
- description:
45
- 'Bare 4-char anchor from a read row (the text before the `│` separator), never the row content. A pasted diff row or `anchor│` prefix is stripped with a warning. The anchor line is preserved; lines go after or before it.',
46
- }),
47
- direction: Type.Union(
48
- [
49
- Type.Literal("after"),
50
- Type.Literal("before"),
51
- ],
52
- { description: '"after" or "before"' },
53
- ),
54
- lines: Type.Array(
55
- Type.String({
56
- description:
57
- "One line to insert; never embed \\n inside an element.",
58
- }),
59
- {
60
- description:
61
- 'One string per line; [""] is a blank line; never include the anchor line.',
62
- }
63
- ),
44
+ anchor: insertAnchorSchema,
45
+ direction: insertDirectionSchema,
46
+ lines: insertLinesSchema,
64
47
  },
65
48
  { additionalProperties: false },
66
49
  );
67
50
 
68
- function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
51
+ export function buildInsertToolSchema(requirePath: boolean): typeof insertToolSchema {
52
+ if (!requirePath) return insertToolSchema;
53
+ return Type.Object(
54
+ {
55
+ path: insertPathRequiredSchema,
56
+ anchor: insertAnchorSchema,
57
+ direction: insertDirectionSchema,
58
+ lines: insertLinesSchema,
59
+ },
60
+ { additionalProperties: false },
61
+ ) as typeof insertToolSchema;
62
+ }
63
+
64
+ export function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
69
65
  const trimmedAnchor = raw.trim();
70
66
  const warnings: string[] = [];
71
67
  const anchorText = stripAnchorRow(trimmedAnchor, "anchor entry", warnings);
72
68
  return { ref: parseHashRef(anchorText), warnings };
73
69
  }
74
70
 
75
- function buildInsertEdit(
71
+ export function buildInsertEdit(
76
72
  req: InsertReq,
77
73
  preload: NormFile,
78
74
  ref: Anchor,
@@ -97,17 +93,28 @@ function buildInsertEdit(
97
93
  export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
98
94
  try {
99
95
  const normalized = normReq(request);
96
+ const previewFixes: string[] = [];
100
97
  if (isRec(normalized)) {
101
98
  const expanded = decodeStringArray(normalized.lines);
102
- if (expanded) normalized.lines = expanded;
99
+ if (expanded) {
100
+ previewFixes.push('[W_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
101
+ normalized.lines = expanded;
102
+ }
103
103
  }
104
104
  assertInsertReq(normalized);
105
- const { ref } = parseInsertAnchor(normalized.anchor);
106
- const targetPath = resolveEditTarget(normalized.anchor);
105
+ const previewReq = normalized as InsertReq;
106
+ const { ref, warnings: previewAnchorWarnings } = parseInsertAnchor(previewReq.anchor);
107
+ await throwIfStrictInput([...previewFixes, ...previewAnchorWarnings]);
108
+ const targetPath = await resolveEditTargetWithRequirement({
109
+ anchor: previewReq.anchor,
110
+ providedPath: previewReq.path,
111
+ cwd,
112
+ });
107
113
  const preload = await readNormFile(targetPath, cwd, {
108
114
  accessMode: constants.R_OK,
109
115
  maxLines: MAX_HASH_LINES,
110
116
  noPersist: true,
117
+ allocation: "shadow",
111
118
  signal,
112
119
  });
113
120
  const { editParams } = buildInsertEdit(normalized, preload, ref, targetPath);
@@ -151,15 +158,20 @@ function getInsertInput(args: unknown): { path?: string; anchor?: string; direct
151
158
 
152
159
  type InsertToolDef = ToolDefinition<any, ReplaceDetails, RRState> & { renderShell?: "default" | "self" };
153
160
 
154
- export function buildInsertToolDef(): InsertToolDef {
161
+ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): InsertToolDef {
162
+ const prompted = withInsertPrompts({
163
+ description: loadP("../prompts/insert.md"),
164
+ snippet: loadP("../prompts/insert-snippet.md"),
165
+ guidelines: loadGuide("../prompts/insert-guidelines.md"),
166
+ }, flags);
155
167
  return {
156
168
  name: "insert",
157
169
  label: "Insert",
158
- description: loadP("../prompts/insert.md"),
159
- promptSnippet: loadP("../prompts/insert-snippet.md"),
160
- promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
170
+ description: prompted.description,
171
+ promptSnippet: prompted.snippet,
172
+ promptGuidelines: prompted.guidelines,
161
173
  ...editToolBase,
162
- parameters: insertToolSchema,
174
+ parameters: buildInsertToolSchema(flags.requirePath),
163
175
  renderCall: editRenderCallWrapper(insertPreview, getInsertInput, "insert"),
164
176
  renderResult: editRenderResultWrapper,
165
177
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -174,37 +186,78 @@ export function buildInsertToolDef(): InsertToolDef {
174
186
  }
175
187
  assertInsertReq(canonical);
176
188
  const req = canonical;
177
- const targetPath = resolveEditTarget(req.anchor);
178
- const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
189
+ const targetPath = await resolveEditTargetWithRequirement({
190
+ anchor: req.anchor,
191
+ providedPath: req.path,
192
+ cwd: ctx.cwd,
193
+ }).catch((error: unknown) => {
194
+ const member = batchMemberFor(_toolCallId);
195
+ if (member) noteBatchFailure(member, error);
196
+ throw error;
197
+ });
198
+ let ref: Anchor;
199
+ let anchorWarnings: string[];
200
+ try {
201
+ ({ ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor));
202
+ await throwIfStrictInput([...anchorWarnings, ...insertWarnings]);
203
+ } catch (error) {
204
+ const member = batchMemberFor(_toolCallId);
205
+ if (member) noteBatchFailure(member, error);
206
+ throw error;
207
+ }
179
208
  return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
209
+ const member = batchMemberFor(_toolCallId);
180
210
  const preload = await readNormFile(targetPath, ctx.cwd, {
181
211
  signal,
182
212
  accessMode: constants.R_OK | constants.W_OK,
183
213
  maxLines: MAX_HASH_LINES,
184
214
  });
185
215
  const { editParams, anchorLine } = buildInsertEdit(req, preload, ref, targetPath);
186
- const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
187
- accessMode: constants.R_OK | constants.W_OK,
188
- signal,
189
- preloadedNorm: preload,
190
- skipBoundaryDedup: true,
191
- });
192
- return commitEdit(pipe, {
193
- path: pipe.path,
194
- absolutePath,
216
+ if (!member) {
217
+ const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
218
+ accessMode: constants.R_OK | constants.W_OK,
219
+ signal,
220
+ preloadedNorm: preload,
221
+ skipBoundaryDedup: true,
222
+ });
223
+ return commitEdit(pipe, {
224
+ path: pipe.path,
225
+ absolutePath,
226
+ mutationTargetPath,
227
+ signal,
228
+ verb: "inserted",
229
+ noopNoun: "Insertion",
230
+ foldedAnchorLines: anchorLine === undefined ? 0 : 1,
231
+ prefixWarnings: [...anchorWarnings, ...insertWarnings],
232
+ onApplied: () => clearBoundaryBypass(mutationTargetPath),
233
+ });
234
+ }
235
+ let hedit: HEdit;
236
+ const resWarnings: string[] = [];
237
+ try {
238
+ hedit = resEdit(editParams, resWarnings);
239
+ } catch (error) {
240
+ noteBatchFailure(member, error);
241
+ throw error;
242
+ }
243
+ return executeBatchMember({
244
+ kind: "insert",
245
+ member,
246
+ targetPath,
195
247
  mutationTargetPath,
248
+ cwd: ctx.cwd,
196
249
  signal,
197
- verb: "inserted",
198
- noopNoun: "Insertion",
199
- foldedAnchorLines: anchorLine === undefined ? 0 : 1,
200
- prefixWarnings: [...anchorWarnings, ...insertWarnings],
201
- onApplied: () => clearBoundaryBypass(mutationTargetPath),
250
+ hedit,
251
+ extraWarnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
252
+ skipBoundaryDedup: true,
253
+ strictBoundaryDedup: false,
254
+ foldedLines: anchorLine === undefined ? 0 : 1,
202
255
  });
203
256
  });
204
257
  },
205
258
  };
206
259
  }
207
260
 
208
- export function regInsert(pi: ExtensionAPI): void {
209
- pi.registerTool(buildInsertToolDef());
261
+ export function regInsert(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FLAGS): void {
262
+ pi.registerTool(buildInsertToolDef(flags));
210
263
  }
@@ -21,6 +21,10 @@ const removeToSchema = Type.String({
21
21
  description:
22
22
  "Bare 4-char anchor from a read row (the text before the `│` separator), never the row content. Marks the LAST line to remove (inclusive)",
23
23
  });
24
+ const pathRequiredSchema = Type.String({
25
+ description:
26
+ "Path to the file the anchors were served for; required and must match anchor ownership. Anchors still resolve the target.",
27
+ });
24
28
 
25
29
  export const editToolSchema = Type.Object(
26
30
  {
@@ -31,19 +35,35 @@ export const editToolSchema = Type.Object(
31
35
  { additionalProperties: false },
32
36
  );
33
37
 
38
+ export function buildEditToolSchema(requirePath: boolean): typeof editToolSchema {
39
+ if (!requirePath) return editToolSchema;
40
+ return Type.Object(
41
+ {
42
+ path: pathRequiredSchema,
43
+ remove_from: removeFromSchema,
44
+ remove_to: removeToSchema,
45
+ replacement_lines: replacementLinesSchema,
46
+ },
47
+ { additionalProperties: false },
48
+ ) as typeof editToolSchema;
49
+ }
50
+
34
51
  export type ReqParams = {
52
+ path?: string;
35
53
  remove_from: string;
36
54
  remove_to: string;
37
55
  replacement_lines: string[];
38
56
  };
39
57
 
40
- const ROOT_KS = new Set(["remove_from", "remove_to", "replacement_lines"]);
41
-
58
+ const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
42
59
  export function assertReq(request: unknown): asserts request is ReqParams {
43
60
  if (!isRec(request)) {
44
61
  throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
45
62
  }
46
63
  rejectUnknownFields(request, ROOT_KS, "Edit request");
64
+ if (request.path !== undefined && typeof request.path !== "string") {
65
+ throw new Error('[E_BAD_SHAPE] Edit request field "path" must be a string when provided.');
66
+ }
47
67
  if (
48
68
  typeof request.remove_from !== "string" ||
49
69
  typeof request.remove_to !== "string" ||
@@ -89,3 +109,31 @@ export function getPreviewInput(args: unknown): { path?: string; remove_from: st
89
109
  replacement_lines: normalized.replacement_lines as string[],
90
110
  };
91
111
  }
112
+
113
+ const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
114
+
115
+ export interface InsertReq {
116
+ path?: string;
117
+ anchor: string;
118
+ direction: "before" | "after";
119
+ lines: string[];
120
+ }
121
+
122
+ export function assertInsertReq(request: unknown): asserts request is InsertReq {
123
+ if (!isRec(request)) {
124
+ throw new Error("[E_BAD_SHAPE] Insert request must be an object.");
125
+ }
126
+ rejectUnknownFields(request, INSERT_KS, "Insert request");
127
+ if (request.path !== undefined && typeof request.path !== "string") {
128
+ throw new Error('[E_BAD_SHAPE] Insert request field "path" must be a string when provided.');
129
+ }
130
+ if (typeof request.anchor !== "string" || request.anchor.length === 0) {
131
+ throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
132
+ }
133
+ if (request.direction !== "before" && request.direction !== "after") {
134
+ throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
135
+ }
136
+ if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
137
+ throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
138
+ }
139
+ }