@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,198 @@
1
+ import { ErrorCode, recordEvent, getRecordedEventCount } from "./telemetry";
2
+ import { wrap, currentCommandName } from "./envelope";
3
+ import { resolveFormat, renderText, OutputFormat } from "./format";
4
+ import { isQuiet } from "./output";
5
+
6
+ /**
7
+ * Process exit codes. Agents branch on these, so the numbers are a contract —
8
+ * see docs/ADAPTER-CONTRACT.md. Never renumber an existing code.
9
+ */
10
+ export enum ExitCode {
11
+ /** Operation succeeded. */
12
+ OK = 0,
13
+ /** Bad invocation: missing/malformed flag, denied path, unsupported operation. Retrying verbatim will not help. */
14
+ USAGE = 1,
15
+ /** The edit itself failed (symbol not found, parse error, ambiguous match). */
16
+ EDIT_FAILED = 2,
17
+ /** A precondition no longer holds (stale anchor, hash mismatch). Agent-retryable after a fresh read. */
18
+ PRECONDITION = 3,
19
+ /** The edit applied but verification (format/lint/test) failed. */
20
+ VERIFY_FAILED = 4,
21
+ /** Filesystem-level failure: file missing, unreadable, or unwritable. */
22
+ IO = 5,
23
+ /** Uncaught internal error — a bug in HashPilot. */
24
+ INTERNAL = 70,
25
+ }
26
+
27
+ const ERROR_CODE_EXITS: Record<string, ExitCode> = {
28
+ [ErrorCode.STALE_ANCHOR]: ExitCode.PRECONDITION,
29
+ [ErrorCode.LOCK_TIMEOUT]: ExitCode.PRECONDITION,
30
+ [ErrorCode.HASH_MISMATCH]: ExitCode.PRECONDITION,
31
+ [ErrorCode.FILE_NOT_FOUND]: ExitCode.IO,
32
+ [ErrorCode.WRITE_FAILED]: ExitCode.IO,
33
+ [ErrorCode.READ_FAILED]: ExitCode.IO,
34
+ [ErrorCode.PATH_DENIED]: ExitCode.USAGE,
35
+ [ErrorCode.INVALID_ARGUMENT]: ExitCode.USAGE,
36
+ [ErrorCode.UNSUPPORTED_OPERATION]: ExitCode.USAGE,
37
+ [ErrorCode.SYMBOL_NOT_FOUND]: ExitCode.EDIT_FAILED,
38
+ [ErrorCode.PARSE_ERROR]: ExitCode.EDIT_FAILED,
39
+ [ErrorCode.DUPLICATE_MATCH]: ExitCode.EDIT_FAILED,
40
+ // "Same name binds more than one symbol in this file" is a failed edit
41
+ // attempt (a precondition the caller didn't meet), not a stale/retryable
42
+ // anchor — so it shares the EDIT_FAILED band with SYMBOL_NOT_FOUND.
43
+ [ErrorCode.AMBIGUOUS_SYMBOL]: ExitCode.EDIT_FAILED,
44
+ [ErrorCode.UNSUPPORTED_LANGUAGE]: ExitCode.EDIT_FAILED,
45
+ // The file is fine and the operation exists; this particular import cannot
46
+ // be written into this particular module system. Same band as
47
+ // UNSUPPORTED_LANGUAGE: the edit failed on a precondition the caller can
48
+ // fix, and retrying the same call verbatim will not help (#139).
49
+ [ErrorCode.MODULE_SYSTEM_MISMATCH]: ExitCode.EDIT_FAILED,
50
+ // The search did not complete, so the edit did not happen for a reason the
51
+ // caller can act on (raise the cap, target a shallower node) — an edit
52
+ // failure, not a retryable precondition (#39).
53
+ [ErrorCode.SEARCH_TRUNCATED]: ExitCode.EDIT_FAILED,
54
+ [ErrorCode.VERIFY_FAILED]: ExitCode.VERIFY_FAILED,
55
+ // A timeout shares the verification band — the edit applied, verification did
56
+ // not conclude — but carries its own error code so an agent can tell "your
57
+ // change broke the tests" from "the suite ran out of time".
58
+ [ErrorCode.VERIFY_TIMEOUT]: ExitCode.VERIFY_FAILED,
59
+ // Same band again: the edit applied, verification produced no verdict. A
60
+ // separate code so an agent can tell "nothing was checked" from "the checks
61
+ // failed" and re-run with the flags it forgot (#106).
62
+ [ErrorCode.VERIFY_NO_CHECKS]: ExitCode.VERIFY_FAILED,
63
+ // Deliberately IO, not VERIFY_FAILED: a half-reverted tree is a filesystem
64
+ // problem the agent must stop and inspect, not a retryable test failure.
65
+ [ErrorCode.ROLLBACK_INCOMPLETE]: ExitCode.IO,
66
+ [ErrorCode.INTERNAL_ERROR]: ExitCode.INTERNAL,
67
+ };
68
+
69
+ /** Shape every command result is inspected through. All fields optional — results vary by command. */
70
+ export interface ResultLike {
71
+ success?: boolean;
72
+ passed?: boolean;
73
+ error?: string | { code?: string };
74
+ errorCode?: string;
75
+ stale?: boolean;
76
+ [key: string]: unknown;
77
+ }
78
+
79
+ /**
80
+ * Derive an exit code from a command result. Falls back to EDIT_FAILED for an
81
+ * unrecognized failure rather than OK — an unmapped error must never exit 0.
82
+ */
83
+ export function exitCodeFor(result: ResultLike | ResultLike[] | undefined): ExitCode {
84
+ if (result === undefined) return ExitCode.OK;
85
+
86
+ if (Array.isArray(result)) {
87
+ // Batch: worst (highest) code wins, so a single failure is never masked.
88
+ return result.reduce<ExitCode>((worst, r) => {
89
+ const code = exitCodeFor(r);
90
+ return code > worst ? code : worst;
91
+ }, ExitCode.OK);
92
+ }
93
+
94
+ // Wrapper results (route-edit, batch, plan steps) carry the real outcome in
95
+ // `result`. Ignoring it made a failed edit exit 0.
96
+ if (result.success === undefined && result.passed === undefined && result.error === undefined
97
+ && result.result && typeof result.result === "object") {
98
+ return exitCodeFor(result.result as ResultLike);
99
+ }
100
+
101
+ const explicit = result.errorCode ?? (typeof result.error === "object" ? result.error?.code : undefined);
102
+ if (explicit && ERROR_CODE_EXITS[explicit]) return ERROR_CODE_EXITS[explicit];
103
+
104
+ const failed =
105
+ result.success === false ||
106
+ result.passed === false ||
107
+ (result.error !== undefined && result.error !== null);
108
+ if (!failed) return ExitCode.OK;
109
+
110
+ if (result.stale === true) return ExitCode.PRECONDITION;
111
+ return ExitCode.EDIT_FAILED;
112
+ }
113
+
114
+ /* ── Output format (#19 B16) ───────────────────────────────────────────── */
115
+ let outputFormat: OutputFormat = "json";
116
+ let currentCommand = "";
117
+
118
+ /** Called once by preAction; sets the global output format + running command name. */
119
+ export function setOutputFormat(fmt: OutputFormat, command: string): void {
120
+ outputFormat = fmt;
121
+ currentCommand = command;
122
+ }
123
+
124
+ /** Current output format — used by action handlers to branch on text vs. JSON. */
125
+ export function getOutputFormat(): OutputFormat {
126
+ return outputFormat;
127
+ }
128
+
129
+ /** Re-export for cli.ts convenience. */
130
+ export { resolveFormat, renderText };
131
+
132
+ /**
133
+ * Single exit point for every CLI command.
134
+ *
135
+ * #19 (B16): when `outputFormat === "text"` the success payload is rendered as
136
+ * compact human-readable output via per-command renderers. Error payloads are
137
+ * ALWAYS JSON — the API contract (apiVersion 1) is the canonical machine output
138
+ * and must not be degraded. Commands without a renderer fall back to a compact
139
+ * key/value dump; never raw JSON in text mode.
140
+ */
141
+ // `telemetry *` reads, clears, and prunes the log; recording an event there
142
+ // would grow the very file the command is inspecting and skew its own report.
143
+ // `uninstall` removes the log directory outright.
144
+ const TELEMETRY_EXEMPT_COMMANDS = ["uninstall"];
145
+
146
+ const processStart = Date.now();
147
+
148
+ /**
149
+ * CLAUDE.md says every command records a telemetry event, but a dozen commands
150
+ * (`capabilities`, `route`, `config`, `undo`, `provenance query`, …) recorded
151
+ * nothing, leaving silent holes in the health report's operation coverage.
152
+ * Rather than thread a recordEvent call through every action and its several
153
+ * finish() call sites, emit a fallback here — the one choke point every command
154
+ * already funnels through — but only when the action recorded nothing itself,
155
+ * so commands with richer events are not double-counted (#51).
156
+ */
157
+ function recordFallbackEvent(exit: ExitCode): void {
158
+ const command = currentCommandName();
159
+ if (!command) return;
160
+ if (getRecordedEventCount() > 0) return;
161
+ if (command.startsWith("telemetry") || TELEMETRY_EXEMPT_COMMANDS.includes(command)) return;
162
+ recordEvent({
163
+ operation: command,
164
+ route: "other",
165
+ success: exit === ExitCode.OK,
166
+ elapsed_ms: Date.now() - processStart,
167
+ });
168
+ }
169
+
170
+ export function finish(payload: unknown, code?: ExitCode): void {
171
+ const exit = code ?? exitCodeFor(payload as ResultLike);
172
+ recordFallbackEvent(exit);
173
+ // In text mode: success payloads get the compact renderer; errors always emit JSON.
174
+ if (outputFormat === "text" && (payload as { success?: boolean }).success !== false) {
175
+ // `--quiet` drops the human-readable success line. It deliberately does not
176
+ // drop the JSON envelope below: that is the apiVersion 1 contract, and a
177
+ // caller who asked for JSON and got silence cannot tell ok from a crash (#47).
178
+ if (isQuiet()) {
179
+ process.exitCode = exit;
180
+ return;
181
+ }
182
+ // `wrap()` produces { apiVersion, ok, command, data, ... }; `data` carries
183
+ // the per-command payload. Render `data` when present, else the raw payload.
184
+ const data = (payload as { data?: Record<string, unknown> }).data;
185
+ const rendererTarget = data || (payload as Record<string, unknown>);
186
+ renderText(currentCommand, rendererTarget as Record<string, unknown>);
187
+ process.exitCode = exit;
188
+ return;
189
+ }
190
+ // JSON path: emit the envelope (apiVersion 1)
191
+ console.log(JSON.stringify(wrap(payload, exit), null, 2));
192
+ process.exitCode = exit;
193
+ }
194
+
195
+ /** Emit a usage error and exit 1. For malformed flags and missing required options. */
196
+ export function usageError(message: string, extra: Record<string, unknown> = {}): void {
197
+ finish({ success: false, errorCode: ErrorCode.INVALID_ARGUMENT, message, ...extra }, ExitCode.USAGE);
198
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Output format selection for the CLI.
3
+ *
4
+ * #19 (B16) — a global `--format <json|text>` flag, with TTY-aware defaults:
5
+ * - `--format json` : always JSON (agent default, CI safe)
6
+ * - `--format text` : compact human-readable output (human default, interactive shell)
7
+ * - no `--format` : JSON if stdout is piped/redirected or `$CI` true; otherwise text
8
+ *
9
+ * The JSON envelope remains the canonical machine-readable contract (apiVersion 1);
10
+ * `text` mode is a human convenience layer that must never replace it.
11
+ */
12
+
13
+ import { colorizeGlyphs } from "./output";
14
+
15
+ /* ── Output format type ──────────────────────────────────────────────── */
16
+
17
+ export type OutputFormat = "json" | "text";
18
+
19
+ /**
20
+ * Single stdout choke point for every renderer (#47). Status glyphs are
21
+ * colorized here — and only here — so no renderer has to know whether color is
22
+ * enabled, and so an escape sequence can never reach a JSON path.
23
+ */
24
+ function write(text: string): void {
25
+ process.stdout.write(colorizeGlyphs(text));
26
+ }
27
+
28
+ /**
29
+ * Resolve the output format from an explicit option, CI detection, and TTY state.
30
+ *
31
+ * Precedence:
32
+ * 1. Explicit `--format <json|text>` → wins always.
33
+ * 2. `--json` (deprecated alias for `--format json`) → emits a one-time stderr
34
+ * deprecation warning.
35
+ * 3. `$CI` environment variable is truthy → JSON.
36
+ * 4. stdout is a TTY (interactive shell) → text.
37
+ * 5. Fall back → JSON (safe default for anything not detected above).
38
+ */
39
+ export function resolveFormat(
40
+ opts: { format?: string; json?: boolean },
41
+ ctx: { isTTY?: boolean; ci?: boolean } = {}
42
+ ): { format: OutputFormat; warnDeprecate?: boolean } {
43
+ // 1. explicit --format
44
+ if (opts.format === "json" || opts.format === "text") return { format: opts.format };
45
+ // 2. deprecated --json alias
46
+ if (opts.json === true) return { format: "json", warnDeprecate: true };
47
+ // 3. CI env
48
+ const ci = ctx.ci ?? process.env.CI;
49
+ if (ci === "true" || ci === "1") return { format: "json" };
50
+ // 4. TTY
51
+ const isTTY = ctx.isTTY ?? process.stdout.isTTY;
52
+ if (isTTY) return { format: "text" };
53
+ // 5. default to JSON
54
+ return { format: "json" };
55
+ }
56
+
57
+ /* ── Text renderers ────────────────────────────────────────────────────
58
+ *
59
+ * Each renderer receives a `ResultPayload` (the `data` field that `wrap()`
60
+ * would produce) and prints a compact, human-readable line to stdout.
61
+ * Rendered lines never carry diagnostic noise — errors go through the same
62
+ * `finish()` path so they emit JSON, not text.
63
+ */
64
+
65
+ type ResultPayload = {
66
+ success: boolean;
67
+ [key: string]: unknown;
68
+ };
69
+
70
+ /**
71
+ * Render a result payload as compact text for human readers.
72
+ * Each command registers its own renderer via the `registerRenderer` map;
73
+ * unmatched commands fall back to a generic key/value dump.
74
+ */
75
+ export function renderText(command: string, payload: ResultPayload): void {
76
+ const renderer = renderers[command];
77
+ if (renderer) {
78
+ renderer(payload);
79
+ } else {
80
+ // Generic fallback: compact key/value dump
81
+ renderGenericDump(payload);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Generic renderer: print a single-line summary of the result.
87
+ *
88
+ * `finish()` only reaches a renderer when `success !== false`, so a payload with
89
+ * no `success` field at all (every read-only command: find-symbols, route,
90
+ * capabilities) is a success. Testing `payload.success` for truthiness instead
91
+ * marked all of them "✗ error" (#132).
92
+ */
93
+ function renderGenericDump(payload: ResultPayload): void {
94
+ const lines: string[] = [];
95
+ lines.push(payload.success !== false ? "✓ ok" : "✗ " + (String(payload.errorCode || "error")));
96
+ for (const [k, v] of Object.entries(payload)) {
97
+ if (k === "success" || k === "apiVersion" || k === "error") continue;
98
+ if (v === null || v === undefined) continue;
99
+ if (Array.isArray(v)) {
100
+ lines.push(` ${k}: ${v.length} item${v.length === 1 ? "" : "s"}`);
101
+ } else if (typeof v === "object") {
102
+ lines.push(` ${k}: ${summarize(v)}`);
103
+ } else {
104
+ lines.push(` ${k}: ${String(v)}`);
105
+ }
106
+ if (lines.length >= 8) {
107
+ lines.push(" …");
108
+ break;
109
+ }
110
+ }
111
+ write(lines.join("\n") + "\n");
112
+ }
113
+
114
+ /** Compact one-line summary of a value for inline display. */
115
+ function summarize(v: unknown): string {
116
+ if (v === null || v === undefined) return "none";
117
+ if (typeof v === "string") return `"${v.length > 60 ? v.slice(0, 57) + "…" : v}"`;
118
+ if (typeof v === "number") return String(v);
119
+ if (typeof v === "boolean") return v ? "true" : "false";
120
+ if (Array.isArray(v)) return `[${v.length}]`;
121
+ return "...";
122
+ }
123
+
124
+ /* ── Per-command renderers ────────────────────────────────────────────── */
125
+
126
+ const renderers: Record<string, (p: ResultPayload) => void> = {};
127
+
128
+ /**
129
+ * Register a text renderer for a specific command.
130
+ * Called at module init for each command that has a dedicated renderer.
131
+ */
132
+ export function registerRenderer(command: string, fn: (p: ResultPayload) => void): void {
133
+ renderers[command] = fn;
134
+ }
135
+
136
+ /* ── Common renderer registrations ───────────────────────────────────── */
137
+
138
+ // doctor — a checklist. Reads `status`/`message`, the fields DoctorCheck
139
+ // actually has; the original read `ok`/`detail`, so every check rendered as a
140
+ // nameless failure (#46).
141
+ registerRenderer("doctor", (p) => {
142
+ const checks = (p.checks || []) as { name: string; status: string; message: string; remediation?: string }[];
143
+ const s = (p.summary || {}) as Record<string, number>;
144
+ const glyph: Record<string, string> = { pass: "✓", fail: "✗", warn: "!", skip: "·" };
145
+ write(`HashPilot ${p.version} — ${p.healthy ? "✓ healthy" : "✗ issues found"} (${p.installMode} install)\n`);
146
+ write(` pass ${s.pass ?? 0} fail ${s.fail ?? 0} warn ${s.warn ?? 0} skip ${s.skip ?? 0}\n`);
147
+ for (const c of checks) {
148
+ write(` ${glyph[c.status] || "?"} ${c.name}: ${c.message}\n`);
149
+ if (c.remediation) write(` fix: ${c.remediation}\n`);
150
+ }
151
+ });
152
+
153
+ // read / read-many — print the first few lines + hash
154
+ registerRenderer("read", (p) => {
155
+ printReadResult(p);
156
+ });
157
+ registerRenderer("read-many", (p) => {
158
+ const results = (p.results || p) as unknown;
159
+ if (Array.isArray(results)) {
160
+ for (const r of results as ResultPayload[]) printReadResult(r);
161
+ } else printReadResult(p);
162
+ });
163
+ registerRenderer("read-hash", (p) => printReadResult(p));
164
+
165
+ /** Shared renderer for any "read" variant: show line count + hash. */
166
+ function printReadResult(p: ResultPayload) {
167
+ if (p.error) {
168
+ write("✗ " + p.error + "\n");
169
+ return;
170
+ }
171
+ const lines = (p.lines as string[]) || (p.content ? [p.content] : []);
172
+ const n = lines.length;
173
+ const hash = p.hash || p.lineHash || "";
174
+ const filePath = p.file || p.path || "";
175
+ write(
176
+ `${filePath ? basePath(filePath) + ": " : ""}${n} line${n === 1 ? "" : "s"}${hash ? " " + hash.slice(0, 8) : ""}\n`
177
+ );
178
+ if (lines.length > 0 && lines.length <= 5) {
179
+ for (const l of lines) write(" " + l + "\n");
180
+ }
181
+ }
182
+
183
+ // ast-replace / hash-replace / diff-apply — print success + file:line + hash
184
+ registerRenderer("ast-replace", (p) => printEditResult(p));
185
+ registerRenderer("hash-replace", (p) => printEditResult(p));
186
+ registerRenderer("diff-apply", (p) => printEditResult(p));
187
+ registerRenderer("structured-edit", (p) => printEditResult(p));
188
+ registerRenderer("edit-many", (p) => {
189
+ const results = (p.results || []) as ResultPayload[];
190
+ const ok = results.filter((r) => r.success).length;
191
+ write(`${ok}/${results.length} edits succeeded\n`);
192
+ for (const r of results.filter((r) => !r.success)) {
193
+ write("✗ " + basePath(r.file || r.path || "?") + ": " + (r.error || "failed") + "\n");
194
+ }
195
+ });
196
+
197
+ /** Shared renderer for any single edit result. */
198
+ function printEditResult(p: ResultPayload) {
199
+ const f = p.file || p.path || "";
200
+ const route = p.route || "?";
201
+ write(
202
+ `${p.success ? "✓" : "✗"} ${route} ${f ? basePath(f) : ""}${p.line ? ":" + p.line : ""}\n`
203
+ );
204
+ if (!p.success) write(" " + (p.error || p.message || "failed") + "\n");
205
+ }
206
+
207
+ // grep / grep-many — print N matches across M files
208
+ registerRenderer("grep", (p) => {
209
+ const r = p.results || p;
210
+ const matches = (r?.matches || []) as unknown[];
211
+ write(`${matches.length} match${matches.length === 1 ? "" : "es"}\n`);
212
+ for (const m of (matches as any[]).slice(0, 10)) {
213
+ write(
214
+ " " + basePath(m.file || m.path || "?") + ":" + (m.line || "?") + " " + truncate(m.content, 60) + "\n"
215
+ );
216
+ }
217
+ if (matches.length > 10) write(` … ${matches.length - 10} more\n`);
218
+ });
219
+ registerRenderer("grep-many", (p) => {
220
+ const r = p.results || p;
221
+ const matches = (r?.matches || []) as unknown[];
222
+ write(`${matches.length} match${matches.length === 1 ? "" : "es"}\n`);
223
+ for (const m of (matches as any[]).slice(0, 10)) {
224
+ write(
225
+ " " + basePath(m.file || m.path || "?") + ":" + (m.line || "?") + " " + truncate(m.content, 60) + "\n"
226
+ );
227
+ }
228
+ });
229
+
230
+ // symbol-lookup
231
+ registerRenderer("symbol-lookup", (p) => {
232
+ const results = (p.results || p.symbols || []) as ResultPayload[];
233
+ write(`${results.length} symbol${results.length === 1 ? "" : "s"}` + "\n");
234
+ for (const r of results.slice(0, 10)) {
235
+ write(
236
+ ` ${r.name || r.symbol || "?"} ${r.kind || "?"} ${basePath(r.file || r.path || "")}:${r.line || "?"}\n`
237
+ );
238
+ }
239
+ });
240
+
241
+ // intent
242
+ registerRenderer("intent", (p) => {
243
+ const plan = p.plan as ResultPayload | undefined;
244
+ if (p.success !== false) {
245
+ write("✓ plan succeeded\n");
246
+ if (plan?.impactSummary) write(" " + plan.impactSummary + "\n");
247
+ } else {
248
+ write("✗ " + (p.errorCode || p.error || "failed") + "\n");
249
+ }
250
+ const unresolved = plan?.unresolved;
251
+ if (Array.isArray(unresolved) && unresolved.length > 0) {
252
+ write(` ${unresolved.length} unresolved item(s):\n`);
253
+ for (const u of unresolved as ResultPayload[]) {
254
+ write(" " + basePath(u.file || "") + ": " + (u.reason || "") + "\n");
255
+ }
256
+ }
257
+ });
258
+
259
+ // verify
260
+ registerRenderer("verify", (p) => {
261
+ // "no checks ran" is its own line: printing "all checks passed" over an empty
262
+ // check set is exactly the false green of #106.
263
+ if (p.overall === "skipped") write("⚠ no checks ran — nothing was verified\n");
264
+ else if (p.success !== false) write("✓ all checks passed\n");
265
+ else write("✗ " + (p.errorCode || "checks failed") + "\n");
266
+ const checks = (p.checks || p.results || []) as ResultPayload[];
267
+ for (const c of checks.slice(0, 5)) {
268
+ write(` ${c.overall === "pass" || c.success ? "✓" : "✗"} ${c.command || c.name || "?"}\n`);
269
+ }
270
+ });
271
+
272
+ // verify-changes — the CLI command name; the "verify" renderer above serves the
273
+ // plan/step payloads that embed a list of checks.
274
+ registerRenderer("verify-changes", (p) => {
275
+ const ran = (p.checksRun || []) as string[];
276
+ if (p.overall === "skipped") {
277
+ // Never "all checks passed" over an empty check set — that false green is
278
+ // the whole of #106.
279
+ write("⚠ no checks ran — nothing was verified\n");
280
+ write(" " + String(p.message || "") + "\n");
281
+ return;
282
+ }
283
+ const mark = p.overall === "pass" ? "✓" : "✗";
284
+ write(`${mark} ${p.overall} (${ran.length} check${ran.length === 1 ? "" : "s"}: ${ran.join(", ")})\n`);
285
+ for (const name of ran) {
286
+ const run = p[name] as ResultPayload | undefined;
287
+ if (run) write(` ${run.passed ? "✓" : "✗"} ${name}\n`);
288
+ }
289
+ });
290
+
291
+ // telemetry subcommands
292
+ registerRenderer("telemetry-show", (p) => {
293
+ const events = (p.events || p) as ResultPayload[];
294
+ const arr = Array.isArray(events) ? events : [events];
295
+ write(`${arr.length} event${arr.length === 1 ? "" : "s"}\n`);
296
+ for (const e of arr.slice(0, 10)) {
297
+ write(
298
+ " " +
299
+ (e.operation || "?") +
300
+ " " +
301
+ (e.success ? "✓" : "✗") +
302
+ " " +
303
+ (e.route || "") +
304
+ (e.elapsed_ms ? " " + e.elapsed_ms + "ms" : "") +
305
+ "\n"
306
+ );
307
+ }
308
+ });
309
+ registerRenderer("telemetry-export", (p) => write("✓ exported " + (p.count || 0) + " event(s)\n"));
310
+ registerRenderer("telemetry-prune", (p) => write("✓ pruned " + (p.count || 0) + " event(s)\n"));
311
+
312
+ // route-query
313
+ registerRenderer("route", (p) => {
314
+ write((p.success !== false ? "✓ " + (p.route || "ok") : "✗ " + (p.errorCode || "routing failed")) + "\n");
315
+ });
316
+
317
+ // ast-capabilities
318
+ registerRenderer("ast-capabilities", (p) => {
319
+ const langs = (p.languages || []) as string[];
320
+ write(`${langs.length} language(s) supported\n`);
321
+ });
322
+
323
+ // health / telemetry-summary
324
+ registerRenderer("health", (p) => {
325
+ write((p.success !== false ? "✓ " : "✗ ") + (p.message || "") + "\n");
326
+ });
327
+ registerRenderer("telemetry-summary", (p) => {
328
+ write((p.success !== false ? "✓ " : "✗ ") + (p.message || JSON.stringify(Object.keys(p)).slice(0, 120)) + "\n");
329
+ });
330
+
331
+ // ── helpers ────────────────────────────────────────────────────────────
332
+
333
+ function basePath(p: string): string {
334
+ return p.includes("/") ? p.split("/").pop() || p : p;
335
+ }
336
+ function truncate(s: unknown, n: number): string {
337
+ const str = String(s);
338
+ return str.length > n ? str.slice(0, n - 1) + "…" : str;
339
+ }