@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,303 @@
1
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "fs";
2
+ import { join } from "path";
3
+ import { loadConfig } from "./config";
4
+ import { diskUsage, DISK_WARN_BYTES } from "./telemetry";
5
+ import { probeParsers } from "./ast-edit";
6
+ import pkg from "../../package.json" with { type: "json" };
7
+
8
+ const HOME = process.env.HOME || "/root";
9
+ const AGENTIC_TOOLS = join(HOME, ".agentic-tools");
10
+ const CORE_DIR = join(AGENTIC_TOOLS, "structured-editing");
11
+ const BIN_DIR = join(AGENTIC_TOOLS, "bin");
12
+ const CLI_LAUNCHER = join(BIN_DIR, "hashpilot");
13
+ const LOG_DIR = join(AGENTIC_TOOLS, "logs");
14
+ const MANIFEST = join(AGENTIC_TOOLS, "manifest.json");
15
+ const CONFIG_DIR = join(HOME, ".config", "hashpilot");
16
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
17
+ const CLAUDE_FILE = join(HOME, ".claude", "CLAUDE.md");
18
+ const OPENCODE_SKILL = join(HOME, ".config", "opencode", "skills", "hashpilot", "SKILL.md");
19
+ const OPENCODE_AGENT = join(HOME, ".config", "opencode", "agent", "hashpilot.md");
20
+ const PI_EXTENSION = join(HOME, ".pi", "agent", "extensions", "hashpilot.ts");
21
+ const PI_SKILL = join(HOME, ".pi", "agent", "skills", "hashpilot", "SKILL.md");
22
+
23
+ export interface DoctorCheck {
24
+ name: string;
25
+ status: "pass" | "fail" | "warn" | "skip";
26
+ message: string;
27
+ /** Exact command that fixes this check. Required on every `fail` (#46). */
28
+ remediation?: string;
29
+ }
30
+
31
+ /**
32
+ * How this HashPilot is being run. The `~/.agentic-tools` layout checks only
33
+ * mean something for an `installed` copy — reporting them as failures when a
34
+ * contributor runs `bun run src/cli.ts doctor` from a checkout, or when a user
35
+ * installed via `npm i -g`, is a false alarm that makes the exit code useless
36
+ * as a gate (#46).
37
+ */
38
+ export type InstallMode = "installed" | "source" | "package";
39
+
40
+ export interface DoctorReport {
41
+ checks: DoctorCheck[];
42
+ /** True when no check failed. Warnings and skips do not make an install unhealthy. */
43
+ healthy: boolean;
44
+ timestamp: string;
45
+ version: string;
46
+ installMode: InstallMode;
47
+ summary: { pass: number; fail: number; warn: number; skip: number };
48
+ versions: Record<string, string>;
49
+ parsers: { lang: string; loaded: boolean; error?: string }[];
50
+ configPaths: { global: string; project: string; inUse: string[] };
51
+ /**
52
+ * 0 healthy · 1 warnings only · 2 one or more failures. Doctor is meant to be
53
+ * a CI gate and the installer's final verification step, so the health has to
54
+ * reach the shell (#46).
55
+ */
56
+ exitCode: 0 | 1 | 2;
57
+ errorCode?: string;
58
+ message?: string;
59
+ recovery?: string;
60
+ }
61
+
62
+ const HASH_VERSION: string = pkg.version;
63
+ const CLAUDE_MARKER = "HashPilot Claude — Structured Editing Integration";
64
+
65
+ function checkFile(path: string, label: string, remediation?: string): DoctorCheck {
66
+ if (existsSync(path)) {
67
+ return { name: label, status: "pass", message: `Found: ${path}` };
68
+ }
69
+ return { name: label, status: "fail", message: `Missing: ${path}`, remediation };
70
+ }
71
+
72
+ const checkDir = checkFile;
73
+
74
+ /**
75
+ * A check that only applies to an installed layout. Outside one it reports
76
+ * `skip` with the reason, so `bun run src/cli.ts doctor` in a checkout and
77
+ * `hashpilot doctor` from an npm install both stay exit 0 (#46).
78
+ */
79
+ function whenInstalled(mode: InstallMode, label: string, run: () => DoctorCheck): DoctorCheck {
80
+ if (mode === "installed") return run();
81
+ return {
82
+ name: label,
83
+ status: "skip",
84
+ message: mode === "source"
85
+ ? "Running from a source checkout — installed layout not expected"
86
+ : "Running from an npm package — ~/.agentic-tools layout not expected",
87
+ };
88
+ }
89
+
90
+ /**
91
+ * An adapter integration that is simply not installed is not a broken
92
+ * HashPilot. Nobody has every agent host on one machine, so a missing Pi
93
+ * extension used to fail an otherwise-perfect install (#46).
94
+ */
95
+ function checkOptionalFile(path: string, label: string, what: string): DoctorCheck {
96
+ if (existsSync(path)) return { name: label, status: "pass", message: `Found: ${path}` };
97
+ return { name: label, status: "skip", message: `${what} not installed` };
98
+ }
99
+
100
+ /**
101
+ * Where this HashPilot is running from. `installed` means the script lives
102
+ * under `~/.agentic-tools`; `package` means a node_modules tree (npm install);
103
+ * anything else is a working checkout.
104
+ */
105
+ export function detectInstallMode(dir: string = import.meta.dir): InstallMode {
106
+ if (dir.startsWith(AGENTIC_TOOLS + "/")) return "installed";
107
+ if (dir.includes("/node_modules/")) return "package";
108
+ return existsSync(CORE_DIR) ? "installed" : "source";
109
+ }
110
+
111
+ function checkWritable(path: string, label: string): DoctorCheck {
112
+ try {
113
+ if (!existsSync(path)) {
114
+ mkdirSync(path, { recursive: true });
115
+ }
116
+ const testFile = join(path, `.doctor-write-test-${Date.now()}`);
117
+ writeFileSync(testFile, "");
118
+ try { rmSync(testFile); } catch {}
119
+ return { name: label, status: "pass", message: `Writable: ${path}` };
120
+ } catch {
121
+ return { name: label, status: "fail", message: `Not writable: ${path}` };
122
+ }
123
+ }
124
+
125
+ export function doctor(): DoctorReport {
126
+ const checks: DoctorCheck[] = [];
127
+ const timestamp = new Date().toISOString();
128
+ const installMode = detectInstallMode();
129
+ const install = (label: string, run: () => DoctorCheck) => checks.push(whenInstalled(installMode, label, run));
130
+
131
+ // 1-3. Installed layout: core files, launcher, PATH, and a live CLI probe.
132
+ install("core-directory", () => checkDir(CORE_DIR, "core-directory", "Reinstall: curl -fsSL https://raw.githubusercontent.com/bigknoxy/HashPilot/main/scripts/install.sh | bash"));
133
+ install("core-cli.ts", () => checkFile(join(CORE_DIR, "src", "cli.ts"), "core-cli.ts", "Reinstall HashPilot"));
134
+ install("core-package.json", () => checkFile(join(CORE_DIR, "package.json"), "core-package.json", "Reinstall HashPilot"));
135
+ install("cli-launcher", () => checkFile(CLI_LAUNCHER, "cli-launcher", "Run `bun run install-cli`"));
136
+ install("bin-on-path", checkPathEntry);
137
+ install("cli-executable", checkCLIExecutable);
138
+
139
+ // 4. Config
140
+ checks.push(...checkConfig());
141
+
142
+ // 5. Agent host integrations — optional by nature.
143
+ checks.push(checkClaudeIntegration());
144
+ checks.push(checkOptionalFile(OPENCODE_SKILL, "opencode-skill", "OpenCode"));
145
+ checks.push(checkOptionalFile(OPENCODE_AGENT, "opencode-agent", "OpenCode"));
146
+ checks.push(checkOptionalFile(PI_EXTENSION, "pi-extension", "Pi"));
147
+ checks.push(checkOptionalFile(PI_SKILL, "pi-skill", "Pi"));
148
+
149
+ // 6. Telemetry store
150
+ checks.push(checkWritable(LOG_DIR, "telemetry-writable"));
151
+ checks.push(checkTelemetrySize());
152
+ install("manifest", () => checkFile(MANIFEST, "manifest", "Reinstall HashPilot"));
153
+
154
+ // 7. tree-sitter bindings. `getParser` swallows load errors and the router
155
+ // silently downgrades AST -> diff, so this is the only place a broken native
156
+ // build is visible before edit quality quietly drops (#46).
157
+ const parsers = probeParsers();
158
+ const broken = parsers.filter((p) => !p.loaded);
159
+ checks.push(
160
+ broken.length === 0
161
+ ? { name: "ast-parsers", status: "pass", message: `All ${parsers.length} tree-sitter parsers load` }
162
+ : {
163
+ name: "ast-parsers",
164
+ status: "fail",
165
+ message: `${broken.length}/${parsers.length} parsers failed to load: ${broken.map((p) => `${p.lang} (${p.error})`).join(", ")}`,
166
+ remediation: "Run `bun install` to rebuild the tree-sitter native bindings",
167
+ },
168
+ );
169
+
170
+ const summary = {
171
+ pass: checks.filter((c) => c.status === "pass").length,
172
+ fail: checks.filter((c) => c.status === "fail").length,
173
+ warn: checks.filter((c) => c.status === "warn").length,
174
+ skip: checks.filter((c) => c.status === "skip").length,
175
+ };
176
+ // A skip is "does not apply here", not "broken". Requiring every check to
177
+ // pass marked a perfectly good install unhealthy whenever the user had no
178
+ // config file or no Pi (#46).
179
+ const healthy = summary.fail === 0;
180
+ const exitCode: 0 | 1 | 2 = summary.fail > 0 ? 2 : summary.warn > 0 ? 1 : 0;
181
+
182
+ const failed = checks.filter((c) => c.status === "fail");
183
+ return {
184
+ checks,
185
+ healthy,
186
+ timestamp,
187
+ version: HASH_VERSION,
188
+ installMode,
189
+ summary,
190
+ versions: { hashpilot: HASH_VERSION, bun: Bun.version, node: process.versions.node },
191
+ parsers,
192
+ configPaths: {
193
+ global: CONFIG_FILE,
194
+ project: join(process.cwd(), ".hashpilot.json"),
195
+ inUse: [CONFIG_FILE, join(process.cwd(), ".hashpilot.json")].filter((p) => existsSync(p)),
196
+ },
197
+ exitCode,
198
+ ...(failed.length > 0
199
+ ? {
200
+ errorCode: "DOCTOR_FAILED",
201
+ message: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}`,
202
+ recovery: failed.find((c) => c.remediation)?.remediation,
203
+ }
204
+ : {}),
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Report the telemetry store's size. Retention is enforced automatically, but
210
+ * a machine that was never pruned by an older version — or one running with a
211
+ * long `retentionDays` — can still be carrying hundreds of megabytes nobody
212
+ * knows about (#50). A warn, never a fail: a large log is not a broken install.
213
+ */
214
+ function checkTelemetrySize(): DoctorCheck {
215
+ const bytes = diskUsage();
216
+ const mb = (bytes / (1024 * 1024)).toFixed(1);
217
+ if (bytes > DISK_WARN_BYTES) {
218
+ return {
219
+ name: "telemetry-size",
220
+ status: "warn",
221
+ message: `Telemetry store is ${mb} MB (threshold ${(DISK_WARN_BYTES / (1024 * 1024)).toFixed(0)} MB) — run 'hashpilot telemetry prune' or lower telemetry.retentionDays`,
222
+ };
223
+ }
224
+ return { name: "telemetry-size", status: "pass", message: `Telemetry store is ${mb} MB` };
225
+ }
226
+
227
+ /**
228
+ * The launcher existing is not the same as it being runnable. `install-cli`
229
+ * used to create the symlink and stop, leaving `hashpilot` unresolvable in a
230
+ * fresh shell while every other check passed. Report that as its own failure
231
+ * with the exact line to add.
232
+ */
233
+ export function checkPathEntry(): DoctorCheck {
234
+ const entries = (process.env.PATH || "").split(":").filter(Boolean);
235
+ if (entries.includes(BIN_DIR)) {
236
+ return { name: "bin-on-path", status: "pass", message: `${BIN_DIR} is on PATH` };
237
+ }
238
+ return {
239
+ name: "bin-on-path",
240
+ status: "fail",
241
+ message: `${BIN_DIR} is not on PATH. Run \`bun run install-cli\`, or add: export PATH="$HOME/.agentic-tools/bin:$PATH"`,
242
+ };
243
+ }
244
+
245
+ function checkCLIExecutable(): DoctorCheck {
246
+ try {
247
+ // Invoke the launcher by absolute path: this check is about whether the
248
+ // CLI runs at all. PATH resolution is `checkPathEntry`'s job, and injecting
249
+ // BIN_DIR here is what used to hide a broken install.
250
+ const proc = Bun.spawnSync([CLI_LAUNCHER, "--version"]);
251
+ if (proc.exitCode === 0) {
252
+ return { name: "cli-executable", status: "pass", message: `CLI works: ${proc.stdout.toString().trim()}` };
253
+ }
254
+ return { name: "cli-executable", status: "fail", message: `CLI exited with code ${proc.exitCode}: ${proc.stderr.toString().trim()}` };
255
+ } catch (e: any) {
256
+ return { name: "cli-executable", status: "fail", message: `Cannot run CLI: ${e.message}` };
257
+ }
258
+ }
259
+
260
+ function checkConfig(): DoctorCheck[] {
261
+ const results: DoctorCheck[] = [];
262
+ const cfgExists = existsSync(CONFIG_FILE);
263
+ if (cfgExists) {
264
+ results.push(checkFile(CONFIG_FILE, "config-file"));
265
+ try {
266
+ const cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
267
+ results.push({ name: "config-parseable", status: "pass", message: "Config is valid JSON" });
268
+ if (cfg.telemetry && typeof cfg.telemetry.enabled !== "boolean") {
269
+ results.push({ name: "config-telemetry-type", status: "warn", message: "telemetry.enabled should be boolean" });
270
+ }
271
+ if (cfg.routePolicy) {
272
+ results.push({ name: "config-has-policy", status: "pass", message: "Route policy configured" });
273
+ }
274
+ } catch {
275
+ results.push({ name: "config-parseable", status: "fail", message: "Config is not valid JSON" });
276
+ }
277
+ } else {
278
+ results.push({ name: "config-file", status: "skip", message: "No config file — using defaults" });
279
+ }
280
+ // Verify loadConfig() works regardless
281
+ try {
282
+ const cfg = loadConfig();
283
+ results.push({ name: "config-loadable", status: "pass", message: "Config defaults load correctly" });
284
+ } catch {
285
+ results.push({ name: "config-loadable", status: "fail", message: "Cannot load config" });
286
+ }
287
+ return results;
288
+ }
289
+
290
+ function checkClaudeIntegration(): DoctorCheck {
291
+ if (!existsSync(CLAUDE_FILE)) {
292
+ return { name: "claude-integration", status: "skip", message: "Claude CLAUDE.md not found — not installed" };
293
+ }
294
+ try {
295
+ const content = readFileSync(CLAUDE_FILE, "utf-8");
296
+ if (content.includes(CLAUDE_MARKER)) {
297
+ return { name: "claude-integration", status: "pass", message: "HashPilot section found in CLAUDE.md" };
298
+ }
299
+ return { name: "claude-integration", status: "warn", message: "CLAUDE.md exists but HashPilot section missing" };
300
+ } catch {
301
+ return { name: "claude-integration", status: "fail", message: "Cannot read CLAUDE.md" };
302
+ }
303
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Byte-fidelity for file content (issue #30).
3
+ *
4
+ * A structured-editing tool has exactly one non-negotiable property: it must
5
+ * not change bytes it was not asked to change. Reading a file with
6
+ * `.split("\n")` and writing it back with `.join("\n")` breaks that three ways
7
+ * — it deletes `\r` from every line of a CRLF file, folds a BOM into line 1
8
+ * where it corrupts that line's hash, and drops or invents a trailing newline.
9
+ * The result is a one-line edit that produces a diff touching every line.
10
+ *
11
+ * The fix is to normalize at the boundary: `decodeText` strips the BOM and
12
+ * converts every line ending to `\n`, all the editing tiers operate on that
13
+ * plain-LF text, and `encodeText` puts the original bytes back. Everything in
14
+ * between stays simple, and only this module knows about `\r`.
15
+ */
16
+
17
+ /** How a file's bytes were laid out, so a write can reproduce them. */
18
+ export interface FileEncoding {
19
+ /** File began with U+FEFF. */
20
+ bom: boolean;
21
+ /** Dominant line ending, used for any line the edit created. */
22
+ eol: "\n" | "\r\n" | "\r";
23
+ /**
24
+ * The original ending of each line, when the file mixed styles. Lines the
25
+ * edit did not add keep their own ending; anything past the end of this
26
+ * array falls back to `eol`. Absent when the file was consistent.
27
+ */
28
+ endings?: string[];
29
+ /** File ended with a line ending. */
30
+ trailingNewline: boolean;
31
+ }
32
+
33
+ const BOM = "";
34
+
35
+ /** Matches every line terminator we preserve: CRLF, lone LF, lone CR. */
36
+ const EOL_RE = /\r\n|\n|\r/g;
37
+
38
+ /**
39
+ * Split raw file text into plain-LF content plus the information needed to
40
+ * write the original bytes back. `encodeText(decodeText(raw))` is the
41
+ * identity for any input.
42
+ */
43
+ export function decodeText(raw: string): { text: string; encoding: FileEncoding } {
44
+ const bom = raw.startsWith(BOM);
45
+ const body = bom ? raw.slice(BOM.length) : raw;
46
+
47
+ const endings: string[] = [];
48
+ let counts = { "\n": 0, "\r\n": 0, "\r": 0 };
49
+ let text = "";
50
+ let last = 0;
51
+ EOL_RE.lastIndex = 0;
52
+ let m: RegExpExecArray | null;
53
+ while ((m = EOL_RE.exec(body)) !== null) {
54
+ text += body.slice(last, m.index) + "\n";
55
+ endings.push(m[0]);
56
+ counts[m[0] as keyof typeof counts]++;
57
+ last = m.index + m[0].length;
58
+ }
59
+ text += body.slice(last);
60
+
61
+ const trailingNewline = endings.length > 0 && last === body.length;
62
+
63
+ // Dominant style, with LF as the tiebreak: a file with no line endings at
64
+ // all has no evidence either way, and LF is what a new line should use.
65
+ let eol: FileEncoding["eol"] = "\n";
66
+ let best = 0;
67
+ for (const style of ["\n", "\r\n", "\r"] as const) {
68
+ if (counts[style] > best) {
69
+ best = counts[style];
70
+ eol = style;
71
+ }
72
+ }
73
+
74
+ const consistent = endings.every((e) => e === eol);
75
+ return {
76
+ text,
77
+ encoding: { bom, eol, trailingNewline, ...(consistent ? {} : { endings }) },
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Reassemble plain-LF text into the file's original byte layout.
83
+ *
84
+ * Line endings are restored by position, so an edit that rewrites line 3 of a
85
+ * mixed-ending file leaves lines 1, 2, and 4 exactly as they were. Lines the
86
+ * edit added take the dominant style — there is no original ending to copy.
87
+ *
88
+ * Trailing-newline presence follows the original file, not the edit: an agent
89
+ * that hands back content with or without a final newline is describing the
90
+ * lines it wants, not asking to change how the file terminates.
91
+ */
92
+ export function encodeText(text: string, encoding: FileEncoding): string {
93
+ // Emptying a file means an empty file. Restoring the trailing newline here
94
+ // would turn a deletion into a file containing one blank line.
95
+ if (text === "") return encoding.bom ? BOM : "";
96
+
97
+ const hadTrailing = text.endsWith("\n");
98
+ const body = hadTrailing ? text.slice(0, -1) : text;
99
+ const lines = body.split("\n");
100
+
101
+ let out = encoding.bom ? BOM : "";
102
+ for (let i = 0; i < lines.length; i++) {
103
+ out += lines[i];
104
+ const isLast = i === lines.length - 1;
105
+ if (isLast && !encoding.trailingNewline) break;
106
+ out += encoding.endings?.[i] ?? encoding.eol;
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /** Read a file and decode it in one step. */
112
+ export async function readDecoded(
113
+ filePath: string
114
+ ): Promise<{ text: string; encoding: FileEncoding }> {
115
+ return decodeText(await Bun.file(filePath).text());
116
+ }
@@ -0,0 +1,163 @@
1
+ import { ErrorCode } from "./telemetry";
2
+ import { ExitCode, exitCodeFor, type ResultLike } from "./exit-codes";
3
+
4
+ /**
5
+ * The single JSON shape every command writes to stdout.
6
+ *
7
+ * Before this existed, each of the 24 commands returned its own ad-hoc shape —
8
+ * some a bare array, some an object — so an adapter had to special-case the
9
+ * command it had just run, and had no field to detect a contract change with.
10
+ * See docs/ADAPTER-CONTRACT.md and schema/hashpilot-envelope.schema.json.
11
+ */
12
+ export interface Envelope<T = unknown> {
13
+ /** Bumped only on a breaking change to the envelope itself. */
14
+ apiVersion: string;
15
+ /** The one boolean an adapter checks. Always agrees with the exit code. */
16
+ ok: boolean;
17
+ /** The subcommand path that produced this, e.g. `telemetry show`. */
18
+ command: string;
19
+ /** The per-command payload — the shape commands used to return at top level. */
20
+ data: T | null;
21
+ error: EnvelopeError | null;
22
+ /** Non-fatal notices (route fallback, anchor relocation, corrupt log lines). */
23
+ warnings: EnvelopeWarning[];
24
+ }
25
+
26
+ export interface EnvelopeError {
27
+ /** A member of `ErrorCode`. Adapters branch on this, never on `message`. */
28
+ code: string;
29
+ message: string;
30
+ /**
31
+ * The literal next command to run, where one exists — not prose. This is what
32
+ * turns a dead end into a retry, so prefer a runnable string over advice.
33
+ */
34
+ recovery?: string;
35
+ details?: Record<string, unknown>;
36
+ }
37
+
38
+ export interface EnvelopeWarning {
39
+ code: string;
40
+ message: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ /** Current envelope version. Bump only when the envelope's own shape breaks. */
45
+ export const API_VERSION = "1";
46
+
47
+ let currentCommand = "";
48
+ let warnings: EnvelopeWarning[] = [];
49
+
50
+ /** Records which subcommand is running, so `wrap` can name it. Set by the CLI's preAction hook. */
51
+ export function setCommand(name: string): void {
52
+ currentCommand = name;
53
+ warnings = [];
54
+ }
55
+
56
+ export function currentCommandName(): string {
57
+ return currentCommand;
58
+ }
59
+
60
+ /**
61
+ * Attach a non-fatal notice to the envelope the current command will emit.
62
+ *
63
+ * Route fallbacks and anchor relocations used to be entirely invisible: an AST
64
+ * edit that silently became a diff edit looked identical to one that did not.
65
+ */
66
+ export function addWarning(warning: EnvelopeWarning): void {
67
+ warnings.push(warning);
68
+ }
69
+
70
+ export function takeWarnings(): EnvelopeWarning[] {
71
+ const taken = warnings;
72
+ warnings = [];
73
+ return taken;
74
+ }
75
+
76
+ /** Fields a command result may carry that the envelope lifts out of `data`. */
77
+ interface ErrorBearing {
78
+ success?: boolean;
79
+ passed?: boolean;
80
+ error?: unknown;
81
+ errorCode?: string;
82
+ message?: string;
83
+ recovery?: string;
84
+ }
85
+
86
+ function firstFailure(payload: unknown): ErrorBearing | undefined {
87
+ if (Array.isArray(payload)) {
88
+ // Batch results: report the first failing element, since the exit code
89
+ // already reflects the worst one.
90
+ for (const item of payload) {
91
+ const found = firstFailure(item);
92
+ if (found) return found;
93
+ }
94
+ return undefined;
95
+ }
96
+ if (!payload || typeof payload !== "object") return undefined;
97
+ const p = payload as ErrorBearing & { result?: unknown };
98
+ // `errorCode` alone counts as a failure. `verify-changes` returns neither
99
+ // `success` nor `error` — only `errorCode` — so its failures used to reach the
100
+ // envelope as `{code: "UNKNOWN", message: "Operation failed."}` beside a
101
+ // perfectly specific `data.errorCode` (#106).
102
+ const failed =
103
+ p.success === false || p.passed === false || (p.error !== undefined && p.error !== null) || !!p.errorCode;
104
+ if (failed) return p;
105
+ // Wrapper shapes (route-edit, batch) nest the real outcome under `result`.
106
+ return p.result ? firstFailure(p.result) : undefined;
107
+ }
108
+
109
+ function messageOf(p: ErrorBearing): string {
110
+ if (typeof p.message === "string" && p.message) return p.message;
111
+ if (typeof p.error === "string" && p.error) return p.error;
112
+ if (p.error && typeof p.error === "object") {
113
+ const nested = (p.error as { message?: string }).message;
114
+ if (nested) return nested;
115
+ }
116
+ return "Operation failed.";
117
+ }
118
+
119
+ function codeOf(p: ErrorBearing): string {
120
+ if (p.errorCode) return p.errorCode;
121
+ if (p.error && typeof p.error === "object") {
122
+ const nested = (p.error as { code?: string }).code;
123
+ if (nested) return nested;
124
+ }
125
+ // An unmapped failure is still a failure. Naming it beats emitting `ok: false`
126
+ // with no code for an adapter to branch on.
127
+ return ErrorCode.UNKNOWN;
128
+ }
129
+
130
+ /**
131
+ * Wrap a command result in the envelope.
132
+ *
133
+ * `ok` is derived from the same exit code the process will use, so the two
134
+ * cannot disagree — an adapter that trusts `ok` and one that trusts `$?` reach
135
+ * the same conclusion.
136
+ */
137
+ export function wrap<T>(payload: T, code: ExitCode, command = currentCommand): Envelope<T> {
138
+ const failure = code === ExitCode.OK ? undefined : firstFailure(payload);
139
+ const error: EnvelopeError | null =
140
+ code === ExitCode.OK
141
+ ? null
142
+ : failure
143
+ ? {
144
+ code: codeOf(failure),
145
+ message: messageOf(failure),
146
+ ...(failure.recovery ? { recovery: failure.recovery } : {}),
147
+ }
148
+ : { code: ErrorCode.UNKNOWN, message: "Operation failed." };
149
+
150
+ return {
151
+ apiVersion: API_VERSION,
152
+ ok: code === ExitCode.OK,
153
+ command,
154
+ data: payload ?? null,
155
+ error,
156
+ warnings: takeWarnings(),
157
+ };
158
+ }
159
+
160
+ /** Convenience for callers that have a result but not yet an exit code. */
161
+ export function wrapResult<T>(payload: T, command?: string): Envelope<T> {
162
+ return wrap(payload, exitCodeFor(payload as ResultLike), command);
163
+ }