@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,292 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, statSync, readdirSync } from "fs";
2
+ import { createHash, randomBytes } from "crypto";
3
+ import { join, dirname, resolve as pathResolve } from "path";
4
+ import { findProjectRoot } from "./paths";
5
+
6
+ /** Lock directory, relative to the *target file's* project root — never to cwd. */
7
+ const LOCK_DIR_NAME = join(".hashpilot", "locks");
8
+
9
+ /** Maximum wait before abandoning a lock acquisition. */
10
+ export const LOCK_TIMEOUT_MS = 10_000;
11
+
12
+ /** How often to retry when waiting for a lock (ms). */
13
+ const LOCK_RETRY_MS = 50;
14
+
15
+ /** How often a held lock refreshes its `ts` so others can see it is alive. */
16
+ const HEARTBEAT_MS = 5_000;
17
+
18
+ /** If the holder hasn't refreshed `ts` in this many ms, treat the lock as stale. */
19
+ const STALE_THRESHOLD_MS = 30_000;
20
+
21
+ /** On-disk lock payload. */
22
+ interface LockPayload {
23
+ pid: number;
24
+ /** Per-acquisition token. Release only unlinks a file still carrying our nonce. */
25
+ nonce: string;
26
+ ts: number;
27
+ targets: string[];
28
+ }
29
+
30
+ /** Errors thrown by this module. */
31
+ export class LockAcquireError extends Error {
32
+ public readonly reason: "timeout" | "stale";
33
+
34
+ constructor(message: string, reason: "timeout" | "stale") {
35
+ super(message);
36
+ this.name = "LockAcquireError";
37
+ this.reason = reason;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Derive the lock-file path for a target file.
43
+ *
44
+ * The key is a SHA-256 of the *absolute* target path, and the directory is
45
+ * anchored to that target's project root. Both halves must be cwd-independent:
46
+ * a cwd-relative lock directory combined with an absolute key means two
47
+ * processes editing the same file from different working directories write to
48
+ * different lock files and never exclude each other.
49
+ */
50
+ export function lockPathFor(targetFile: string): string {
51
+ const resolved = pathResolve(targetFile);
52
+ const root = findProjectRoot(dirname(resolved));
53
+ const key = createHash("sha256").update(resolved).digest("hex").slice(0, 32);
54
+ return join(root, LOCK_DIR_NAME, `${key}.lock`);
55
+ }
56
+
57
+ /** Ensure the lock directory exists with safe permissions. */
58
+ function ensureLockDir(lockPath: string): void {
59
+ mkdirSync(dirname(lockPath), { recursive: true, mode: 0o755 });
60
+ }
61
+
62
+ /** Check whether a PID is alive (best-effort). */
63
+ function isPidAlive(pid: number): boolean {
64
+ try {
65
+ // send signal 0 — checks process existence without delivering anything.
66
+ process.kill(pid, 0);
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /** Read a lockfile back into structured data. `null` means absent or unreadable. */
74
+ function readLockFile(lockPath: string): LockPayload | null {
75
+ try {
76
+ return JSON.parse(readFileSync(lockPath, "utf8")) as LockPayload;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Age of a lock, in ms. Prefers the payload's heartbeat `ts`; falls back to the
84
+ * file's mtime when the payload is unreadable (a torn write, or a lockfile from
85
+ * an older version). Returns `null` if the file is gone.
86
+ */
87
+ function lockAgeMs(lockPath: string, payload: LockPayload | null): number | null {
88
+ if (payload && typeof payload.ts === "number") return Date.now() - payload.ts;
89
+ try {
90
+ return Date.now() - statSync(lockPath).mtimeMs;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Atomically create the lockfile. Returns `true` only if *we* created it.
98
+ *
99
+ * `wx` is O_CREAT|O_EXCL: the existence check and the create are one syscall.
100
+ * An `existsSync` guard followed by a plain write is check-then-act — two
101
+ * processes can both observe no lockfile and both write, which is precisely the
102
+ * race this lock exists to prevent.
103
+ */
104
+ function tryCreateLock(lockPath: string, payload: LockPayload): boolean {
105
+ ensureLockDir(lockPath);
106
+ try {
107
+ writeFileSync(lockPath, JSON.stringify(payload), { flag: "wx" });
108
+ return true;
109
+ } catch {
110
+ return false; // EEXIST (held) or an I/O error — either way we did not acquire.
111
+ }
112
+ }
113
+
114
+ /** Refresh the heartbeat, but only while the lockfile is still ours. */
115
+ function heartbeat(lockPath: string, payload: LockPayload): void {
116
+ const current = readLockFile(lockPath);
117
+ if (!current || current.nonce !== payload.nonce) return; // no longer ours
118
+ payload.ts = Date.now();
119
+ try {
120
+ writeFileSync(lockPath, JSON.stringify(payload));
121
+ } catch {
122
+ /* transient I/O — the next tick retries */
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Release a lock we own.
128
+ *
129
+ * Unlinks only if the lockfile still carries our nonce. Unlinking by path alone
130
+ * is unsafe: if our lock was reclaimed as stale and another process acquired it,
131
+ * a blind unlink would delete *their* lockfile and hand a third writer the same
132
+ * file — two writers, silently.
133
+ */
134
+ function releaseOwnedLock(lockPath: string, payload: LockPayload, timer: ReturnType<typeof setInterval>): void {
135
+ clearInterval(timer);
136
+ const current = readLockFile(lockPath);
137
+ if (!current || current.nonce !== payload.nonce) return; // reclaimed by someone else
138
+ try {
139
+ unlinkSync(lockPath);
140
+ } catch {
141
+ /* already gone */
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Acquire an advisory lock for the given target file.
147
+ *
148
+ * Returns a release callback. Callers MUST invoke it even on error paths
149
+ * (e.g. in a `finally`). Extra calls are no-ops.
150
+ *
151
+ * Locks are **not** re-entrant: acquiring the same file twice in one process
152
+ * blocks until the timeout. Callers that already hold a lock must say so rather
153
+ * than nesting an acquire.
154
+ */
155
+ export async function acquireLock(
156
+ targetPath: string,
157
+ opts?: { timeoutMs?: number },
158
+ ): Promise<() => void> {
159
+ const maxWait = opts?.timeoutMs ?? LOCK_TIMEOUT_MS;
160
+ const lockFile = lockPathFor(targetPath);
161
+ const deadline = Date.now() + maxWait;
162
+
163
+ while (Date.now() < deadline) {
164
+ const payload: LockPayload = {
165
+ pid: process.pid,
166
+ nonce: randomBytes(12).toString("hex"),
167
+ ts: Date.now(),
168
+ targets: [targetPath],
169
+ };
170
+
171
+ if (tryCreateLock(lockFile, payload)) {
172
+ const timer = setInterval(() => heartbeat(lockFile, payload), HEARTBEAT_MS);
173
+ // Never hold the event loop open just to heartbeat.
174
+ (timer as unknown as { unref?: () => void }).unref?.();
175
+ return once(() => releaseOwnedLock(lockFile, payload, timer));
176
+ }
177
+
178
+ // Creation failed — someone holds it (or it is a leftover). Check staleness.
179
+ const existing = readLockFile(lockFile);
180
+ const age = lockAgeMs(lockFile, existing);
181
+ if (age === null) {
182
+ // Vanished between create and read — retry immediately-ish.
183
+ await sleep(LOCK_RETRY_MS);
184
+ continue;
185
+ }
186
+ // Reclaim only when the holder stopped heartbeating AND its PID is gone.
187
+ // An unreadable payload has no PID to check, so age alone decides.
188
+ const holderGone = !existing || !isPidAlive(existing.pid);
189
+ if (age > STALE_THRESHOLD_MS && holderGone) {
190
+ try {
191
+ unlinkSync(lockFile);
192
+ } catch {
193
+ /* someone else raced us to the reclaim */
194
+ }
195
+ }
196
+
197
+ // Always yield. A `continue` without sleeping busy-spins a core for the
198
+ // whole timeout, starving the very edit we are waiting on.
199
+ await sleep(LOCK_RETRY_MS);
200
+ }
201
+
202
+ const holder = readLockFile(lockFile)?.pid ?? "?";
203
+ throw new LockAcquireError(
204
+ `Lock on ${targetPath} timed out after ${maxWait}ms (held by PID ${holder})`,
205
+ "timeout",
206
+ );
207
+ }
208
+
209
+ /** Wrap a release so extra calls are no-ops. */
210
+ function once(fn: () => void): () => void {
211
+ let done = false;
212
+ return () => {
213
+ if (done) return;
214
+ done = true;
215
+ fn();
216
+ };
217
+ }
218
+
219
+ /** Sleep for `ms` milliseconds (async). */
220
+ function sleep(ms: number): Promise<void> {
221
+ return new Promise((resolve) => setTimeout(resolve, ms));
222
+ }
223
+
224
+ /**
225
+ * Acquire locks for multiple files in deterministic order to prevent deadlock.
226
+ *
227
+ * Deduplication is by *lock path*, not by input path: two different inputs that
228
+ * map to the same lockfile would otherwise make this function block against
229
+ * itself, since locks are not re-entrant.
230
+ */
231
+ export async function acquireSortedLocks(
232
+ paths: string[],
233
+ opts?: { timeoutMs?: number },
234
+ ): Promise<() => void> {
235
+ const byLockPath = new Map<string, string>();
236
+ for (const p of paths) {
237
+ const lp = lockPathFor(p);
238
+ if (!byLockPath.has(lp)) byLockPath.set(lp, p);
239
+ }
240
+ const sorted = [...byLockPath.keys()].sort((a, b) => a.localeCompare(b));
241
+ const releases: (() => void)[] = [];
242
+
243
+ for (const lp of sorted) {
244
+ try {
245
+ releases.push(await acquireLock(byLockPath.get(lp)!, opts));
246
+ } catch (err) {
247
+ // Release everything we already acquired on failure.
248
+ for (const rel of releases) {
249
+ try { rel(); } catch { /* ignore */ }
250
+ }
251
+ throw err;
252
+ }
253
+ }
254
+
255
+ return once(() => {
256
+ // Release in reverse order (LIFO).
257
+ for (let i = releases.length - 1; i >= 0; i--) {
258
+ try { releases[i](); } catch { /* ignore */ }
259
+ }
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Remove reclaimable lockfiles left behind by crashed processes.
265
+ * Returns the number of lockfiles removed. Safe to call at startup.
266
+ */
267
+ export function pruneStaleLocks(root: string = findProjectRoot()): number {
268
+ const dir = join(root, LOCK_DIR_NAME);
269
+ if (!existsSync(dir)) return 0;
270
+ let removed = 0;
271
+ let entries: string[];
272
+ try {
273
+ entries = readdirSync(dir);
274
+ } catch {
275
+ return 0;
276
+ }
277
+ for (const name of entries) {
278
+ if (!name.endsWith(".lock")) continue;
279
+ const lockPath = join(dir, name);
280
+ const payload = readLockFile(lockPath);
281
+ const age = lockAgeMs(lockPath, payload);
282
+ if (age === null) continue;
283
+ const holderGone = !payload || !isPidAlive(payload.pid);
284
+ if (age > STALE_THRESHOLD_MS && holderGone) {
285
+ try {
286
+ unlinkSync(lockPath);
287
+ removed++;
288
+ } catch { /* ignore */ }
289
+ }
290
+ }
291
+ return removed;
292
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * JavaScript module-system detection (#139).
3
+ *
4
+ * `add-import` used to emit ESM `import` syntax into every JavaScript file,
5
+ * report `success: true`, and leave a CommonJS file that Node refuses to load.
6
+ * The parse-validity gate cannot catch it: tree-sitter's JavaScript grammar
7
+ * accepts `import` and `require` in the same file, so the result parses. Node
8
+ * does not.
9
+ *
10
+ * This module answers "which module system does this file use?" from the file
11
+ * path plus its content, so the caller can emit the right syntax or refuse.
12
+ * It is deliberately free of tree-sitter: the signals are the extension, the
13
+ * nearest `package.json`, and a content sniff, none of which need a parse.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { dirname, join, parse as parsePath, resolve } from "node:path";
17
+
18
+ export type ModuleSystem = "esm" | "cjs";
19
+
20
+ /** How the verdict was reached — carried into error messages so a refusal explains itself. */
21
+ export type ModuleSystemSignal = "extension" | "package.json" | "content" | "default";
22
+
23
+ export interface ModuleSystemVerdict {
24
+ /** `null` when the signals contradict each other and no default is safe. */
25
+ system: ModuleSystem | null;
26
+ signal: ModuleSystemSignal;
27
+ /** Human-readable reason, suitable for an error message. */
28
+ detail: string;
29
+ }
30
+
31
+ /**
32
+ * Walk up from `startDir` looking for the nearest `package.json`, and report its
33
+ * `type` field. Returns `null` when no `package.json` exists anywhere above the
34
+ * file — a bare script outside any package, where the field cannot speak.
35
+ *
36
+ * A `package.json` that exists but declares no `type` is not silence: the field
37
+ * defaults to `"commonjs"` per Node's own resolution rules, so that is a real
38
+ * CJS signal.
39
+ *
40
+ * `startDir` is absolutized against `process.cwd()` before the walk begins
41
+ * (#161): `path.parse()` reports `root: ""` for a relative path, which would
42
+ * otherwise never equal `dir` and let the walk run past the real filesystem
43
+ * root — silently stopping at `cwd` instead of reaching a monorepo root
44
+ * `package.json` that sits above it.
45
+ */
46
+ export function nearestPackageType(startDir: string): { system: ModuleSystem; path: string } | null {
47
+ let dir = resolve(startDir);
48
+ const { root } = parsePath(dir);
49
+ // Bounded by the filesystem root; `dirname("/") === "/"` terminates the walk.
50
+ for (;;) {
51
+ const candidate = join(dir, "package.json");
52
+ let raw: string;
53
+ try {
54
+ raw = readFileSync(candidate, "utf8");
55
+ } catch {
56
+ if (dir === root) return null;
57
+ const parent = dirname(dir);
58
+ if (parent === dir) return null;
59
+ dir = parent;
60
+ continue;
61
+ }
62
+ let type: unknown;
63
+ try {
64
+ type = (JSON.parse(raw) as { type?: unknown }).type;
65
+ } catch {
66
+ // A malformed package.json is not a signal. Keep walking rather than
67
+ // guessing from a file we could not read.
68
+ if (dir === root) return null;
69
+ const parent = dirname(dir);
70
+ if (parent === dir) return null;
71
+ dir = parent;
72
+ continue;
73
+ }
74
+ return { system: type === "module" ? "esm" : "cjs", path: candidate };
75
+ }
76
+ }
77
+
78
+ /** True when the source contains a CommonJS marker outside of an obvious comment. */
79
+ function hasCjsMarkers(source: string): boolean {
80
+ return /(^|[^.\w$])require\s*\(/.test(source) || /\bmodule\.exports\b/.test(source) || /\bexports\.\w/.test(source);
81
+ }
82
+
83
+ /** True when the source contains an ESM marker at the start of some line. */
84
+ function hasEsmMarkers(source: string): boolean {
85
+ return /^\s*import\s+[^(]/m.test(source) || /^\s*import\s*[{*]/m.test(source) || /^\s*export\s/m.test(source);
86
+ }
87
+
88
+ /**
89
+ * Decide a JavaScript file's module system. Signals, cheapest first — first
90
+ * match wins:
91
+ *
92
+ * 1. Extension: `.cjs` is always CommonJS, `.mjs` always ESM. Node ignores
93
+ * `package.json` for both, so nothing below can overrule them.
94
+ * 2. Nearest `package.json` `type` field (absent field ⇒ CommonJS).
95
+ * 3. Content: `require(` / `module.exports` / `exports.x` ⇒ CJS, a top-level
96
+ * `import`/`export` ⇒ ESM. **Both** ⇒ no verdict; the caller must refuse
97
+ * rather than pick.
98
+ *
99
+ * With no signal at all — a bare script outside any package, holding neither
100
+ * marker — the verdict is ESM. That is the historical behavior and the modern
101
+ * default; it is reported as `signal: "default"` so a caller can tell a guess
102
+ * from a finding.
103
+ *
104
+ * Only meaningful for JavaScript. TypeScript compiles to whichever system its
105
+ * own config selects, so callers must not consult this for `.ts`/`.tsx`.
106
+ */
107
+ export function detectModuleSystem(filePath: string, source: string): ModuleSystemVerdict {
108
+ if (filePath.endsWith(".cjs")) {
109
+ return { system: "cjs", signal: "extension", detail: "the .cjs extension is always CommonJS" };
110
+ }
111
+ if (filePath.endsWith(".mjs")) {
112
+ return { system: "esm", signal: "extension", detail: "the .mjs extension is always ESM" };
113
+ }
114
+
115
+ const pkg = nearestPackageType(dirname(filePath));
116
+ if (pkg) {
117
+ return {
118
+ system: pkg.system,
119
+ signal: "package.json",
120
+ detail:
121
+ pkg.system === "esm"
122
+ ? `${pkg.path} declares "type": "module"`
123
+ : `${pkg.path} does not declare "type": "module", so Node treats this file as CommonJS`,
124
+ };
125
+ }
126
+
127
+ const cjs = hasCjsMarkers(source);
128
+ const esm = hasEsmMarkers(source);
129
+ if (cjs && esm) {
130
+ return {
131
+ system: null,
132
+ signal: "content",
133
+ detail:
134
+ "the file mixes CommonJS (require/module.exports) and ESM (import/export) syntax, " +
135
+ "and no extension or package.json settles which one Node will use",
136
+ };
137
+ }
138
+ if (cjs) return { system: "cjs", signal: "content", detail: "the file already uses require/module.exports" };
139
+ if (esm) return { system: "esm", signal: "content", detail: "the file already uses import/export" };
140
+
141
+ return { system: "esm", signal: "default", detail: "no extension, package.json, or in-file signal; defaulting to ESM" };
142
+ }