@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,463 @@
1
+ import { safeWrite } from "./paths";
2
+ import {
3
+ isLanguageSupported,
4
+ detectLanguage,
5
+ renameSymbol,
6
+ replaceBody,
7
+ addImport,
8
+ removeImport,
9
+ insertBeforeSymbol,
10
+ insertAfterSymbol,
11
+ findSymbols,
12
+ findSymbolsDetailed,
13
+ firstParseError,
14
+ } from "./ast-edit";
15
+ import { replaceHash } from "./hash-edit";
16
+ import { readMany, readHash, computeHash } from "./read";
17
+ import { recordEvent, ErrorCode } from "./telemetry";
18
+ import { buildProvenanceFields } from "./provenance";
19
+ import { loadConfig, policyForce, RoutePolicy } from "./config";
20
+ import { addWarning } from "./envelope";
21
+ import { acquireLock, LOCK_TIMEOUT_MS, LockAcquireError } from "./locking";
22
+ import { readDecoded } from "./encoding";
23
+ import { toPreview } from "./diff-engine";
24
+ import { verboseLog } from "./output";
25
+
26
+ export type EditRoute = "ast" | "hash" | "diff";
27
+
28
+ export interface RouteExplanation {
29
+ route: EditRoute;
30
+ reasons: string[];
31
+ policyApplied: boolean;
32
+ policySource?: string;
33
+ }
34
+
35
+ export interface RouterResult {
36
+ route: EditRoute;
37
+ routeReason: string;
38
+ fallback?: string;
39
+ result: any;
40
+ elapsed_ms: number;
41
+ explanation?: RouteExplanation;
42
+ }
43
+
44
+ export function chooseRoute(
45
+ filePath: string,
46
+ operation: string,
47
+ policy?: RoutePolicy
48
+ ): { route: EditRoute; explanation: RouteExplanation } {
49
+ const lang = detectLanguage(filePath);
50
+ const reasons: string[] = [];
51
+ let policyApplied = false;
52
+ let policySource: string | undefined;
53
+
54
+ // Derive a language key from extension for policy matching (even for unsupported langs)
55
+ const extMatch = filePath.match(/\.([^.]+)$/);
56
+ const extKey = lang || (extMatch ? extMatch[1] : null);
57
+
58
+ // 1. Check policy overrides first
59
+ const forced = policyForce(policy, extKey, operation);
60
+ if (forced) {
61
+ const src = lang && policy?.languageOverrides?.[lang]
62
+ ? `language override for '${lang}'`
63
+ : `operation override for '${operation}'`;
64
+ const fromConf = lang && policy?.languageOverrides?.[lang] ? "language" : "operation";
65
+ reasons.push(`Policy ${fromConf} forces route '${forced}'`);
66
+ policyApplied = true;
67
+ policySource = forced !== chooseRoute(filePath, operation).route ? fromConf : undefined;
68
+ return { route: forced, explanation: { route: forced, reasons, policyApplied, policySource } };
69
+ }
70
+
71
+ // 2. Language + AST operation check
72
+ if (isLanguageSupported(filePath) && isASTOperation(operation)) {
73
+ reasons.push(`Language '${lang}' supports AST operations`);
74
+ return { route: "ast", explanation: { route: "ast", reasons, policyApplied: false } };
75
+ }
76
+
77
+ // 3. Hash operations
78
+ if (isHashOperation(operation)) {
79
+ reasons.push(`Operation '${operation}' uses hash-based editing`);
80
+ return { route: "hash", explanation: { route: "hash", reasons, policyApplied: false } };
81
+ }
82
+
83
+ // 4. Diff fallback
84
+ const unsupported = !isLanguageSupported(filePath)
85
+ ? `Language '${lang || "unknown"}' not supported for AST`
86
+ : `Operation '${operation}' not available via AST or hash`;
87
+ reasons.push(unsupported);
88
+ reasons.push(`Falling back to diff route`);
89
+ return { route: "diff", explanation: { route: "diff", reasons, policyApplied: false } };
90
+ }
91
+
92
+ function isASTOperation(op: string): boolean {
93
+ return [
94
+ "rename-symbol",
95
+ "replace-body",
96
+ "add-import",
97
+ "remove-import",
98
+ "insert-before",
99
+ "insert-after",
100
+ "find-symbols",
101
+ ].includes(op);
102
+ }
103
+
104
+ function isHashOperation(op: string): boolean {
105
+ return ["read-hash", "replace-hash"].includes(op);
106
+ }
107
+
108
+ export async function routeEdit(params: {
109
+ filePath: string;
110
+ operation: string;
111
+ method?: EditRoute;
112
+ policy?: RoutePolicy;
113
+ // Hash params
114
+ oldHash?: string;
115
+ newContent?: string;
116
+ range?: { start: number; end: number };
117
+ // AST params
118
+ oldName?: string;
119
+ newName?: string;
120
+ symbolName?: string;
121
+ newBody?: string;
122
+ importSpec?: string;
123
+ content?: string;
124
+ // Diff params (search-and-replace fallback)
125
+ oldContent?: string;
126
+ dryRun?: boolean;
127
+ /**
128
+ * Dry runs return a unified diff instead of the whole post-edit file. Set this
129
+ * when you genuinely want the full text back (#98).
130
+ */
131
+ includeSource?: boolean;
132
+ /**
133
+ * Internal: the caller already holds this file's advisory lock (`batch-edit`
134
+ * locks its whole file set up front in sorted order, then routes each file).
135
+ * The lock is not re-entrant on purpose — two genuinely concurrent writers in
136
+ * one process must still exclude each other — so nesting would self-deadlock
137
+ * until the timeout. Not exposed on the CLI.
138
+ */
139
+ alreadyLocked?: boolean;
140
+ // Provenance params
141
+ actor?: string;
142
+ taskId?: string;
143
+ reason?: string;
144
+ }): Promise<RouterResult> {
145
+ const start = Date.now();
146
+ let editSource: string | undefined;
147
+ let editResult: string | undefined;
148
+ const { filePath, operation, method, policy, oldHash, newContent, range, oldName, newName, symbolName, newBody, importSpec, content: insertContent, oldContent, dryRun, includeSource, alreadyLocked, actor, taskId, reason } = params;
149
+
150
+ let route: EditRoute;
151
+ let explanation: RouteExplanation;
152
+
153
+ // Load config-based policy if not explicitly provided
154
+ const resolvedPolicy = policy || loadConfig().routePolicy;
155
+
156
+ if (method) {
157
+ route = method;
158
+ explanation = { route, reasons: [`Explicit method override: ${method}`], policyApplied: false };
159
+ } else {
160
+ const decision = chooseRoute(filePath, operation, resolvedPolicy);
161
+ route = decision.route;
162
+ explanation = decision.explanation;
163
+ // A downgrade to the diff route is a real degradation of edit safety, and it
164
+ // used to be visible only in prose inside `routeReason`. Surface it.
165
+ if (route === "diff" && explanation.reasons.some((r) => r.startsWith("Falling back"))) {
166
+ addWarning({
167
+ code: "ROUTE_FALLBACK",
168
+ message: explanation.reasons.join("; "),
169
+ from: "ast",
170
+ to: "diff",
171
+ });
172
+ }
173
+ }
174
+
175
+ let result: any;
176
+ let routeReason = explanation.reasons.join("; ");
177
+ let fallback: string | undefined;
178
+
179
+ if (route === "ast" && !isLanguageSupported(filePath)) {
180
+ if (method) {
181
+ result = { success: false, message: `Cannot force AST route: ${filePath} is not a supported language file` };
182
+ } else {
183
+ fallback = "AST unsupported for this file type";
184
+ // A silent downgrade looks identical to a successful AST edit, which is
185
+ // exactly the debugging problem the envelope's `warnings` exists for.
186
+ addWarning({ code: "ROUTE_FALLBACK", message: fallback, from: "ast", to: "hash" });
187
+ route = "hash";
188
+ }
189
+ }
190
+
191
+ if (route === "hash") {
192
+ if (!oldHash || newContent === undefined) {
193
+ route = "diff";
194
+ fallback = "Hash edit requires oldHash and newContent";
195
+ addWarning({ code: "ROUTE_FALLBACK", message: fallback, from: "hash", to: "diff" });
196
+ }
197
+ }
198
+
199
+ routeReason = `${explanation.reasons.join("; ")}${fallback ? `; ${fallback}` : ""}`;
200
+
201
+ // `--verbose` explains the tier choice on stderr. Routing is the single most
202
+ // opaque decision HashPilot makes, and a silent AST->diff downgrade is exactly
203
+ // what an agent needs to see while debugging (#47).
204
+ verboseLog(() => `route: ${route} for ${operation} on ${filePath} (${routeReason})`);
205
+
206
+ // Compare-and-swap alone cannot close the single-file race: between the hash
207
+ // compare and `safeWrite` another writer can land, and CAS then reports success
208
+ // over top of an edit it never saw. Hold the same advisory lock `batch-edit`
209
+ // uses across the whole read → edit → compare → write window so CAS is checking
210
+ // a snapshot nobody else can invalidate. `batch-edit` locked its whole file set
211
+ // up front and passes `alreadyLocked` so it does not wait on itself (#21/B18).
212
+ let releaseFileLock: (() => void) | undefined;
213
+ if (!result && !dryRun && !alreadyLocked) {
214
+ try {
215
+ releaseFileLock = await acquireLock(filePath, { timeoutMs: LOCK_TIMEOUT_MS });
216
+ } catch (e: any) {
217
+ // A contended lock is transient, so it stays retryable — but it is NOT a
218
+ // stale anchor. Reporting it as one told callers to re-read the file
219
+ // (which changes nothing here) and inflated the stale-anchor health
220
+ // metric. `batch-edit` reports LOCK_TIMEOUT for the identical condition.
221
+ result = {
222
+ success: false,
223
+ stale: true,
224
+ errorCode: ErrorCode.LOCK_TIMEOUT,
225
+ message:
226
+ e instanceof LockAcquireError
227
+ ? `Could not lock ${filePath}: ${e.message}`
228
+ : `Could not lock ${filePath}: ${e?.message ?? e}`,
229
+ recovery: "Another edit holds this file. Wait and retry.",
230
+ };
231
+ }
232
+ }
233
+
234
+ try {
235
+ if (!result) {
236
+ switch (route) {
237
+ case "ast": {
238
+ let source: string;
239
+ let casHash: string | undefined;
240
+ try {
241
+ source = (await readDecoded(filePath)).text;
242
+ editSource = source;
243
+ casHash = computeHash(source);
244
+ } catch (e: any) {
245
+ result = { success: false, message: `Failed to read file: ${e.message}` };
246
+ break;
247
+ }
248
+ try {
249
+ switch (operation) {
250
+ case "rename-symbol":
251
+ result = renameSymbol(source, filePath, oldName!, newName!);
252
+ break;
253
+ case "replace-body":
254
+ result = replaceBody(source, filePath, symbolName!, newBody!);
255
+ break;
256
+ case "add-import":
257
+ result = addImport(source, filePath, importSpec!);
258
+ break;
259
+ case "remove-import":
260
+ result = removeImport(source, filePath, importSpec!);
261
+ break;
262
+ case "insert-before":
263
+ result = insertBeforeSymbol(source, filePath, symbolName!, insertContent!);
264
+ break;
265
+ case "insert-after":
266
+ result = insertAfterSymbol(source, filePath, symbolName!, insertContent!);
267
+ break;
268
+ case "find-symbols":
269
+ {
270
+ const search = findSymbolsDetailed(source, filePath);
271
+ result = { success: true, symbols: search.symbols, truncated: search.truncated, message: "Symbols found" };
272
+ }
273
+ break;
274
+ default:
275
+ result = { success: false, message: `Unknown AST operation: ${operation}` };
276
+ }
277
+ } catch (e: any) {
278
+ // The tree-sitter binding throws bare errors (it used to throw
279
+ // `Invalid argument` for any source over 32KB — see #55). Whatever
280
+ // the cause, an AST failure is an edit failure, never an internal
281
+ // crash: this is the only thing standing between a parser bug and
282
+ // exit 70.
283
+ result = {
284
+ success: false,
285
+ errorCode: ErrorCode.PARSE_ERROR,
286
+ message: `AST parse failed for ${filePath}: ${e?.message ?? e}`,
287
+ recovery: "Retry with an explicit --old-content/--new-content pair to use the diff route.",
288
+ };
289
+ addWarning({
290
+ code: "ROUTE_FALLBACK",
291
+ message: `AST route failed to parse ${filePath}; a diff-route edit is the remaining option.`,
292
+ from: "ast",
293
+ to: "diff",
294
+ });
295
+ }
296
+ // Write result to file if successful — CAS guard prevents silent data
297
+ // loss when concurrent edits both read the same snapshot.
298
+ if (result.success && (result as any).newSource && !dryRun) {
299
+ const currentOnDisk = (await readDecoded(filePath)).text;
300
+ const nowHash = computeHash(currentOnDisk);
301
+ if (nowHash !== casHash) {
302
+ result = {
303
+ success: false,
304
+ stale: true,
305
+ errorCode: ErrorCode.STALE_ANCHOR,
306
+ message: `CAS failed for ${filePath}: file changed while editing`,
307
+ newCurrentHash: nowHash,
308
+ recovery: "Re-read the file and retry the edit.",
309
+ };
310
+ } else {
311
+ await safeWrite(filePath, (result as any).newSource);
312
+ editResult = (result as any).newSource;
313
+ }
314
+ }
315
+ break;
316
+ }
317
+ case "hash":
318
+ editSource = (await readDecoded(filePath)).text;
319
+ result = await replaceHash(filePath, oldHash!, newContent!, { range, dryRun });
320
+ editResult = ((await readDecoded(filePath)).text);
321
+ break;
322
+ case "diff": {
323
+ // An empty newContent is a deletion. Only oldContent must be non-empty —
324
+ // there is nothing to search for otherwise (#40 falsy-parameter audit).
325
+ if (!oldContent || newContent === undefined) {
326
+ result = { success: false, message: "Diff route requires oldContent and newContent" };
327
+ break;
328
+ }
329
+ let source: string;
330
+ let casHash: string | undefined;
331
+ try {
332
+ source = (await readDecoded(filePath)).text;
333
+ editSource = source;
334
+ casHash = computeHash(source);
335
+ } catch (e: any) {
336
+ result = { success: false, message: `Failed to read file: ${e.message}` };
337
+ break;
338
+ }
339
+ result = applyTextReplace(source, filePath, oldContent, newContent);
340
+ // Same post-edit parse check the AST and hash tiers apply: a search-and-
341
+ // replace that lands mid-expression must not reach disk (#13).
342
+ if (result.success && (result as any).newSource) {
343
+ const after = firstParseError((result as any).newSource, filePath);
344
+ if (after && !firstParseError(source, filePath)) {
345
+ result = {
346
+ success: false,
347
+ errorCode: ErrorCode.PARSE_ERROR,
348
+ message:
349
+ `Edit was discarded: the result does not parse (syntax error at line ${after.line}:${after.column} — ${after.nodeType}). ` +
350
+ `The file parsed cleanly before, so this replacement would have corrupted it.`,
351
+ };
352
+ break;
353
+ }
354
+ }
355
+ if (result.success && (result as any).newSource && !dryRun) {
356
+ const currentOnDisk = (await readDecoded(filePath)).text;
357
+ const nowHash = computeHash(currentOnDisk);
358
+ if (nowHash !== casHash) {
359
+ result = {
360
+ success: false,
361
+ stale: true,
362
+ errorCode: ErrorCode.STALE_ANCHOR,
363
+ message: `CAS failed for ${filePath}: file changed while editing`,
364
+ newCurrentHash: nowHash,
365
+ recovery: "Re-read the file and retry the edit.",
366
+ };
367
+ } else {
368
+ await safeWrite(filePath, (result as any).newSource);
369
+ editResult = (result as any).newSource;
370
+ }
371
+ }
372
+ break;
373
+ }
374
+ default:
375
+ result = { success: false, message: `Unknown route: ${route}` };
376
+ }
377
+ }
378
+ } finally {
379
+ releaseFileLock?.();
380
+ }
381
+
382
+ const elapsed = Date.now() - start;
383
+
384
+ let errorCode: ErrorCode | undefined;
385
+ if (!result.success) {
386
+ if (result.stale) {
387
+ errorCode = ErrorCode.STALE_ANCHOR;
388
+ } else if (result.message?.includes("not found") || result.message?.includes("ENOENT")) {
389
+ errorCode = ErrorCode.FILE_NOT_FOUND;
390
+ } else if (result.message?.includes("hash")) {
391
+ errorCode = ErrorCode.HASH_MISMATCH;
392
+ }
393
+ }
394
+
395
+ const provenanceFields = buildProvenanceFields({
396
+ actor,
397
+ taskId,
398
+ reason,
399
+ source: editSource,
400
+ newSource: editResult,
401
+ filePath,
402
+ });
403
+
404
+ recordEvent({
405
+ operation,
406
+ route,
407
+ file: filePath,
408
+ language: detectLanguage(filePath) || undefined,
409
+ success: result.success ?? false,
410
+ fallback_reason: fallback,
411
+ retries: result.retries,
412
+ elapsed_ms: elapsed,
413
+ errorCode,
414
+ ...provenanceFields,
415
+ });
416
+
417
+ // A dry run previews; it does not dump the file back at the caller (#98).
418
+ const payload = dryRun ? toPreview(result, editSource, filePath, includeSource) : result;
419
+
420
+ verboseLog(() => `result: ${result.success ? "ok" : "failed"} via ${route} in ${elapsed}ms`);
421
+
422
+ return { route, routeReason, fallback, result: payload, elapsed_ms: elapsed, explanation };
423
+ }
424
+
425
+ /**
426
+ * Search-and-replace fallback for the diff route.
427
+ * Detects duplicates and reports the count. If oldContent appears more than once,
428
+ * fails with a message listing occurrences so the caller can disambiguate.
429
+ */
430
+ function applyTextReplace(
431
+ source: string,
432
+ filePath: string,
433
+ oldContent: string,
434
+ newContent: string
435
+ ): { success: boolean; message: string; newSource?: string } {
436
+ // Count exact occurrences in the full source
437
+ const occurrences: number[] = [];
438
+ let idx = 0;
439
+ while ((idx = source.indexOf(oldContent, idx)) !== -1) {
440
+ const lineNum = source.slice(0, idx).split("\n").length;
441
+ occurrences.push(lineNum);
442
+ idx += oldContent.length;
443
+ }
444
+
445
+ if (occurrences.length === 0) {
446
+ return { success: false, message: `Content not found in ${filePath}. File may have changed — re-read and retry.` };
447
+ }
448
+
449
+ if (occurrences.length > 1) {
450
+ const locs = occurrences.map((l) => `line ${l}`).join(", ");
451
+ return {
452
+ success: false,
453
+ message: `Content appears ${occurrences.length} times (${locs}). Provide more context to disambiguate.`,
454
+ };
455
+ }
456
+
457
+ const newSource = source.split(oldContent).join(newContent);
458
+ return {
459
+ success: true,
460
+ message: `Replaced content at line ${occurrences[0]}`,
461
+ newSource,
462
+ };
463
+ }