-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrefresh.ts
More file actions
442 lines (415 loc) · 15.8 KB
/
Copy pathrefresh.ts
File metadata and controls
442 lines (415 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
/**
* loadout-os refresh — the Index Freshness Ritual, folded into one command.
*
* Replaces the manual three-step ritual (claude-memories index → validate →
* copy to ~/.ai-loadout/index.json). It (a) regenerates the store index from
* MEMORY.md, (b) validates with an ANDON HALT on any error-severity issue,
* (c) rewrites relative entry paths to absolute paths under the store root and
* writes the global resolver index the runtime hook reads, then (d) re-validates
* the written destination (warn-only).
*
* Standards compliance (workflow-standards.md — this IS a pipeline with an
* irreversible write, unlike doctor/report which are pure reads):
*
* ANDON_AUTHORITY 3 — step (b) is a hard halt: if validateMemory /
* validateMemoryIndex surface ANY error-severity issue, refresh prints them
* and EXITS 1 writing nothing downstream. The bad index never reaches the
* live global path the hook reads. Tested: `refresh: andon halt on an
* invalid store writes nothing and signals error`.
*
* NAMED_COMPENSATORS 3 — the one irreversible action is the write to --dest
* (the live global index ~/.ai-loadout/index.json that the UserPromptSubmit
* hook reads on every prompt). COMPENSATOR: before writing, if --dest
* already exists we copy it to `<dest>.bak`; on ANY write failure we restore
* <dest> from that backup and re-throw, so a half-written or failed write
* never leaves the live index corrupt. The undo line is printed for the
* human ("undo: copy <dest>.bak back over <dest>"). Owner: the operator who
* ran refresh. Tested: `refresh: compensator backs up an existing dest`.
*
* EXTERNAL_VERIFIER 2 — generation (claude-memories generateIndex) and
* verification (kernel validateIndex on the written dest) are different
* library surfaces; the CLI does not self-grade its own output.
*
* PIN_PER_STEP 1 — the three wrapped library versions are pinned in
* package.json (the "model" of a deterministic pipeline is its dep set);
* not byte-pinned per invocation. Remediation: not required for a
* deterministic (non-LLM) pipeline — there is no sampling to replay.
*
* DECOMPOSE_BY_SECRETS 2 — index generation, validation, path-rewrite, and
* the write+compensator are separate functions; the volatile bit (the
* irreversible write) is isolated behind one guarded writer.
*
* UNCERTAINTY_GATED_HUMANS 2 — --dry-run is the human gate: when uncertain,
* run it first to see the N entries / M paths that WOULD change before
* committing the live write. Framed contrastively in output.
*/
import {
writeFileSync,
existsSync,
statSync,
copyFileSync,
mkdirSync,
} from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { homedir } from "node:os";
import {
analyzeMemoryMd,
generateIndex as generateMemoryIndex,
validateMemory,
validateMemoryIndex,
type MemoryIndex,
} from "@mcptoolshop/claude-memories";
import {
validateIndex as kernelValidateIndex,
type LoadoutIndex,
type ValidationIssue,
} from "@mcptoolshop/ai-loadout";
import {
BOLD,
DIM,
RESET,
GREEN,
YELLOW,
RED,
CYAN,
log,
ok,
warn,
info,
fail,
hasFlag,
flagValue,
} from "./console.js";
/**
* Canonical memory store (holds MEMORY.md + topic files).
*
* Derived from the running user's home directory, not hardcoded. The literal
* path baked in here was one machine's, which made this shipped default resolve
* on exactly one computer — and put a username into a public package.
* `defaultDest()` below already derived its path this way; this matches it.
*/
export const DEFAULT_STORE = join(
homedir(),
".claude",
"projects",
"F--AI",
"memory",
);
/** Default destination: the live global resolver index the hook reads. */
export function defaultDest(): string {
return join(homedir(), ".ai-loadout", "index.json");
}
/**
* True when two paths resolve to the SAME file on disk. On Windows —
* case-insensitive volumes — drive-letter and path case are folded, because
* node's `path.resolve` PRESERVES drive-letter case (`resolve("c:/x")` →
* `"c:\\x"`, not `"C:\\x"`). A raw `===` would therefore misfire on a
* `--dest c:/…/.ai-loadout/index.json` that names the live index in lowercase:
* the write hits the real live index, but the comparison says "custom path",
* so printRefresh would falsely tell the user the live index was NOT modified.
* Folding case on win32 closes that false-negative; POSIX stays case-sensitive.
*/
export function sameResolvedPath(a: string, b: string): boolean {
const ra = resolve(a);
const rb = resolve(b);
return process.platform === "win32"
? ra.toLowerCase() === rb.toLowerCase()
: ra === rb;
}
export interface RefreshOptions {
/** Store directory containing MEMORY.md (default DEFAULT_STORE). */
store?: string;
/** Destination global index path (default ~/.ai-loadout/index.json). */
dest?: string;
/** Compute everything, write nothing; report what WOULD change. */
dryRun?: boolean;
}
export interface RefreshResult {
store: string;
memoryMd: string;
storeIndexPath: string;
dest: string;
dryRun: boolean;
/** Entries in the generated index. */
entryCount: number;
/** How many entry paths were rewritten to absolute (i.e. were relative). */
pathsRewritten: number;
/** Validation issues from the ANDON gate (memory + memory-index). */
gateErrors: ValidationIssue[];
gateWarnings: ValidationIssue[];
/** Issues from re-validating the written dest (warn-only). */
destIssues: ValidationIssue[];
/** Backup path written by the compensator (when dest pre-existed + not dry-run). */
backupPath: string | null;
/** True once the dest was written (false on dry-run or andon halt). */
wrote: boolean;
}
/** Structured error carrying an exit code, so the caller maps it 1:1. */
export class RefreshError extends Error {
readonly code: string;
readonly exitCode: number;
readonly issues?: ValidationIssue[];
constructor(code: string, message: string, exitCode: number, issues?: ValidationIssue[]) {
super(message);
this.name = "RefreshError";
this.code = code;
this.exitCode = exitCode;
this.issues = issues;
}
}
/**
* Rewrite each entry's relative `path` to an absolute path under the store
* root, and stamp `source` = <store>/MEMORY.md. Returns a fresh index object
* (does not mutate the input) plus a count of how many paths were rewritten.
*/
export function rewritePathsAbsolute(
index: MemoryIndex,
storeRoot: string,
memoryMd: string,
): { index: MemoryIndex; pathsRewritten: number } {
let pathsRewritten = 0;
const entries = index.entries.map((e) => {
if (isAbsolute(e.path)) return { ...e };
pathsRewritten++;
return { ...e, path: resolve(storeRoot, e.path) };
});
return {
index: { ...index, entries, source: memoryMd },
pathsRewritten,
};
}
/**
* Core refresh logic. Pure with respect to its inputs (store/dest are
* parameters), so tests drive it against a SCRATCH store + a temp dest —
* never the live ~/.ai-loadout or the canonical store.
*
* Steps:
* (a) index the store — generateIndex(analyzeMemoryMd(MEMORY.md))
* (b) validate (ANDON HALT) — validateMemory + validateMemoryIndex
* (c) path-rewrite + copy — rewrite to absolute, write --dest (guarded)
* (d) re-validate the dest — kernel.validateIndex (warn-only)
*
* Throws RefreshError with the right exitCode on a missing store (2), an andon
* validation error (1), or a write failure (1, after compensator restore).
*/
export function runRefresh(opts: RefreshOptions): RefreshResult {
const store = resolve(opts.store ?? DEFAULT_STORE);
const dest = resolve(opts.dest ?? defaultDest());
const dryRun = !!opts.dryRun;
// ── precondition: store + MEMORY.md present (exit 2 when missing) ──
const memoryMd = join(store, "MEMORY.md");
if (!existsSync(store) || !statSync(store).isDirectory()) {
throw new RefreshError(
"STORE_NOT_FOUND",
`Store directory not found: ${store}`,
2,
);
}
if (!existsSync(memoryMd)) {
throw new RefreshError(
"MEMORY_MD_NOT_FOUND",
`MEMORY.md not found in store: ${memoryMd}`,
2,
);
}
// ── (a) index the store ──────────────────────────────────────
const analysis = analyzeMemoryMd(memoryMd);
const storeIndex = generateMemoryIndex(analysis);
const storeIndexPath = join(store, "index.json");
// ── (b) validate — ANDON HALT on any error-severity issue ────
const gate: ValidationIssue[] = [
...validateMemory(analysis),
...validateMemoryIndex(storeIndex),
];
const gateErrors = gate.filter((i) => i.severity === "error");
const gateWarnings = gate.filter((i) => i.severity === "warning");
if (gateErrors.length > 0) {
// Halt the pipeline: nothing downstream (store index.json, dest) is written.
throw new RefreshError(
"VALIDATION_FAILED",
`${gateErrors.length} validation error(s) — halting, nothing written`,
1,
gateErrors,
);
}
// ── (c) path-rewrite (compute always; write only when not dry-run) ──
const { index: rewritten, pathsRewritten } = rewritePathsAbsolute(
storeIndex,
store,
memoryMd,
);
const destJson = JSON.stringify(rewritten, null, 2) + "\n";
const storeJson = JSON.stringify(storeIndex, null, 2) + "\n";
// re-validate the rewritten dest payload (warn-only) — kernel structure check
const destIssues = kernelValidateIndex(rewritten as unknown as LoadoutIndex);
if (dryRun) {
return {
store,
memoryMd,
storeIndexPath,
dest,
dryRun: true,
entryCount: rewritten.entries.length,
pathsRewritten,
gateErrors,
gateWarnings,
destIssues,
backupPath: null,
wrote: false,
};
}
// write the store index.json first (idempotent, in-tree, reversible by re-run)
writeFileSync(storeIndexPath, storeJson, "utf-8");
// ── COMPENSATOR (NAMED, irreversible write to the live global index) ──
// The dest is the index the UserPromptSubmit hook reads on every prompt; a
// corrupt/half-written dest breaks every future session. Before overwriting,
// back up the existing dest to <dest>.bak; on ANY write failure, restore the
// dest from that backup and re-throw. Undo command is printed for the human.
let backupPath: string | null = null;
if (existsSync(dest)) {
backupPath = `${dest}.bak`;
copyFileSync(dest, backupPath);
}
try {
// Create the parent dir for a first-ever write (e.g. a fresh machine with
// no ~/.ai-loadout yet). mkdir is idempotent + reversible, not the
// irreversible action the compensator guards — that is the dest write below.
const destDir = dirname(dest);
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
writeFileSync(dest, destJson, "utf-8");
} catch (e) {
// restore from the backup so the live index is never left corrupt
if (backupPath && existsSync(backupPath)) {
try {
copyFileSync(backupPath, dest);
} catch {
/* best-effort restore; surface the original error below */
}
}
if (e instanceof RefreshError) throw e;
throw new RefreshError(
"WRITE_FAILED",
`Failed to write dest (${dest}): ${(e as Error).message}${
backupPath ? ` — restored from ${backupPath}` : ""
}`,
1,
);
}
return {
store,
memoryMd,
storeIndexPath,
dest,
dryRun: false,
entryCount: rewritten.entries.length,
pathsRewritten,
gateErrors,
gateWarnings,
destIssues,
backupPath,
wrote: true,
};
}
/** Render a refresh result as a human screen. */
export function printRefresh(r: RefreshResult): void {
log();
log(`${BOLD}loadout-os refresh${RESET} ${DIM}— Index Freshness Ritual${RESET}`);
log(` ${DIM}store: ${r.store}${RESET}`);
log(` ${DIM}dest: ${r.dest}${RESET}`);
log();
if (r.dryRun) {
info(`${BOLD}--dry-run${RESET} — nothing was written.`);
log(
` ${CYAN}Would write${RESET} ${r.storeIndexPath} and ${r.dest}: ` +
`${r.entryCount} entr${r.entryCount === 1 ? "y" : "ies"}, ` +
`${r.pathsRewritten} relative path(s) → absolute.`,
);
if (existsSync(r.dest)) {
log(` ${DIM}A live dest exists; a real run would back it up to ${r.dest}.bak first.${RESET}`);
} else {
log(` ${DIM}No live dest yet; a real run would create it (no backup needed).${RESET}`);
}
if (r.gateWarnings.length > 0) warn(`${r.gateWarnings.length} validation warning(s) (non-blocking).`);
log();
return;
}
ok(`Wrote store index: ${r.storeIndexPath}`);
ok(`Wrote global index: ${r.dest} (${r.entryCount} entries, ${r.pathsRewritten} paths → absolute)`);
if (r.backupPath) {
log(` ${DIM}compensator: backed up previous dest → ${r.backupPath}${RESET}`);
log(` ${DIM}undo: copy ${r.backupPath} back over ${r.dest}${RESET}`);
} else {
log(` ${DIM}compensator: no prior dest existed; nothing to back up (undo = delete ${r.dest})${RESET}`);
}
if (r.gateWarnings.length > 0) {
warn(`${r.gateWarnings.length} validation warning(s) (non-blocking):`);
for (const i of r.gateWarnings) log(` ${YELLOW}![${i.code}]${RESET} ${i.message}`);
}
if (r.destIssues.length > 0) {
const errs = r.destIssues.filter((i) => i.severity === "error");
const wrns = r.destIssues.filter((i) => i.severity === "warning");
if (errs.length > 0) {
// dest re-validation is warn-only by contract; surface but do not fail.
warn(`dest re-validation found ${errs.length} structural error(s) (reported, not blocking):`);
for (const i of errs) log(` ${RED}✗[${i.code}]${RESET} ${i.message}`);
}
if (wrns.length > 0) {
for (const i of wrns) log(` ${DIM}![${i.code}] ${i.message}${RESET}`);
}
}
log();
if (sameResolvedPath(r.dest, defaultDest())) {
// dest IS the live global resolver index the hook reads every prompt.
log(
` ${DIM}This refreshed the LIVE global resolver index the UserPromptSubmit hook reads on every prompt — the change is live now.${RESET}`,
);
} else {
// a custom --dest (scratch/test/alternate): the live index was untouched.
log(
` ${DIM}Note: --dest is a custom path, not the live resolver index (${defaultDest()}) — the index the hook reads was NOT modified. Re-run without --dest to refresh the live one.${RESET}`,
);
}
log();
}
/**
* Print the validation errors that triggered the andon halt, in the shared
* structured shape, for the dispatcher to render before exiting 1.
*/
export function printRefreshAndon(issues: ValidationIssue[]): void {
log();
log(`${RED}${BOLD}loadout-os refresh — ANDON HALT${RESET}`);
log(` ${DIM}validation failed; nothing was written downstream.${RESET}`);
log();
for (const i of issues) {
log(` ${RED}✗ [${i.code}]${RESET} ${i.message}`);
if (i.hint) log(` ${DIM}${i.hint}${RESET}`);
}
log();
}
/**
* Dispatcher wiring: parse flags, run refresh, render, map exit codes.
* 0 success · 1 andon validation error OR write failure · 2 store/MEMORY.md missing
*/
export function dispatchRefresh(args: string[]): void {
const store = flagValue(args, "store") ?? DEFAULT_STORE;
const dest = flagValue(args, "dest") ?? defaultDest();
const dryRun = hasFlag(args, "dry-run");
try {
const result = runRefresh({ store, dest, dryRun });
printRefresh(result);
} catch (e) {
if (e instanceof RefreshError) {
if (e.code === "VALIDATION_FAILED" && e.issues) {
printRefreshAndon(e.issues);
} else {
log();
log(` ${RED}✗ [${e.code}]${RESET} ${e.message}`);
log();
}
// Surface through the shared structured error so the process boundary
// exits with the mapped code (2 store missing, 1 andon/write).
fail(e.code, e.message, undefined, e.exitCode);
}
throw e;
}
}