@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,557 @@
1
+ /**
2
+ * The shared operation registry (#25 / B21).
3
+ *
4
+ * HashPilot has two front doors: the Commander CLI and the MCP server. Written
5
+ * separately they would drift, and a drifted MCP surface is worse than no MCP
6
+ * surface — it disagrees silently with the documented CLI. So each operation is
7
+ * declared once here (name, model-facing description, typed parameters,
8
+ * handler) and both front doors read from this list.
9
+ *
10
+ * The CLI's Commander definitions still own their own flag parsing, `@file`
11
+ * expansion, and telemetry; what this registry guarantees is that every entry
12
+ * corresponds to a real CLI command and that the parameter names agree.
13
+ * `tests/operations-parity.test.ts` enforces both directions.
14
+ *
15
+ * Handlers deliberately route edits through `routeEdit` rather than calling the
16
+ * tier functions directly: that is the path that takes the advisory lock, does
17
+ * the compare-and-swap, records the snapshot, and writes provenance. An MCP
18
+ * caller must not get a weaker guarantee than a CLI caller.
19
+ */
20
+
21
+ import { readMany, readHash } from "./read";
22
+ import { grepMany, symbolLookupMany } from "./grep";
23
+ import { findSymbols,
24
+ findSymbolsDetailed, astCapabilities } from "./ast-edit";
25
+ import { routeEdit } from "./router";
26
+ import { verifyChanges } from "./verify";
27
+
28
+ /* ── Types ───────────────────────────────────────────────────────────── */
29
+
30
+ export type ParamType = "string" | "number" | "boolean" | "string[]";
31
+
32
+ export interface OperationParam {
33
+ /** Parameter name, in camelCase. Must match the CLI's argument or option name. */
34
+ name: string;
35
+ type: ParamType;
36
+ required: boolean;
37
+ /** Written for a model: what to put here, not what the field is called. */
38
+ description: string;
39
+ }
40
+
41
+ export interface Operation {
42
+ /** MCP tool name. snake_case, because that is what MCP hosts display. */
43
+ name: string;
44
+ /** The CLI command path this mirrors, e.g. `["ast", "rename-symbol"]`. */
45
+ cliCommand: string[];
46
+ /** One line, shown in tool lists. */
47
+ summary: string;
48
+ /**
49
+ * The model-facing description. Every entry states when NOT to use the tool —
50
+ * an agent picking the wrong tier is the common failure, not a wrong argument.
51
+ */
52
+ description: string;
53
+ params: OperationParam[];
54
+ /** True for anything that can write. Hosts surface this as a consent prompt. */
55
+ mutates: boolean;
56
+ handler: (args: Record<string, unknown>) => Promise<unknown>;
57
+ }
58
+
59
+ /* ── Argument coercion ───────────────────────────────────────────────── */
60
+
61
+ function str(args: Record<string, unknown>, key: string): string | undefined {
62
+ const v = args[key];
63
+ // An explicit empty string is meaningful (a deletion, #40), so only
64
+ // undefined/null count as absent.
65
+ return v === undefined || v === null ? undefined : String(v);
66
+ }
67
+
68
+ function num(args: Record<string, unknown>, key: string): number | undefined {
69
+ const v = args[key];
70
+ if (v === undefined || v === null || v === "") return undefined;
71
+ const n = Number(v);
72
+ return Number.isFinite(n) ? n : undefined;
73
+ }
74
+
75
+ function bool(args: Record<string, unknown>, key: string): boolean {
76
+ return args[key] === true || args[key] === "true";
77
+ }
78
+
79
+ function strArray(args: Record<string, unknown>, key: string): string[] {
80
+ const v = args[key];
81
+ if (Array.isArray(v)) return v.map(String);
82
+ if (typeof v === "string" && v !== "") return [v];
83
+ return [];
84
+ }
85
+
86
+ /** `"3:9"` → `{start: 3, end: 9}`; `"3"` → `{start: 3, end: 3}`. */
87
+ function parseRangeArg(raw: string | undefined): { start: number; end: number } | undefined {
88
+ if (!raw) return undefined;
89
+ const [s, e] = raw.split(":").map(Number);
90
+ if (!Number.isFinite(s)) return undefined;
91
+ return { start: s, end: Number.isFinite(e) ? e : s };
92
+ }
93
+
94
+ /** Provenance parameters every mutating operation accepts. */
95
+ const PROVENANCE_PARAMS: OperationParam[] = [
96
+ { name: "actor", type: "string", required: false, description: "Your agent identity, recorded in the edit history." },
97
+ { name: "taskId", type: "string", required: false, description: "Task or issue reference this edit belongs to." },
98
+ { name: "reason", type: "string", required: false, description: "Why this edit is being made, in one line." },
99
+ { name: "dryRun", type: "boolean", required: false, description: "Compute the edit and report it without writing to disk. The result is a unified `diff` of the changed hunks, not the whole file." },
100
+ ];
101
+
102
+ /**
103
+ * Opt-in on the operations whose dry run would otherwise hand back a whole file
104
+ * (#98). The hash tier already answers with a diff, so it does not carry this.
105
+ */
106
+ const PREVIEW_PARAM: OperationParam = {
107
+ name: "includeSource",
108
+ type: "boolean",
109
+ required: false,
110
+ description: "On a dry run, return the full post-edit text as `newSource` instead of just the diff. Costs one whole file of context.",
111
+ };
112
+
113
+ /** Shared handler for every edit: one path, so locking and provenance are uniform. */
114
+ function editHandler(
115
+ operation: string,
116
+ method: "ast" | "hash" | "diff" | undefined,
117
+ map: (args: Record<string, unknown>) => Record<string, unknown>
118
+ ) {
119
+ return async (args: Record<string, unknown>) =>
120
+ routeEdit({
121
+ filePath: str(args, "file")!,
122
+ operation,
123
+ method,
124
+ dryRun: bool(args, "dryRun"),
125
+ includeSource: bool(args, "includeSource"),
126
+ actor: str(args, "actor"),
127
+ taskId: str(args, "taskId"),
128
+ reason: str(args, "reason"),
129
+ ...map(args),
130
+ } as Parameters<typeof routeEdit>[0]);
131
+ }
132
+
133
+ const FILE_PARAM: OperationParam = {
134
+ name: "file",
135
+ type: "string",
136
+ required: true,
137
+ description: "Path to the file to edit, relative to the project root.",
138
+ };
139
+
140
+ /* ── The registry ────────────────────────────────────────────────────── */
141
+
142
+ export const OPERATIONS: Operation[] = [
143
+ /* — Reading — */
144
+ {
145
+ name: "read_many",
146
+ cliCommand: ["read-many"],
147
+ summary: "Read whole files and get a SHA-256 hash for each.",
148
+ description:
149
+ "Read one or more files, returning content plus a content hash per file. " +
150
+ "Read this way before any hash-anchored edit: the hash you get back is the " +
151
+ "anchor `replace_hash` verifies against, which is what makes the edit refuse " +
152
+ "rather than clobber when the file changed underneath you. " +
153
+ "Do NOT use it to scan a large tree looking for something — use `grep_many`, " +
154
+ "which returns matches instead of whole files.",
155
+ params: [{ name: "files", type: "string[]", required: true, description: "Paths of the files to read, relative to the project root." }],
156
+ mutates: false,
157
+ handler: async (a) => readMany(strArray(a, "files")),
158
+ },
159
+ {
160
+ name: "read_hash",
161
+ cliCommand: ["read-hash"],
162
+ summary: "Read a single line with its hash and surrounding context.",
163
+ description:
164
+ "Read one line of a file plus N lines of context, with a hash for the line. " +
165
+ "Use it when you already know the line you intend to change and do not want " +
166
+ "the whole file in context. Do NOT use it to read a region — pass a `range` " +
167
+ "to `replace_hash` instead of stitching single lines together.",
168
+ params: [
169
+ { name: "file", type: "string", required: true, description: "Path to the file to read." },
170
+ { name: "line", type: "number", required: true, description: "1-indexed line number." },
171
+ { name: "context", type: "number", required: false, description: "Lines of context either side. Default 3." },
172
+ ],
173
+ mutates: false,
174
+ handler: async (a) => readHash(str(a, "file")!, num(a, "line")!, num(a, "context") ?? 3),
175
+ },
176
+ {
177
+ name: "grep_many",
178
+ cliCommand: ["grep-many"],
179
+ summary: "Regex search across paths.",
180
+ description:
181
+ "Search a regex across files or directories and get back file, line number, " +
182
+ "and matching text. This is the cheapest way to locate code. " +
183
+ "Do NOT use it to find where a symbol is defined — `symbol_lookup_many` " +
184
+ "understands definitions and will not drown you in call sites.",
185
+ params: [
186
+ { name: "pattern", type: "string", required: true, description: "Regular expression to search for." },
187
+ { name: "paths", type: "string[]", required: true, description: "Files or directories to search." },
188
+ { name: "ignoreCase", type: "boolean", required: false, description: "Case-insensitive match." },
189
+ { name: "filePattern", type: "string", required: false, description: "Glob limiting which files are searched, e.g. '*.ts'." },
190
+ { name: "maxResults", type: "number", required: false, description: "Cap on returned matches." },
191
+ ],
192
+ mutates: false,
193
+ handler: async (a) =>
194
+ grepMany(str(a, "pattern")!, strArray(a, "paths"), {
195
+ ignoreCase: bool(a, "ignoreCase"),
196
+ filePattern: str(a, "filePattern"),
197
+ maxResults: num(a, "maxResults"),
198
+ }),
199
+ },
200
+ {
201
+ name: "symbol_lookup_many",
202
+ cliCommand: ["symbol-lookup-many"],
203
+ summary: "Find where symbols are defined.",
204
+ description:
205
+ "Locate the definitions of named symbols across paths. Use it to answer " +
206
+ "'where does this function live' before editing it. " +
207
+ "Do NOT use it to find call sites — it reports definitions only; use `grep_many` for references.",
208
+ params: [
209
+ { name: "names", type: "string[]", required: true, description: "Symbol names whose definitions you want located." },
210
+ { name: "paths", type: "string[]", required: true, description: "Files or directories to search." },
211
+ ],
212
+ mutates: false,
213
+ handler: async (a) => symbolLookupMany(strArray(a, "names"), strArray(a, "paths")),
214
+ },
215
+ {
216
+ name: "find_symbols",
217
+ cliCommand: ["ast", "find-symbols"],
218
+ summary: "List every symbol declared in one file.",
219
+ description:
220
+ "Parse a file and list the symbols it declares, with their kinds and lines. " +
221
+ "Line and column numbers come in two conventions: `startLine`/`endLine`/" +
222
+ "`startColumn`/`endColumn` are 1-indexed and are what you want — they match " +
223
+ "the `range` the hash tier accepts. `startRow`/`endRow`/`startCol`/`endCol` " +
224
+ "are the raw 0-indexed tree-sitter coordinates, kept for compatibility; " +
225
+ "passing one of those as a `range` targets the line above the symbol. " +
226
+ "Use it to orient yourself in an unfamiliar file before an AST edit. " +
227
+ "Do NOT use it on an unsupported language — check `ast_capabilities` first, " +
228
+ "or the call returns a parse error.",
229
+ params: [FILE_PARAM],
230
+ mutates: false,
231
+ handler: async (a) => {
232
+ const file = str(a, "file")!;
233
+ // A path that does not exist is the caller's mistake, not ours. Without
234
+ // this it surfaces as INTERNAL_ERROR, which reads to a model as "the tool
235
+ // is broken" rather than "fix the path".
236
+ const handle = Bun.file(file);
237
+ if (!(await handle.exists())) {
238
+ return { success: false, errorCode: "FILE_NOT_FOUND", error: `No such file: ${file}` };
239
+ }
240
+ const search = findSymbolsDetailed(await handle.text(), file);
241
+ return { symbols: search.symbols, truncated: search.truncated };
242
+ },
243
+ },
244
+ {
245
+ name: "ast_capabilities",
246
+ cliCommand: ["ast", "capabilities"],
247
+ summary: "List languages and operations the AST tier supports.",
248
+ description:
249
+ "Report which languages have tree-sitter support and which AST operations " +
250
+ "exist. Call it once when you are unsure whether a file can be edited " +
251
+ "structurally. Do NOT call it before every edit — the answer does not change " +
252
+ "within a session, and `route_edit` falls back on its own.",
253
+ params: [],
254
+ mutates: false,
255
+ handler: async () => astCapabilities(),
256
+ },
257
+
258
+ /* — Hash tier — */
259
+ {
260
+ name: "replace_hash",
261
+ cliCommand: ["replace-hash"],
262
+ summary: "Replace content anchored to a SHA-256 hash you read earlier.",
263
+ description:
264
+ "Replace a region of a file identified by the hash returned from `read_many` " +
265
+ "or `read_hash`. If the file changed since you read it, the edit refuses or " +
266
+ "relocates the anchor rather than overwriting someone else's work — this is " +
267
+ "the safest edit HashPilot offers and the right default for any language " +
268
+ "without AST support. " +
269
+ "Do NOT use it for a rename that spans call sites: `rename_symbol` understands " +
270
+ "bindings and this does not. " +
271
+ "On success it returns `newHash` (the hash of the content it just wrote) and " +
272
+ "`newRange`: pass that pair straight back as `oldHash`/`range` to edit the same " +
273
+ "region again without re-reading the file. `fileHash` is the whole file after " +
274
+ "the edit and is not an anchor.",
275
+ params: [
276
+ FILE_PARAM,
277
+ { name: "oldHash", type: "string", required: true, description: "The hash of the content you are replacing, from a prior read." },
278
+ { name: "newContent", type: "string", required: true, description: "Replacement text. An empty string deletes the region." },
279
+ { name: "range", type: "string", required: false, description: "Line range as 'start:end' or a single 'N', 1-indexed." },
280
+ ...PROVENANCE_PARAMS,
281
+ ],
282
+ mutates: true,
283
+ handler: editHandler("replace-hash", "hash", (a) => ({
284
+ oldHash: str(a, "oldHash"),
285
+ newContent: str(a, "newContent"),
286
+ range: parseRangeArg(str(a, "range")),
287
+ })),
288
+ },
289
+ {
290
+ name: "replace_content",
291
+ cliCommand: ["route-edit"],
292
+ summary: "Search-and-replace fallback for content you cannot hash or parse.",
293
+ description:
294
+ "Replace an exact block of text with another. Refuses when the old text " +
295
+ "appears more than once, so an ambiguous match fails loudly instead of " +
296
+ "editing the wrong copy. " +
297
+ "Do NOT reach for this first — prefer `replace_hash` (verified) or an AST " +
298
+ "tool (structural). This tier exists for languages and shapes the other two cannot handle.",
299
+ params: [
300
+ FILE_PARAM,
301
+ { name: "oldContent", type: "string", required: true, description: "Exact existing text to replace. Must occur exactly once." },
302
+ { name: "newContent", type: "string", required: true, description: "Replacement text. An empty string deletes the block." },
303
+ ...PROVENANCE_PARAMS,
304
+ PREVIEW_PARAM,
305
+ ],
306
+ mutates: true,
307
+ handler: editHandler("replace-content", "diff", (a) => ({
308
+ oldContent: str(a, "oldContent"),
309
+ newContent: str(a, "newContent"),
310
+ })),
311
+ },
312
+
313
+ /* — AST tier — */
314
+ {
315
+ name: "rename_symbol",
316
+ cliCommand: ["ast", "rename-symbol"],
317
+ summary: "Rename a symbol and its references within one file.",
318
+ description:
319
+ "Rename a declaration and every reference bound to it in the same file, using " +
320
+ "the syntax tree rather than text matching — so a string containing the name, " +
321
+ "or a different symbol that happens to share it, is left alone. Refuses with " +
322
+ "AMBIGUOUS_SYMBOL when the name binds more than once in the file. " +
323
+ "Do NOT expect it to cross file boundaries: it is file-scoped by design. " +
324
+ "Rename each file, or use `intent` for a cross-file plan.",
325
+ params: [
326
+ FILE_PARAM,
327
+ { name: "oldName", type: "string", required: true, description: "Current symbol name." },
328
+ { name: "newName", type: "string", required: true, description: "New symbol name." },
329
+ ...PROVENANCE_PARAMS,
330
+ PREVIEW_PARAM,
331
+ ],
332
+ mutates: true,
333
+ handler: editHandler("rename-symbol", "ast", (a) => ({
334
+ oldName: str(a, "oldName"),
335
+ newName: str(a, "newName"),
336
+ })),
337
+ },
338
+ {
339
+ name: "replace_body",
340
+ cliCommand: ["ast", "replace-body"],
341
+ summary: "Replace a function or method body, keeping its signature.",
342
+ description:
343
+ "Swap the body of a named function or method without touching its signature, " +
344
+ "decorators, or surrounding code. The result is reparsed and the edit is " +
345
+ "discarded if it would not parse. " +
346
+ "Do NOT use it to change the signature — that is `intent` with add-parameter, " +
347
+ "which also updates call sites.",
348
+ params: [
349
+ FILE_PARAM,
350
+ { name: "symbol", type: "string", required: true, description: "Name of the function or method." },
351
+ { name: "newBody", type: "string", required: true, description: "Replacement body, including its braces or indentation block." },
352
+ ...PROVENANCE_PARAMS,
353
+ PREVIEW_PARAM,
354
+ ],
355
+ mutates: true,
356
+ handler: editHandler("replace-body", "ast", (a) => ({
357
+ symbolName: str(a, "symbol"),
358
+ newBody: str(a, "newBody"),
359
+ })),
360
+ },
361
+ {
362
+ name: "add_import",
363
+ cliCommand: ["ast", "add-import"],
364
+ summary: "Add an import statement in the language's own style.",
365
+ description:
366
+ "Insert an import, placing it with the file's existing imports and merging " +
367
+ "into a grouped import from the same module where the language allows it. " +
368
+ "Do NOT hand-write the import with an insert tool — this one knows the " +
369
+ "per-language formatting and will not produce a duplicate.",
370
+ params: [
371
+ FILE_PARAM,
372
+ { name: "importSpec", type: "string", required: true, description: "Import to add, e.g. '{ Foo } from ./bar'." },
373
+ ...PROVENANCE_PARAMS,
374
+ PREVIEW_PARAM,
375
+ ],
376
+ mutates: true,
377
+ handler: editHandler("add-import", "ast", (a) => ({ importSpec: str(a, "importSpec") })),
378
+ },
379
+ {
380
+ name: "remove_import",
381
+ cliCommand: ["ast", "remove-import"],
382
+ summary: "Remove an import statement.",
383
+ description:
384
+ "Delete an import, and drop just one name out of a grouped import when the " +
385
+ "rest are still used. " +
386
+ "Do NOT use it to clean up every unused import — it removes what you name, " +
387
+ "and does not analyse usage.",
388
+ params: [
389
+ FILE_PARAM,
390
+ { name: "importSpec", type: "string", required: true, description: "Import to remove, in the same form as it appears." },
391
+ ...PROVENANCE_PARAMS,
392
+ PREVIEW_PARAM,
393
+ ],
394
+ mutates: true,
395
+ handler: editHandler("remove-import", "ast", (a) => ({ importSpec: str(a, "importSpec") })),
396
+ },
397
+ {
398
+ name: "insert_before",
399
+ cliCommand: ["ast", "insert-before"],
400
+ summary: "Insert code immediately before a symbol's declaration.",
401
+ description:
402
+ "Place new code directly above a named declaration — a decorator, a helper, " +
403
+ "a comment block. Anchored to the symbol, so it stays correct even if line " +
404
+ "numbers moved since you read the file. " +
405
+ "Do NOT use it to add an import; `add_import` handles placement and grouping. " +
406
+ "The symbol must name a declaration: a name resolving only to a parameter, import " +
407
+ "specifier, or type parameter is refused rather than spliced into an expression, and " +
408
+ "a name matching more than one declaration is refused with every candidate listed.",
409
+ params: [
410
+ FILE_PARAM,
411
+ { name: "symbol", type: "string", required: true, description: "Symbol to insert before." },
412
+ { name: "content", type: "string", required: true, description: "Code to insert." },
413
+ ...PROVENANCE_PARAMS,
414
+ PREVIEW_PARAM,
415
+ ],
416
+ mutates: true,
417
+ handler: editHandler("insert-before", "ast", (a) => ({
418
+ symbolName: str(a, "symbol"),
419
+ content: str(a, "content"),
420
+ })),
421
+ },
422
+ {
423
+ name: "insert_after",
424
+ cliCommand: ["ast", "insert-after"],
425
+ summary: "Insert code immediately after a symbol's declaration.",
426
+ description:
427
+ "Place new code directly below a named declaration — a sibling function, a " +
428
+ "test, an export. Anchored to the symbol rather than to a line number. " +
429
+ "Do NOT use it to append to the end of a file; anchor to the last symbol you " +
430
+ "actually mean to follow.",
431
+ params: [
432
+ FILE_PARAM,
433
+ { name: "symbol", type: "string", required: true, description: "Symbol to insert after." },
434
+ { name: "content", type: "string", required: true, description: "Code to insert." },
435
+ ...PROVENANCE_PARAMS,
436
+ PREVIEW_PARAM,
437
+ ],
438
+ mutates: true,
439
+ handler: editHandler("insert-after", "ast", (a) => ({
440
+ symbolName: str(a, "symbol"),
441
+ content: str(a, "content"),
442
+ })),
443
+ },
444
+
445
+ /* — Routing and verification — */
446
+ {
447
+ name: "route_edit",
448
+ cliCommand: ["route-edit"],
449
+ summary: "Apply an edit and let HashPilot pick the safest tier automatically.",
450
+ description:
451
+ "Run any edit operation through the AST → hash → diff pipeline, choosing the " +
452
+ "strongest tier the language and operation support and falling back when it " +
453
+ "cannot. Use it when you do not want to reason about tiers. " +
454
+ "Do NOT use it when you already know the tier — the specific tool gives a " +
455
+ "clearer failure when its precondition is not met, instead of silently falling back.",
456
+ params: [
457
+ FILE_PARAM,
458
+ { name: "operation", type: "string", required: true, description: "One of: rename-symbol, replace-body, add-import, remove-import, insert-before, insert-after, replace-hash, replace-content." },
459
+ { name: "method", type: "string", required: false, description: "Force a tier: 'ast', 'hash', or 'diff'. Omit to auto-route." },
460
+ { name: "oldHash", type: "string", required: false, description: "Anchor hash, for the hash tier." },
461
+ { name: "newContent", type: "string", required: false, description: "Replacement content." },
462
+ { name: "oldContent", type: "string", required: false, description: "Existing content to match, for the diff tier." },
463
+ { name: "range", type: "string", required: false, description: "Line range as 'start:end', for the hash tier." },
464
+ { name: "oldName", type: "string", required: false, description: "Current name, for rename-symbol." },
465
+ { name: "newName", type: "string", required: false, description: "New name, for rename-symbol." },
466
+ { name: "symbol", type: "string", required: false, description: "Symbol name, for body replacement and inserts." },
467
+ { name: "newBody", type: "string", required: false, description: "New body, for replace-body." },
468
+ { name: "importSpec", type: "string", required: false, description: "Import spec, for add-import and remove-import." },
469
+ { name: "content", type: "string", required: false, description: "Content, for insert-before and insert-after." },
470
+ ...PROVENANCE_PARAMS,
471
+ PREVIEW_PARAM,
472
+ ],
473
+ mutates: true,
474
+ handler: async (a) =>
475
+ routeEdit({
476
+ filePath: str(a, "file")!,
477
+ operation: str(a, "operation")!,
478
+ method: str(a, "method"),
479
+ oldHash: str(a, "oldHash"),
480
+ newContent: str(a, "newContent"),
481
+ oldContent: str(a, "oldContent"),
482
+ range: parseRangeArg(str(a, "range")),
483
+ oldName: str(a, "oldName"),
484
+ newName: str(a, "newName"),
485
+ symbolName: str(a, "symbol"),
486
+ newBody: str(a, "newBody"),
487
+ importSpec: str(a, "importSpec"),
488
+ content: str(a, "content"),
489
+ dryRun: bool(a, "dryRun"),
490
+ includeSource: bool(a, "includeSource"),
491
+ actor: str(a, "actor"),
492
+ taskId: str(a, "taskId"),
493
+ reason: str(a, "reason"),
494
+ } as Parameters<typeof routeEdit>[0]),
495
+ },
496
+ {
497
+ name: "verify_changes",
498
+ cliCommand: ["verify-changes"],
499
+ summary: "Run the project's formatter, linter, and tests over changed files.",
500
+ description:
501
+ "Run the checks the project already defines, scoped to the files you edited. " +
502
+ "Every check is opt-in, so ask for what you need. Call it after a batch of " +
503
+ "edits, not after each one. " +
504
+ "Do NOT treat a failure as proof your edit broke something unless a baseline " +
505
+ "was recorded — pass `useBaseline` so pre-existing failures are subtracted.",
506
+ params: [
507
+ { name: "files", type: "string[]", required: true, description: "Files the checks should cover." },
508
+ { name: "autoDetect", type: "boolean", required: false, description: "Detect the project's formatter, linter, and test runner from its manifest. Usually what you want." },
509
+ { name: "formatter", type: "string", required: false, description: "Formatter command to run, e.g. 'prettier'. Overrides detection." },
510
+ { name: "linter", type: "string", required: false, description: "Linter command to run, e.g. 'eslint'. Overrides detection." },
511
+ { name: "typecheck", type: "string", required: false, description: "Type checker command, e.g. 'tsc --noEmit'." },
512
+ { name: "testRunner", type: "string", required: false, description: "Test runner, e.g. 'bun test', 'vitest', 'pytest', 'go test'." },
513
+ { name: "testFilter", type: "string", required: false, description: "Only run tests matching this pattern." },
514
+ { name: "scopeTests", type: "boolean", required: false, description: "Run only tests related to the changed files. Default true." },
515
+ { name: "useBaseline", type: "boolean", required: false, description: "Subtract tests that were already failing at this commit, so only new breakage fails the run." },
516
+ { name: "revertOnFailure", type: "boolean", required: false, description: "Restore the files to their pre-edit contents if any check fails." },
517
+ { name: "timeout", type: "number", required: false, description: "Per-check timeout in milliseconds. Default 30000." },
518
+ ],
519
+ mutates: true,
520
+ handler: async (a) =>
521
+ verifyChanges(strArray(a, "files"), {
522
+ autoDetect: bool(a, "autoDetect"),
523
+ formatter: str(a, "formatter"),
524
+ linter: str(a, "linter"),
525
+ typecheck: str(a, "typecheck"),
526
+ testRunner: str(a, "testRunner"),
527
+ testFilter: str(a, "testFilter"),
528
+ // `scopeTests` defaults to true in the core; only an explicit false turns it off.
529
+ scopeTests: a.scopeTests === undefined ? undefined : bool(a, "scopeTests"),
530
+ useBaseline: bool(a, "useBaseline"),
531
+ revertOnFailure: bool(a, "revertOnFailure"),
532
+ timeout: num(a, "timeout"),
533
+ }),
534
+ },
535
+ ];
536
+
537
+ /** Look an operation up by MCP tool name. */
538
+ export function getOperation(name: string): Operation | undefined {
539
+ return OPERATIONS.find((o) => o.name === name);
540
+ }
541
+
542
+ /** JSON Schema for an operation's parameters, as MCP's `inputSchema`. */
543
+ export function inputSchemaFor(op: Operation): Record<string, unknown> {
544
+ const properties: Record<string, unknown> = {};
545
+ for (const p of op.params) {
546
+ properties[p.name] =
547
+ p.type === "string[]"
548
+ ? { type: "array", items: { type: "string" }, description: p.description }
549
+ : { type: p.type, description: p.description };
550
+ }
551
+ return {
552
+ type: "object",
553
+ properties,
554
+ required: op.params.filter((p) => p.required).map((p) => p.name),
555
+ additionalProperties: false,
556
+ };
557
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Output control: verbosity and color (#47).
3
+ *
4
+ * Three global flags plus one environment variable decide how much the CLI
5
+ * prints and whether it prints in color:
6
+ *
7
+ * --quiet / -q suppress non-essential output; errors still print
8
+ * --verbose / -v diagnostic detail on **stderr** (never stdout)
9
+ * --no-color disable ANSI color; `NO_COLOR` (any non-empty value) does
10
+ * the same, as does a non-TTY stdout
11
+ *
12
+ * Two rules are load-bearing rather than cosmetic:
13
+ *
14
+ * 1. **JSON output is never colorized.** ANSI escapes in a piped envelope
15
+ * corrupt machine parsing, which makes this a correctness concern, not a
16
+ * styling one. Color is gated on `format === "text"` here so no renderer has
17
+ * to remember it.
18
+ * 2. **Verbose output goes to stderr.** Diagnostics on stdout would land inside
19
+ * the JSON an agent is parsing.
20
+ *
21
+ * `--quiet` deliberately does **not** suppress the JSON envelope: that envelope
22
+ * is the apiVersion 1 contract, and a caller that asked for JSON and got silence
23
+ * cannot tell success from a crash. It suppresses text-mode success rendering
24
+ * and verbose diagnostics.
25
+ */
26
+ import chalk, { type ChalkInstance } from "chalk";
27
+ import type { OutputFormat } from "./format";
28
+
29
+ export type Verbosity = "quiet" | "normal" | "verbose";
30
+
31
+ let verbosity: Verbosity = "normal";
32
+ let colorOn = false;
33
+
34
+ export interface OutputOptions {
35
+ quiet?: boolean;
36
+ verbose?: boolean;
37
+ /** Commander sets this to `false` when `--no-color` is passed. */
38
+ color?: boolean;
39
+ format?: OutputFormat;
40
+ isTTY?: boolean;
41
+ env?: Record<string, string | undefined>;
42
+ }
43
+
44
+ /**
45
+ * Decide whether ANSI color may be emitted.
46
+ *
47
+ * Every one of these is a veto; none of them is an override. `--color` is not a
48
+ * flag, so there is deliberately no way to force color into a pipe.
49
+ */
50
+ export function resolveColor(opts: OutputOptions = {}): boolean {
51
+ const env = opts.env ?? process.env;
52
+ if (opts.color === false) return false;
53
+ // NO_COLOR: any non-empty value disables color (no-color.org).
54
+ if ((env.NO_COLOR ?? "") !== "") return false;
55
+ if (env.TERM === "dumb") return false;
56
+ // Rule 1: the machine-readable envelope stays byte-clean.
57
+ if ((opts.format ?? "json") !== "text") return false;
58
+ const isTTY = opts.isTTY ?? process.stdout.isTTY;
59
+ return Boolean(isTTY);
60
+ }
61
+
62
+ /** Resolve verbosity. `--quiet` wins over `--verbose` — the quieter ask is the safer one. */
63
+ export function resolveVerbosity(opts: OutputOptions = {}): Verbosity {
64
+ if (opts.quiet) return "quiet";
65
+ if (opts.verbose) return "verbose";
66
+ return "normal";
67
+ }
68
+
69
+ /** Called once by the CLI's preAction hook. */
70
+ export function configureOutput(opts: OutputOptions = {}): void {
71
+ verbosity = resolveVerbosity(opts);
72
+ colorOn = resolveColor(opts);
73
+ }
74
+
75
+ /** Reset to defaults. Tests use this; the CLI configures once per process. */
76
+ export function resetOutput(): void {
77
+ verbosity = "normal";
78
+ colorOn = false;
79
+ }
80
+
81
+ export function getVerbosity(): Verbosity {
82
+ return verbosity;
83
+ }
84
+ export function isQuiet(): boolean {
85
+ return verbosity === "quiet";
86
+ }
87
+ export function isVerbose(): boolean {
88
+ return verbosity === "verbose";
89
+ }
90
+ export function colorEnabled(): boolean {
91
+ return colorOn;
92
+ }
93
+
94
+ /**
95
+ * Emit a diagnostic line on stderr, only under `--verbose`.
96
+ *
97
+ * Callers pass a thunk when the message costs something to build, so the
98
+ * formatting work does not happen on the default path.
99
+ */
100
+ export function verboseLog(message: string | (() => string)): void {
101
+ if (verbosity !== "verbose") return;
102
+ const text = typeof message === "function" ? message() : message;
103
+ process.stderr.write(paint(chalk.dim, "[verbose] " + text) + "\n");
104
+ }
105
+
106
+ /** Apply a chalk style, or return the string untouched when color is off. */
107
+ export function paint(style: ChalkInstance, text: string): string {
108
+ return colorOn ? style(text) : text;
109
+ }
110
+
111
+ /**
112
+ * Colorize the status glyphs a renderer emits. Applied at the single write
113
+ * choke point in `format.ts` so no individual renderer has to know about color,
114
+ * and so nothing can leak an escape into a non-text path.
115
+ */
116
+ export function colorizeGlyphs(text: string): string {
117
+ if (!colorOn) return text;
118
+ return text
119
+ .replace(/✓/g, chalk.green("✓"))
120
+ .replace(/✗/g, chalk.red("✗"))
121
+ .replace(/⚠/g, chalk.yellow("⚠"));
122
+ }