@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,191 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ health,
4
+ doctor,
5
+ finish,
6
+ ExitCode,
7
+ getOutputFormat,
8
+ } from "../core/index";
9
+ import { writeFileSync, rmSync, existsSync, mkdirSync } from "fs";
10
+ import { join } from "path";
11
+
12
+ /** Register the `maintenance` command group. */
13
+ export function register(program: Command): void {
14
+ program
15
+ .command("doctor")
16
+ .description("Verify HashPilot installation health")
17
+
18
+ .action(() => {
19
+ // Both formats go through finish(): it is the only place that sets the
20
+ // process exit code, and the old console.log bypass is why `doctor`
21
+ // always exited 0 no matter how broken the install was (#46).
22
+ const report = doctor();
23
+ finish(report, report.exitCode as ExitCode);
24
+ });
25
+
26
+ program
27
+ .command("upgrade")
28
+ .description("Upgrade HashPilot to the latest version from GitHub")
29
+ .option("--channel <channel>", "Release channel (default: main)", "main")
30
+ .option("--target <dir>", "Install target directory (default: ~/.agentic-tools)")
31
+ .option("--keep-telemetry", "Preserve existing telemetry on upgrade")
32
+ .option("--force", "Skip confirmation prompt")
33
+ .option("--dry-run", "Show what would be done without executing")
34
+ .action(async (opts) => {
35
+ const channel = opts.channel;
36
+ const targetDir = opts.target || join(process.env.HOME || "/root", ".agentic-tools");
37
+ const keepTelemetry = opts.keepTelemetry;
38
+ const force = opts.force;
39
+ const dryRun = opts.dryRun;
40
+
41
+ const installUrl = `https://raw.githubusercontent.com/bigknoxy/HashPilot/${channel}/scripts/install.sh`;
42
+ console.error(`Upgrading HashPilot from ${channel}...`);
43
+ console.error(`Target: ${targetDir}`);
44
+
45
+ if (dryRun) {
46
+ finish({ success: true, message: "Dry run - would upgrade", dryRun: true, channel, targetDir, keepTelemetry, force });
47
+ console.error("Dry run - would upgrade");
48
+ return;
49
+ }
50
+
51
+ try {
52
+ const response = await fetch(installUrl);
53
+ if (!response.ok) {
54
+ throw new Error(`Failed to download install script: ${response.status} ${response.statusText}`);
55
+ }
56
+ const script = await response.text();
57
+
58
+ // Write script to temp file and execute
59
+ const tmpScript = join(targetDir, `.hashpilot-upgrade-${Date.now()}.sh`);
60
+ writeFileSync(tmpScript, script, { mode: 0o755 });
61
+
62
+ const args = ["--target", targetDir];
63
+ if (keepTelemetry) args.push("--keep-telemetry");
64
+ if (force) args.push("--force");
65
+
66
+ const proc = Bun.spawn(["bash", tmpScript, ...args], {
67
+ stdout: "pipe",
68
+ stderr: "pipe",
69
+ env: { ...process.env, PATH: `${join(targetDir, "bin")}:${process.env.PATH || ""}` },
70
+ });
71
+
72
+ const stdout = await new Response(proc.stdout).text();
73
+ const stderr = await new Response(proc.stderr).text();
74
+ const exitCode = await proc.exited;
75
+
76
+ try { rmSync(tmpScript); } catch {}
77
+
78
+ if (stdout) console.error(stdout.trim());
79
+ if (stderr) console.error(stderr.trim());
80
+
81
+ if (exitCode !== 0) {
82
+ finish({ success: false, error: `Upgrade failed with exit code ${exitCode}` }, ExitCode.INTERNAL);
83
+ return;
84
+ }
85
+
86
+ finish({ success: true, message: "Upgrade completed successfully" });
87
+ console.error("Upgrade completed successfully");
88
+ } catch (e: any) {
89
+ finish({ success: false, error: e.message }, ExitCode.INTERNAL);
90
+ console.error(`Upgrade failed: ${e.message}`);
91
+ }
92
+ });
93
+
94
+ program
95
+ .command("uninstall")
96
+ .description("Remove HashPilot and all its components from the system")
97
+ .option("--keep-config", "Preserve config and telemetry data")
98
+ .option("--force", "Skip confirmation prompt (auto-detected when piped)")
99
+ .option("--dry-run", "Show what would be removed without deleting anything")
100
+ .option("--target <dir>", "Install target directory (default: ~/.agentic-tools)")
101
+
102
+ .action(async (opts) => {
103
+ const targetDir = opts.target || join(process.env.HOME || "/root", ".agentic-tools");
104
+
105
+ if (opts.dryRun) {
106
+ const components: string[] = [];
107
+ const keep = Boolean(opts.keepConfig);
108
+ const kept = (label: string) => keep ? `${label} [preserved by --keep-config]` : label;
109
+ if (existsSync(join(targetDir, "bin", "hashpilot"))) components.push(`CLI launcher: ${targetDir}/bin/hashpilot`);
110
+ if (existsSync(join(targetDir, "structured-editing"))) components.push(`Core source: ${targetDir}/structured-editing`);
111
+ components.push(kept(`Telemetry logs: ${targetDir}/logs`));
112
+ if (existsSync(join(targetDir, "manifest.json"))) components.push(`Manifest: ${targetDir}/manifest.json`);
113
+ components.push(kept("Config: ~/.config/hashpilot/config.json"));
114
+ if (existsSync(join(process.env.HOME || "/root", ".claude", "CLAUDE.md")))
115
+ components.push("Claude integration: ~/.claude/CLAUDE.md (section removal)");
116
+ if (existsSync(join(process.env.HOME || "/root", ".config", "opencode", "skills", "hashpilot", "SKILL.md")))
117
+ components.push("OpenCode skill: ~/.config/opencode/skills/hashpilot/SKILL.md");
118
+ if (existsSync(join(process.env.HOME || "/root", ".config", "opencode", "agent", "hashpilot.md")))
119
+ components.push("OpenCode agent: ~/.config/opencode/agent/hashpilot.md");
120
+ if (existsSync(join(process.env.HOME || "/root", ".pi", "agent", "extensions", "hashpilot.ts")))
121
+ components.push("Pi extension: ~/.pi/agent/extensions/hashpilot.ts");
122
+ if (existsSync(join(process.env.HOME || "/root", ".pi", "agent", "skills", "hashpilot", "SKILL.md")))
123
+ components.push("Pi skill: ~/.pi/agent/skills/hashpilot/SKILL.md");
124
+ finish({
125
+ success: true,
126
+ dryRun: true,
127
+ keepConfig: Boolean(opts.keepConfig),
128
+ targetDir,
129
+ components: components.length > 0 ? components : ["Nothing to remove — no HashPilot installation detected."],
130
+ });
131
+ return;
132
+ }
133
+
134
+ const uninstallUrl = `https://raw.githubusercontent.com/bigknoxy/HashPilot/main/scripts/uninstall.sh`;
135
+ console.error(`Uninstalling HashPilot from ${targetDir}...`);
136
+
137
+ if (!opts.force && process.stdin.isTTY) {
138
+ console.error("This will remove HashPilot and all its components.");
139
+ console.error(` Target: ${targetDir}`);
140
+ console.error(` Keep config: ${Boolean(opts.keepConfig)}`);
141
+ console.error(" Pass --force to skip this prompt.");
142
+ }
143
+
144
+ try {
145
+ const response = await fetch(uninstallUrl);
146
+ if (!response.ok) {
147
+ throw new Error(`Failed to download uninstall script: ${response.status} ${response.statusText}`);
148
+ }
149
+ const script = await response.text();
150
+
151
+ const tmpScript = join(targetDir, `.hashpilot-uninstall-${Date.now()}.sh`);
152
+ try { mkdirSync(join(targetDir), { recursive: true }); } catch {}
153
+ writeFileSync(tmpScript, script, { mode: 0o755 });
154
+
155
+ const args: string[] = [];
156
+ if (opts.target) args.push("--target", opts.target);
157
+ if (opts.keepConfig) args.push("--keep-config");
158
+ if (opts.force || !process.stdin.isTTY) args.push("--force");
159
+
160
+ const proc = Bun.spawn(["bash", tmpScript, ...args], {
161
+ stdout: "pipe",
162
+ stderr: "pipe",
163
+ env: {
164
+ ...process.env,
165
+ HASHPILOT_DIR: targetDir,
166
+ PATH: `${join(targetDir, "bin")}:${process.env.PATH || ""}`,
167
+ },
168
+ });
169
+
170
+ const stdout = await new Response(proc.stdout).text();
171
+ const stderr = await new Response(proc.stderr).text();
172
+ const exitCode = await proc.exited;
173
+
174
+ try { rmSync(tmpScript); } catch {}
175
+
176
+ if (stdout) console.error(stdout.trim());
177
+ if (stderr) console.error(stderr.trim());
178
+
179
+ if (exitCode !== 0) {
180
+ finish({ success: false, error: `Uninstall failed with exit code ${exitCode}` }, ExitCode.INTERNAL);
181
+ return;
182
+ }
183
+
184
+ finish({ success: true, message: "HashPilot uninstalled successfully" });
185
+ console.error("Uninstall completed successfully");
186
+ } catch (e: any) {
187
+ finish({ success: false, error: e.message }, ExitCode.INTERNAL);
188
+ console.error(`Uninstall failed: ${e.message}`);
189
+ }
190
+ });
191
+ }
@@ -0,0 +1,28 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ recordEvent,
4
+ } from "../core/index";
5
+ import { runStdioServer } from "../mcp/server";
6
+
7
+ /** Register the `mcp` command group. */
8
+ export function register(program: Command): void {
9
+ program
10
+ .command("mcp")
11
+ .description("Run HashPilot as an MCP server over stdio")
12
+ .option("--stdio", "Speak MCP over stdin/stdout (the only transport, and the default)")
13
+ .action(async () => {
14
+ // stdout is the protocol stream from here on, so nothing may print to it —
15
+ // including the JSON envelope every other command emits. The server runs
16
+ // until the host closes stdin; the telemetry event is recorded on the way
17
+ // out, when the session length is actually known.
18
+ const start = Date.now();
19
+ await runStdioServer();
20
+ recordEvent({
21
+ operation: "mcp",
22
+ route: "none",
23
+ success: true,
24
+ elapsed_ms: Date.now() - start,
25
+ });
26
+ process.exit(0);
27
+ });
28
+ }
@@ -0,0 +1,111 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ ErrorCode,
4
+ setCurrentChangeSet,
5
+ listChangeSets,
6
+ lastChangeSetId,
7
+ undoChangeSet,
8
+ provenanceQuery,
9
+ changeSetQuery,
10
+ formatProvenanceHuman,
11
+ finish,
12
+ usageError,
13
+ ExitCode,
14
+ exitCodeFor,
15
+ } from "../core/index";
16
+ import { parseIntFlag } from "./shared";
17
+
18
+ /** Register the `provenance` command group. */
19
+ export function register(program: Command): void {
20
+ const provCmd = program
21
+ .command("provenance")
22
+ .description("Query edit provenance — who changed what, when, and why");
23
+
24
+ provCmd
25
+ .command("query")
26
+ .description("Show edit history for a file (like git blame for agent edits)")
27
+ .argument("<file>", "File path")
28
+ .argument("[line]", "Optional line number to filter by")
29
+ .option("--human", "Human-readable output")
30
+
31
+ .option("--fuzzy", "Include edits without diff data in line-filtered queries")
32
+ .option("--limit <n>", "Max entries to show")
33
+ .action((file, line, opts) => {
34
+ const lineNum = line ? parseInt(line) : undefined;
35
+ let results = provenanceQuery(file, lineNum, !!opts.fuzzy);
36
+ if (opts.limit) results = results.slice(0, parseInt(opts.limit));
37
+ if (opts.human) {
38
+ console.log(formatProvenanceHuman(results));
39
+ return;
40
+ }
41
+ // A file with no recorded edits is an empty history, not a failure.
42
+ finish(results, ExitCode.OK);
43
+ });
44
+
45
+ provCmd
46
+ .command("changeset")
47
+ .description("Show all edits in a changeSet")
48
+ .argument("<changeSetId>", "ChangeSet UUID")
49
+ .option("--human", "Human-readable output")
50
+ .action((changeSetId, opts) => {
51
+ const result = changeSetQuery(changeSetId);
52
+ if (!result) {
53
+ return finish(
54
+ {
55
+ success: false,
56
+ errorCode: ErrorCode.FILE_NOT_FOUND,
57
+ changeSetId,
58
+ message: `No edits found for changeSet: ${changeSetId}`,
59
+ recovery: "hashpilot telemetry sessions",
60
+ },
61
+ ExitCode.USAGE,
62
+ );
63
+ }
64
+ if (opts.human) {
65
+ console.log(`ChangeSet: ${result.changeSetId}`);
66
+ console.log(`Actor: ${result.actor}`);
67
+ console.log(`Task: ${result.taskId ?? "N/A"}`);
68
+ console.log(`Reason: ${result.reason}`);
69
+ console.log(`Edits: ${result.editCount}`);
70
+ console.log(`Time: ${result.timeRange.first} -- ${result.timeRange.last}\n`);
71
+ console.log(formatProvenanceHuman(result.entries));
72
+ process.exitCode = exitCodeFor(result);
73
+ } else {
74
+ finish(result);
75
+ }
76
+ });
77
+
78
+ program
79
+ .command("changesets")
80
+ .description("List undoable changeSets, newest first")
81
+ .option("--limit <n>", "Max changeSets to list (default 20)")
82
+ .action((opts) => {
83
+ const limit = parseIntFlag(opts.limit, "--limit", 20);
84
+ if (typeof limit === "object") return usageError(limit.error);
85
+ finish({ changeSets: listChangeSets(limit) }, ExitCode.OK);
86
+ });
87
+
88
+ program
89
+ .command("undo")
90
+ .description("Restore every file in a changeSet to its pre-edit contents")
91
+ .argument("[changeSetId]", "ChangeSet to undo; omit with --last")
92
+ .option("--last", "Undo the most recent changeSet")
93
+ .option("--force", "Restore even files modified since the edit was applied")
94
+ .option("--dry-run", "Report what would be restored without touching the disk")
95
+ .action((changeSetId, opts) => {
96
+ const id = opts.last ? lastChangeSetId() : changeSetId;
97
+ if (!id) {
98
+ return usageError(
99
+ opts.last
100
+ ? "No changeSets have been recorded yet."
101
+ : "Provide a changeSet ID, or pass --last.",
102
+ { recovery: "hashpilot changesets" },
103
+ );
104
+ }
105
+ // The undo's own write must not be snapshotted as a new changeSet — that
106
+ // would make `undo --last` toggle between two states forever.
107
+ setCurrentChangeSet(null);
108
+ const result = undoChangeSet(id, { force: Boolean(opts.force), dryRun: Boolean(opts.dryRun) });
109
+ finish(result, result.success ? ExitCode.OK : ExitCode.PRECONDITION);
110
+ });
111
+ }
@@ -0,0 +1,117 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ readMany,
4
+ readHash,
5
+ grepMany,
6
+ symbolLookupMany,
7
+ recordEvent,
8
+ safeWrite,
9
+ finish,
10
+ usageError,
11
+ } from "../core/index";
12
+ import { parseIntFlag } from "./shared";
13
+
14
+ /** Register the `read` command group. */
15
+ export function register(program: Command): void {
16
+ program
17
+ .command("read-many")
18
+ .description("Read multiple files, return content + hashes")
19
+ .argument("<files...>", "File paths")
20
+
21
+ .action(async (files: string[], opts) => {
22
+ const start = Date.now();
23
+ const results = await readMany(files);
24
+ recordEvent({
25
+ operation: "read-many",
26
+ route: "read",
27
+ files_count: files.length,
28
+ success: !results.some((r) => r.error),
29
+ elapsed_ms: Date.now() - start,
30
+ });
31
+ finish(results);
32
+ });
33
+
34
+ program
35
+ .command("read-hash")
36
+ .description("Read a line with hash and context")
37
+ .argument("<file>", "File path")
38
+ .argument("<line>", "Line number", parseInt)
39
+ .option("-c, --context <n>", "Context lines", "3")
40
+
41
+ .action(async (file: string, line: number, opts) => {
42
+ const start = Date.now();
43
+ const context = parseIntFlag(opts.context, "--context", 3);
44
+ if (typeof context === "object") return usageError(context.error, { path: file });
45
+ const result = await readHash(file, line, context);
46
+ recordEvent({
47
+ operation: "read-hash",
48
+ route: "hash",
49
+ file,
50
+ success: !result.error,
51
+ lines_read: 1 + (result.contextBefore?.length || 0) + (result.contextAfter?.length || 0),
52
+ elapsed_ms: Date.now() - start,
53
+ });
54
+ finish(result);
55
+ });
56
+
57
+ program
58
+ .command("grep-many")
59
+ .description(
60
+ 'Search pattern across multiple paths. Usage: grep-many "safeWrite" src/ ' +
61
+ '(or the flag form: grep-many --pattern "safeWrite" --path src/)',
62
+ )
63
+ .argument("[pattern]", "Regex pattern (or use --pattern)")
64
+ .argument("[paths...]", "Paths to search (or use --path)")
65
+ .option("-i, --ignore-case", "Case insensitive")
66
+ .option("--pattern <p>", "Regex pattern, flag form of the positional")
67
+ .option(
68
+ "--path <dir>",
69
+ "Path to search, flag form of the positional (repeatable)",
70
+ (value: string, previous: string[]) => previous.concat([value]),
71
+ [] as string[],
72
+ )
73
+ .option("--file-pattern <glob>", "File pattern filter")
74
+ .option("--max-results <n>", "Max results", parseInt)
75
+
76
+ .action(async (patternArg: string | undefined, pathsArg: string[], opts) => {
77
+ if (patternArg !== undefined && opts.pattern !== undefined) {
78
+ return usageError('Pass the pattern positionally or as --pattern, not both.');
79
+ }
80
+ if (pathsArg.length > 0 && opts.path.length > 0) {
81
+ return usageError("Pass the paths positionally or as --path, not both.");
82
+ }
83
+ const pattern = patternArg ?? opts.pattern;
84
+ const paths = pathsArg.length > 0 ? pathsArg : opts.path;
85
+ if (!pattern) {
86
+ return usageError('A pattern is required: grep-many "<pattern>" <paths...> (or --pattern/--path).');
87
+ }
88
+ if (paths.length === 0) {
89
+ return usageError('At least one path is required: grep-many "<pattern>" <paths...> (or --pattern/--path).');
90
+ }
91
+ const result = await grepMany(pattern, paths, {
92
+ ignoreCase: opts.ignoreCase,
93
+ filePattern: opts.filePattern,
94
+ maxResults: opts.maxResults,
95
+ });
96
+ recordEvent({
97
+ operation: "grep-many",
98
+ route: "grep",
99
+ files_count: paths.length,
100
+ success: !result.error,
101
+ elapsed_ms: result.elapsed_ms,
102
+ });
103
+ finish(result);
104
+ });
105
+
106
+ program
107
+ .command("symbol-lookup-many")
108
+ .description("Find symbol definitions. Usage: symbol-lookup-many <paths...> --names n1,n2")
109
+ .argument("<paths...>", "Paths to search")
110
+ .option("--names <names>", "Comma-separated symbol names")
111
+
112
+ .action(async (paths: string[], opts) => {
113
+ const names = (opts.names || "").split(",").filter(Boolean);
114
+ const results = await symbolLookupMany(names, paths);
115
+ finish(results);
116
+ });
117
+ }
@@ -0,0 +1,42 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ detectLanguage,
4
+ chooseRoute,
5
+ loadConfig,
6
+ finish,
7
+ } from "../core/index";
8
+
9
+ /** Register the `route` command group. */
10
+ export function register(program: Command): void {
11
+ program
12
+ .command("route")
13
+ .description("Show which edit route would be chosen (with detailed explanation)")
14
+ .argument("<file>", "File path")
15
+ .argument("<operation>", "Operation name")
16
+ .option("--policy <json>", "Inline policy JSON to test")
17
+ .option("--no-default-config", "Ignore config file policies")
18
+ .action((file: string, operation: string, opts) => {
19
+ const lang = detectLanguage(file);
20
+ let policy = opts.policy ? JSON.parse(opts.policy) : undefined;
21
+ if (!policy && !opts.defaultConfig) {
22
+ policy = loadConfig().routePolicy;
23
+ }
24
+ const { route, explanation } = chooseRoute(file, operation, policy);
25
+ finish({
26
+ file,
27
+ operation,
28
+ language: lang,
29
+ route,
30
+ explanation,
31
+ });
32
+ });
33
+
34
+ program
35
+ .command("config")
36
+ .description("Show current HashPilot configuration")
37
+ .option("--config <path>", "Config file path override")
38
+ .action((opts) => {
39
+ const config = loadConfig(opts.config);
40
+ finish(config);
41
+ });
42
+ }
@@ -0,0 +1,65 @@
1
+ import type { Command } from "commander";
2
+
3
+ /**
4
+ * Parse `--range`. Accepts `N` (meaning `N:N`) or `N:M`, both 1-indexed and
5
+ * inclusive. Returns an error string rather than throwing so the caller can
6
+ * emit it through `usageError`.
7
+ *
8
+ * The old implementation was `opts.range.split(":").map(Number)`, which turned
9
+ * `--range 5` into `{start: 5, end: NaN}` and silently duplicated the file.
10
+ */
11
+ export function parseRange(raw: string): { range: { start: number; end: number } } | { error: string } {
12
+ const match = /^(\d+)(?::(\d+))?$/.exec(raw.trim());
13
+ if (!match) {
14
+ return { error: `Invalid --range "${raw}": expected N or N:M with positive integers.` };
15
+ }
16
+ const start = Number(match[1]);
17
+ const end = match[2] === undefined ? start : Number(match[2]);
18
+ if (start < 1) return { error: `Invalid --range "${raw}": line numbers are 1-indexed.` };
19
+ if (start > end) return { error: `Invalid --range "${raw}": start is after end.` };
20
+ return { range: { start, end } };
21
+ }
22
+
23
+ /** Parse a numeric flag, rejecting the NaN that bare `parseInt` yields on garbage. */
24
+ export function parseIntFlag(raw: string | undefined, name: string, fallback: number): number | { error: string } {
25
+ if (raw === undefined) return fallback;
26
+ if (!/^\d+$/.test(String(raw).trim())) {
27
+ return { error: `Invalid ${name} "${raw}": expected a non-negative integer.` };
28
+ }
29
+ return Number(raw);
30
+ }
31
+
32
+ /**
33
+ * The provenance flag trio, previously copy-pasted onto nine commands (#48).
34
+ * Appended last so `--help` output keeps the order it always had.
35
+ */
36
+ export function withProvenance(cmd: Command): Command {
37
+ return cmd
38
+ .option("--actor <name>", "Agent identity for provenance tracking")
39
+ .option("--task-id <id>", "Task/issue reference for provenance")
40
+ .option("--reason <text>", "Human-readable reason for the edit");
41
+ }
42
+
43
+ /** The routed-edit flag block shared by `route-edit` and `batch` (#48). */
44
+ export function withEditFlags(cmd: Command): Command {
45
+ return cmd
46
+ .option("--method <route>", "Force a specific route (ast, hash, diff)")
47
+ .option("--old-hash <hash>", "Hash for hash-route verification")
48
+ .option("--new-content <text>", "New content (or @file)")
49
+ .option("--old-content <text>", "Old content for diff-route search-and-replace")
50
+ .option("--range <start:end>", "Line range for hash route")
51
+ .option("--old-name <name>", "Old symbol name (rename-symbol)")
52
+ .option("--new-name <name>", "New symbol name (rename-symbol)")
53
+ .option("--symbol <name>", "Symbol name (replace-body, insert-before, insert-after)")
54
+ .option("--new-body <text>", "New body statements only — no braces, no indentation (replace-body, or @file)")
55
+ .option("--import-spec <spec>", 'Import spec, module path quoted: \'{ Foo } from "./bar"\'')
56
+ .option("--content <text>", "Content (insert-before, insert-after, or @file)")
57
+ .option("--policy <json>", "Inline RoutePolicy JSON");
58
+ }
59
+
60
+ /** `--dry-run` + `--include-source`, shared by every previewing edit command. */
61
+ export function withPreview(cmd: Command, dryRunDescription = "Preview without writing"): Command {
62
+ return cmd
63
+ .option("--dry-run", dryRunDescription)
64
+ .option("--include-source", "On a dry run, return the whole post-edit file instead of a diff");
65
+ }
@@ -0,0 +1,126 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ readEvents,
4
+ lastReadSkipped,
5
+ clearEvents,
6
+ summary,
7
+ health,
8
+ healthTrend,
9
+ listSessions,
10
+ exportEvents,
11
+ pruneEvents,
12
+ finish,
13
+ usageError,
14
+ addWarning,
15
+ ExitCode,
16
+ } from "../core/index";
17
+ import { parseIntFlag } from "./shared";
18
+
19
+ /** Register the `telemetry` command group. */
20
+ export function register(program: Command): void {
21
+ /**
22
+ * Corruption must be visible. It rides the envelope's `warnings` array (so a
23
+ * machine consumer sees it) and stderr (so a human running the command does).
24
+ */
25
+ function warnSkipped(): void {
26
+ const skipped = lastReadSkipped();
27
+ if (skipped > 0) {
28
+ const message = `skipped ${skipped} malformed telemetry line(s) — the log is corrupt`;
29
+ addWarning({ code: "TELEMETRY_LOG_CORRUPT", message, skipped });
30
+ console.error(`warning: ${message}`);
31
+ }
32
+ }
33
+
34
+ const telCmd = program
35
+ .command("telemetry")
36
+ .description("View or manage telemetry");
37
+
38
+ telCmd
39
+ .command("show")
40
+ .description("Show recent telemetry events")
41
+ .option("-n, --limit <n>", "Number of events", "20")
42
+ .action(async (opts) => {
43
+ const limit = parseIntFlag(opts.limit, "--limit", 20);
44
+ if (typeof limit === "object") return usageError(limit.error);
45
+ const events = readEvents(limit);
46
+ warnSkipped();
47
+ // A telemetry event's `success` field describes the operation it recorded,
48
+ // not this query. Letting `finish` infer the code turns "your log contains a
49
+ // failure" into "the query failed" (exit 2). Reads that complete are exit 0.
50
+ finish(events, ExitCode.OK);
51
+ });
52
+
53
+ telCmd
54
+ .command("summary")
55
+ .description("Show telemetry summary")
56
+ .action(() => {
57
+ const result = summary();
58
+ warnSkipped();
59
+ // Read-only query: see the note on `telemetry show`.
60
+ finish(result, ExitCode.OK);
61
+ });
62
+
63
+ telCmd
64
+ .command("health")
65
+ .description("Show telemetry health report with per-language stats and threshold warnings")
66
+ .option("-w, --window <days>", "Time window in days", "7")
67
+ .option("-t, --trend", "Compare current window to previous window")
68
+ .action((opts) => {
69
+ const window = parseIntFlag(opts.window, "--window", 7);
70
+ if (typeof window === "object") return usageError(window.error);
71
+ const report = opts.trend ? healthTrend(window) : health(window);
72
+ warnSkipped();
73
+ // Read-only query: see the note on `telemetry show`.
74
+ finish(report, ExitCode.OK);
75
+ });
76
+
77
+ telCmd
78
+ .command("clear")
79
+ .description("Clear telemetry log")
80
+ .action(() => {
81
+ clearEvents();
82
+ finish({ success: true, message: "Telemetry cleared." }, ExitCode.OK);
83
+ });
84
+
85
+ telCmd
86
+ .command("sessions")
87
+ .description("List session summaries")
88
+ .action(() => {
89
+ const sessions = listSessions();
90
+ warnSkipped();
91
+ // Read-only query: see the note on `telemetry show`.
92
+ finish(sessions, ExitCode.OK);
93
+ });
94
+
95
+ telCmd
96
+ .command("export")
97
+ .description("Export telemetry events as NDJSON")
98
+ .option("--from <date>", "Start date (ISO format)")
99
+ .option("--to <date>", "End date (ISO format)")
100
+ .option("--session <id>", "Session ID filter")
101
+ .option("--ndjson", "Stream one compact event per line instead of the JSON envelope")
102
+ .action((opts) => {
103
+ const events = exportEvents({
104
+ from: opts.from ? new Date(opts.from) : undefined,
105
+ to: opts.to ? new Date(opts.to) : undefined,
106
+ sessionId: opts.session,
107
+ });
108
+ warnSkipped();
109
+ // Default to the envelope like every other command; `--ndjson` keeps the
110
+ // streamable one-object-per-line form for pipes into jq and friends.
111
+ if (opts.ndjson) {
112
+ for (const e of events) console.log(JSON.stringify(e));
113
+ return;
114
+ }
115
+ finish(events, ExitCode.OK);
116
+ });
117
+
118
+ telCmd
119
+ .command("prune")
120
+ .description("Delete old rotated telemetry files")
121
+ .option("-d, --older-than <days>", "Days threshold", "30")
122
+ .action((opts) => {
123
+ const deleted = pruneEvents(parseInt(opts.olderThan));
124
+ finish({ success: true, deleted, message: `Pruned ${deleted} telemetry file(s).` }, ExitCode.OK);
125
+ });
126
+ }