pi-hashline-edit-pro 4.1.0 → 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.
package/src/config.ts CHANGED
@@ -3,12 +3,14 @@ import { configPath } from "./paths";
3
3
  import { errCode, isRec } from "./utils";
4
4
  import { writeAtomic } from "./fs-write";
5
5
 
6
+ export type BoundaryDedupMode = "on" | "off" | "strict";
7
+
6
8
  export interface Config {
7
9
  autoRead: boolean;
8
10
  anchorGrepEnabled: boolean;
9
11
  requirePath?: boolean;
10
12
  strictInput?: boolean;
11
- boundaryDedupEnabled?: boolean;
13
+ boundaryDedupMode?: BoundaryDedupMode;
12
14
  }
13
15
 
14
16
  const DEFAULT_CONFIG: Config = {
@@ -16,9 +18,18 @@ const DEFAULT_CONFIG: Config = {
16
18
  anchorGrepEnabled: true,
17
19
  requirePath: false,
18
20
  strictInput: false,
19
- boundaryDedupEnabled: true
21
+ boundaryDedupMode: "on"
20
22
  };
21
23
 
24
+ const BOUNDARY_DEDUP_MODES: BoundaryDedupMode[] = ["on", "strict", "off"];
25
+
26
+ function parseBoundaryDedupMode(mode: unknown, legacy: unknown): BoundaryDedupMode {
27
+ if (mode === "on" || mode === "strict" || mode === "off") return mode;
28
+ if (legacy === true) return "on";
29
+ if (legacy === false) return "off";
30
+ return DEFAULT_CONFIG.boundaryDedupMode ?? "on";
31
+ }
32
+
22
33
  function parseConfig(content: string): Config {
23
34
  const parsed = JSON.parse(content) as unknown;
24
35
  const autoRead = isRec(parsed) ? parsed.autoRead : undefined;
@@ -28,13 +39,14 @@ function parseConfig(content: string): Config {
28
39
  const anchorGrepEnabled = isRec(parsed) ? parsed.anchorGrepEnabled : undefined;
29
40
  const requirePath = isRec(parsed) ? parsed.requirePath : undefined;
30
41
  const strictInput = isRec(parsed) ? parsed.strictInput : undefined;
31
- const boundaryDedupEnabled = isRec(parsed) ? parsed.boundaryDedupEnabled : undefined;
42
+ const boundaryDedupMode = isRec(parsed) ? parsed.boundaryDedupMode : undefined;
43
+ const legacyBoundaryDedup = isRec(parsed) ? parsed.boundaryDedupEnabled : undefined;
32
44
  return {
33
45
  autoRead,
34
46
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
35
47
  requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
36
48
  strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
37
- boundaryDedupEnabled: typeof boundaryDedupEnabled === "boolean" ? boundaryDedupEnabled : DEFAULT_CONFIG.boundaryDedupEnabled,
49
+ boundaryDedupMode: parseBoundaryDedupMode(boundaryDedupMode, legacyBoundaryDedup),
38
50
  };
39
51
  }
40
52
 
@@ -83,10 +95,11 @@ export async function toggleStrictInput(): Promise<boolean> {
83
95
  return config.strictInput === true;
84
96
  }
85
97
 
86
- export async function toggleBoundaryDedup(): Promise<boolean> {
98
+ export async function cycleBoundaryDedupMode(): Promise<BoundaryDedupMode> {
87
99
  const config = await readConfig();
88
- const enabled = config.boundaryDedupEnabled !== false;
89
- config.boundaryDedupEnabled = !enabled;
100
+ const current = config.boundaryDedupMode ?? "on";
101
+ const next = BOUNDARY_DEDUP_MODES[(BOUNDARY_DEDUP_MODES.indexOf(current) + 1) % BOUNDARY_DEDUP_MODES.length] ?? "on";
102
+ config.boundaryDedupMode = next;
90
103
  await writeConfig(config);
91
- return !enabled;
104
+ return next;
92
105
  }
@@ -3,7 +3,7 @@ import { resolveInCwd } from "./fs-write";
3
3
  import { abortIf, makePrepareArguments } from "./utils";
4
4
  import { ownerOf } from "./anchor-registry";
5
5
  import { parseHashRef, stripAnchorRow } from "./hashline";
6
- import { readConfig } from "./config";
6
+ import { readConfig, type BoundaryDedupMode } from "./config";
7
7
  import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
8
8
  import type { ReplaceDetails } from "./replace";
9
9
  export const editPrepare = makePrepareArguments();
@@ -11,14 +11,14 @@ export const editPrepare = makePrepareArguments();
11
11
  export interface EditToolFlags {
12
12
  requirePath: boolean;
13
13
  strictInput: boolean;
14
- boundaryDedupEnabled: boolean;
14
+ boundaryDedupMode: BoundaryDedupMode;
15
15
  autoRead: boolean;
16
16
  }
17
17
 
18
18
  export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
19
19
  requirePath: false,
20
20
  strictInput: false,
21
- boundaryDedupEnabled: true,
21
+ boundaryDedupMode: "on",
22
22
  autoRead: true
23
23
  };
24
24
 
@@ -27,7 +27,7 @@ export async function currentEditFlags(): Promise<EditToolFlags> {
27
27
  return {
28
28
  requirePath: config.requirePath === true,
29
29
  strictInput: config.strictInput === true,
30
- boundaryDedupEnabled: config.boundaryDedupEnabled !== false,
30
+ boundaryDedupMode: config.boundaryDedupMode ?? "on",
31
31
  autoRead: config.autoRead !== false
32
32
  };
33
33
  }
@@ -38,7 +38,7 @@ export function withReplacePrompts(base: { description: string; snippet: string;
38
38
  let guidelines = [...base.guidelines];
39
39
  if (!flags.autoRead) {
40
40
  description = description.replace(" Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading.", "");
41
- guidelines = guidelines.map((guideline) => guideline.includes("post-edit diff") ? "`replace`: one edit per turn; verify each result before the next edit on that file." : guideline);
41
+ guidelines = guidelines.map((guideline) => guideline.includes("post-edit diff") ? "`replace`: one batch per file per turn; verify each result before the next edit on that file." : guideline);
42
42
  }
43
43
  const descriptionParts = [description];
44
44
  if (flags.requirePath) {
@@ -50,9 +50,12 @@ export function withReplacePrompts(base: { description: string; snippet: string;
50
50
  descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
51
51
  guidelines.push("`replace`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
52
52
  }
53
- if (!flags.boundaryDedupEnabled) {
53
+ if (flags.boundaryDedupMode === "off") {
54
54
  descriptionParts.push("Boundary dedup is off: edits apply literally.");
55
55
  guidelines.push("`replace`: boundary dedup is off: edits apply literally.");
56
+ } else if (flags.boundaryDedupMode === "strict") {
57
+ descriptionParts.push("Boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
58
+ guidelines.push("`replace`: boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
56
59
  }
57
60
  return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
58
61
  }
@@ -116,10 +119,10 @@ export interface PathRequirementInput {
116
119
  export async function resolveEditTargetWithRequirement(input: PathRequirementInput): Promise<string> {
117
120
  const { requirePath } = await readConfig();
118
121
  if (!requirePath && input.providedPath !== undefined) {
119
- throw new Error("[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only; enable require-path in /hashline-config to require it.");
122
+ throw new Error("[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only; retry without `path`.");
120
123
  }
121
124
  if (requirePath && (typeof input.providedPath !== "string" || input.providedPath.length === 0)) {
122
- throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string when require-path mode is on (disable it in /hashline-config).');
125
+ throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string when require-path mode is on. Provide `path` matching the file the anchors were served for.');
123
126
  }
124
127
  const anchorTarget = typeof input.anchor === "string"
125
128
  ? resolveEditTarget(input.anchor)
@@ -138,13 +141,12 @@ export async function throwIfStrictInput(warnings: string[]): Promise<void> {
138
141
  if (fixes.length === 0) return;
139
142
  const { strictInput } = await readConfig();
140
143
  if (strictInput === true) {
141
- throw new Error(`[E_BAD_SHAPE] Strict-input mode rejects auto-fixable input (disable it in /hashline-config):\n${fixes.join("\n")}`);
144
+ throw new Error(`[E_BAD_SHAPE] Strict-input mode rejects auto-fixable input:\n${fixes.join("\n")}`);
142
145
  }
143
146
  }
144
147
 
145
- export async function isBoundaryDedupEnabled(): Promise<boolean> {
146
- const { boundaryDedupEnabled } = await readConfig();
147
- return boundaryDedupEnabled !== false;
148
+ export async function getBoundaryDedupMode(): Promise<BoundaryDedupMode> {
149
+ return (await readConfig()).boundaryDedupMode ?? "on";
148
150
  }
149
151
 
150
152
  export function editRenderCallWrapper(
package/src/grep.ts CHANGED
@@ -677,7 +677,7 @@ export function regGrep(pi: ExtensionAPI): void {
677
677
  if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
678
678
  if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
679
679
  if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
680
- if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; free anchors with /clear-anchors or narrow the search.]`);
680
+ if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
681
681
  const truncated = limitTruncated || rowTruncated;
682
682
  const truncation: TruncationResult | undefined = rowTruncated
683
683
  ? {
@@ -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";
package/src/insert.ts CHANGED
@@ -3,43 +3,17 @@ 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
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
-
16
- const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
17
-
18
- export interface InsertReq {
19
- path?: string;
20
- anchor: string;
21
- direction: "before" | "after";
22
- lines: string[];
23
- }
24
-
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 (request.path !== undefined && typeof request.path !== "string") {
31
- throw new Error('[E_BAD_SHAPE] Insert request field "path" must be a string when provided.');
32
- }
33
- if (typeof request.anchor !== "string" || request.anchor.length === 0) {
34
- throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (4-char anchor from read output).');
35
- }
36
- if (request.direction !== "before" && request.direction !== "after") {
37
- throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
38
- }
39
- if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
40
- throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
41
- }
42
- }
16
+ export { assertInsertReq, type InsertReq };
43
17
 
44
18
  const insertAnchorSchema = Type.String({
45
19
  description:
@@ -87,14 +61,14 @@ export function buildInsertToolSchema(requirePath: boolean): typeof insertToolSc
87
61
  ) as typeof insertToolSchema;
88
62
  }
89
63
 
90
- function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
64
+ export function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
91
65
  const trimmedAnchor = raw.trim();
92
66
  const warnings: string[] = [];
93
67
  const anchorText = stripAnchorRow(trimmedAnchor, "anchor entry", warnings);
94
68
  return { ref: parseHashRef(anchorText), warnings };
95
69
  }
96
70
 
97
- function buildInsertEdit(
71
+ export function buildInsertEdit(
98
72
  req: InsertReq,
99
73
  preload: NormFile,
100
74
  ref: Anchor,
@@ -216,32 +190,68 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
216
190
  anchor: req.anchor,
217
191
  providedPath: req.path,
218
192
  cwd: ctx.cwd,
193
+ }).catch((error: unknown) => {
194
+ const member = batchMemberFor(_toolCallId);
195
+ if (member) noteBatchFailure(member, error);
196
+ throw error;
219
197
  });
220
- const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
221
- await throwIfStrictInput([...anchorWarnings, ...insertWarnings]);
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
+ }
222
208
  return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
209
+ const member = batchMemberFor(_toolCallId);
223
210
  const preload = await readNormFile(targetPath, ctx.cwd, {
224
211
  signal,
225
212
  accessMode: constants.R_OK | constants.W_OK,
226
213
  maxLines: MAX_HASH_LINES,
227
214
  });
228
215
  const { editParams, anchorLine } = buildInsertEdit(req, preload, ref, targetPath);
229
- const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
230
- accessMode: constants.R_OK | constants.W_OK,
231
- signal,
232
- preloadedNorm: preload,
233
- skipBoundaryDedup: true,
234
- });
235
- return commitEdit(pipe, {
236
- path: pipe.path,
237
- 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,
238
247
  mutationTargetPath,
248
+ cwd: ctx.cwd,
239
249
  signal,
240
- verb: "inserted",
241
- noopNoun: "Insertion",
242
- foldedAnchorLines: anchorLine === undefined ? 0 : 1,
243
- prefixWarnings: [...anchorWarnings, ...insertWarnings],
244
- onApplied: () => clearBoundaryBypass(mutationTargetPath),
250
+ hedit,
251
+ extraWarnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
252
+ skipBoundaryDedup: true,
253
+ strictBoundaryDedup: false,
254
+ foldedLines: anchorLine === undefined ? 0 : 1,
245
255
  });
246
256
  });
247
257
  },
@@ -109,3 +109,31 @@ export function getPreviewInput(args: unknown): { path?: string; remove_from: st
109
109
  replacement_lines: normalized.replacement_lines as string[],
110
110
  };
111
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
+ }