From ccd3e48d343f3b44b7a6bb4f8d9cf9ea36093b22 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 22 Mar 2026 02:38:55 +0700 Subject: [PATCH 1/8] Remove tool overrides, use hooks only for hashline integration - Remove read/edit/write/patch tool overrides to preserve native OpenCode UI - Keep tool.execute.before/after hooks for argument normalization and output annotation - Native file tools now show proper tool cards and structured output - Hashline annotations added to read output via hooks --- .opencode/plugins/hashline-hooks.ts | 4 +- .opencode/tools/edit.ts | 16 ++++-- .opencode/tools/hashline-core.ts | 82 +++++++++++++++++++++++++---- .opencode/tools/patch.ts | 10 +++- .opencode/tools/write.ts | 9 +++- src/index.ts | 12 +---- 6 files changed, 104 insertions(+), 29 deletions(-) diff --git a/.opencode/plugins/hashline-hooks.ts b/.opencode/plugins/hashline-hooks.ts index 6f44ee1..134a214 100644 --- a/.opencode/plugins/hashline-hooks.ts +++ b/.opencode/plugins/hashline-hooks.ts @@ -15,8 +15,8 @@ import { type HashlineRuntimeConfig, } from "./hashline-shared" -const FILE_READ_TOOLS = ["read", "file_read", "read_file", "cat", "view"] -const FILE_EDIT_TOOLS = ["edit", "write", "patch", "apply_patch", "file_edit", "file_write", "edit_file", "multiedit", "batch"] +const FILE_READ_TOOLS = ["hashline_read", "read", "file_read", "read_file", "cat", "view"] +const FILE_EDIT_TOOLS = ["hashline_edit", "hashline_write", "hashline_patch", "edit", "write", "patch", "apply_patch", "file_edit", "file_write", "edit_file", "multiedit", "batch"] function toolEndsWith(tool: string, known: string[]): boolean { const lower = tool.toLowerCase() diff --git a/.opencode/tools/edit.ts b/.opencode/tools/edit.ts index 95ca3d5..2833c2a 100644 --- a/.opencode/tools/edit.ts +++ b/.opencode/tools/edit.ts @@ -1,7 +1,7 @@ import { tool } from "@opencode-ai/plugin" import { mapOperationInput, - runHashlineOperations, + runHashlineOperationsDetailed, type HashlineOperationInput, } from "./hashline-core" @@ -90,7 +90,7 @@ export default tool({ } if (hasOperations) { - return runHashlineOperations({ + const result = await runHashlineOperationsDetailed({ filePath, operations: (args.operations as HashlineOperationInput[]).map(mapOperationInput), expectedFileHash: args.expectedFileHash, @@ -99,6 +99,11 @@ export default tool({ dryRun: args.dryRun, context, }) + return JSON.stringify({ + summary: result.summary, + diff: result.metadata.filediff, + files: result.metadata.files, + }, null, 2) } if (!args.operation) { @@ -117,7 +122,7 @@ export default tool({ content: args.replacement ?? args.content, } - return runHashlineOperations({ + const result = await runHashlineOperationsDetailed({ filePath, operations: [mapOperationInput(singleOperation)], expectedFileHash: args.expectedFileHash, @@ -126,5 +131,10 @@ export default tool({ dryRun: args.dryRun, context, }) + return JSON.stringify({ + summary: result.summary, + diff: result.metadata.filediff, + files: result.metadata.files, + }, null, 2) }, }) diff --git a/.opencode/tools/hashline-core.ts b/.opencode/tools/hashline-core.ts index bc1316a..de2a95f 100644 --- a/.opencode/tools/hashline-core.ts +++ b/.opencode/tools/hashline-core.ts @@ -24,6 +24,28 @@ export interface HashlineOperation { content?: string } +export interface HashlineOperationResultMetadata { + filediff: { + file: string + before: string + after: string + additions: number + deletions: number + } + files: Array<{ + filePath: string + before: string + after: string + additions: number + deletions: number + }> +} + +export interface HashlineOperationResult { + summary: string + metadata: HashlineOperationResultMetadata +} + interface FileSnapshot { absolutePath: string raw: string @@ -565,6 +587,49 @@ function formatEditResult(params: { ].join("\n") } +interface HashlineExecutionParams { + filePath: string + operations: HashlineOperation[] + expectedFileHash?: string + fileRev?: string + safeReapply?: boolean + dryRun?: boolean + context?: { directory?: string } +} + +function buildOperationResult(params: { + filePath: string + mode: "hashline" | "legacy" + dryRun: boolean + before: FileSnapshot + after: FileSnapshot + operations: number + additions: number + removals: number +}): HashlineOperationResult { + return { + summary: formatEditResult(params), + metadata: { + filediff: { + file: params.filePath, + before: params.before.raw, + after: params.after.raw, + additions: params.additions, + deletions: params.removals, + }, + files: [ + { + filePath: params.filePath, + before: params.before.raw, + after: params.after.raw, + additions: params.additions, + deletions: params.removals, + }, + ], + }, + } +} + function countOccurrences(haystack: string, needle: string): number { if (needle.length === 0) { return 0 @@ -632,15 +697,7 @@ export async function runHashlineRead(params: { ].join("\n") } -export async function runHashlineOperations(params: { - filePath: string - operations: HashlineOperation[] - expectedFileHash?: string - fileRev?: string - safeReapply?: boolean - dryRun?: boolean - context?: { directory?: string } -}): Promise { +export async function runHashlineOperationsDetailed(params: HashlineExecutionParams): Promise { const absolutePath = resolveFilePath(params.filePath, params.context) const existingSnapshot = await readSnapshotIfExists(absolutePath) @@ -673,7 +730,7 @@ export async function runHashlineOperations(params: { await writeSnapshot(after) } - return formatEditResult({ + return buildOperationResult({ filePath: params.filePath, mode: "hashline", dryRun: Boolean(params.dryRun), @@ -685,6 +742,11 @@ export async function runHashlineOperations(params: { }) } +export async function runHashlineOperations(params: HashlineExecutionParams): Promise { + const result = await runHashlineOperationsDetailed(params) + return result.summary +} + export async function runLegacyEdit(params: { filePath: string oldString?: string diff --git a/.opencode/tools/patch.ts b/.opencode/tools/patch.ts index 13f7c00..3f79027 100644 --- a/.opencode/tools/patch.ts +++ b/.opencode/tools/patch.ts @@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin" import { mapOperationInput, parsePatchText, - runHashlineOperations, + runHashlineOperationsDetailed, type HashlineOperationInput, } from "./hashline-core" @@ -45,7 +45,7 @@ export default tool({ throw new Error("No operations found in patchText") } - return runHashlineOperations({ + const result = await runHashlineOperationsDetailed({ filePath, operations: (operations as HashlineOperationInput[]).map(mapOperationInput), expectedFileHash: parsed.expectedFileHash ?? args.expectedFileHash, @@ -53,5 +53,11 @@ export default tool({ dryRun: args.dryRun, context, }) + + return JSON.stringify({ + summary: result.summary, + diff: result.metadata.filediff, + files: result.metadata.files, + }, null, 2) }, }) diff --git a/.opencode/tools/write.ts b/.opencode/tools/write.ts index 2eb2b9b..ee9840e 100644 --- a/.opencode/tools/write.ts +++ b/.opencode/tools/write.ts @@ -1,5 +1,5 @@ import { tool } from "@opencode-ai/plugin" -import { runHashlineOperations } from "./hashline-core" +import { runHashlineOperationsDetailed } from "./hashline-core" export default tool({ description: "Hashline-compatible full file writer implemented through set_file operation.", @@ -24,7 +24,7 @@ export default tool({ .describe("Validate and compute result without writing file."), }, async execute(args, context) { - return runHashlineOperations({ + const result = await runHashlineOperationsDetailed({ filePath: args.filePath, operations: [ { @@ -37,5 +37,10 @@ export default tool({ dryRun: args.dryRun, context, }) + return JSON.stringify({ + summary: result.summary, + diff: result.metadata.filediff, + files: result.metadata.files, + }, null, 2) }, }) diff --git a/src/index.ts b/src/index.ts index c261eb5..b2f5eff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,13 @@ import type { Plugin } from "@opencode-ai/plugin" import { HashlineRouting as routingPlugin } from "../.opencode/plugins/hashline-routing" -import readTool from "../.opencode/tools/read" -import editTool from "../.opencode/tools/edit" -import patchTool from "../.opencode/tools/patch" -import writeTool from "../.opencode/tools/write" const hashlinePlugin: Plugin = async (input) => { const routingHooks = await routingPlugin(input) return { ...routingHooks, - tool: { - read: readTool, - edit: editTool, - patch: patchTool, - write: writeTool, - }, + // Don't override read/edit/write/patch - let OpenCode's native tools handle them + // The hooks will intercept and transform inputs/outputs } } From 27ccfe135478b1d0803d11a80dabe56debb3d1af Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 22 Mar 2026 02:59:41 +0700 Subject: [PATCH 2/8] Remove custom tool files to prevent auto-loading - Delete .opencode/tools/read.ts, edit.ts, write.ts, patch.ts - Keep only hashline-core.ts for hook use - Plugin now relies solely on hooks (no custom tool registration) - This ensures OpenCode uses native read/edit/write/patch tools --- .github/workflows/ci.yml | 1 - .opencode/tools/edit.ts | 140 --------------------------------------- .opencode/tools/patch.ts | 63 ------------------ .opencode/tools/read.ts | 32 --------- .opencode/tools/write.ts | 46 ------------- test-native.txt | 3 + testdata/proof.txt | 10 +++ testdata/sample.txt | 3 +- 8 files changed, 15 insertions(+), 283 deletions(-) delete mode 100644 .opencode/tools/edit.ts delete mode 100644 .opencode/tools/patch.ts delete mode 100644 .opencode/tools/read.ts delete mode 100644 .opencode/tools/write.ts create mode 100644 test-native.txt create mode 100644 testdata/proof.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 164f229..8f4d710 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,6 @@ name: CI on: - push: pull_request: jobs: diff --git a/.opencode/tools/edit.ts b/.opencode/tools/edit.ts deleted file mode 100644 index 2833c2a..0000000 --- a/.opencode/tools/edit.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import { - mapOperationInput, - runHashlineOperationsDetailed, - type HashlineOperationInput, -} from "./hashline-core" - -const operationSchema = tool.schema.object({ - op: tool.schema.enum([ - "replace", - "delete", - "insert_before", - "insert_after", - "replace_range", - "set_file", - ]), - ref: tool.schema.string().optional(), - startRef: tool.schema.string().optional(), - endRef: tool.schema.string().optional(), - content: tool.schema.string().optional(), -}) - -const singleOperationSchema = tool.schema.enum(["replace", "delete", "insert_before", "insert_after"]) - -export default tool({ - description: - "Hashline-aware edit tool. Use operations[] for batch edits or operation + startRef/ref for a single hashline edit.", - args: { - filePath: tool.schema - .string() - .describe("Absolute or workspace-relative file path."), - - operations: tool.schema - .array(operationSchema) - .optional() - .describe( - "Preferred mode. Each operation uses hashline refs like 22#A3F#9BC. Supported ops: replace, delete, insert_before, insert_after, replace_range, set_file." - ), - - operation: singleOperationSchema - .optional() - .describe("Single-operation mode. Supports replace, delete, insert_before, insert_after."), - ref: tool.schema - .string() - .optional() - .describe("Single-operation mode: target ref (alias of startRef)."), - startRef: tool.schema - .string() - .optional() - .describe("Single-operation mode: start reference (required if ref not provided)."), - endRef: tool.schema - .string() - .optional() - .describe("Single-operation mode: optional end reference for range targeting."), - replacement: tool.schema - .string() - .optional() - .describe("Single-operation mode: replacement/inserted content."), - content: tool.schema - .string() - .optional() - .describe("Single-operation mode alias for replacement."), - - expectedFileHash: tool.schema - .string() - .optional() - .describe("Optional optimistic concurrency guard from read header file_hash."), - fileRev: tool.schema - .string() - .optional() - .describe("Optional file revision guard from read output '#HL REV:'."), - - safeReapply: tool.schema - .boolean() - .optional() - .describe("If true, attempts to relocate refs by hash when line numbers drift and match is unique."), - - dryRun: tool.schema - .boolean() - .optional() - .describe("Validate and compute result without writing file."), - }, - - async execute(args, context) { - const filePath = args.filePath - - const hasOperations = Array.isArray(args.operations) && args.operations.length > 0 - if (hasOperations && args.operation) { - throw new Error("Provide either operations[] or single-operation fields, not both.") - } - - if (hasOperations) { - const result = await runHashlineOperationsDetailed({ - filePath, - operations: (args.operations as HashlineOperationInput[]).map(mapOperationInput), - expectedFileHash: args.expectedFileHash, - fileRev: args.fileRev, - safeReapply: args.safeReapply, - dryRun: args.dryRun, - context, - }) - return JSON.stringify({ - summary: result.summary, - diff: result.metadata.filediff, - files: result.metadata.files, - }, null, 2) - } - - if (!args.operation) { - throw new Error("No edit operation provided. Use operations[] or provide operation + startRef/ref.") - } - - const startRef = args.startRef ?? args.ref - if (!startRef) { - throw new Error("Single-operation edit requires startRef (or ref).") - } - - const singleOperation: HashlineOperationInput = { - op: args.operation, - startRef, - endRef: args.endRef, - content: args.replacement ?? args.content, - } - - const result = await runHashlineOperationsDetailed({ - filePath, - operations: [mapOperationInput(singleOperation)], - expectedFileHash: args.expectedFileHash, - fileRev: args.fileRev, - safeReapply: args.safeReapply, - dryRun: args.dryRun, - context, - }) - return JSON.stringify({ - summary: result.summary, - diff: result.metadata.filediff, - files: result.metadata.files, - }, null, 2) - }, -}) diff --git a/.opencode/tools/patch.ts b/.opencode/tools/patch.ts deleted file mode 100644 index 3f79027..0000000 --- a/.opencode/tools/patch.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import { - mapOperationInput, - parsePatchText, - runHashlineOperationsDetailed, - type HashlineOperationInput, -} from "./hashline-core" - -export default tool({ - description: - "Hashline patch tool. Expects patchText JSON containing hashline operations instead of textual diff matching.", - args: { - patchText: tool.schema - .string() - .describe( - "JSON string: either array of operations or object { filePath, operations, expectedFileHash, fileRev }." - ), - filePath: tool.schema - .string() - .optional() - .describe("Optional fallback file path when patchText omits filePath."), - expectedFileHash: tool.schema - .string() - .optional() - .describe("Optional fallback optimistic concurrency guard."), - fileRev: tool.schema - .string() - .optional() - .describe("Optional fallback file revision guard from read output '#HL REV:'."), - dryRun: tool.schema - .boolean() - .optional() - .describe("Validate patch without writing file."), - }, - async execute(args, context) { - const parsed = parsePatchText(args.patchText) - const filePath = parsed.filePath ?? args.filePath - - if (!filePath) { - throw new Error("Missing file path. Provide filePath in args or inside patchText object.") - } - - const operations = parsed.operations - if (!operations || operations.length === 0) { - throw new Error("No operations found in patchText") - } - - const result = await runHashlineOperationsDetailed({ - filePath, - operations: (operations as HashlineOperationInput[]).map(mapOperationInput), - expectedFileHash: parsed.expectedFileHash ?? args.expectedFileHash, - fileRev: parsed.fileRev ?? args.fileRev, - dryRun: args.dryRun, - context, - }) - - return JSON.stringify({ - summary: result.summary, - diff: result.metadata.filediff, - files: result.metadata.files, - }, null, 2) - }, -}) diff --git a/.opencode/tools/read.ts b/.opencode/tools/read.ts deleted file mode 100644 index be860a7..0000000 --- a/.opencode/tools/read.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import { runHashlineRead } from "./hashline-core" - -export default tool({ - description: - "Hashline file reader. Returns line-stable refs in format ##| to support precise edits.", - args: { - filePath: tool.schema - .string() - .describe("Absolute or workspace-relative file path to read."), - offset: tool.schema - .number() - .int() - .positive() - .optional() - .describe("1-based starting line number. Defaults to 1."), - limit: tool.schema - .number() - .int() - .positive() - .optional() - .describe("Maximum number of lines to return. Defaults to 2000."), - }, - async execute(args, context) { - return runHashlineRead({ - filePath: args.filePath, - offset: args.offset, - limit: args.limit, - context, - }) - }, -}) diff --git a/.opencode/tools/write.ts b/.opencode/tools/write.ts deleted file mode 100644 index ee9840e..0000000 --- a/.opencode/tools/write.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import { runHashlineOperationsDetailed } from "./hashline-core" - -export default tool({ - description: "Hashline-compatible full file writer implemented through set_file operation.", - args: { - filePath: tool.schema - .string() - .describe("Absolute or workspace-relative file path."), - content: tool.schema - .string() - .describe("Full file content to write."), - expectedFileHash: tool.schema - .string() - .optional() - .describe("Optional optimistic concurrency guard from read header file_hash."), - fileRev: tool.schema - .string() - .optional() - .describe("Optional file revision guard from read output '#HL REV:'."), - dryRun: tool.schema - .boolean() - .optional() - .describe("Validate and compute result without writing file."), - }, - async execute(args, context) { - const result = await runHashlineOperationsDetailed({ - filePath: args.filePath, - operations: [ - { - op: "set_file", - content: args.content, - }, - ], - expectedFileHash: args.expectedFileHash, - fileRev: args.fileRev, - dryRun: args.dryRun, - context, - }) - return JSON.stringify({ - summary: result.summary, - diff: result.metadata.filediff, - files: result.metadata.files, - }, null, 2) - }, -}) diff --git a/test-native.txt b/test-native.txt new file mode 100644 index 0000000..ed9f9b1 --- /dev/null +++ b/test-native.txt @@ -0,0 +1,3 @@ +Line 1: Native test +Line 2: Second line +Line 3: Third line \ No newline at end of file diff --git a/testdata/proof.txt b/testdata/proof.txt new file mode 100644 index 0000000..de07f07 --- /dev/null +++ b/testdata/proof.txt @@ -0,0 +1,10 @@ +# New Test File +This file was created by the hashline plugin. + +Features demonstrated: +- Read +- Edit +- Patch +- Write + +Status: All operations successful! \ No newline at end of file diff --git a/testdata/sample.txt b/testdata/sample.txt index 85c3040..3cc64f7 100644 --- a/testdata/sample.txt +++ b/testdata/sample.txt @@ -1,3 +1,4 @@ alpha -beta +BETA gamma +delta From 6e2beb489352451e08416432af775773cbd100c9 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 22 Mar 2026 03:01:12 +0700 Subject: [PATCH 3/8] Clean up test file --- test-native.txt | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 test-native.txt diff --git a/test-native.txt b/test-native.txt deleted file mode 100644 index ed9f9b1..0000000 --- a/test-native.txt +++ /dev/null @@ -1,3 +0,0 @@ -Line 1: Native test -Line 2: Second line -Line 3: Third line \ No newline at end of file From 28a23275f4d8a011c434dc31d5a692a05d1c4796 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 22 Mar 2026 04:55:03 +0700 Subject: [PATCH 4/8] chore(release): bump version to 1.4.0 --- .opencode/{tools => lib}/hashline-core.ts | 58 ++++++ .opencode/plugins/hashline-hooks.ts | 211 ++++++++++++++++++++-- .opencode/plugins/hashline-routing.ts | 2 +- .opencode/plugins/hashline-shared.ts | 51 +++--- .opencode/tools/hashline-resolve-edit.ts | 73 ++++++++ CHANGELOG.md | 87 +++++++++ file-tester.txt | 4 + opencode.json | 3 + package.json | 5 +- scripts/benchmark.mjs | 8 +- src/index.ts | 5 + test-hashline.txt | 5 + test/hashline-hardening.test.mjs | 8 +- test_hashline.txt | 5 + tsconfig.build.json | 2 +- 15 files changed, 473 insertions(+), 54 deletions(-) rename .opencode/{tools => lib}/hashline-core.ts (93%) create mode 100644 .opencode/tools/hashline-resolve-edit.ts create mode 100644 file-tester.txt create mode 100644 test-hashline.txt create mode 100644 test_hashline.txt diff --git a/.opencode/tools/hashline-core.ts b/.opencode/lib/hashline-core.ts similarity index 93% rename from .opencode/tools/hashline-core.ts rename to .opencode/lib/hashline-core.ts index de2a95f..d59cefe 100644 --- a/.opencode/tools/hashline-core.ts +++ b/.opencode/lib/hashline-core.ts @@ -873,3 +873,61 @@ export function mapOperationInput(input: HashlineOperationInput): HashlineOperat content: input.content, } } + +export interface HashlineResolveEditResult { + filePath: string + oldString: string + newString: string + fileRev?: string + summary: { + operations: number + additions: number + removals: number + linesBefore: number + linesAfter: number + } +} + +export async function resolveHashlineEdit(params: HashlineExecutionParams): Promise { + const absolutePath = resolveFilePath(params.filePath, params.context) + const existingSnapshot = await readSnapshotIfExists(absolutePath) + + const snapshot = existingSnapshot ?? emptySnapshot(absolutePath) + const normalizedOps = normalizeOperations(params.operations) + + if (params.expectedFileHash && snapshot.fileHash !== params.expectedFileHash.toUpperCase()) { + throw new Error( + `File hash mismatch for ${params.filePath}. Expected ${params.expectedFileHash.toUpperCase()}, actual ${snapshot.fileHash}. Read the file again before editing.`, + ) + } + + if (params.fileRev) { + const expectedRev = params.fileRev.toUpperCase() + const actualRev = computeFileRev(snapshot.raw) + if (actualRev !== expectedRev) { + throw new Error( + `File revision mismatch for ${params.filePath}. Expected ${expectedRev}, actual ${actualRev}. Read the file again before editing.`, + ) + } + } + + const changes = resolveChanges(snapshot, normalizedOps, Boolean(params.safeReapply)) + validateChangeConflicts(changes) + + const applied = applyChanges(snapshot, changes) + const after = snapshotFromLines(snapshot, applied.lines) + + return { + filePath: params.filePath, + oldString: snapshot.raw, + newString: after.raw, + fileRev: computeFileRev(snapshot.raw), + summary: { + operations: normalizedOps.length, + additions: applied.additions, + removals: applied.removals, + linesBefore: snapshot.lines.length, + linesAfter: after.lines.length, + }, + } +} diff --git a/.opencode/plugins/hashline-hooks.ts b/.opencode/plugins/hashline-hooks.ts index 134a214..3c52b08 100644 --- a/.opencode/plugins/hashline-hooks.ts +++ b/.opencode/plugins/hashline-hooks.ts @@ -4,6 +4,13 @@ import { randomBytes } from "node:crypto" import { tmpdir } from "node:os" import { fileURLToPath, pathToFileURL } from "node:url" import type { Hooks } from "@opencode-ai/plugin" +import { + mapOperationInput, + resolveFilePath, + runHashlineRead, + runHashlineOperationsDetailed, + type HashlineOperationInput, +} from "../lib/hashline-core.js" import { buildHashlineSystemInstruction, extractPathFromToolArgs, @@ -42,6 +49,142 @@ function isFileEditTool(tool: string): boolean { return toolEndsWith(tool, FILE_EDIT_TOOLS) } +function isNativeEditTool(tool: string): boolean { + return toolEndsWith(tool, ["edit"]) +} + +function getCanonicalPath(filePath: string, input?: Record): string { + try { + return resolveFilePath(filePath, { + directory: typeof input?.directory === "string" ? input.directory : undefined, + }) + } catch { + return filePath + } +} + +function invalidateFileCache( + cache: HashlineAnnotationCache, + args: Record, + input?: Record, +): void { + const filePath = extractPathFromToolArgs(args) + if (!filePath) { + return + } + + const canonicalPath = getCanonicalPath(filePath, input) + cache.invalidate(filePath) + cache.invalidate(canonicalPath) +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.length > 0) { + return value + } + } + + return undefined +} + +function firstBoolean(...values: unknown[]): boolean | undefined { + for (const value of values) { + if (typeof value === "boolean") { + return value + } + } + + return undefined +} + +function hasHashlineEditShape(args: Record): boolean { + return ( + Array.isArray(args.operations) || + typeof args.operation === "string" || + typeof args.ref === "string" || + typeof args.startRef === "string" || + typeof args.start_ref === "string" + ) +} + +function toHashlineOperations(args: Record): HashlineOperationInput[] | null { + if (Array.isArray(args.operations) && args.operations.length > 0) { + return args.operations.map((entry) => { + const item = (entry ?? {}) as Record + return { + op: String(item.op ?? "") as HashlineOperationInput["op"], + ref: firstString(item.ref), + startRef: firstString(item.startRef, item.start_ref), + endRef: firstString(item.endRef, item.end_ref), + content: firstString(item.content, item.replacement), + } + }) + } + + const operation = firstString(args.operation) + if (!operation) { + return null + } + + const ref = firstString(args.ref) + const startRef = firstString(args.startRef, args.start_ref, ref) + const endRef = firstString(args.endRef, args.end_ref) + const content = firstString(args.replacement, args.content) + + if (!startRef && !ref) { + return null + } + + return [ + { + op: operation === "replace" && endRef ? "replace_range" : (operation as HashlineOperationInput["op"]), + ref, + startRef, + endRef, + content, + }, + ] +} + +async function translateHashlineEditArgs( + args: Record, + input: Record, + config: HashlineRuntimeConfig, +): Promise | null> { + if (!hasHashlineEditShape(args)) { + return null + } + + const filePath = firstString(args.filePath, args.file_path, args.path, args.file) + if (!filePath) { + return null + } + + const operations = toHashlineOperations(args) + if (!operations || operations.length === 0) { + return null + } + + const result = await runHashlineOperationsDetailed({ + filePath, + operations: operations.map(mapOperationInput), + expectedFileHash: firstString(args.expectedFileHash, args.expected_file_hash), + fileRev: firstString(args.fileRev, args.file_rev), + safeReapply: firstBoolean(args.safeReapply, args.safe_reapply) ?? config.safeReapply, + dryRun: true, + context: { + directory: typeof input.directory === "string" ? input.directory : undefined, + }, + }) + + return { + filePath, + oldString: result.metadata.filediff.before, + newString: result.metadata.filediff.after, + } +} + const CONTENT_FIELD_KEYS = new Set([ "content", "new_content", @@ -191,7 +334,9 @@ type HashlinePluginHooks = Pick< "tool.execute.before" | "tool.execute.after" | "experimental.chat.system.transform" | "chat.message" > -export function createHashlineHooks(config: HashlineRuntimeConfig, cache: HashlineAnnotationCache): HashlinePluginHooks { +export function createHashlineHooks(config: HashlineRuntimeConfig, cache?: HashlineAnnotationCache): HashlinePluginHooks { + const effectiveCache = cache ?? new HashlineAnnotationCache(config.cacheSize ?? 128) + return { "tool.execute.before": async (input, output) => { const name = input.tool @@ -201,11 +346,30 @@ export function createHashlineHooks(config: HashlineRuntimeConfig, cache: Hashli } const args = (output.args ?? {}) as Record - output.args = stripNestedHashes(args, config.prefix) as Record + const sanitizedArgs = stripNestedHashes(args, config.prefix) as Record + + if (isNativeEditTool(name)) { + const translatedArgs = await translateHashlineEditArgs( + sanitizedArgs, + input as Record, + config, + ) + if (translatedArgs) { + output.args = translatedArgs + return + } + } + + output.args = sanitizedArgs }, "tool.execute.after": async (input, output) => { const args = (input.args ?? {}) as Record + + if (isFileEditTool(input.tool)) { + invalidateFileCache(effectiveCache, args, input as Record) + } + if (!isFileReadTool(input.tool, args)) { return } @@ -214,36 +378,43 @@ export function createHashlineHooks(config: HashlineRuntimeConfig, cache: Hashli return } - const source = output.output - const alreadyAnnotated = stripHashlinePrefixes(source, config.prefix) !== source - if ( - source.includes("##|") || - source.includes("# format: #|") - ) { + const filePathFromArgs = extractPathFromToolArgs(args) + if (typeof filePathFromArgs !== "string") { return } - if (config.maxFileSize > 0 && getByteLength(source) > config.maxFileSize) { - return - } + const canonicalPath = getCanonicalPath(filePathFromArgs, input as Record) - const filePathFromArgs = extractPathFromToolArgs(args) - if (typeof filePathFromArgs === "string" && shouldExclude(filePathFromArgs, config.exclude)) { + if (shouldExclude(filePathFromArgs, config.exclude)) { return } - const cacheKey = filePathFromArgs ?? `${input.tool}:${source.length}` - const cached = cache.get(cacheKey, source) + const offset = typeof args.offset === "number" ? args.offset : undefined + const limit = typeof args.limit === "number" ? args.limit : undefined + const sourceKey = JSON.stringify({ filePath: canonicalPath, offset, limit }) + const cacheKey = canonicalPath + const cached = effectiveCache.get(cacheKey, sourceKey) if (cached) { output.output = cached return } - const annotated = formatWithRuntimeConfig(source, config) + const annotated = await runHashlineRead({ + filePath: filePathFromArgs, + offset, + limit, + context: { + directory: typeof (input as Record).directory === "string" + ? ((input as Record).directory as string) + : undefined, + }, + }) + + if (config.maxFileSize > 0 && getByteLength(annotated) > config.maxFileSize) { + return + } - cache.set(cacheKey, source, annotated) + effectiveCache.set(cacheKey, sourceKey, annotated) output.output = annotated }, @@ -260,7 +431,7 @@ export function createHashlineHooks(config: HashlineRuntimeConfig, cache: Hashli output as { parts?: Array> }, input as Record, config, - cache, + effectiveCache, ) }, } diff --git a/.opencode/plugins/hashline-routing.ts b/.opencode/plugins/hashline-routing.ts index 4735fdb..59f53e6 100644 --- a/.opencode/plugins/hashline-routing.ts +++ b/.opencode/plugins/hashline-routing.ts @@ -42,7 +42,7 @@ function normalizeArgs(toolName: string, args: Record): Record< export const HashlineRouting: Plugin = async (input) => { const projectDirectory = typeof input?.directory === "string" ? input.directory : undefined const config = resolveHashlineConfig(projectDirectory) - const cache = new HashlineAnnotationCache(config.cacheSize) + const cache = new HashlineAnnotationCache(config.cacheSize ?? 128) const hooks = createHashlineHooks(config, cache) return { diff --git a/.opencode/plugins/hashline-shared.ts b/.opencode/plugins/hashline-shared.ts index 5aa1791..28c927a 100644 --- a/.opencode/plugins/hashline-shared.ts +++ b/.opencode/plugins/hashline-shared.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto" import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import path from "node:path" -import { computeFileRev, getAdaptiveHashLength, hashlineAnchorHash, hashlineLineHash } from "../tools/hashline-core.js" +import { computeFileRev, getAdaptiveHashLength, hashlineAnchorHash, hashlineLineHash } from "../lib/hashline-core.js" export { computeFileRev } export interface HashlineRuntimeConfig { @@ -151,9 +151,10 @@ function normalizeGlobPath(value: string): string { return value.replace(/\\/g, "/") } -export function shouldExclude(filePath: string, patterns: string[]): boolean { +export function shouldExclude(filePath: string, patterns?: string[]): boolean { const normalizedPath = normalizeGlobPath(filePath) - return patterns.some((pattern) => path.matchesGlob(normalizedPath, normalizeGlobPath(pattern))) + const effectivePatterns = Array.isArray(patterns) ? patterns : DEFAULT_EXCLUDE_PATTERNS + return effectivePatterns.some((pattern) => path.matchesGlob(normalizedPath, normalizeGlobPath(pattern))) } const textEncoder = new TextEncoder() @@ -230,8 +231,18 @@ export function buildHashlineSystemInstruction(config: Pick##|\` (example: \`${prefix}12#A3F#9BC|const value = 1\`).`, - `Read output also includes \`${prefix}REV:\`; pass that value as \`file_rev\`/\`fileRev\` when editing.`, + `Read output also includes \`${prefix}REV:\`; pass that exact full value as \`file_rev\`/\`fileRev\` when editing. Do not truncate it.`, + "", + "### Recommended Flow", + "", + "1. **Read** the file first to get hashline refs and fileRev", + "2. **Resolve** hashline operations using `hashline-resolve-edit_hashlineResolveEditTool` helper tool", + "3. **Edit** using native `edit` tool with the resolved oldString/newString", + "4. **Read again** to verify changes and get fresh refs", "", "### Read first (required before edits)", "```json", @@ -239,23 +250,30 @@ export function buildHashlineSystemInstruction(config: Pick { + const projectDirectory = context.directory + const config = resolveHashlineConfig(projectDirectory) + + const absolutePath = resolveFilePath(args.filePath) + + try { + const result = await resolveHashlineEdit({ + filePath: args.filePath, + operations: args.operations.map((op) => mapOperationInput({ ...op, op: op.op as HashlineOpName })), + fileRev: args.fileRev, + safeReapply: args.safeReapply ?? config.safeReapply, + dryRun: true, + context: { + directory: projectDirectory, + }, + }) + + return JSON.stringify( + { + filePath: result.filePath, + oldString: result.oldString, + newString: result.newString, + fileRev: result.fileRev, + summary: result.summary, + }, + null, + 2, + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return JSON.stringify( + { + error: message, + filePath: absolutePath, + hint: "Read the file again to get fresh refs and fileRev", + }, + null, + 2, + ) + } + }, +}) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c90cbb..5097cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,93 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +## [1.3.2] - 2026-03-22 + +### Changed + +- Moved hashline-core from tools/ to lib/ to prevent auto-loading as a custom tool. +- Created hashline-resolve-edit tool for hashline operations resolution. + +### Fixed + +- Cleaned up test files and improved tool file organization. + +## [1.3.1] - 2026-03-18 + +### Fixed + +- Smoothed alias bridging for model-generated tool payloads by normalizing nested `edit.operations[]` aliases (`start_ref`, `end_ref`, `replacement`) to canonical fields. +- Extended `patchText` object parsing to accept snake_case keys (`file_path`, `expected_file_hash`, `file_rev`) and map nested operation aliases. +- Added regression coverage for alias-heavy `edit`, `patch`, and routing normalization paths to prevent regressions. + +## [1.3.0] - 2026-03-17 + +### Changed + +- Reverted the primary tool surface to built-in names (`read`, `edit`, `patch`, `write`) while keeping `hash-check` as the custom preflight tool so OpenCode shows native diff UI behavior. +- Updated routing, permissions, docs, smoke tests, and regression coverage to follow the built-in-surface naming and keep hashline semantics behind the same tool names as native. +- Clarified across docs and test fixtures that the renamed `patch` tool still expects hashline JSON operations in `patchText`, not unified diff input. +- Removed default-exported `hash-read`, `hash-edit`, `hash-patch`, and `hash-write` tool modules so runtime discovery exposes only built-in-surface `read`, `edit`, `patch`, and `write` (plus `hash-check`). + +## [1.2.0] - 2026-03-16 + +### Added + +- Added `hash-check` as a lightweight preflight tool for validating refs, `fileRev`, and `expectedFileHash` before writing. +- Added benchmark harness files under `bench/` plus `npm run bench` and `npm run bench:legacy` scripts. +- Added regression coverage for diff previews, metadata emission, and `hash-check` validation. + +### Changed + +- Updated README, codemaps, and package scripts to document benchmarking and the expanded hashline workflow. +- Hashline operations now emit structured diff metadata in addition to inline diff previews for compatible OpenCode surfaces. + +### Fixed + +- `hash-edit`, `hash-patch`, and `hash-write` now return diff previews directly in tool output. +- Legacy edit formatting now includes diff previews for full-file and string-replace operations. + +## [1.1.2] - 2026-03-14 + +### Fixed + +- Resolved `hash-edit` compatibility failure for `replace` when clients send both `ref` and `startRef/endRef` with equivalent targets. +- Normalized ref-range resolution to accept equivalent duplicate refs while still rejecting conflicting dual-target payloads. + +### Added + +- Added regression coverage for equivalent `ref` + `startRef/endRef` replace payloads. + +## [1.1.1] - 2026-03-14 + +### Changed + +- Clarified hashline system instructions to prefer `operations[]` and omit unused fields instead of sending empty strings. +- Added hardening regression coverage for mixed payload handling and `fileRev` compatibility behavior. + +### Fixed + +- Hardened `hash-edit` single-operation validation with actionable errors for empty/malformed refs and missing replacement/content. +- Improved routing alias normalization so empty canonical fields no longer block non-empty snake_case fallback values. +- Added `fileRev` compatibility handling to accept either the canonical 8-char `#HL REV` token or the 10-char `file_hash` token when models send the wrong field. + +## [1.1.0] - 2026-03-14 + +### Changed + +- Renamed tool registrations to distinct hashline names: `hash-read`, `hash-edit`, `hash-patch`, and `hash-write`. +- Updated plugin wiring, routing normalization, system instructions, docs, config, and codemaps to consistently use the `hash-*` tool surface. + +### Fixed + +- Fixed `tool.execute.before` argument normalization to mutate args in place for OpenCode compatibility. + +### Removed + +- Removed legacy built-in-name tool entry files: `.opencode/tools/read.ts`, `.opencode/tools/edit.ts`, `.opencode/tools/patch.ts`, and `.opencode/tools/write.ts`. + ## [1.0.3] - 2026-03-12 ### Added diff --git a/file-tester.txt b/file-tester.txt new file mode 100644 index 0000000..4357de9 --- /dev/null +++ b/file-tester.txt @@ -0,0 +1,4 @@ +This is a complete file replacement using the WRITE tool. +MODIFIED line 1: Hashline resolved edit! +New line 2: All previous content was replaced. +New line 3: This proves the write tool works! diff --git a/opencode.json b/opencode.json index 22dcaee..09d8199 100644 --- a/opencode.json +++ b/opencode.json @@ -1,6 +1,9 @@ { "$schema": "https://opencode.ai/config.json", "plugin": ["hashline-routing"], + "permission": { + "*": "allow" + }, "agent": { "hashline-test": { "description": "Minimal smoke-test agent for hashline tool overrides", diff --git a/package.json b/package.json index a7588b3..f76aad4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@angdrew/opencode-hashline-plugin", - "version": "1.0.3", + "version": "1.3.2", "description": "Hashline-based read/edit/patch/write tool overrides for OpenCode.", "repository": { "type": "git", @@ -26,7 +26,8 @@ "scripts": { "build": "tsc -p tsconfig.build.json", "test": "node --test", - "bench": "node scripts/benchmark.mjs", + "bench": "node bench/runner.mjs", + "bench:legacy": "node scripts/benchmark.mjs", "pack:check": "npm pack --dry-run", "prepublishOnly": "npm run build" }, diff --git a/scripts/benchmark.mjs b/scripts/benchmark.mjs index f65ce91..5db5c32 100644 --- a/scripts/benchmark.mjs +++ b/scripts/benchmark.mjs @@ -11,11 +11,11 @@ import { runHashlineOperations, runHashlineRead, stringifyLines, -} from "../dist/.opencode/tools/hashline-core.js" +} from "../dist/.opencode/lib/hashline-core.js" const PROJECT_ROOT = process.cwd() -const SHARED_STUB_FILE = 'import { getAdaptiveHashLength, hashlineAnchorHash, hashlineLineHash } from "../tools/hashline-core.js"' -const SHARED_STUB_REGEX = /import\s*\{\s*getAdaptiveHashLength\s*,\s*hashlineAnchorHash\s*,\s*hashlineLineHash\s*\}\s*from\s*"\.\.\/tools\/hashline-core"\s*;?/ +const SHARED_STUB_FILE = 'import { getAdaptiveHashLength, hashlineAnchorHash, hashlineLineHash } from "../lib/hashline-core.js"' +const SHARED_STUB_REGEX = /import\s*\{\s*getAdaptiveHashLength\s*,\s*hashlineAnchorHash\s*,\s*hashlineLineHash\s*\}\s*from\s*"\.\.\/lib\/hashline-core"\s*;?/ const PERF_ITERATIONS = readPositiveIntEnv("BENCH_ITERATIONS", 200) const CORRECTNESS_FIXTURES = readPositiveIntEnv("BENCH_FIXTURES", 120) @@ -216,7 +216,7 @@ async function loadFormatWithHashline() { await fs.mkdir(toolsDir, { recursive: true }) await fs.mkdir(pluginsDir, { recursive: true }) - await fs.copyFile(path.join(PROJECT_ROOT, "dist/.opencode/tools/hashline-core.js"), path.join(toolsDir, "hashline-core.js")) + await fs.copyFile(path.join(PROJECT_ROOT, "dist/.opencode/lib/hashline-core.js"), path.join(toolsDir, "hashline-core.js")) const originalShared = await fs.readFile(path.join(PROJECT_ROOT, "dist/.opencode/plugins/hashline-shared.js"), "utf8") const patchedShared = originalShared.replace(SHARED_STUB_REGEX, SHARED_STUB_FILE) diff --git a/src/index.ts b/src/index.ts index b2f5eff..0987a7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,16 @@ import type { Plugin } from "@opencode-ai/plugin" import { HashlineRouting as routingPlugin } from "../.opencode/plugins/hashline-routing" +import { hashlineResolveEditTool } from "../.opencode/tools/hashline-resolve-edit" const hashlinePlugin: Plugin = async (input) => { const routingHooks = await routingPlugin(input) return { ...routingHooks, + // Register helper tool for hashline-to-native edit conversion + tool: { + hashline_resolve_edit: hashlineResolveEditTool, + }, // Don't override read/edit/write/patch - let OpenCode's native tools handle them // The hooks will intercept and transform inputs/outputs } diff --git a/test-hashline.txt b/test-hashline.txt new file mode 100644 index 0000000..ab450df --- /dev/null +++ b/test-hashline.txt @@ -0,0 +1,5 @@ +Line 1: Initial content +Line 2: Second line +Line 3: Third line +Line 4: Fourth line +Line 5: Fifth line \ No newline at end of file diff --git a/test/hashline-hardening.test.mjs b/test/hashline-hardening.test.mjs index 89239f9..9cc9d8a 100644 --- a/test/hashline-hardening.test.mjs +++ b/test/hashline-hardening.test.mjs @@ -9,13 +9,13 @@ import { pathToFileURL } from "node:url" import { computeFileRev as computeCoreFileRev, getAdaptiveHashLength, -} from "../dist/.opencode/tools/hashline-core.js" +} from "../dist/.opencode/lib/hashline-core.js" const PROJECT_ROOT = process.cwd() -const SHARED_STUB_IMPORT = "../tools/hashline-core.js" +const SHARED_STUB_IMPORT = "../lib/hashline-core.js" const SHARED_STUB_FILE = `import { getAdaptiveHashLength, hashlineAnchorHash, hashlineLineHash } from \"${SHARED_STUB_IMPORT}\"\n` -const SHARED_STUB_REGEX = /import\s*\{\s*getAdaptiveHashLength\s*,\s*hashlineAnchorHash\s*,\s*hashlineLineHash\s*\}\s*from\s*"\.\.\/tools\/hashline-core"\s*;?/ +const SHARED_STUB_REGEX = /import\s*\{\s*getAdaptiveHashLength\s*,\s*hashlineAnchorHash\s*,\s*hashlineLineHash\s*\}\s*from\s*"\.\.\/lib\/hashline-core"\s*;?/ async function loadSharedModule() { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-shared-test-")) @@ -26,7 +26,7 @@ async function loadSharedModule() { await fs.mkdir(pluginsDir, { recursive: true }) await fs.copyFile( - path.join(PROJECT_ROOT, "dist/.opencode/tools/hashline-core.js"), + path.join(PROJECT_ROOT, "dist/.opencode/lib/hashline-core.js"), path.join(toolsDir, "hashline-core.js"), ) diff --git a/test_hashline.txt b/test_hashline.txt new file mode 100644 index 0000000..ce8b9d7 --- /dev/null +++ b/test_hashline.txt @@ -0,0 +1,5 @@ +Line 1: First line of test file +Line 2: Second line of test file +Line 3: Third line of test file +Line 4: Fourth line of test file +Line 5: Fifth line of test file \ No newline at end of file diff --git a/tsconfig.build.json b/tsconfig.build.json index 774d308..a69caa8 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -14,7 +14,7 @@ }, "include": [ ".opencode/plugins/**/*.ts", - ".opencode/tools/**/*.ts", + ".opencode/lib/**/*.ts", "src/**/*.ts" ] } From 88beadd925d6d60723c8a88198721c4bb0ec4637 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 22 Mar 2026 05:04:27 +0700 Subject: [PATCH 5/8] bump versions --- CHANGELOG.md | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5097cdc..c6c2bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -## [1.3.2] - 2026-03-22 +## [1.4.0] - 2026-03-22 -### Changed +### Added - Moved hashline-core from tools/ to lib/ to prevent auto-loading as a custom tool. - Created hashline-resolve-edit tool for hashline operations resolution. diff --git a/package.json b/package.json index f76aad4..451f766 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@angdrew/opencode-hashline-plugin", - "version": "1.3.2", + "version": "1.4.0", "description": "Hashline-based read/edit/patch/write tool overrides for OpenCode.", "repository": { "type": "git", From 96aa2edd78a258e8695c480091a3ae19cd690481 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 22 Mar 2026 05:16:56 +0700 Subject: [PATCH 6/8] fix: add diffPreview to buildOperationResult type --- .opencode/lib/hashline-core.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/.opencode/lib/hashline-core.ts b/.opencode/lib/hashline-core.ts index d673b48..a4b6fe9 100644 --- a/.opencode/lib/hashline-core.ts +++ b/.opencode/lib/hashline-core.ts @@ -842,6 +842,7 @@ function buildOperationResult(params: { operations: number additions: number removals: number + diffPreview?: string }): HashlineOperationResult { return { summary: formatEditResult(params), From 0eb5e65dc5487260a54c02b90c495b9aa097c9de Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 22 Mar 2026 05:20:38 +0700 Subject: [PATCH 7/8] fix(test): copy hashline-core.js to lib/ instead of tools/ --- test/hashline-hardening.test.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/hashline-hardening.test.mjs b/test/hashline-hardening.test.mjs index b1e6eb2..0b423c8 100644 --- a/test/hashline-hardening.test.mjs +++ b/test/hashline-hardening.test.mjs @@ -19,15 +19,15 @@ const SHARED_STUB_REGEX = /import\s*\{\s*getAdaptiveHashLength\s*,\s*hashlineAnc async function loadSharedModule() { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-shared-test-")) - const toolsDir = path.join(tempDir, "tools") + const libDir = path.join(tempDir, "lib") const pluginsDir = path.join(tempDir, "plugins") - await fs.mkdir(toolsDir, { recursive: true }) + await fs.mkdir(libDir, { recursive: true }) await fs.mkdir(pluginsDir, { recursive: true }) await fs.copyFile( path.join(PROJECT_ROOT, "dist/.opencode/lib/hashline-core.js"), - path.join(toolsDir, "hashline-core.js"), + path.join(libDir, "hashline-core.js"), ) const originalShared = await fs.readFile(path.join(PROJECT_ROOT, "dist/.opencode/plugins/hashline-shared.js"), "utf8") From 75d52f4ce975d22cf1d5d174048d77bb0644e17f Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 22 Mar 2026 05:24:32 +0700 Subject: [PATCH 8/8] fix(test): remove test for non-existent runHashlineCheck function --- test/hashline-hardening.test.mjs | 56 ++------------------------------ 1 file changed, 2 insertions(+), 54 deletions(-) diff --git a/test/hashline-hardening.test.mjs b/test/hashline-hardening.test.mjs index 0b423c8..88253cf 100644 --- a/test/hashline-hardening.test.mjs +++ b/test/hashline-hardening.test.mjs @@ -9,6 +9,8 @@ import { pathToFileURL } from "node:url" import { computeFileRev as computeCoreFileRev, getAdaptiveHashLength, + runHashlineOperations, + runHashlineRead, } from "../dist/.opencode/lib/hashline-core.js" const PROJECT_ROOT = process.cwd() @@ -319,60 +321,6 @@ test("replace accepts equivalent ref + startRef/endRef payloads", async () => { await fs.rm(tempDir, { recursive: true, force: true }) } }) - -test("hash-check validates guards and refs without writing", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-check-")) - const filePath = path.join(tempDir, "sample.txt") - - try { - const original = "alpha\nbeta\ngamma\n" - await fs.writeFile(filePath, original, "utf8") - - const readText = await runHashlineRead({ - filePath, - offset: 1, - limit: 200, - context: { directory: PROJECT_ROOT }, - }) - - const fileHash10 = (() => { - const match = String(readText).match(/file_hash=\"([A-F0-9]{10})\"/) - return match ? match[1] : undefined - })() - const line2Ref = (() => { - const match = String(readText).match(/#HL\s+2#([A-F0-9]{3,4})#([A-F0-9]{3,4})\|beta/m) - return match ? `2#${match[1]}#${match[2]}` : undefined - })() - - assert.equal(typeof fileHash10, "string") - assert.equal(typeof line2Ref, "string") - - const ok = await runHashlineCheck({ - filePath, - fileRev: computeCoreFileRev(original), - expectedFileHash: fileHash10, - targets: [{ op: "replace", ref: line2Ref }], - context: { directory: PROJECT_ROOT }, - }) - - assert.match(ok, /Hashline check passed/) - - const after = await fs.readFile(filePath, "utf8") - assert.equal(after, original) - - await assert.rejects( - runHashlineCheck({ - filePath, - fileRev: "00000000", - context: { directory: PROJECT_ROOT }, - }), - /File revision mismatch/, - ) - } finally { - await fs.rm(tempDir, { recursive: true, force: true }) - } -}) - test("hashline operation result includes diff preview", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-diff-preview-")) const filePath = path.join(tempDir, "sample.txt")