@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,185 @@
1
+ import { routeEdit, RouterResult } from "./router";
2
+ import { recordEvent, ErrorCode } from "./telemetry";
3
+ import type { RoutePolicy, EditRoute } from "./config";
4
+ import { acquireSortedLocks, LOCK_TIMEOUT_MS } from "./locking";
5
+
6
+ export interface BatchParams {
7
+ files: string[];
8
+ operation: string;
9
+ method?: EditRoute;
10
+ policy?: RoutePolicy;
11
+ // Hash params
12
+ oldHash?: string;
13
+ newContent?: string;
14
+ range?: { start: number; end: number };
15
+ // AST params
16
+ oldName?: string;
17
+ newName?: string;
18
+ symbolName?: string;
19
+ newBody?: string;
20
+ importSpec?: string;
21
+ content?: string;
22
+ // Diff params
23
+ oldContent?: string;
24
+ dryRun?: boolean;
25
+ /** Dry runs return a diff; set this to get the whole post-edit file back (#98). */
26
+ includeSource?: boolean;
27
+ // Provenance params
28
+ actor?: string;
29
+ taskId?: string;
30
+ reason?: string;
31
+ }
32
+
33
+ export interface BatchSummary {
34
+ total: number;
35
+ succeeded: number;
36
+ failed: number;
37
+ conflicts: number; // CAS/STALE_ANCHOR failures (distinct from other errors)
38
+ elapsed_ms: number;
39
+ }
40
+
41
+ export interface BatchResult {
42
+ results: RouterResult[];
43
+ summary: BatchSummary;
44
+ }
45
+
46
+ async function editOne(
47
+ file: string,
48
+ params: BatchParams,
49
+ /** `editMany` holds every target's lock already; `editManySerial` does not. */
50
+ alreadyLocked = false,
51
+ ): Promise<RouterResult> {
52
+ return routeEdit({
53
+ filePath: file,
54
+ alreadyLocked,
55
+ operation: params.operation,
56
+ method: params.method,
57
+ policy: params.policy,
58
+ oldHash: params.oldHash,
59
+ newContent: params.newContent,
60
+ range: params.range,
61
+ oldName: params.oldName,
62
+ newName: params.newName,
63
+ symbolName: params.symbolName,
64
+ newBody: params.newBody,
65
+ importSpec: params.importSpec,
66
+ content: params.content,
67
+ oldContent: params.oldContent,
68
+ dryRun: params.dryRun,
69
+ includeSource: params.includeSource,
70
+ actor: params.actor,
71
+ taskId: params.taskId,
72
+ reason: params.reason,
73
+ });
74
+ }
75
+ export type BatchEditOptions = { timeoutMs?: number };
76
+
77
+ export async function editMany(params: BatchParams, opts?: BatchEditOptions): Promise<BatchResult> {
78
+ const start = Date.now();
79
+ const uniqueFiles = [...new Set(params.files)];
80
+
81
+ // Acquire advisory locks in sorted path order (deterministic lock ordering)
82
+ // to prevent deadlock when two plans touch overlapping file sets ({A,B} vs {B,A}).
83
+ let releaseLocks: (() => void) | undefined;
84
+
85
+ try {
86
+ releaseLocks = await acquireSortedLocks(uniqueFiles, {
87
+ timeoutMs: opts?.timeoutMs ?? LOCK_TIMEOUT_MS,
88
+ });
89
+ } catch (err: any) {
90
+ // Lock acquisition failed — report per-file LOCK_TIMEOUT instead of aborting.
91
+ const lockFailed: RouterResult[] = uniqueFiles.map((f) => ({
92
+ route: null as any,
93
+ routeReason: "lock timeout",
94
+ result: {
95
+ success: false,
96
+ // A lock timeout is retryable, exactly like a stale anchor. Tag it the
97
+ // same way so callers that branch on `stale` retry instead of giving up.
98
+ stale: true,
99
+ errorCode: ErrorCode.LOCK_TIMEOUT,
100
+ message: `Cannot acquire lock for ${f}: ${err.message}`,
101
+ recovery: "Retry; the file may be locked by another HashPilot process.",
102
+ },
103
+ elapsed_ms: Date.now() - start,
104
+ }));
105
+
106
+ recordEvent({
107
+ operation: `batch-${params.operation}`,
108
+ route: "batch",
109
+ files_count: uniqueFiles.length,
110
+ success: false,
111
+ elapsed_ms: Date.now() - start,
112
+ });
113
+
114
+ return {
115
+ results: lockFailed,
116
+ // A lock timeout is a conflict, not a hard failure — the same classification
117
+ // the per-file path below gives a router-reported LOCK_TIMEOUT. Counting it
118
+ // as `failed` here made the same condition land in two different buckets
119
+ // depending on whether the lock was taken up front or mid-batch.
120
+ summary: { total: uniqueFiles.length, succeeded: 0, failed: 0, conflicts: uniqueFiles.length, elapsed_ms: Date.now() - start },
121
+ };
122
+ }
123
+
124
+ try {
125
+ const results = await Promise.all(
126
+ uniqueFiles.map((f) => editOne(f, params, true))
127
+ );
128
+
129
+ // Distinguish CAS/STALE_ANCHOR conflicts from other per-file failures.
130
+ const succeeded = results.filter((r) => r.result.success).length;
131
+ const conflicts = results.filter(
132
+ (r) => !r.result.success && (
133
+ r.result.errorCode === ErrorCode.STALE_ANCHOR ||
134
+ r.result.stale === true
135
+ ),
136
+ ).length;
137
+ const failed = results.length - succeeded - conflicts;
138
+
139
+ recordEvent({
140
+ operation: `batch-${params.operation}`,
141
+ route: "batch",
142
+ files_count: uniqueFiles.length,
143
+ success: failed === 0 && conflicts === 0,
144
+ elapsed_ms: Date.now() - start,
145
+ });
146
+
147
+ return {
148
+ results,
149
+ summary: { total: uniqueFiles.length, succeeded, failed, conflicts, elapsed_ms: Date.now() - start },
150
+ };
151
+ } finally {
152
+ releaseLocks();
153
+ }
154
+ }
155
+ export async function editManySerial(params: BatchParams): Promise<BatchResult> {
156
+ const start = Date.now();
157
+
158
+ const results: RouterResult[] = [];
159
+ for (const f of params.files) {
160
+ results.push(await editOne(f, params));
161
+ }
162
+
163
+ const elapsed = Date.now() - start;
164
+ const succeeded = results.filter((r) => r.result.success).length;
165
+ const conflicts = results.filter(
166
+ (r) => !r.result.success && (
167
+ r.result.errorCode === ErrorCode.STALE_ANCHOR ||
168
+ r.result.stale === true
169
+ ),
170
+ ).length;
171
+ const failed = results.length - succeeded - conflicts;
172
+
173
+ recordEvent({
174
+ operation: `batch-${params.operation}-serial`,
175
+ route: "batch",
176
+ files_count: params.files.length,
177
+ success: failed === 0 && conflicts === 0,
178
+ elapsed_ms: elapsed,
179
+ });
180
+
181
+ return {
182
+ results,
183
+ summary: { total: params.files.length, succeeded, failed, conflicts, elapsed_ms: elapsed },
184
+ };
185
+ }
@@ -0,0 +1,189 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import type { EditRoute } from "./router";
4
+
5
+ export interface RoutePolicy {
6
+ /**
7
+ * Force a specific route for given languages. E.g. { "python": "hash" }.
8
+ * `null` means "unset": it removes an override inherited from a
9
+ * lower-priority config, which a merge alone cannot express (#51).
10
+ */
11
+ languageOverrides?: Record<string, EditRoute | null>;
12
+ /**
13
+ * Force a specific route for given operations. E.g. { "add-import": "diff" }.
14
+ * `null` unsets an inherited override — see `languageOverrides`.
15
+ */
16
+ operationOverrides?: Record<string, EditRoute | null>;
17
+ /** When multiple overrides match, which wins: "language" | "operation" | "strictest" */
18
+ conflictResolution?: "language" | "operation" | "strictest";
19
+ }
20
+
21
+ export interface TelemetryConfig {
22
+ enabled?: boolean;
23
+ maxFileSize?: number;
24
+ maxRotatedFiles?: number;
25
+ retentionDays?: number;
26
+ /**
27
+ * Cap on one serialized JSONL record, in bytes. Oversized payloads (a
28
+ * captured diff) are stored out-of-line by hash and referenced from the
29
+ * record, so the log stays a log rather than an object store (#20).
30
+ */
31
+ maxRecordBytes?: number;
32
+ }
33
+
34
+ export interface ProvenanceConfig {
35
+ /** Default actor identity when not provided at invocation */
36
+ defaultActor?: string;
37
+ /** Max length of stored context field (prevents log bloat), default 500 */
38
+ maxContextLength?: number;
39
+ /**
40
+ * Record a unified diff of every edit in the telemetry log. Off by default:
41
+ * a diff puts real source lines on disk in plaintext, which is a leak for
42
+ * private repos and anything holding credentials. Turn on deliberately.
43
+ */
44
+ captureDiffs?: boolean;
45
+ }
46
+
47
+ export interface SnapshotConfig {
48
+ /** Take a pre-edit snapshot of every written file so `undo` can restore it. Default true. */
49
+ enabled?: boolean;
50
+ /** Keep at most this many changeSets, default 200. */
51
+ maxChangeSets?: number;
52
+ /** Drop changeSets older than this many days, default 7. */
53
+ maxAgeDays?: number;
54
+ }
55
+
56
+ export interface HashPilotConfig {
57
+ routePolicy?: RoutePolicy;
58
+ telemetry?: TelemetryConfig;
59
+ provenance?: ProvenanceConfig;
60
+ snapshots?: SnapshotConfig;
61
+ /** Extra directories writes may target, beyond the project root. Relative entries resolve against cwd. */
62
+ allowedRoots?: string[];
63
+ }
64
+
65
+ const DEFAULT_CONFIG: HashPilotConfig = Object.freeze({
66
+ telemetry: Object.freeze({ enabled: true, maxFileSize: 10 * 1024 * 1024, maxRotatedFiles: 10, retentionDays: 30, maxRecordBytes: 4096 }),
67
+ }) as HashPilotConfig;
68
+
69
+ /**
70
+ * Every `loadConfig` call must hand back a config that shares no object with
71
+ * the defaults or with a previously returned config. A shallow spread left the
72
+ * nested `telemetry` object aliased, so one caller mutating its own config
73
+ * silently rewrote the defaults for the rest of the process — invisible in the
74
+ * CLI (one process, one call) but a cross-request leak in the MCP server and
75
+ * any library embedding (#51).
76
+ */
77
+ function cloneDefaults(): HashPilotConfig {
78
+ return structuredClone({ ...DEFAULT_CONFIG }) as HashPilotConfig;
79
+ }
80
+
81
+ const ROUTE_PRECEDENCE: EditRoute[] = ["diff", "hash", "ast"];
82
+
83
+ function resolveConflict(
84
+ fromLang: EditRoute | undefined,
85
+ fromOp: EditRoute | undefined,
86
+ method: "language" | "operation" | "strictest" = "operation"
87
+ ): EditRoute | undefined {
88
+ if (!fromLang && !fromOp) return undefined;
89
+ if (!fromOp) return fromLang;
90
+ if (!fromLang) return fromOp;
91
+ if (method === "language") return fromLang;
92
+ if (method === "operation") return fromOp;
93
+ // strictest: lowest precedence wins (diff < hash < ast)
94
+ return ROUTE_PRECEDENCE.indexOf(fromLang) <= ROUTE_PRECEDENCE.indexOf(fromOp)
95
+ ? fromLang
96
+ : fromOp;
97
+ }
98
+
99
+ export function policyForce(
100
+ policy: RoutePolicy | undefined,
101
+ language: string | null,
102
+ operation: string
103
+ ): EditRoute | undefined {
104
+ if (!policy) return undefined;
105
+ const fromLang = (language ? policy.languageOverrides?.[language] : undefined) ?? undefined;
106
+ const fromOp = policy.operationOverrides?.[operation] ?? undefined;
107
+ if (!fromLang && !fromOp) return undefined;
108
+ return resolveConflict(fromLang, fromOp, policy.conflictResolution);
109
+ }
110
+
111
+ export function loadConfig(configPath?: string): HashPilotConfig {
112
+ const paths: string[] = [];
113
+
114
+ // Global config
115
+ const globalDir = join(process.env.HOME || "/root", ".config", "hashpilot");
116
+ const globalPath = join(globalDir, "config.json");
117
+ if (existsSync(globalPath)) paths.push(globalPath);
118
+
119
+ // Project config (cwd)
120
+ const projectPath = join(process.cwd(), ".hashpilot.json");
121
+ if (existsSync(projectPath) && projectPath !== globalPath) paths.push(projectPath);
122
+
123
+ // CLI override
124
+ if (configPath && existsSync(configPath) && !paths.includes(configPath)) {
125
+ paths.push(configPath);
126
+ }
127
+
128
+ const config: HashPilotConfig = cloneDefaults();
129
+
130
+ for (const p of paths) {
131
+ try {
132
+ const data = JSON.parse(readFileSync(p, "utf-8"));
133
+ mergeConfig(config, data);
134
+ } catch {}
135
+ }
136
+
137
+ // Environment variable override
138
+ const envPolicy = process.env.HASHPILOT_ROUTE_POLICY;
139
+ if (envPolicy) {
140
+ try {
141
+ const parsed = JSON.parse(envPolicy);
142
+ mergeConfig(config, { routePolicy: parsed });
143
+ } catch {}
144
+ }
145
+
146
+ return config;
147
+ }
148
+
149
+ /**
150
+ * Merge one override map, treating an explicit `null` as "unset". Without it a
151
+ * higher-priority config can change or add an override but never remove one, so
152
+ * a global "always diff for Python" policy could not be opted out of per
153
+ * project (#51).
154
+ */
155
+ function mergeOverrides(
156
+ base: Record<string, EditRoute | null> | undefined,
157
+ override: Record<string, EditRoute | null> | undefined
158
+ ): Record<string, EditRoute | null> {
159
+ const merged: Record<string, EditRoute | null> = { ...base };
160
+ for (const [key, value] of Object.entries(override ?? {})) {
161
+ if (value === null) delete merged[key];
162
+ else merged[key] = value;
163
+ }
164
+ return merged;
165
+ }
166
+
167
+ function mergeConfig(base: HashPilotConfig, override: Partial<HashPilotConfig>): void {
168
+ if (override.telemetry) {
169
+ base.telemetry = { ...base.telemetry, ...override.telemetry };
170
+ }
171
+ if (override.routePolicy) {
172
+ const basePolicy = base.routePolicy || {};
173
+ base.routePolicy = {
174
+ ...basePolicy,
175
+ conflictResolution: override.routePolicy.conflictResolution ?? basePolicy.conflictResolution,
176
+ languageOverrides: mergeOverrides(basePolicy.languageOverrides, override.routePolicy.languageOverrides),
177
+ operationOverrides: mergeOverrides(basePolicy.operationOverrides, override.routePolicy.operationOverrides),
178
+ };
179
+ }
180
+ if (override.provenance) {
181
+ base.provenance = { ...base.provenance, ...override.provenance };
182
+ }
183
+ if (override.snapshots) {
184
+ base.snapshots = { ...base.snapshots, ...override.snapshots };
185
+ }
186
+ if (override.allowedRoots) {
187
+ base.allowedRoots = [...(base.allowedRoots || []), ...override.allowedRoots];
188
+ }
189
+ }