@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,202 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ BOLD='\033[1m'
5
+ DIM='\033[2m'
6
+ GREEN='\033[0;32m'
7
+ YELLOW='\033[0;33m'
8
+ RED='\033[0;31m'
9
+ NC='\033[0m'
10
+
11
+ log() { printf "${GREEN}[hashpilot]${NC} %s\n" "$1"; }
12
+ warn() { printf "${YELLOW}[hashpilot]${NC} %s\n" "$1"; }
13
+ err() { printf "${RED}[hashpilot]${NC} %s\n" "$1"; }
14
+ detail() { printf "${DIM} →${NC} %s\n" "$1"; }
15
+
16
+ TARGET_DIR="${HASHPILOT_DIR:-${HOME}/.agentic-tools}"
17
+ KEEP_CONFIG=false
18
+ FORCE=false
19
+
20
+ while [[ $# -gt 0 ]]; do
21
+ case "$1" in
22
+ --keep-config) KEEP_CONFIG=true; shift ;;
23
+ --force|-f) FORCE=true; shift ;;
24
+ --help|-h)
25
+ echo "HashPilot Uninstaller"
26
+ echo "Usage: $0 [options]"
27
+ echo " --keep-config Preserve config and telemetry data"
28
+ echo " --force, -f Skip confirmation prompt (auto-detected when piped)"
29
+ echo " --help, -h Show this help"
30
+ echo ""
31
+ echo "One-liner: curl -fsSL https://raw.githubusercontent.com/bigknoxy/HashPilot/main/scripts/uninstall.sh | sh -s -- -f"
32
+ exit 0
33
+ ;;
34
+ *) err "Unknown option: $1"; exit 1 ;;
35
+ esac
36
+ done
37
+
38
+ echo "${BOLD}HashPilot Uninstaller${NC}"
39
+ echo ""
40
+
41
+ # ── Confirmation ─────────────────────────────────────────────────────────
42
+ if [[ "$FORCE" != "true" ]]; then
43
+ # When piped (no TTY), skip prompt — auto-force
44
+ if ! [[ -t 0 ]]; then
45
+ FORCE=true
46
+ else
47
+ echo "This will remove HashPilot and all its components."
48
+ echo " Target: $TARGET_DIR"
49
+ echo " Keep config: $KEEP_CONFIG"
50
+ echo ""
51
+ echo -n "Continue? [y/N] "
52
+ read -r CONFIRM
53
+ if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then
54
+ log "Uninstall cancelled."
55
+ exit 0
56
+ fi
57
+ fi
58
+ fi
59
+
60
+ REMOVED=0
61
+ SKIPPED=0
62
+
63
+ remove_file() {
64
+ local path="$1"
65
+ local label="$2"
66
+ # Expand ~ if present
67
+ path="${path/#\~/${HOME}}"
68
+ if [[ -f "$path" ]]; then
69
+ rm -f "$path"
70
+ detail "Removed ${label}: ${path}"
71
+ REMOVED=$((REMOVED+1))
72
+ elif [[ -d "$path" ]]; then
73
+ rm -rf "$path"
74
+ detail "Removed ${label}: ${path}"
75
+ REMOVED=$((REMOVED+1))
76
+ else
77
+ detail "Already removed: ${path}"
78
+ SKIPPED=$((SKIPPED+1))
79
+ fi
80
+ }
81
+
82
+ # ── Read manifest if available ───────────────────────────────────────────
83
+ MANIFEST="$TARGET_DIR/manifest.json"
84
+ if [[ -f "$MANIFEST" ]]; then
85
+ log "Reading manifest..."
86
+ else
87
+ warn "No manifest found at $MANIFEST — will attempt best-effort cleanup"
88
+ fi
89
+
90
+ # ── Claude integration ───────────────────────────────────────────────────
91
+ log "Removing Claude integration..."
92
+ CLAUDE_FILE="${HOME}/.claude/CLAUDE.md"
93
+ CLAUDE_MARKER_START="HashPilot Claude"
94
+ if [[ -f "$CLAUDE_FILE" ]]; then
95
+ # Remove the HashPilot section: from marker to the next ## or end
96
+ if grep -q "$CLAUDE_MARKER_START" "$CLAUDE_FILE" 2>/dev/null; then
97
+ # Use sed to remove from HashPilot heading to end (or next heading)
98
+ if grep -q "^## " "$CLAUDE_FILE" 2>/dev/null; then
99
+ # Has multiple sections — remove just this one by matching its end
100
+ sed -i '/^## .*HashPilot Claude/,/^## /{
101
+ /^## .*HashPilot Claude/d
102
+ /^## /!d
103
+ }' "$CLAUDE_FILE"
104
+ else
105
+ # Single section — remove from HashPilot heading to end
106
+ sed -i '/^## .*HashPilot Claude/,$d' "$CLAUDE_FILE"
107
+ fi
108
+ # Clean up trailing blank lines + deduplicate mid-file blank lines
109
+ sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' "$CLAUDE_FILE" 2>/dev/null || true
110
+ sed -i '/^$/{ N; /^\n$/d }' "$CLAUDE_FILE" 2>/dev/null || true
111
+ detail "Removed HashPilot section from $CLAUDE_FILE"
112
+ REMOVED=$((REMOVED+1))
113
+ else
114
+ detail "HashPilot section not found in $CLAUDE_FILE (skipping)"
115
+ SKIPPED=$((SKIPPED+1))
116
+ fi
117
+ else
118
+ detail "CLAUDE.md not found (skipping)"
119
+ SKIPPED=$((SKIPPED+1))
120
+ fi
121
+
122
+ # ── OpenCode integration ─────────────────────────────────────────────────
123
+ log "Removing OpenCode integration..."
124
+ remove_file "${HOME}/.config/opencode/skills/hashpilot/SKILL.md" "OpenCode skill"
125
+ remove_file "${HOME}/.config/opencode/agent/hashpilot.md" "OpenCode agent"
126
+ # Remove empty skill/agent dirs
127
+ rmdir "${HOME}/.config/opencode/skills/hashpilot" 2>/dev/null || true
128
+ rmdir "${HOME}/.config/opencode/skills" 2>/dev/null || true
129
+ rmdir "${HOME}/.config/opencode/agent" 2>/dev/null || true
130
+
131
+ # ── Pi integration ───────────────────────────────────────────────────────
132
+ log "Removing Pi integration..."
133
+ remove_file "${HOME}/.pi/agent/extensions/hashpilot.ts" "Pi extension"
134
+ remove_file "${HOME}/.pi/agent/skills/hashpilot/SKILL.md" "Pi skill"
135
+ rmdir "${HOME}/.pi/agent/extensions" 2>/dev/null || true
136
+ rmdir "${HOME}/.pi/agent/skills/hashpilot" 2>/dev/null || true
137
+ rmdir "${HOME}/.pi/agent/skills" 2>/dev/null || true
138
+
139
+ # ── Core, bin, telemetry ─────────────────────────────────────────────────
140
+ log "Removing Core files..."
141
+ remove_file "$TARGET_DIR/bin/hashpilot" "CLI launcher"
142
+ # Also remove stale symlink from old binary name (pre-3.1 installs).
143
+ remove_file "$TARGET_DIR/bin/structured-edit" "stale CLI launcher (old name)"
144
+ rmdir "$TARGET_DIR/bin" 2>/dev/null || true
145
+
146
+ if [[ "$KEEP_CONFIG" == "true" ]]; then
147
+ detail "Preserving telemetry (--keep-config)"
148
+ # Remove core but keep logs
149
+ remove_file "$TARGET_DIR/structured-editing" "Core source"
150
+ # Preserve logs
151
+ detail "Preserving: $TARGET_DIR/logs"
152
+ else
153
+ remove_file "$TARGET_DIR/structured-editing" "Core source"
154
+ remove_file "$TARGET_DIR/logs" "Telemetry logs"
155
+ fi
156
+
157
+ # ── Config ───────────────────────────────────────────────────────────────
158
+ if [[ "$KEEP_CONFIG" == "true" ]]; then
159
+ detail "Preserving config (--keep-config)"
160
+ else
161
+ remove_file "${HOME}/.config/hashpilot/config.json" "Config file"
162
+ rmdir "${HOME}/.config/hashpilot" 2>/dev/null || true
163
+ fi
164
+
165
+ # ── Remove PATH entry from shell rc ──────────────────────────────────────
166
+ log "Removing PATH entries..."
167
+ for rc in "${HOME}/.bashrc" "${HOME}/.zshrc" "${HOME}/.bash_profile" "${HOME}/.profile"; do
168
+ if [[ -f "$rc" ]]; then
169
+ if grep -q "# >>> hashpilot path >>>" "$rc" 2>/dev/null; then
170
+ # BSD sed (macOS) requires an explicit backup suffix after -i; GNU sed
171
+ # accepts the empty one, so `-i ''` is not portable either. Pick per-platform.
172
+ if sed --version >/dev/null 2>&1; then SED_INPLACE=(sed -i); else SED_INPLACE=(sed -i ''); fi
173
+ "${SED_INPLACE[@]}" '/^# >>> hashpilot path >>>/,/^# <<< hashpilot path <<</d' "$rc"
174
+ # Clean up trailing blank lines
175
+ "${SED_INPLACE[@]}" -e :a -e '/^\n*$/{$d;N;ba' -e '}' "$rc" 2>/dev/null || true
176
+ detail "Removed PATH entry from $rc"
177
+ REMOVED=$((REMOVED+1))
178
+ fi
179
+ fi
180
+ done
181
+
182
+ # ── Remove manifest and target dir if empty ──────────────────────────────
183
+ remove_file "$MANIFEST" "Manifest"
184
+ rmdir "$TARGET_DIR" 2>/dev/null || true
185
+
186
+ # ── Summary ───────────────────────────────────────────────────────────────
187
+ echo ""
188
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
189
+ printf "${GREEN} HashPilot uninstalled${NC}\n"
190
+ printf " Removed: %d Skipped: %d\n" "$REMOVED" "$SKIPPED"
191
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
192
+ echo ""
193
+ echo "Note: The following may remain if they existed before HashPilot:"
194
+ echo " ~/.bashrc modifications (restored)"
195
+ echo " ~/.claude/CLAUDE.md modifications (restored)"
196
+ echo " ~/.pi/agent/extensions/ (if still empty)"
197
+ echo " ~/.config/opencode/skills/hashpilot/ (if still empty)"
198
+ echo ""
199
+ echo "To complete removal, restart your shell or run:"
200
+ echo " hash -r 2>/dev/null || exec \$SHELL"
201
+ echo ""
202
+
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ // Node-parseable entry point for the `hashpilot` binary.
3
+ //
4
+ // HashPilot runs on Bun (src/cli.ts has a `#!/usr/bin/env bun` shebang and uses
5
+ // Bun-only APIs). Pointing `bin` straight at the TypeScript source means
6
+ // `npm i -g hashpilot` on a Node-only machine installs cleanly and then dies with
7
+ // a syntax error on first use. This shim is deliberately plain CommonJS so any
8
+ // Node >= 14 can parse it, and its only job is to hand off to Bun — or, when Bun
9
+ // is absent, to say so in one actionable line instead of a stack trace.
10
+ //
11
+ // `.cjs` (not `.js`) because package.json declares `"type": "module"`.
12
+ // See issue #35, Option A.
13
+
14
+ "use strict";
15
+
16
+ var path = require("path");
17
+ var spawnSync = require("child_process").spawnSync;
18
+
19
+ var CLI = path.join(__dirname, "cli.ts");
20
+
21
+ // Array argv, never a shell string: arguments routinely contain source code,
22
+ // spaces, quotes, and newlines, and a shell would both mangle and execute them.
23
+ var res = spawnSync("bun", ["run", CLI].concat(process.argv.slice(2)), {
24
+ stdio: "inherit",
25
+ });
26
+
27
+ if (res.error && res.error.code === "ENOENT") {
28
+ process.stderr.write(
29
+ "hashpilot requires Bun (>= 1.2.0), which was not found on your PATH.\n" +
30
+ "\n" +
31
+ " Install it: curl -fsSL https://bun.sh/install | bash\n" +
32
+ " Then reopen your shell and re-run this command.\n" +
33
+ "\n" +
34
+ "HashPilot is Bun-only today; see the support matrix in the README.\n"
35
+ );
36
+ process.exit(127);
37
+ }
38
+
39
+ if (res.error) {
40
+ process.stderr.write("hashpilot: failed to launch Bun: " + res.error.message + "\n");
41
+ process.exit(70);
42
+ }
43
+
44
+ // Propagate the child's exit status verbatim. HashPilot's exit codes are a
45
+ // documented contract (0 ok · 1 usage · 2 edit failed · 3 stale · 4 verify
46
+ // failed · 5 I/O · 70 internal); collapsing them to 0/1 would break every
47
+ // caller that branches on code 3 to retry a stale anchor.
48
+ if (res.signal) {
49
+ process.exit(128 + (require("os").constants.signals[res.signal] || 0));
50
+ }
51
+ process.exit(res.status === null ? 70 : res.status);
package/src/cli.ts ADDED
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env bun
2
+ import { Command, CommanderError } from "commander";
3
+ // Single source of truth for the version. Bun inlines this JSON import at build
4
+ // time, so dist/ carries the real version instead of a hardcoded literal.
5
+ import pkg from "../package.json" with { type: "json" };
6
+ import {
7
+ loadConfig,
8
+ createChangeSet,
9
+ configureSnapshots,
10
+ setCurrentChangeSet,
11
+ pruneSnapshots,
12
+ PathDeniedError,
13
+ configureWriteBoundary,
14
+ finish,
15
+ usageError,
16
+ setCommand,
17
+ setAllowParseErrors,
18
+ ErrorCode,
19
+ ExitCode,
20
+ configureTelemetry,
21
+ enableTelemetry,
22
+ resolveTelemetryEnabled,
23
+ setOutputFormat,
24
+ configureOutput,
25
+ resolveFormat,
26
+ TelemetryReadError,
27
+ } from "./core/index";
28
+
29
+ // Every command group registers itself onto `program`. The registration order
30
+ // below is the order the groups appear in `--help`; do not reshuffle it (#48).
31
+ import { register as registerRead } from "./commands/read";
32
+ import { register as registerHash } from "./commands/hash";
33
+ import { register as registerAst } from "./commands/ast";
34
+ import { register as registerEdit } from "./commands/edit";
35
+ import { register as registerIntent } from "./commands/intent";
36
+ import { register as registerDiff } from "./commands/diff";
37
+ import { register as registerVerify } from "./commands/verify";
38
+ import { register as registerTelemetry } from "./commands/telemetry";
39
+ import { register as registerProvenance } from "./commands/provenance";
40
+ import { register as registerMcp } from "./commands/mcp";
41
+ import { register as registerMaintenance } from "./commands/maintenance";
42
+ import { register as registerRoute } from "./commands/route";
43
+
44
+ const VERSION: string = pkg.version;
45
+
46
+ const program = new Command();
47
+
48
+ program
49
+ .name("hashpilot")
50
+ .description("HashPilot — Structured Editing Core for Coding Agents")
51
+ .version(VERSION)
52
+ .option("--allow-outside-root", "Permit writes outside the project root (credentials and system paths stay blocked)")
53
+ .option("--allowed-root <dir...>", "Additional directory writes may target")
54
+ .option("--no-telemetry", "Disable telemetry logging for this invocation")
55
+ .option("--allow-parse-errors", "Edit a file that already has syntax errors (the post-edit parse check still applies)")
56
+ .option("--format <fmt>", "Output format: json or text (default: json if piped/CI, text if TTY)")
57
+ .option("--json", "[deprecated: use --format json] Force JSON output", false)
58
+ .option("-q, --quiet", "Suppress the human-readable success line (the JSON envelope is never suppressed)")
59
+ .option("-v, --verbose", "Write routing and timing diagnostics to stderr")
60
+ .option("--no-color", "Disable ANSI color in text output (also honors NO_COLOR)")
61
+ .hook("preAction", (thisCommand, actionCommand) => {
62
+ // Name the running subcommand so the envelope can report it. Walk up so
63
+ // nested commands read as "telemetry show", not "show".
64
+ const path: string[] = [];
65
+ for (let c: typeof actionCommand | null = actionCommand; c && c.parent; c = c.parent) path.unshift(c.name());
66
+ setCommand(path.join(" "));
67
+
68
+ // #19 (B16): resolve output format and set it globally
69
+ const globals = thisCommand.opts();
70
+ const { format, warnDeprecate } = resolveFormat(globals, { ci: process.env.CI === "true" || process.env.CI === "1" });
71
+ setOutputFormat(format, path.join(" "));
72
+ // Color and verbosity resolve from the same globals, after the format is
73
+ // known: color is text-mode only, so JSON output can never carry escapes (#47).
74
+ configureOutput({
75
+ quiet: Boolean(globals.quiet),
76
+ verbose: Boolean(globals.verbose),
77
+ color: globals.color,
78
+ format,
79
+ isTTY: Boolean(process.stdout.isTTY),
80
+ });
81
+ if (warnDeprecate) process.stderr.write("[deprecation] --json is deprecated; use --format json\n");
82
+ const config = loadConfig();
83
+ configureWriteBoundary({
84
+ allowOutsideRoot: Boolean(globals.allowOutsideRoot),
85
+ allowedRoots: [...(config.allowedRoots || []), ...(globals.allowedRoot || [])],
86
+ });
87
+ // Apply sizing/retention from config, then the kill switch, so the CLI flag
88
+ // and env var win over `telemetry.enabled`.
89
+ configureTelemetry(config.telemetry);
90
+ enableTelemetry(resolveTelemetryEnabled(config.telemetry, globals.telemetry === false));
91
+ setAllowParseErrors(Boolean(globals.allowParseErrors));
92
+
93
+ // Every write this invocation makes belongs to one changeSet, so `undo`
94
+ // has a unit to work in even for commands that never mint one themselves.
95
+ configureSnapshots(config.snapshots);
96
+ setCurrentChangeSet(config.snapshots?.enabled === false ? null : createChangeSet());
97
+ pruneSnapshots();
98
+ });
99
+ registerRead(program);
100
+ registerHash(program);
101
+ registerAst(program);
102
+ registerEdit(program);
103
+ registerIntent(program);
104
+ registerDiff(program);
105
+ registerVerify(program);
106
+ registerTelemetry(program);
107
+ registerProvenance(program);
108
+ registerMcp(program);
109
+ registerMaintenance(program);
110
+ registerRoute(program);
111
+
112
+ /** Node syscall codes that mean "the filesystem said no", not "HashPilot has a bug". */
113
+ const IO_SYSCALL_CODES = new Set([
114
+ "ENOENT", "EACCES", "EPERM", "EISDIR", "ENOTDIR", "ENOSPC", "EROFS", "EMFILE", "ENFILE", "EBUSY",
115
+ ]);
116
+
117
+ /**
118
+ * Nothing below a command action should ever surface a raw stack trace to an
119
+ * agent parsing stdout. Uncaught failures exit 70 with the same JSON envelope
120
+ * shape as every other error.
121
+ */
122
+ function reportInternalError(err: unknown): void {
123
+ const message = err instanceof Error ? err.message : String(err);
124
+ if (err instanceof TelemetryReadError) {
125
+ // A log that exists but cannot be read is an I/O failure, not an empty log.
126
+ // Returning `[]` here would report a broken telemetry store as a healthy one.
127
+ finish(
128
+ {
129
+ success: false,
130
+ errorCode: ErrorCode.READ_FAILED,
131
+ path: err.file,
132
+ message,
133
+ recovery: "Check that the telemetry log is readable, or run `hashpilot telemetry clear`.",
134
+ },
135
+ ExitCode.IO,
136
+ );
137
+ return;
138
+ }
139
+ if (err instanceof PathDeniedError) {
140
+ finish({ success: false, errorCode: err.errorCode, path: err.path, message }, ExitCode.USAGE);
141
+ return;
142
+ }
143
+ // Commander's async actions reject outside the try/catch around `parse()`,
144
+ // so a plain missing file lands here. Those are ordinary I/O failures, not
145
+ // HashPilot bugs — reporting them as exit 70 tells an agent to file a bug
146
+ // report instead of fixing its path.
147
+ const syscall = (err as { code?: string } | undefined)?.code;
148
+ if (syscall !== undefined && IO_SYSCALL_CODES.has(syscall)) {
149
+ finish(
150
+ {
151
+ success: false,
152
+ errorCode: syscall === "ENOENT" ? ErrorCode.FILE_NOT_FOUND : ErrorCode.WRITE_FAILED,
153
+ message,
154
+ recovery: "Check that the path exists and is readable and writable.",
155
+ },
156
+ ExitCode.IO,
157
+ );
158
+ return;
159
+ }
160
+ finish(
161
+ {
162
+ success: false,
163
+ errorCode: "INTERNAL_ERROR",
164
+ message,
165
+ detail: err instanceof Error ? err.stack : undefined,
166
+ recovery: "This is a bug in HashPilot. Please report it with the command that triggered it.",
167
+ },
168
+ ExitCode.INTERNAL,
169
+ );
170
+ }
171
+
172
+ process.on("uncaughtException", reportInternalError);
173
+ process.on("unhandledRejection", reportInternalError);
174
+
175
+ /**
176
+ * Route Commander's own parse failures (unknown flag, missing required
177
+ * argument, bad choice) through the JSON usage envelope instead of letting a
178
+ * bare `error: unknown option '--x'` line escape to stderr. An agent that
179
+ * cannot parse the failure cannot self-correct from it (#57).
180
+ *
181
+ * `--help` and `--version` also arrive here as CommanderErrors; those already
182
+ * wrote their output to stdout and must exit with their own code.
183
+ */
184
+ function applyExitOverride(cmd: Command): void {
185
+ cmd.exitOverride();
186
+ cmd.configureOutput({ writeErr: () => {} });
187
+ for (const sub of cmd.commands) applyExitOverride(sub as Command);
188
+ }
189
+
190
+ applyExitOverride(program);
191
+
192
+ try {
193
+ program.parse();
194
+ } catch (err) {
195
+ if (err instanceof CommanderError) {
196
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.version" || err.code === "commander.help") {
197
+ process.exit(err.exitCode);
198
+ }
199
+ const attempted = process.argv[2];
200
+ if (attempted && program.commands.some((c) => (c as Command).name() === attempted)) {
201
+ setCommand(attempted);
202
+ }
203
+ usageError(err.message.replace(/^error: /, ""), {
204
+ recovery: `Run \`hashpilot ${attempted && !attempted.startsWith("-") ? attempted + " " : ""}--help\` for the accepted arguments.`,
205
+ });
206
+ } else {
207
+ reportInternalError(err);
208
+ }
209
+ }