@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,255 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ findSymbolsDetailed,
4
+ renameSymbol,
5
+ replaceBody,
6
+ addImport,
7
+ removeImport,
8
+ insertBeforeSymbol,
9
+ insertAfterSymbol,
10
+ detectLanguage,
11
+ recordEvent,
12
+ astCapabilities,
13
+ toPreview,
14
+ ErrorCode,
15
+ buildProvenanceFields,
16
+ safeWrite,
17
+ finish,
18
+ } from "../core/index";
19
+ import type { TelemetryEvent } from "../core/telemetry";
20
+ import { withPreview, withProvenance } from "./shared";
21
+
22
+ /** Register the `ast` command group. */
23
+ export function register(program: Command): void {
24
+ const astCmd = program
25
+ .command("ast")
26
+ .description("Syntax-aware editing via tree-sitter");
27
+
28
+ astCmd
29
+ .command("capabilities")
30
+ .description("Show supported AST languages, operations, and limitations")
31
+ .action(() => {
32
+ finish(astCapabilities());
33
+ });
34
+
35
+ astCmd
36
+ .command("find-symbols")
37
+ .description("List symbols in a file")
38
+ .argument("<file>", "File path")
39
+ .action(async (file: string) => {
40
+ const content = await Bun.file(file).text();
41
+ // Report the truncation flag rather than an array that looks complete (#39).
42
+ const { symbols, truncated } = findSymbolsDetailed(content, file);
43
+ finish({ symbols, truncated });
44
+ });
45
+
46
+ function recordProvenanceEvent(opts: {
47
+ operation: string; route: TelemetryEvent["route"]; file: string; success: boolean; elapsed_ms: number;
48
+ source?: string; newSource?: string; errorCode?: ErrorCode; language?: string;
49
+ actor?: string; taskId?: string; reason?: string; filePath?: string;
50
+ }) {
51
+ const provFields = buildProvenanceFields({
52
+ actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
53
+ source: opts.source, newSource: opts.newSource, filePath: opts.filePath,
54
+ });
55
+ recordEvent({
56
+ operation: opts.operation, route: opts.route, file: opts.file,
57
+ language: opts.language, success: opts.success, elapsed_ms: opts.elapsed_ms,
58
+ errorCode: opts.errorCode, ...provFields,
59
+ });
60
+ }
61
+
62
+ withProvenance(
63
+ withPreview(
64
+ astCmd
65
+ .command("rename-symbol")
66
+ .description(
67
+ "File-scoped, binding-aware rename of a symbol and its references. " +
68
+ "Refuses with AMBIGUOUS_SYMBOL when the name binds more than one symbol " +
69
+ "in the file (a shadowed local, a foreign import, or a duplicate declaration).",
70
+ )
71
+ .argument("<file>", "File path")
72
+ .argument("<old-name>", "Current symbol name")
73
+ .argument("<new-name>", "New symbol name"),
74
+ "Preview only",
75
+ )
76
+ )
77
+
78
+ .action(async (file: string, oldName: string, newName: string, opts) => {
79
+ const start = Date.now();
80
+ const content = await Bun.file(file).text();
81
+ const result = renameSymbol(content, file, oldName, newName);
82
+ if (result.success && result.newSource && !opts.dryRun) {
83
+ await safeWrite(file, result.newSource);
84
+ }
85
+ recordProvenanceEvent({
86
+ operation: "rename-symbol", route: "ast", file,
87
+ language: detectLanguage(file) || undefined,
88
+ success: result.success, elapsed_ms: Date.now() - start,
89
+ errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
90
+ source: content, newSource: result.newSource, filePath: file,
91
+ actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
92
+ });
93
+ // A dry run previews the change; it does not hand back the whole file (#98).
94
+ finish(opts.dryRun ? toPreview(result, content, file, opts.includeSource) : result);
95
+ });
96
+
97
+ withProvenance(
98
+ withPreview(
99
+ astCmd
100
+ .command("replace-body")
101
+ .description("Replace function/method body")
102
+ .argument("<file>", "File path")
103
+ .argument("<symbol>", "Symbol name")
104
+ .argument("<new-body>", "New body statements only — no braces, no indentation (or @file)"),
105
+ "Preview only",
106
+ )
107
+ )
108
+
109
+ .action(async (file: string, symbol: string, newBody: string, opts) => {
110
+ const start = Date.now();
111
+ let body = newBody;
112
+ if (newBody.startsWith("@")) body = await Bun.file(newBody.slice(1)).text();
113
+ const content = await Bun.file(file).text();
114
+ const result = replaceBody(content, file, symbol, body);
115
+ if (result.success && result.newSource && !opts.dryRun) {
116
+ await safeWrite(file, result.newSource);
117
+ }
118
+ recordProvenanceEvent({
119
+ operation: "replace-body", route: "ast", file,
120
+ language: detectLanguage(file) || undefined,
121
+ success: result.success, elapsed_ms: Date.now() - start,
122
+ errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
123
+ source: content, newSource: result.newSource, filePath: file,
124
+ actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
125
+ });
126
+ // A dry run previews the change; it does not hand back the whole file (#98).
127
+ finish(opts.dryRun ? toPreview(result, content, file, opts.includeSource) : result);
128
+ });
129
+
130
+ withProvenance(
131
+ withPreview(
132
+ astCmd
133
+ .command("add-import")
134
+ .description("Add an import statement")
135
+ .argument("<file>", "File path")
136
+ .argument("<import-spec>", 'Import spec, module path quoted: \'{ Foo } from "./bar"\''),
137
+ "Preview only",
138
+ )
139
+ )
140
+
141
+ .action(async (file: string, importSpec: string, opts) => {
142
+ const start = Date.now();
143
+ const content = await Bun.file(file).text();
144
+ const result = addImport(content, file, importSpec);
145
+ if (result.success && result.newSource && !opts.dryRun) {
146
+ await safeWrite(file, result.newSource);
147
+ }
148
+ recordProvenanceEvent({
149
+ operation: "add-import", route: "ast", file,
150
+ language: detectLanguage(file) || undefined,
151
+ success: result.success, elapsed_ms: Date.now() - start,
152
+ errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
153
+ source: content, newSource: result.newSource, filePath: file,
154
+ actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
155
+ });
156
+ // A dry run previews the change; it does not hand back the whole file (#98).
157
+ finish(opts.dryRun ? toPreview(result, content, file, opts.includeSource) : result);
158
+ });
159
+
160
+ withProvenance(
161
+ withPreview(
162
+ astCmd
163
+ .command("remove-import")
164
+ .description("Remove an import statement")
165
+ .argument("<file>", "File path")
166
+ .argument("<import-spec>", 'Import spec to remove, e.g. \'{ Foo } from "./bar"\' or a bare binding name'),
167
+ "Preview only",
168
+ )
169
+ )
170
+
171
+ .action(async (file: string, importSpec: string, opts) => {
172
+ const start = Date.now();
173
+ const content = await Bun.file(file).text();
174
+ const result = removeImport(content, file, importSpec);
175
+ if (result.success && result.newSource && !opts.dryRun) {
176
+ await safeWrite(file, result.newSource);
177
+ }
178
+ recordProvenanceEvent({
179
+ operation: "remove-import", route: "ast", file,
180
+ language: detectLanguage(file) || undefined,
181
+ success: result.success, elapsed_ms: Date.now() - start,
182
+ errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
183
+ source: content, newSource: result.newSource, filePath: file,
184
+ actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
185
+ });
186
+ // A dry run previews the change; it does not hand back the whole file (#98).
187
+ finish(opts.dryRun ? toPreview(result, content, file, opts.includeSource) : result);
188
+ });
189
+
190
+ withProvenance(
191
+ withPreview(
192
+ astCmd
193
+ .command("insert-before")
194
+ .description("Insert content before a symbol")
195
+ .argument("<file>", "File path")
196
+ .argument("<symbol>", "Symbol name")
197
+ .argument("<content>", "Content to insert (or @file)"),
198
+ "Preview only",
199
+ )
200
+ )
201
+
202
+ .action(async (file: string, symbol: string, content: string, opts) => {
203
+ const start = Date.now();
204
+ let c = content;
205
+ if (c.startsWith("@")) c = await Bun.file(c.slice(1)).text();
206
+ const src = await Bun.file(file).text();
207
+ const result = insertBeforeSymbol(src, file, symbol, c);
208
+ if (result.success && result.newSource && !opts.dryRun) {
209
+ await safeWrite(file, result.newSource);
210
+ }
211
+ recordProvenanceEvent({
212
+ operation: "insert-before", route: "ast", file,
213
+ language: detectLanguage(file) || undefined,
214
+ success: result.success, elapsed_ms: Date.now() - start,
215
+ errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
216
+ source: src, newSource: result.newSource, filePath: file,
217
+ actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
218
+ });
219
+ // A dry run previews the change; it does not hand back the whole file (#98).
220
+ finish(opts.dryRun ? toPreview(result, src, file, opts.includeSource) : result);
221
+ });
222
+
223
+ withProvenance(
224
+ withPreview(
225
+ astCmd
226
+ .command("insert-after")
227
+ .description("Insert content after a symbol")
228
+ .argument("<file>", "File path")
229
+ .argument("<symbol>", "Symbol name")
230
+ .argument("<content>", "Content to insert (or @file)"),
231
+ "Preview only",
232
+ )
233
+ )
234
+
235
+ .action(async (file: string, symbol: string, content: string, opts) => {
236
+ const start = Date.now();
237
+ let c = content;
238
+ if (c.startsWith("@")) c = await Bun.file(c.slice(1)).text();
239
+ const src = await Bun.file(file).text();
240
+ const result = insertAfterSymbol(src, file, symbol, c);
241
+ if (result.success && result.newSource && !opts.dryRun) {
242
+ await safeWrite(file, result.newSource);
243
+ }
244
+ recordProvenanceEvent({
245
+ operation: "insert-after", route: "ast", file,
246
+ language: detectLanguage(file) || undefined,
247
+ success: result.success, elapsed_ms: Date.now() - start,
248
+ errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
249
+ source: src, newSource: result.newSource, filePath: file,
250
+ actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
251
+ });
252
+ // A dry run previews the change; it does not hand back the whole file (#98).
253
+ finish(opts.dryRun ? toPreview(result, src, file, opts.includeSource) : result);
254
+ });
255
+ }
@@ -0,0 +1,98 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ detectLanguage,
4
+ recordEvent,
5
+ generateUnifiedDiff,
6
+ applyPatch,
7
+ buildProvenanceFields,
8
+ finish,
9
+ usageError,
10
+ ExitCode,
11
+ } from "../core/index";
12
+ import { join } from "path";
13
+ import { parseIntFlag, withProvenance } from "./shared";
14
+
15
+ /** Register the `diff` command group. */
16
+ export function register(program: Command): void {
17
+ const diffCmd = program
18
+ .command("diff")
19
+ .description("Unified diff generation and patch application");
20
+
21
+ diffCmd
22
+ .command("generate")
23
+ .description("Generate a unified diff between old and new content")
24
+ .argument("<file>", "File path (for diff header)")
25
+ .argument("<old-content>", "Old content (or @file)")
26
+ .argument("<new-content>", "New content (or @file)")
27
+ .option("-c, --context <n>", "Context lines", "3")
28
+ .option("--raw", "Print the diff text alone, without the JSON envelope")
29
+ .action(async (file: string, oldContent: string, newContent: string, opts) => {
30
+ const start = Date.now();
31
+ let oldSrc = oldContent;
32
+ let newSrc = newContent;
33
+ if (oldContent.startsWith("@")) oldSrc = await Bun.file(oldContent.slice(1)).text();
34
+ if (newContent.startsWith("@")) newSrc = await Bun.file(newContent.slice(1)).text();
35
+ const diff = generateUnifiedDiff(oldSrc, newSrc, file, parseInt(opts.context));
36
+ recordEvent({
37
+ operation: "diff-generate",
38
+ route: "diff",
39
+ file,
40
+ success: true,
41
+ elapsed_ms: Date.now() - start,
42
+ });
43
+ // The diff itself is the payload; it rides the envelope like every other
44
+ // command so a consumer has one parse path (`--raw` prints it bare).
45
+ if (opts.raw) {
46
+ console.log(diff || "(no changes)");
47
+ return;
48
+ }
49
+ finish({ path: file, changed: diff.length > 0, diff }, ExitCode.OK);
50
+ });
51
+
52
+ withProvenance(
53
+ diffCmd
54
+ .command("apply")
55
+ .description("Apply a unified diff patch to a file")
56
+ .argument("<file>", "File to patch")
57
+ .option("--patch <file>", "Patch file to apply (or '-' for stdin)")
58
+ .option("--dry-run", "Preview without writing")
59
+ .option("-f, --fuzzy <n>", "Fuzzy match tolerance in lines; 0 = strict (exact offset and content, refuses otherwise)", "3")
60
+ )
61
+
62
+ .action(async (file: string, opts) => {
63
+ const start = Date.now();
64
+ let patchText: string;
65
+ if (opts.patch === "-") {
66
+ // Read from stdin
67
+ const chunks: string[] = [];
68
+ for await (const chunk of Bun.stdin.stream()) {
69
+ chunks.push(Buffer.from(chunk).toString());
70
+ }
71
+ patchText = chunks.join("");
72
+ } else if (opts.patch) {
73
+ patchText = await Bun.file(opts.patch).text();
74
+ } else {
75
+ // Must return: without it, patchText is unassigned and applyPatch throws.
76
+ return usageError("--patch is required", { path: file });
77
+ }
78
+ const fuzzy = parseIntFlag(opts.fuzzy, "--fuzzy", 3);
79
+ if (typeof fuzzy === "object") return usageError(fuzzy.error, { path: file });
80
+ const result = await applyPatch(file, patchText, {
81
+ dryRun: opts.dryRun,
82
+ fuzzyMatch: fuzzy,
83
+ });
84
+ const provFields = buildProvenanceFields({
85
+ actor: opts.actor, taskId: opts.taskId, reason: opts.reason, filePath: file,
86
+ });
87
+ recordEvent({
88
+ operation: "diff-apply",
89
+ route: "diff",
90
+ file,
91
+ language: detectLanguage(file) || undefined,
92
+ success: result.success,
93
+ elapsed_ms: Date.now() - start,
94
+ ...provFields,
95
+ });
96
+ finish(result);
97
+ });
98
+ }
@@ -0,0 +1,93 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ routeEdit,
4
+ editMany,
5
+ editManySerial,
6
+ resolveContent,
7
+ finish,
8
+ } from "../core/index";
9
+ import { withEditFlags, withProvenance } from "./shared";
10
+
11
+ /** Register the `edit` command group. */
12
+ export function register(program: Command): void {
13
+ withProvenance(
14
+ withEditFlags(
15
+ program
16
+ .command("route-edit")
17
+ .description("Auto-routed structured edit through AST → Hash → Diff pipeline")
18
+ .argument("<file>", "File path")
19
+ .argument("<operation>", "Operation (rename-symbol, replace-body, add-import, remove-import, insert-before, insert-after, replace-hash, replace-content)")
20
+ )
21
+ .option("--dry-run", "Preview without writing")
22
+ .option("--include-source", "On a dry run, return the whole post-edit file instead of a diff")
23
+ )
24
+
25
+ .action(async (file: string, operation: string, opts) => {
26
+ const result = await routeEdit({
27
+ filePath: file,
28
+ operation,
29
+ method: opts.method,
30
+ oldHash: opts.oldHash,
31
+ newContent: await resolveContent(opts.newContent),
32
+ oldContent: opts.oldContent,
33
+ range: opts.range ? (([s, e]: number[]) => ({ start: s, end: e }))(opts.range.split(":").map(Number)) : undefined,
34
+ oldName: opts.oldName,
35
+ newName: opts.newName,
36
+ symbolName: opts.symbol,
37
+ newBody: await resolveContent(opts.newBody),
38
+ importSpec: opts.importSpec,
39
+ content: await resolveContent(opts.content),
40
+ policy: opts.policy ? JSON.parse(opts.policy) : undefined,
41
+ dryRun: opts.dryRun,
42
+ includeSource: opts.includeSource,
43
+ actor: opts.actor,
44
+ taskId: opts.taskId,
45
+ reason: opts.reason,
46
+ });
47
+
48
+ finish(result);
49
+ });
50
+
51
+ withProvenance(
52
+ withEditFlags(
53
+ program
54
+ .command("batch")
55
+ .description("Apply the same edit to multiple files in parallel")
56
+ .argument("<operation>", "Operation (rename-symbol, replace-body, add-import, remove-import, insert-before, insert-after, replace-hash, replace-content)")
57
+ .argument("<files...>", "Files to edit")
58
+ )
59
+ .option("--serial", "Execute sequentially instead of parallel")
60
+ .option("--dry-run", "Preview without writing")
61
+ .option("--include-source", "On a dry run, return the whole post-edit file instead of a diff")
62
+ )
63
+
64
+ .action(async (operation: string, files: string[], opts) => {
65
+ const batchParams = {
66
+ files,
67
+ operation,
68
+ method: opts.method,
69
+ oldHash: opts.oldHash,
70
+ newContent: await resolveContent(opts.newContent),
71
+ oldContent: opts.oldContent,
72
+ range: opts.range ? (([s, e]: number[]) => ({ start: s, end: e }))(opts.range.split(":").map(Number)) : undefined,
73
+ oldName: opts.oldName,
74
+ newName: opts.newName,
75
+ symbolName: opts.symbol,
76
+ newBody: await resolveContent(opts.newBody),
77
+ importSpec: opts.importSpec,
78
+ content: await resolveContent(opts.content),
79
+ policy: opts.policy ? JSON.parse(opts.policy) : undefined,
80
+ dryRun: opts.dryRun,
81
+ includeSource: opts.includeSource,
82
+ actor: opts.actor,
83
+ taskId: opts.taskId,
84
+ reason: opts.reason,
85
+ };
86
+
87
+ const result = opts.serial
88
+ ? await editManySerial(batchParams)
89
+ : await editMany(batchParams);
90
+
91
+ finish(result);
92
+ });
93
+ }
@@ -0,0 +1,64 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ replaceHash,
4
+ detectLanguage,
5
+ recordEvent,
6
+ buildProvenanceFields,
7
+ finish,
8
+ usageError,
9
+ } from "../core/index";
10
+ import { parseRange, withProvenance } from "./shared";
11
+
12
+ /** Register the `hash` command group. */
13
+ export function register(program: Command): void {
14
+ withProvenance(
15
+ program
16
+ .command("replace-hash")
17
+ .description("Replace content identified by hash anchor")
18
+ .argument("<file>", "File path")
19
+ .argument("<old-hash>", "Hash of content to replace")
20
+ .argument("<new-content>", "New content (or @file to read from file)")
21
+ .option("--range <start:end>", "Line range (1-indexed). N or N:M")
22
+ .option("--no-recover", "Fail immediately on a stale anchor instead of attempting relocation")
23
+ .option("--dry-run", "Preview without writing")
24
+ )
25
+
26
+ .action(async (file: string, oldHash: string, newContent: string, opts) => {
27
+ const start = Date.now();
28
+ let content = newContent;
29
+ if (newContent.startsWith("@")) {
30
+ content = await Bun.file(newContent.slice(1)).text();
31
+ }
32
+ let range: { start: number; end: number } | undefined;
33
+ if (opts.range) {
34
+ const parsed = parseRange(opts.range);
35
+ if ("error" in parsed) return usageError(parsed.error, { path: file });
36
+ range = parsed.range;
37
+ }
38
+ const result = await replaceHash(file, oldHash, content, {
39
+ range,
40
+ dryRun: opts.dryRun,
41
+ // Commander maps --no-recover to opts.recover === false.
42
+ recovery: opts.recover === false ? "off" : "relocate",
43
+ skipParseCheck: Boolean(program.opts().allowParseErrors),
44
+ });
45
+ const provFields = buildProvenanceFields({
46
+ actor: opts.actor,
47
+ taskId: opts.taskId,
48
+ reason: opts.reason,
49
+ filePath: file,
50
+ });
51
+ recordEvent({
52
+ operation: "replace-hash",
53
+ route: "hash",
54
+ file,
55
+ language: detectLanguage(file) || undefined,
56
+ success: result.success,
57
+ fallback_reason: result.stale ? "stale-anchor" : undefined,
58
+ retries: result.retries ?? 0,
59
+ elapsed_ms: Date.now() - start,
60
+ ...provFields,
61
+ });
62
+ finish(result);
63
+ });
64
+ }
@@ -0,0 +1,68 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ executeIntent,
4
+ finish,
5
+ exitCodeFor,
6
+ getOutputFormat,
7
+ } from "../core/index";
8
+ import { withProvenance } from "./shared";
9
+
10
+ /** Register the `intent` command group. */
11
+ export function register(program: Command): void {
12
+ withProvenance(
13
+ program
14
+ .command("intent")
15
+ .description("Execute an editing intent — one command, full blast radius")
16
+ .argument("<intent>", "Intent as JSON: {\"operation\":\"add-parameter\",\"symbol\":\"fn\",\"param\":{\"name\":\"x\"}}")
17
+ .option("--project-root <dir>", "Project root directory")
18
+ .option("--dry-run", "Preview plan without modifying files")
19
+ .option("--yes", "Apply the plan even though part of the intent could not be resolved")
20
+ .option("--no-verify", "Skip verification after execution")
21
+ .option("--no-revert", "Don't roll back on failure")
22
+ .option("--timeout <ms>", "Timeout per operation in ms", "30000")
23
+ )
24
+ .option("--context <text>", "Agent prompt/context (or @file)")
25
+
26
+ .action(async (intent: string, opts) => {
27
+ try {
28
+ let context = opts.context;
29
+ if (context && context.startsWith("@")) {
30
+ context = await Bun.file(context.slice(1)).text();
31
+ }
32
+
33
+ const result = await executeIntent(intent, {
34
+ projectRoot: opts.projectRoot || process.cwd(),
35
+ dryRun: opts.dryRun,
36
+ yes: Boolean(opts.yes),
37
+ verify: opts.verify,
38
+ revertOnFailure: opts.revert,
39
+ timeout: parseInt(opts.timeout),
40
+ actor: opts.actor,
41
+ taskId: opts.taskId,
42
+ reason: opts.reason,
43
+ context,
44
+ });
45
+
46
+ if (getOutputFormat() === "json") {
47
+ finish(result);
48
+ } else {
49
+ console.log(`Intent: ${result.plan.intent.operation} on '${result.plan.definition.name}'`);
50
+ console.log(`Impact: ${result.plan.impactSummary}`);
51
+ for (const u of result.plan.unresolved) {
52
+ console.log(`Unresolved (${u.file}): ${u.reason}`);
53
+ console.log(` → ${u.resolution}`);
54
+ }
55
+ console.log(`Success: ${result.success}`);
56
+ if (result.execution.verification) {
57
+ console.log(`Verification: ${result.execution.verification.overall}`);
58
+ }
59
+ // Human output still has to carry the exit contract — an agent may run
60
+ // without --json and branch on the code.
61
+ process.exitCode = exitCodeFor(result);
62
+ }
63
+ } catch (err: any) {
64
+ console.error(`Intent failed: ${err.message}`);
65
+ process.exitCode = 1;
66
+ }
67
+ });
68
+ }