@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,846 @@
1
+ # HashPilot — Architecture & Design
2
+
3
+ A living document capturing the architecture, design decisions, and data flow of the HashPilot structured editing system.
4
+
5
+ **Landing page:** https://bigknoxy.github.io/HashPilot/ — problem statement, audience, quick start.
6
+
7
+ **Roadmap & backlog:** [../ROADMAP.md](../ROADMAP.md) — sprint-ordered work queue derived from [../AUDIT-2026-08.md](../AUDIT-2026-08.md). Several known defects described there contradict behavior documented on this page; the roadmap is authoritative on what is broken today.
8
+
9
+ ---
10
+
11
+ ## Why This Document Exists
12
+
13
+ HashPilot has two complementary docs that must always be kept in sync with the code:
14
+
15
+ | Document | Purpose | Audience |
16
+ |----------|---------|----------|
17
+ | **README.md** | Product landing page — what, why, quick start | Developers, agents, teams |
18
+ | **ARCHITECTURE.md** (this) | Design doc — how it works, why it's built this way | Engineers, contributors, reviewers |
19
+
20
+ **Verification rule:** Every PR that touches `src/` must update one or both docs. A CI check (`docs-verify`) validates that if `src/` files change, either `README.md` or `docs/ARCHITECTURE.md` must also change.
21
+
22
+ ---
23
+
24
+ ## Design Philosophy
25
+
26
+ 1. **Correctness over cleverness** — Boring, readable solutions that are easy to maintain. Every edit should be verifiable.
27
+ 2. **Smallest change that works** — Minimize blast radius. Don't refactor adjacent code unless it reduces risk.
28
+ 3. **Leverage existing patterns** — Follow project conventions before introducing new abstractions.
29
+ 4. **Cryptographic certainty** — SHA-256 content identity eliminates fuzzy matching. If the hash matches, you're editing the right content.
30
+ 5. **Auto-recovery** — Stale anchors, failed verifies, race conditions. The system detects and recovers transparently.
31
+ 6. **Auditability** — Every edit records who, what, when, and why. Provenance is a first-class concern.
32
+
33
+ ---
34
+
35
+ ## Module Architecture
36
+
37
+ ```
38
+ ┌──────────────────────────────────────────────────────────────────┐
39
+ │ hashpilot CLI │
40
+ │ (Commander-based, Bun runtime) │
41
+ ├───────────┬───────────┬──────────┬──────────┬────────────────────┤
42
+ │ Read │ AST │ Hash │ Diff │ Verify + Batch │
43
+ │ Search │ Ops │ Ops │ Ops │ + Intent + Route │
44
+ ├───────────┴───────────┴──────────┴──────────┴────────────────────┤
45
+ │ Router (auto-select) │
46
+ │ chooseRoute(): AST → Hash → Diff │
47
+ │ routeEdit(): execute + telemetry + provenance │
48
+ ├──────────────────────────────────────────────────────────────────┤
49
+ │ Cross-Cutting Layers │
50
+ │ • Telemetry (JSONL) • Provenance (agent git blame) │
51
+ │ • Config (env→CLI→project→global) • Error/exit codes │
52
+ │ • Doctor (health check) • Batch (parallel/serial edits) │
53
+ └──────────────────────────────────────────────────────────────────┘
54
+ ```
55
+
56
+ **Telemetry timing:** Every CLI command measures `elapsed_ms` via `Date.now() - start`
57
+ recorded at command entry. This is enforced by a regression test that asserts no
58
+ command reports a hardcoded zero. Previously `replace-hash` hardcoded `elapsed_ms: 0`
59
+ (issue #51, defect #3), poisoning health-report averages since it is one of the
60
+ highest-frequency operations.
61
+
62
+ ### Module Responsibilities
63
+
64
+ #### `src/cli-node.cjs` — Node-parseable Launcher
65
+ - The `bin` target for the published package. Deliberately plain CommonJS so any Node can parse it.
66
+ - Spawns `bun run src/cli.ts` with an **array** argv (never a shell string — edit payloads contain code, quotes, and newlines).
67
+ - Forwards Bun's exit status verbatim, preserving the 0/1/2/3/4/5/70 contract.
68
+ - Bun missing → one actionable install line on stderr and exit **127**, instead of a syntax-error stack trace ([#35](../../issues/35)).
69
+
70
+ #### `src/cli.ts` — CLI Entry Point (~200 lines)
71
+ - Wiring only: root options, the `preAction` hook, the top-level error handlers, and one `register(program)` call per command group. No command actions live here ([#48](../../issues/48)).
72
+ - The registration order in `cli.ts` is the order groups appear in `--help`; reshuffling it changes user-visible output.
73
+
74
+ #### `src/commands/*.ts` — Command Groups
75
+ - One module per group, each exporting `register(program: Command): void`: `read`, `hash`, `ast`, `edit`, `intent`, `diff`, `verify`, `telemetry`, `provenance`, `mcp`, `maintenance`, `route`.
76
+ - Every command wraps its action in `recordEvent({...})` for telemetry.
77
+ - `shared.ts` holds the flag blocks that were previously copy-pasted across commands: `withProvenance` (`--actor`/`--task-id`/`--reason`, nine commands), `withEditFlags` (the routed-edit block shared by `route-edit` and `batch`), `withPreview` (`--dry-run`/`--include-source`), plus `parseRange` and `parseIntFlag`. Helpers **wrap** a Commander chain rather than being chained onto it, and append their options last so `--help` order is unchanged.
78
+ - Subcommands: `read-many`, `read-hash`, `replace-hash`, `grep-many`, `symbol-lookup-many`, `ast *`, `diff *`, `route-edit`, `batch`, `intent`, `verify-changes`, `telemetry *`, `provenance *`, `changesets`, `undo`, `mcp`, `doctor`, `config`, `upgrade`, `uninstall`, `route`
79
+
80
+ #### `src/core/resolve-content.ts` — CLI Content Arguments
81
+ - `resolveContent(val)`: `@path` reads the file, anything else is literal. An explicit empty string is a deletion, so only `undefined` short-circuits ([#40](../../issues/40)). Single definition, shared by `route-edit` and `batch`.
82
+
83
+ #### `src/router.ts` — Route Selection & Dispatch
84
+ - `chooseRoute(file, operation)`: Determines AST vs Hash vs Diff based on:
85
+ - File extension and language detection
86
+ - Operation type (rename, replace, insert, etc.)
87
+ - User-configured route policies that can override per language or per operation
88
+ - Conflict resolution: `"language"`, `"operation"`, or `"strictest"`
89
+ - `routeEdit(file, operation, args)`: Unified execution entry point
90
+ - Routes the edit, applies it, records telemetry event
91
+ - Returns `{ route, success, error?, message? }`
92
+ - Route policy merge priority: env var → CLI flag → project config → global config → defaults
93
+
94
+ #### `src/ast-edit.ts` — Tree-Sitter AST Operations
95
+ - Tree-sitter parsing for TS, TSX, JS, Python, Go, Rust
96
+ - `insert-before`/`insert-after` anchor only on a per-language allowlist of statement- and declaration-level node types; a name that resolves to a parameter, import specifier, or type parameter is refused rather than spliced into an expression
97
+ - Insertion anchors that match more than once are refused with every candidate named; inserted content is re-indented to the anchor
98
+ - Symbol search walks the tree with an explicit work stack bounded by `MAX_AST_DEPTH` (200), not recursion: the old walk stopped at depth 10 and reported the symbol as absent. A search that hits the bound sets `truncated: true` and attaches a `SEARCH_TRUNCATED` warning, and an edit that fails after an incomplete walk returns `SEARCH_TRUNCATED` rather than `SYMBOL_NOT_FOUND`
99
+ - `.d.ts` files excluded from AST editing
100
+ - Operations:
101
+ - `findSymbols(file)` — enumerate all functions, classes, methods, variables. Reports
102
+ 1-indexed `startLine`/`endLine`/`startColumn`/`endColumn` (matching the hash tier's
103
+ `range` and `read-hash`) alongside the raw 0-indexed tree-sitter
104
+ `startRow`/`endRow`/`startCol`/`endCol` kept for compatibility (#99).
105
+ - `renameSymbol(file, oldName, newName)` — rename + all references via tree queries
106
+ - `replaceBody(file, symbolName, newBody)` — replace function/method body
107
+ - `addImport(file, specifier, source)` — add an import, merging into an existing
108
+ statement for the same module rather than emitting a duplicate one. TS/TSX/JS
109
+ merge named and default bindings into the existing clause (namespace imports
110
+ have no merge form and still get their own statement); Python merges
111
+ `from X import Y`; Go merges into an existing `import ( ... )` block. A binding
112
+ already bound from that module — including under an alias — is refused with
113
+ `changes: 0`. Type-only and value imports never merge into each other:
114
+ `import type { .. }` erases its bindings at compile time, so folding a value
115
+ import into one would silently delete it. A newly inserted statement consumes
116
+ exactly one newline after the last import, so the blank line separating the
117
+ import block from the code below it survives; when the last import ends at EOF
118
+ with no trailing newline, one is opened so the statements never glue onto a
119
+ single line (#103).
120
+ - `removeImport(file, specifier)` — remove a binding or a whole import statement.
121
+ Matching is against parsed binding tokens, not source substrings: a name that is
122
+ one of several bindings is removed from the clause and the surviving bindings are
123
+ rewritten; the statement is deleted only when nothing survives it. Accepts the bare
124
+ name, the exact module path, and the full `{ X } from "mod"` / `from mod import X`
125
+ forms. TS/TSX/JS, Python, Go, and Rust all take this path (#102).
126
+ - `insertBefore(file, symbolName, content)` — insert content before symbol
127
+ - `insertAfter(file, symbolName, content)` — insert content after symbol
128
+ - Per-language configs for import formatting and grouped import handling
129
+ - Returns `{ success, symbolFound, edits: SyntaxEdits[], error? }`
130
+
131
+ #### `src/hash-edit.ts` — SHA-256 Anchored Content Replacement
132
+ - `replaceHash(file, hash, content, options?)`:
133
+ - Computes SHA-256 of target file content
134
+ - Matches against provided hash
135
+ - If match: performs the replacement at byte range
136
+ - If stale: auto-recovers by re-reading the file
137
+ - Returns `{ success, stale, newHash?, fileHash?, newRange?, error? }`
138
+ - **`newHash` is the hash of the content that was written, not of the file (#101).**
139
+ The success path used to return the whole-file hash while every `STALE_ANCHOR`
140
+ path returned the range hash, so the one field meant two incompatible things and
141
+ the value an agent naturally chained on could never match its own next call. The
142
+ whole-file hash is now `fileHash` (informational, not an anchor), and `newRange`
143
+ reports where the written content landed — a replacement with a different line
144
+ count moves the region, so `{newHash, newRange}` is the pair that chains.
145
+ - Stale-anchor recovery protocol:
146
+ 1. Read current file content and hash
147
+ 2. Match against expected hash
148
+ 3. If mismatch: report stale anchor, re-read, retry with new hash
149
+ - Critical for concurrent editing scenarios where two agents may edit the same file
150
+
151
+ #### `src/diff-engine.ts` — LCS-Based Unified Diff
152
+ - Longest Common Subsequence (LCS) algorithm
153
+ - Generates unified diffs (`diff -u` format)
154
+ - Applies patches with fuzzy matching, tolerance in lines (`fuzzyMatch`, default 3)
155
+ - `fuzzyMatch: 0` is **strict mode**: the hunk applies at exactly the recorded offset
156
+ with exactly the recorded content, or it refuses. Strict mode also refuses a patch
157
+ that has already been applied, so a retry cannot duplicate an inserted block
158
+ - The fuzzy window is `±fuzzy` lines around the recorded offset. It deliberately does
159
+ not widen by the hunk body length, which used to let a hunk land a whole body away
160
+ from where it was recorded and silently patch the wrong region ([#31](../../issues/31))
161
+ - A window with more than one match is **ambiguous, not decisive**: the engine refuses
162
+ and names every candidate line rather than taking the first, which in repetitive code
163
+ (case arms, fixture tables, generated blocks) patched a different block than the one
164
+ asked for, with a success exit code ([#33](../../issues/33))
165
+ - Every applied hunk reports a `HunkPlacement` — `expectedAt`, `appliedAt`, `offset` —
166
+ on `PatchResult.placements`, with `fuzzyPlacements` filtered to the non-zero offsets
167
+ and the result message calling out that hunks matched off their recorded position
168
+ - Hunk bodies are consumed by the line counts the `@@` header declares rather than by
169
+ scanning for the next marker: every body line is prefixed, so a removed line whose
170
+ content starts with `-- ` renders as `--- ...` and a marker scan mistook file content
171
+ for the next file header, truncating the hunk ([#31](../../issues/31))
172
+ - `toPreview` turns a **dry-run** result into a preview: a unified diff of the changed
173
+ hunks plus `sourceOmitted: true`, in place of the whole post-edit file. A dry run
174
+ exists so a caller can decide whether to commit the edit, and for an agent that
175
+ decision is paid in context tokens — returning the file made previewing an edit more
176
+ expensive than making it, so the cheapest correct move became "skip the dry run".
177
+ The full text stays available behind `--include-source` / `includeSource: true`
178
+ ([#98](../../issues/98))
179
+ - Duplicate detection: if oldContent matches multiple locations, fails with disambiguation hints
180
+ - Fallback route for unsupported languages and operations
181
+ - Covered by property tests (`tests/diff-property.test.ts`): `apply(diff(A,B)) === B` over a
182
+ generated alphabet containing every reserved unified-diff token, plus empty and
183
+ whitespace-only lines, repeated identical lines, CR characters, long lines, and
184
+ astral-plane characters. Seeded for CI reproducibility; `FC_RANDOM_SEED=1` runs unseeded
185
+
186
+ #### `src/read.ts` — Batch & Contextual File Reading
187
+ - `readMany(files)`: Batch read files returning content + SHA-256 hashes
188
+ - `readHash(file, line)`: Read single line with surrounding context + hashes
189
+ - Both return structured JSON for agent consumption
190
+
191
+ #### `src/grep.ts` — Regex Search
192
+ - `grepMany(pattern, paths)`: System grep wrapper
193
+ - `symbolLookupMany(paths, names)`: Regex-based symbol definition search
194
+ - Compact, deterministic output
195
+
196
+ #### `src/intent.ts` — Intent-Based Editing (M5)
197
+ - Parses structured intents (e.g., `{"operation":"add-parameter","symbol":"fn","param":{"name":"x"}}`)
198
+ - Resolves symbol definitions and all call sites
199
+ - Generates an `EditPlan` with:
200
+ - Ordered steps (definition first, then references)
201
+ - Blast radius summary (how many files affected)
202
+ - Prerequisite checks
203
+ - `unresolved: UnresolvedItem[]` — work the planner could not compute, each with `{file, operation, reason, resolution}`
204
+ - Returns `{ success, plan: EditPlan, steps: EditStep[], error? }`
205
+ - **No invented source text (#16).** `add-parameter` without a `param.default`
206
+ has no argument to pass at the call sites. The planner used to emit a C-style
207
+ `/* TODO */` placeholder there — not a comment in Python, so a "successful"
208
+ plan wrote a syntax error to disk. It now records an `unresolved` entry
209
+ instead; the executor refuses the whole plan with `UNSUPPORTED_OPERATION`
210
+ (exit 1) unless `--yes` is given, in which case only the computable steps run.
211
+
212
+
213
+
214
+ - **Tree-sitter reference resolution (#15).** \`findReferences\` was replaced by
215
+ \`resolveReferences\`, which walks every parsable file in the project with the
216
+ same \`getParser()\` as the AST route. A call site is a bare \`identifier\`/ \`type_identifier\` that is neither a declaration name, a member access, nor an
217
+ import binding. Per-language \`REF_QUERIES\` cover TS/TSX/JS/Python/Go/Rust.
218
+ The \`EditPlan\` now carries an optional \`reconciliation\` field
219
+ (\`{resolved, unresolved, ambiguous}\`): \`unresolved\` counts files in
220
+ languages HashPilot does not parse; \`ambiguous\` counts caller files that
221
+ bind the same name multiple times; both trigger refusal via the
222
+ \`plan.unresolved\` guard.
223
+
224
+ #### `src/plan-executor.ts` — Edit Plan Execution
225
+ - Executes `EditPlan` steps through the router
226
+ - Supports: dry-run mode, per-step verify, revert-on-failure
227
+ - `executeIntent(intentJSON)`: Top-level entry:
228
+ 1. Parse intent JSON
229
+ 2. Resolve symbols and references
230
+ 3. Generate EditPlan
231
+ 4. Execute through router
232
+ 5. Verify results
233
+ 6. Return `{ success, changeset, steps: [{file, operation, status, error?}] }`
234
+
235
+ **Rollback & verification invariants (issues #10, #17)**:
236
+ - Verification failure (`VerifyResult.overall === "fail"`), not just step failure, must
237
+ trigger rollback when `revertOnFailure` is true. `PlanResult.success` reflects both
238
+ dimensions; it is `false` when either the steps fail *or* the post-edit verification
239
+ fails. The `errorCode` of `VERIFY_FAILED` maps to exit code 4.
240
+ - A half-reverted tree must never claim `reverted: true`. The revert loop now tracks
241
+ every snapshot file whose `safeWrite` threw and returns them in `unrevertedFiles`.
242
+ `reverted` is `true` only when every snapshot file was fully restored to its pre-edit
243
+ state; otherwise `reverted: false` and `unrevertedFiles` names the files still in
244
+ their post-edit state.
245
+ - **Verification is skipped entirely when a step has already failed.** The tree is
246
+ half-applied at that point, so the suite would report failures that are a
247
+ consequence of the incomplete edit, not findings about the change — at the cost of
248
+ a full test run on work that is about to be reverted. It also used to corrupt the
249
+ diagnosis: `errorCode` became `VERIFY_FAILED` (exit 4, "the edit applied but tests
250
+ failed") when the edit had never applied at all (exit 2).
251
+ - **An incomplete rollback outranks every other error code.** `unrevertedFiles`
252
+ being non-empty yields `ROLLBACK_INCOMPLETE`, which maps to exit **5** (I/O) rather
253
+ than 4. Exit 4 sits in the band an agent reads as "your edit landed, the tests are
254
+ red" — safe to retry. A half-reverted tree is not safe to retry, so it must not
255
+ share that code.
256
+ - **The rollback snapshot's own read failures count as unreverted.** A file that
257
+ could not be read when the pre-edit snapshot was taken has nothing to write back,
258
+ so the revert loop — which iterates the snapshot — would neither restore it nor
259
+ report it, reproducing the exact defect #17 closed on the write side. Such a file
260
+ is folded into `unrevertedFiles` when a step actually modified it. Both the
261
+ snapshot and the step read through `Bun.file().text()`, so today this requires the
262
+ file to become readable between the two — a race window, not a reproducible path.
263
+ It is guarded by construction so the invariant survives future step types.
264
+ - **The rollback decision reports *why* it fired.** `PlanResult.revertReason` carries
265
+ `"verification-failed"` (every step applied but a check reported `overall: "fail"`)
266
+ or `"step-failed"` (a step could not apply, so the tree is half-applied); it is
267
+ absent when nothing was reverted (#10). This is what lets a caller tell a red
268
+ verification (fix the check, then retry) apart from a broken plan. A verification
269
+ *timeout* is its own verdict (`VERIFY_TIMEOUT`, exit 4) and never reverts, so it
270
+ yields no `revertReason`.
271
+
272
+ #### `src/provenance.ts` — Edit History (M6)
273
+ - ChangeSet-based tracking (group of related edits)
274
+ - `provenanceQuery(file, line?)`: Shows edit history per file/line
275
+ - Like `git blame` for agent edits
276
+ - Records: actor, taskId, reason, timestamp, operation, file, hash
277
+ - Unified diffs are **opt-in** (`provenance.captureDiffs`, default off) and are
278
+ never captured for files `isSensitiveFile` matches; hashes still record that
279
+ the file changed
280
+
281
+ #### `src/paths.ts` — Write Boundary
282
+ - `assertWritable(path, opts)`: resolves symlinks, then requires the target to sit
283
+ inside the project root (or an explicitly allowed root). Otherwise `PATH_DENIED`.
284
+ - Hard deny-list that no option can override: `~/.ssh`, `~/.aws`, `~/.gnupg`,
285
+ `/etc`, shell startup files, and HashPilot's own telemetry log. Deny targets are
286
+ themselves realpath-resolved (on macOS `/etc` is a symlink to `/private/etc`).
287
+ - Widened by `allowedRoots` in config or `--allowed-root`; disabled by `--allow-outside-root`.
288
+ - `safeWrite` is the single write path used by every edit route. It snapshots the
289
+ file's pre-edit bytes, then writes atomically: sibling temp file → `fsync` →
290
+ `rename` over the target → `fsync` of the directory, with the target's mode
291
+ preserved. A crash mid-write leaves the original byte-identical, and orphaned
292
+ `.hashpilot-tmp-*` files older than an hour are swept after each write.
293
+
294
+ #### `src/core/encoding.ts` — Byte Fidelity (#30)
295
+ - A structured editor's one non-negotiable property is that it must not change
296
+ bytes it was not asked to change. Reading with `.split("\n")` and writing back
297
+ with `.join("\n")` breaks that three ways: it deletes `\r` from every line of a
298
+ CRLF file, folds a BOM into line 1 where it corrupts that line's hash, and drops
299
+ or invents a trailing newline. A one-line edit then produces a whole-file diff.
300
+ - **Normalize at the boundary.** `decodeText(raw)` strips the BOM and converts
301
+ CRLF/CR/LF to plain `\n`, returning that text plus a `FileEncoding` record
302
+ (`bom`, dominant `eol`, per-line `endings` when the file was inconsistent,
303
+ `trailingNewline`). Every tier — hashing, line splitting, AST offsets — operates
304
+ on plain-LF text and never sees a `\r`. `encodeText(text, encoding)` puts the
305
+ original layout back at write time. `readDecoded(path)` is the read-side entry
306
+ point, used by `read.ts`, `hash-edit.ts`, `diff-engine.ts`, `plan-executor.ts`,
307
+ and `router.ts`.
308
+ - **Write side.** `paths.ts` re-applies the *target file's* layout inside both
309
+ `safeWrite` and `atomicWrite`. It re-decodes the incoming content first, so the
310
+ transform is correct whether the caller handed back normalized text or text still
311
+ carrying the file's endings, and applying it twice changes nothing. Encoding runs
312
+ **before** `recordSnapshot`, or the snapshot would hash bytes that never reached
313
+ disk and `undo` would fail its own verification.
314
+ - Trailing-newline presence follows the original file, not the edit: an agent
315
+ handing back content without a final newline is describing lines, not asking to
316
+ change how the file terminates. The one exception is emptying a file, which
317
+ yields an empty file rather than a lone blank line.
318
+ - **Known limitation.** A mixed-ending file restores endings *by line position*, so
319
+ lines after an inserted line take the ending that used to belong to the line at
320
+ that index. Lines the edit created take the dominant style. Consistent files —
321
+ effectively all real ones — are unaffected.
322
+ - Astral-plane content is safe without special handling: tree-sitter node
323
+ `startIndex`/`endIndex` are UTF-16 code units, matching JS string offsets, so
324
+ emoji and CJK Extension B characters do not shift AST edits.
325
+
326
+ #### `src/core/path-normalize.ts` — Path Canonicalization for Comparison (#41)
327
+ - `normalizePath(file)` resolves `./`, `../`, and trailing slashes, then expresses
328
+ the result **relative to `process.cwd()`** when it lives underneath it, and
329
+ leaves it absolute otherwise. `pathsEqual(a, b)` is the normalized comparison.
330
+ - Used by `intent.ts` to dedupe plan steps. Without it, the same file reached via
331
+ `src/a.ts`, `./src/a.ts`, and `/abs/proj/src/a.ts` produced one plan step per
332
+ spelling, and a reference spelled differently from the definition escaped the
333
+ `!== definition.file` filter and got renamed twice.
334
+ - **Deliberately separate from `paths.ts`.** That module is the write boundary and
335
+ must not accumulate comparison helpers — a permissive canonicalizer sitting next
336
+ to `assertWritable` invites using it where a realpath check is required.
337
+ - **Not interchangeable with lock keys.** Output is cwd-relative, so it is only
338
+ valid for comparisons made within a single process at a fixed cwd. Persisted or
339
+ cross-process keys must stay cwd-independent — see `locking.ts` below.
340
+
341
+ #### `src/core/module-system.ts` — JavaScript Module-System Detection (#139)
342
+ - `detectModuleSystem(filePath, source)` answers ESM or CommonJS, and returns the
343
+ signal it used so a refusal can explain itself. Signals, first match wins:
344
+ a `.cjs`/`.mjs` extension; the nearest `package.json` `type` field (walking up
345
+ from the file — an **absent** field is CommonJS, per Node's own default, not
346
+ silence); then a content sniff for `require`/`module.exports` versus a
347
+ top-level `import`/`export`.
348
+ - **Why it has to exist at all.** The parse-validity gate cannot substitute for
349
+ it. tree-sitter's JavaScript grammar accepts `import` and `require` in the same
350
+ file, so ESM syntax written into a CommonJS file parses cleanly, passes the
351
+ gate, reports success, and then fails to load at runtime — the silent
352
+ corruption class the gate was built to stop. Module system is a *packaging*
353
+ fact, not a syntactic one, so it cannot be read off the tree.
354
+ - `ast-edit.ts` consults it for **JavaScript only** before choosing import
355
+ syntax. TypeScript and TSX compile to whichever system their own config
356
+ selects, so their emission stays ESM unconditionally.
357
+ - Both markers present with no extension or `package.json` to settle it yields
358
+ **no verdict**, and `add-import` refuses with `MODULE_SYSTEM_MISMATCH` rather
359
+ than picking. No signal at all defaults to ESM, reported as
360
+ `signal: "default"` so a caller can tell a guess from a finding.
361
+ - Deliberately free of tree-sitter: every signal is a path or a regex, so it
362
+ costs nothing and works on a file that does not parse.
363
+
364
+ #### `src/core/locking.ts` — Advisory Locks and Concurrency (#21 / B18)
365
+ - Lockfiles under `<project root>/.hashpilot/locks/`, named by a SHA-256 of the
366
+ **absolute** target path, holding `{pid, nonce, ts, targets}`.
367
+ - **Both halves of the lock path must be cwd-independent.** The directory is
368
+ anchored to the *target file's* project root, not to `process.cwd()`: a
369
+ cwd-relative directory combined with an absolute key means two agents editing
370
+ one file from different working directories write to different lockfiles and
371
+ exclude nobody. The key is a cryptographic hash rather than a 32-bit string
372
+ fold, whose collisions made unrelated files share a lockfile — and since locks
373
+ are not re-entrant, a collision inside one batch self-deadlocks until timeout.
374
+ - **Acquisition is atomic.** The lockfile is created with `O_CREAT|O_EXCL`
375
+ (`writeFileSync` flag `wx`), so the existence check and the create are one
376
+ syscall. An `existsSync` guard followed by a plain write is check-then-act:
377
+ two processes can both observe no lockfile and both write.
378
+ - **The heartbeat is real.** A held lock refreshes its `ts` every 5s on an
379
+ `unref`'d timer. Reclaim requires *both* a heartbeat older than 30s and a dead
380
+ PID. Without the refresh, any lock held longer than the threshold was stealable
381
+ from a live, working holder.
382
+ - **Release is ownership-checked.** Each acquisition mints a `nonce`; release
383
+ unlinks only a lockfile still carrying it. A blind unlink-by-path would delete
384
+ the lockfile of whoever reclaimed and re-acquired after us, handing a third
385
+ writer the same file.
386
+ - Waiting always yields (50ms) between attempts rather than spinning, so a waiter
387
+ does not burn a core starving the very edit it is waiting on.
388
+ - `acquireLock(file)` for a single file; `acquireSortedLocks(files)` sorts and
389
+ dedupes **by lock path** (not by input path) so two plans touching `{A,B}` and
390
+ `{B,A}` cannot deadlock and no set can block against itself.
391
+ - `pruneStaleLocks(root?)` sweeps reclaimable leftovers from crashed processes.
392
+ - **Compare-and-swap is necessary but not sufficient.** CAS re-reads the file and
393
+ compares hashes before writing, but between that compare and `safeWrite` another
394
+ writer can land — and CAS then reports success over an edit it never saw. So
395
+ `routeEdit` holds the lock across the entire read → edit → compare → write
396
+ window; CAS is checking a snapshot nobody else can invalidate.
397
+ - **Locks are deliberately not re-entrant.** Refcounting by path would let two
398
+ genuinely concurrent writers *inside one process* both "hold" the lock, which is
399
+ the lost update the lock exists to prevent. `batch-edit` already locks its whole
400
+ file set up front, so it passes `alreadyLocked: true` to the router rather than
401
+ nesting an acquire that would wait on itself until the timeout.
402
+ - Release functions are idempotent: a `finally` that runs twice must not unlink a
403
+ lockfile a later acquirer now owns.
404
+ - A contended lock surfaces as a retryable `LOCK_TIMEOUT` (exit 3), not a hard
405
+ edit failure, so callers reuse the retry path they already have. It is
406
+ deliberately *not* reported as `STALE_ANCHOR`: that code tells the caller to
407
+ re-read the file, which changes nothing here, and it inflated the stale-anchor
408
+ health metric. `batch-edit` reports the same code for the same condition.
409
+
410
+ ```mermaid
411
+ sequenceDiagram
412
+ participant A as Writer A
413
+ participant L as .hashpilot/locks
414
+ participant F as file.ts
415
+ A->>L: acquireLock(file.ts)
416
+ L-->>A: held
417
+ A->>F: read + hash (CAS ref)
418
+ Note over A,F: edit computed
419
+ A->>F: re-read, compare, safeWrite
420
+ A->>L: release
421
+ Note over L: Writer B waited here,<br/>then reads A's committed bytes
422
+ ```
423
+
424
+ #### `src/snapshot.ts` — Pre-Edit Snapshots and Undo (#12)
425
+ - Content-addressed store at `~/.agentic-tools/snapshots/` (`objects/<sha256>` +
426
+ `index.jsonl`), outside the project tree so it never appears in `git status`.
427
+ - Keyed by changeSet ID — the CLI mints one per invocation via `createChangeSet()`,
428
+ so every write in one command undoes as a unit.
429
+ - `undoChangeSet(id, {force, dryRun})` restores the pre-*first*-edit bytes per file,
430
+ removes files the changeSet created, and refuses any file whose current hash no
431
+ longer matches what the edit wrote (`HASH_MISMATCH`, exit 3) unless `--force`.
432
+ - An undo is not itself snapshotted, so `undo --last` cannot ping-pong.
433
+ - Retention: 200 changeSets / 7 days by default, pruned on every invocation;
434
+ configurable under `snapshots` in `.hashpilot.json`.
435
+
436
+ #### `src/exit-codes.ts` — Agent-Facing Exit Contract
437
+ - Maps `ErrorCode` → process exit code: `0` ok, `1` usage, `2` edit failed,
438
+ `3` stale/precondition (retryable), `4` verify failed, `5` I/O, `70` internal.
439
+ - `finish(payload)` prints JSON and sets the code; batch commands take the worst.
440
+
441
+ #### `src/core/output.ts` — Verbosity and Color (#47)
442
+ - Global flags `-q, --quiet`, `-v, --verbose`, `--no-color`, resolved once in the
443
+ CLI's `preAction` hook via `configureOutput()` and read from anywhere after that.
444
+ - **Color is veto-only.** There is no `--color` force flag, so an escape sequence
445
+ can never enter a pipe. It requires *all* of: `--format text`, a TTY stdout,
446
+ `NO_COLOR` unset, `TERM !== "dumb"`, and no `--no-color`. JSON output is
447
+ therefore never colorized — the apiVersion 1 envelope stays byte-clean.
448
+ - `--quiet` beats `--verbose` when both are passed; the quieter ask is the safer
449
+ one. It drops the text-mode success line and all verbose diagnostics, but never
450
+ the JSON envelope: a caller who asked for JSON and got silence cannot tell
451
+ success from a crash.
452
+ - `verboseLog()` writes to **stderr** only, so `--verbose` never corrupts a
453
+ stdout parse. `routeEdit` emits the chosen tier, the reasons behind it, and the
454
+ elapsed time — routing being the most opaque decision HashPilot makes.
455
+ - Glyph colorization happens at exactly one `write()` choke point in `format.ts`,
456
+ so no renderer has to know whether color is on.
457
+
458
+ #### `src/redact.ts` — Credential Scrubbing
459
+ - `redactSecrets(text)`: replaces credential shapes (AWS, OpenAI, Anthropic,
460
+ GitHub, Slack, Google, JWT, private-key blocks, auth headers, connection-string
461
+ passwords, and secret-named assignments) with `[REDACTED]`.
462
+ - `isSensitiveFile(path)`: basename denylist (`.env*`, `*.pem`, `*.key`, `id_rsa`,
463
+ `credentials`, `.npmrc`, `.netrc`, `secrets.*`) used to suppress diff capture.
464
+ - `redactEvent(event)`: recursive walk applied to every telemetry record.
465
+
466
+ #### `src/telemetry.ts` — Structured JSONL Logging
467
+ - Logs to `~/.agentic-tools/logs/` (dir `0700`, file `0600`; older logs tightened on write)
468
+ - Kill switch, highest priority first: `--no-telemetry` → `HASHPILOT_TELEMETRY=0`
469
+ → `telemetry.enabled` in config → on
470
+ - Every record passes through `redactEvent` before it is written
471
+ - Records are capped at `telemetry.maxRecordBytes` (default 4 KB). A captured diff is the
472
+ one unbounded field, so an oversized one spills to a content-addressed payload store at
473
+ `~/.agentic-tools/logs/payloads/` and the record keeps `diffRef` + `diffBytes` — 27.5 KB
474
+ down to 185 B on a real edit. Readers rehydrate `diff` transparently, so the query
475
+ contract is unchanged, and `prunePayloads()` sweeps objects no record references ([#20](../../issues/20))
476
+ - Every CLI command records: operation name, route, file, language, success, elapsed_ms
477
+ - Health reports with threshold warnings:
478
+ - Stale-anchor rate (warns >10%)
479
+ - Diff fallback rate (warns >15%)
480
+ - Verify failure rate (warns >5%)
481
+ - Per-language failure rate (warns >10%)
482
+ - Trend comparison: compares current window vs previous window
483
+ - Sessions: group events by session ID
484
+
485
+ #### `src/verify.ts` — Verification Bundling
486
+ - `verifyChanges(files, options)`: Run checks on specified files
487
+ - All checks opt-in via CLI flags
488
+ - Auto-detects tools from:
489
+ - `package.json` (lint-staged, eslint, prettier, typescript, jest, vitest, bun:test)
490
+ - `pyproject.toml` (ruff, mypy, pytest)
491
+ - `go.mod` (gofmt, go vet)
492
+ - `Cargo.toml` (cargo fmt, cargo clippy, cargo test)
493
+ - Revert-on-failure: if verify fails, undo the edit
494
+ - **A run with no checks is `overall: "skipped"`, not `"pass"` (#106).** Every
495
+ check being opt-in meant a call that requested none had an empty check set, and
496
+ "all checks passed" is vacuously true over one — so nothing-was-verified was
497
+ reported identically to a fully green run, and `revertOnFailure` could never
498
+ fire. `skipped` carries `errorCode: VERIFY_NO_CHECKS` (exit 4) and a warning
499
+ naming the recovery; `checksRun` lists what actually ran on every result.
500
+ - **Binary allowlist (B19).** Verification spawns tools, so the command string is
501
+ an execution surface. Commands are split on whitespace and spawned as an argv
502
+ array — never through a shell — and the executable is checked three ways:
503
+ - A bare name must be on the allowlist (`prettier`, `tsc`, `pytest`, …).
504
+ - A *path* is resolved with `realpathSync` and must land in the project's own
505
+ `node_modules/.bin`. Matching on the basename instead let `/tmp/evil/tsc`
506
+ through: an allowlisted name on a file the caller chose to place there.
507
+ - Arguments are checked too. Several allowlisted tools have a flag that turns
508
+ them into a general-purpose interpreter (`node -e`, `python -c`, `go run`,
509
+ `bun -e`, `npx --call`), which defeats the allowlist entirely; those are
510
+ denied on any argument position.
511
+ - `--allow-arbitrary-tool` bypasses all three, and logs a `WARNING` naming the
512
+ command so an audit can tell a vetted tool from a bypassed one.
513
+ - **Scoping, baselines, and timeouts (#24).** Verification used to run the whole
514
+ suite and treat any red as "your edit broke this", which meant an unrelated
515
+ pre-existing failure could drive `--revert-on-failure` into deleting correct
516
+ work. Three changes close that:
517
+ - `src/verify-scope.ts` — `buildTestInvocation()` narrows the run to the tests
518
+ related to the changed files, per runner (`jest --findRelatedTests`,
519
+ `vitest --related=`, changed/convention-derived test files for `bun test`
520
+ and `pytest`, per-package `./dir` for `go test`, `--test <name>` for
521
+ `cargo test` when every change is an integration test). Every result reports
522
+ `testScope.scoped` and a `reason`, so an unscoped fallback is visible rather
523
+ than silent. `parseFailures()` extracts individual test names and returns
524
+ `null` — never an empty list — when the output shape is unrecognised. Its
525
+ jest/vitest file marker is `FAIL` only: `✗` also prefixes a failing test
526
+ name, and matching it as a filename swallowed every failure on that line.
527
+ - `src/verify-baseline.ts` — a pre-edit run of the same scope, cached under
528
+ `~/.agentic-tools/verify-baselines/` keyed by root + commit SHA + runner +
529
+ scope signature. `recordVerifyBaseline()` is called by `plan-executor` on the
530
+ pristine tree (the only honest moment) and exposed as `--record-baseline`.
531
+ With `--use-baseline`, only tests that were *not* already failing count.
532
+ Every uncertainty — missing baseline, runner mismatch, scope mismatch,
533
+ unparseable output — resolves to `comparable: false`, so a doubtful baseline
534
+ makes the caller re-check rather than suppressing a real regression.
535
+ - Timeouts are their own outcome: `overall: "timeout"` with
536
+ `ErrorCode.VERIFY_TIMEOUT` (still exit 4), excluded from both the verify
537
+ revert and the plan rollback. A check that never reached a verdict is
538
+ evidence of nothing. Child output is drained from stdout and stderr
539
+ concurrently, retaining 256 KB but reading past it, so a chatty tool cannot
540
+ deadlock on a full pipe and masquerade as a timeout.
541
+
542
+ #### `src/config.ts` — Layered Configuration
543
+ - Merge priority: env var → CLI flag → project `.hashpilot.json` → global `~/.config/hashpilot/config.json` → defaults
544
+ - Route policies can override routing per language or per operation; `null` in an override map unsets an inherited entry
545
+ - Config schema validated at load time
546
+ - Every `loadConfig` returns a deep clone — no returned config shares nested objects with the defaults or with a previous call, so a long-lived host (MCP server, library embedding) cannot leak one caller's mutation into the next
547
+
548
+ #### `src/doctor.ts` — Installation Health Check
549
+ - Verifies: core files exist, CLI is on PATH, config is valid
550
+ - Checks adapter integrations: Claude Code, OpenCode, Pi — all `skip` when absent, since nobody runs every host
551
+ - Probes every tree-sitter binding (`probeParsers`, `ast-edit.ts`). `getParser()` swallows load errors and the router silently downgrades AST → diff, so this is the only place a broken native build is visible before edit quality drops (#46)
552
+ - Reports: `installMode`, `summary {pass,fail,warn,skip}`, `versions {hashpilot,bun,node}`, `parsers[]`, `configPaths`, and a `remediation` command on every failure
553
+ - **Install-mode scoping** (`detectInstallMode`): `installed` (under `~/.agentic-tools`), `package` (a `node_modules` tree), or `source` (a working checkout). The `~/.agentic-tools` layout checks report `skip` outside an installed copy — without this, `bun run src/cli.ts doctor` in CI would fail on an install that was never meant to exist there
554
+ - **Health**: `healthy` is `fail === 0`. A `skip` means "does not apply here", not "broken"; requiring every check to `pass` marked a good install unhealthy whenever the user had no config file (#46)
555
+ - **Exit code**: `0` healthy · `1` warnings only · `2` failures, set through `finish()` like every other command. The old text path called `console.log` directly, so `doctor` always exited 0 and could not gate anything; `scripts/install.sh` now fails the install on `2`
556
+ - Version comes from `package.json`, not a literal — it read `0.1.0` for the whole 4.x line
557
+ - Single command: `hashpilot doctor`
558
+
559
+ #### `src/batch-edit.ts` — Batch Editing
560
+ - `editMany(operation, files)`: Same edit applied to many files in parallel
561
+ - `editManySerial(operation, files)`: Serial execution for dependent operations
562
+ - Parallel mode uses `Promise.all` for concurrent file processing
563
+
564
+ #### `src/core/operations.ts` — Operation Registry (#25)
565
+ - One declarative list of every operation both front doors expose: name, CLI command, params, and handler
566
+ - MCP tool schemas are generated from it, so the MCP surface cannot silently drift from the documented CLI
567
+ - Every handler delegates to `routeEdit`, so an MCP caller gets the same locking, compare-and-swap, snapshot, and provenance guarantees a CLI caller does
568
+ - `tests/operations-parity.test.ts` drives the real Commander `--help` tree and asserts every registry param exists as a real CLI flag or argument
569
+
570
+ #### `src/mcp/server.ts` — MCP Server (#25)
571
+ - Newline-delimited JSON-RPC 2.0 over stdio, protocol revision `2024-11-05`; no SDK dependency
572
+ - Methods: `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`
573
+ - Protocol errors (`-32700`/`-32600`/`-32601`/`-32603`) are the host's to handle; a failed edit comes back as `isError: true` on a successful result, for the model to read and recover from
574
+ - Requests are handled strictly in order: the advisory lock is not re-entrant, so concurrent handling would deadlock
575
+ - `hashpilot mcp --stdio` owns stdout for the whole process — it is the one command that emits no JSON envelope
576
+ - Host setup: [INTEGRATION-MCP.md](INTEGRATION-MCP.md)
577
+
578
+ #### `src/index.ts` — Barrel File
579
+ - Re-exports all public API surface from core modules
580
+
581
+ ---
582
+
583
+ ## Data Flow
584
+
585
+ ### The Canonical Edit Cycle
586
+
587
+ ```
588
+ READ EDIT VERIFY
589
+ ┌─────┐ ┌───────┐ ┌───────┐
590
+ │ │ hash + content │ │ edit result │ │
591
+ │ src/ ├────────────────▶│ route │───────────────────▶│ verify│
592
+ │ .ts │ │ .edit │ │ .ts │
593
+ │ │ ◀────────────────│ │ │ │
594
+ └─────┘ stale? re-read └───────┘ └───────┘
595
+
596
+ ┌─────┐ │
597
+ │ │ pass │
598
+ │ done│◀───────────────────│
599
+ │ │ │
600
+ └─────┘ │
601
+ │ fail
602
+
603
+ ┌─────────┐
604
+ │ revert │
605
+ └─────────┘
606
+ ```
607
+
608
+ ### Intent Flow (Multi-File)
609
+
610
+ ```
611
+ ┌────────┐ ┌───────────┐ ┌──────────┐ ┌──────────────┐
612
+ │ intent │───▶│ resolve │───▶│ plan │───▶│ execute │
613
+ │ parse │ │ symbols │ │ steps │ │ (via router) │
614
+ └────────┘ │ discover │ └──────────┘ └──────┬───────┘
615
+ │ refs │ │
616
+ └───────────┘ ▼
617
+ ┌─────────┐
618
+ │ verify │
619
+ │ steps │
620
+ └─────────┘
621
+ ```
622
+
623
+ ---
624
+
625
+ ## Edit Lifecycle (Step by Step)
626
+
627
+ 1. **Agent reads** a file via `read-many` → gets content + SHA-256 hash
628
+ 2. **Agent calls** `route-edit` (or `replace-hash`, `ast rename-symbol`, etc.)
629
+ 3. **Router determines** the best strategy:
630
+ - AST route: for supported languages + operations, tree-sitter guarantees structural validity
631
+ - Hash route: for all other cases, SHA-256 anchor guarantees content identity
632
+ - Diff route: fallback, LCS-based with fuzzy matching
633
+ 4. **Edit is applied** — returns success/failure + new hash if applicable
634
+ 5. **Telemetry records** the event (operation, route, file, language, success, elapsed_ms)
635
+ 6. **Provenance records** the change (actor, taskId, reason, timestamp)
636
+ 7. **(Optional) Verify** runs format + lint + typecheck + tests
637
+ 8. **(Optional) Auto-revert** if verify fails
638
+
639
+ ---
640
+
641
+ ## Key Design Decisions
642
+
643
+ ### 1. Tree-sitter for AST (not Babel, not TypeScript Compiler API)
644
+ - **Why:** Tree-sitter is incremental, fast, and supports multiple languages in one library. Babel/TypeScript are JS-only and require full project context. Tree-sitter queries are declarative and composable.
645
+ - **Cost:** Limited to 6 languages. Rust/Go work well; no Java, Kotlin, Swift, C#, or PHP support yet.
646
+ - **Mitigation:** Hash and Diff routes cover all languages. AST is a best-effort optimization, not a requirement.
647
+
648
+ ### 2. SHA-256 for Content Identity (not line numbers, not CRC)
649
+ - **Why:** SHA-256 is the standard for content verification. Collision-resistant, fast, and universally understood. Line numbers drift. CRCs are weak.
650
+ - **Cost:** Must read the file to compute the hash. Cannot hash without I/O.
651
+ - **Mitigation:** `read-many` returns both content and hash in one call. Cached by the agent.
652
+
653
+ ### 3. LCS for Diff (not Myers, not Patience)
654
+ - **Why:** LCS is simple, well-understood, and sufficient for search-and-replace with fuzzy matching. Myers and Patience are better for human diffs but overkill for machine-driven replacements.
655
+ - **Cost:** O(n²) on old+new content size. Long files with many changes hit quadratic behaviour.
656
+ - **Mitigation:** Content lengths are bounded by file size; typical edits are small (1-50 lines).
657
+
658
+ ### 4. 3-Tier Routing (not just one strategy)
659
+ - **Why:** No single strategy works for all files and all edits. AST requires a supported language. Hash requires knowing the old content. Diff is the catch-all.
660
+ - **Cost:** Routing logic adds complexity to the codebase.
661
+ - **Mitigation:** The router is a simple decision tree (~100 lines). Defaults are safe for all cases.
662
+
663
+ ### 5. Telemetry-First Design (not bolt-on)
664
+ - **Why:** AI agents are non-deterministic. Telemetry is the only way to know if edits are working correctly. Every CLI command records an event.
665
+ - **Cost:** Logs to `~/.agentic-tools/logs/` — disk usage proportional to usage.
666
+ - **Mitigation:** Health reports provide actionable signals (stale-anchor rate, diff-fallback rate).
667
+
668
+ ### 6. Provenance as First-Class Concern (not afterthought)
669
+ - **Why:** AI-generated changes need audit trails. Teams need to know which agent changed what and why.
670
+ - **Cost:** Every edit records additional metadata. Adds storage overhead.
671
+ - **Mitigation:** Provenance data is queryable per file/line — indexed for fast retrieval.
672
+
673
+ ---
674
+
675
+ ## Language Support Matrix
676
+
677
+ | Language | Extensions | AST Ops | Hash Ops | Diff Ops |
678
+ |----------|-----------|---------|----------|----------|
679
+ | TypeScript | `.ts` (not `.d.ts`) | All 7 | ✓ | ✓ |
680
+ | TSX | `.tsx` | All 7 | ✓ | ✓ |
681
+ | JavaScript | `.js`, `.jsx`, `.mjs`, `.cjs` | All 7 | ✓ | ✓ |
682
+ | Python | `.py` | All 7 | ✓ | ✓ |
683
+ | Go | `.go` | All 7 | ✓ | ✓ |
684
+ | Rust | `.rs` | All 7 | ✓ | ✓ |
685
+ | Any other | any | — | ✓ | ✓ |
686
+
687
+ ---
688
+
689
+ ## Configuration Reference
690
+
691
+ ```json
692
+ {
693
+ "routePolicy": {
694
+ "languageOverrides": {
695
+ "python": "hash",
696
+ "javascript": "ast"
697
+ },
698
+ "operationOverrides": {
699
+ "add-import": "diff",
700
+ "replace-body": "ast"
701
+ },
702
+ "conflictResolution": "operation"
703
+ },
704
+ "telemetry": {
705
+ "enabled": true,
706
+ "logDir": "~/.agentic-tools/logs"
707
+ },
708
+ "provenance": {
709
+ "enabled": true,
710
+ "storageDir": "~/.agentic-tools/provenance"
711
+ }
712
+ }
713
+ ```
714
+
715
+ Merge priority: `env var` → `CLI --config` → `.hashpilot.json` → `~/.config/hashpilot/config.json` → defaults.
716
+
717
+ Route policies:
718
+ - `languageOverrides`: force a route for a specific language (e.g., Python → hash). An explicit `null` **unsets** an override inherited from a lower-priority config, so a project can opt out of a global rule.
719
+ - `operationOverrides`: force a route for a specific operation (e.g., add-import → diff). Also accepts `null` to unset.
720
+ - `conflictResolution`: when language and operation rules conflict — `"language"`, `"operation"`, or `"strictest"` (the most restrictive wins)
721
+
722
+ ---
723
+
724
+ ## Adapters
725
+
726
+ HashPilot integrates with three coding agent platforms via the [Adapter Contract](ADAPTER-CONTRACT.md):
727
+
728
+ | Platform | Mechanism | Files |
729
+ |----------|-----------|-------|
730
+ | **Claude Code** | CLAUDE.md injection | `~/.claude/CLAUDE.md` + agent bindings |
731
+ | **OpenCode** | Skill + subagent | `~/.config/opencode/skills/hashpilot/` + `~/.config/opencode/agent/hashpilot.md` |
732
+ | **Pi** | Native extension | `~/.pi/agent/extensions/hashpilot.ts` + 7 custom tools |
733
+
734
+ Each adapter teaches the agent to use `hashpilot` commands instead of raw file editing.
735
+
736
+ ---
737
+
738
+ ## Telemetry & Health
739
+
740
+ ### Event Schema
741
+ ```json
742
+ {
743
+ "ts": "2026-06-11T20:12:45Z",
744
+ "operation": "replace-hash",
745
+ "route": "hash",
746
+ "file": "src/main.ts",
747
+ "language": "typescript",
748
+ "success": true,
749
+ "elapsed_ms": 42,
750
+ "actor": "claude",
751
+ "taskId": "abc123",
752
+ "reason": "Refactor port to config"
753
+ }
754
+ ```
755
+
756
+ ### Health Thresholds
757
+ | Metric | Warning | Critical |
758
+ |--------|---------|----------|
759
+ | Stale-anchor rate | >10% | >25% |
760
+ | Diff fallback rate | >15% | >30% |
761
+ | Verify failure rate | >5% | >15% |
762
+ | Per-language failure | >10% | >20% |
763
+
764
+ ### Trend Tracking
765
+ Health reports compare the current window against the previous window (same duration). Worsening trends are flagged even if absolute rates are below thresholds.
766
+
767
+ ---
768
+
769
+ ## Error Handling
770
+
771
+ ### Error Codes
772
+ | Code | Meaning | Recovery |
773
+ |------|---------|----------|
774
+ | `PARSE_ERROR` | Could not parse file | Fall back to hash route |
775
+ | `SYMBOL_NOT_FOUND` | Symbol not in tree | Fall back to hash route |
776
+ | `STALE_ANCHOR` | Hash mismatch (file changed) | Auto-recover: re-read and retry |
777
+ | `FILE_NOT_FOUND` | File does not exist | Return error to agent |
778
+ | `UNSUPPORTED_LANGUAGE` | AST not available | Fall back to hash route |
779
+ | `AMBIGUOUS_MATCH` | Diff found N > 1 matches | Return disambiguation hints |
780
+ | `VERIFY_FAILED` | Post-edit verification failed | Auto-revert (if configured) |
781
+
782
+ ### Recovery Strategy
783
+ - **Stale anchors:** Re-read the file, compute new hash, retry the edit. If still stale, report error.
784
+ - **Failed verifies:** If `--revert-on-fail` is set, undo the edit. Otherwise, return error with verify output.
785
+ - **Parse errors:** Router automatically falls back down the tier (AST → Hash → Diff).
786
+
787
+ ---
788
+
789
+ ## Future Directions
790
+
791
+ ### Planned
792
+ - **More AST languages:** Java, Kotlin, PHP, C#, Swift (blocked on tree-sitter grammar quality)
793
+ - **Batch verification:** Parallel verify across changed files
794
+ - **Provenance UI:** Web-based timeline of agent edits
795
+
796
+ ### Exploratory
797
+ - **Intent library:** Pre-built intents for common refactoring patterns
798
+ - **Learning mode:** Telemetry-driven route optimization (auto-select best route based on success rates)
799
+ - **Stale-anchor prediction:** Warn before stale anchor occurs (based on file change frequency)
800
+
801
+ ---
802
+
803
+ ## Post-Deploy Verification
804
+
805
+ Every deploy to GitHub Pages **must** be verified with browser automation:
806
+
807
+ ```yaml
808
+ # In gh-pages.yml — after peaceiris/actions-gh-pages
809
+ - name: Verify site with browser automation
810
+ run: |
811
+ SITE_URL="https://bigknoxy.github.io/HashPilot/"
812
+ agent-browser open "$SITE_URL"
813
+ agent-browser wait --load networkidle
814
+ TITLE=$(agent-browser eval "document.title")
815
+ HAS_PILOT=$(agent-browser eval "document.body.innerText.includes('HashPilot')")
816
+ if [ "$HAS_PILOT" = "true" ]; then
817
+ echo "✓ Site verified — $TITLE"
818
+ else
819
+ echo "✗ Verification failed"
820
+ agent-browser screenshot /tmp/deploy-failed.png
821
+ exit 1
822
+ fi
823
+ agent-browser screenshot /tmp/deploy-verified.png
824
+ agent-browser close
825
+ ```
826
+
827
+ **Why:** `curl` alone cannot verify JavaScript-rendered SPAs, console errors, or layout issues. Browser automation catches: broken assets, missing content, JS errors, incorrect routing, and visual regressions.
828
+
829
+ **Rule:** A deploy is not complete until browser verification passes with evidence (screenshot + text assertion). The verification must check for the correct branding/content on the live URL.
830
+
831
+ ---
832
+
833
+ ## How to Update This Document
834
+
835
+ 1. **When adding a new module:** Update the Module Architecture section. Add the module file to the table.
836
+ 2. **When changing routing logic:** Update the Router description. Note any new route policies.
837
+ 3. **When adding a new language:** Update the Language Support Matrix.
838
+ 4. **When changing the edit cycle:** Update the Data Flow section.
839
+ 5. **Every PR that touches `src/`:** Confirm that README.md and/or ARCHITECTURE.md reflects the change.
840
+ 6. **After every deploy:** Browser-verify the live site (see Post-Deploy Verification above).
841
+
842
+ The CI check `docs-verify` enforces rule 5 — if `src/` files change but neither landing nor design doc changes, the PR fails.
843
+
844
+ ---
845
+
846
+ _Last updated: 2026-08-20_