@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,474 @@
1
+ import { safeWrite } from "./paths";
2
+ import { readDecoded } from "./encoding";
3
+ export interface Hunk {
4
+ oldStart: number;
5
+ oldLines: number;
6
+ newStart: number;
7
+ newLines: number;
8
+ header: string;
9
+ lines: string[];
10
+ }
11
+
12
+ /**
13
+ * Where a hunk actually landed. A fuzzy match that silently slid is
14
+ * indistinguishable from an exact one unless the offset is reported, and
15
+ * "applied, but not where you asked" is the failure mode fuzzy matching
16
+ * creates (#33).
17
+ */
18
+ export interface HunkPlacement {
19
+ /** 1-indexed line the patch recorded for this hunk, adjusted for prior hunks. */
20
+ expectedAt: number;
21
+ /** 1-indexed line the hunk was applied at. */
22
+ appliedAt: number;
23
+ /** `appliedAt - expectedAt`. Zero when the hunk landed where it said it would. */
24
+ offset: number;
25
+ }
26
+
27
+ export interface PatchResult {
28
+ success: boolean;
29
+ hunksApplied: number;
30
+ hunksFailed: number;
31
+ message: string;
32
+ newSource?: string;
33
+ diff?: string;
34
+ /** One entry per applied hunk, in patch order. */
35
+ placements?: HunkPlacement[];
36
+ /** Placements whose `offset` is non-zero — the ones worth looking at. */
37
+ fuzzyPlacements?: HunkPlacement[];
38
+ }
39
+
40
+ // --- LCS-based diff ---
41
+
42
+ function lcsTable(a: string[], b: string[]): number[][] {
43
+ const m = a.length;
44
+ const n = b.length;
45
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
46
+ for (let i = 1; i <= m; i++) {
47
+ for (let j = 1; j <= n; j++) {
48
+ if (a[i - 1] === b[j - 1]) {
49
+ dp[i][j] = dp[i - 1][j - 1] + 1;
50
+ } else {
51
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
52
+ }
53
+ }
54
+ }
55
+ return dp;
56
+ }
57
+
58
+ function backtrack(a: string[], b: string[], dp: number[][]): DiffOp[] {
59
+ const result: DiffOp[] = [];
60
+ let i = a.length;
61
+ let j = b.length;
62
+ while (i > 0 || j > 0) {
63
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
64
+ result.push({ type: "same", line: a[i - 1] });
65
+ i--;
66
+ j--;
67
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
68
+ result.push({ type: "added", line: b[j - 1] });
69
+ j--;
70
+ } else {
71
+ result.push({ type: "removed", line: a[i - 1] });
72
+ i--;
73
+ }
74
+ }
75
+ result.reverse();
76
+ return result;
77
+ }
78
+
79
+ interface DiffOp {
80
+ type: "same" | "removed" | "added";
81
+ line: string;
82
+ }
83
+
84
+ // --- Hunk grouping ---
85
+
86
+ function groupHunks(ops: DiffOp[], contextLines: number): Hunk[] {
87
+ const hunks: Hunk[] = [];
88
+ const len = ops.length;
89
+
90
+ let i = 0;
91
+ const regions: Array<{ start: number; end: number }> = [];
92
+
93
+ while (i < len) {
94
+ while (i < len && ops[i].type === "same") i++;
95
+ if (i >= len) break;
96
+
97
+ const changeStart = i;
98
+ while (i < len && ops[i].type !== "same") i++;
99
+ const changeEnd = i;
100
+
101
+ const start = Math.max(0, changeStart - contextLines);
102
+ const end = Math.min(len, changeEnd + contextLines);
103
+
104
+ if (regions.length > 0 && start <= regions[regions.length - 1].end) {
105
+ regions[regions.length - 1].end = end;
106
+ } else {
107
+ regions.push({ start, end });
108
+ }
109
+ }
110
+
111
+ for (const region of regions) {
112
+ let oldStart = 1;
113
+ let oldLines = 0;
114
+ let newStart = 1;
115
+ let newLines = 0;
116
+
117
+ for (let k = 0; k < region.start; k++) {
118
+ if (ops[k].type !== "added") oldStart++;
119
+ if (ops[k].type !== "removed") newStart++;
120
+ }
121
+
122
+ const lines: string[] = [];
123
+ let k = region.start;
124
+
125
+ for (; k < region.end; k++) {
126
+ if (ops[k].type === "same") {
127
+ oldLines++;
128
+ newLines++;
129
+ lines.push(` ${ops[k].line}`);
130
+ } else if (ops[k].type === "removed") {
131
+ oldLines++;
132
+ lines.push(`-${ops[k].line}`);
133
+ } else {
134
+ newLines++;
135
+ lines.push(`+${ops[k].line}`);
136
+ }
137
+ }
138
+
139
+ const header = `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`;
140
+ hunks.push({ oldStart, oldLines, newStart, newLines, header, lines });
141
+ }
142
+
143
+ return hunks;
144
+ }
145
+
146
+ // --- Public API ---
147
+
148
+ export function generateUnifiedDiff(
149
+ oldSource: string,
150
+ newSource: string,
151
+ filePath: string,
152
+ contextLines: number = 3
153
+ ): string {
154
+ const oldLines = oldSource.split("\n");
155
+ const newLines = newSource.split("\n");
156
+
157
+ const dp = lcsTable(oldLines, newLines);
158
+ const ops = backtrack(oldLines, newLines, dp);
159
+ const hunks = groupHunks(ops, contextLines);
160
+
161
+ if (hunks.length === 0) return "";
162
+
163
+ const parts: string[] = [];
164
+ parts.push(`--- a/${filePath}`);
165
+ parts.push(`+++ b/${filePath}`);
166
+
167
+ for (const hunk of hunks) {
168
+ parts.push(hunk.header);
169
+ for (const line of hunk.lines) {
170
+ parts.push(line);
171
+ }
172
+ }
173
+
174
+ return parts.join("\n") + "\n";
175
+ }
176
+
177
+ export function parsePatch(patchText: string): { filePath: string; hunks: Hunk[] } {
178
+ const lines = patchText.split("\n");
179
+ const hunks: Hunk[] = [];
180
+ let filePath = "";
181
+
182
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
183
+ lines.pop();
184
+ }
185
+
186
+ let i = 0;
187
+ while (i < lines.length) {
188
+ const line = lines[i];
189
+
190
+ if (line.startsWith("--- a/")) {
191
+ filePath = line.slice(6);
192
+ i++;
193
+ continue;
194
+ }
195
+ if (line.startsWith("--- ") && !line.startsWith("--- a/")) {
196
+ filePath = line.slice(4);
197
+ i++;
198
+ continue;
199
+ }
200
+ if (line.startsWith("+++ ")) {
201
+ i++;
202
+ continue;
203
+ }
204
+
205
+ const hdrMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
206
+ if (hdrMatch) {
207
+ const oldStart = parseInt(hdrMatch[1]);
208
+ const oldLines = hdrMatch[2] !== undefined ? parseInt(hdrMatch[2]) : 1;
209
+ const newStart = parseInt(hdrMatch[3]);
210
+ const newLines = hdrMatch[4] !== undefined ? parseInt(hdrMatch[4]) : 1;
211
+
212
+ // Consume exactly the number of lines the header declares rather than
213
+ // scanning for the next "@@"/"--- " marker. Hunk bodies are prefixed, so a
214
+ // removed line whose content starts with "-- " renders as "--- ..." and a
215
+ // marker scan would mistake file content for the next file header,
216
+ // truncating the hunk (#31).
217
+ const hunkLines: string[] = [];
218
+ let oldSeen = 0;
219
+ let newSeen = 0;
220
+ i++;
221
+ while (i < lines.length && (oldSeen < oldLines || newSeen < newLines)) {
222
+ const hl = lines[i];
223
+ if (hl.startsWith(" ")) {
224
+ oldSeen++;
225
+ newSeen++;
226
+ } else if (hl.startsWith("-")) {
227
+ oldSeen++;
228
+ } else if (hl.startsWith("+")) {
229
+ newSeen++;
230
+ } else if (!hl.startsWith("\\")) {
231
+ break; // malformed body line; stop rather than mis-consume
232
+ }
233
+ hunkLines.push(hl);
234
+ i++;
235
+ }
236
+ // Trailing "" markers belong to this hunk.
237
+ while (i < lines.length && lines[i].startsWith("\\")) {
238
+ hunkLines.push(lines[i]);
239
+ i++;
240
+ }
241
+
242
+ hunks.push({ oldStart, oldLines, newStart, newLines, header: line, lines: hunkLines });
243
+ continue;
244
+ }
245
+
246
+ i++;
247
+ }
248
+
249
+ return { filePath, hunks };
250
+ }
251
+
252
+ export function applyPatchToSource(
253
+ source: string,
254
+ patchText: string,
255
+ options?: { fuzzyMatch?: number }
256
+ ): PatchResult {
257
+ const fuzzy = options?.fuzzyMatch ?? 3;
258
+ const parsed = parsePatch(patchText);
259
+
260
+ if (parsed.hunks.length === 0) {
261
+ return { success: false, hunksApplied: 0, hunksFailed: 0, message: "No hunks found in patch" };
262
+ }
263
+
264
+ const srcLines = source.split("\n");
265
+ let hunksApplied = 0;
266
+ let hunksFailed = 0;
267
+ let lineOffset = 0;
268
+ const placements: HunkPlacement[] = [];
269
+
270
+ for (const hunk of parsed.hunks) {
271
+ const result = applyHunk(srcLines, hunk, lineOffset, fuzzy);
272
+ if (result.success) {
273
+ lineOffset += result.offsetDelta;
274
+ hunksApplied++;
275
+ if (result.placement) placements.push(result.placement);
276
+ } else {
277
+ hunksFailed++;
278
+ if (result.error) {
279
+ return { success: false, hunksApplied, hunksFailed, message: result.error, placements };
280
+ }
281
+ }
282
+ }
283
+
284
+ const newSource = srcLines.join("\n");
285
+ const fuzzyPlacements = placements.filter((p) => p.offset !== 0);
286
+ const slid = fuzzyPlacements.length > 0
287
+ ? ` (${fuzzyPlacements.length} hunk(s) matched off their recorded position)`
288
+ : "";
289
+
290
+ return {
291
+ success: hunksFailed === 0,
292
+ hunksApplied,
293
+ hunksFailed,
294
+ message: (hunksFailed === 0 ? `Applied ${hunksApplied} hunk(s)` : `Applied ${hunksApplied}, failed ${hunksFailed}`) + slid,
295
+ newSource,
296
+ placements,
297
+ fuzzyPlacements,
298
+ };
299
+ }
300
+
301
+ export async function applyPatch(
302
+ filePath: string,
303
+ patchText: string,
304
+ options?: { dryRun?: boolean; fuzzyMatch?: number }
305
+ ): Promise<PatchResult> {
306
+ const dryRun = options?.dryRun ?? false;
307
+
308
+ let source: string;
309
+ try {
310
+ source = (await readDecoded(filePath)).text;
311
+ } catch {
312
+ return { success: false, hunksApplied: 0, hunksFailed: parsePatch(patchText).hunks.length, message: `Cannot read file: ${filePath}` };
313
+ }
314
+
315
+ const result = applyPatchToSource(source, patchText, options);
316
+
317
+ if (result.success && result.newSource && !dryRun) {
318
+ try {
319
+ await safeWrite(filePath, result.newSource);
320
+ } catch {
321
+ return { success: false, hunksApplied: result.hunksApplied, hunksFailed: result.hunksFailed, message: `Cannot write file: ${filePath}` };
322
+ }
323
+ }
324
+
325
+ return result;
326
+ }
327
+
328
+ // --- Internal hunk application ---
329
+
330
+ interface HunkApplyResult {
331
+ success: boolean;
332
+ offsetDelta: number;
333
+ error?: string;
334
+ placement?: HunkPlacement;
335
+ }
336
+
337
+ function applyHunk(
338
+ srcLines: string[],
339
+ hunk: Hunk,
340
+ lineOffset: number,
341
+ fuzzy: number
342
+ ): HunkApplyResult {
343
+ const targetOldStart = hunk.oldStart + lineOffset - 1; // 0-indexed
344
+
345
+ // Search for a hunk match within the fuzzy window. The window is +/- `fuzzy`
346
+ // lines around the recorded position -- it deliberately does NOT extend by
347
+ // `hunk.oldLines`, which used to let a hunk land a whole body-length away from
348
+ // where it was recorded and silently patch the wrong region (#31).
349
+ //
350
+ // `fuzzy: 0` is strict mode: the hunk applies at exactly the recorded offset
351
+ // with exactly the recorded content, or it refuses.
352
+ const searchStart = Math.max(0, targetOldStart - fuzzy);
353
+ const searchEnd = Math.min(srcLines.length, targetOldStart + fuzzy) + 1;
354
+ // Every candidate in the window, not the first one. Taking the first made an
355
+ // ambiguous window look decisive: in repetitive code (case arms, fixture
356
+ // tables, generated blocks) a second region matches just as well, and the
357
+ // patch landed on one of them with a success exit code and no signal (#33).
358
+ const candidates: number[] = [];
359
+ for (let srcPos = searchStart; srcPos < searchEnd; srcPos++) {
360
+ if (hunkMatches(srcLines, hunk, srcPos)) candidates.push(srcPos);
361
+ }
362
+
363
+ if (candidates.length > 1) {
364
+ const lines = candidates.map((c) => c + 1).join(", ");
365
+ return {
366
+ success: false,
367
+ offsetDelta: 0,
368
+ error:
369
+ `Hunk failed at ${hunk.header}: ambiguous — the context matches at lines ${lines}. ` +
370
+ `Re-generate the patch with more context lines, lower fuzzyMatch (0 pins the recorded position), ` +
371
+ `or use the hash tier, which anchors on content rather than position.`,
372
+ };
373
+ }
374
+
375
+ const matchIdx = candidates.length === 1 ? candidates[0] : -1;
376
+
377
+ if (matchIdx < 0) {
378
+ return {
379
+ success: false,
380
+ offsetDelta: 0,
381
+ error:
382
+ fuzzy === 0
383
+ ? `Hunk failed at ${hunk.header}: strict mode (fuzzy 0) requires an exact match at line ${targetOldStart + 1}`
384
+ : `Hunk failed at ${hunk.header}: context not found near line ${targetOldStart + 1}`,
385
+ };
386
+ }
387
+
388
+ // Build replacement: context and added lines, skip removed lines
389
+ const replacementLines: string[] = [];
390
+ for (const hl of hunk.lines) {
391
+ if (hl.startsWith(" ")) {
392
+ replacementLines.push(hl.slice(1));
393
+ } else if (hl.startsWith("+")) {
394
+ replacementLines.push(hl.slice(1));
395
+ }
396
+ }
397
+
398
+ // Strict mode: refuse a patch that has already been applied. Both sides of a
399
+ // hunk can match the same position -- a pure insertion still matches its own
400
+ // old side after it has landed -- so when both match, the longer side wins. If
401
+ // that is the new side, the region is already in its post-patch state and
402
+ // re-applying would duplicate it (#31).
403
+ if (fuzzy === 0 && replacementLines.length > hunk.oldLines && linesMatchAt(srcLines, replacementLines, matchIdx)) {
404
+ return {
405
+ success: false,
406
+ offsetDelta: 0,
407
+ error: `Hunk failed at ${hunk.header}: already applied at line ${matchIdx + 1}`,
408
+ };
409
+ }
410
+
411
+ srcLines.splice(matchIdx, hunk.oldLines, ...replacementLines);
412
+
413
+ const offsetDelta = replacementLines.length - hunk.oldLines;
414
+ return {
415
+ success: true,
416
+ offsetDelta,
417
+ placement: {
418
+ expectedAt: targetOldStart + 1,
419
+ appliedAt: matchIdx + 1,
420
+ offset: matchIdx - targetOldStart,
421
+ },
422
+ };
423
+ }
424
+
425
+ /** True when `want` appears verbatim in `srcLines` starting at `pos`. */
426
+ function linesMatchAt(srcLines: string[], want: string[], pos: number): boolean {
427
+ if (pos + want.length > srcLines.length) return false;
428
+ for (let k = 0; k < want.length; k++) {
429
+ if (srcLines[pos + k] !== want[k]) return false;
430
+ }
431
+ return true;
432
+ }
433
+
434
+ function hunkMatches(srcLines: string[], hunk: Hunk, srcPos: number): boolean {
435
+ let s = srcPos;
436
+ for (const hl of hunk.lines) {
437
+ if (hl.startsWith(" ")) {
438
+ if (s >= srcLines.length || srcLines[s] !== hl.slice(1)) return false;
439
+ s++;
440
+ } else if (hl.startsWith("-")) {
441
+ if (s >= srcLines.length || srcLines[s] !== hl.slice(1)) return false;
442
+ s++;
443
+ } else if (hl.startsWith("+")) {
444
+ // Added line: no source line consumed
445
+ }
446
+ // Non-prefix lines (e.g., "\ No newline") are ignored
447
+ }
448
+ return (s - srcPos) === hunk.oldLines;
449
+ }
450
+
451
+ /**
452
+ * Turn a dry-run edit result into a preview.
453
+ *
454
+ * A dry run exists so a caller can decide whether to commit the edit, and for an
455
+ * agent the cost of that decision is context tokens. Returning the whole
456
+ * post-edit file made previewing an edit more expensive than making it, so the
457
+ * cheapest correct move became "skip the dry run" — exactly backwards (#98).
458
+ * The default payload is now a unified diff of the changed hunks; the full text
459
+ * stays available behind an explicit `includeSource` opt-in.
460
+ *
461
+ * Only dry runs are rewritten, and only successful ones: a failure carries no
462
+ * `newSource`, and a real write already told the caller what landed on disk.
463
+ */
464
+ export function toPreview<T extends { success?: boolean; newSource?: string }>(
465
+ result: T,
466
+ source: string | undefined,
467
+ filePath: string,
468
+ includeSource?: boolean
469
+ ): T & { diff?: string; sourceOmitted?: boolean } {
470
+ if (includeSource || !result.success || !result.newSource || source === undefined) return result;
471
+ const diff = generateUnifiedDiff(source, result.newSource, filePath);
472
+ const { newSource: _omitted, ...rest } = result as Record<string, unknown>;
473
+ return { ...rest, diff, sourceOmitted: true } as T & { diff?: string; sourceOmitted?: boolean };
474
+ }