diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 0a30e66cf..5b8601ad6 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1864,7 +1864,7 @@ export const SETTINGS_SCHEMA = { tab: "editing", label: "Hash Lines", description: - "Include snapshot-tag headers and line numbers in read output for hashline edit mode (¶PATH#tag plus LINE:content)", + "Include snapshot-tag headers and line numbers in read output for hashline edit mode ([PATH#TAG] plus LINE:content)", }, }, diff --git a/packages/coding-agent/src/edit/file-snapshot-store.ts b/packages/coding-agent/src/edit/file-snapshot-store.ts index ca51dbd11..467aab33e 100644 --- a/packages/coding-agent/src/edit/file-snapshot-store.ts +++ b/packages/coding-agent/src/edit/file-snapshot-store.ts @@ -14,7 +14,7 @@ import { normalizeToLF } from "./normalize"; /** * Upper bound on the file size we snapshot. A section tag is a content hash of * the *whole* file, so minting one means holding the full normalized text in - * the store. Files above this cap emit no `¶path#tag` header — line-anchored + * the store. Files above this cap emit no `[path#tag]` header — line-anchored * editing of multi-megabyte files is out of scope under the full-content model. */ export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024; diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts index 6686c4d5c..8bbb30e31 100644 --- a/packages/coding-agent/src/edit/index.ts +++ b/packages/coding-agent/src/edit/index.ts @@ -275,7 +275,7 @@ function extractApprovalPath(args: unknown): string { const record = args && typeof args === "object" ? (args as Record) : {}; const input = typeof record.input === "string" ? record.input : undefined; if (input) { - const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input); + const hashlineMatch = /^\[([^#\r\n]+)(?:#[0-9a-fA-F]{4})?\]/m.exec(input); if (hashlineMatch?.[1]) return hashlineMatch[1]; const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input); diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index f2de22869..6b64205f1 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -2,7 +2,7 @@ * Edit tool renderer and LSP batching helpers. */ -import { HL_FILE_PREFIX } from "@oh-my-pi/hashline"; +import { HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui"; import { sanitizeText } from "@oh-my-pi/pi-utils"; @@ -328,12 +328,12 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string { } function parseHashlineInputPreviewHeader(line: string): string | null { - if (!line.startsWith(HL_FILE_PREFIX)) return null; - // Mirror hashline/input.ts: strip every leading file marker so canonical - // `¶ PATH` headers and stray `¶¶ PATH` / `¶¶¶PATH` runs render clean paths. - let prefixEnd = 0; - while (prefixEnd < line.length && line[prefixEnd] === HL_FILE_PREFIX) prefixEnd++; - const body = line.slice(prefixEnd).trim(); + const trimmed = line.trimEnd(); + if (!trimmed.startsWith(HL_FILE_PREFIX)) return null; + // Keep streaming previews tolerant while the closing bracket is still + // being generated; the parser enforces the final `[path#TAG]` shape. + const bodyEnd = trimmed.endsWith(HL_FILE_SUFFIX) ? trimmed.length - HL_FILE_SUFFIX.length : trimmed.length; + const body = trimmed.slice(HL_FILE_PREFIX.length, bodyEnd).trim(); const previewPath = normalizeHashlineInputPreviewPath(body); return previewPath.length > 0 ? previewPath : null; } diff --git a/packages/coding-agent/src/edit/streaming.ts b/packages/coding-agent/src/edit/streaming.ts index b411ed70b..718d164db 100644 --- a/packages/coding-agent/src/edit/streaming.ts +++ b/packages/coding-agent/src/edit/streaming.ts @@ -424,7 +424,7 @@ const hashlineStrategy: EditStreamingStrategy = { return previews.length > 0 ? previews : null; }, renderStreamingFallback() { - // Never leak raw hashline syntax (`64:`, `|payload`, `¶path#hash`) + // Never leak raw hashline syntax (`64:`, `|payload`, `[path#hash]`) // to the user — the streaming preview already projects every // parseable op onto the real file via applyPartialTo, and an // unparseable trailing chunk renders as "no preview yet" rather diff --git a/packages/coding-agent/src/prompts/tools/ast-edit.md b/packages/coding-agent/src/prompts/tools/ast-edit.md index 2b2986f0c..68aefb044 100644 --- a/packages/coding-agent/src/prompts/tools/ast-edit.md +++ b/packages/coding-agent/src/prompts/tools/ast-edit.md @@ -14,7 +14,7 @@ Performs structural AST-aware rewrites via native ast-grep. -- Replacement summary, per-file replacement counts, and change diffs as `¶src/foo.ts#0a`, `-12:before`, `+12:after` lines in hashline mode +- Replacement summary, per-file replacement counts, and change diffs as `[src/foo.ts#1A2B]`, `-12:before`, `+12:after` lines in hashline mode - Parse issues when files cannot be processed diff --git a/packages/coding-agent/src/prompts/tools/ast-grep.md b/packages/coding-agent/src/prompts/tools/ast-grep.md index 48502520b..2e7053a29 100644 --- a/packages/coding-agent/src/prompts/tools/ast-grep.md +++ b/packages/coding-agent/src/prompts/tools/ast-grep.md @@ -18,7 +18,7 @@ Performs structural code search using AST matching via native ast-grep. - Grouped matches with file path, byte range, line/column ranges, metavariable captures -- Match lines are numbered under a file snapshot tag header in hashline mode: `¶src/foo.ts#0a`, `*42:content` for the matched line, ` 43:content` for context +- Match lines are numbered under a file snapshot tag header in hashline mode: `[src/foo.ts#1A2B]`, `*42:content` for the matched line, ` 43:content` for context - Summary counts (`totalMatches`, `filesWithMatches`, `filesSearched`) and parse issues when present diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index d8b0aab25..6479105b5 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -28,7 +28,7 @@ Append `:` to `path`. The bare path falls back to the default mode. - Reading a directory path returns a depth-limited dirent listing. {{#if IS_HL_MODE}} -- Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: `¶src/foo.ts#0a` then `41:def alpha():`. Copy the `¶PATH#TAG` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag. +- Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: `[src/foo.ts#1A2B]` then `41:def alpha():`. Copy the `[PATH#TAG]` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag. {{else}} {{#if IS_LINE_NUMBER_MODE}} - Reading a file with an explicit selector returns lines prefixed with line numbers: `41|def alpha():`. diff --git a/packages/coding-agent/src/prompts/tools/search.md b/packages/coding-agent/src/prompts/tools/search.md index 68401694b..245515e79 100644 --- a/packages/coding-agent/src/prompts/tools/search.md +++ b/packages/coding-agent/src/prompts/tools/search.md @@ -9,7 +9,7 @@ Searches files using powerful regex matching. {{#if IS_HL_MODE}} -- Text output emits a file snapshot tag header per matched file plus numbered lines: `¶src/login.ts#1f`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers. +- Text output emits a file snapshot tag header per matched file plus numbered lines: `[src/login.ts#1A2B]`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers. {{else}} {{#if IS_LINE_NUMBER_MODE}} - Text output is line-number-prefixed diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 409030bad..7e9b36e7f 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -58,7 +58,7 @@ import { import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; -const LOOSE_HASHLINE_HEADER_RE = /^\s*¶\S+#[^ \t\r\n]*\s*$/; +const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/; let fflateModulePromise: Promise | undefined; async function loadFflate(): Promise { @@ -109,7 +109,7 @@ function stripWriteContentWithPotentialLooseHeader(lines: string[]): { text: str /** * Strip hashline display prefixes from write content. * - * Only active when hashline edit mode is enabled — the model sees `¶PATH#HASH` + * Only active when hashline edit mode is enabled — the model sees `[PATH#HASH]` * headers plus `LINE:` prefixes in read output and sometimes copies them into write content. */ function stripWriteContent(session: ToolSession, content: string): { text: string; stripped: boolean } { @@ -122,7 +122,7 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin /** * Record a snapshot of the freshly-written `content` for `absolutePath` * so subsequent hashline edits address the new file with a current tag, - * and return the matching `¶displayPath#TAG` header. Returns `undefined` + * and return the matching `[displayPath#TAG]` header. Returns `undefined` * when the session is not in hashline mode so callers can no-op cheaply. * * Mirrors the post-commit snapshot recording the hashline patcher performs @@ -770,7 +770,7 @@ export class WriteTool implements AgentTool> { return untilAborted(signal, async () => { - // Strip hashline display prefixes (¶PATH#HASH + LINE:) if the model copied them from read output + // Strip hashline display prefixes ([PATH#HASH] + LINE:) if the model copied them from read output const { text: cleanContent, stripped } = stripWriteContent(this.session, content); const internalRouter = InternalUrlRouter.instance(); if (internalRouter.canHandle(path)) { diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 8cc9548db..4f281a67e 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -184,7 +184,7 @@ describe("hashline normalization", () => { describe("hashline parser — range-anchor syntax", () => { it("keeps parsed edits reusable across different target snapshots", () => { - const section = Patch.parseSingle(["¶a.ts", `insert after ${tag(2, "bbb")}:`, repl("tail")].join("\n")); + const section = Patch.parseSingle(["[a.ts]", `insert after ${tag(2, "bbb")}:`, repl("tail")].join("\n")); expect(section.applyTo("aaa\nbbb").text).toBe("aaa\nbbb\ntail"); expect(section.applyTo("aaa\nbbb\nccc").text).toBe("aaa\nbbb\ntail\nccc"); @@ -546,9 +546,9 @@ describe("hashline — snapshot tag binding", () => { }); }); -describe("splitHashlineInput — @ headers", () => { - it("extracts path, snapshot tag, and diff body from @path#tag header", () => { - const input = [`¶src/foo.ts#1A2B`, `${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n"); +describe("splitHashlineInput — bracket headers", () => { + it("extracts path, snapshot tag, and diff body from [path#tag] header", () => { + const input = [`[src/foo.ts#1A2B]`, `${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n"); expect(splitHashlineInput(input)).toEqual({ path: "src/foo.ts", fileHash: "1A2B", @@ -557,7 +557,7 @@ describe("splitHashlineInput — @ headers", () => { }); it("strips leading blank lines", () => { - expect(splitHashlineInput(`\n¶foo.ts\ninsert head:\n${repl("x")}`)).toEqual({ + expect(splitHashlineInput(`\n[foo.ts]\ninsert head:\n${repl("x")}`)).toEqual({ path: "foo.ts", diff: `insert head:\n${repl("x")}`, }); @@ -566,7 +566,7 @@ describe("splitHashlineInput — @ headers", () => { it("normalizes cwd-prefixed absolute paths to cwd-relative paths", () => { const cwd = process.cwd(); const absolute = path.join(cwd, "src", "foo.ts"); - expect(splitHashlineInput(`¶${absolute}\ninsert head:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts"); + expect(splitHashlineInput(`[${absolute}]\ninsert head:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts"); }); it("uses explicit fallback path only when input has recognizable operations", () => { @@ -578,7 +578,7 @@ describe("splitHashlineInput — @ headers", () => { }); it("splits multiple edit sections", () => { - const input = ["¶a.ts", "insert head:", repl("a"), "¶b.ts", "insert tail:", repl("b")].join("\n"); + const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]", "insert tail:", repl("b")].join("\n"); expect(splitHashlineInputs(input)).toEqual([ { path: "a.ts", diff: `insert head:\n${repl("a")}` }, { path: "b.ts", diff: `insert tail:\n${repl("b")}` }, @@ -595,7 +595,7 @@ describe("splitHashlineInput — @ headers", () => { }); it("silently drops a trailing header with no operations", () => { - const input = ["¶a.ts", "insert head:", repl("a"), "¶b.ts"].join("\n"); + const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]"].join("\n"); expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `insert head:\n${repl("a")}` }]); }); }); @@ -630,7 +630,7 @@ it("preflights write policy for every section before committing a batch", async describe("hashline executor", () => { it("rejects file creation and directs to the write tool", async () => { await withTempDir(async tempDir => { - const input = `¶new.ts\ninsert head:\n${repl("export const x = 1;")}\n`; + const input = `[new.ts]\ninsert head:\n${repl("export const x = 1;")}\n`; await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow(/write tool/); expect(await Bun.file(path.join(tempDir, "new.ts")).exists()).toBe(false); }); @@ -681,7 +681,7 @@ describe("hashline executor", () => { await Bun.write(bPath, "bbb\n"); const session = makeHashlineSession(tempDir); const aTag = recordFullSnapshot(getFileReadCache(session), aPath, "aaa\n"); - const bHeader = "¶b.ts#FFFF"; + const bHeader = "[b.ts#FFFF]"; const input = [ header("a.ts", aTag), `${sameLineRange(tag(1, "aaa"))}`, @@ -795,14 +795,14 @@ describe("hashlineEditParamsSchema — payload shape", () => { it("tolerates provider extra fields without declaring `path`", () => { expect( - hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\ninsert head:\n${repl("x")}` }).success, + hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `[x.ts]\ninsert head:\n${repl("x")}` }).success, ).toBe(true); }); it("accepts `_input` as a provider-emitted alias for `input`", () => { - const parsed = hashlineEditParamsSchema.safeParse({ _input: `¶x.ts\ninsert head:\n${repl("x")}` }); + const parsed = hashlineEditParamsSchema.safeParse({ _input: `[x.ts]\ninsert head:\n${repl("x")}` }); expect(parsed.success).toBe(true); - if (parsed.success) expect(parsed.data.input).toBe(`¶x.ts\ninsert head:\n${repl("x")}`); + if (parsed.success) expect(parsed.data.input).toBe(`[x.ts]\ninsert head:\n${repl("x")}`); }); it("still requires `input`", () => { @@ -1088,11 +1088,11 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => it("splitter respects *** Abort like *** End Patch", () => { const input = [ - `¶a.ts`, + `[a.ts]`, `insert after ${tag(1, "alpha")}:`, repl("a-payload"), sentinel, - `¶b.ts`, + `[b.ts]`, `insert after ${tag(1, "beta")}:`, repl("never-emitted"), ].join("\n"); @@ -1112,33 +1112,33 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => describe("hashline parser — delete and empty-block semantics", () => { it("inline delete deletes a single line", () => { const text = "line1\nline2\nline3\n"; - const { diff } = splitHashlineInput(`¶a.ts\ndelete 2\n`); + const { diff } = splitHashlineInput(`[a.ts]\ndelete 2\n`); expect(applyDiff(text, diff)).toBe("line1\nline3\n"); }); it("inline delete deletes the range", () => { const text = "line1\nline2\nline3\nline4\n"; - const { diff } = splitHashlineInput(`¶a.ts\ndelete 2..3\n`); + const { diff } = splitHashlineInput(`[a.ts]\ndelete 2..3\n`); expect(applyDiff(text, diff)).toBe("line1\nline4\n"); }); it("empty replace removes the range", () => { const text = "line1\nline2\nline3\n"; - const { diff } = splitHashlineInput(`¶a.ts\nreplace 2..2:\n`); + const { diff } = splitHashlineInput(`[a.ts]\nreplace 2..2:\n`); expect(applyDiff(text, diff)).toBe("line1\nline3\n"); }); it("`2..2=replacement` (old format) parses as orphan body, not as inline payload", () => { - const { diff } = splitHashlineInput(`¶a.ts\n2..2=replacement\n`); + const { diff } = splitHashlineInput(`[a.ts]\n2..2=replacement\n`); expect(() => parseHashline(diff)).toThrow(/payload line has no preceding hunk header/); }); it("explicit empty literal rows insert blank lines when the anchor is repeated", () => { const text = "line1\nline2\nline3\n"; - const aboveDiff = splitHashlineInput(`¶a.ts\ninsert before 2:\n${repl("")}\n`).diff; + const aboveDiff = splitHashlineInput(`[a.ts]\ninsert before 2:\n${repl("")}\n`).diff; expect(applyDiff(text, aboveDiff)).toBe("line1\n\nline2\nline3\n"); - const belowDiff = splitHashlineInput(`¶a.ts\ninsert after 2:\n${repl("")}\n`).diff; + const belowDiff = splitHashlineInput(`[a.ts]\ninsert after 2:\n${repl("")}\n`).diff; expect(applyDiff(text, belowDiff)).toBe("line1\nline2\n\nline3\n"); }); }); @@ -1146,28 +1146,28 @@ describe("hashline parser — delete and empty-block semantics", () => { describe("hashline parser — explicit blank payload rows", () => { it("raw blank lines between ops are ignored", () => { const text = "a\nb\nc\nd\ne\n"; - const ops = `¶a.ts\nreplace 1..1:\n${repl("A")}\n\nreplace 3..3:\n${repl("C")}\n`; + const ops = `[a.ts]\nreplace 1..1:\n${repl("A")}\n\nreplace 3..3:\n${repl("C")}\n`; const { diff } = splitHashlineInput(ops); expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n"); }); it("empty replacement payload rows are appended as blank payload lines", () => { const text = "a\nb\nc\nd\ne\n"; - const ops = `¶a.ts\nreplace 1..1:\n${repl("A")}\n${repl("")}\n${repl("")}\nreplace 3..3:\n${repl("C")}\n`; + const ops = `[a.ts]\nreplace 1..1:\n${repl("A")}\n${repl("")}\n${repl("")}\nreplace 3..3:\n${repl("C")}\n`; const { diff } = splitHashlineInput(ops); expect(applyDiff(text, diff)).toBe("A\n\n\nb\nC\nd\ne\n"); }); it("`replace N..N:` followed by two empty replace rows replaces the line with two blanks", () => { const text = "a\nb\nc\nd\ne\n"; - const ops = `¶a.ts\nreplace 2..2:\n${repl("")}\n${repl("")}\nreplace 4..4:\n${repl("D")}\n`; + const ops = `[a.ts]\nreplace 2..2:\n${repl("")}\n${repl("")}\nreplace 4..4:\n${repl("D")}\n`; const { diff } = splitHashlineInput(ops); expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n"); }); it("empty replace row inside payload between two content lines is preserved", () => { const text = "a\nb\nc\n"; - const ops = `¶a.ts\nreplace 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; + const ops = `[a.ts]\nreplace 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; const { diff } = splitHashlineInput(ops); expect(applyDiff(text, diff)).toBe("a\nfirst\n\nsecond\nc\n"); }); diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index 594322c15..e82fa3ac4 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -276,7 +276,7 @@ describe("computeHashlineDiff", () => { // preview/diff path MUST emit the SAME rejection so a successful preview // never precedes a failing apply. const result = await computeHashlineDiff( - { input: `¶${relativePath}\ninsert tail:\n+second` }, + { input: `[${relativePath}]\ninsert tail:\n+second` }, tempDir, new InMemorySnapshotStore(), ); @@ -287,7 +287,7 @@ describe("computeHashlineDiff", () => { }); test("returns a handled error when the source path is a local URL", async () => { const result = await computeHashlineDiff( - { input: "¶local://PLAN.md\ninsert tail:\n+x" }, + { input: "[local://PLAN.md]\ninsert tail:\n+x" }, tempDir, new InMemorySnapshotStore(), ); diff --git a/packages/coding-agent/test/edit-streaming-preview.test.ts b/packages/coding-agent/test/edit-streaming-preview.test.ts index 7e808b736..c220a342d 100644 --- a/packages/coding-agent/test/edit-streaming-preview.test.ts +++ b/packages/coding-agent/test/edit-streaming-preview.test.ts @@ -136,7 +136,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { }); test("does not surface stale hash errors while streaming", async () => { - const input = "¶a.ts#FFFF\nreplace 2..2:\n+const b = 22"; + const input = "[a.ts#FFFF]\nreplace 2..2:\n+const b = 22"; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toBeUndefined(); @@ -170,7 +170,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { }); test("surfaces stale hash errors once streaming is complete", async () => { - const input = "¶a.ts#FFFF\nreplace 2..2:\n+const b = 22\n"; + const input = "[a.ts#FFFF]\nreplace 2..2:\n+const b = 22\n"; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toContain("not from this session"); @@ -245,12 +245,12 @@ describe("apply_patch streaming preview (trailing partial line)", () => { describe("matcherDigest", () => { test("hashline: digests stripped `+` body rows only, never headers or op lines", () => { - const input = ["¶a.ts#AB12", "replace 1..2:", "+const x = 1;", "+const y = 2;", "delete 5", ""].join("\n"); + const input = ["[a.ts#AB12]", "replace 1..2:", "+const x = 1;", "+const y = 2;", "delete 5", ""].join("\n"); expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input })).toBe("const x = 1;\nconst y = 2;"); }); test("hashline: grammar-only payload digests to empty, missing input to undefined", () => { - expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: "¶a.ts#AB12\ndelete 3\n" })).toBe(""); + expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: "[a.ts#AB12]\ndelete 3\n" })).toBe(""); expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({})).toBeUndefined(); }); diff --git a/packages/coding-agent/test/read-column-truncation-snapshot.test.ts b/packages/coding-agent/test/read-column-truncation-snapshot.test.ts index a01c8113d..2c4c037a8 100644 --- a/packages/coding-agent/test/read-column-truncation-snapshot.test.ts +++ b/packages/coding-agent/test/read-column-truncation-snapshot.test.ts @@ -23,7 +23,7 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import type { ReadToolDetails } from "@oh-my-pi/pi-coding-agent/tools/read"; import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read"; -const HASHLINE_HEADER_LINE = /^¶(\S+)#([0-9A-F]{4})$/m; +const HASHLINE_HEADER_LINE = /^\[([^#\r\n]+)#([0-9A-F]{4})\]$/m; const COLUMN_CAP = 64; const LONG_LINE_LEN = COLUMN_CAP * 3; diff --git a/packages/coding-agent/test/tools/conflict-integration.test.ts b/packages/coding-agent/test/tools/conflict-integration.test.ts index d1b5b318e..73c784d50 100644 --- a/packages/coding-agent/test/tools/conflict-integration.test.ts +++ b/packages/coding-agent/test/tools/conflict-integration.test.ts @@ -511,7 +511,7 @@ describe("write resolves conflicts via conflict://N", () => { await read.execute("read-hashed", { path: "hashed.ts" }); const result = await write.execute("write-hashed", { path: "conflict://1", - content: "¶hashed.ts#1a2b\n42:cleanline\n", + content: "[hashed.ts#1a2b]\n42:cleanline\n", }); expect(getText(result)).toContain("auto-stripped hashline display prefixes"); const after = await Bun.file(filePath).text(); diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index 75c8d1e16..79ca8a72c 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -57,7 +57,7 @@ describe("editToolRenderer", () => { const uiTheme = await getUiTheme(); const component = editToolRenderer.renderCall( { - input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview", + input: "[packages/coding-agent/src/edit/renderer.ts]\ninsert tail:\n+// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -75,9 +75,12 @@ describe("editToolRenderer", () => { const component = new ToolExecutionComponent( "edit", { - input: ["*** Begin Patch", "¶crates/pi-natives/src/shell.rs", "EOF:", "|pub fn streaming_preview() {"].join( - "\n", - ), + input: [ + "*** Begin Patch", + "[crates/pi-natives/src/shell.rs]", + "insert tail:", + "+pub fn streaming_preview() {", + ].join("\n"), }, {}, hashlineTool, @@ -86,8 +89,8 @@ describe("editToolRenderer", () => { const rendered = Bun.stripANSI(component.render(160).join("\n")); expect(rendered).toContain("crates/pi-natives/src/shell.rs"); - expect(rendered).not.toContain("EOF:"); - expect(rendered).not.toContain("|pub fn streaming_preview() {"); + expect(rendered).not.toContain("insert tail:"); + expect(rendered).not.toContain("+pub fn streaming_preview() {"); expect(rendered).not.toContain("*** Begin Patch"); }); @@ -95,7 +98,7 @@ describe("editToolRenderer", () => { const uiTheme = await getUiTheme(); const compactComponent = editToolRenderer.renderCall( { - input: "¶foo bar.ts\nBOF:\n|// preview", + input: "[foo bar.ts]\ninsert head:\n+// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -103,7 +106,7 @@ describe("editToolRenderer", () => { const quotedComponent = editToolRenderer.renderCall( { - input: "¶'baz qux.ts'\nBOF:\n|// preview", + input: "['baz qux.ts']\ninsert head:\n+// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -115,33 +118,33 @@ describe("editToolRenderer", () => { expect(quotedRendered).toContain("baz qux.ts"); }); - it("strips canonical `¶` and longer `¶` runs from hashline input headers", async () => { + it("strips bracket delimiters from hashline input headers", async () => { const uiTheme = await getUiTheme(); - // Canonical `¶PATH` form — the parser strips the marker and the + // Canonical `[PATH]` form — the parser strips the delimiters and the // renderer keeps the title clean. const canonical = editToolRenderer.renderCall( { - input: "¶packages/coding-agent/src/slash-commands/builtin-registry.ts\nBOF:\n|// preview", + input: "[packages/coding-agent/src/slash-commands/builtin-registry.ts]\ninsert head:\n+// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, ); - // Even longer runs should still produce the clean path. - const triple = editToolRenderer.renderCall( - { input: "¶¶¶a/b/c.ts\nBOF:\n|// preview" }, + // While streaming, the closing bracket may not have arrived yet. + const partial = editToolRenderer.renderCall( + { input: "[a/b/c.ts\ninsert head:\n+// preview" }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, ); const canonicalRendered = Bun.stripANSI(canonical.render(160).join("\n")); - const tripleRendered = Bun.stripANSI(triple.render(160).join("\n")); + const partialRendered = Bun.stripANSI(partial.render(160).join("\n")); expect(canonicalRendered).toContain("packages/coding-agent/src/slash-commands/builtin-registry.ts"); - expect(canonicalRendered).not.toMatch(/¶packages\/coding-agent/); - expect(tripleRendered).toContain("a/b/c.ts"); - expect(tripleRendered).not.toMatch(/¶+a\/b\/c\.ts/); + expect(canonicalRendered).not.toMatch(/\[packages\/coding-agent/); + expect(partialRendered).toContain("a/b/c.ts"); + expect(partialRendered).not.toMatch(/\[a\/b\/c\.ts/); }); it("uses hashline input headers for completed single-file result path", async () => { @@ -157,7 +160,7 @@ describe("editToolRenderer", () => { { expanded: false, isPartial: false, renderContext: { editMode: "hashline" } }, uiTheme, { - input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview", + input: "[packages/coding-agent/src/edit/renderer.ts]\ninsert tail:\n+// preview", }, ); @@ -182,7 +185,7 @@ describe("editToolRenderer", () => { // The trailing payload line carries no newline — the common shape for a // single-line edit. The streaming pass trims that in-flight line, so the // preview only becomes computable once args are marked complete. - const input = `¶memory.ts#${tag}\nreplace 2..2:\n+export const b = 22;`; + const input = `[memory.ts#${tag}]\nreplace 2..2:\n+export const b = 22;`; const component = new ToolExecutionComponent("edit", { input }, { snapshots }, hashlineTool, uiStub, tmpDir); component.setArgsComplete(); @@ -208,7 +211,7 @@ describe("editToolRenderer", () => { const snapshots = new InMemorySnapshotStore(); const tag = snapshots.record(filePath, content); - const input = `¶memory.ts#${tag}\nreplace 2..2:\n+export const b = 22;\n`; + const input = `[memory.ts#${tag}]\nreplace 2..2:\n+export const b = 22;\n`; const component = new ToolExecutionComponent( "edit", { __partialJson: input }, diff --git a/packages/coding-agent/test/tools/search-internal-urls.test.ts b/packages/coding-agent/test/tools/search-internal-urls.test.ts index 52edda3b5..c541ab4a8 100644 --- a/packages/coding-agent/test/tools/search-internal-urls.test.ts +++ b/packages/coding-agent/test/tools/search-internal-urls.test.ts @@ -251,7 +251,7 @@ describe("SearchTool internal URL resolution", () => { const text = getResultText(result); expect(text).toContain("needle"); // No hashline section headers or numbered editable lines for immutable sources. - expect(text).not.toMatch(/^¶.*#[0-9A-F]{4}$/m); + expect(text).not.toMatch(/^\[[^#\r\n]+#[0-9A-F]{4}\]$/m); expect(text).not.toMatch(/^\*?\s*\d+:/m); }); @@ -291,7 +291,7 @@ describe("SearchTool internal URL resolution", () => { const text = getResultText(result); expect(text).toContain("needle"); // Mutable local:// sources keep a hashline section header plus numbered match lines. - expect(text).toMatch(/^¶.*#[0-9A-F]{4}$/m); + expect(text).toMatch(/^\[[^#\r\n]+#[0-9A-F]{4}\]$/m); expect(text).toMatch(/^\*\d+:.*needle/m); }); diff --git a/packages/coding-agent/test/tools/search-path-lists.test.ts b/packages/coding-agent/test/tools/search-path-lists.test.ts index d23a66c4e..cb7b695dd 100644 --- a/packages/coding-agent/test/tools/search-path-lists.test.ts +++ b/packages/coding-agent/test/tools/search-path-lists.test.ts @@ -194,7 +194,7 @@ describe("tool path arrays", () => { const text = getText(result); const details = result.details as { fileCount?: number; missingPaths?: string[] } | undefined; - expect(text).toMatch(/^¶packages\/grep\.txt#[0-9A-F]{4}/m); + expect(text).toMatch(/^\[packages\/grep\.txt#[0-9A-F]{4}\]/m); expect(text).toContain("Skipped missing paths: missing.txt"); expect(text).not.toContain("apps"); expect(details?.fileCount).toBe(1); diff --git a/packages/coding-agent/test/ttsr.test.ts b/packages/coding-agent/test/ttsr.test.ts index 84579de08..4f04ad3e0 100644 --- a/packages/coding-agent/test/ttsr.test.ts +++ b/packages/coding-agent/test/ttsr.test.ts @@ -349,7 +349,7 @@ describe("TtsrManager snapshot matching", () => { streamKey: "toolcall:tc-1", }; const patch = [ - "¶src/repo.ts#AB12", + "[src/repo.ts#AB12]", "replace block 1:", "+export async function isRepository(cwd: string): Promise {", "+\treturn repo.isRepository(cwd);", diff --git a/packages/coding-agent/test/write-hashline-header.test.ts b/packages/coding-agent/test/write-hashline-header.test.ts index 2e1e965d2..61b2e0764 100644 --- a/packages/coding-agent/test/write-hashline-header.test.ts +++ b/packages/coding-agent/test/write-hashline-header.test.ts @@ -30,7 +30,7 @@ function resultText(result: { content: { type: string; text?: string }[] }): str .join("\n"); } -const HASHLINE_HEADER_LINE = /^¶(\S+)#([0-9A-F]{4})$/; +const HASHLINE_HEADER_LINE = /^\[([^#\r\n]+)#([0-9A-F]{4})\]$/; describe("write tool hashline header", () => { let tmpDir: string; @@ -47,7 +47,7 @@ describe("write tool hashline header", () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); - it("insert heads a fresh ¶path#TAG header that maps to the written content", async () => { + it("inserts a fresh [path#TAG] header that maps to the written content", async () => { const filePath = path.join(tmpDir, "module.ts"); const session = createSession(tmpDir); const tool = new WriteTool(session); @@ -111,7 +111,7 @@ describe("write tool hashline header", () => { const result = await tool.execute("call-1", { path: filePath, content }); const text = resultText(result); - expect(text.startsWith("¶")).toBe(false); + expect(text.startsWith("[")).toBe(false); expect(text).toBe(`Successfully wrote ${content.length} bytes to ${path.relative(tmpDir, filePath)}`); }); }); diff --git a/packages/hashline/README.md b/packages/hashline/README.md index b98cc7e3e..545f98826 100644 --- a/packages/hashline/README.md +++ b/packages/hashline/README.md @@ -23,10 +23,10 @@ const snapshots = new InMemorySnapshotStore(); const before = `const greeting = "hi";\nexport { greeting };\n`; await fs.writeText("hello.ts", before); -const tag = snapshots.recordContiguous("hello.ts", 1, before.split("\n"), { fullText: before }); +const tag = snapshots.record("hello.ts", before); const patcher = new Patcher({ fs, snapshots }); -const patch = Patch.parse(String.raw`¶hello.ts#${tag} -@@ 1..1 @@ +const patch = Patch.parse(String.raw`[hello.ts#${tag}] +replace 1..1: +const greeting = "hello";`); const result = await patcher.apply(patch); @@ -39,19 +39,19 @@ console.log(await fs.readText("hello.ts")); See [`src/prompt.md`](./src/prompt.md) for the user-facing description and [`src/grammar.lark`](./src/grammar.lark) for the formal grammar. -Each file section starts with `¶PATH#TAG`. The tag is a 3-hex opaque -pointer into the `SnapshotStore` that minted it; it is not content-derived -and is not meaningful outside that store. The patcher protects against -stale anchors by resolving the tag, verifying the recorded snapshot lines -against live file content, and refusing or attempting session-aware -recovery on mismatch. +Each file section starts with `[PATH#TAG]`. The tag is a 4-hex +content hash of the full normalized file text recorded by the +`SnapshotStore`, and it is not meaningful outside that store. The patcher +protects against stale anchors by resolving the tag, verifying the live file +still matches the recorded content hash, and refusing or attempting +session-aware recovery on mismatch. Inside a section: -- `@@ A..B @@` — open a hunk on lines A..B (use `@@ A,A @@` for a single line; bare `@@ A @@` is also accepted). -- `@@ BOF @@` / `@@ EOF @@` — virtual hunks at the beginning/end of file. +- `replace A..B:` — replace lines A..B with following `+TEXT` body rows. +- `replace block A:` — replace the syntactic block beginning on line A. +- `delete A..B` / `delete block A` — delete concrete lines or a resolved block. +- `insert before A:` / `insert after A:` / `insert head:` / `insert tail:` — insert following body rows. - `+TEXT` — literal body row (use `+` alone for a blank line). -- `&A..B` — repeat original file lines A..B inline (`&A` for one line). -- Empty body — delete the selected range. ## Abstractions @@ -67,9 +67,10 @@ text-document protocol, a Git tree, anything. ### `SnapshotStore` -Required. Hashline tags are opaque store pointers, so `Patcher` must receive -the store that minted them. Recovery replays edits against the cached pre-edit -snapshot and 3-way-merges onto current content when the live file diverged. +Required. Hashline tags are full-file content hashes recorded per path, so +`Patcher` must receive the store that observed them. Recovery replays edits +against the cached pre-edit snapshot and 3-way-merges onto current content +when the live file diverged. ### `Patcher` diff --git a/packages/hashline/src/format.ts b/packages/hashline/src/format.ts index 2f4df998e..ef752aa97 100644 --- a/packages/hashline/src/format.ts +++ b/packages/hashline/src/format.ts @@ -6,8 +6,9 @@ import type { Cursor } from "./types"; -/** File-section header prefix: `¶path#hash`. */ -export const HL_FILE_PREFIX = "¶"; +/** File-section header delimiters: `[path#hash]`. */ +export const HL_FILE_PREFIX = "["; +export const HL_FILE_SUFFIX = "]"; /** Payload sigil for literal body rows. */ export const HL_PAYLOAD_REPLACE = "+"; @@ -118,7 +119,7 @@ export function describeAnchorExamples(linePrefix = ""): string { /** Format a hashline section header for a file path and snapshot tag. */ export function formatHashlineHeader(filePath: string, fileHash: string): string { - return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}`; + return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}${HL_FILE_SUFFIX}`; } /** Formats a single numbered line as `LINE:TEXT`. */ diff --git a/packages/hashline/src/grammar.lark b/packages/hashline/src/grammar.lark index d2e016ecb..ae11cb32b 100644 --- a/packages/hashline/src/grammar.lark +++ b/packages/hashline/src/grammar.lark @@ -3,7 +3,7 @@ begin_patch: "*** Begin Patch" LF end_patch: "*** End Patch" LF? file_patch: file_header hunk+ -file_header: "¶" filename "#" file_hash LF +file_header: "[" filename "#" file_hash "]" LF file_hash: /[0-9A-F]{4}/ filename: /[^#\r\n]+/ diff --git a/packages/hashline/src/input.ts b/packages/hashline/src/input.ts index 4b43c1e82..0dc597cc1 100644 --- a/packages/hashline/src/input.ts +++ b/packages/hashline/src/input.ts @@ -1,6 +1,6 @@ /** * Top-level patch parser. Splits an authored hashline input into a list of - * {@link PatchSection}s, each rooted at a `¶PATH#HASH` header, then exposes + * {@link PatchSection}s, each rooted at a `[PATH#HASH]` header, then exposes * a {@link Patch} class that gives lazy access to the parsed edits per * section. * @@ -10,7 +10,7 @@ import * as path from "node:path"; import { applyEdits } from "./apply"; import { resolveBlockEdits } from "./block"; -import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format"; +import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format"; import { parsePatch, parsePatchStreaming } from "./parser"; import { Tokenizer } from "./tokenizer"; import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types"; @@ -47,15 +47,17 @@ function stripApplyPatchPathNoise(pathText: string): string { } /** - * Best-effort recovery for `¶`-prefixed lines the strict tokenizer + * Best-effort recovery for bracketed header lines the strict tokenizer * rejects. Strips apply_patch keyword noise (`Update File:`, `Update:`, - * etc.) and an extra leading `***` (some models emit a hybrid `¶***foo.ts` - * shape), then expects `PATH(#HASH)?`. + * etc.) and an extra leading `***` (some models emit a hybrid + * `[***foo.ts#HASH]` shape), then expects `PATH(#HASH)?`. * Returns `null` when no clean path can be salvaged. */ function tryParseRecoveryHeader(line: string, cwd?: string): RawSection | null { - if (!line.startsWith(HL_FILE_PREFIX)) return null; - const body = stripApplyPatchPathNoise(line.slice(HL_FILE_PREFIX.length).trim()); + if (!line.startsWith(HL_FILE_PREFIX) || !line.endsWith(HL_FILE_SUFFIX)) return null; + const body = stripApplyPatchPathNoise( + line.slice(HL_FILE_PREFIX.length, line.length - HL_FILE_SUFFIX.length).trim(), + ); if (body.length === 0) return null; // Trailing `#XXXX` is the tag; everything before it is the path. The @@ -99,9 +101,9 @@ interface RawSection { } /** - * Parse a `¶PATH[#hash]` header line. Returns `null` for lines that do - * not start with `¶`. Throws the strict "Input header must be …" error - * when a `¶`-prefixed line fails the strict shape (so malformed paths + * Parse a `[PATH]` or `[PATH#hash]` header line. Returns `null` for lines that do + * not start with `[`. Throws the strict "Input header must be …" error + * when a bracketed line fails the strict shape (so malformed paths * surface immediately instead of being silently re-classified as payload). */ function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null { @@ -111,18 +113,18 @@ function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null const token = TOKENIZER.tokenize(trimmed); if (token.kind !== "header") { // Recovery: try to extract a path from the raw line after stripping - // apply_patch noise. This handles `*** Update File:foo.ts#CB5` and + // apply_patch noise. This handles `[*** Update File:foo.ts#CB5A]` and // the half-dozen variants models actually emit. const recovered = tryParseRecoveryHeader(trimmed, cwd); if (recovered !== null) return recovered; throw new Error( - `Input header must be ${HL_FILE_PREFIX}PATH or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}TAG with a ${HL_FILE_HASH_LENGTH}-hex content-hash tag; got ${JSON.stringify(trimmed)}.`, + `Input header must be ${HL_FILE_PREFIX}PATH${HL_FILE_SUFFIX} or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}TAG${HL_FILE_SUFFIX} with a ${HL_FILE_HASH_LENGTH}-hex content-hash tag; got ${JSON.stringify(trimmed)}.`, ); } const parsedPath = normalizeHashlinePath(token.path, cwd); if (parsedPath.length === 0) { - throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`); + throw new Error(`Input header "${HL_FILE_PREFIX}${HL_FILE_SUFFIX}" is empty; provide a file path.`); } return token.fileHash !== undefined ? { path: parsedPath, fileHash: token.fileHash, diff: "" } @@ -165,7 +167,7 @@ function normalizeFallbackInput(input: string, options: SplitOptions): string { if (!options.path || !containsRecognizableHashlineOperations(input)) return input; const fallbackPath = normalizeHashlinePath(options.path, options.cwd); if (fallbackPath.length === 0) return input; - return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`; + return `${HL_FILE_PREFIX}${fallbackPath}${HL_FILE_SUFFIX}\n${input}`; } function splitRawSections(input: string, options: SplitOptions = {}): RawSection[] { @@ -180,13 +182,13 @@ function splitRawSections(input: string, options: SplitOptions = {}): RawSection if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(firstTrimmed)) { throw new Error( "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " + - "File sections start with `¶path#HASH`; use `replace`, `delete`, or `insert` ops.", + `File sections start with \`${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}HASH${HL_FILE_SUFFIX}\`; use \`replace\`, \`delete\`, or \`insert\` ops.`, ); } const preview = JSON.stringify(firstLine.slice(0, 120)); throw new Error( - `input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH" on the first non-blank line for anchored edits; got: ${preview}. ` + - `Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}0A3" then edit ops.`, + `input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH${HL_FILE_SUFFIX}" on the first non-blank line for anchored edits; got: ${preview}. ` + + `Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX}" then edit ops.`, ); } @@ -207,7 +209,7 @@ function splitRawSections(input: string, options: SplitOptions = {}): RawSection if (token.kind === "envelope-end" || token.kind === "abort") break; if (token.kind === "envelope-begin") continue; - // Route every `¶`-prefixed line through parseHashlineHeaderLine so + // Route every bracket-prefixed line through parseHashlineHeaderLine so // malformed headers still raise the strict "Input header must be …" // diagnostic (the tokenizer alone would silently classify them as // payload). @@ -343,7 +345,7 @@ export class PatchSection { /** * A parsed hashline patch — zero or more {@link PatchSection}s, each rooted - * at a `¶PATH#HASH` header. Construct via {@link Patch.parse}. + * at a `[PATH#HASH]` header. Construct via {@link Patch.parse}. * * `Patch` is pure data: parsing is line-anchored and does not look at the * filesystem. To apply a patch, hand it to {@link Patcher.apply}. diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index 5eff387b3..e5e33640d 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -5,7 +5,7 @@ * them. */ -import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format"; +import { HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format"; /** Lines of context shown either side of a hash mismatch. */ export const MISMATCH_CONTEXT = 2; @@ -124,5 +124,5 @@ export const HEADTAIL_DRIFT_WARNING = * this single builder to stay in lockstep. */ export function missingSnapshotTagMessage(sectionPath: string): string { - return `Missing hashline snapshot tag for edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag\` from your latest read/search output. To create a new file, use the write tool.`; + return `Missing hashline snapshot tag for edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX}\` from your latest read/search output. To create a new file, use the write tool.`; } diff --git a/packages/hashline/src/mismatch.ts b/packages/hashline/src/mismatch.ts index b77d02454..5e9bed476 100644 --- a/packages/hashline/src/mismatch.ts +++ b/packages/hashline/src/mismatch.ts @@ -6,7 +6,7 @@ * plus a couple of lines of surrounding context. The {@link MismatchError} * formats this into a message at construction time. */ -import { formatNumberedLine, HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format"; +import { formatNumberedLine, HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format"; import { MISMATCH_CONTEXT } from "./messages"; const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/; @@ -15,7 +15,7 @@ export function formatFullAnchorRequirement(raw?: string): string { const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`; return ( `a bare line number from read/search output plus the section header content-hash tag ` + - `(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]} and line "160")${received}` + `(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX} and line "160")${received}` ); } @@ -99,12 +99,12 @@ export class MismatchError extends Error { if (!hashRecognized) { return [ `Edit rejected${pathText}: hash ${HL_FILE_HASH_SEP}${details.expectedFileHash} is not from this session.`, - `The current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. Re-read the file with \`read\` to copy a current ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag header — never invent the tag and never reuse one from a prior session.`, + `The current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. Re-read the file with \`read\` to copy a current ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX} header — never invent the tag and never reuse one from a prior session.`, ]; } return [ `Edit rejected${pathText}: file changed between read and edit.`, - `Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If a prior edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash header from that edit's response; otherwise re-read the file with \`read\` to refresh the tag before retrying.`, + `Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If a prior edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash${HL_FILE_SUFFIX} header from that edit's response; otherwise re-read the file with \`read\` to refresh the tag before retrying.`, ]; } diff --git a/packages/hashline/src/parser.ts b/packages/hashline/src/parser.ts index 918aa6d4d..47e56b699 100644 --- a/packages/hashline/src/parser.ts +++ b/packages/hashline/src/parser.ts @@ -43,7 +43,7 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed; return ( `apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` + - "File sections start with `¶path#HASH` (no `Update File:` / `Add File:` keyword). " + + "File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " + "Use `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops." ); } diff --git a/packages/hashline/src/patcher.ts b/packages/hashline/src/patcher.ts index 7204867a6..de257fa40 100644 --- a/packages/hashline/src/patcher.ts +++ b/packages/hashline/src/patcher.ts @@ -64,9 +64,9 @@ export interface PatchSectionResult { persisted: string; /** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */ written: string; - /** 3-hex opaque snapshot tag for `after`. Use to anchor follow-up edits. */ + /** 4-hex content-hash tag for `after`. Use to anchor follow-up edits. */ fileHash: string; - /** Hashline section header (`¶path#tag`) of the post-edit content. */ + /** Hashline section header (`[path#tag]`) of the post-edit content. */ header: string; /** 1-indexed first changed line in `after`, or `undefined` for noops. */ firstChangedLine?: number; diff --git a/packages/hashline/src/prefixes.ts b/packages/hashline/src/prefixes.ts index 56515d55a..6e18950da 100644 --- a/packages/hashline/src/prefixes.ts +++ b/packages/hashline/src/prefixes.ts @@ -14,9 +14,11 @@ * otherwise turn every content line into a (malformed) op. */ +import { HL_FILE_HASH_LENGTH } from "./format"; + const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/; const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/; -const HL_HEADER_RE = /^\s*¶\S+#[0-9a-fA-F]{3}\s*$/; +const HL_HEADER_RE = new RegExp(`^\\s*\\[[^#\\r\\n]+#[0-9a-fA-F]{${HL_FILE_HASH_LENGTH}}\\]\\s*$`); const DIFF_PLUS_RE = /^[+](?![+])/; const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/; diff --git a/packages/hashline/src/prompt.md b/packages/hashline/src/prompt.md index 623d13d89..85396d8cf 100644 --- a/packages/hashline/src/prompt.md +++ b/packages/hashline/src/prompt.md @@ -1,7 +1,7 @@ Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `delete` has no body. -Every file section starts with `¶PATH#TAG`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist. +Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist. @@ -23,9 +23,9 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k -- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `¶PATH#TAG` header; use the bare LINE numbers. +- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `[PATH#TAG]` header; use the bare LINE numbers. - Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply. -- Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the `¶PATH#TAG` and lines from the edit response (or re-`read`), never on pre-edit numbers. +- Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the `[PATH#TAG]` and lines from the edit response (or re-`read`), never on pre-edit numbers. - A line number is an offset, not a structural boundary: never `insert after N` into a construct you have not read, and never start or end a `replace`/`delete` range mid-expression or mid-block. If unsure what is on those lines, `read` them first. - On a stale-tag rejection — or any result you cannot fully account for — STOP and re-`read`. Never stack more line-numbered edits onto output you have not re-grounded; that compounds corruption. - One hunk per range; the body is the final content, never an old/new pair. @@ -37,7 +37,7 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k Original (the exact shape `read` returns): ``` -¶greet.py#A1B2 +[greet.py#A1B2] 1:def greet(name): 2: msg = "Hello, " + name 3: print(msg) @@ -46,14 +46,14 @@ Original (the exact shape `read` returns): Insert a guard after line 1: ``` -¶greet.py#A1B2 +[greet.py#A1B2] insert after 1: + if not name: name = "stranger" ``` Replace line 2 with two lines: ``` -¶greet.py#A1B2 +[greet.py#A1B2] replace 2..2: + greeting = "Hi" + msg = f"{greeting}, {name}" @@ -61,13 +61,13 @@ replace 2..2: Delete line 3: ``` -¶greet.py#A1B2 +[greet.py#A1B2] delete 3 ``` Add a header and trailer: ``` -¶greet.py#A1B2 +[greet.py#A1B2] insert head: +# generated header insert tail: @@ -76,7 +76,7 @@ insert tail: Replace the whole `greet` function block — `replace block 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays: ``` -¶greet.py#A1B2 +[greet.py#A1B2] replace block 1: +def greet(name): + print(f"Hello, {name}") diff --git a/packages/hashline/src/tokenizer.ts b/packages/hashline/src/tokenizer.ts index aac2519b7..491fd7dc3 100644 --- a/packages/hashline/src/tokenizer.ts +++ b/packages/hashline/src/tokenizer.ts @@ -3,7 +3,7 @@ * * Format shape: * ``` - * ¶path/to/file.ts#0A3 + * [path/to/file.ts#1A2B] * replace 5..7: * +literal new line * ``` @@ -15,6 +15,7 @@ import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, + HL_FILE_SUFFIX, HL_HEADER_COLON, HL_INSERT_AFTER, HL_INSERT_BEFORE, @@ -45,6 +46,7 @@ const CHAR_LOWER_F = 102; const CHAR_PAYLOAD_REPLACE = HL_PAYLOAD_REPLACE.charCodeAt(0); const CHAR_COLON = HL_HEADER_COLON.charCodeAt(0); const FILE_PREFIX_LENGTH = HL_FILE_PREFIX.length; +const FILE_SUFFIX_LENGTH = HL_FILE_SUFFIX.length; function isDigitCode(code: number): boolean { return code >= CHAR_ZERO && code <= CHAR_NINE; @@ -137,7 +139,7 @@ export function parseLid(raw: string, lineNum: number): Anchor { if (number === null || skipWhitespace(raw, number.nextIndex, end) !== end) { throw new Error( `line ${lineNum}: expected a line number such as ${describeAnchorExamples("119")}; ` + - `got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash from your latest read for file-version binding.`, + `got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash${HL_FILE_SUFFIX} from your latest read for file-version binding.`, ); } return { line: number.line }; @@ -312,17 +314,20 @@ function tryParseHunkHeader(line: string): ParsedHunkHeader | null { function tryParseHeader(line: string): { path: string; fileHash?: string } | null { if (!line.startsWith(HL_FILE_PREFIX)) return null; const end = trimEndIndex(line); - if (FILE_PREFIX_LENGTH >= end) return null; + if (FILE_PREFIX_LENGTH + FILE_SUFFIX_LENGTH >= end) return null; + if (!line.endsWith(HL_FILE_SUFFIX, end)) return null; + const bodyEnd = end - FILE_SUFFIX_LENGTH; + if (FILE_PREFIX_LENGTH >= bodyEnd) return null; - // The snapshot tag, when present, is the trailing `#XXXX` block. We - // detect it from the suffix so the path may legitimately contain - // whitespace (e.g. `OneDrive - Company/file.ts`). - let pathEnd = end; + // The snapshot tag, when present, is the trailing `#XXXX` block inside the + // bracketed header. We detect it from the suffix so the path may + // legitimately contain whitespace (e.g. `OneDrive - Company/file.ts`). + let pathEnd = bodyEnd; let fileHash: string | undefined; - const trailingHashStart = end - HL_FILE_HASH_LENGTH - 1; + const trailingHashStart = bodyEnd - HL_FILE_HASH_LENGTH - 1; if (trailingHashStart >= FILE_PREFIX_LENGTH && line.charCodeAt(trailingHashStart) === CHAR_HASH) { let allHex = true; - for (let probe = trailingHashStart + 1; probe < end; probe++) { + for (let probe = trailingHashStart + 1; probe < bodyEnd; probe++) { if (!isHexDigitCode(line.charCodeAt(probe))) { allHex = false; break; @@ -330,7 +335,7 @@ function tryParseHeader(line: string): { path: string; fileHash?: string } | nul } if (allHex) { pathEnd = trailingHashStart; - fileHash = line.slice(trailingHashStart + 1, end).toUpperCase(); + fileHash = line.slice(trailingHashStart + 1, bodyEnd).toUpperCase(); } } diff --git a/packages/hashline/src/types.ts b/packages/hashline/src/types.ts index 55a9ca010..82326c628 100644 --- a/packages/hashline/src/types.ts +++ b/packages/hashline/src/types.ts @@ -72,7 +72,7 @@ export interface SplitOptions { /** Resolves absolute paths inside hashline headers to cwd-relative form. */ cwd?: string; /** - * Fallback path used when the input lacks a `¶PATH` header but contains + * Fallback path used when the input lacks a `[PATH]` header but contains * recognizable hashline operations. Lets streaming previews work before * the model has written the header. */ diff --git a/packages/hashline/test/block.test.ts b/packages/hashline/test/block.test.ts index 9ded37f47..507b7f1b0 100644 --- a/packages/hashline/test/block.test.ts +++ b/packages/hashline/test/block.test.ts @@ -91,8 +91,8 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => { const text = "function x() {\n if (y) {\n }\n}\n"; it("applyTo resolves a block edit and matches the equivalent `replace`", () => { - const blockSection = Patch.parseSingle(`¶${PATH}#1A2B\nreplace block 2:\n+ if (y || z) {\n+ }`); - const replaceSection = Patch.parseSingle(`¶${PATH}#1A2B\nreplace 2..3:\n+ if (y || z) {\n+ }`); + const blockSection = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+ if (y || z) {\n+ }`); + const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nreplace 2..3:\n+ if (y || z) {\n+ }`); const blockResult = blockSection.applyTo(text, stubResolver); const replaceResult = replaceSection.applyTo(text); @@ -102,12 +102,12 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => { }); it("applyTo throws when a block edit has no resolver", () => { - const section = Patch.parseSingle(`¶${PATH}#1A2B\nreplace block 2:\n+X`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+X`); expect(() => section.applyTo(text)).toThrow("replace block"); }); it("applyPartialTo drops an unresolvable block edit instead of throwing", () => { - const section = Patch.parseSingle(`¶${PATH}#1A2B\nreplace block 2:\n+X`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+X`); // No resolver → drop. The lone block edit vanishes, so the text is unchanged. const result = section.applyPartialTo(text); expect(result.text).toBe(text); @@ -123,7 +123,7 @@ describe("Patcher with a block resolver", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace block 2:\n+ if (y || z) {\n+ }`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+ if (y || z) {\n+ }`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("function x() {\n if (y || z) {\n }\n}\n"); @@ -140,7 +140,7 @@ describe("Patcher with a block resolver", () => { // `block 2` resolves against the SNAPSHOT → span [2,3] → replace // "line1","line2"; recovery 3-way-merges the change onto the live file. - const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace block 2:\n+NEW`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+NEW`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("line0\nNEW\nline3\nline4\nline5\n"); @@ -155,7 +155,7 @@ describe("Patcher with a block resolver", () => { const bogus = live === "FFFF" ? "0000" : "FFFF"; const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - await expect(patcher.apply(Patch.parse(`¶${PATH}#${bogus}\nreplace block 2:\n+NEW`))).rejects.toBeInstanceOf( + await expect(patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nreplace block 2:\n+NEW`))).rejects.toBeInstanceOf( MismatchError, ); expect(fs.get(PATH)).toBe(liveText); @@ -167,7 +167,7 @@ describe("Patcher with a block resolver", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: () => null }); - await expect(patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace block 2:\n+X`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+X`))).rejects.toThrow( "could not resolve a syntactic block", ); expect(fs.get(PATH)).toBe(text); @@ -201,13 +201,13 @@ describe("delete block", () => { }); it("applyTo deletes the resolved block span", () => { - const section = Patch.parseSingle(`¶${PATH}#1A2B\ndelete block 2`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\ndelete block 2`); // stub span [2,3] → drop " if (y) {" and " }". expect(section.applyTo(text, stubResolver).text).toBe("function x() {\n}\n"); }); it("applyPartialTo drops an unresolvable delete-block edit instead of throwing", () => { - const section = Patch.parseSingle(`¶${PATH}#1A2B\ndelete block 2`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\ndelete block 2`); expect(section.applyPartialTo(text).text).toBe(text); }); @@ -217,7 +217,7 @@ describe("delete block", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\ndelete block 2`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\ndelete block 2`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("function x() {\n}\n"); diff --git a/packages/hashline/test/leniency.test.ts b/packages/hashline/test/leniency.test.ts index 362b09f77..b28f57c27 100644 --- a/packages/hashline/test/leniency.test.ts +++ b/packages/hashline/test/leniency.test.ts @@ -9,7 +9,7 @@ const FILE = "a\nb\nc\nd\ne"; describe("hashline section headers", () => { it("accepts paths with spaces in anchored section headers", () => { - const section = Patch.parseSingle("¶dir with spaces/file.ts#1a2b\nreplace 1..1:\n+after"); + const section = Patch.parseSingle("[dir with spaces/file.ts#1a2b]\nreplace 1..1:\n+after"); expect(section.path).toBe("dir with spaces/file.ts"); expect(section.fileHash).toBe("1A2B"); @@ -17,7 +17,7 @@ describe("hashline section headers", () => { }); it("recovers apply_patch-contaminated headers whose paths contain spaces", () => { - const section = Patch.parseSingle("¶*** Update File: dir with spaces/file.ts#1A2B\nreplace 1..1:\n+after"); + const section = Patch.parseSingle("[*** Update File: dir with spaces/file.ts#1A2B]\nreplace 1..1:\n+after"); expect(section.path).toBe("dir with spaces/file.ts"); expect(section.fileHash).toBe("1A2B"); @@ -25,29 +25,41 @@ describe("hashline section headers", () => { }); it("rejects trailing junk after a snapshot tag", () => { - expect(() => Patch.parse("¶src/a.ts#1A2B copied from read\nreplace 1..1:\n+after")).toThrow( + expect(() => Patch.parse("[src/a.ts#1A2B copied from read]\nreplace 1..1:\n+after")).toThrow( /Input header must be/, ); - expect(() => Patch.parse("¶src/a.ts#1A2B:812\nreplace 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B:812]\nreplace 1..1:\n+after")).toThrow(/Input header must be/); }); it("rejects trailing junk after a snapshot tag even with apply_patch noise", () => { - expect(() => Patch.parse("¶Update File: src/a.ts#1A2B copied from read\nreplace 1..1:\n+after")).toThrow( + expect(() => Patch.parse("[Update File: src/a.ts#1A2B copied from read]\nreplace 1..1:\n+after")).toThrow( /Input header must be/, ); - expect(() => Patch.parse("¶Update File: src/a.ts#1A2B:812\nreplace 1..1:\n+after")).toThrow( + expect(() => Patch.parse("[Update File: src/a.ts#1A2B:812]\nreplace 1..1:\n+after")).toThrow( /Input header must be/, ); }); it("rejects malformed snapshot tags", () => { - expect(() => Patch.parse("¶src/a.ts#1A2\nreplace 1..1:\n+after")).toThrow(/Input header must be/); - expect(() => Patch.parse("¶src/a.ts#1A2G\nreplace 1..1:\n+after")).toThrow(/Input header must be/); - expect(() => Patch.parse("¶src/a.ts#1A2B5\nreplace 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2]\nreplace 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2G]\nreplace 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B5]\nreplace 1..1:\n+after")).toThrow(/Input header must be/); }); it("rejects malformed snapshot tags even with apply_patch noise", () => { - expect(() => Patch.parse("¶Update File: src/a.ts#1A2G\nreplace 1..1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[Update File: src/a.ts#1A2G]\nreplace 1..1:\n+after")).toThrow(/Input header must be/); + }); + + it("reports bracket syntax with a 4-hex example when the header is missing", () => { + try { + Patch.parse("delete 38..40"); + throw new Error("expected missing-header error"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + expect(message).toContain('input must begin with "[PATH#HASH]"'); + expect(message).toContain('Example: "[src/foo.ts#1A2B]"'); + expect(message).not.toContain("#0A3"); + } }); }); diff --git a/packages/hashline/test/patcher.test.ts b/packages/hashline/test/patcher.test.ts index 1196683cc..82e891969 100644 --- a/packages/hashline/test/patcher.test.ts +++ b/packages/hashline/test/patcher.test.ts @@ -25,7 +25,7 @@ describe("Patcher snapshot tag integrity", () => { const tag = snapshots.record(PATH, "before\n"); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace 1..1:\n+after`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 1..1:\n+after`)); expect(result.sections[0]?.op).toBe("update"); expect(result.sections[0]?.fileHash).toMatch(/^[0-9A-F]{4}$/); @@ -45,14 +45,14 @@ describe("Patcher snapshot tag integrity", () => { expect(snapshots.byHash(PATH, tag)).toBeNull(); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace 3..3:\n+L3`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 3..3:\n+L3`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("l1\nl2\nL3\nl4\nl5\n"); }); it("normalizes lowercase section tags while parsing", () => { - const section = Patch.parseSingle(`¶${PATH}#1a2b\nreplace 1..1:\n+after`); + const section = Patch.parseSingle(`[${PATH}#1a2b]\nreplace 1..1:\n+after`); expect(section.fileHash).toBe("1A2B"); }); @@ -65,7 +65,7 @@ describe("Patcher snapshot tag integrity", () => { const patcher = new Patcher({ fs, snapshots }); try { - await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace 1..1:\n+after`)); + await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 1..1:\n+after`)); throw new Error("expected MismatchError"); } catch (error) { expect(error).toBeInstanceOf(MismatchError); @@ -89,7 +89,7 @@ describe("Patcher snapshot tag integrity", () => { const bogus = live === "FFFF" ? "0000" : "FFFF"; try { - await patcher.apply(Patch.parse(`¶${PATH}#${bogus}\nreplace 1..1:\n+after`)); + await patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nreplace 1..1:\n+after`)); throw new Error("expected MismatchError"); } catch (error) { expect(error).toBeInstanceOf(MismatchError); @@ -109,7 +109,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const snapshots = new InMemorySnapshotStore(); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`¶${PATH}\ninsert tail:\n+c`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}]\ninsert tail:\n+c`))).rejects.toThrow( /Missing hashline snapshot tag.*use the write tool/s, ); expect(fs.get(PATH)).toBe("a\nb\n"); @@ -120,7 +120,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const snapshots = new InMemorySnapshotStore(); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`¶${PATH}\nreplace 1..1:\n+X`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}]\nreplace 1..1:\n+X`))).rejects.toThrow( /Missing hashline snapshot tag/, ); }); @@ -130,7 +130,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const snapshots = new InMemorySnapshotStore(); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`¶ghost.ts#1A2B\ninsert tail:\n+c`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[ghost.ts#1A2B]\ninsert tail:\n+c`))).rejects.toThrow( /File not found.*use the write tool/is, ); }); @@ -143,7 +143,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const stale = live === "0000" ? "FFFF" : "0000"; const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`¶${PATH}#${stale}\ninsert tail:\n+c`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${stale}]\ninsert tail:\n+c`)); const section = result.sections[0]; expect(section?.op).toBe("update"); @@ -158,7 +158,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const tag = snapshots.record(PATH, content); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\ninsert tail:\n+c`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\ninsert tail:\n+c`)); const section = result.sections[0]; expect(section?.op).toBe("update");