dsh-hashline-edit 0.1.0 → 0.2.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.
Files changed (131) hide show
  1. package/README.md +72 -70
  2. package/cordis.patch.yml +1 -1
  3. package/lib/index.js +4494 -4813
  4. package/lib/types/dsh/contracts.d.ts +69 -6
  5. package/lib/types/dsh/fs.d.ts +38 -3
  6. package/lib/types/dsh/guidance.d.ts +5 -5
  7. package/lib/types/dsh/hide.d.ts +78 -0
  8. package/lib/types/dsh/host-compat.d.ts +7 -78
  9. package/lib/types/dsh/index.d.ts +17 -7
  10. package/lib/types/dsh/metrics/events.d.ts +8 -1
  11. package/lib/types/dsh/metrics/instrument.d.ts +20 -14
  12. package/lib/types/dsh/metrics/observer.d.ts +28 -0
  13. package/lib/types/dsh/sandbox.d.ts +2 -2
  14. package/lib/types/dsh/shell-hook.d.ts +17 -8
  15. package/lib/types/dsh/shell-view.d.ts +65 -3
  16. package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
  17. package/lib/types/dsh/tools/context.d.ts +19 -1
  18. package/lib/types/dsh/tools/output.d.ts +32 -4
  19. package/lib/types/dsh/tools/read.d.ts +12 -15
  20. package/lib/types/dsh/tools/undo.d.ts +5 -0
  21. package/lib/types/dsh/write-hook.d.ts +8 -22
  22. package/lib/types/src/apply-patch/commit.d.ts +49 -0
  23. package/lib/types/src/apply-patch/grammar.d.ts +29 -0
  24. package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
  25. package/lib/types/src/apply-patch/plan.d.ts +50 -0
  26. package/lib/types/src/apply-patch/render.d.ts +51 -0
  27. package/lib/types/src/apply-patch/resolve.d.ts +129 -0
  28. package/lib/types/src/apply-patch/tool.d.ts +54 -0
  29. package/lib/types/src/apply-patch/types.d.ts +362 -0
  30. package/lib/types/src/bash-view-banner.d.ts +20 -0
  31. package/lib/types/src/domain-errors.d.ts +16 -2
  32. package/lib/types/src/edit-undo.d.ts +11 -2
  33. package/lib/types/src/file-content/detection.d.ts +17 -0
  34. package/lib/types/src/file-content/index.d.ts +2 -2
  35. package/lib/types/src/file-content/preview.d.ts +1 -1
  36. package/lib/types/src/fs-lifecycle.d.ts +44 -0
  37. package/lib/types/src/hashline/resolve.d.ts +6 -0
  38. package/lib/types/src/host-utils.d.ts +63 -0
  39. package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
  40. package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
  41. package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
  42. package/lib/types/src/mutation-engine/types.d.ts +50 -58
  43. package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
  44. package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
  45. package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
  46. package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
  47. package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
  48. package/lib/types/src/served-session/serve-render.d.ts +9 -3
  49. package/lib/types/src/undo-store.d.ts +17 -1
  50. package/package.json +10 -22
  51. package/prompts/apply-patch-guidelines.md +10 -0
  52. package/prompts/apply-patch.md +1 -0
  53. package/prompts/read-guidelines.md +5 -5
  54. package/prompts/read.md +1 -1
  55. package/prompts/undo-last-edit-guidelines.md +2 -2
  56. package/prompts/undo-last-edit-snippet.md +1 -1
  57. package/prompts/undo-last-edit.md +1 -1
  58. package/src/apply-patch/commit.ts +190 -0
  59. package/src/apply-patch/grammar.ts +620 -0
  60. package/src/apply-patch/index.ts +17 -0
  61. package/src/apply-patch/lifecycle.ts +195 -0
  62. package/src/apply-patch/plan.ts +800 -0
  63. package/src/apply-patch/render.ts +137 -0
  64. package/src/apply-patch/resolve.ts +470 -0
  65. package/src/apply-patch/tool.ts +257 -0
  66. package/src/apply-patch/types.ts +387 -0
  67. package/src/cut-repair.ts +77 -11
  68. package/src/domain-errors.ts +58 -10
  69. package/src/edit-undo.ts +379 -25
  70. package/src/file-content/detection.ts +34 -0
  71. package/src/file-content/index.ts +2 -2
  72. package/src/file-content/preview.ts +1 -1
  73. package/src/file-content/served-header.ts +1 -1
  74. package/src/fs-lifecycle.ts +201 -0
  75. package/src/hash-store.ts +18 -1
  76. package/src/hashline/lease-resolve.ts +14 -8
  77. package/src/hashline/resolve.ts +30 -27
  78. package/src/host-utils.ts +224 -0
  79. package/src/mutation-engine/batch-span-gate.ts +17 -4
  80. package/src/mutation-engine/edit-request.ts +39 -0
  81. package/src/mutation-engine/pipeline.ts +535 -63
  82. package/src/mutation-engine/types.ts +49 -65
  83. package/src/mutation-queue.ts +1 -1
  84. package/src/prompts.ts +0 -28
  85. package/src/read-expansion/ast-provider.ts +4 -0
  86. package/src/read-expansion/grammar-manifest.ts +21 -2
  87. package/src/read-expansion/java-decoration.ts +34 -0
  88. package/src/read-expansion/span-expansion.ts +6 -2
  89. package/src/read-expansion/span-kind.ts +3 -1
  90. package/src/read-expansion/vue-decoration.ts +43 -0
  91. package/src/served-session/serve-render.ts +5 -5
  92. package/src/undo-store.ts +74 -5
  93. package/lib/types/dsh/encoding-bridge.d.ts +0 -25
  94. package/lib/types/dsh/encoding.d.ts +0 -144
  95. package/lib/types/dsh/tools/edit.d.ts +0 -10
  96. package/lib/types/src/edit-tool.d.ts +0 -41
  97. package/lib/types/src/mutation-engine/engine.d.ts +0 -31
  98. package/lib/types/src/payload-contract.d.ts +0 -124
  99. package/prompts/edit-guidelines.md +0 -10
  100. package/prompts/edit-snippet.md +0 -1
  101. package/prompts/edit.md +0 -1
  102. package/src/bash-classifier.ts +0 -1626
  103. package/src/bash-conversion/decide.ts +0 -812
  104. package/src/bash-display-transform.ts +0 -50
  105. package/src/bash-multifile-config.ts +0 -239
  106. package/src/bash-view-config.ts +0 -158
  107. package/src/edit-normalize.ts +0 -8
  108. package/src/edit-render.ts +0 -161
  109. package/src/edit-tool.ts +0 -116
  110. package/src/edit.ts +0 -136
  111. package/src/integrations/pi-lens/command.ts +0 -712
  112. package/src/integrations/pi-lens/config.ts +0 -148
  113. package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
  114. package/src/integrations/pi-lens/io-bridge.ts +0 -112
  115. package/src/integrations/pi-lens/settings-overlay.ts +0 -272
  116. package/src/lifecycle-hooks/index.ts +0 -1992
  117. package/src/lifecycle-hooks/types.ts +0 -64
  118. package/src/mutated-files.ts +0 -75
  119. package/src/mutation-engine/engine.ts +0 -173
  120. package/src/mutation-engine/index.ts +0 -17
  121. package/src/payload-contract.ts +0 -637
  122. package/src/preview-controller.ts +0 -84
  123. package/src/read.ts +0 -865
  124. package/src/search-hook.ts +0 -41
  125. package/src/store-config.ts +0 -73
  126. package/src/store-janitor-warning.ts +0 -169
  127. package/src/store-janitor.ts +0 -539
  128. package/src/store-kernel/settings.ts +0 -233
  129. package/src/store.ts +0 -154
  130. package/src/tui-presenter.ts +0 -134
  131. package/src/write-hook.ts +0 -130
@@ -0,0 +1,201 @@
1
+ import { lstat, mkdir, realpath, rename, rm, rmdir } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
+ import { DomainError } from "./domain-errors.js";
4
+ import { reportDiagnostic } from "./diagnostics.js";
5
+ import { resolveTarget } from "./fs-write.js";
6
+ import { errCode } from "./utils.js";
7
+
8
+ /**
9
+ * Filesystem primitives for apply_patch's file lifecycle: classify, create directories, delete a
10
+ * regular file and rename one. Every entry point resolves through {@link resolveTarget}, the same
11
+ * symlink-resolving boundary every read/write uses, so a lifecycle action can never escape it.
12
+ *
13
+ * These are primitives, not a second write engine: file CONTENTS still go through writeAtomic;
14
+ * this module only owns namespace operations that writeAtomic deliberately does not express.
15
+ *
16
+ * @module dsh-hashline-edit/fs-lifecycle
17
+ */
18
+
19
+ /** The physical kind of a path as the file layer sees it (symlinks resolved first). */
20
+ export type PathKind = "file" | "directory" | "other" | "absent";
21
+
22
+ /** Classify one path through the file layer's symlink-resolving boundary. */
23
+ export async function pathKind(path: string): Promise<PathKind> {
24
+ const targetPath = await resolveTarget(path);
25
+ try {
26
+ const info = await lstat(targetPath);
27
+ if (info.isSymbolicLink()) return "other";
28
+ if (info.isFile()) return "file";
29
+ if (info.isDirectory()) return "directory";
30
+ return "other";
31
+ } catch (error: unknown) {
32
+ if (errCode(error) === "ENOENT") return "absent";
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * True when `path` resolves inside `root` once every existing symlink/junction component is
39
+ * followed. A missing tail is checked against its nearest existing ancestor: an Add File under a
40
+ * junction ancestor therefore fails before mkdir can create anything outside the workspace.
41
+ */
42
+ export async function isPathWithinWorkspace(root: string, path: string): Promise<boolean> {
43
+ const absolutePath = resolve(path);
44
+ const { root: fsRoot } = parse(absolutePath);
45
+ let cursor = fsRoot;
46
+ for (const part of absolutePath
47
+ .slice(fsRoot.length)
48
+ .split(sep)
49
+ .filter((value) => value.length > 0)) {
50
+ cursor = join(cursor, part);
51
+ try {
52
+ const info = await lstat(cursor);
53
+ if (info.isSymbolicLink()) return false;
54
+ // WHY: Windows junctions are reparse points that some Node builds do not classify as
55
+ // WHY: symlinks; a realpath that differs from the lexical path proves a link component.
56
+ if ((await realpath(cursor)) !== cursor) return false;
57
+ } catch (error: unknown) {
58
+ if (errCode(error) === "ENOENT") break;
59
+ throw error;
60
+ }
61
+ }
62
+ const rootReal = await realpath(resolve(root));
63
+ let ancestor = absolutePath;
64
+ for (;;) {
65
+ try {
66
+ const real = await realpath(ancestor);
67
+ const rel = relative(rootReal, real);
68
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
69
+ } catch (error: unknown) {
70
+ if (errCode(error) !== "ENOENT") throw error;
71
+ const parent = dirname(ancestor);
72
+ if (parent === ancestor) return false;
73
+ ancestor = parent;
74
+ }
75
+ }
76
+ }
77
+
78
+ /** Throw the E_PATCH_SYNTAX boundary refusal for one escaping lifecycle path. */
79
+ export async function assertPathWithinWorkspace(root: string, path: string): Promise<void> {
80
+ if (!(await isPathWithinWorkspace(root, path))) {
81
+ throw new DomainError("E_PATCH_SYNTAX", {
82
+ message: `path escapes the workspace through a symlink or junction: ${path}`,
83
+ path,
84
+ });
85
+ }
86
+ }
87
+
88
+ /** Create one directory (and missing parents), through the symlink-resolving boundary. */
89
+ export async function mkdirp(path: string, workspaceRoot?: string): Promise<void> {
90
+ if (path.includes("\0")) {
91
+ throw new DomainError("E_INTERNAL_INVARIANT", { message: "Path contains null byte." });
92
+ }
93
+ if (workspaceRoot !== undefined) await assertPathWithinWorkspace(workspaceRoot, path);
94
+ const targetPath = await resolveTarget(path);
95
+ await mkdir(targetPath, { recursive: true });
96
+ if (workspaceRoot !== undefined) await assertPathWithinWorkspace(workspaceRoot, path);
97
+ }
98
+
99
+ /**
100
+ * Delete one regular file. Deliberately NOT recursive: a lifecycle delete targets a file, and a
101
+ * directory at the same path is a caller bug that must surface, never be removed wholesale.
102
+ */
103
+ export async function removeFile(path: string, signal?: AbortSignal): Promise<void> {
104
+ signal?.throwIfAborted();
105
+ const targetPath = await resolveTarget(path);
106
+ const kind = await pathKind(targetPath);
107
+ if (kind === "absent") {
108
+ const error = new Error(
109
+ "ENOENT: no such file or directory, unlink '" + targetPath + "'",
110
+ ) as NodeJS.ErrnoException;
111
+ error.code = "ENOENT";
112
+ throw error;
113
+ }
114
+ if (kind !== "file") {
115
+ throw new DomainError("E_INTERNAL_INVARIANT", {
116
+ message: "Refusing to delete a non-file lifecycle target: " + path,
117
+ });
118
+ }
119
+ await rm(targetPath, { force: false });
120
+ }
121
+
122
+ /**
123
+ * Rename one regular file, creating the destination parent first. The destination must not exist:
124
+ * a move never clobbers another file (spec §6: target-exists is a rejection, not an overwrite).
125
+ */
126
+ export async function renameFile(
127
+ from: string,
128
+ to: string,
129
+ signal?: AbortSignal,
130
+ workspaceRoot?: string,
131
+ ): Promise<void> {
132
+ signal?.throwIfAborted();
133
+ if (workspaceRoot !== undefined) {
134
+ await assertPathWithinWorkspace(workspaceRoot, from);
135
+ await assertPathWithinWorkspace(workspaceRoot, to);
136
+ }
137
+ const fromPath = await resolveTarget(from);
138
+ const toPath = await resolveTarget(to);
139
+ const destinationKind = await pathKind(toPath);
140
+ if (destinationKind !== "absent") {
141
+ const error = new Error(
142
+ "EEXIST: file already exists, rename '" + fromPath + "' -> '" + toPath + "'",
143
+ ) as NodeJS.ErrnoException;
144
+ error.code = "EEXIST";
145
+ throw error;
146
+ }
147
+ await mkdirp(dirname(toPath), workspaceRoot);
148
+ if (workspaceRoot !== undefined) await assertPathWithinWorkspace(workspaceRoot, to);
149
+ await rename(fromPath, toPath);
150
+ }
151
+
152
+ /**
153
+ * The directories on the way to `path` that do not exist yet, OUTERMOST FIRST. Captured before a
154
+ * lifecycle add writes, so an undo removes exactly the directories the add created and no others.
155
+ */
156
+ export async function missingAncestorDirs(path: string): Promise<string[]> {
157
+ const targetPath = await resolveTarget(path);
158
+ const missing: string[] = [];
159
+ let cursor = dirname(targetPath);
160
+ const { root } = parse(cursor);
161
+ while (cursor !== root) {
162
+ try {
163
+ const info = await lstat(cursor);
164
+ if (info.isDirectory()) break;
165
+ break;
166
+ } catch (error: unknown) {
167
+ if (errCode(error) !== "ENOENT") throw error;
168
+ missing.unshift(cursor);
169
+ cursor = dirname(cursor);
170
+ }
171
+ }
172
+ return missing;
173
+ }
174
+
175
+ /**
176
+ * Best-effort removal of directories a lifecycle add created, deepest first. Returns the removed
177
+ * paths. A directory that is no longer empty (an outside writer put something there) is kept.
178
+ */
179
+ export async function pruneEmptyDirs(dirs: readonly string[]): Promise<string[]> {
180
+ const removed: string[] = [];
181
+ for (const dir of [...dirs].sort((a, b) => b.length - a.length)) {
182
+ try {
183
+ await rmdir(dir);
184
+ removed.push(dir);
185
+ } catch (error: unknown) {
186
+ const code = errCode(error);
187
+ if (
188
+ code === "ENOENT" ||
189
+ code === "ENOTEMPTY" ||
190
+ code === "EEXIST" ||
191
+ code === "EPERM" ||
192
+ code === "EISDIR" ||
193
+ code === "ENOTDIR"
194
+ ) {
195
+ continue;
196
+ }
197
+ reportDiagnostic("fs-lifecycle", "failed to prune a lifecycle-created directory:", error);
198
+ }
199
+ }
200
+ return removed;
201
+ }
package/src/hash-store.ts CHANGED
@@ -156,7 +156,16 @@ function openDb(storePath: string, budget?: BusyBudget): OpenedDb {
156
156
  const MIGRATION_COLUMNS = {
157
157
  cut_intent: { direction: "TEXT" },
158
158
  file_snapshots: { stat_key: "TEXT" },
159
- file_undo: { snapshot_hash: "TEXT", transaction_id: "TEXT", raw_pre: "TEXT" },
159
+ file_undo: {
160
+ snapshot_hash: "TEXT",
161
+ transaction_id: "TEXT",
162
+ raw_pre: "TEXT",
163
+ lifecycle: "TEXT",
164
+ from_path: "TEXT",
165
+ to_path: "TEXT",
166
+ raw_post: "TEXT",
167
+ created_dirs: "TEXT",
168
+ },
160
169
  served: { retired: "TEXT", canons: "TEXT", snapshotId: "TEXT" },
161
170
  undo: { result_content: "TEXT NOT NULL DEFAULT ''" },
162
171
  } as const;
@@ -625,6 +634,14 @@ export function ensureFileUndoSchema(db: DatabaseSync): void {
625
634
  addColumnIfMissing(db, "file_undo", "snapshot_hash");
626
635
  addColumnIfMissing(db, "file_undo", "transaction_id");
627
636
  addColumnIfMissing(db, "file_undo", "raw_pre");
637
+ // WHY: apply_patch lifecycle rows are still edit-shaped rows: one file_undo row per member,
638
+ // WHY: one shared transaction id. These columns only widen what a row can describe — an
639
+ // WHY: existing row (all NULL) stays an ordinary edit, so the addition is back-compatible.
640
+ addColumnIfMissing(db, "file_undo", "lifecycle");
641
+ addColumnIfMissing(db, "file_undo", "from_path");
642
+ addColumnIfMissing(db, "file_undo", "to_path");
643
+ addColumnIfMissing(db, "file_undo", "raw_post");
644
+ addColumnIfMissing(db, "file_undo", "created_dirs");
628
645
  // WHY: versions the persisted anchor set — existing rows keep 0 (never current),
629
646
  // WHY: so a pre-bump store is refused, not migrated, on first open after upgrade.
630
647
  // WHY: `canon_version` (pre-rename builds) is renamed in place, preserving stamped values.
@@ -85,6 +85,14 @@ function resolvedAt(edit: HEdit, fileHashes: string[], fromLine: number, toLine:
85
85
  ...(edit.placement !== undefined ? { placement: edit.placement } : {}),
86
86
  };
87
87
  }
88
+ /**
89
+ * SAFETY: one copy of each refused anchor, in submission order. A single-line edit names the same
90
+ * anchor in both bounds; collapsing the pair keeps the refusal's count and quoted list honest (one
91
+ * anchor, not two).
92
+ */
93
+ function distinctAnchors(anchors: readonly string[]): string[] {
94
+ return [...new Set(anchors)];
95
+ }
88
96
 
89
97
  /**
90
98
  * Refuses anchors the lease seam cannot place: the session holds no lease for them in this file.
@@ -103,14 +111,12 @@ function throwUnknownOrForeign(args: {
103
111
  }): never {
104
112
  const { edit, snapshot, source } = args;
105
113
  const path = snapshot.filePath ?? "this file";
106
- const refused = [
107
- ...new Set(
108
- [
109
- args.refusedFrom ? edit.hash_bounds[0].hash : undefined,
110
- args.refusedTo ? edit.hash_bounds[1].hash : undefined,
111
- ].filter((hash): hash is string => hash !== undefined),
112
- ),
113
- ];
114
+ const refused = distinctAnchors(
115
+ [
116
+ args.refusedFrom ? edit.hash_bounds[0].hash : undefined,
117
+ args.refusedTo ? edit.hash_bounds[1].hash : undefined,
118
+ ].filter((hash): hash is string => hash !== undefined),
119
+ );
114
120
  const homes = [
115
121
  ...new Set(refused.flatMap((anchor) => source.anchorHomes?.(anchor) ?? [])),
116
122
  ].sort();
@@ -1,4 +1,4 @@
1
- import { abortIf, rejectUnknownFields, clipLine } from "../utils.js";
1
+ import { abortIf, rejectUnknownFields } from "../utils.js";
2
2
  import { DomainError, formatWarning } from "../domain-errors.js";
3
3
  import { HASH_CLASS, HASH_LEN } from "./hash-identity.js";
4
4
  import { parseHashRef, parseText, type Anchor } from "./parse.js";
@@ -239,51 +239,54 @@ function _fmtMismatch(mismatches: HMismatch[], snapshot: FileSnapshotContext): s
239
239
  return fmtMismatchWithServes(mismatches, snapshot).message;
240
240
  }
241
241
 
242
+ /**
243
+ * SAFETY: one copy per distinct anchor hash, in submission order. A single-line edit names its one
244
+ * anchor in both bounds, so the size of this array — never the raw mismatch count — owns the label
245
+ * and the count in the rendered refusal.
246
+ */
247
+ function distinctByHash<T extends { ref: { hash: string } }>(items: readonly T[]): T[] {
248
+ return [...new Map(items.map((item) => [item.ref.hash, item])).values()];
249
+ }
250
+
251
+ /**
252
+ * SAFETY: the summary names which anchors failed and how many distinct anchors they are. Row
253
+ * evidence is collected through `pushRow` for the rejection's single `servedBlock`; echoing the rows
254
+ * here as a second context block is the duplicate this function no longer produces.
255
+ */
242
256
  function formatNotFound(
243
257
  notFound: HMismatch[],
244
258
  fileLines: string[],
245
- fileHashes: string[],
246
259
  filePath: string | undefined,
247
260
  pushRow: (ln: number) => void,
248
261
  out: string[],
249
262
  ): void {
250
263
  if (notFound.length === 0) return;
251
- const distinct = [...new Map(notFound.map((m) => [m.ref.hash, m])).values()];
264
+ const distinct = distinctByHash(notFound);
252
265
  const refList = distinct.map((m) => `"${m.ref.hash}"`).join(", ");
253
266
  // WHY: the body carries no code tag — the registry owns the `[MODEL] [E_*]`
254
267
  // WHY: header when the caller wraps this in a `DomainError`.
255
268
  out.push(
256
- `${distinct.length} stale anchor${distinct.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Re-read the full file and copy the fresh ${HASH_LEN}-char anchors (the ${HASH_LEN} chars before │, e.g. "wUpX").`,
269
+ `${distinct.length} stale anchor${distinct.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}.`,
257
270
  );
258
271
  for (const m of distinct) {
259
272
  const ctx = m.context;
260
273
  if (!ctx) continue;
261
274
  const from = Math.max(1, ctx.line - 1);
262
275
  const to = Math.min(fileLines.length, ctx.line + 1);
263
- const rows: string[] = [];
264
- for (let ln = from; ln <= to; ln++) {
265
- rows.push(` ${ln}: ${fileHashes[ln - 1]}│${clipLine(fileLines[ln - 1] ?? "")}`);
266
- pushRow(ln);
267
- }
268
- out.push("");
269
- out.push(
270
- ` Current context around resolved anchor "${ctx.hash}" (line ${ctx.line}):\n${rows.join("\n")}`,
271
- );
276
+ for (let ln = from; ln <= to; ln++) pushRow(ln);
272
277
  }
273
278
  }
274
279
  function formatAmbiguous(
275
280
  ambiguous: HMismatch[],
276
- fileLines: string[],
277
- fileHashes: string[],
278
281
  filePath: string | undefined,
279
282
  pushRow: (ln: number) => void,
280
283
  out: string[],
281
284
  ): void {
282
285
  if (ambiguous.length === 0) return;
283
286
  if (out.length > 0) out.push("");
284
- const distinctAmbiguous = [...new Map(ambiguous.map((m) => [m.ref.hash, m])).values()];
287
+ const distinctAmbiguous = distinctByHash(ambiguous);
285
288
  out.push(
286
- `${distinctAmbiguous.length} ambiguous anchor${distinctAmbiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Re-read the full file and copy the fresh ${HASH_LEN}-char anchors (the ${HASH_LEN} chars before │, e.g. "wUpX").`,
289
+ `${distinctAmbiguous.length} ambiguous anchor${distinctAmbiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}.`,
287
290
  );
288
291
  for (const m of distinctAmbiguous) {
289
292
  const sample = (m.candidates ?? []).slice(0, 5);
@@ -291,14 +294,8 @@ function formatAmbiguous(
291
294
  (m.candidates?.length ?? 0) > sample.length
292
295
  ? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
293
296
  : "";
294
- const lines = sample
295
- .map((line) => {
296
- const content = clipLine(fileLines[line - 1] ?? "");
297
- pushRow(line);
298
- return ` ${line}: ${fileHashes[line - 1]}│${content}`;
299
- })
300
- .join("\n");
301
- out.push(` Hash "${m.ref.hash}" matches lines ${sample.join(", ")}${more}.\n${lines}`);
297
+ for (const line of sample) pushRow(line);
298
+ out.push(` Hash "${m.ref.hash}" matches lines ${sample.join(", ")}${more}.`);
302
299
  }
303
300
  }
304
301
  function buildHashIndex(fileHashes: string[]): Map<string, number[]> {
@@ -311,6 +308,12 @@ function buildHashIndex(fileHashes: string[]): Map<string, number[]> {
311
308
  }
312
309
  return hashIndex;
313
310
  }
311
+ /**
312
+ * SAFETY: the rejection summary plus the rows to serve. The message is facts-only: it names the
313
+ * distinct failing anchors and, for ambiguous ones, the candidate line numbers. Row content travels
314
+ * only in `servedRows` so a caller renders one evidence block; a message that also echoes the rows
315
+ * would make the retry read the same lines twice.
316
+ */
314
317
  export function fmtMismatchWithServes(
315
318
  mismatches: HMismatch[],
316
319
  snapshot: FileSnapshotContext,
@@ -330,8 +333,8 @@ export function fmtMismatchWithServes(
330
333
  };
331
334
  const notFound = mismatches.filter((m) => m.kind === "not_found");
332
335
  const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
333
- formatNotFound(notFound, fileLines, fileHashes, filePath, pushRow, out);
334
- formatAmbiguous(ambiguous, fileLines, fileHashes, filePath, pushRow, out);
336
+ formatNotFound(notFound, fileLines, filePath, pushRow, out);
337
+ formatAmbiguous(ambiguous, filePath, pushRow, out);
335
338
 
336
339
  return { message: out.join("\n"), servedRows };
337
340
  }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Host-neutral utilities the hashline core needs from the historical pi runtime.
3
+ *
4
+ * WHY: the dsh plugin ships without the pi agent package. Three core modules used
5
+ * to import small utility functions from `@earendil-works/pi-coding-agent`:
6
+ * `src/file-content/preview.ts` and `src/file-content/served-header.ts` use
7
+ * `formatSize` / `truncateHead` / `DEFAULT_MAX_LINES` / `DEFAULT_MAX_BYTES`
8
+ * to render read pages, and `src/mutation-queue.ts` uses
9
+ * `withFileMutationQueue` to serialize same-file mutations for the cut
10
+ * transaction and its correlated undo. This module owns that surface as plain
11
+ * host-neutral code; `dsh/host-compat.ts` re-exports it for the dsh bundle.
12
+ *
13
+ * @module dsh-hashline-edit/host-utils
14
+ */
15
+ import { realpath } from "node:fs/promises";
16
+ import { resolve } from "node:path";
17
+
18
+ /** Retained line budget, matching pi's `DEFAULT_MAX_LINES`. */
19
+ export const DEFAULT_MAX_LINES = 2000;
20
+
21
+ /** Retained byte budget, matching pi's `DEFAULT_MAX_BYTES` (50KB). */
22
+ export const DEFAULT_MAX_BYTES = 50 * 1024;
23
+
24
+ /** The truncation report the core's read page renders. */
25
+ export interface TruncationResult {
26
+ /** The truncated content. */
27
+ content: string;
28
+ /** Whether truncation occurred. */
29
+ truncated: boolean;
30
+ /** Which limit was hit: `"lines"`, `"bytes"`, or `null` when nothing was cut. */
31
+ truncatedBy: "lines" | "bytes" | null;
32
+ /** Total number of lines in the original content. */
33
+ totalLines: number;
34
+ /** Total number of bytes in the original content. */
35
+ totalBytes: number;
36
+ /** Number of complete lines in the truncated output. */
37
+ outputLines: number;
38
+ /** Number of bytes in the truncated output. */
39
+ outputBytes: number;
40
+ /** Whether the last line was partially truncated (tail truncation only). */
41
+ lastLinePartial: boolean;
42
+ /** Whether the first line exceeded the byte limit (head truncation only). */
43
+ firstLineExceedsLimit: boolean;
44
+ /** The line limit that was applied. */
45
+ maxLines: number;
46
+ /** The byte limit that was applied. */
47
+ maxBytes: number;
48
+ }
49
+
50
+ /** The two independent limits a truncation call applies. */
51
+ export interface TruncationOptions {
52
+ /** Maximum number of lines; defaults to {@link DEFAULT_MAX_LINES}. */
53
+ maxLines?: number;
54
+ /** Maximum number of bytes; defaults to {@link DEFAULT_MAX_BYTES}. */
55
+ maxBytes?: number;
56
+ }
57
+
58
+ /** Split content into lines for counting, dropping the empty tail of a trailing newline. */
59
+ function splitLinesForCounting(content: string): string[] {
60
+ if (content.length === 0) return [];
61
+ const lines = content.split("\n");
62
+ if (content.endsWith("\n")) lines.pop();
63
+ return lines;
64
+ }
65
+
66
+ /**
67
+ * Format bytes as a human-readable size (`512B`, `1.5KB`, `2.0MB`).
68
+ * @param bytes - the byte count to render.
69
+ * @returns the pi-compatible size label.
70
+ */
71
+ export function formatSize(bytes: number): string {
72
+ if (bytes < 1024) return `${bytes}B`;
73
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
74
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
75
+ }
76
+
77
+ /**
78
+ * Truncate content from the head (keep the first N lines / bytes).
79
+ *
80
+ * WHY: the read page's oversize and truncation warnings must agree with the pi
81
+ * implementation the rest of the contract was written against, so this is a
82
+ * faithful port including its two edge rules: complete lines only, and an
83
+ * over-long first line yields empty content with `firstLineExceedsLimit`.
84
+ * @param content - the text to truncate.
85
+ * @param options - the line and byte limits.
86
+ * @returns the truncation report.
87
+ */
88
+ export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
89
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
90
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
91
+ const totalBytes = Buffer.byteLength(content, "utf-8");
92
+ const lines = splitLinesForCounting(content);
93
+ const totalLines = lines.length;
94
+
95
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
96
+ return {
97
+ content,
98
+ truncated: false,
99
+ truncatedBy: null,
100
+ totalLines,
101
+ totalBytes,
102
+ outputLines: totalLines,
103
+ outputBytes: totalBytes,
104
+ lastLinePartial: false,
105
+ firstLineExceedsLimit: false,
106
+ maxLines,
107
+ maxBytes,
108
+ };
109
+ }
110
+
111
+ const firstLineBytes = Buffer.byteLength(lines[0] ?? "", "utf-8");
112
+ if (firstLineBytes > maxBytes) {
113
+ return {
114
+ content: "",
115
+ truncated: true,
116
+ truncatedBy: "bytes",
117
+ totalLines,
118
+ totalBytes,
119
+ outputLines: 0,
120
+ outputBytes: 0,
121
+ lastLinePartial: false,
122
+ firstLineExceedsLimit: true,
123
+ maxLines,
124
+ maxBytes,
125
+ };
126
+ }
127
+
128
+ const outputLinesArr: string[] = [];
129
+ let outputBytesCount = 0;
130
+ let truncatedBy: "lines" | "bytes" = "lines";
131
+ for (let index = 0; index < lines.length && index < maxLines; index += 1) {
132
+ const line = lines[index] ?? "";
133
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (index > 0 ? 1 : 0);
134
+ if (outputBytesCount + lineBytes > maxBytes) {
135
+ truncatedBy = "bytes";
136
+ break;
137
+ }
138
+ outputLinesArr.push(line);
139
+ outputBytesCount += lineBytes;
140
+ }
141
+ if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
142
+ truncatedBy = "lines";
143
+ }
144
+
145
+ const outputContent = outputLinesArr.join("\n");
146
+ return {
147
+ content: outputContent,
148
+ truncated: true,
149
+ truncatedBy,
150
+ totalLines,
151
+ totalBytes,
152
+ outputLines: outputLinesArr.length,
153
+ outputBytes: Buffer.byteLength(outputContent, "utf-8"),
154
+ lastLinePartial: false,
155
+ firstLineExceedsLimit: false,
156
+ maxLines,
157
+ maxBytes,
158
+ };
159
+ }
160
+
161
+ /** The per-path queue tails; an entry exists only while a mutation holds or awaits it. */
162
+ const fileMutationQueues = new Map<string, Promise<void>>();
163
+
164
+ /** WHY: queue registration itself must be serialized so two callers cannot adopt the same tail. */
165
+ let registrationQueue: Promise<void> = Promise.resolve();
166
+
167
+ /** Whether a realpath failure means "no such path" (the queue then keys by the resolved path). */
168
+ function isMissingPathError(error: unknown): boolean {
169
+ return (
170
+ typeof error === "object" &&
171
+ error !== null &&
172
+ "code" in error &&
173
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
174
+ );
175
+ }
176
+
177
+ /** Key a mutation by the canonical path, falling back to the resolved path for missing files. */
178
+ async function getMutationQueueKey(filePath: string): Promise<string> {
179
+ const resolvedPath = resolve(filePath);
180
+ try {
181
+ return await realpath(resolvedPath);
182
+ } catch (error) {
183
+ if (isMissingPathError(error)) return resolvedPath;
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Serialize file mutation operations that target the same file; different files still run
190
+ * in parallel. A faithful port of pi's queue so the core's cut transaction keeps its
191
+ * lock ordering guarantees under the dsh host.
192
+ * @param filePath - the file the mutation targets.
193
+ * @param run - the critical section to run once the path is free.
194
+ * @returns the critical section's result.
195
+ */
196
+ export async function withFileMutationQueue<T>(
197
+ filePath: string,
198
+ run: () => Promise<T>,
199
+ ): Promise<T> {
200
+ const registration = registrationQueue.then(async () => {
201
+ const key = await getMutationQueueKey(filePath);
202
+ const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
203
+ let releaseNext: () => void = () => {};
204
+ const nextQueue = new Promise<void>((resolveQueue) => {
205
+ releaseNext = resolveQueue;
206
+ });
207
+ const chainedQueue = currentQueue.then(() => nextQueue);
208
+ fileMutationQueues.set(key, chainedQueue);
209
+ return { key, currentQueue, chainedQueue, releaseNext };
210
+ });
211
+ registrationQueue = registration.then(
212
+ () => undefined,
213
+ () => undefined,
214
+ );
215
+
216
+ const { key, currentQueue, chainedQueue, releaseNext } = await registration;
217
+ await currentQueue;
218
+ try {
219
+ return await run();
220
+ } finally {
221
+ releaseNext();
222
+ if (fileMutationQueues.get(key) === chainedQueue) fileMutationQueues.delete(key);
223
+ }
224
+ }
@@ -101,6 +101,12 @@ export interface BaselineSpanContext {
101
101
  sessionKey: string;
102
102
  absolutePath: string;
103
103
  isPreview: boolean;
104
+ /**
105
+ * WHY: the CALLER's own item numbers, parallel to `edits` — a per-file plan keeps its items'
106
+ * WHY: global positions, so an aggregated refusal names the item the model sent instead of this
107
+ * WHY: plan's local index. Absent means `edits` IS the whole call, so local index = caller number.
108
+ */
109
+ itemNumbers?: readonly number[];
104
110
  path: string;
105
111
  originalHashes: string[];
106
112
  originalNormalized: string;
@@ -224,14 +230,21 @@ export async function assertBatchSpansDisjoint(
224
230
  failures.push({ error, index });
225
231
  }
226
232
  }
233
+ const numberOf = (local: number): number => ctx.itemNumbers?.[local] ?? local;
227
234
  // WHY: anchor failures mask overlap reporting — without valid anchors there are no coordinates
228
235
  // WHY: to compare, so validation rejections throw before the overlap scan below.
229
236
  if (failures.length === 1) {
230
237
  const only = failures[0]!;
231
- throw batchAbortFor({ error: only.error, index: only.index, path: ctx.path });
238
+ throw batchAbortFor({ error: only.error, index: numberOf(only.index), path: ctx.path });
232
239
  }
233
240
  if (failures.length > 1) {
234
- throw batchAbortForMany({ failures, path: ctx.path });
241
+ throw batchAbortForMany({
242
+ failures: failures.map((failure) => ({
243
+ error: failure.error,
244
+ index: numberOf(failure.index),
245
+ })),
246
+ path: ctx.path,
247
+ });
235
248
  }
236
249
  for (let i = 0; i < spans.length; i++) {
237
250
  for (let j = i + 1; j < spans.length; j++) {
@@ -250,8 +263,8 @@ export async function assertBatchSpansDisjoint(
250
263
  const originalLines = splitLines(ctx.originalNormalized);
251
264
  const rows = serveRowsForEdit(edits[b.index]!, ctx.originalHashes);
252
265
  throw new DomainError("E_BATCH_ABORT", {
253
- earlierIndex: a.index,
254
- laterIndex: b.index,
266
+ earlierIndex: numberOf(a.index),
267
+ laterIndex: numberOf(b.index),
255
268
  earlierStart: a.startLine,
256
269
  earlierEnd: a.endLine,
257
270
  laterStart: b.startLine,