diff --git a/src/tools/hashline-edit/tool-description.ts b/src/tools/hashline-edit/tool-description.ts
index c8a566860..211a487ad 100644
--- a/src/tools/hashline-edit/tool-description.ts
+++ b/src/tools/hashline-edit/tool-description.ts
@@ -7,63 +7,89 @@ WORKFLOW:
4. If same file needs another call, re-read first.
5. Use anchors as "LINE#ID" only (never include trailing "|content").
-VALIDATION:
- Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
- Each edit must be one of: replace, append, prepend
- Edit shape: { "op": "replace"|"append"|"prepend", "pos"?: "LINE#ID", "end"?: "LINE#ID", "lines": string|string[]|null }
- lines must contain plain replacement text only (no LINE#ID prefixes, no diff + markers)
- CRITICAL: all operations validate against the same pre-edit file snapshot and apply bottom-up. Refs/tags are interpreted against the last-read version of the file.
+
+- SNAPSHOT: All edits in one call reference the ORIGINAL file state. Do NOT adjust line numbers for prior edits in the same call — the system applies them bottom-up automatically.
+- replace removes lines pos..end (inclusive) and inserts lines in their place. Lines BEFORE pos and AFTER end are UNTOUCHED — do NOT include them in lines. If you do, they will appear twice.
+- lines must contain ONLY the content that belongs inside the consumed range. Content after end survives unchanged.
+- Tags MUST be copied exactly from read output or >>> mismatch output. NEVER guess tags.
+- Batch = multiple operations in edits[], NOT one big replace covering everything. Each operation targets the smallest possible change.
+- lines must contain plain replacement text only (no LINE#ID prefixes, no diff + markers).
+
-LINE#ID FORMAT (CRITICAL):
- Each line reference must be in "{line_number}#{hash_id}" format where:
- {line_number}: 1-based line number
- {hash_id}: Two CID letters from the set ZPMQVRWSNKTXJBYH
+
+LINE#ID FORMAT:
+ Each line reference must be in "{line_number}#{hash_id}" format where:
+ {line_number}: 1-based line number
+ {hash_id}: Two CID letters from the set ZPMQVRWSNKTXJBYH
-FILE MODES:
- delete=true deletes file and requires edits=[] with no rename
- rename moves final content to a new path and removes old path
+OPERATION CHOICE:
+ replace with pos only -> replace one line at pos
+ replace with pos+end -> replace range pos..end inclusive as a block (ranges MUST NOT overlap across edits)
+ append with pos/end anchor -> insert after that anchor
+ prepend with pos/end anchor -> insert before that anchor
+ append/prepend without anchors -> EOF/BOF insertion (also creates missing files)
CONTENT FORMAT:
lines can be a string (single line) or string[] (multi-line, preferred).
If you pass a multi-line string, it is split by real newline characters.
- Literal "\\n" is preserved as text.
+ lines: null or lines: [] with replace -> delete those lines.
-FILE CREATION:
- append without anchors adds content at EOF. If file does not exist, creates it.
- prepend without anchors adds content at BOF. If file does not exist, creates it.
- CRITICAL: only unanchored append/prepend can create a missing file.
+FILE MODES:
+ delete=true deletes file and requires edits=[] with no rename
+ rename moves final content to a new path and removes old path
-OPERATION CHOICE:
- replace with pos only -> replace one line at pos
- replace with pos+end -> replace ENTIRE range pos..end as a block (ranges MUST NOT overlap across edits)
- append with pos/end anchor -> insert after that anchor
- prepend with pos/end anchor -> insert before that anchor
- append/prepend without anchors -> EOF/BOF insertion
+RULES:
+ 1. Minimize scope: one logical mutation site per operation.
+ 2. Preserve formatting: keep indentation, punctuation, line breaks, trailing commas, brace style.
+ 3. Prefer insertion over neighbor rewrites: anchor to structural boundaries (}, ], },), not interior property lines.
+ 4. No no-ops: replacement content must differ from current content.
+ 5. Touch only requested code: avoid incidental edits.
+ 6. Use exact current tokens: NEVER rewrite approximately.
+ 7. For swaps/moves: prefer one range operation over multiple single-line operations.
+ 8. Anchor to structural lines (function/class/brace), NEVER blank lines.
+ 9. Re-read after each successful edit call before issuing another on the same file.
+
-RULES (CRITICAL):
- 1. Minimize scope: one logical mutation site per operation.
- 2. Preserve formatting: keep indentation, punctuation, line breaks, trailing commas, brace style.
- 3. Prefer insertion over neighbor rewrites: anchor to structural boundaries (}, ], },), not interior property lines.
- 4. No no-ops: replacement content must differ from current content.
- 5. Touch only requested code: avoid incidental edits.
- 6. Use exact current tokens: NEVER rewrite approximately.
- 7. For swaps/moves: prefer one range operation over multiple single-line operations.
- 8. Output tool calls only; no prose or commentary between them.
+
+Given this file content after read:
+ 10#VK|function hello() {
+ 11#XJ| console.log("hi");
+ 12#MB| console.log("bye");
+ 13#QR|}
+ 14#TN|
+ 15#WS|function world() {
-TAG CHOICE (ALWAYS):
- - Copy tags exactly from read output or >>> mismatch output.
- - NEVER guess tags.
- - Anchor to structural lines (function/class/brace), NEVER blank lines.
- - Anti-pattern warning: blank/whitespace anchors are fragile.
- - Re-read after each successful edit call before issuing another on the same file.
+Single-line replace (change line 11):
+ { op: "replace", pos: "11#XJ", lines: [" console.log(\\"hello\\");"] }
+ Result: line 11 replaced. Lines 10, 12-15 unchanged.
-AUTOCORRECT (built-in - you do NOT need to handle these):
- Merged lines are auto-expanded back to original line count.
- Indentation is auto-restored from original lines.
- BOM and CRLF line endings are preserved automatically.
- Hashline prefixes and diff markers in text are auto-stripped.
+Range replace (rewrite function body, lines 11-12):
+ { op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hello world\\";"] }
+ Result: lines 11-12 removed, replaced by 1 new line. Lines 10, 13-15 unchanged.
+
+Delete a line:
+ { op: "replace", pos: "12#MB", lines: null }
+ Result: line 12 removed. Lines 10-11, 13-15 unchanged.
+
+Insert after line 13 (between functions):
+ { op: "append", pos: "13#QR", lines: ["", "function added() {", " return true;", "}"] }
+ Result: 4 new lines inserted after line 13. All existing lines unchanged.
+
+BAD — lines extend past end (DUPLICATES line 13):
+ { op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hi\\";", "}"] }
+ Line 13 is "}" which already exists after end. Including "}" in lines duplicates it.
+ CORRECT: { op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hi\\";"] }
+
+
+
+Built-in autocorrect (you do NOT need to handle these):
+ Merged lines are auto-expanded back to original line count.
+ Indentation is auto-restored from original lines.
+ BOM and CRLF line endings are preserved automatically.
+ Hashline prefixes and diff markers in text are auto-stripped.
+ Boundary echo lines (duplicating adjacent surviving lines) are auto-stripped.
+
RECOVERY (when >>> mismatch error appears):
- Copy the updated LINE#ID tags shown in the error output directly.
- Re-read only if the needed tags are missing from the error snippet.
- ALWAYS batch all edits for one file in a single call.`
+ Copy the updated LINE#ID tags shown in the error output directly.
+ Re-read only if the needed tags are missing from the error snippet.`
diff --git a/src/tools/hashline-edit/tools.ts b/src/tools/hashline-edit/tools.ts
index 798b86273..6d9cb9424 100644
--- a/src/tools/hashline-edit/tools.ts
+++ b/src/tools/hashline-edit/tools.ts
@@ -30,7 +30,7 @@ export function createHashlineEditTool(): ToolDefinition {
pos: tool.schema.string().optional().describe("Primary anchor in LINE#ID format"),
end: tool.schema.string().optional().describe("Range end anchor in LINE#ID format"),
lines: tool.schema
- .union([tool.schema.string(), tool.schema.null()])
+ .union([tool.schema.array(tool.schema.string()), tool.schema.string(), tool.schema.null()])
.describe("Replacement or inserted lines as newline-delimited string. null deletes with replace"),
})
)
diff --git a/benchmarks/bun.lock b/tests/hashline/bun.lock
similarity index 100%
rename from benchmarks/bun.lock
rename to tests/hashline/bun.lock
diff --git a/benchmarks/headless.ts b/tests/hashline/headless.ts
similarity index 100%
rename from benchmarks/headless.ts
rename to tests/hashline/headless.ts
diff --git a/benchmarks/package.json b/tests/hashline/package.json
similarity index 100%
rename from benchmarks/package.json
rename to tests/hashline/package.json
diff --git a/benchmarks/test-edge-cases.ts b/tests/hashline/test-edge-cases.ts
similarity index 100%
rename from benchmarks/test-edge-cases.ts
rename to tests/hashline/test-edge-cases.ts
diff --git a/benchmarks/test-edit-ops.ts b/tests/hashline/test-edit-ops.ts
similarity index 100%
rename from benchmarks/test-edit-ops.ts
rename to tests/hashline/test-edit-ops.ts
diff --git a/benchmarks/test-multi-model.ts b/tests/hashline/test-multi-model.ts
similarity index 100%
rename from benchmarks/test-multi-model.ts
rename to tests/hashline/test-multi-model.ts