From 2aeb96c3f65e10b41d8a9fb8a263825f77b9c9b5 Mon Sep 17 00:00:00 2001 From: minpeter Date: Tue, 24 Feb 2026 15:35:10 +0900 Subject: [PATCH 1/9] fix(hashline-edit): improve error messages for invalid LINE#ID references - Detect non-numeric prefixes (e.g., "LINE#HK", "POS#VK") and explain that the prefix must be an actual line number, not literal text - Add suggestLineForHash() that reverse-looks up a hash in file lines to suggest the correct reference (e.g., Did you mean "1#HK"?) - Unify error message format from "LINE#ID" to "{line_number}#{hash_id}" matching the tool description convention - Add 3 tests covering non-numeric prefix detection and hash suggestion --- src/tools/hashline-edit/validation.test.ts | 27 ++++++++++++- src/tools/hashline-edit/validation.ts | 47 ++++++++++++++++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts index cf2457ab..4d4c1b00 100644 --- a/src/tools/hashline-edit/validation.test.ts +++ b/src/tools/hashline-edit/validation.test.ts @@ -19,7 +19,23 @@ describe("parseLineRef", () => { const ref = "42:VK" //#when / #then - expect(() => parseLineRef(ref)).toThrow("LINE#ID") + expect(() => parseLineRef(ref)).toThrow("{line_number}#{hash_id}") + }) + + it("gives specific hint when literal text is used instead of line number", () => { + //#given — model sends "LINE#HK" instead of "1#HK" + const ref = "LINE#HK" + + //#when / #then — error should mention that LINE is not a valid number + expect(() => parseLineRef(ref)).toThrow(/not a line number/i) + }) + + it("gives specific hint for other non-numeric prefixes like POS#VK", () => { + //#given + const ref = "POS#VK" + + //#when / #then + expect(() => parseLineRef(ref)).toThrow(/not a line number/i) }) it("accepts refs copied with markers and trailing content", () => { @@ -60,4 +76,13 @@ describe("validateLineRef", () => { expect(() => validateLineRefs(lines, ["2#ZZ"])) .toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}\|two/) }) + + it("suggests correct line number when hash matches a file line", () => { + //#given — model sends LINE#XX where XX is the actual hash for line 1 + const lines = ["function hello() {", " return 42", "}"] + const hash = computeLineHash(1, lines[0]) + + //#when / #then — error should suggest the correct reference + expect(() => validateLineRefs(lines, [`LINE#${hash}`])).toThrow(new RegExp(`1#${hash}`)) + }) }) diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts index 72a33286..89c5d6eb 100644 --- a/src/tools/hashline-edit/validation.ts +++ b/src/tools/hashline-edit/validation.ts @@ -38,13 +38,30 @@ export function parseLineRef(ref: string): LineRef { hash: match[2], } } + const nonNumericMatch = ref.trim().match(/^([A-Za-z_]+)#([ZPMQVRWSNKTXJBYH]{2})$/) + if (nonNumericMatch) { + throw new Error( + `Invalid line reference: "${ref}". "${nonNumericMatch[1]}" is not a line number. ` + + `Use the actual line number from the read output.` + ) + } throw new Error( - `Invalid line reference format: "${ref}". Expected format: "LINE#ID" (e.g., "42#VK")` + `Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"` ) } export function validateLineRef(lines: string[], ref: string): void { - const { line, hash } = parseLineRef(ref) + let parsed: LineRef + try { + parsed = parseLineRef(ref) + } catch (parseError) { + const hint = suggestLineForHash(ref, lines) + if (hint && parseError instanceof Error) { + throw new Error(`${parseError.message} ${hint}`) + } + throw parseError + } + const { line, hash } = parsed if (line < 1 || line > lines.length) { throw new Error( @@ -92,7 +109,7 @@ export class HashlineMismatchError extends Error { const output: string[] = [] output.push( `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` + - "Use updated LINE#ID references below (>>> marks changed lines)." + "Use updated {line_number}#{hash_id} references below (>>> marks changed lines)." ) output.push("") @@ -117,11 +134,33 @@ export class HashlineMismatchError extends Error { } } +function suggestLineForHash(ref: string, lines: string[]): string | null { + const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/) + if (!hashMatch) return null + const hash = hashMatch[1] + for (let i = 0; i < lines.length; i++) { + if (computeLineHash(i + 1, lines[i]) === hash) { + return `Did you mean "${i + 1}#${hash}"?` + } + } + return null +} + export function validateLineRefs(lines: string[], refs: string[]): void { const mismatches: HashMismatch[] = [] for (const ref of refs) { - const { line, hash } = parseLineRef(ref) + let parsed: LineRef + try { + parsed = parseLineRef(ref) + } catch (parseError) { + const hint = suggestLineForHash(ref, lines) + if (hint && parseError instanceof Error) { + throw new Error(`${parseError.message} ${hint}`) + } + throw parseError + } + const { line, hash } = parsed if (line < 1 || line > lines.length) { throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`) From c6a69899d80327b7269e56143ca49194fecea99d Mon Sep 17 00:00:00 2001 From: minpeter Date: Tue, 24 Feb 2026 15:58:36 +0900 Subject: [PATCH 2/9] fix(hashline-read-enhancer): simplify write tool output to line count summary Replace full hashlined file content in write tool response with a simple 'File written successfully. N lines written.' summary to reduce context bloat. --- src/hooks/hashline-read-enhancer/hook.ts | 9 ++------- src/hooks/hashline-read-enhancer/index.test.ts | 9 +++++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index b6f4db30..09a46c11 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -1,6 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" import { computeLineHash } from "../../tools/hashline-edit/hash-computation" -import { toHashlineContent } from "../../tools/hashline-edit/diff-utils" interface HashlineReadEnhancerConfig { hashline_edit?: { enabled: boolean } @@ -137,10 +136,6 @@ function extractFilePath(metadata: unknown): string | undefined { } async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise { - if (output.output.includes("Updated file (LINE#ID|content):")) { - return - } - const filePath = extractFilePath(output.metadata) if (!filePath) { return @@ -152,8 +147,8 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk } const content = await file.text() - const hashlined = toHashlineContent(content) - output.output = `${output.output}\n\nUpdated file (LINE#ID|content):\n${hashlined}` + const lineCount = content === "" ? 0 : content.split("\n").length + output.output = `File written successfully. ${lineCount} lines written.` } export function createHashlineReadEnhancerHook( diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts index 0f41874b..627380e0 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -164,7 +164,7 @@ describe("hashline-read-enhancer", () => { expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/) }) - it("appends LINE#ID output for write tool using metadata filepath", async () => { + it("appends simple summary for write tool instead of full hashlined content", async () => { //#given const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-write-")) @@ -181,9 +181,10 @@ describe("hashline-read-enhancer", () => { await hook["tool.execute.after"](input, output) //#then - expect(output.output).toContain("Updated file (LINE#ID|content):") - expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1/) - expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2/) + expect(output.output).toContain("File written successfully.") + expect(output.output).toContain("2 lines written.") + expect(output.output).not.toContain("Updated file (LINE#ID|content):") + expect(output.output).not.toContain("const x = 1") fs.rmSync(tempDir, { recursive: true, force: true }) }) From 55ad4297d432d47572172a165e809417176b3f22 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Feb 2026 17:32:44 +0900 Subject: [PATCH 3/9] fix(hashline-edit): widen non-numeric prefix detection and remove duplicate try-catch - Replace regex /^([A-Za-z_]+)#.../ with indexOf-based prefix check to catch line-ref#VK and line.ref#VK style inputs that were previously giving generic errors - Extract parseLineRefWithHint helper to eliminate duplicated try-catch in validateLineRef and validateLineRefs - Restore idempotency guard in appendWriteHashlineOutput using new output format - Add tests for LINE42 extraction, line-ref hint, line.ref hint, and guard behavior Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/hooks/hashline-read-enhancer/hook.ts | 4 ++ .../hashline-read-enhancer/index.test.ts | 22 ++++++++ src/tools/hashline-edit/validation.test.ts | 26 ++++++++++ src/tools/hashline-edit/validation.ts | 52 +++++++++---------- 4 files changed, 76 insertions(+), 28 deletions(-) diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 09a46c11..16484bd0 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -136,6 +136,10 @@ function extractFilePath(metadata: unknown): string | undefined { } async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise { + if (output.output.startsWith("File written successfully.")) { + return + } + const filePath = extractFilePath(output.metadata) if (!filePath) { return diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts index 627380e0..60bbd630 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -189,6 +189,28 @@ describe("hashline-read-enhancer", () => { fs.rmSync(tempDir, { recursive: true, force: true }) }) + it("does not re-process write output that already contains the success marker", async () => { + //#given + const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-idem-")) + const filePath = path.join(tempDir, "demo.ts") + fs.writeFileSync(filePath, "a\nb\nc\nd\ne") + const input = { tool: "write", sessionID: "s", callID: "c" } + const output = { + title: "write", + output: "File written successfully. 99 lines written.", + metadata: { filepath: filePath }, + } + + //#when + await hook["tool.execute.after"](input, output) + + //#then — guard should prevent re-reading the file and updating the count + expect(output.output).toBe("File written successfully. 99 lines written.") + + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + it("skips when feature is disabled", async () => { //#given const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: false } }) diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts index 4d4c1b00..b41d748d 100644 --- a/src/tools/hashline-edit/validation.test.ts +++ b/src/tools/hashline-edit/validation.test.ts @@ -38,6 +38,32 @@ describe("parseLineRef", () => { expect(() => parseLineRef(ref)).toThrow(/not a line number/i) }) + it("extracts valid line number from mixed prefix like LINE42 without throwing", () => { + //#given — normalizeLineRef extracts 42#VK from LINE42#VK + const ref = "LINE42#VK" + + //#when / #then — should parse successfully as line 42 + const result = parseLineRef(ref) + expect(result.line).toBe(42) + expect(result.hash).toBe("VK") + }) + + it("gives specific hint when hyphenated prefix like line-ref is used", () => { + //#given + const ref = "line-ref#VK" + + //#when / #then + expect(() => parseLineRef(ref)).toThrow(/not a line number/i) + }) + + it("gives specific hint when prefix contains a period like line.ref", () => { + //#given + const ref = "line.ref#VK" + + //#when / #then + expect(() => parseLineRef(ref)).toThrow(/not a line number/i) + }) + it("accepts refs copied with markers and trailing content", () => { //#given const ref = ">>> 42#VK|const value = 1" diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts index 89c5d6eb..93e8e01e 100644 --- a/src/tools/hashline-edit/validation.ts +++ b/src/tools/hashline-edit/validation.ts @@ -38,12 +38,16 @@ export function parseLineRef(ref: string): LineRef { hash: match[2], } } - const nonNumericMatch = ref.trim().match(/^([A-Za-z_]+)#([ZPMQVRWSNKTXJBYH]{2})$/) - if (nonNumericMatch) { - throw new Error( - `Invalid line reference: "${ref}". "${nonNumericMatch[1]}" is not a line number. ` + - `Use the actual line number from the read output.` - ) + const hashIdx = normalized.indexOf('#') + if (hashIdx > 0) { + const prefix = normalized.slice(0, hashIdx) + const suffix = normalized.slice(hashIdx + 1) + if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) { + throw new Error( + `Invalid line reference: "${ref}". "${prefix}" is not a line number. ` + + `Use the actual line number from the read output.` + ) + } } throw new Error( `Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"` @@ -51,17 +55,7 @@ export function parseLineRef(ref: string): LineRef { } export function validateLineRef(lines: string[], ref: string): void { - let parsed: LineRef - try { - parsed = parseLineRef(ref) - } catch (parseError) { - const hint = suggestLineForHash(ref, lines) - if (hint && parseError instanceof Error) { - throw new Error(`${parseError.message} ${hint}`) - } - throw parseError - } - const { line, hash } = parsed + const { line, hash } = parseLineRefWithHint(ref, lines) if (line < 1 || line > lines.length) { throw new Error( @@ -145,22 +139,24 @@ function suggestLineForHash(ref: string, lines: string[]): string | null { } return null } +function parseLineRefWithHint(ref: string, lines: string[]): LineRef { + try { + return parseLineRef(ref) + } catch (parseError) { + const hint = suggestLineForHash(ref, lines) + if (hint && parseError instanceof Error) { + throw new Error(`${parseError.message} ${hint}`) + } + throw parseError + } +} + export function validateLineRefs(lines: string[], refs: string[]): void { const mismatches: HashMismatch[] = [] for (const ref of refs) { - let parsed: LineRef - try { - parsed = parseLineRef(ref) - } catch (parseError) { - const hint = suggestLineForHash(ref, lines) - if (hint && parseError instanceof Error) { - throw new Error(`${parseError.message} ${hint}`) - } - throw parseError - } - const { line, hash } = parsed + const { line, hash } = parseLineRefWithHint(ref, lines) if (line < 1 || line > lines.length) { throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`) From a567cd0d6880454d583bc30f1402d1637fe68b07 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Feb 2026 17:41:30 +0900 Subject: [PATCH 4/9] fix(hashline-edit): address Oracle review feedback - Extract WRITE_SUCCESS_MARKER constant to couple guard and output string - Remove double blank line after parseLineRefWithHint - Add comment clarifying normalized equals ref.trim() in error paths --- src/hooks/hashline-read-enhancer/hook.ts | 6 ++++-- src/tools/hashline-edit/validation.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 16484bd0..208ebef0 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -1,6 +1,8 @@ import type { PluginInput } from "@opencode-ai/plugin" import { computeLineHash } from "../../tools/hashline-edit/hash-computation" +const WRITE_SUCCESS_MARKER = "File written successfully." + interface HashlineReadEnhancerConfig { hashline_edit?: { enabled: boolean } } @@ -136,7 +138,7 @@ function extractFilePath(metadata: unknown): string | undefined { } async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise { - if (output.output.startsWith("File written successfully.")) { + if (output.output.startsWith(WRITE_SUCCESS_MARKER)) { return } @@ -152,7 +154,7 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk const content = await file.text() const lineCount = content === "" ? 0 : content.split("\n").length - output.output = `File written successfully. ${lineCount} lines written.` + output.output = `${WRITE_SUCCESS_MARKER} ${lineCount} lines written.` } export function createHashlineReadEnhancerHook( diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts index 93e8e01e..d3368d85 100644 --- a/src/tools/hashline-edit/validation.ts +++ b/src/tools/hashline-edit/validation.ts @@ -38,6 +38,7 @@ export function parseLineRef(ref: string): LineRef { hash: match[2], } } + // normalized equals ref.trim() in all error paths — extraction only succeeds for valid refs const hashIdx = normalized.indexOf('#') if (hashIdx > 0) { const prefix = normalized.slice(0, hashIdx) @@ -151,7 +152,6 @@ function parseLineRefWithHint(ref: string, lines: string[]): LineRef { } } - export function validateLineRefs(lines: string[], refs: string[]): void { const mismatches: HashMismatch[] = [] From d61c0f8cb57d0975a8612c503fe9c1491d95c728 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Feb 2026 17:52:04 +0900 Subject: [PATCH 5/9] fix(hashline-read-enhancer): guard against overwriting error output with success message --- src/hooks/hashline-read-enhancer/hook.ts | 5 ++++ .../hashline-read-enhancer/index.test.ts | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 208ebef0..8d0b2a5b 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -142,6 +142,11 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk return } + const outputLower = output.output.toLowerCase() + if (outputLower.startsWith("error") || outputLower.includes("failed")) { + return + } + const filePath = extractFilePath(output.metadata) if (!filePath) { return diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts index 60bbd630..0a3f29df 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -211,6 +211,29 @@ describe("hashline-read-enhancer", () => { fs.rmSync(tempDir, { recursive: true, force: true }) }) + it("does not overwrite write tool error output with success message", async () => { + //#given — write tool failed, but stale file exists from previous write + const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-err-")) + const filePath = path.join(tempDir, "demo.ts") + fs.writeFileSync(filePath, "const x = 1") + const input = { tool: "write", sessionID: "s", callID: "c" } + const output = { + title: "write", + output: "Error: EACCES: permission denied, open '" + filePath + "'", + metadata: { filepath: filePath }, + } + + //#when + await hook["tool.execute.after"](input, output) + + //#then — error output must be preserved, not overwritten with success message + expect(output.output).toContain("Error: EACCES") + expect(output.output).not.toContain("File written successfully.") + + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + it("skips when feature is disabled", async () => { //#given const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: false } }) From ac962d62ab9cef6a75623721362a29d1f4db27a6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Feb 2026 18:21:05 +0900 Subject: [PATCH 6/9] fix(hashline-edit): add same-line operation precedence ordering --- src/tools/hashline-edit/edit-operations.test.ts | 16 ++++++++++++++++ src/tools/hashline-edit/edit-operations.ts | 9 +++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/tools/hashline-edit/edit-operations.test.ts b/src/tools/hashline-edit/edit-operations.test.ts index 1940b9e4..5d8ad08b 100644 --- a/src/tools/hashline-edit/edit-operations.test.ts +++ b/src/tools/hashline-edit/edit-operations.test.ts @@ -92,6 +92,22 @@ describe("hashline edit operations", () => { expect(result).toEqual("line 1\ninserted\nline 2\nmodified") }) + it("applies replace before prepend when both target same line", () => { + //#given + const content = "line 1\nline 2\nline 3" + const lines = content.split("\n") + const edits: HashlineEdit[] = [ + { op: "prepend", pos: anchorFor(lines, 2), lines: "before line 2" }, + { op: "replace", pos: anchorFor(lines, 2), lines: "modified line 2" }, + ] + + //#when + const result = applyHashlineEdits(content, edits) + + //#then + expect(result).toEqual("line 1\nbefore line 2\nmodified line 2\nline 3") + }) + it("deduplicates identical insert edits in one pass", () => { //#given const content = "line 1\nline 2" diff --git a/src/tools/hashline-edit/edit-operations.ts b/src/tools/hashline-edit/edit-operations.ts index fae662d1..caec2307 100644 --- a/src/tools/hashline-edit/edit-operations.ts +++ b/src/tools/hashline-edit/edit-operations.ts @@ -27,7 +27,13 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi } const dedupeResult = dedupeEdits(edits) - const sortedEdits = [...dedupeResult.edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a)) + const EDIT_PRECEDENCE: Record = { replace: 0, append: 1, prepend: 2 } + const sortedEdits = [...dedupeResult.edits].sort((a, b) => { + const lineA = getEditLineNumber(a) + const lineB = getEditLineNumber(b) + if (lineB !== lineA) return lineB - lineA + return (EDIT_PRECEDENCE[a.op] ?? 3) - (EDIT_PRECEDENCE[b.op] ?? 3) + }) let noopEdits = 0 @@ -87,4 +93,3 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string { return applyHashlineEditsWithReport(content, edits).content } - From 1785313f3bada195d9d3f2d758fc02ff1f82f5fc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Feb 2026 18:21:05 +0900 Subject: [PATCH 7/9] fix(hashline-read-enhancer): skip hashifying OpenCode-truncated lines --- src/hooks/hashline-read-enhancer/hook.ts | 4 +++ .../hashline-read-enhancer/index.test.ts | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 8d0b2a5b..652c000f 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -13,6 +13,7 @@ const CONTENT_OPEN_TAG = "" const CONTENT_CLOSE_TAG = "" const FILE_OPEN_TAG = "" const FILE_CLOSE_TAG = "" +const OPENCODE_LINE_TRUNCATION_SUFFIX = "... (line truncated to 2000 chars)" function isReadTool(toolName: string): boolean { return toolName.toLowerCase() === "read" @@ -56,6 +57,9 @@ function transformLine(line: string): string { if (!parsed) { return line } + if (parsed.content.endsWith(OPENCODE_LINE_TRUNCATION_SUFFIX)) { + return line + } const hash = computeLineHash(parsed.lineNumber, parsed.content) return `${parsed.lineNumber}#${hash}|${parsed.content}` } diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts index 0a3f29df..dcab65bc 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -84,6 +84,33 @@ describe("hashline-read-enhancer", () => { expect(lines[7]).toBe("") }) + it("keeps OpenCode-truncated lines unhashed while hashifying normal lines", async () => { + //#given + const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) + const input = { tool: "read", sessionID: "s", callID: "c" } + const truncatedLine = `${"x".repeat(60)}... (line truncated to 2000 chars)` + const output = { + title: "demo.ts", + output: [ + "/tmp/demo.ts", + "file", + "", + `1: ${truncatedLine}`, + "2: normal line", + "", + ].join("\n"), + metadata: {}, + } + + //#when + await hook["tool.execute.after"](input, output) + + //#then + const lines = output.output.split("\n") + expect(lines[3]).toBe(`1: ${truncatedLine}`) + expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|normal line$/) + }) + it("hashifies plain read output without content tags", async () => { //#given const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) From 365d863e3ac73854d57cf54a37f9277b27d63fd6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Feb 2026 18:21:05 +0900 Subject: [PATCH 8/9] fix(hashline-edit): use instanceof for hash mismatch error detection --- .../hashline-edit/hashline-edit-executor.ts | 3 ++- src/tools/hashline-edit/tools.test.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/tools/hashline-edit/hashline-edit-executor.ts b/src/tools/hashline-edit/hashline-edit-executor.ts index bbbba4ac..e20ebbf9 100644 --- a/src/tools/hashline-edit/hashline-edit-executor.ts +++ b/src/tools/hashline-edit/hashline-edit-executor.ts @@ -5,6 +5,7 @@ import { countLineDiffs, generateUnifiedDiff } from "./diff-utils" import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization" import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits" import type { HashlineEdit } from "./types" +import { HashlineMismatchError } from "./validation" interface HashlineEditArgs { filePath: string @@ -158,7 +159,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T return `Updated ${effectivePath}` } catch (error) { const message = error instanceof Error ? error.message : String(error) - if (message.toLowerCase().includes("hash")) { + if (error instanceof HashlineMismatchError) { return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.` } return `Error: ${message}` diff --git a/src/tools/hashline-edit/tools.test.ts b/src/tools/hashline-edit/tools.test.ts index 46f663a3..cb76b834 100644 --- a/src/tools/hashline-edit/tools.test.ts +++ b/src/tools/hashline-edit/tools.test.ts @@ -103,6 +103,25 @@ describe("createHashlineEditTool", () => { expect(result).toContain(">>>") }) + it("does not classify invalid pos format as hash mismatch", async () => { + //#given + const filePath = path.join(tempDir, "invalid-format.txt") + fs.writeFileSync(filePath, "line1\nline2") + + //#when + const result = await tool.execute( + { + filePath, + edits: [{ op: "replace", pos: "42", lines: "updated" }], + }, + createMockContext(), + ) + + //#then + expect(result).toContain("Error") + expect(result.toLowerCase()).not.toContain("hash mismatch") + }) + it("preserves literal backslash-n and supports string[] payload", async () => { //#given const filePath = path.join(tempDir, "test.txt") From b8257dc59cd78c674a6ad0590ca9bfa47e8ef78d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Feb 2026 18:21:05 +0900 Subject: [PATCH 9/9] fix(hashline-edit): tolerate >>> prefix and spaces around # in line refs --- src/tools/hashline-edit/validation.test.ts | 22 ++++++++++++++++++++++ src/tools/hashline-edit/validation.ts | 10 ++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts index b41d748d..751a90b9 100644 --- a/src/tools/hashline-edit/validation.test.ts +++ b/src/tools/hashline-edit/validation.test.ts @@ -74,6 +74,28 @@ describe("parseLineRef", () => { //#then expect(result).toEqual({ line: 42, hash: "VK" }) }) + + it("accepts refs copied with >>> marker only", () => { + //#given + const ref = ">>> 42#VK" + + //#when + const result = parseLineRef(ref) + + //#then + expect(result).toEqual({ line: 42, hash: "VK" }) + }) + + it("accepts refs with spaces around hash separator", () => { + //#given + const ref = "42 # VK" + + //#when + const result = parseLineRef(ref) + + //#then + expect(result).toEqual({ line: 42, hash: "VK" }) + }) }) describe("validateLineRef", () => { diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts index d3368d85..fc5b395a 100644 --- a/src/tools/hashline-edit/validation.ts +++ b/src/tools/hashline-edit/validation.ts @@ -16,7 +16,13 @@ const MISMATCH_CONTEXT = 2 const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/ function normalizeLineRef(ref: string): string { - const trimmed = ref.trim() + const originalTrimmed = ref.trim() + let trimmed = originalTrimmed + trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "") + trimmed = trimmed.replace(/\s*#\s*/, "#") + trimmed = trimmed.replace(/\|.*$/, "") + trimmed = trimmed.trim() + if (HASHLINE_REF_PATTERN.test(trimmed)) { return trimmed } @@ -26,7 +32,7 @@ function normalizeLineRef(ref: string): string { return extracted[1] } - return trimmed + return originalTrimmed } export function parseLineRef(ref: string): LineRef {