@bigknoxy/hashpilot 4.6.3

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +777 -0
  3. package/docs/ADAPTER-CONTRACT.md +1260 -0
  4. package/docs/ARCHITECTURE.md +846 -0
  5. package/docs/CLI-QUICKREF.md +827 -0
  6. package/docs/COMPETITIVE-ANALYSIS.md +307 -0
  7. package/docs/INSTALL.md +403 -0
  8. package/docs/INTEGRATION-CLAUDE.md +126 -0
  9. package/docs/INTEGRATION-MCP.md +196 -0
  10. package/docs/INTEGRATION-OPENCODE.md +136 -0
  11. package/docs/INTEGRATION-PI.md +195 -0
  12. package/package.json +77 -0
  13. package/scripts/build-site.sh +39 -0
  14. package/scripts/doctor.sh +218 -0
  15. package/scripts/gen-cli-quickref.ts +232 -0
  16. package/scripts/install-cli.sh +60 -0
  17. package/scripts/install.sh +466 -0
  18. package/scripts/roadmap-lint.ts +200 -0
  19. package/scripts/uninstall.sh +202 -0
  20. package/src/cli-node.cjs +51 -0
  21. package/src/cli.ts +209 -0
  22. package/src/commands/ast.ts +255 -0
  23. package/src/commands/diff.ts +98 -0
  24. package/src/commands/edit.ts +93 -0
  25. package/src/commands/hash.ts +64 -0
  26. package/src/commands/intent.ts +68 -0
  27. package/src/commands/maintenance.ts +191 -0
  28. package/src/commands/mcp.ts +28 -0
  29. package/src/commands/provenance.ts +111 -0
  30. package/src/commands/read.ts +117 -0
  31. package/src/commands/route.ts +42 -0
  32. package/src/commands/shared.ts +65 -0
  33. package/src/commands/telemetry.ts +126 -0
  34. package/src/commands/verify.ts +61 -0
  35. package/src/core/ast-edit.ts +2357 -0
  36. package/src/core/batch-edit.ts +185 -0
  37. package/src/core/config.ts +189 -0
  38. package/src/core/diff-engine.ts +474 -0
  39. package/src/core/doctor.ts +303 -0
  40. package/src/core/encoding.ts +116 -0
  41. package/src/core/envelope.ts +163 -0
  42. package/src/core/exit-codes.ts +198 -0
  43. package/src/core/format.ts +339 -0
  44. package/src/core/grep.ts +180 -0
  45. package/src/core/hash-edit.ts +416 -0
  46. package/src/core/index.ts +155 -0
  47. package/src/core/intent.ts +584 -0
  48. package/src/core/locking.ts +292 -0
  49. package/src/core/module-system.ts +142 -0
  50. package/src/core/operations.ts +557 -0
  51. package/src/core/output.ts +122 -0
  52. package/src/core/path-normalize.ts +61 -0
  53. package/src/core/paths.ts +326 -0
  54. package/src/core/plan-executor.ts +437 -0
  55. package/src/core/platform.ts +132 -0
  56. package/src/core/provenance.ts +214 -0
  57. package/src/core/read.ts +111 -0
  58. package/src/core/redact.ts +98 -0
  59. package/src/core/resolve-content.ts +12 -0
  60. package/src/core/router.ts +463 -0
  61. package/src/core/snapshot.ts +346 -0
  62. package/src/core/telemetry.ts +838 -0
  63. package/src/core/utils.ts +7 -0
  64. package/src/core/verify-baseline.ts +186 -0
  65. package/src/core/verify-scope.ts +282 -0
  66. package/src/core/verify.ts +753 -0
  67. package/src/mcp/server.ts +325 -0
  68. package/templates/claude-section.md +12 -0
  69. package/templates/opencode-agent.md +106 -0
  70. package/templates/opencode-skill.md +241 -0
  71. package/templates/pi-extension.ts +288 -0
  72. package/templates/pi-skill.md +123 -0
  73. package/tsconfig.json +19 -0
@@ -0,0 +1,180 @@
1
+ import { spawn } from "child_process";
2
+ import { glob as globSync } from "glob";
3
+ import { escapeRegex } from "./utils";
4
+
5
+ export interface GrepResult {
6
+ path: string;
7
+ line: number;
8
+ column: number;
9
+ content: string;
10
+ match: string;
11
+ }
12
+
13
+ export interface GrepManyResult {
14
+ pattern: string;
15
+ results: GrepResult[];
16
+ error?: string;
17
+ elapsed_ms: number;
18
+ }
19
+
20
+ export async function grepMany(
21
+ pattern: string,
22
+ paths: string[],
23
+ options: {
24
+ ignoreCase?: boolean;
25
+ filePattern?: string;
26
+ maxResults?: number;
27
+ wordMatch?: boolean;
28
+ } = {}
29
+ ): Promise<GrepManyResult> {
30
+ const start = Date.now();
31
+ try {
32
+ // `-H` forces the filename prefix even for a single file argument, so every
33
+ // output line has the same shape and the parser never has to guess (#105).
34
+ const args: string[] = ["-rnH"];
35
+ if (options.ignoreCase) args.push("-i");
36
+ if (options.wordMatch) args.push("-w");
37
+ if (options.filePattern) args.push("--include", options.filePattern);
38
+ if (options.maxResults) args.push("-m", String(options.maxResults));
39
+ args.push("-E", pattern, ...paths);
40
+
41
+ const result = await runCommand("grep", args);
42
+ const lines = result.stdout.split("\n").filter(Boolean);
43
+ const results: GrepResult[] = lines.flatMap((line) => {
44
+ const parsed = parseGrepLine(line, paths);
45
+ if (!parsed) return [];
46
+ return [{ ...parsed, column: columnOf(parsed.content, pattern, options), match: pattern }];
47
+ });
48
+
49
+ return { pattern, results, elapsed_ms: Date.now() - start };
50
+ } catch (e: any) {
51
+ if (e?.code === 1 && !e.stderr) {
52
+ return { pattern, results: [], elapsed_ms: Date.now() - start };
53
+ }
54
+ return {
55
+ pattern,
56
+ results: [],
57
+ error: e.message,
58
+ elapsed_ms: Date.now() - start,
59
+ };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Split one `path:line:content` output line.
65
+ *
66
+ * The previous parser tried `file:line:column:text` first, which GNU grep never
67
+ * emits — it has no column-output mode — so the only lines that reached that
68
+ * branch were ones whose *content* began with digits and a colon, and those got
69
+ * their prefix eaten and a fabricated `column` (#105). Parsing is now anchored
70
+ * on the search roots: the longest root the line starts with is stripped first,
71
+ * so a root containing a colon parses correctly, and everything after the line
72
+ * number is content, verbatim.
73
+ */
74
+ function parseGrepLine(
75
+ line: string,
76
+ roots: string[]
77
+ ): { path: string; line: number; content: string } | null {
78
+ const byLength = [...roots].sort((a, b) => b.length - a.length);
79
+ for (const root of byLength) {
80
+ if (!line.startsWith(root)) continue;
81
+ const rest = line.slice(root.length);
82
+ // Either `:12:text` (the root was the file) or `/sub/f.ts:12:text` (the root
83
+ // was a directory grep recursed into).
84
+ const m = rest.match(/^(.*?):(\d+):([\s\S]*)$/);
85
+ if (m) return { path: root + m[1], line: parseInt(m[2], 10), content: m[3] };
86
+ }
87
+ // A root we cannot attribute the line to (a symlinked root, say). Fall back to
88
+ // the shortest plausible path prefix rather than dropping the match.
89
+ const m = line.match(/^(.*?):(\d+):([\s\S]*)$/);
90
+ return m ? { path: m[1], line: parseInt(m[2], 10), content: m[3] } : null;
91
+ }
92
+
93
+ /**
94
+ * 1-indexed column of the match within the line.
95
+ *
96
+ * This used to be hardcoded to 1, so an agent building a `file:line:col` jump
97
+ * from it always landed at the start of the line while the field claimed
98
+ * otherwise. It is recomputed in process because grep does not report it.
99
+ */
100
+ function columnOf(content: string, pattern: string, options: { ignoreCase?: boolean; wordMatch?: boolean }): number {
101
+ try {
102
+ const source = options.wordMatch ? `\\b(?:${pattern})\\b` : pattern;
103
+ const re = new RegExp(source, options.ignoreCase ? "i" : "");
104
+ const m = re.exec(content);
105
+ if (m) return m.index + 1;
106
+ } catch {
107
+ // A POSIX ERE grep accepts but JS does not. Column 1 is the honest floor.
108
+ }
109
+ return 1;
110
+ }
111
+
112
+ export interface SymbolLookupResult {
113
+ name: string;
114
+ path: string;
115
+ line: number;
116
+ kind: string;
117
+ }
118
+
119
+ export async function symbolLookupMany(
120
+ names: string[],
121
+ paths: string[]
122
+ ): Promise<SymbolLookupResult[]> {
123
+ const results: SymbolLookupResult[] = [];
124
+ for (const name of names) {
125
+ const grepRes = await grepMany(
126
+ `\\b(function|class|interface|type|const|let|var|export)\\s+${escapeRegex(name)}\\b`,
127
+ paths,
128
+ { maxResults: 20 }
129
+ );
130
+ for (const r of grepRes.results) {
131
+ results.push({
132
+ name,
133
+ path: r.path,
134
+ line: r.line,
135
+ kind: detectSymbolKind(r.content, name),
136
+ });
137
+ }
138
+ }
139
+ return results;
140
+ }
141
+
142
+ function detectSymbolKind(content: string, _name: string): string {
143
+ const trimmed = content.trim();
144
+ // Strip leading "export " to unify exported and non-exported declarations
145
+ const stripped = trimmed.startsWith("export ") ? trimmed.slice(7) : trimmed;
146
+ if (stripped.startsWith("function ")) return "function";
147
+ if (stripped.startsWith("class ")) return "class";
148
+ if (stripped.startsWith("interface ")) return "interface";
149
+ if (stripped.startsWith("type ")) return "type";
150
+ if (stripped.startsWith("const ")) return "const";
151
+ if (stripped.startsWith("let ")) return "let";
152
+ if (stripped.startsWith("var ")) return "var";
153
+ return "unknown";
154
+ }
155
+
156
+ function runCommand(
157
+ cmd: string,
158
+ args: string[]
159
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
160
+ return new Promise((resolve, reject) => {
161
+ const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
162
+ let stdout = "";
163
+ let stderr = "";
164
+ proc.stdout.on("data", (d) => (stdout += d));
165
+ proc.stderr.on("data", (d) => (stderr += d));
166
+ proc.on("close", (code) => {
167
+ if (code === 1 && !stderr) {
168
+ resolve({ stdout, stderr, code });
169
+ } else if (code !== 0) {
170
+ const err: any = new Error(`Command failed: ${cmd} ${args.join(" ")}`);
171
+ err.code = code;
172
+ err.stderr = stderr;
173
+ reject(err);
174
+ } else {
175
+ resolve({ stdout, stderr, code });
176
+ }
177
+ });
178
+ proc.on("error", reject);
179
+ });
180
+ }
@@ -0,0 +1,416 @@
1
+ import { computeHash } from "./read";
2
+ import { ErrorCode } from "./telemetry";
3
+ import { addWarning } from "./envelope";
4
+ import { assertWritable, atomicWrite, PathDeniedError, type AssertWritableOptions } from "./paths";
5
+ import { recordSnapshot } from "./snapshot";
6
+ import { firstParseError } from "./ast-edit";
7
+ import { readDecoded } from "./encoding";
8
+
9
+ /**
10
+ * What to do when the anchor hash no longer matches the content at the given range.
11
+ *
12
+ * - `relocate` (default): search the file for a window whose hash equals `oldHash`.
13
+ * Exactly one match relocates the edit; zero or several is a hard failure.
14
+ * - `off`: any mismatch fails immediately.
15
+ *
16
+ * Neither mode ever applies the edit to content the caller did not anchor to.
17
+ */
18
+ export type RecoveryMode = "relocate" | "off";
19
+
20
+ export interface ReplaceHashOptions {
21
+ range?: { start: number; end: number };
22
+ dryRun?: boolean;
23
+ contextLines?: number;
24
+ /** Stale-anchor policy. Defaults to `"relocate"`. */
25
+ recovery?: RecoveryMode;
26
+ /** @deprecated Use `recovery: "off"`. Retained for source compatibility. */
27
+ noRecovery?: boolean;
28
+ /** Write-boundary overrides forwarded to `assertWritable`. */
29
+ pathOptions?: AssertWritableOptions;
30
+ /**
31
+ * Skip the post-edit parse check. Off by default; the CLI sets it from
32
+ * `--allow-parse-errors` only for files that already fail to parse.
33
+ */
34
+ skipParseCheck?: boolean;
35
+ }
36
+
37
+ export interface ReplaceHashResult {
38
+ path: string;
39
+ success: boolean;
40
+ oldHash: string;
41
+ /**
42
+ * Hash of the content that was just written — the *range*, not the file, so a
43
+ * caller can chain it straight into the next `oldHash` for the same region
44
+ * without re-reading. On a whole-file edit the range is the file, so the two
45
+ * coincide. The failure paths already reported the range hash; the success
46
+ * path used to report the whole-file hash, so the one field meant two
47
+ * incompatible things and chaining it always went STALE (#101).
48
+ */
49
+ newHash: string;
50
+ /** Hash of the whole file after the edit. Absent on failure paths. */
51
+ fileHash?: string;
52
+ /**
53
+ * Where the written content now lives, 1-indexed and inclusive of `start`,
54
+ * exclusive-of-nothing (`end` is the last line). Pass it back as `range`
55
+ * alongside `newHash`: a replacement with a different line count moves the
56
+ * region, so the old range no longer describes it (#101).
57
+ */
58
+ newRange?: { start: number; end: number };
59
+ linesChanged: number;
60
+ stale: boolean;
61
+ message: string;
62
+ diff?: string;
63
+ /** Number of auto-retries performed (1 if recovered from stale anchor, 0 otherwise) */
64
+ retries?: number;
65
+ /** Machine-readable failure cause. Absent on success. */
66
+ errorCode?: ErrorCode;
67
+ /** What the caller should do next when `success` is false. */
68
+ recovery?: string;
69
+ /** Range the anchor was relocated to, when relocation succeeded. */
70
+ relocatedTo?: { start: number; end: number };
71
+ }
72
+
73
+ /**
74
+ * Slide a window of `windowSize` lines across the file, collecting every start
75
+ * index whose content hashes to `oldHash`. Stops after two hits — one is enough
76
+ * to relocate, two is enough to know it is ambiguous.
77
+ */
78
+ function findAnchorCandidates(lines: string[], windowSize: number, oldHash: string): number[] {
79
+ const hits: number[] = [];
80
+ if (windowSize <= 0 || windowSize > lines.length) return hits;
81
+ for (let start = 0; start + windowSize <= lines.length; start++) {
82
+ if (computeHash(lines.slice(start, start + windowSize).join("\n")) === oldHash) {
83
+ hits.push(start);
84
+ if (hits.length > 1) break;
85
+ }
86
+ }
87
+ return hits;
88
+ }
89
+
90
+ export async function replaceHash(
91
+ filePath: string,
92
+ oldHash: string,
93
+ newContent: string,
94
+ options: ReplaceHashOptions = {}
95
+ ): Promise<ReplaceHashResult> {
96
+ const { range, dryRun = false } = options;
97
+ // `noRecovery: true` is the legacy spelling of `recovery: "off"`.
98
+ const recoveryMode: RecoveryMode = options.recovery ?? (options.noRecovery ? "off" : "relocate");
99
+
100
+ const fail = (
101
+ message: string,
102
+ errorCode: ErrorCode,
103
+ recovery: string,
104
+ extra: Partial<ReplaceHashResult> = {},
105
+ ): ReplaceHashResult => ({
106
+ path: filePath,
107
+ success: false,
108
+ oldHash,
109
+ newHash: "",
110
+ linesChanged: 0,
111
+ stale: false,
112
+ retries: 0,
113
+ message,
114
+ errorCode,
115
+ recovery,
116
+ ...extra,
117
+ });
118
+
119
+ let content: string;
120
+ try {
121
+ content = (await readDecoded(filePath)).text;
122
+ } catch (e: any) {
123
+ return fail(
124
+ `Failed to read file: ${e.message}`,
125
+ ErrorCode.FILE_NOT_FOUND,
126
+ "Check that the path exists and is readable.",
127
+ );
128
+ }
129
+
130
+ const lines = content.split("\n");
131
+
132
+ // Defensive range validation. The CLI validates too, but replaceHash is a
133
+ // public API: a NaN or inverted range must never reach the slice arithmetic,
134
+ // where `slice(x, NaN)` yields an empty window and `slice(NaN)` re-appends
135
+ // the whole file.
136
+ if (range) {
137
+ const { start, end } = range;
138
+ if (!Number.isInteger(start) || !Number.isInteger(end)) {
139
+ return fail(
140
+ `Invalid range: start and end must be integers (got ${start}:${end}).`,
141
+ ErrorCode.INVALID_ARGUMENT,
142
+ "Pass --range as N or N:M with positive integers.",
143
+ );
144
+ }
145
+ if (start < 1 || end < 1) {
146
+ return fail(
147
+ `Invalid range ${start}:${end}: line numbers are 1-indexed.`,
148
+ ErrorCode.INVALID_ARGUMENT,
149
+ "Use a start and end of 1 or greater.",
150
+ );
151
+ }
152
+ if (start > end) {
153
+ return fail(
154
+ `Invalid range ${start}:${end}: start is after end.`,
155
+ ErrorCode.INVALID_ARGUMENT,
156
+ "Swap the bounds so start <= end.",
157
+ );
158
+ }
159
+ if (end > lines.length) {
160
+ return fail(
161
+ `Invalid range ${start}:${end}: file has only ${lines.length} lines.`,
162
+ ErrorCode.INVALID_ARGUMENT,
163
+ `Use an end of at most ${lines.length}.`,
164
+ );
165
+ }
166
+ }
167
+
168
+ let targetStart: number;
169
+ let targetEnd: number;
170
+
171
+ if (range) {
172
+ targetStart = range.start - 1;
173
+ targetEnd = range.end;
174
+ } else {
175
+ targetStart = 0;
176
+ targetEnd = lines.length;
177
+ }
178
+
179
+ let targetLines = lines.slice(targetStart, targetEnd);
180
+ let targetText = targetLines.join("\n");
181
+ const currentHash = computeHash(targetText);
182
+ let stale = false;
183
+ let retries = 0;
184
+ let messageSuffix = "";
185
+ let relocatedTo: { start: number; end: number } | undefined;
186
+
187
+ if (currentHash !== oldHash) {
188
+ // A whole-file anchor has nothing to relocate to — the anchor *is* the
189
+ // file, so a mismatch means the caller's view of the file is stale. There
190
+ // is no safe interpretation; refuse.
191
+ const relocatable = recoveryMode === "relocate" && range !== undefined;
192
+
193
+ if (!relocatable) {
194
+ return fail(
195
+ buildStaleMessage(oldHash, currentHash, targetStart + 1, targetEnd),
196
+ ErrorCode.STALE_ANCHOR,
197
+ "Re-read the file to obtain a current hash, then retry.",
198
+ { newHash: currentHash, stale: true },
199
+ );
200
+ }
201
+
202
+ const candidates = findAnchorCandidates(lines, targetEnd - targetStart, oldHash);
203
+
204
+ if (candidates.length === 0) {
205
+ return fail(
206
+ buildStaleMessage(oldHash, currentHash, targetStart + 1, targetEnd) +
207
+ `\n The anchored content was not found anywhere else in the file.`,
208
+ ErrorCode.STALE_ANCHOR,
209
+ "Re-read the file to obtain a current hash, then retry.",
210
+ { newHash: currentHash, stale: true },
211
+ );
212
+ }
213
+ if (candidates.length > 1) {
214
+ return fail(
215
+ `AMBIGUOUS ANCHOR: content matching hash ${oldHash} appears at more than one location in ${filePath}.`,
216
+ ErrorCode.AMBIGUOUS_ANCHOR,
217
+ "Widen the range so the anchored content is unique, then retry.",
218
+ { newHash: currentHash, stale: true },
219
+ );
220
+ }
221
+
222
+ // Exactly one match: the content moved. Re-anchor onto it.
223
+ const windowSize = targetEnd - targetStart;
224
+ targetStart = candidates[0]!;
225
+ targetEnd = targetStart + windowSize;
226
+ targetLines = lines.slice(targetStart, targetEnd);
227
+ targetText = targetLines.join("\n");
228
+ stale = true;
229
+ retries = 1;
230
+ relocatedTo = { start: targetStart + 1, end: targetEnd };
231
+ messageSuffix = ` (anchor relocated to lines ${relocatedTo.start}-${relocatedTo.end})`;
232
+ // The edit succeeds, but it did not land where the caller pointed. Say so.
233
+ addWarning({
234
+ code: "ANCHOR_RELOCATED",
235
+ message: `Anchor content moved; edit applied at lines ${relocatedTo.start}-${relocatedTo.end}.`,
236
+ relocatedTo,
237
+ });
238
+ }
239
+
240
+ let writePath: string | undefined;
241
+ if (!dryRun) {
242
+ try {
243
+ writePath = assertWritable(filePath, options.pathOptions);
244
+ } catch (e) {
245
+ if (e instanceof PathDeniedError) {
246
+ return fail(e.message, ErrorCode.PATH_DENIED, "Pass --allow-outside-root or choose a path inside the project root.");
247
+ }
248
+ throw e;
249
+ }
250
+ }
251
+
252
+ return applyReplacement(
253
+ filePath, lines, targetStart, targetEnd, targetLines, targetText,
254
+ newContent, oldHash, dryRun, stale, retries, messageSuffix, relocatedTo, writePath,
255
+ options.skipParseCheck === true,
256
+ );
257
+ }
258
+
259
+ async function applyReplacement(
260
+ filePath: string,
261
+ lines: string[],
262
+ targetStart: number,
263
+ targetEnd: number,
264
+ targetLines: string[],
265
+ targetText: string,
266
+ newContent: string,
267
+ oldHash: string,
268
+ dryRun: boolean,
269
+ stale: boolean,
270
+ retries: number,
271
+ messageSuffix: string = "",
272
+ relocatedTo?: { start: number; end: number },
273
+ /** Symlink-resolved destination returned by assertWritable. Falls back to filePath on dry runs. */
274
+ writePath?: string,
275
+ skipParseCheck: boolean = false,
276
+ ): Promise<ReplaceHashResult> {
277
+ const newContentLines = newContent.split("\n");
278
+ if (newContentLines[newContentLines.length - 1] === "" && !targetText.endsWith("\n")) {
279
+ newContentLines.pop();
280
+ }
281
+
282
+ const newLines = [
283
+ ...lines.slice(0, targetStart),
284
+ ...newContentLines,
285
+ ...lines.slice(targetEnd),
286
+ ];
287
+ const newFullContent = newLines.join("\n");
288
+ const newFullHash = computeHash(newFullContent);
289
+ // What actually landed in the file, hashed the same way `oldHash` was, so it
290
+ // round-trips into the next call (#101).
291
+ const newRangeText = newContentLines.join("\n");
292
+ const newRangeHash = computeHash(newRangeText);
293
+ const diff = buildDiff(targetStart + 1, targetLines, newContentLines);
294
+ const linesChanged = Math.abs(newContentLines.length - targetLines.length) + countChangedLines(targetLines, newContentLines);
295
+ const rangeLabel = `range ${targetStart + 1}-${targetEnd}`;
296
+
297
+ // A hash edit is content-blind: it will happily splice half a function into
298
+ // the middle of another one. When a parser exists for this language, refuse
299
+ // the write if the result does not parse and the original did (#13).
300
+ if (!skipParseCheck) {
301
+ const after = firstParseError(newFullContent, filePath);
302
+ if (after) {
303
+ const lines0 = lines.join("\n");
304
+ const before = firstParseError(lines0, filePath);
305
+ if (!before) {
306
+ return {
307
+ path: filePath,
308
+ success: false,
309
+ oldHash,
310
+ newHash: "",
311
+ linesChanged: 0,
312
+ stale: false,
313
+ retries,
314
+ errorCode: ErrorCode.PARSE_ERROR,
315
+ message:
316
+ `Edit was discarded: the result does not parse (syntax error at line ${after.line}:${after.column} — ${after.nodeType}). ` +
317
+ `The file parsed cleanly before, so this replacement would have corrupted it.`,
318
+ recovery: `hashpilot read-hash ${filePath} ${after.line} — re-read around the break, or pass --allow-parse-errors to write anyway.`,
319
+ };
320
+ }
321
+ }
322
+ }
323
+
324
+ if (!dryRun) {
325
+ // `writePath` is already boundary-resolved by the caller. Atomic, and
326
+ // snapshotted, for the same reasons every other write is (#12).
327
+ const target = writePath ?? filePath;
328
+ recordSnapshot(target, newFullContent);
329
+ atomicWrite(target, newFullContent);
330
+ }
331
+
332
+ const action = dryRun ? "Dry run: would replace" : "Replaced";
333
+ return {
334
+ path: filePath,
335
+ success: true,
336
+ oldHash,
337
+ newHash: newRangeHash,
338
+ fileHash: newFullHash,
339
+ newRange: { start: targetStart + 1, end: targetStart + newContentLines.length },
340
+ linesChanged,
341
+ stale,
342
+ retries,
343
+ relocatedTo,
344
+ message: dryRun
345
+ ? `${action} ${targetLines.length} lines with ${newContentLines.length} lines${messageSuffix}`
346
+ : `${action} ${targetLines.length} lines with ${newContentLines.length} lines${messageSuffix} (${rangeLabel})`,
347
+ diff,
348
+ };
349
+ }
350
+
351
+ function buildStaleMessage(
352
+ expected: string,
353
+ actual: string,
354
+ start: number,
355
+ end: number
356
+ ): string {
357
+ return (
358
+ `STALE ANCHOR: Content hash mismatch in lines ${start}-${end}.\n` +
359
+ ` Expected hash: ${expected}\n` +
360
+ ` Actual hash: ${actual}\n` +
361
+ ` The file has been modified since the hash was computed.\n` +
362
+ ` Re-read the file and retry with the current hash.`
363
+ );
364
+ }
365
+
366
+ function buildDiff(
367
+ startLine: number,
368
+ oldLines: string[],
369
+ newLines: string[]
370
+ ): string {
371
+ const maxCtx = 3;
372
+ const parts: string[] = [];
373
+ const maxLen = Math.max(oldLines.length, newLines.length);
374
+ let changeStart = -1;
375
+ let changeEnd = -1;
376
+
377
+ for (let i = 0; i < maxLen; i++) {
378
+ const oldL = oldLines[i] ?? "";
379
+ const newL = newLines[i] ?? "";
380
+ if (oldL !== newL) {
381
+ if (changeStart === -1) changeStart = i;
382
+ changeEnd = i;
383
+ }
384
+ }
385
+
386
+ if (changeStart === -1) return "(no changes)";
387
+
388
+ const ctxStart = Math.max(0, changeStart - maxCtx);
389
+ const ctxEnd = Math.min(maxLen - 1, changeEnd + maxCtx);
390
+
391
+ for (let i = ctxStart; i <= ctxEnd; i++) {
392
+ const ln = startLine + i;
393
+ const oldL = oldLines[i];
394
+ const newL = newLines[i];
395
+ if (oldL === undefined && newL !== undefined) {
396
+ parts.push(`+ ${ln} | ${newL}`);
397
+ } else if (newL === undefined && oldL !== undefined) {
398
+ parts.push(`- ${ln} | ${oldL}`);
399
+ } else if (oldL !== newL) {
400
+ parts.push(`- ${ln} | ${oldL}`);
401
+ parts.push(`+ ${ln} | ${newL}`);
402
+ } else {
403
+ parts.push(` ${ln} | ${oldL}`);
404
+ }
405
+ }
406
+ return parts.join("\n");
407
+ }
408
+
409
+ function countChangedLines(oldLines: string[], newLines: string[]): number {
410
+ let count = 0;
411
+ const maxLen = Math.max(oldLines.length, newLines.length);
412
+ for (let i = 0; i < maxLen; i++) {
413
+ if ((oldLines[i] ?? "") !== (newLines[i] ?? "")) count++;
414
+ }
415
+ return count;
416
+ }