@oh-my-pi/hashline 15.5.15 → 15.6.0

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.
@@ -44,3 +44,18 @@ export declare const RECOVERY_SESSION_CHAIN_WARNING = "Recovered from a stale fi
44
44
  * model verifies before continuing.
45
45
  */
46
46
  export declare const RECOVERY_SESSION_REPLAY_WARNING = "Recovered by replaying your edits onto the current file content \u2014 your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
47
+ /**
48
+ * Warning emitted when an `insert head:` / `insert tail:` edit is applied to an
49
+ * existing file whose snapshot tag is stale (the file drifted since the read).
50
+ * Head/tail insert position is content-independent — "start"/"end" cannot move
51
+ * with drift — so this is non-fatal: the edit applies onto the live content and
52
+ * we surface the drift instead of hard-failing (unlike an anchored mismatch).
53
+ */
54
+ export declare const HEADTAIL_DRIFT_WARNING = "Applied an `insert head:`/`insert tail:` edit onto the current file content even though the snapshot tag was stale (the file changed since your read). Head/tail position is content-independent, so the insert was not rejected \u2014 but re-read if the drift was unexpected.";
55
+ /**
56
+ * Error text emitted when a hashline section omits the mandatory snapshot tag.
57
+ * The tag is REQUIRED on every section, enforced identically by the apply path
58
+ * ({@link Patcher.prepare}) and the preview/diff path, so both surfaces reuse
59
+ * this single builder to stay in lockstep.
60
+ */
61
+ export declare function missingSnapshotTagMessage(sectionPath: string): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.5.15",
4
+ "version": "15.6.0",
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",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "diff": "^9.0.0",
37
- "lru-cache": "11.3.6"
37
+ "lru-cache": "11.5.1"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/bun": "^1.3.14"
package/src/grammar.lark CHANGED
@@ -3,7 +3,7 @@ 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
8
  filename: /[^\s#]+/
9
9
 
package/src/messages.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * them.
6
6
  */
7
7
 
8
+ import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
9
+
8
10
  /** Lines of context shown either side of a hash mismatch. */
9
11
  export const MISMATCH_CONTEXT = 2;
10
12
 
@@ -65,3 +67,23 @@ export const RECOVERY_SESSION_CHAIN_WARNING =
65
67
  */
66
68
  export const RECOVERY_SESSION_REPLAY_WARNING =
67
69
  "Recovered by replaying your edits onto the current file content — your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
70
+
71
+ /**
72
+ * Warning emitted when an `insert head:` / `insert tail:` edit is applied to an
73
+ * existing file whose snapshot tag is stale (the file drifted since the read).
74
+ * Head/tail insert position is content-independent — "start"/"end" cannot move
75
+ * with drift — so this is non-fatal: the edit applies onto the live content and
76
+ * we surface the drift instead of hard-failing (unlike an anchored mismatch).
77
+ */
78
+ export const HEADTAIL_DRIFT_WARNING =
79
+ "Applied an `insert head:`/`insert tail:` edit onto the current file content even though the snapshot tag was stale (the file changed since your read). Head/tail position is content-independent, so the insert was not rejected — but re-read if the drift was unexpected.";
80
+
81
+ /**
82
+ * Error text emitted when a hashline section omits the mandatory snapshot tag.
83
+ * The tag is REQUIRED on every section, enforced identically by the apply path
84
+ * ({@link Patcher.prepare}) and the preview/diff path, so both surfaces reuse
85
+ * this single builder to stay in lockstep.
86
+ */
87
+ export function missingSnapshotTagMessage(sectionPath: string): string {
88
+ 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.`;
89
+ }
package/src/patcher.ts CHANGED
@@ -23,10 +23,11 @@
23
23
  * filesystem configuration.
24
24
  */
25
25
  import { applyEdits } from "./apply";
26
- import { computeFileHash, formatHashlineHeader, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
26
+ import { computeFileHash, formatHashlineHeader } from "./format";
27
27
  import type { Filesystem, WriteResult } from "./fs";
28
28
  import { isNotFound } from "./fs";
29
29
  import type { Patch, PatchSection } from "./input";
30
+ import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage } from "./messages";
30
31
  import { MismatchError } from "./mismatch";
31
32
  import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
32
33
  import { Recovery, type RecoveryResult } from "./recovery";
@@ -102,11 +103,9 @@ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
102
103
  });
103
104
  }
104
105
 
105
- function assertSectionHashAllowed(sectionPath: string, fileHash: string | undefined, edits: readonly Edit[]): void {
106
- if (fileHash !== undefined || !hasAnchorScopedEdit(edits)) return;
107
- throw new Error(
108
- `Missing hashline snapshot tag for anchored edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag\` from your latest read/search output.`,
109
- );
106
+ function assertSectionHashPresent(sectionPath: string, fileHash: string | undefined): void {
107
+ if (fileHash !== undefined) return;
108
+ throw new Error(missingSnapshotTagMessage(sectionPath));
110
109
  }
111
110
 
112
111
  function recoveryToApplyResult(result: RecoveryResult): ApplyResult {
@@ -213,13 +212,13 @@ export class Patcher {
213
212
  */
214
213
  async prepare(section: PatchSection): Promise<PreparedSection> {
215
214
  const { edits, warnings: parseWarnings } = section.parse();
216
- assertSectionHashAllowed(section.path, section.fileHash, edits);
215
+ assertSectionHashPresent(section.path, section.fileHash);
217
216
 
218
217
  const canonicalPath = this.fs.canonicalPath(section.path);
219
218
  await this.fs.preflightWrite(section.path);
220
219
  const { exists, rawContent } = await this.#tryRead(section.path);
221
- if (!exists && hasAnchorScopedEdit(edits)) {
222
- throw new Error(`File not found: ${section.path}`);
220
+ if (!exists) {
221
+ throw new Error(`File not found: ${section.path}. Use the write tool to create new files.`);
223
222
  }
224
223
 
225
224
  const { bom, text } = stripBom(rawContent);
@@ -320,6 +319,14 @@ export class Patcher {
320
319
  // Whole-file unchanged → the tag still names the live content, so an
321
320
  // edit anchored at ANY line (displayed or not) is safe to apply.
322
321
  if (computeFileHash(normalized) === expected) return applyEdits(normalized, [...edits]);
322
+ // Head/tail-only inserts are position-stable: "start"/"end" cannot move
323
+ // with content drift, so a stale tag is non-fatal. Apply onto the live
324
+ // content and warn instead of hard-failing — unlike an anchored
325
+ // mismatch, which cannot be safely relocated and must reject.
326
+ if (!hasAnchorScopedEdit(edits)) {
327
+ const result = applyEdits(normalized, [...edits]);
328
+ return { ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] };
329
+ }
323
330
  // File drifted: try to replay the edit against the version the tag
324
331
  // names and 3-way-merge it onto the live content.
325
332
  const recovered = this.recovery.tryRecover({
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 3-char snapshot tag from your latest `read`/`search`. REQUIRED for any hunk that names line numbers. Hashless `¶PATH` is allowed only for new-file creation or a patch that is purely `insert head:` / `insert tail:`.
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,6 +23,9 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
23
23
  <rules>
24
24
  - Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `¶PATH#TAG` header; use the bare LINE numbers.
25
25
  - Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
26
+ - 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.
27
+ - 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.
28
+ - 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.
26
29
  - One hunk per range; the body is the final content, never an old/new pair.
27
30
  - To change lines 2 and 5 while keeping 3–4, issue two hunks (`replace 2..2:` and `replace 5..5:`). Untouched lines are simply absent from every range.
28
31
  </rules>
@@ -30,7 +33,7 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
30
33
  <example>
31
34
  Original (the exact shape `read` returns):
32
35
  ```
33
- ¶greet.py#A1
36
+ ¶greet.py#A1B2
34
37
  1:def greet(name):
35
38
  2: msg = "Hello, " + name
36
39
  3: print(msg)
@@ -39,14 +42,14 @@ Original (the exact shape `read` returns):
39
42
 
40
43
  Insert a guard after line 1:
41
44
  ```
42
- ¶greet.py#A1
45
+ ¶greet.py#A1B2
43
46
  insert after 1:
44
47
  + if not name: name = "stranger"
45
48
  ```
46
49
 
47
50
  Replace line 2 with two lines:
48
51
  ```
49
- ¶greet.py#A1
52
+ ¶greet.py#A1B2
50
53
  replace 2..2:
51
54
  + greeting = "Hi"
52
55
  + msg = f"{greeting}, {name}"
@@ -54,13 +57,13 @@ replace 2..2:
54
57
 
55
58
  Delete line 3:
56
59
  ```
57
- ¶greet.py#A1
60
+ ¶greet.py#A1B2
58
61
  delete 3
59
62
  ```
60
63
 
61
64
  Add a header and trailer:
62
65
  ```
63
- ¶greet.py#A1
66
+ ¶greet.py#A1B2
64
67
  insert head:
65
68
  +# generated header
66
69
  insert tail: