@oh-my-pi/hashline 15.9.3 → 15.9.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,10 +23,10 @@ const snapshots = new InMemorySnapshotStore();
23
23
  const before = `const greeting = "hi";\nexport { greeting };\n`;
24
24
  await fs.writeText("hello.ts", before);
25
25
 
26
- const tag = snapshots.recordContiguous("hello.ts", 1, before.split("\n"), { fullText: before });
26
+ const tag = snapshots.record("hello.ts", before);
27
27
  const patcher = new Patcher({ fs, snapshots });
28
- const patch = Patch.parse(String.rawhello.ts#${tag}
29
- @@ 1..1 @@
28
+ const patch = Patch.parse(String.raw`[hello.ts#${tag}]
29
+ replace 1..1:
30
30
  +const greeting = "hello";`);
31
31
  const result = await patcher.apply(patch);
32
32
 
@@ -39,19 +39,19 @@ console.log(await fs.readText("hello.ts"));
39
39
  See [`src/prompt.md`](./src/prompt.md) for the user-facing description and
40
40
  [`src/grammar.lark`](./src/grammar.lark) for the formal grammar.
41
41
 
42
- Each file section starts with PATH#TAG`. The tag is a 3-hex opaque
43
- pointer into the `SnapshotStore` that minted it; it is not content-derived
44
- and is not meaningful outside that store. The patcher protects against
45
- stale anchors by resolving the tag, verifying the recorded snapshot lines
46
- against live file content, and refusing or attempting session-aware
47
- recovery on mismatch.
42
+ Each file section starts with `[PATH#TAG]`. The tag is a 4-hex
43
+ content hash of the full normalized file text recorded by the
44
+ `SnapshotStore`, and it is not meaningful outside that store. The patcher
45
+ protects against stale anchors by resolving the tag, verifying the live file
46
+ still matches the recorded content hash, and refusing or attempting
47
+ session-aware recovery on mismatch.
48
48
 
49
49
  Inside a section:
50
- - `@@ A..B @@` open a hunk on lines A..B (use `@@ A,A @@` for a single line; bare `@@ A @@` is also accepted).
51
- - `@@ BOF @@` / `@@ EOF @@` virtual hunks at the beginning/end of file.
50
+ - `replace A..B:`replace lines A..B with following `+TEXT` body rows.
51
+ - `replace block A:`replace the syntactic block beginning on line A.
52
+ - `delete A..B` / `delete block A` — delete concrete lines or a resolved block.
53
+ - `insert before A:` / `insert after A:` / `insert head:` / `insert tail:` — insert following body rows.
52
54
  - `+TEXT` — literal body row (use `+` alone for a blank line).
53
- - `&A..B` — repeat original file lines A..B inline (`&A` for one line).
54
- - Empty body — delete the selected range.
55
55
 
56
56
  ## Abstractions
57
57
 
@@ -67,9 +67,10 @@ text-document protocol, a Git tree, anything.
67
67
 
68
68
  ### `SnapshotStore`
69
69
 
70
- Required. Hashline tags are opaque store pointers, so `Patcher` must receive
71
- the store that minted them. Recovery replays edits against the cached pre-edit
72
- snapshot and 3-way-merges onto current content when the live file diverged.
70
+ Required. Hashline tags are full-file content hashes recorded per path, so
71
+ `Patcher` must receive the store that observed them. Recovery replays edits
72
+ against the cached pre-edit snapshot and 3-way-merges onto current content
73
+ when the live file diverged.
73
74
 
74
75
  ### `Patcher`
75
76
 
@@ -4,8 +4,9 @@
4
4
  * tokenizer, the prompt, and the formal grammar.
5
5
  */
6
6
  import type { Cursor } from "./types";
7
- /** File-section header prefix: path#hash`. */
8
- export declare const HL_FILE_PREFIX = "\u00B6";
7
+ /** File-section header delimiters: `[path#hash]`. */
8
+ export declare const HL_FILE_PREFIX = "[";
9
+ export declare const HL_FILE_SUFFIX = "]";
9
10
  /** Payload sigil for literal body rows. */
10
11
  export declare const HL_PAYLOAD_REPLACE = "+";
11
12
  /** Hunk-header keyword for concrete line replacement. */
@@ -69,7 +69,7 @@ export declare class PatchSection {
69
69
  }
70
70
  /**
71
71
  * A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
72
- * at a PATH#HASH` header. Construct via {@link Patch.parse}.
72
+ * at a `[PATH#HASH]` header. Construct via {@link Patch.parse}.
73
73
  *
74
74
  * `Patch` is pure data: parsing is line-anchored and does not look at the
75
75
  * filesystem. To apply a patch, hand it to {@link Patcher.apply}.
@@ -32,9 +32,9 @@ export interface PatchSectionResult {
32
32
  persisted: string;
33
33
  /** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
34
34
  written: string;
35
- /** 3-hex opaque snapshot tag for `after`. Use to anchor follow-up edits. */
35
+ /** 4-hex content-hash tag for `after`. Use to anchor follow-up edits. */
36
36
  fileHash: string;
37
- /** Hashline section header (path#tag`) of the post-edit content. */
37
+ /** Hashline section header (`[path#tag]`) of the post-edit content. */
38
38
  header: string;
39
39
  /** 1-indexed first changed line in `after`, or `undefined` for noops. */
40
40
  firstChangedLine?: number;
@@ -75,7 +75,7 @@ export interface SplitOptions {
75
75
  /** Resolves absolute paths inside hashline headers to cwd-relative form. */
76
76
  cwd?: string;
77
77
  /**
78
- * Fallback path used when the input lacks a PATH` header but contains
78
+ * Fallback path used when the input lacks a `[PATH]` header but contains
79
79
  * recognizable hashline operations. Lets streaming previews work before
80
80
  * the model has written the header.
81
81
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.9.3",
4
+ "version": "15.9.67",
5
5
  "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
package/src/format.ts CHANGED
@@ -6,8 +6,9 @@
6
6
 
7
7
  import type { Cursor } from "./types";
8
8
 
9
- /** File-section header prefix: path#hash`. */
10
- export const HL_FILE_PREFIX = "";
9
+ /** File-section header delimiters: `[path#hash]`. */
10
+ export const HL_FILE_PREFIX = "[";
11
+ export const HL_FILE_SUFFIX = "]";
11
12
 
12
13
  /** Payload sigil for literal body rows. */
13
14
  export const HL_PAYLOAD_REPLACE = "+";
@@ -118,7 +119,7 @@ export function describeAnchorExamples(linePrefix = ""): string {
118
119
 
119
120
  /** Format a hashline section header for a file path and snapshot tag. */
120
121
  export function formatHashlineHeader(filePath: string, fileHash: string): string {
121
- return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}`;
122
+ return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}${HL_FILE_SUFFIX}`;
122
123
  }
123
124
 
124
125
  /** Formats a single numbered line as `LINE:TEXT`. */
package/src/grammar.lark CHANGED
@@ -3,9 +3,9 @@ begin_patch: "*** Begin Patch" LF
3
3
  end_patch: "*** End Patch" LF?
4
4
 
5
5
  file_patch: file_header hunk+
6
- file_header: "" filename "#" file_hash LF
6
+ file_header: "[" filename "#" file_hash "]" LF
7
7
  file_hash: /[0-9A-F]{4}/
8
- filename: /[^\s#]+/
8
+ filename: /[^#\r\n]+/
9
9
 
10
10
  hunk: replace_hunk | replace_block_hunk | insert_hunk | delete_hunk | delete_block_hunk
11
11
  replace_hunk: replace_anchor LF emit_op*
package/src/input.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Top-level patch parser. Splits an authored hashline input into a list of
3
- * {@link PatchSection}s, each rooted at a PATH#HASH` header, then exposes
3
+ * {@link PatchSection}s, each rooted at a `[PATH#HASH]` header, then exposes
4
4
  * a {@link Patch} class that gives lazy access to the parsed edits per
5
5
  * section.
6
6
  *
@@ -10,7 +10,7 @@
10
10
  import * as path from "node:path";
11
11
  import { applyEdits } from "./apply";
12
12
  import { resolveBlockEdits } from "./block";
13
- import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
13
+ import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
14
14
  import { parsePatch, parsePatchStreaming } from "./parser";
15
15
  import { Tokenizer } from "./tokenizer";
16
16
  import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types";
@@ -47,21 +47,41 @@ function stripApplyPatchPathNoise(pathText: string): string {
47
47
  }
48
48
 
49
49
  /**
50
- * Best-effort recovery for `¶`-prefixed lines the strict tokenizer
50
+ * Best-effort recovery for bracketed header lines the strict tokenizer
51
51
  * rejects. Strips apply_patch keyword noise (`Update File:`, `Update:`,
52
- * etc.) and an extra leading `***` (some models emit a hybrid `¶***foo.ts`
53
- * shape), then expects `PATH(#HASH)?` with no embedded whitespace.
52
+ * etc.) and an extra leading `***` (some models emit a hybrid
53
+ * `[***foo.ts#HASH]` shape), then expects `PATH(#HASH)?`.
54
54
  * Returns `null` when no clean path can be salvaged.
55
55
  */
56
56
  function tryParseRecoveryHeader(line: string, cwd?: string): RawSection | null {
57
- if (!line.startsWith(HL_FILE_PREFIX)) return null;
58
- const body = stripApplyPatchPathNoise(line.slice(HL_FILE_PREFIX.length).trim());
57
+ if (!line.startsWith(HL_FILE_PREFIX) || !line.endsWith(HL_FILE_SUFFIX)) return null;
58
+ const body = stripApplyPatchPathNoise(line.slice(HL_FILE_PREFIX.length, line.length - HL_FILE_SUFFIX.length).trim());
59
59
  if (body.length === 0) return null;
60
- const match = new RegExp(`^(\\S+?)(?:#([0-9A-Fa-f]{${HL_FILE_HASH_LENGTH}}))?\\s*$`).exec(body);
61
- if (match === null) return null;
62
- const path = normalizeHashlinePath(match[1], cwd);
60
+
61
+ // Trailing `#XXXX` is the tag; everything before it is the path. The
62
+ // path may contain whitespace (Windows OneDrive folders, Program Files,
63
+ // etc.), so we anchor the tag at end-of-body rather than scanning
64
+ // forward and stopping at the first space.
65
+ const trailing = new RegExp(`#([0-9A-Fa-f]{${HL_FILE_HASH_LENGTH}})\\s*$`).exec(body);
66
+ let pathText: string;
67
+ let fileHash: string | undefined;
68
+ if (trailing !== null) {
69
+ pathText = body.slice(0, trailing.index);
70
+ fileHash = trailing[1].toUpperCase();
71
+ } else {
72
+ pathText = body.replace(/\s+$/, "");
73
+ }
74
+
75
+ // Same rule as the strict tokenizer: the hashline header grammar uses
76
+ // `#` as the path/tag separator and does not allow `#` inside
77
+ // filenames. Anything `#` left in the path body — short tags, non-hex
78
+ // tags, over-long tags, stale-tag copy-paste, line-suffixed tags —
79
+ // means the header is malformed, not a path with an embedded hash.
80
+ if (pathText.includes("#")) return null;
81
+
82
+ const path = normalizeHashlinePath(pathText, cwd);
63
83
  if (path.length === 0) return null;
64
- return match[2] !== undefined ? { path, fileHash: match[2].toUpperCase(), diff: "" } : { path, diff: "" };
84
+ return fileHash !== undefined ? { path, fileHash, diff: "" } : { path, diff: "" };
65
85
  }
66
86
 
67
87
  function normalizeHashlinePath(rawPath: string, cwd?: string): string {
@@ -79,9 +99,9 @@ interface RawSection {
79
99
  }
80
100
 
81
101
  /**
82
- * Parse a PATH[#hash]` header line. Returns `null` for lines that do
83
- * not start with `¶`. Throws the strict "Input header must be …" error
84
- * when a `¶`-prefixed line fails the strict shape (so malformed paths
102
+ * Parse a `[PATH]` or `[PATH#hash]` header line. Returns `null` for lines that do
103
+ * not start with `[`. Throws the strict "Input header must be …" error
104
+ * when a bracketed line fails the strict shape (so malformed paths
85
105
  * surface immediately instead of being silently re-classified as payload).
86
106
  */
87
107
  function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null {
@@ -91,18 +111,18 @@ function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null
91
111
  const token = TOKENIZER.tokenize(trimmed);
92
112
  if (token.kind !== "header") {
93
113
  // Recovery: try to extract a path from the raw line after stripping
94
- // apply_patch noise. This handles `*** Update File:foo.ts#CB5` and
114
+ // apply_patch noise. This handles `[*** Update File:foo.ts#CB5A]` and
95
115
  // the half-dozen variants models actually emit.
96
116
  const recovered = tryParseRecoveryHeader(trimmed, cwd);
97
117
  if (recovered !== null) return recovered;
98
118
  throw new Error(
99
- `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)}.`,
119
+ `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)}.`,
100
120
  );
101
121
  }
102
122
 
103
123
  const parsedPath = normalizeHashlinePath(token.path, cwd);
104
124
  if (parsedPath.length === 0) {
105
- throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
125
+ throw new Error(`Input header "${HL_FILE_PREFIX}${HL_FILE_SUFFIX}" is empty; provide a file path.`);
106
126
  }
107
127
  return token.fileHash !== undefined
108
128
  ? { path: parsedPath, fileHash: token.fileHash, diff: "" }
@@ -145,7 +165,7 @@ function normalizeFallbackInput(input: string, options: SplitOptions): string {
145
165
  if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
146
166
  const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
147
167
  if (fallbackPath.length === 0) return input;
148
- return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`;
168
+ return `${HL_FILE_PREFIX}${fallbackPath}${HL_FILE_SUFFIX}\n${input}`;
149
169
  }
150
170
 
151
171
  function splitRawSections(input: string, options: SplitOptions = {}): RawSection[] {
@@ -160,13 +180,13 @@ function splitRawSections(input: string, options: SplitOptions = {}): RawSection
160
180
  if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(firstTrimmed)) {
161
181
  throw new Error(
162
182
  "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
163
- "File sections start with path#HASH`; use `replace`, `delete`, or `insert` ops.",
183
+ `File sections start with \`${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}HASH${HL_FILE_SUFFIX}\`; use \`replace\`, \`delete\`, or \`insert\` ops.`,
164
184
  );
165
185
  }
166
186
  const preview = JSON.stringify(firstLine.slice(0, 120));
167
187
  throw new Error(
168
- `input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH" on the first non-blank line for anchored edits; got: ${preview}. ` +
169
- `Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}0A3" then edit ops.`,
188
+ `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}. ` +
189
+ `Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX}" then edit ops.`,
170
190
  );
171
191
  }
172
192
 
@@ -187,7 +207,7 @@ function splitRawSections(input: string, options: SplitOptions = {}): RawSection
187
207
  if (token.kind === "envelope-end" || token.kind === "abort") break;
188
208
  if (token.kind === "envelope-begin") continue;
189
209
 
190
- // Route every `¶`-prefixed line through parseHashlineHeaderLine so
210
+ // Route every bracket-prefixed line through parseHashlineHeaderLine so
191
211
  // malformed headers still raise the strict "Input header must be …"
192
212
  // diagnostic (the tokenizer alone would silently classify them as
193
213
  // payload).
@@ -323,7 +343,7 @@ export class PatchSection {
323
343
 
324
344
  /**
325
345
  * A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
326
- * at a PATH#HASH` header. Construct via {@link Patch.parse}.
346
+ * at a `[PATH#HASH]` header. Construct via {@link Patch.parse}.
327
347
  *
328
348
  * `Patch` is pure data: parsing is line-anchored and does not look at the
329
349
  * filesystem. To apply a patch, hand it to {@link Patcher.apply}.
package/src/messages.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * them.
6
6
  */
7
7
 
8
- import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
8
+ import { HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
9
9
 
10
10
  /** Lines of context shown either side of a hash mismatch. */
11
11
  export const MISMATCH_CONTEXT = 2;
@@ -124,5 +124,5 @@ export const HEADTAIL_DRIFT_WARNING =
124
124
  * this single builder to stay in lockstep.
125
125
  */
126
126
  export function missingSnapshotTagMessage(sectionPath: string): string {
127
- 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.`;
127
+ 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.`;
128
128
  }
package/src/mismatch.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * plus a couple of lines of surrounding context. The {@link MismatchError}
7
7
  * formats this into a message at construction time.
8
8
  */
9
- import { formatNumberedLine, HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
9
+ import { formatNumberedLine, HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
10
10
  import { MISMATCH_CONTEXT } from "./messages";
11
11
 
12
12
  const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/;
@@ -15,7 +15,7 @@ export function formatFullAnchorRequirement(raw?: string): string {
15
15
  const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
16
16
  return (
17
17
  `a bare line number from read/search output plus the section header content-hash tag ` +
18
- `(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]} and line "160")${received}`
18
+ `(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX} and line "160")${received}`
19
19
  );
20
20
  }
21
21
 
@@ -99,12 +99,12 @@ export class MismatchError extends Error {
99
99
  if (!hashRecognized) {
100
100
  return [
101
101
  `Edit rejected${pathText}: hash ${HL_FILE_HASH_SEP}${details.expectedFileHash} is not from this session.`,
102
- `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.`,
102
+ `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.`,
103
103
  ];
104
104
  }
105
105
  return [
106
106
  `Edit rejected${pathText}: file changed between read and edit.`,
107
- `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.`,
107
+ `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.`,
108
108
  ];
109
109
  }
110
110
 
package/src/parser.ts CHANGED
@@ -43,7 +43,7 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
43
43
  const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
44
44
  return (
45
45
  `apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
46
- "File sections start with path#HASH` (no `Update File:` / `Add File:` keyword). " +
46
+ "File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " +
47
47
  "Use `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
48
48
  );
49
49
  }
package/src/patcher.ts CHANGED
@@ -64,9 +64,9 @@ export interface PatchSectionResult {
64
64
  persisted: string;
65
65
  /** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
66
66
  written: string;
67
- /** 3-hex opaque snapshot tag for `after`. Use to anchor follow-up edits. */
67
+ /** 4-hex content-hash tag for `after`. Use to anchor follow-up edits. */
68
68
  fileHash: string;
69
- /** Hashline section header (path#tag`) of the post-edit content. */
69
+ /** Hashline section header (`[path#tag]`) of the post-edit content. */
70
70
  header: string;
71
71
  /** 1-indexed first changed line in `after`, or `undefined` for noops. */
72
72
  firstChangedLine?: number;
package/src/prefixes.ts CHANGED
@@ -14,9 +14,11 @@
14
14
  * otherwise turn every content line into a (malformed) op.
15
15
  */
16
16
 
17
+ import { HL_FILE_HASH_LENGTH } from "./format";
18
+
17
19
  const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/;
18
20
  const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/;
19
- const HL_HEADER_RE = /^\s*¶\S+#[0-9a-fA-F]{3}\s*$/;
21
+ const HL_HEADER_RE = new RegExp(`^\\s*\\[[^#\\r\\n]+#[0-9a-fA-F]{${HL_FILE_HASH_LENGTH}}\\]\\s*$`);
20
22
  const DIFF_PLUS_RE = /^[+](?![+])/;
21
23
  const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
22
24
 
package/src/prompt.md CHANGED
@@ -1,7 +1,7 @@
1
1
  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.
2
2
 
3
3
  <headers>
4
- 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.
4
+ 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.
5
5
  </headers>
6
6
 
7
7
  <ops>
@@ -23,9 +23,9 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
23
23
  </body-rows>
24
24
 
25
25
  <rules>
26
- - Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the PATH#TAG` header; use the bare LINE numbers.
26
+ - Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `[PATH#TAG]` header; use the bare LINE numbers.
27
27
  - Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
28
- - 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.
28
+ - 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.
29
29
  - 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.
30
30
  - 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.
31
31
  - 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
37
37
  <example>
38
38
  Original (the exact shape `read` returns):
39
39
  ```
40
- greet.py#A1B2
40
+ [greet.py#A1B2]
41
41
  1:def greet(name):
42
42
  2: msg = "Hello, " + name
43
43
  3: print(msg)
@@ -46,14 +46,14 @@ Original (the exact shape `read` returns):
46
46
 
47
47
  Insert a guard after line 1:
48
48
  ```
49
- greet.py#A1B2
49
+ [greet.py#A1B2]
50
50
  insert after 1:
51
51
  + if not name: name = "stranger"
52
52
  ```
53
53
 
54
54
  Replace line 2 with two lines:
55
55
  ```
56
- greet.py#A1B2
56
+ [greet.py#A1B2]
57
57
  replace 2..2:
58
58
  + greeting = "Hi"
59
59
  + msg = f"{greeting}, {name}"
@@ -61,13 +61,13 @@ replace 2..2:
61
61
 
62
62
  Delete line 3:
63
63
  ```
64
- greet.py#A1B2
64
+ [greet.py#A1B2]
65
65
  delete 3
66
66
  ```
67
67
 
68
68
  Add a header and trailer:
69
69
  ```
70
- greet.py#A1B2
70
+ [greet.py#A1B2]
71
71
  insert head:
72
72
  +# generated header
73
73
  insert tail:
@@ -76,7 +76,7 @@ insert tail:
76
76
 
77
77
  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:
78
78
  ```
79
- greet.py#A1B2
79
+ [greet.py#A1B2]
80
80
  replace block 1:
81
81
  +def greet(name):
82
82
  + print(f"Hello, {name}")
package/src/tokenizer.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Format shape:
5
5
  * ```
6
- * path/to/file.ts#0A3
6
+ * [path/to/file.ts#1A2B]
7
7
  * replace 5..7:
8
8
  * +literal new line
9
9
  * ```
@@ -15,6 +15,7 @@ import {
15
15
  HL_FILE_HASH_LENGTH,
16
16
  HL_FILE_HASH_SEP,
17
17
  HL_FILE_PREFIX,
18
+ HL_FILE_SUFFIX,
18
19
  HL_HEADER_COLON,
19
20
  HL_INSERT_AFTER,
20
21
  HL_INSERT_BEFORE,
@@ -45,6 +46,7 @@ const CHAR_LOWER_F = 102;
45
46
  const CHAR_PAYLOAD_REPLACE = HL_PAYLOAD_REPLACE.charCodeAt(0);
46
47
  const CHAR_COLON = HL_HEADER_COLON.charCodeAt(0);
47
48
  const FILE_PREFIX_LENGTH = HL_FILE_PREFIX.length;
49
+ const FILE_SUFFIX_LENGTH = HL_FILE_SUFFIX.length;
48
50
 
49
51
  function isDigitCode(code: number): boolean {
50
52
  return code >= CHAR_ZERO && code <= CHAR_NINE;
@@ -137,7 +139,7 @@ export function parseLid(raw: string, lineNum: number): Anchor {
137
139
  if (number === null || skipWhitespace(raw, number.nextIndex, end) !== end) {
138
140
  throw new Error(
139
141
  `line ${lineNum}: expected a line number such as ${describeAnchorExamples("119")}; ` +
140
- `got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash from your latest read for file-version binding.`,
142
+ `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.`,
141
143
  );
142
144
  }
143
145
  return { line: number.line };
@@ -312,28 +314,44 @@ function tryParseHunkHeader(line: string): ParsedHunkHeader | null {
312
314
  function tryParseHeader(line: string): { path: string; fileHash?: string } | null {
313
315
  if (!line.startsWith(HL_FILE_PREFIX)) return null;
314
316
  const end = trimEndIndex(line);
315
- let index = FILE_PREFIX_LENGTH;
316
- if (index >= end) return null;
317
- const pathStart = index;
318
- while (index < end) {
319
- const code = line.charCodeAt(index);
320
- if (code === CHAR_HASH || code === CHAR_SPACE || code === CHAR_TAB) break;
321
- index++;
322
- }
323
- if (index === pathStart) return null;
324
- const path = line.slice(pathStart, index);
317
+ if (FILE_PREFIX_LENGTH + FILE_SUFFIX_LENGTH >= end) return null;
318
+ if (!line.endsWith(HL_FILE_SUFFIX, end)) return null;
319
+ const bodyEnd = end - FILE_SUFFIX_LENGTH;
320
+ if (FILE_PREFIX_LENGTH >= bodyEnd) return null;
321
+
322
+ // The snapshot tag, when present, is the trailing `#XXXX` block inside the
323
+ // bracketed header. We detect it from the suffix so the path may
324
+ // legitimately contain whitespace (e.g. `OneDrive - Company/file.ts`).
325
+ let pathEnd = bodyEnd;
325
326
  let fileHash: string | undefined;
326
- if (index < end && line.charCodeAt(index) === CHAR_HASH) {
327
- const hashStart = index + 1;
328
- const hashEnd = hashStart + HL_FILE_HASH_LENGTH;
329
- if (hashEnd > end) return null;
330
- for (let probe = hashStart; probe < hashEnd; probe++) {
331
- if (!isHexDigitCode(line.charCodeAt(probe))) return null;
327
+ const trailingHashStart = bodyEnd - HL_FILE_HASH_LENGTH - 1;
328
+ if (trailingHashStart >= FILE_PREFIX_LENGTH && line.charCodeAt(trailingHashStart) === CHAR_HASH) {
329
+ let allHex = true;
330
+ for (let probe = trailingHashStart + 1; probe < bodyEnd; probe++) {
331
+ if (!isHexDigitCode(line.charCodeAt(probe))) {
332
+ allHex = false;
333
+ break;
334
+ }
335
+ }
336
+ if (allHex) {
337
+ pathEnd = trailingHashStart;
338
+ fileHash = line.slice(trailingHashStart + 1, bodyEnd).toUpperCase();
332
339
  }
333
- fileHash = line.slice(hashStart, hashEnd).toUpperCase();
334
- index = hashEnd;
335
340
  }
336
- if (skipWhitespace(line, index, end) !== end) return null;
341
+
342
+ // The hashline header grammar uses `#` as the path/tag separator and
343
+ // does not allow `#` inside filenames. Anything `#` left in the path
344
+ // body — short tags (`#1A2`), non-hex tags (`#1A2G`), over-long tags
345
+ // (`#1A2B5`), stale-tag copy-paste (`#1A2B copied from read`), or
346
+ // line-suffixed tags (`#1A2B:42`) — means the header is malformed.
347
+ // Surface the focused diagnostic instead of silently mis-routing the
348
+ // edit or reporting a missing tag downstream.
349
+ for (let i = FILE_PREFIX_LENGTH; i < pathEnd; i++) {
350
+ if (line.charCodeAt(i) === CHAR_HASH) return null;
351
+ }
352
+
353
+ if (pathEnd === FILE_PREFIX_LENGTH) return null;
354
+ const path = line.slice(FILE_PREFIX_LENGTH, pathEnd);
337
355
  return fileHash !== undefined ? { path, fileHash } : { path };
338
356
  }
339
357
 
package/src/types.ts CHANGED
@@ -72,7 +72,7 @@ export interface SplitOptions {
72
72
  /** Resolves absolute paths inside hashline headers to cwd-relative form. */
73
73
  cwd?: string;
74
74
  /**
75
- * Fallback path used when the input lacks a PATH` header but contains
75
+ * Fallback path used when the input lacks a `[PATH]` header but contains
76
76
  * recognizable hashline operations. Lets streaming previews work before
77
77
  * the model has written the header.
78
78
  */