@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.
- package/LICENSE +21 -0
- package/README.md +777 -0
- package/docs/ADAPTER-CONTRACT.md +1260 -0
- package/docs/ARCHITECTURE.md +846 -0
- package/docs/CLI-QUICKREF.md +827 -0
- package/docs/COMPETITIVE-ANALYSIS.md +307 -0
- package/docs/INSTALL.md +403 -0
- package/docs/INTEGRATION-CLAUDE.md +126 -0
- package/docs/INTEGRATION-MCP.md +196 -0
- package/docs/INTEGRATION-OPENCODE.md +136 -0
- package/docs/INTEGRATION-PI.md +195 -0
- package/package.json +77 -0
- package/scripts/build-site.sh +39 -0
- package/scripts/doctor.sh +218 -0
- package/scripts/gen-cli-quickref.ts +232 -0
- package/scripts/install-cli.sh +60 -0
- package/scripts/install.sh +466 -0
- package/scripts/roadmap-lint.ts +200 -0
- package/scripts/uninstall.sh +202 -0
- package/src/cli-node.cjs +51 -0
- package/src/cli.ts +209 -0
- package/src/commands/ast.ts +255 -0
- package/src/commands/diff.ts +98 -0
- package/src/commands/edit.ts +93 -0
- package/src/commands/hash.ts +64 -0
- package/src/commands/intent.ts +68 -0
- package/src/commands/maintenance.ts +191 -0
- package/src/commands/mcp.ts +28 -0
- package/src/commands/provenance.ts +111 -0
- package/src/commands/read.ts +117 -0
- package/src/commands/route.ts +42 -0
- package/src/commands/shared.ts +65 -0
- package/src/commands/telemetry.ts +126 -0
- package/src/commands/verify.ts +61 -0
- package/src/core/ast-edit.ts +2357 -0
- package/src/core/batch-edit.ts +185 -0
- package/src/core/config.ts +189 -0
- package/src/core/diff-engine.ts +474 -0
- package/src/core/doctor.ts +303 -0
- package/src/core/encoding.ts +116 -0
- package/src/core/envelope.ts +163 -0
- package/src/core/exit-codes.ts +198 -0
- package/src/core/format.ts +339 -0
- package/src/core/grep.ts +180 -0
- package/src/core/hash-edit.ts +416 -0
- package/src/core/index.ts +155 -0
- package/src/core/intent.ts +584 -0
- package/src/core/locking.ts +292 -0
- package/src/core/module-system.ts +142 -0
- package/src/core/operations.ts +557 -0
- package/src/core/output.ts +122 -0
- package/src/core/path-normalize.ts +61 -0
- package/src/core/paths.ts +326 -0
- package/src/core/plan-executor.ts +437 -0
- package/src/core/platform.ts +132 -0
- package/src/core/provenance.ts +214 -0
- package/src/core/read.ts +111 -0
- package/src/core/redact.ts +98 -0
- package/src/core/resolve-content.ts +12 -0
- package/src/core/router.ts +463 -0
- package/src/core/snapshot.ts +346 -0
- package/src/core/telemetry.ts +838 -0
- package/src/core/utils.ts +7 -0
- package/src/core/verify-baseline.ts +186 -0
- package/src/core/verify-scope.ts +282 -0
- package/src/core/verify.ts +753 -0
- package/src/mcp/server.ts +325 -0
- package/templates/claude-section.md +12 -0
- package/templates/opencode-agent.md +106 -0
- package/templates/opencode-skill.md +241 -0
- package/templates/pi-extension.ts +288 -0
- package/templates/pi-skill.md +123 -0
- 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_
|