@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.
- package/LICENSE +21 -0
- package/README.md +777 -0
- package/docs/ADAPTER-CONTRACT.md +1260 -0
- package/docs/ARCHITECTURE.md +846 -0
- package/docs/CLI-QUICKREF.md +827 -0
- package/docs/COMPETITIVE-ANALYSIS.md +307 -0
- package/docs/INSTALL.md +403 -0
- package/docs/INTEGRATION-CLAUDE.md +126 -0
- package/docs/INTEGRATION-MCP.md +196 -0
- package/docs/INTEGRATION-OPENCODE.md +136 -0
- package/docs/INTEGRATION-PI.md +195 -0
- package/package.json +77 -0
- package/scripts/build-site.sh +39 -0
- package/scripts/doctor.sh +218 -0
- package/scripts/gen-cli-quickref.ts +232 -0
- package/scripts/install-cli.sh +60 -0
- package/scripts/install.sh +466 -0
- package/scripts/roadmap-lint.ts +200 -0
- package/scripts/uninstall.sh +202 -0
- package/src/cli-node.cjs +51 -0
- package/src/cli.ts +209 -0
- package/src/commands/ast.ts +255 -0
- package/src/commands/diff.ts +98 -0
- package/src/commands/edit.ts +93 -0
- package/src/commands/hash.ts +64 -0
- package/src/commands/intent.ts +68 -0
- package/src/commands/maintenance.ts +191 -0
- package/src/commands/mcp.ts +28 -0
- package/src/commands/provenance.ts +111 -0
- package/src/commands/read.ts +117 -0
- package/src/commands/route.ts +42 -0
- package/src/commands/shared.ts +65 -0
- package/src/commands/telemetry.ts +126 -0
- package/src/commands/verify.ts +61 -0
- package/src/core/ast-edit.ts +2357 -0
- package/src/core/batch-edit.ts +185 -0
- package/src/core/config.ts +189 -0
- package/src/core/diff-engine.ts +474 -0
- package/src/core/doctor.ts +303 -0
- package/src/core/encoding.ts +116 -0
- package/src/core/envelope.ts +163 -0
- package/src/core/exit-codes.ts +198 -0
- package/src/core/format.ts +339 -0
- package/src/core/grep.ts +180 -0
- package/src/core/hash-edit.ts +416 -0
- package/src/core/index.ts +155 -0
- package/src/core/intent.ts +584 -0
- package/src/core/locking.ts +292 -0
- package/src/core/module-system.ts +142 -0
- package/src/core/operations.ts +557 -0
- package/src/core/output.ts +122 -0
- package/src/core/path-normalize.ts +61 -0
- package/src/core/paths.ts +326 -0
- package/src/core/plan-executor.ts +437 -0
- package/src/core/platform.ts +132 -0
- package/src/core/provenance.ts +214 -0
- package/src/core/read.ts +111 -0
- package/src/core/redact.ts +98 -0
- package/src/core/resolve-content.ts +12 -0
- package/src/core/router.ts +463 -0
- package/src/core/snapshot.ts +346 -0
- package/src/core/telemetry.ts +838 -0
- package/src/core/utils.ts +7 -0
- package/src/core/verify-baseline.ts +186 -0
- package/src/core/verify-scope.ts +282 -0
- package/src/core/verify.ts +753 -0
- package/src/mcp/server.ts +325 -0
- package/templates/claude-section.md +12 -0
- package/templates/opencode-agent.md +106 -0
- package/templates/opencode-skill.md +241 -0
- package/templates/pi-extension.ts +288 -0
- package/templates/pi-skill.md +123 -0
- 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
|
+
}
|