@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,346 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync,
|
|
3
|
+
readdirSync, statSync, unlinkSync, renameSync,
|
|
4
|
+
} from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pre-edit snapshots, so an edit can be undone.
|
|
10
|
+
*
|
|
11
|
+
* Every write that goes through `safeWrite` first stores the file's original
|
|
12
|
+
* bytes in a content-addressed object store keyed by changeSet ID. Without
|
|
13
|
+
* this an agent that made a wrong-but-successful edit had nothing to roll back
|
|
14
|
+
* to — and neither did the human watching (#12).
|
|
15
|
+
*
|
|
16
|
+
* The store lives beside the telemetry log rather than in the project tree:
|
|
17
|
+
* a snapshot directory inside the repo would show up in `git status` and in
|
|
18
|
+
* the agent's own file listings, and the tool must work in non-git trees.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Resolved per call rather than at import: `HOME` is what isolates a test run
|
|
22
|
+
// (and a sandboxed agent) from the real store, and it is not set yet when this
|
|
23
|
+
// module is first imported.
|
|
24
|
+
function root(): string {
|
|
25
|
+
return join(process.env.HOME || "/root", ".agentic-tools", "snapshots");
|
|
26
|
+
}
|
|
27
|
+
function objectsDir(): string {
|
|
28
|
+
return join(root(), "objects");
|
|
29
|
+
}
|
|
30
|
+
function indexFile(): string {
|
|
31
|
+
return join(root(), "index.jsonl");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One file's pre-image within a changeSet. */
|
|
35
|
+
export interface SnapshotRecord {
|
|
36
|
+
changeSetId: string;
|
|
37
|
+
/** ISO-8601, when the snapshot was taken. */
|
|
38
|
+
timestamp: string;
|
|
39
|
+
/** Absolute, symlink-resolved path — the same path that was written. */
|
|
40
|
+
file: string;
|
|
41
|
+
/** SHA-256 of the original bytes, or null when the file did not exist yet. */
|
|
42
|
+
beforeHash: string | null;
|
|
43
|
+
/** SHA-256 of the bytes we wrote, so `undo` can detect later external edits. */
|
|
44
|
+
afterHash: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ChangeSetSummary {
|
|
48
|
+
changeSetId: string;
|
|
49
|
+
timestamp: string;
|
|
50
|
+
files: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SnapshotRetention {
|
|
54
|
+
/** Keep at most this many changeSets. */
|
|
55
|
+
maxChangeSets: number;
|
|
56
|
+
/** Drop changeSets older than this. */
|
|
57
|
+
maxAgeDays: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_RETENTION: SnapshotRetention = { maxChangeSets: 200, maxAgeDays: 7 };
|
|
61
|
+
|
|
62
|
+
let retention: SnapshotRetention = { ...DEFAULT_RETENTION };
|
|
63
|
+
let enabled = true;
|
|
64
|
+
/** ChangeSet the current command's writes belong to. Set by the CLI bootstrap. */
|
|
65
|
+
let currentChangeSet: string | null = null;
|
|
66
|
+
|
|
67
|
+
export function configureSnapshots(options: Partial<SnapshotRetention> & { enabled?: boolean } = {}): void {
|
|
68
|
+
if (options.enabled !== undefined) enabled = options.enabled;
|
|
69
|
+
if (options.maxChangeSets !== undefined) retention.maxChangeSets = options.maxChangeSets;
|
|
70
|
+
if (options.maxAgeDays !== undefined) retention.maxAgeDays = options.maxAgeDays;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reset to built-in defaults. For tests. */
|
|
74
|
+
export function resetSnapshots(): void {
|
|
75
|
+
retention = { ...DEFAULT_RETENTION };
|
|
76
|
+
enabled = true;
|
|
77
|
+
currentChangeSet = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function setCurrentChangeSet(id: string | null): void {
|
|
81
|
+
currentChangeSet = id;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getCurrentChangeSet(): string | null {
|
|
85
|
+
return currentChangeSet;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function sha256(content: string | Buffer): string {
|
|
89
|
+
return createHash("sha256").update(content).digest("hex");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ensureDirs(): void {
|
|
93
|
+
if (!existsSync(objectsDir())) mkdirSync(objectsDir(), { recursive: true, mode: 0o700 });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function snapshotRoot(): string {
|
|
97
|
+
return root();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Record `file`'s current bytes before it is overwritten with `newContent`.
|
|
102
|
+
*
|
|
103
|
+
* A no-op when snapshots are off or no changeSet is active — a library caller
|
|
104
|
+
* that never opts in pays nothing. Failures here never propagate: losing a
|
|
105
|
+
* snapshot must not turn a good edit into a failed one.
|
|
106
|
+
*/
|
|
107
|
+
export function recordSnapshot(file: string, newContent: string): void {
|
|
108
|
+
if (!enabled || !currentChangeSet) return;
|
|
109
|
+
try {
|
|
110
|
+
ensureDirs();
|
|
111
|
+
let beforeHash: string | null = null;
|
|
112
|
+
if (existsSync(file)) {
|
|
113
|
+
const original = readFileSync(file);
|
|
114
|
+
beforeHash = sha256(original);
|
|
115
|
+
const objectPath = join(objectsDir(), beforeHash);
|
|
116
|
+
// Content-addressed: identical bytes are stored once, so re-editing the
|
|
117
|
+
// same file across many changeSets does not multiply storage.
|
|
118
|
+
if (!existsSync(objectPath)) writeFileSync(objectPath, original, { mode: 0o600 });
|
|
119
|
+
}
|
|
120
|
+
const record: SnapshotRecord = {
|
|
121
|
+
changeSetId: currentChangeSet,
|
|
122
|
+
timestamp: new Date().toISOString(),
|
|
123
|
+
file,
|
|
124
|
+
beforeHash,
|
|
125
|
+
afterHash: sha256(newContent),
|
|
126
|
+
};
|
|
127
|
+
appendFileSync(indexFile(), JSON.stringify(record) + "\n", { mode: 0o600 });
|
|
128
|
+
} catch {
|
|
129
|
+
// Snapshotting is best-effort by design; see the doc comment.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function readIndex(): SnapshotRecord[] {
|
|
134
|
+
if (!existsSync(indexFile())) return [];
|
|
135
|
+
return readFileSync(indexFile(), "utf8")
|
|
136
|
+
.split("\n")
|
|
137
|
+
.filter((l) => l.trim().length > 0)
|
|
138
|
+
.map((l) => {
|
|
139
|
+
try { return JSON.parse(l) as SnapshotRecord; } catch { return null; }
|
|
140
|
+
})
|
|
141
|
+
.filter((r): r is SnapshotRecord => r !== null);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Newest changeSet first. */
|
|
145
|
+
export function listChangeSets(limit = 20): ChangeSetSummary[] {
|
|
146
|
+
const byId = new Map<string, ChangeSetSummary>();
|
|
147
|
+
// Append order breaks timestamp ties: two writes inside the same millisecond
|
|
148
|
+
// carry identical ISO timestamps, and "newest first" must still be the order
|
|
149
|
+
// they actually happened in.
|
|
150
|
+
const order = new Map<string, number>();
|
|
151
|
+
for (const r of readIndex()) {
|
|
152
|
+
if (!order.has(r.changeSetId)) order.set(r.changeSetId, order.size);
|
|
153
|
+
const existing = byId.get(r.changeSetId);
|
|
154
|
+
if (existing) {
|
|
155
|
+
if (!existing.files.includes(r.file)) existing.files.push(r.file);
|
|
156
|
+
// Keep the latest write in the set as the set's timestamp.
|
|
157
|
+
if (r.timestamp > existing.timestamp) existing.timestamp = r.timestamp;
|
|
158
|
+
} else {
|
|
159
|
+
byId.set(r.changeSetId, { changeSetId: r.changeSetId, timestamp: r.timestamp, files: [r.file] });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return [...byId.values()]
|
|
163
|
+
.sort((a, b) =>
|
|
164
|
+
b.timestamp.localeCompare(a.timestamp) ||
|
|
165
|
+
(order.get(b.changeSetId)! - order.get(a.changeSetId)!),
|
|
166
|
+
)
|
|
167
|
+
.slice(0, limit);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function lastChangeSetId(): string | null {
|
|
171
|
+
return listChangeSets(1)[0]?.changeSetId ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface UndoFileResult {
|
|
175
|
+
file: string;
|
|
176
|
+
restored: boolean;
|
|
177
|
+
/** Why it was skipped, when `restored` is false. */
|
|
178
|
+
reason?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface UndoResult {
|
|
182
|
+
success: boolean;
|
|
183
|
+
changeSetId: string;
|
|
184
|
+
files: UndoFileResult[];
|
|
185
|
+
message: string;
|
|
186
|
+
errorCode?: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Restore every file in a changeSet to its pre-edit bytes.
|
|
191
|
+
*
|
|
192
|
+
* Refuses any file whose current content no longer matches what the edit
|
|
193
|
+
* wrote: something else has touched it since, and clobbering that would be a
|
|
194
|
+
* second data loss on top of the one undo exists to fix. `force` overrides.
|
|
195
|
+
*/
|
|
196
|
+
export function undoChangeSet(changeSetId: string, options: { force?: boolean; dryRun?: boolean } = {}): UndoResult {
|
|
197
|
+
const records = readIndex().filter((r) => r.changeSetId === changeSetId);
|
|
198
|
+
if (records.length === 0) {
|
|
199
|
+
return {
|
|
200
|
+
success: false,
|
|
201
|
+
changeSetId,
|
|
202
|
+
files: [],
|
|
203
|
+
message: `No snapshots recorded for changeSet ${changeSetId}.`,
|
|
204
|
+
errorCode: "FILE_NOT_FOUND",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Oldest snapshot per file wins: it holds the bytes from before the *first*
|
|
209
|
+
// write in the set, which is what "undo the whole changeSet" means.
|
|
210
|
+
const firstPerFile = new Map<string, SnapshotRecord>();
|
|
211
|
+
const lastPerFile = new Map<string, SnapshotRecord>();
|
|
212
|
+
for (const r of records) {
|
|
213
|
+
if (!firstPerFile.has(r.file)) firstPerFile.set(r.file, r);
|
|
214
|
+
lastPerFile.set(r.file, r);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const files: UndoFileResult[] = [];
|
|
218
|
+
for (const [file, first] of firstPerFile) {
|
|
219
|
+
const last = lastPerFile.get(file)!;
|
|
220
|
+
const exists = existsSync(file);
|
|
221
|
+
if (exists) {
|
|
222
|
+
const current = sha256(readFileSync(file));
|
|
223
|
+
if (current !== last.afterHash && !options.force) {
|
|
224
|
+
files.push({
|
|
225
|
+
file,
|
|
226
|
+
restored: false,
|
|
227
|
+
reason: "modified since the edit was applied; pass --force to restore anyway",
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (first.beforeHash === null) {
|
|
234
|
+
// The changeSet created this file, so undoing it means removing it.
|
|
235
|
+
if (!options.dryRun && exists) {
|
|
236
|
+
try { unlinkSync(file); } catch (e: unknown) {
|
|
237
|
+
files.push({ file, restored: false, reason: `could not remove: ${(e as Error).message}` });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
files.push({ file, restored: true, reason: "created by this changeSet; removed" });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const objectPath = join(objectsDir(), first.beforeHash);
|
|
246
|
+
if (!existsSync(objectPath)) {
|
|
247
|
+
files.push({ file, restored: false, reason: "snapshot object was pruned; nothing to restore from" });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!options.dryRun) {
|
|
251
|
+
try {
|
|
252
|
+
atomicWriteSync(file, readFileSync(objectPath));
|
|
253
|
+
} catch (e: unknown) {
|
|
254
|
+
files.push({ file, restored: false, reason: `restore failed: ${(e as Error).message}` });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
files.push({ file, restored: true });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const failed = files.filter((f) => !f.restored);
|
|
262
|
+
return {
|
|
263
|
+
success: failed.length === 0,
|
|
264
|
+
changeSetId,
|
|
265
|
+
files,
|
|
266
|
+
// HASH_MISMATCH, not a bespoke code: the refusal is exactly the anchor
|
|
267
|
+
// check the hash route already exits 3 for, and adapters branch on it.
|
|
268
|
+
errorCode: failed.length ? "HASH_MISMATCH" : undefined,
|
|
269
|
+
message: failed.length
|
|
270
|
+
? `Restored ${files.length - failed.length}/${files.length} file(s); ${failed.length} refused.`
|
|
271
|
+
: `Restored ${files.length} file(s) from changeSet ${changeSetId}.`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Synchronous atomic replace, used by `undo`. The async write path lives in
|
|
277
|
+
* `paths.ts`; both go temp-file → rename so a crash can never leave a
|
|
278
|
+
* half-written file on disk.
|
|
279
|
+
*/
|
|
280
|
+
function atomicWriteSync(target: string, content: Buffer): void {
|
|
281
|
+
const dir = join(target, "..");
|
|
282
|
+
const tmp = join(dir, `.hashpilot-tmp-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
|
283
|
+
let mode = 0o644;
|
|
284
|
+
try { mode = statSync(target).mode & 0o777; } catch { /* new file: default mode */ }
|
|
285
|
+
writeFileSync(tmp, content, { mode });
|
|
286
|
+
try {
|
|
287
|
+
renameSync(tmp, target);
|
|
288
|
+
} catch (e) {
|
|
289
|
+
try { unlinkSync(tmp); } catch { /* nothing to clean */ }
|
|
290
|
+
throw e;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Drop changeSets past the retention limits, then any object no live record
|
|
296
|
+
* still references. Called after each snapshot batch, so the store cannot grow
|
|
297
|
+
* without bound in a long-running agent session.
|
|
298
|
+
*/
|
|
299
|
+
export function pruneSnapshots(now: number = Date.now()): { changeSetsRemoved: number; objectsRemoved: number } {
|
|
300
|
+
if (!existsSync(indexFile())) return { changeSetsRemoved: 0, objectsRemoved: 0 };
|
|
301
|
+
const records = readIndex();
|
|
302
|
+
const sets = listChangeSets(Number.MAX_SAFE_INTEGER);
|
|
303
|
+
const cutoff = now - retention.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
304
|
+
|
|
305
|
+
const keep = new Set(
|
|
306
|
+
sets
|
|
307
|
+
.filter((s, i) => i < retention.maxChangeSets && Date.parse(s.timestamp) >= cutoff)
|
|
308
|
+
.map((s) => s.changeSetId),
|
|
309
|
+
);
|
|
310
|
+
const changeSetsRemoved = sets.length - keep.size;
|
|
311
|
+
if (changeSetsRemoved === 0) return { changeSetsRemoved: 0, objectsRemoved: 0 };
|
|
312
|
+
|
|
313
|
+
const kept = records.filter((r) => keep.has(r.changeSetId));
|
|
314
|
+
writeFileSync(indexFile(), kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""), { mode: 0o600 });
|
|
315
|
+
|
|
316
|
+
const live = new Set(kept.map((r) => r.beforeHash).filter((h): h is string => h !== null));
|
|
317
|
+
let objectsRemoved = 0;
|
|
318
|
+
if (existsSync(objectsDir())) {
|
|
319
|
+
for (const name of readdirSync(objectsDir())) {
|
|
320
|
+
if (live.has(name)) continue;
|
|
321
|
+
try { unlinkSync(join(objectsDir(), name)); objectsRemoved++; } catch { /* already gone */ }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { changeSetsRemoved, objectsRemoved };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Remove stale `.hashpilot-tmp-*` files in `dir`. A crash between temp-write
|
|
329
|
+
* and rename leaves one behind; without this they accumulate in the user's
|
|
330
|
+
* source tree forever.
|
|
331
|
+
*/
|
|
332
|
+
export function cleanOrphanTempFiles(dir: string, maxAgeMs = 60 * 60 * 1000, now: number = Date.now()): number {
|
|
333
|
+
let removed = 0;
|
|
334
|
+
try {
|
|
335
|
+
for (const name of readdirSync(dir)) {
|
|
336
|
+
if (!name.startsWith(".hashpilot-tmp-")) continue;
|
|
337
|
+
const p = join(dir, name);
|
|
338
|
+
try {
|
|
339
|
+
if (now - statSync(p).mtimeMs < maxAgeMs) continue;
|
|
340
|
+
unlinkSync(p);
|
|
341
|
+
removed++;
|
|
342
|
+
} catch { /* raced with another process */ }
|
|
343
|
+
}
|
|
344
|
+
} catch { /* unreadable directory is not our problem here */ }
|
|
345
|
+
return removed;
|
|
346
|
+
}
|