Merge pull request #2079 from minpeter/feat/hashline-edit-op-schema

refactor(hashline-edit): align tool payload to op/pos/end/lines
This commit is contained in:
YeonGyu-Kim 2026-02-24 15:13:45 +09:00 committed by GitHub
commit 5d30ec80df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 540 additions and 465 deletions

View File

@ -217,9 +217,9 @@ MCPサーバーがあなたのコンテキスト予算を食いつぶしてい
[oh-my-pi](https://github.com/can1357/oh-my-pi) に触発され、**Hashline**を実装しました。エージェントが読むすべての行にコンテンツハッシュがタグ付けされて返されます: [oh-my-pi](https://github.com/can1357/oh-my-pi) に触発され、**Hashline**を実装しました。エージェントが読むすべての行にコンテンツハッシュがタグ付けされて返されます:
``` ```
11#VK: function hello() { 11#VK| function hello() {
22#XJ: return "world"; 22#XJ| return "world";
33#MB: } 33#MB| }
``` ```
エージェントはこのタグを参照して編集します。最後に読んだ後でファイルが変更されていた場合、ハッシュが一致せず、コードが壊れる前に編集が拒否されます。空白を正確に再現する必要もなく、間違った行を編集するエラー (stale-line) もありません。 エージェントはこのタグを参照して編集します。最後に読んだ後でファイルが変更されていた場合、ハッシュが一致せず、コードが壊れる前に編集が拒否されます。空白を正確に再現する必要もなく、間違った行を編集するエラー (stale-line) もありません。

View File

@ -216,9 +216,9 @@ MCP 서버들이 당신의 컨텍스트 예산을 다 잡아먹죠. 우리가
[oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감을 받아, **Hashline**을 구현했습니다. 에이전트가 읽는 모든 줄에는 콘텐츠 해시 태그가 붙어 나옵니다: [oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감을 받아, **Hashline**을 구현했습니다. 에이전트가 읽는 모든 줄에는 콘텐츠 해시 태그가 붙어 나옵니다:
``` ```
11#VK: function hello() { 11#VK| function hello() {
22#XJ: return "world"; 22#XJ| return "world";
33#MB: } 33#MB| }
``` ```
에이전트는 이 태그를 참조해서 편집합니다. 마지막으로 읽은 후 파일이 변경되었다면 해시가 일치하지 않아 코드가 망가지기 전에 편집이 거부됩니다. 공백을 똑같이 재현할 필요도 없고, 엉뚱한 줄을 수정하는 에러(stale-line)도 없습니다. 에이전트는 이 태그를 참조해서 편집합니다. 마지막으로 읽은 후 파일이 변경되었다면 해시가 일치하지 않아 코드가 망가지기 전에 편집이 거부됩니다. 공백을 똑같이 재현할 필요도 없고, 엉뚱한 줄을 수정하는 에러(stale-line)도 없습니다.

View File

@ -220,9 +220,9 @@ The harness problem is real. Most agent failures aren't the model. It's the edit
Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi), we implemented **Hashline**. Every line the agent reads comes back tagged with a content hash: Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi), we implemented **Hashline**. Every line the agent reads comes back tagged with a content hash:
``` ```
11#VK: function hello() { 11#VK| function hello() {
22#XJ: return "world"; 22#XJ| return "world";
33#MB: } 33#MB| }
``` ```
The agent edits by referencing those tags. If the file changed since the last read, the hash won't match and the edit is rejected before corruption. No whitespace reproduction. No stale-line errors. The agent edits by referencing those tags. If the file changed since the last read, the hash won't match and the edit is rejected before corruption. No whitespace reproduction. No stale-line errors.

View File

@ -218,9 +218,9 @@ Harness 问题是真的。绝大多数所谓的 Agent 故障,其实并不是
受 [oh-my-pi](https://github.com/can1357/oh-my-pi) 的启发,我们实现了 **Hashline** 技术。Agent 读到的每一行代码,末尾都会打上一个强绑定的内容哈希值: 受 [oh-my-pi](https://github.com/can1357/oh-my-pi) 的启发,我们实现了 **Hashline** 技术。Agent 读到的每一行代码,末尾都会打上一个强绑定的内容哈希值:
``` ```
11#VK: function hello() { 11#VK| function hello() {
22#XJ: return "world"; 22#XJ| return "world";
33#MB: } 33#MB| }
``` ```
Agent 发起修改时,必须通过这些标签引用目标行。如果在此期间文件发生过变化,哈希验证就会失败,从而在代码被污染前直接驳回。不再有缩进空格错乱,彻底告别改错行的惨剧。 Agent 发起修改时,必须通过这些标签引用目标行。如果在此期间文件发生过变化,哈希验证就会失败,从而在代码被污染前直接驳回。不再有缩进空格错乱,彻底告别改错行的惨剧。

View File

@ -6,9 +6,12 @@ interface HashlineReadEnhancerConfig {
hashline_edit?: { enabled: boolean } hashline_edit?: { enabled: boolean }
} }
const READ_LINE_PATTERN = /^(\d+): ?(.*)$/ const COLON_READ_LINE_PATTERN = /^\s*(\d+): ?(.*)$/
const PIPE_READ_LINE_PATTERN = /^\s*(\d+)\| ?(.*)$/
const CONTENT_OPEN_TAG = "<content>" const CONTENT_OPEN_TAG = "<content>"
const CONTENT_CLOSE_TAG = "</content>" const CONTENT_CLOSE_TAG = "</content>"
const FILE_OPEN_TAG = "<file>"
const FILE_CLOSE_TAG = "</file>"
function isReadTool(toolName: string): boolean { function isReadTool(toolName: string): boolean {
return toolName.toLowerCase() === "read" return toolName.toLowerCase() === "read"
@ -24,18 +27,36 @@ function shouldProcess(config: HashlineReadEnhancerConfig): boolean {
function isTextFile(output: string): boolean { function isTextFile(output: string): boolean {
const firstLine = output.split("\n")[0] ?? "" const firstLine = output.split("\n")[0] ?? ""
return READ_LINE_PATTERN.test(firstLine) return COLON_READ_LINE_PATTERN.test(firstLine) || PIPE_READ_LINE_PATTERN.test(firstLine)
}
function parseReadLine(line: string): { lineNumber: number; content: string } | null {
const colonMatch = COLON_READ_LINE_PATTERN.exec(line)
if (colonMatch) {
return {
lineNumber: Number.parseInt(colonMatch[1], 10),
content: colonMatch[2],
}
}
const pipeMatch = PIPE_READ_LINE_PATTERN.exec(line)
if (pipeMatch) {
return {
lineNumber: Number.parseInt(pipeMatch[1], 10),
content: pipeMatch[2],
}
}
return null
} }
function transformLine(line: string): string { function transformLine(line: string): string {
const match = READ_LINE_PATTERN.exec(line) const parsed = parseReadLine(line)
if (!match) { if (!parsed) {
return line return line
} }
const lineNumber = parseInt(match[1], 10) const hash = computeLineHash(parsed.lineNumber, parsed.content)
const content = match[2] return `${parsed.lineNumber}#${hash}|${parsed.content}`
const hash = computeLineHash(lineNumber, content)
return `${lineNumber}#${hash}:${content}`
} }
function transformOutput(output: string): string { function transformOutput(output: string): string {
@ -44,25 +65,43 @@ function transformOutput(output: string): string {
} }
const lines = output.split("\n") const lines = output.split("\n")
const contentStart = lines.indexOf(CONTENT_OPEN_TAG) const contentStart = lines.findIndex(
(line) => line === CONTENT_OPEN_TAG || line.startsWith(CONTENT_OPEN_TAG)
)
const contentEnd = lines.indexOf(CONTENT_CLOSE_TAG) const contentEnd = lines.indexOf(CONTENT_CLOSE_TAG)
const fileStart = lines.findIndex((line) => line === FILE_OPEN_TAG || line.startsWith(FILE_OPEN_TAG))
const fileEnd = lines.indexOf(FILE_CLOSE_TAG)
if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart + 1) { const blockStart = contentStart !== -1 ? contentStart : fileStart
const fileLines = lines.slice(contentStart + 1, contentEnd) const blockEnd = contentStart !== -1 ? contentEnd : fileEnd
const openTag = contentStart !== -1 ? CONTENT_OPEN_TAG : FILE_OPEN_TAG
if (blockStart !== -1 && blockEnd !== -1 && blockEnd > blockStart) {
const openLine = lines[blockStart] ?? ""
const inlineFirst = openLine.startsWith(openTag) && openLine !== openTag
? openLine.slice(openTag.length)
: null
const fileLines = inlineFirst !== null
? [inlineFirst, ...lines.slice(blockStart + 1, blockEnd)]
: lines.slice(blockStart + 1, blockEnd)
if (!isTextFile(fileLines[0] ?? "")) { if (!isTextFile(fileLines[0] ?? "")) {
return output return output
} }
const result: string[] = [] const result: string[] = []
for (const line of fileLines) { for (const line of fileLines) {
if (!READ_LINE_PATTERN.test(line)) { if (!parseReadLine(line)) {
result.push(...fileLines.slice(result.length)) result.push(...fileLines.slice(result.length))
break break
} }
result.push(transformLine(line)) result.push(transformLine(line))
} }
return [...lines.slice(0, contentStart + 1), ...result, ...lines.slice(contentEnd)].join("\n") const prefixLines = inlineFirst !== null
? [...lines.slice(0, blockStart), openTag]
: lines.slice(0, blockStart + 1)
return [...prefixLines, ...result, ...lines.slice(blockEnd)].join("\n")
} }
if (!isTextFile(lines[0] ?? "")) { if (!isTextFile(lines[0] ?? "")) {
@ -71,7 +110,7 @@ function transformOutput(output: string): string {
const result: string[] = [] const result: string[] = []
for (const line of lines) { for (const line of lines) {
if (!READ_LINE_PATTERN.test(line)) { if (!parseReadLine(line)) {
result.push(...lines.slice(result.length)) result.push(...lines.slice(result.length))
break break
} }
@ -98,7 +137,7 @@ function extractFilePath(metadata: unknown): string | undefined {
} }
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> { async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
if (output.output.includes("Updated file (LINE#ID:content):")) { if (output.output.includes("Updated file (LINE#ID|content):")) {
return return
} }
@ -114,7 +153,7 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
const content = await file.text() const content = await file.text()
const hashlined = toHashlineContent(content) const hashlined = toHashlineContent(content)
output.output = `${output.output}\n\nUpdated file (LINE#ID:content):\n${hashlined}` output.output = `${output.output}\n\nUpdated file (LINE#ID|content):\n${hashlined}`
} }
export function createHashlineReadEnhancerHook( export function createHashlineReadEnhancerHook(

View File

@ -1,3 +1,5 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test" import { describe, it, expect } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { createHashlineReadEnhancerHook } from "./hook" import { createHashlineReadEnhancerHook } from "./hook"
@ -45,11 +47,43 @@ describe("hashline-read-enhancer", () => {
//#then //#then
const lines = output.output.split("\n") const lines = output.output.split("\n")
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1$/) expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2$/) expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
expect(lines[10]).toBe("1: keep this unchanged") expect(lines[10]).toBe("1: keep this unchanged")
}) })
it("hashifies inline <content> format from updated OpenCode read tool", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const output = {
title: "demo.ts",
output: [
"<path>/tmp/demo.ts</path>",
"<type>file</type>",
"<content>1: const x = 1",
"2: const y = 2",
"",
"(End of file - total 2 lines)",
"</content>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[0]).toBe("<path>/tmp/demo.ts</path>")
expect(lines[1]).toBe("<type>file</type>")
expect(lines[2]).toBe("<content>")
expect(lines[3]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
expect(lines[6]).toBe("(End of file - total 2 lines)")
expect(lines[7]).toBe("</content>")
})
it("hashifies plain read output without content tags", async () => { it("hashifies plain read output without content tags", async () => {
//#given //#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
@ -71,12 +105,65 @@ describe("hashline-read-enhancer", () => {
//#then //#then
const lines = output.output.split("\n") const lines = output.output.split("\n")
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:# Oh-My-OpenCode Features$/) expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|# Oh-My-OpenCode Features$/)
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:$/) expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|$/)
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:Hashline test$/) expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}\|Hashline test$/)
expect(lines[4]).toBe("(End of file - total 3 lines)") expect(lines[4]).toBe("(End of file - total 3 lines)")
}) })
it("hashifies read output with <file> and zero-padded pipe format", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const output = {
title: "demo.ts",
output: [
"<file>",
"00001| const x = 1",
"00002| const y = 2",
"",
"(End of file - total 2 lines)",
"</file>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
expect(lines[5]).toBe("</file>")
})
it("hashifies pipe format even with leading spaces", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const output = {
title: "demo.ts",
output: [
"<file>",
" 00001| const x = 1",
" 00002| const y = 2",
"",
"(End of file - total 2 lines)",
"</file>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[1]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1$/)
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
})
it("appends LINE#ID output for write tool using metadata filepath", async () => { it("appends LINE#ID output for write tool using metadata filepath", async () => {
//#given //#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
@ -94,9 +181,9 @@ describe("hashline-read-enhancer", () => {
await hook["tool.execute.after"](input, output) await hook["tool.execute.after"](input, output)
//#then //#then
expect(output.output).toContain("Updated file (LINE#ID:content):") expect(output.output).toContain("Updated file (LINE#ID|content):")
expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}:const x = 1/) expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1/)
expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}:const y = 2/) expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2/)
fs.rmSync(tempDir, { recursive: true, force: true }) fs.rmSync(tempDir, { recursive: true, force: true })
}) })

View File

@ -15,6 +15,7 @@ export function stripMergeOperatorChars(text: string): string {
} }
function leadingWhitespace(text: string): string { function leadingWhitespace(text: string): string {
if (!text) return ""
const match = text.match(/^\s*/) const match = text.match(/^\s*/)
return match ? match[0] : "" return match ? match[0] : ""
} }
@ -36,7 +37,9 @@ export function restoreOldWrappedLines(originalLines: string[], replacementLines
const candidates: { start: number; len: number; replacement: string; canonical: string }[] = [] const candidates: { start: number; len: number; replacement: string; canonical: string }[] = []
for (let start = 0; start < replacementLines.length; start += 1) { for (let start = 0; start < replacementLines.length; start += 1) {
for (let len = 2; len <= 10 && start + len <= replacementLines.length; len += 1) { for (let len = 2; len <= 10 && start + len <= replacementLines.length; len += 1) {
const canonicalSpan = stripAllWhitespace(replacementLines.slice(start, start + len).join("")) const span = replacementLines.slice(start, start + len)
if (span.some((line) => line.trim().length === 0)) continue
const canonicalSpan = stripAllWhitespace(span.join(""))
const original = canonicalToOriginal.get(canonicalSpan) const original = canonicalToOriginal.get(canonicalSpan)
if (original && original.count === 1 && canonicalSpan.length >= 6) { if (original && original.count === 1 && canonicalSpan.length >= 6) {
candidates.push({ start, len, replacement: original.line, canonical: canonicalSpan }) candidates.push({ start, len, replacement: original.line, canonical: canonicalSpan })
@ -159,6 +162,7 @@ export function restoreIndentForPairedReplacement(
if (leadingWhitespace(line).length > 0) return line if (leadingWhitespace(line).length > 0) return line
const indent = leadingWhitespace(originalLines[idx]) const indent = leadingWhitespace(originalLines[idx])
if (indent.length === 0) return line if (indent.length === 0) return line
if (originalLines[idx].trim() === line.trim()) return line
return `${indent}${line}` return `${indent}${line}`
}) })
} }

View File

@ -9,7 +9,7 @@ export function toHashlineContent(content: string): string {
const hashlined = contentLines.map((line, i) => { const hashlined = contentLines.map((line, i) => {
const lineNum = i + 1 const lineNum = i + 1
const hash = computeLineHash(lineNum, line) const hash = computeLineHash(lineNum, line)
return `${lineNum}#${hash}:${line}` return `${lineNum}#${hash}|${line}`
}) })
return hasTrailingNewline ? hashlined.join("\n") + "\n" : hashlined.join("\n") return hasTrailingNewline ? hashlined.join("\n") + "\n" : hashlined.join("\n")
} }

View File

@ -6,23 +6,13 @@ function normalizeEditPayload(payload: string | string[]): string {
} }
function buildDedupeKey(edit: HashlineEdit): string { function buildDedupeKey(edit: HashlineEdit): string {
switch (edit.type) { switch (edit.op) {
case "set_line":
return `set_line|${edit.line}|${normalizeEditPayload(edit.text)}`
case "replace_lines":
return `replace_lines|${edit.start_line}|${edit.end_line}|${normalizeEditPayload(edit.text)}`
case "insert_after":
return `insert_after|${edit.line}|${normalizeEditPayload(edit.text)}`
case "insert_before":
return `insert_before|${edit.line}|${normalizeEditPayload(edit.text)}`
case "insert_between":
return `insert_between|${edit.after_line}|${edit.before_line}|${normalizeEditPayload(edit.text)}`
case "replace": case "replace":
return `replace|${edit.old_text}|${normalizeEditPayload(edit.new_text)}` return `replace|${edit.pos}|${edit.end ?? ""}|${normalizeEditPayload(edit.lines)}`
case "append": case "append":
return `append|${normalizeEditPayload(edit.text)}` return `append|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
case "prepend": case "prepend":
return `prepend|${normalizeEditPayload(edit.text)}` return `prepend|${edit.pos ?? ""}|${normalizeEditPayload(edit.lines)}`
default: default:
return JSON.stringify(edit) return JSON.stringify(edit)
} }

View File

@ -63,7 +63,7 @@ export function applyReplaceLines(
const corrected = autocorrectReplacementLines(originalRange, stripped) const corrected = autocorrectReplacementLines(originalRange, stripped)
const restored = corrected.map((entry, idx) => { const restored = corrected.map((entry, idx) => {
if (idx !== 0) return entry if (idx !== 0) return entry
return restoreLeadingIndent(lines[startLine - 1], entry) return restoreLeadingIndent(lines[startLine - 1] ?? "", entry)
}) })
result.splice(startLine - 1, endLine - startLine + 1, ...restored) result.splice(startLine - 1, endLine - startLine + 1, ...restored)
return result return result
@ -150,11 +150,3 @@ export function applyPrepend(lines: string[], text: string | string[]): string[]
} }
return [...normalized, ...lines] return [...normalized, ...lines]
} }
export function applyReplace(content: string, oldText: string, newText: string | string[]): string {
if (!content.includes(oldText)) {
throw new Error(`Text not found: "${oldText}"`)
}
const replacement = Array.isArray(newText) ? newText.join("\n") : newText
return content.replaceAll(oldText, replacement)
}

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test" import { describe, expect, it } from "bun:test"
import { applyHashlineEdits, applyInsertAfter, applyReplace, applyReplaceLines, applySetLine } from "./edit-operations" import { applyHashlineEdits, applyInsertAfter, applyReplaceLines, applySetLine } from "./edit-operations"
import { applyAppend, applyPrepend } from "./edit-operation-primitives" import { applyAppend, applyInsertBetween, applyPrepend } from "./edit-operation-primitives"
import { computeLineHash } from "./hash-computation" import { computeLineHash } from "./hash-computation"
import type { HashlineEdit } from "./types" import type { HashlineEdit } from "./types"
@ -49,7 +49,7 @@ describe("hashline edit operations", () => {
//#when //#when
const result = applyHashlineEdits( const result = applyHashlineEdits(
lines.join("\n"), lines.join("\n"),
[{ type: "insert_before", line: anchorFor(lines, 2), text: "before 2" }] [{ op: "prepend", pos: anchorFor(lines, 2), lines: "before 2" }]
) )
//#then //#then
@ -61,15 +61,7 @@ describe("hashline edit operations", () => {
const lines = ["line 1", "line 2", "line 3"] const lines = ["line 1", "line 2", "line 3"]
//#when //#when
const result = applyHashlineEdits( const result = applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), ["between"]).join("\n")
lines.join("\n"),
[{
type: "insert_between",
after_line: anchorFor(lines, 1),
before_line: anchorFor(lines, 2),
text: ["between"],
}]
)
//#then //#then
expect(result).toEqual("line 1\nbetween\nline 2\nline 3") expect(result).toEqual("line 1\nbetween\nline 2\nline 3")
@ -89,7 +81,7 @@ describe("hashline edit operations", () => {
//#when / #then //#when / #then
expect(() => expect(() =>
applyHashlineEdits(lines.join("\n"), [{ type: "insert_before", line: anchorFor(lines, 1), text: [] }]) applyHashlineEdits(lines.join("\n"), [{ op: "prepend", pos: anchorFor(lines, 1), lines: [] }])
).toThrow(/non-empty/i) ).toThrow(/non-empty/i)
}) })
@ -98,28 +90,7 @@ describe("hashline edit operations", () => {
const lines = ["line 1", "line 2"] const lines = ["line 1", "line 2"]
//#when / #then //#when / #then
expect(() => expect(() => applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), [])).toThrow(/non-empty/i)
applyHashlineEdits(
lines.join("\n"),
[{
type: "insert_between",
after_line: anchorFor(lines, 1),
before_line: anchorFor(lines, 2),
text: [],
}]
)
).toThrow(/non-empty/i)
})
it("applies replace operation", () => {
//#given
const content = "hello world foo"
//#when
const result = applyReplace(content, "world", "universe")
//#then
expect(result).toEqual("hello universe foo")
}) })
it("applies mixed edits in one pass", () => { it("applies mixed edits in one pass", () => {
@ -127,8 +98,8 @@ describe("hashline edit operations", () => {
const content = "line 1\nline 2\nline 3" const content = "line 1\nline 2\nline 3"
const lines = content.split("\n") const lines = content.split("\n")
const edits: HashlineEdit[] = [ const edits: HashlineEdit[] = [
{ type: "insert_after", line: anchorFor(lines, 1), text: "inserted" }, { op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
{ type: "set_line", line: anchorFor(lines, 3), text: "modified" }, { op: "replace", pos: anchorFor(lines, 3), lines: "modified" },
] ]
//#when //#when
@ -143,8 +114,8 @@ describe("hashline edit operations", () => {
const content = "line 1\nline 2" const content = "line 1\nline 2"
const lines = content.split("\n") const lines = content.split("\n")
const edits: HashlineEdit[] = [ const edits: HashlineEdit[] = [
{ type: "insert_after", line: anchorFor(lines, 1), text: "inserted" }, { op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
{ type: "insert_after", line: anchorFor(lines, 1), text: "inserted" }, { op: "append", pos: anchorFor(lines, 1), lines: "inserted" },
] ]
//#when //#when
@ -170,7 +141,7 @@ describe("hashline edit operations", () => {
const lines = ["line 1", "line 2", "line 3"] const lines = ["line 1", "line 2", "line 3"]
//#when //#when
const result = applySetLine(lines, anchorFor(lines, 2), "1#VK:first\n2#NP:second") const result = applySetLine(lines, anchorFor(lines, 2), "1#VK|first\n2#NP|second")
//#then //#then
expect(result).toEqual(["line 1", "first", "second", "line 3"]) expect(result).toEqual(["line 1", "first", "second", "line 3"])
@ -206,6 +177,28 @@ describe("hashline edit operations", () => {
expect(result).toEqual(["if (x) {", " return 2", "}"]) expect(result).toEqual(["if (x) {", " return 2", "}"])
}) })
it("preserves intentional indentation removal (tab to no-tab)", () => {
//#given
const lines = ["# Title", "\t1절", "content"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "1절")
//#then
expect(result).toEqual(["# Title", "1절", "content"])
})
it("preserves intentional indentation removal (spaces to no-spaces)", () => {
//#given
const lines = ["function foo() {", " indented", "}"]
//#when
const result = applySetLine(lines, anchorFor(lines, 2), "indented")
//#then
expect(result).toEqual(["function foo() {", "indented", "}"])
})
it("strips boundary echo around replace_lines content", () => { it("strips boundary echo around replace_lines content", () => {
//#given //#given
const lines = ["before", "old 1", "old 2", "after"] const lines = ["before", "old 1", "old 2", "after"]
@ -227,16 +220,9 @@ describe("hashline edit operations", () => {
const lines = ["line 1", "line 2", "line 3"] const lines = ["line 1", "line 2", "line 3"]
//#when / #then //#when / #then
expect(() => expect(() => applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), ["line 1", "line 2"])).toThrow(
applyHashlineEdits(lines.join("\n"), [ /non-empty/i
{ )
type: "insert_between",
after_line: anchorFor(lines, 1),
before_line: anchorFor(lines, 2),
text: ["line 1", "line 2"],
},
])
).toThrow(/non-empty/i)
}) })
it("restores indentation for first replace_lines entry", () => { it("restores indentation for first replace_lines entry", () => {
@ -250,6 +236,22 @@ describe("hashline edit operations", () => {
expect(result).toEqual(["if (x) {", " return 3", " return 4", "}"]) expect(result).toEqual(["if (x) {", " return 3", " return 4", "}"])
}) })
it("preserves blank lines and indentation in range replace (no false unwrap)", () => {
//#given — reproduces the 애국가 bug where blank+indented lines collapse
const lines = ["", "동해물과 백두산이 마르고 닳도록", "하느님이 보우하사 우리나라 만세", "", "무궁화 삼천리 화려강산", "대한사람 대한으로 길이 보전하세", ""]
//#when — replace the range with indented version (blank lines preserved)
const result = applyReplaceLines(
lines,
anchorFor(lines, 1),
anchorFor(lines, 7),
["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""]
)
//#then — all 7 lines preserved with indentation, not collapsed to 3
expect(result).toEqual(["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""])
})
it("collapses wrapped replacement span back to unique original single line", () => { it("collapses wrapped replacement span back to unique original single line", () => {
//#given //#given
const lines = [ const lines = [
@ -322,8 +324,8 @@ describe("hashline edit operations", () => {
//#when //#when
const result = applyHashlineEdits(content, [ const result = applyHashlineEdits(content, [
{ type: "append", text: ["line 3"] }, { op: "append", lines: ["line 3"] },
{ type: "prepend", text: ["line 0"] }, { op: "prepend", lines: ["line 0"] },
]) ])
//#then //#then
@ -367,4 +369,33 @@ describe("hashline edit operations", () => {
//#then //#then
expect(result).toEqual(["const a = 10;", "const b = 20;"]) expect(result).toEqual(["const a = 10;", "const b = 20;"])
}) })
it("throws on overlapping range edits", () => {
//#given
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "replace", pos: anchorFor(lines, 1), end: anchorFor(lines, 3), lines: "replaced A" },
{ op: "replace", pos: anchorFor(lines, 2), end: anchorFor(lines, 4), lines: "replaced B" },
]
//#when / #then
expect(() => applyHashlineEdits(content, edits)).toThrow(/overlapping/i)
})
it("allows non-overlapping range edits", () => {
//#given
const content = "line 1\nline 2\nline 3\nline 4\nline 5"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "replace", pos: anchorFor(lines, 1), end: anchorFor(lines, 2), lines: "replaced A" },
{ op: "replace", pos: anchorFor(lines, 4), end: anchorFor(lines, 5), lines: "replaced B" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("replaced A\nline 3\nreplaced B")
})
}) })

View File

@ -1,13 +1,11 @@
import { dedupeEdits } from "./edit-deduplication" import { dedupeEdits } from "./edit-deduplication"
import { collectLineRefs, getEditLineNumber } from "./edit-ordering" import { collectLineRefs, detectOverlappingRanges, getEditLineNumber } from "./edit-ordering"
import type { HashlineEdit } from "./types" import type { HashlineEdit } from "./types"
import { import {
applyAppend, applyAppend,
applyInsertAfter, applyInsertAfter,
applyInsertBefore, applyInsertBefore,
applyInsertBetween,
applyPrepend, applyPrepend,
applyReplace,
applyReplaceLines, applyReplaceLines,
applySetLine, applySetLine,
} from "./edit-operation-primitives" } from "./edit-operation-primitives"
@ -33,42 +31,20 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
let noopEdits = 0 let noopEdits = 0
let result = content let lines = content.length === 0 ? [] : content.split("\n")
let lines = result.length === 0 ? [] : result.split("\n")
const refs = collectLineRefs(sortedEdits) const refs = collectLineRefs(sortedEdits)
validateLineRefs(lines, refs) validateLineRefs(lines, refs)
const overlapError = detectOverlappingRanges(sortedEdits)
if (overlapError) throw new Error(overlapError)
for (const edit of sortedEdits) { for (const edit of sortedEdits) {
switch (edit.type) { switch (edit.op) {
case "set_line": { case "replace": {
lines = applySetLine(lines, edit.line, edit.text, { skipValidation: true }) const next = edit.end
break ? applyReplaceLines(lines, edit.pos, edit.end, edit.lines, { skipValidation: true })
} : applySetLine(lines, edit.pos, edit.lines, { skipValidation: true })
case "replace_lines": {
lines = applyReplaceLines(lines, edit.start_line, edit.end_line, edit.text, { skipValidation: true })
break
}
case "insert_after": {
const next = applyInsertAfter(lines, edit.line, edit.text, { skipValidation: true })
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
}
lines = next
break
}
case "insert_before": {
const next = applyInsertBefore(lines, edit.line, edit.text, { skipValidation: true })
if (next.join("\n") === lines.join("\n")) {
noopEdits += 1
break
}
lines = next
break
}
case "insert_between": {
const next = applyInsertBetween(lines, edit.after_line, edit.before_line, edit.text, { skipValidation: true })
if (next.join("\n") === lines.join("\n")) { if (next.join("\n") === lines.join("\n")) {
noopEdits += 1 noopEdits += 1
break break
@ -77,7 +53,9 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
break break
} }
case "append": { case "append": {
const next = applyAppend(lines, edit.text) const next = edit.pos
? applyInsertAfter(lines, edit.pos, edit.lines, { skipValidation: true })
: applyAppend(lines, edit.lines)
if (next.join("\n") === lines.join("\n")) { if (next.join("\n") === lines.join("\n")) {
noopEdits += 1 noopEdits += 1
break break
@ -86,7 +64,9 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
break break
} }
case "prepend": { case "prepend": {
const next = applyPrepend(lines, edit.text) const next = edit.pos
? applyInsertBefore(lines, edit.pos, edit.lines, { skipValidation: true })
: applyPrepend(lines, edit.lines)
if (next.join("\n") === lines.join("\n")) { if (next.join("\n") === lines.join("\n")) {
noopEdits += 1 noopEdits += 1
break break
@ -94,17 +74,6 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
lines = next lines = next
break break
} }
case "replace": {
result = lines.join("\n")
const replaced = applyReplace(result, edit.old_text, edit.new_text)
if (replaced === result) {
noopEdits += 1
break
}
result = replaced
lines = result.split("\n")
break
}
} }
} }
@ -124,6 +93,4 @@ export {
applyReplaceLines, applyReplaceLines,
applyInsertAfter, applyInsertAfter,
applyInsertBefore, applyInsertBefore,
applyInsertBetween,
applyReplace,
} from "./edit-operation-primitives" } from "./edit-operation-primitives"

View File

@ -2,23 +2,13 @@ import { parseLineRef } from "./validation"
import type { HashlineEdit } from "./types" import type { HashlineEdit } from "./types"
export function getEditLineNumber(edit: HashlineEdit): number { export function getEditLineNumber(edit: HashlineEdit): number {
switch (edit.type) { switch (edit.op) {
case "set_line":
return parseLineRef(edit.line).line
case "replace_lines":
return parseLineRef(edit.end_line).line
case "insert_after":
return parseLineRef(edit.line).line
case "insert_before":
return parseLineRef(edit.line).line
case "insert_between":
return parseLineRef(edit.before_line).line
case "append":
return Number.NEGATIVE_INFINITY
case "prepend":
return Number.NEGATIVE_INFINITY
case "replace": case "replace":
return Number.NEGATIVE_INFINITY return parseLineRef(edit.end ?? edit.pos).line
case "append":
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
case "prepend":
return edit.pos ? parseLineRef(edit.pos).line : Number.NEGATIVE_INFINITY
default: default:
return Number.POSITIVE_INFINITY return Number.POSITIVE_INFINITY
} }
@ -26,23 +16,41 @@ export function getEditLineNumber(edit: HashlineEdit): number {
export function collectLineRefs(edits: HashlineEdit[]): string[] { export function collectLineRefs(edits: HashlineEdit[]): string[] {
return edits.flatMap((edit) => { return edits.flatMap((edit) => {
switch (edit.type) { switch (edit.op) {
case "set_line": case "replace":
return [edit.line] return edit.end ? [edit.pos, edit.end] : [edit.pos]
case "replace_lines":
return [edit.start_line, edit.end_line]
case "insert_after":
return [edit.line]
case "insert_before":
return [edit.line]
case "insert_between":
return [edit.after_line, edit.before_line]
case "append": case "append":
case "prepend": case "prepend":
case "replace": return edit.pos ? [edit.pos] : []
return []
default: default:
return [] return []
} }
}) })
} }
export function detectOverlappingRanges(edits: HashlineEdit[]): string | null {
const ranges: { start: number; end: number; idx: number }[] = []
for (let i = 0; i < edits.length; i++) {
const edit = edits[i]
if (edit.op !== "replace" || !edit.end) continue
const start = parseLineRef(edit.pos).line
const end = parseLineRef(edit.end).line
ranges.push({ start, end, idx: i })
}
if (ranges.length < 2) return null
ranges.sort((a, b) => a.start - b.start || a.end - b.end)
for (let i = 1; i < ranges.length; i++) {
const prev = ranges[i - 1]
const curr = ranges[i]
if (curr.start <= prev.end) {
return (
`Overlapping range edits detected: ` +
`edit ${prev.idx + 1} (lines ${prev.start}-${prev.end}) overlaps with ` +
`edit ${curr.idx + 1} (lines ${curr.start}-${curr.end}). ` +
`Use pos-only replace for single-line edits.`
)
}
}
return null
}

View File

@ -1,4 +1,4 @@
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}:/ const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\|/
const DIFF_PLUS_RE = /^[+](?![+])/ const DIFF_PLUS_RE = /^[+](?![+])/
function equalsIgnoringWhitespace(a: string, b: string): boolean { function equalsIgnoringWhitespace(a: string, b: string): boolean {
@ -7,6 +7,7 @@ function equalsIgnoringWhitespace(a: string, b: string): boolean {
} }
function leadingWhitespace(text: string): string { function leadingWhitespace(text: string): string {
if (!text) return ""
const match = text.match(/^\s*/) const match = text.match(/^\s*/)
return match ? match[0] : "" return match ? match[0] : ""
} }
@ -53,6 +54,7 @@ export function restoreLeadingIndent(templateLine: string, line: string): string
const templateIndent = leadingWhitespace(templateLine) const templateIndent = leadingWhitespace(templateLine)
if (templateIndent.length === 0) return line if (templateIndent.length === 0) return line
if (leadingWhitespace(line).length > 0) return line if (leadingWhitespace(line).length > 0) return line
if (templateLine.trim() === line.trim()) return line
return `${templateIndent}${line}` return `${templateIndent}${line}`
} }

View File

@ -60,7 +60,7 @@ describe("computeLineHash", () => {
}) })
describe("formatHashLine", () => { describe("formatHashLine", () => {
it("formats single line as LINE#ID:content", () => { it("formats single line as LINE#ID|content", () => {
//#given //#given
const lineNumber = 42 const lineNumber = 42
const content = "const x = 42" const content = "const x = 42"
@ -69,12 +69,12 @@ describe("formatHashLine", () => {
const result = formatHashLine(lineNumber, content) const result = formatHashLine(lineNumber, content)
//#then //#then
expect(result).toMatch(/^42#[ZPMQVRWSNKTXJBYH]{2}:const x = 42$/) expect(result).toMatch(/^42#[ZPMQVRWSNKTXJBYH]{2}\|const x = 42$/)
}) })
}) })
describe("formatHashLines", () => { describe("formatHashLines", () => {
it("formats all lines as LINE#ID:content", () => { it("formats all lines as LINE#ID|content", () => {
//#given //#given
const content = "a\nb\nc" const content = "a\nb\nc"
@ -84,9 +84,9 @@ describe("formatHashLines", () => {
//#then //#then
const lines = result.split("\n") const lines = result.split("\n")
expect(lines).toHaveLength(3) expect(lines).toHaveLength(3)
expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}:a$/) expect(lines[0]).toMatch(/^1#[ZPMQVRWSNKTXJBYH]{2}\|a$/)
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:b$/) expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|b$/)
expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}:c$/) expect(lines[2]).toMatch(/^3#[ZPMQVRWSNKTXJBYH]{2}\|c$/)
}) })
}) })

View File

@ -13,7 +13,7 @@ export function computeLineHash(lineNumber: number, content: string): string {
export function formatHashLine(lineNumber: number, content: string): string { export function formatHashLine(lineNumber: number, content: string): string {
const hash = computeLineHash(lineNumber, content) const hash = computeLineHash(lineNumber, content)
return `${lineNumber}#${hash}:${content}` return `${lineNumber}#${hash}|${content}`
} }
export function formatHashLines(content: string): string { export function formatHashLines(content: string): string {

View File

@ -14,16 +14,16 @@ export function generateHashlineDiff(oldContent: string, newContent: string, fil
const hash = computeLineHash(lineNum, newLine) const hash = computeLineHash(lineNum, newLine)
if (i >= oldLines.length) { if (i >= oldLines.length) {
diff += `+ ${lineNum}#${hash}:${newLine}\n` diff += `+ ${lineNum}#${hash}|${newLine}\n`
continue continue
} }
if (i >= newLines.length) { if (i >= newLines.length) {
diff += `- ${lineNum}# :${oldLine}\n` diff += `- ${lineNum}# |${oldLine}\n`
continue continue
} }
if (oldLine !== newLine) { if (oldLine !== newLine) {
diff += `- ${lineNum}# :${oldLine}\n` diff += `- ${lineNum}# |${oldLine}\n`
diff += `+ ${lineNum}#${hash}:${newLine}\n` diff += `+ ${lineNum}#${hash}|${newLine}\n`
} }
} }

View File

@ -32,7 +32,7 @@ function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined {
function canCreateFromMissingFile(edits: HashlineEdit[]): boolean { function canCreateFromMissingFile(edits: HashlineEdit[]): boolean {
if (edits.length === 0) return false if (edits.length === 0) return false
return edits.every((edit) => edit.type === "append" || edit.type === "prepend") return edits.every((edit) => edit.op === "append" || edit.op === "prepend")
} }
function buildSuccessMeta( function buildSuccessMeta(

View File

@ -8,14 +8,9 @@ export {
export { parseLineRef, validateLineRef } from "./validation" export { parseLineRef, validateLineRef } from "./validation"
export type { LineRef } from "./validation" export type { LineRef } from "./validation"
export type { export type {
SetLine, ReplaceEdit,
ReplaceLines, AppendEdit,
InsertAfter, PrependEdit,
InsertBefore,
InsertBetween,
Replace,
Append,
Prepend,
HashlineEdit, HashlineEdit,
} from "./types" } from "./types"
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants" export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants"
@ -23,8 +18,6 @@ export {
applyHashlineEdits, applyHashlineEdits,
applyInsertAfter, applyInsertAfter,
applyInsertBefore, applyInsertBefore,
applyInsertBetween,
applyReplace,
applyReplaceLines, applyReplaceLines,
applySetLine, applySetLine,
} from "./edit-operations" } from "./edit-operations"

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "bun:test"
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
describe("normalizeHashlineEdits", () => {
it("maps replace with pos to replace", () => {
//#given
const input: RawHashlineEdit[] = [{ op: "replace", pos: "2#VK", lines: "updated" }]
//#when
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([{ op: "replace", pos: "2#VK", lines: "updated" }])
})
it("maps replace with pos and end to replace", () => {
//#given
const input: RawHashlineEdit[] = [{ op: "replace", pos: "2#VK", end: "4#MB", lines: ["a", "b"] }]
//#when
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([{ op: "replace", pos: "2#VK", end: "4#MB", lines: ["a", "b"] }])
})
it("maps anchored append and prepend preserving op", () => {
//#given
const input: RawHashlineEdit[] = [
{ op: "append", pos: "2#VK", lines: ["after"] },
{ op: "prepend", pos: "4#MB", lines: ["before"] },
]
//#when
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([{ op: "append", pos: "2#VK", lines: ["after"] }, { op: "prepend", pos: "4#MB", lines: ["before"] }])
})
it("prefers pos over end for prepend anchors", () => {
//#given
const input: RawHashlineEdit[] = [{ op: "prepend", pos: "3#AA", end: "7#BB", lines: ["before"] }]
//#when
const result = normalizeHashlineEdits(input)
//#then
expect(result).toEqual([{ op: "prepend", pos: "3#AA", lines: ["before"] }])
})
it("rejects legacy payload without op", () => {
//#given
const input = [{ type: "set_line", line: "2#VK", text: "updated" }] as unknown as Parameters<
typeof normalizeHashlineEdits
>[0]
//#when / #then
expect(() => normalizeHashlineEdits(input)).toThrow(/legacy format was removed/i)
})
})

View File

@ -1,142 +1,95 @@
import type { HashlineEdit } from "./types" import type { AppendEdit, HashlineEdit, PrependEdit, ReplaceEdit } from "./types"
type HashlineToolOp = "replace" | "append" | "prepend"
export interface RawHashlineEdit { export interface RawHashlineEdit {
type?: op?: HashlineToolOp
| "set_line" pos?: string
| "replace_lines" end?: string
| "insert_after" lines?: string | string[] | null
| "insert_before"
| "insert_between"
| "replace"
| "append"
| "prepend"
line?: string
start_line?: string
end_line?: string
after_line?: string
before_line?: string
text?: string | string[]
old_text?: string
new_text?: string | string[]
} }
function firstDefined(...values: Array<string | undefined>): string | undefined { function normalizeAnchor(value: string | undefined): string | undefined {
for (const value of values) { if (typeof value !== "string") return undefined
if (typeof value === "string" && value.trim() !== "") return value const trimmed = value.trim()
return trimmed === "" ? undefined : trimmed
}
function requireLines(edit: RawHashlineEdit, index: number): string | string[] {
if (edit.lines === undefined) {
throw new Error(`Edit ${index}: lines is required for ${edit.op ?? "unknown"}`)
} }
return undefined if (edit.lines === null) {
} return []
function requireText(edit: RawHashlineEdit, index: number): string | string[] {
const text = edit.text ?? edit.new_text
if (text === undefined) {
throw new Error(`Edit ${index}: text is required for ${edit.type ?? "unknown"}`)
} }
return text return edit.lines
} }
function requireLine(anchor: string | undefined, index: number, op: string): string { function requireLine(anchor: string | undefined, index: number, op: HashlineToolOp): string {
if (!anchor) { if (!anchor) {
throw new Error(`Edit ${index}: ${op} requires at least one anchor line reference`) throw new Error(`Edit ${index}: ${op} requires at least one anchor line reference (pos or end)`)
} }
return anchor return anchor
} }
export function normalizeHashlineEdits(rawEdits: RawHashlineEdit[]): HashlineEdit[] { function normalizeReplaceEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
const normalized: HashlineEdit[] = [] const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = requireLine(pos ?? end, index, "replace")
const lines = requireLines(edit, index)
for (let index = 0; index < rawEdits.length; index += 1) { const normalized: ReplaceEdit = {
const edit = rawEdits[index] ?? {} op: "replace",
const type = edit.type pos: anchor,
lines,
switch (type) {
case "set_line": {
const anchor = firstDefined(edit.line, edit.start_line, edit.end_line, edit.after_line, edit.before_line)
normalized.push({
type: "set_line",
line: requireLine(anchor, index, "set_line"),
text: requireText(edit, index),
})
break
} }
case "replace_lines": { if (end) normalized.end = end
const startAnchor = firstDefined(edit.start_line, edit.line, edit.after_line)
const endAnchor = firstDefined(edit.end_line, edit.line, edit.before_line)
if (!startAnchor && !endAnchor) {
throw new Error(`Edit ${index}: replace_lines requires start_line or end_line`)
}
if (startAnchor && endAnchor) {
normalized.push({
type: "replace_lines",
start_line: startAnchor,
end_line: endAnchor,
text: requireText(edit, index),
})
} else {
normalized.push({
type: "set_line",
line: requireLine(startAnchor ?? endAnchor, index, "replace_lines"),
text: requireText(edit, index),
})
}
break
}
case "insert_after": {
const anchor = firstDefined(edit.line, edit.after_line, edit.end_line, edit.start_line)
normalized.push({
type: "insert_after",
line: requireLine(anchor, index, "insert_after"),
text: requireText(edit, index),
})
break
}
case "insert_before": {
const anchor = firstDefined(edit.line, edit.before_line, edit.start_line, edit.end_line)
normalized.push({
type: "insert_before",
line: requireLine(anchor, index, "insert_before"),
text: requireText(edit, index),
})
break
}
case "insert_between": {
const afterLine = firstDefined(edit.after_line, edit.line, edit.start_line)
const beforeLine = firstDefined(edit.before_line, edit.end_line, edit.line)
normalized.push({
type: "insert_between",
after_line: requireLine(afterLine, index, "insert_between.after_line"),
before_line: requireLine(beforeLine, index, "insert_between.before_line"),
text: requireText(edit, index),
})
break
}
case "replace": {
const oldText = edit.old_text
const newText = edit.new_text ?? edit.text
if (!oldText) {
throw new Error(`Edit ${index}: replace requires old_text`)
}
if (newText === undefined) {
throw new Error(`Edit ${index}: replace requires new_text or text`)
}
normalized.push({ type: "replace", old_text: oldText, new_text: newText })
break
}
case "append": {
normalized.push({ type: "append", text: requireText(edit, index) })
break
}
case "prepend": {
normalized.push({ type: "prepend", text: requireText(edit, index) })
break
}
default: {
throw new Error(`Edit ${index}: unsupported type "${String(type)}"`)
}
}
}
return normalized return normalized
} }
function normalizeAppendEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = pos ?? end
const lines = requireLines(edit, index)
const normalized: AppendEdit = {
op: "append",
lines,
}
if (anchor) normalized.pos = anchor
return normalized
}
function normalizePrependEdit(edit: RawHashlineEdit, index: number): HashlineEdit {
const pos = normalizeAnchor(edit.pos)
const end = normalizeAnchor(edit.end)
const anchor = pos ?? end
const lines = requireLines(edit, index)
const normalized: PrependEdit = {
op: "prepend",
lines,
}
if (anchor) normalized.pos = anchor
return normalized
}
export function normalizeHashlineEdits(rawEdits: RawHashlineEdit[]): HashlineEdit[] {
return rawEdits.map((rawEdit, index) => {
const edit = rawEdit ?? {}
switch (edit.op) {
case "replace":
return normalizeReplaceEdit(edit, index)
case "append":
return normalizeAppendEdit(edit, index)
case "prepend":
return normalizePrependEdit(edit, index)
default:
throw new Error(
`Edit ${index}: unsupported op "${String(edit.op)}". Legacy format was removed; use op/pos/end/lines.`
)
}
})
}

View File

@ -5,40 +5,40 @@ WORKFLOW:
2. Pick the smallest operation per logical mutation site. 2. Pick the smallest operation per logical mutation site.
3. Submit one edit call per file with all related operations. 3. Submit one edit call per file with all related operations.
4. If same file needs another call, re-read first. 4. If same file needs another call, re-read first.
5. Use anchors as "LINE#ID" only (never include trailing ":content"). 5. Use anchors as "LINE#ID" only (never include trailing "|content").
VALIDATION: VALIDATION:
Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string } Payload shape: { "filePath": string, "edits": [...], "delete"?: boolean, "rename"?: string }
Each edit must be one of: set_line, replace_lines, insert_after, insert_before, insert_between, replace, append, prepend Each edit must be one of: replace, append, prepend
text/new_text must contain plain replacement text only (no LINE#ID prefixes, no diff + markers) 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. 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.
LINE#ID FORMAT (CRITICAL): LINE#ID FORMAT (CRITICAL):
Each line reference must be in "LINE#ID" format where: Each line reference must be in "{line_number}#{hash_id}" format where:
LINE: 1-based line number {line_number}: 1-based line number
ID: Two CID letters from the set ZPMQVRWSNKTXJBYH {hash_id}: Two CID letters from the set ZPMQVRWSNKTXJBYH
FILE MODES: FILE MODES:
delete=true deletes file and requires edits=[] with no rename delete=true deletes file and requires edits=[] with no rename
rename moves final content to a new path and removes old path rename moves final content to a new path and removes old path
CONTENT FORMAT: CONTENT FORMAT:
text/new_text can be a string (single line) or string[] (multi-line, preferred). 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. If you pass a multi-line string, it is split by real newline characters.
Literal "\\n" is preserved as text. Literal "\\n" is preserved as text.
FILE CREATION: FILE CREATION:
append: adds content at EOF. If file does not exist, creates it. append without anchors adds content at EOF. If file does not exist, creates it.
prepend: adds content at BOF. If file does not exist, creates it. prepend without anchors adds content at BOF. If file does not exist, creates it.
CRITICAL: append/prepend are the only operations that work without an existing file. CRITICAL: only unanchored append/prepend can create a missing file.
OPERATION CHOICE: OPERATION CHOICE:
One line wrong -> set_line replace with pos only -> replace one line at pos (MOST COMMON for single-line edits)
Adjacent block rewrite or swap/move -> replace_lines (prefer one range op over many single-line ops) replace with pos+end -> replace ENTIRE range pos..end as a block (ranges MUST NOT overlap across edits)
Both boundaries known -> insert_between (ALWAYS prefer over insert_after/insert_before) append with pos/end anchor -> insert after that anchor
One boundary known -> insert_after or insert_before prepend with pos/end anchor -> insert before that anchor
New file or EOF/BOF addition -> append or prepend append/prepend without anchors -> EOF/BOF insertion
No LINE#ID available -> replace (last resort)
RULES (CRITICAL): RULES (CRITICAL):
1. Minimize scope: one logical mutation site per operation. 1. Minimize scope: one logical mutation site per operation.
@ -53,7 +53,6 @@ RULES (CRITICAL):
TAG CHOICE (ALWAYS): TAG CHOICE (ALWAYS):
- Copy tags exactly from read output or >>> mismatch output. - Copy tags exactly from read output or >>> mismatch output.
- NEVER guess tags. - NEVER guess tags.
- Prefer insert_between over insert_after/insert_before when both boundaries are known.
- Anchor to structural lines (function/class/brace), NEVER blank lines. - Anchor to structural lines (function/class/brace), NEVER blank lines.
- Anti-pattern warning: blank/whitespace anchors are fragile. - Anti-pattern warning: blank/whitespace anchors are fragile.
- Re-read after each successful edit call before issuing another on the same file. - Re-read after each successful edit call before issuing another on the same file.

View File

@ -31,7 +31,7 @@ describe("createHashlineEditTool", () => {
fs.rmSync(tempDir, { recursive: true, force: true }) fs.rmSync(tempDir, { recursive: true, force: true })
}) })
it("applies set_line with LINE#ID anchor", async () => { it("applies replace with single LINE#ID anchor", async () => {
//#given //#given
const filePath = path.join(tempDir, "test.txt") const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "line1\nline2\nline3") fs.writeFileSync(filePath, "line1\nline2\nline3")
@ -41,7 +41,7 @@ describe("createHashlineEditTool", () => {
const result = await tool.execute( const result = await tool.execute(
{ {
filePath, filePath,
edits: [{ type: "set_line", line: `2#${hash}`, text: "modified line2" }], edits: [{ op: "replace", pos: `2#${hash}`, lines: "modified line2" }],
}, },
createMockContext(), createMockContext(),
) )
@ -51,7 +51,7 @@ describe("createHashlineEditTool", () => {
expect(result).toBe(`Updated ${filePath}`) expect(result).toBe(`Updated ${filePath}`)
}) })
it("applies replace_lines and insert_after", async () => { it("applies ranged replace and anchored append", async () => {
//#given //#given
const filePath = path.join(tempDir, "test.txt") const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "line1\nline2\nline3\nline4") fs.writeFileSync(filePath, "line1\nline2\nline3\nline4")
@ -65,15 +65,15 @@ describe("createHashlineEditTool", () => {
filePath, filePath,
edits: [ edits: [
{ {
type: "replace_lines", op: "replace",
start_line: `2#${line2Hash}`, pos: `2#${line2Hash}`,
end_line: `3#${line3Hash}`, end: `3#${line3Hash}`,
text: "replaced", lines: "replaced",
}, },
{ {
type: "insert_after", op: "append",
line: `4#${line4Hash}`, pos: `4#${line4Hash}`,
text: "inserted", lines: "inserted",
}, },
], ],
}, },
@ -93,7 +93,7 @@ describe("createHashlineEditTool", () => {
const result = await tool.execute( const result = await tool.execute(
{ {
filePath, filePath,
edits: [{ type: "set_line", line: "1#ZZ", text: "new" }], edits: [{ op: "replace", pos: "1#ZZ", lines: "new" }],
}, },
createMockContext(), createMockContext(),
) )
@ -113,7 +113,7 @@ describe("createHashlineEditTool", () => {
await tool.execute( await tool.execute(
{ {
filePath, filePath,
edits: [{ type: "set_line", line: `1#${line1Hash}`, text: "join(\\n)" }], edits: [{ op: "replace", pos: `1#${line1Hash}`, lines: "join(\\n)" }],
}, },
createMockContext(), createMockContext(),
) )
@ -121,7 +121,7 @@ describe("createHashlineEditTool", () => {
await tool.execute( await tool.execute(
{ {
filePath, filePath,
edits: [{ type: "insert_after", line: `1#${computeLineHash(1, "join(\\n)")}`, text: ["a", "b"] }], edits: [{ op: "append", pos: `1#${computeLineHash(1, "join(\\n)")}`, lines: ["a", "b"] }],
}, },
createMockContext(), createMockContext(),
) )
@ -130,12 +130,11 @@ describe("createHashlineEditTool", () => {
expect(fs.readFileSync(filePath, "utf-8")).toBe("join(\\n)\na\nb\nline2") expect(fs.readFileSync(filePath, "utf-8")).toBe("join(\\n)\na\nb\nline2")
}) })
it("supports insert_before and insert_between", async () => { it("supports anchored prepend and anchored append", async () => {
//#given //#given
const filePath = path.join(tempDir, "test.txt") const filePath = path.join(tempDir, "test.txt")
fs.writeFileSync(filePath, "line1\nline2\nline3") fs.writeFileSync(filePath, "line1\nline2\nline3")
const line1 = computeLineHash(1, "line1") const line1 = computeLineHash(1, "line1")
const line2 = computeLineHash(2, "line2")
const line3 = computeLineHash(3, "line3") const line3 = computeLineHash(3, "line3")
//#when //#when
@ -143,8 +142,8 @@ describe("createHashlineEditTool", () => {
{ {
filePath, filePath,
edits: [ edits: [
{ type: "insert_before", line: `3#${line3}`, text: ["before3"] }, { op: "prepend", pos: `3#${line3}`, lines: ["before3"] },
{ type: "insert_between", after_line: `1#${line1}`, before_line: `2#${line2}`, text: ["between"] }, { op: "append", pos: `1#${line1}`, lines: ["between"] },
], ],
}, },
createMockContext(), createMockContext(),
@ -164,7 +163,7 @@ describe("createHashlineEditTool", () => {
const result = await tool.execute( const result = await tool.execute(
{ {
filePath, filePath,
edits: [{ type: "insert_after", line: `1#${line1}`, text: [] }], edits: [{ op: "append", pos: `1#${line1}`, lines: [] }],
}, },
createMockContext(), createMockContext(),
) )
@ -186,7 +185,7 @@ describe("createHashlineEditTool", () => {
{ {
filePath, filePath,
rename: renamedPath, rename: renamedPath,
edits: [{ type: "set_line", line: `2#${line2}`, text: "line2-updated" }], edits: [{ op: "replace", pos: `2#${line2}`, lines: "line2-updated" }],
}, },
createMockContext(), createMockContext(),
) )
@ -226,8 +225,8 @@ describe("createHashlineEditTool", () => {
{ {
filePath, filePath,
edits: [ edits: [
{ type: "append", text: ["line2"] }, { op: "append", lines: ["line2"] },
{ type: "prepend", text: ["line1"] }, { op: "prepend", lines: ["line1"] },
], ],
}, },
createMockContext(), createMockContext(),
@ -239,7 +238,7 @@ describe("createHashlineEditTool", () => {
expect(result).toBe(`Updated ${filePath}`) expect(result).toBe(`Updated ${filePath}`)
}) })
it("accepts replace_lines with one anchor and downgrades to set_line", async () => { it("accepts replace with one anchor", async () => {
//#given //#given
const filePath = path.join(tempDir, "degrade.txt") const filePath = path.join(tempDir, "degrade.txt")
fs.writeFileSync(filePath, "line1\nline2\nline3") fs.writeFileSync(filePath, "line1\nline2\nline3")
@ -249,7 +248,7 @@ describe("createHashlineEditTool", () => {
const result = await tool.execute( const result = await tool.execute(
{ {
filePath, filePath,
edits: [{ type: "replace_lines", start_line: `2#${line2Hash}`, text: ["line2-updated"] }], edits: [{ op: "replace", pos: `2#${line2Hash}`, lines: ["line2-updated"] }],
}, },
createMockContext(), createMockContext(),
) )
@ -259,7 +258,7 @@ describe("createHashlineEditTool", () => {
expect(result).toBe(`Updated ${filePath}`) expect(result).toBe(`Updated ${filePath}`)
}) })
it("accepts insert_after using after_line alias", async () => { it("accepts anchored append using end alias", async () => {
//#given //#given
const filePath = path.join(tempDir, "alias.txt") const filePath = path.join(tempDir, "alias.txt")
fs.writeFileSync(filePath, "line1\nline2") fs.writeFileSync(filePath, "line1\nline2")
@ -269,7 +268,7 @@ describe("createHashlineEditTool", () => {
await tool.execute( await tool.execute(
{ {
filePath, filePath,
edits: [{ type: "insert_after", after_line: `1#${line1Hash}`, text: ["inserted"] }], edits: [{ op: "append", end: `1#${line1Hash}`, lines: ["inserted"] }],
}, },
createMockContext(), createMockContext(),
) )
@ -289,7 +288,7 @@ describe("createHashlineEditTool", () => {
await tool.execute( await tool.execute(
{ {
filePath, filePath,
edits: [{ type: "set_line", line: `2#${line2Hash}`, text: "line2-updated" }], edits: [{ op: "replace", pos: `2#${line2Hash}`, lines: "line2-updated" }],
}, },
createMockContext(), createMockContext(),
) )

View File

@ -20,32 +20,19 @@ export function createHashlineEditTool(): ToolDefinition {
edits: tool.schema edits: tool.schema
.array( .array(
tool.schema.object({ tool.schema.object({
type: tool.schema op: tool.schema
.union([ .union([
tool.schema.literal("set_line"),
tool.schema.literal("replace_lines"),
tool.schema.literal("insert_after"),
tool.schema.literal("insert_before"),
tool.schema.literal("insert_between"),
tool.schema.literal("replace"), tool.schema.literal("replace"),
tool.schema.literal("append"), tool.schema.literal("append"),
tool.schema.literal("prepend"), tool.schema.literal("prepend"),
]) ])
.describe("Edit operation type"), .describe("Hashline edit operation mode"),
line: tool.schema.string().optional().describe("Anchor line in LINE#ID format"), pos: tool.schema.string().optional().describe("Primary anchor in LINE#ID format"),
start_line: tool.schema.string().optional().describe("Range start in LINE#ID format"), end: tool.schema.string().optional().describe("Range end anchor in LINE#ID format"),
end_line: tool.schema.string().optional().describe("Range end in LINE#ID format"), lines: tool.schema
after_line: tool.schema.string().optional().describe("Insert boundary (after) in LINE#ID format"), .union([tool.schema.string(), tool.schema.array(tool.schema.string()), tool.schema.null()])
before_line: tool.schema.string().optional().describe("Insert boundary (before) in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.optional() .optional()
.describe("Operation content"), .describe("Replacement or inserted lines. null/[] deletes with replace"),
old_text: tool.schema.string().optional().describe("Legacy text replacement source"),
new_text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.optional()
.describe("Legacy text replacement target"),
}) })
) )
.describe("Array of edit operations to apply (empty when delete=true)"), .describe("Array of edit operations to apply (empty when delete=true)"),

View File

@ -1,57 +1,20 @@
export interface SetLine { export interface ReplaceEdit {
type: "set_line" op: "replace"
line: string pos: string
text: string | string[] end?: string
lines: string | string[]
} }
export interface ReplaceLines { export interface AppendEdit {
type: "replace_lines" op: "append"
start_line: string pos?: string
end_line: string lines: string | string[]
text: string | string[]
} }
export interface InsertAfter { export interface PrependEdit {
type: "insert_after" op: "prepend"
line: string pos?: string
text: string | string[] lines: string | string[]
} }
export interface InsertBefore { export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit
type: "insert_before"
line: string
text: string | string[]
}
export interface InsertBetween {
type: "insert_between"
after_line: string
before_line: string
text: string | string[]
}
export interface Replace {
type: "replace"
old_text: string
new_text: string | string[]
}
export interface Append {
type: "append"
text: string | string[]
}
export interface Prepend {
type: "prepend"
text: string | string[]
}
export type HashlineEdit =
| SetLine
| ReplaceLines
| InsertAfter
| InsertBefore
| InsertBetween
| Replace
| Append
| Prepend

View File

@ -24,7 +24,7 @@ describe("parseLineRef", () => {
it("accepts refs copied with markers and trailing content", () => { it("accepts refs copied with markers and trailing content", () => {
//#given //#given
const ref = ">>> 42#VK:const value = 1" const ref = ">>> 42#VK|const value = 1"
//#when //#when
const result = parseLineRef(ref) const result = parseLineRef(ref)
@ -49,7 +49,7 @@ describe("validateLineRef", () => {
const lines = ["function hello() {"] const lines = ["function hello() {"]
//#when / #then //#when / #then
expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}:/) expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}\|/)
}) })
it("shows >>> mismatch context in batched validation", () => { it("shows >>> mismatch context in batched validation", () => {
@ -58,7 +58,7 @@ describe("validateLineRef", () => {
//#when / #then //#when / #then
expect(() => validateLineRefs(lines, ["2#ZZ"])) expect(() => validateLineRefs(lines, ["2#ZZ"]))
.toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}:two/) .toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}\|two/)
}) })
}) })
@ -90,7 +90,7 @@ describe("legacy LINE:HEX backward compatibility", () => {
const lines = ["function hello() {"] const lines = ["function hello() {"]
//#when / #then //#when / #then
expect(() => validateLineRef(lines, "1:ab")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}:/) expect(() => validateLineRef(lines, "1:ab")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}\|/)
}) })
it("extracts legacy ref from content with markers", () => { it("extracts legacy ref from content with markers", () => {

View File

@ -115,7 +115,7 @@ export class HashlineMismatchError extends Error {
const content = fileLines[line - 1] ?? "" const content = fileLines[line - 1] ?? ""
const hash = computeLineHash(line, content) const hash = computeLineHash(line, content)
const prefix = `${line}#${hash}:${content}` const prefix = `${line}#${hash}|${content}`
if (mismatchByLine.has(line)) { if (mismatchByLine.has(line)) {
output.push(`>>> ${prefix}`) output.push(`>>> ${prefix}`)
} else { } else {