moflo 4.9.2 → 4.9.4
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/.claude/guidance/shipped/moflo-error-handling.md +25 -0
- package/.claude/guidance/shipped/moflo-source-hygiene.md +16 -0
- package/bin/build-embeddings.mjs +31 -14
- package/bin/lib/process-manager.mjs +7 -2
- package/bin/session-start-launcher.mjs +214 -45
- package/dist/src/cli/version.js +1 -1
- package/package.json +5 -4
- package/scripts/post-install-notice.mjs +114 -0
|
@@ -43,6 +43,31 @@
|
|
|
43
43
|
|
|
44
44
|
---
|
|
45
45
|
|
|
46
|
+
## Transient Errors Must Use Retry + Circuit Breaker
|
|
47
|
+
|
|
48
|
+
**Wrap every transient-failure-capable operation in a retry helper with exponential backoff and a circuit breaker.** One-shot try-and-log on a transient class strands users in partial-state loops (#854: Windows file-lock + AV scan race left stale `.claude/helpers/gate.cjs` across 8+ moflo bumps).
|
|
49
|
+
|
|
50
|
+
| Element | Default |
|
|
51
|
+
|---------|---------|
|
|
52
|
+
| Retryable codes | `EBUSY`, `EPERM`, `EACCES`, `EAGAIN`, `ETIMEDOUT`, `ECONNRESET`; HTTP 5xx/429/408 |
|
|
53
|
+
| Hard codes (no retry) | `ENOENT`, `EISDIR`, `EEXIST`, validation errors |
|
|
54
|
+
| Backoff (filesystem) | `[50, 200, 800]ms` — 3 retries |
|
|
55
|
+
| Circuit breaker | Open after 5 distinct failures; tail runs with `maxAttempts=1` |
|
|
56
|
+
| Exhaustion handling | Log error AND name the healer (e.g. `run 'flo doctor --fix' to repair`) |
|
|
57
|
+
|
|
58
|
+
**Reference implementation:** `syncWithRetry` in `bin/session-start-launcher.mjs`. Use it; do not invent ad-hoc retries.
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
// FORBIDDEN
|
|
62
|
+
try { copyFileSync(src, dest); } catch (err) { console.warn(err.message); }
|
|
63
|
+
|
|
64
|
+
// CORRECT
|
|
65
|
+
const result = await syncWithRetry(() => copyFileSync(src, dest));
|
|
66
|
+
if (!result.ok) syncFailures.push({ key, message: `${errMessage(result.err)} (retried after ${result.code})` });
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
46
71
|
## See Also
|
|
47
72
|
|
|
48
73
|
- `.claude/guidance/shipped/moflo-source-hygiene.md` — General source code standards
|
|
@@ -68,6 +68,22 @@ Compiled output is generated by `npm run build` (single `tsc` run from repo root
|
|
|
68
68
|
|
|
69
69
|
---
|
|
70
70
|
|
|
71
|
+
## Async-Eligible Operations Must Be Async By Default
|
|
72
|
+
|
|
73
|
+
**Use the async API for any operation that has one, unless a documented reason forces sync.** Sync IO and busy-waits block the event loop — on a hot path that pins a CPU core, stalls timers, and turns a "best-effort" failure into a hang. Issue #854 burned 5s of CPU to a `while (Date.now() < end)` busy-wait inside an already-async launcher.
|
|
74
|
+
|
|
75
|
+
| Operation | Use | Avoid |
|
|
76
|
+
|-----------|-----|-------|
|
|
77
|
+
| File IO inside an `async` function | `fs/promises` (`readFile`, `copyFile`, `mkdir`) | `*Sync` variants |
|
|
78
|
+
| Delays | `await new Promise(r => setTimeout(r, ms))` | `while (Date.now() < end)` busy-wait |
|
|
79
|
+
| Spawning long-running children | `await new Promise` around `spawn`/`execFile` | `execFileSync`/`spawnSync` (Windows timeouts orphan children — #744) |
|
|
80
|
+
|
|
81
|
+
**Sync is OK only when:** the function cannot be `async` (top-level config reader before any await; certain CommonJS hooks); the IO is microscopic on a known-fast path (one `existsSync` probe, one tiny `readFileSync`); or conversion churn outweighs the benefit. Document the reason in a one-line comment.
|
|
82
|
+
|
|
83
|
+
**Rule of thumb:** if anything in your call stack already `await`s, every IO/delay below it must be async too — mixing sync IO inside an async function is a smell.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
71
87
|
## When Creating New Files
|
|
72
88
|
|
|
73
89
|
Before creating any new source file, verify:
|
package/bin/build-embeddings.mjs
CHANGED
|
@@ -203,6 +203,29 @@ function writeVectorStatsCache(stats, nsStats) {
|
|
|
203
203
|
// Main
|
|
204
204
|
// ============================================================================
|
|
205
205
|
|
|
206
|
+
// Build (or skip) the HNSW sidecar — shared between the all-embedded fast path
|
|
207
|
+
// and the post-embedding finalize path. Issue #854: the early-return path used
|
|
208
|
+
// to skip this entirely, leaving consumers with `hasHnsw: false` permanently
|
|
209
|
+
// after the embeddings migration deleted the stale sidecar without rebuilding.
|
|
210
|
+
// `stats` is passed in by both call sites so we don't redundantly run the
|
|
211
|
+
// COUNT aggregate the caller already executed. `alwaysRebuild` is set by the
|
|
212
|
+
// post-embedding path because newly-embedded rows always invalidate the
|
|
213
|
+
// existing sidecar.
|
|
214
|
+
async function ensureHnswSidecar(stats, { alwaysRebuild = false } = {}) {
|
|
215
|
+
if (stats.withEmbeddings <= 0) return false;
|
|
216
|
+
const sidecarExists = existsSync(hnswIndexPath(projectRoot));
|
|
217
|
+
if (sidecarExists && !force && !alwaysRebuild) return false;
|
|
218
|
+
log(sidecarExists ? 'Rebuilding HNSW sidecar...' : 'Building HNSW sidecar...');
|
|
219
|
+
const result = await buildAndWriteHnswSidecar(DB_PATH, projectRoot, {
|
|
220
|
+
dimensions: EMBEDDING_DIMS,
|
|
221
|
+
});
|
|
222
|
+
log(`HNSW sidecar: ${result.vectorCount} vectors → ${result.sidecarPath} (${(result.bytes / 1024).toFixed(1)} KB)`);
|
|
223
|
+
if (!existsSync(result.sidecarPath)) {
|
|
224
|
+
throw new Error(`HNSW sidecar missing after write: ${result.sidecarPath}`);
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
206
229
|
async function main() {
|
|
207
230
|
console.log('');
|
|
208
231
|
log('═══════════════════════════════════════════════════════════');
|
|
@@ -217,6 +240,8 @@ async function main() {
|
|
|
217
240
|
log('All entries already have embeddings');
|
|
218
241
|
const stats = getEmbeddingStats(db);
|
|
219
242
|
log(`Total: ${stats.withEmbeddings}/${stats.total} entries embedded`);
|
|
243
|
+
// HNSW first so vector-stats reports the post-rebuild `hasHnsw` (#854).
|
|
244
|
+
await ensureHnswSidecar(stats);
|
|
220
245
|
writeVectorStatsCache(stats, getNamespaceStats(db));
|
|
221
246
|
db.close();
|
|
222
247
|
return;
|
|
@@ -312,22 +337,14 @@ async function main() {
|
|
|
312
337
|
}
|
|
313
338
|
log('═══════════════════════════════════════════════════════════');
|
|
314
339
|
|
|
340
|
+
// Rebuild the HNSW sidecar before vector-stats so `hasHnsw` reflects the
|
|
341
|
+
// post-rebuild state in the same session (#854). Newly-embedded rows
|
|
342
|
+
// invalidate the existing sidecar, so we force the rebuild here even
|
|
343
|
+
// when the sidecar already exists.
|
|
344
|
+
await ensureHnswSidecar(stats, { alwaysRebuild: true });
|
|
345
|
+
|
|
315
346
|
writeVectorStatsCache(stats, nsStats);
|
|
316
347
|
db.close();
|
|
317
|
-
|
|
318
|
-
// Rebuild + persist the HNSW sidecar so cold-start memory searches skip
|
|
319
|
-
// the SQL→graph rebuild. Failure here exits non-zero — index-all.mjs and
|
|
320
|
-
// any caller of this script depend on the sidecar landing on disk.
|
|
321
|
-
if (stats.withEmbeddings > 0) {
|
|
322
|
-
log('Building HNSW sidecar...');
|
|
323
|
-
const result = await buildAndWriteHnswSidecar(DB_PATH, projectRoot, {
|
|
324
|
-
dimensions: EMBEDDING_DIMS,
|
|
325
|
-
});
|
|
326
|
-
log(`HNSW sidecar: ${result.vectorCount} vectors → ${result.sidecarPath} (${(result.bytes / 1024).toFixed(1)} KB)`);
|
|
327
|
-
if (!existsSync(result.sidecarPath)) {
|
|
328
|
-
throw new Error(`HNSW sidecar missing after write: ${result.sidecarPath}`);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
348
|
}
|
|
332
349
|
|
|
333
350
|
main().catch(err => {
|
|
@@ -217,12 +217,17 @@ export function createProcessManager(root) {
|
|
|
217
217
|
if (!isAlive(entry.pid)) continue;
|
|
218
218
|
try {
|
|
219
219
|
if (process.platform === 'win32') {
|
|
220
|
-
execFileSync('taskkill', ['/T', '/F', '/PID', String(entry.pid)], { windowsHide: true, timeout:
|
|
220
|
+
execFileSync('taskkill', ['/T', '/F', '/PID', String(entry.pid)], { windowsHide: true, timeout: 10000 });
|
|
221
221
|
} else {
|
|
222
222
|
process.kill(entry.pid, 'SIGTERM');
|
|
223
223
|
}
|
|
224
224
|
killed++;
|
|
225
|
-
} catch {
|
|
225
|
+
} catch {
|
|
226
|
+
// Wrapper call threw (e.g. taskkill exceeded its timeout under load).
|
|
227
|
+
// The process itself may still have been terminated — count it as
|
|
228
|
+
// killed if it is no longer alive.
|
|
229
|
+
if (!isAlive(entry.pid)) killed++;
|
|
230
|
+
}
|
|
226
231
|
}
|
|
227
232
|
|
|
228
233
|
// Clear registry and lock
|
|
@@ -60,6 +60,48 @@ function emitMutation(action, details) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Stderr counterpart to emitMutation for non-fatal failures (#854). Every
|
|
64
|
+
// previously-bare `catch {}` in the upgrade flow is routed through here so
|
|
65
|
+
// partial-migration regressions can never go silent again. The inner try
|
|
66
|
+
// guards against a broken stderr pipe — writing the failure itself must
|
|
67
|
+
// never throw, otherwise a fast session-end would surface as a crash.
|
|
68
|
+
function emitWarning(message) {
|
|
69
|
+
try {
|
|
70
|
+
process.stderr.write(`moflo: ${message}\n`);
|
|
71
|
+
} catch { /* stderr write must not throw */ }
|
|
72
|
+
}
|
|
73
|
+
function errMessage(err) {
|
|
74
|
+
return err && err.message ? err.message : String(err);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Manifest schema (#854 hardening). Originally `string[]`; now `{path,size}[]`
|
|
78
|
+
// so the launcher can detect *content* drift, not just *missing-file* drift.
|
|
79
|
+
// Reading accepts both forms — a legacy v1 manifest is reported via
|
|
80
|
+
// `isLegacy=true` so the drift check forces one re-sync to migrate to v2,
|
|
81
|
+
// closing the failure mode where a v4.9.2 launcher writes a stamp+manifest
|
|
82
|
+
// in stage 1 of an upgrade and the v4.9.3 launcher (with this fix) sees
|
|
83
|
+
// `installedVersion === cachedVersion` + no file-missing drift, then skips
|
|
84
|
+
// section 3 leaving stale `gate.cjs` etc. stuck.
|
|
85
|
+
function readInstallManifest(manifestPath) {
|
|
86
|
+
let raw;
|
|
87
|
+
try { raw = readFileSync(manifestPath, 'utf-8'); } catch { return { entries: [], isLegacy: false }; }
|
|
88
|
+
let parsed;
|
|
89
|
+
try { parsed = JSON.parse(raw); } catch { return { entries: [], isLegacy: false }; }
|
|
90
|
+
if (!Array.isArray(parsed)) return { entries: [], isLegacy: false };
|
|
91
|
+
let isLegacy = false;
|
|
92
|
+
const entries = [];
|
|
93
|
+
for (const item of parsed) {
|
|
94
|
+
if (typeof item === 'string') {
|
|
95
|
+
isLegacy = true;
|
|
96
|
+
entries.push({ path: item, size: null });
|
|
97
|
+
} else if (item && typeof item === 'object' && typeof item.path === 'string') {
|
|
98
|
+
entries.push({ path: item.path, size: typeof item.size === 'number' ? item.size : null });
|
|
99
|
+
}
|
|
100
|
+
// malformed entries are silently dropped — not surfaceable, never written by us
|
|
101
|
+
}
|
|
102
|
+
return { entries, isLegacy };
|
|
103
|
+
}
|
|
104
|
+
|
|
63
105
|
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
64
106
|
|
|
65
107
|
// Captured inside the upgrade/drift branch so the post-spawn notice writer
|
|
@@ -246,7 +288,11 @@ try {
|
|
|
246
288
|
if (scriptsMatch) autoUpdateConfig.scripts = scriptsMatch[1] === 'true';
|
|
247
289
|
if (helpersMatch) autoUpdateConfig.helpers = helpersMatch[1] === 'true';
|
|
248
290
|
}
|
|
249
|
-
} catch
|
|
291
|
+
} catch (err) {
|
|
292
|
+
// Defaults (all true) keep the upgrade flow alive but the user should
|
|
293
|
+
// see when their moflo.yaml fails to parse (#854).
|
|
294
|
+
emitWarning(`moflo.yaml parse failed (${errMessage(err)}) — using defaults`);
|
|
295
|
+
}
|
|
250
296
|
|
|
251
297
|
try {
|
|
252
298
|
const mofloPkgPath = resolve(projectRoot, 'node_modules/moflo/package.json');
|
|
@@ -256,18 +302,27 @@ try {
|
|
|
256
302
|
let cachedVersion = '';
|
|
257
303
|
try { cachedVersion = readFileSync(versionStampPath, 'utf-8').trim(); } catch {}
|
|
258
304
|
|
|
259
|
-
// Drift healing: re-sync if any previously-installed file is missing
|
|
260
|
-
//
|
|
261
|
-
// rm, botched merges, dedup commits
|
|
262
|
-
//
|
|
305
|
+
// Drift healing: re-sync if any previously-installed file is missing
|
|
306
|
+
// OR has drifted in size since we last wrote it. Guards against:
|
|
307
|
+
// - out-of-band deletions (manual rm, botched merges, dedup commits)
|
|
308
|
+
// - stale-content drift (a prior partial migration left the file at
|
|
309
|
+
// pre-upgrade content even though it still exists — #854)
|
|
310
|
+
// - legacy v1 manifests written by an older launcher (force one
|
|
311
|
+
// re-sync to migrate to v2 so subsequent runs can size-check)
|
|
263
312
|
const manifestPath = resolve(projectRoot, '.moflo', 'installed-files.json');
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
313
|
+
const { entries: priorEntries, isLegacy: manifestIsLegacy } = readInstallManifest(manifestPath);
|
|
314
|
+
let manifestDrifted = manifestIsLegacy;
|
|
315
|
+
if (!manifestDrifted) {
|
|
316
|
+
for (const { path: rel, size } of priorEntries) {
|
|
317
|
+
const abs = resolve(projectRoot, rel);
|
|
318
|
+
if (!existsSync(abs)) { manifestDrifted = true; break; }
|
|
319
|
+
if (size !== null) {
|
|
320
|
+
try {
|
|
321
|
+
if (statSync(abs).size !== size) { manifestDrifted = true; break; }
|
|
322
|
+
} catch { manifestDrifted = true; break; }
|
|
323
|
+
}
|
|
269
324
|
}
|
|
270
|
-
}
|
|
325
|
+
}
|
|
271
326
|
|
|
272
327
|
if (installedVersion !== cachedVersion || manifestDrifted) {
|
|
273
328
|
if (installedVersion !== cachedVersion) {
|
|
@@ -366,23 +421,86 @@ try {
|
|
|
366
421
|
// 3. That's it — cleanup is automatic on the next upgrade
|
|
367
422
|
// ────────────────────────────────────────────────────────────────────
|
|
368
423
|
|
|
369
|
-
// Load the previous manifest so we can diff after syncing
|
|
370
|
-
|
|
371
|
-
|
|
424
|
+
// Load the previous manifest so we can diff after syncing.
|
|
425
|
+
// Both v1 (string[]) and v2 ({path,size}[]) are normalized to entries
|
|
426
|
+
// by readInstallManifest — the cleanup loop only needs the path field.
|
|
427
|
+
const { entries: previousManifest } = readInstallManifest(manifestPath);
|
|
372
428
|
|
|
373
429
|
// Track every file we install this round
|
|
374
430
|
const currentManifest = [];
|
|
431
|
+
// Per-file copy failures used to be invisible (#854): a Windows file
|
|
432
|
+
// lock / AV real-time scan / concurrent helper invocation would EBUSY
|
|
433
|
+
// the copy, the bare catch swallowed it, and the file stayed at its
|
|
434
|
+
// pre-upgrade content forever because it was never recorded in the
|
|
435
|
+
// manifest. Surface failures on stderr — Claude Code captures
|
|
436
|
+
// session-start stderr as additionalContext so the user sees them too.
|
|
437
|
+
const syncFailures = [];
|
|
438
|
+
|
|
439
|
+
// Standard retry with exponential backoff + circuit breaker for the
|
|
440
|
+
// transient error class (EBUSY / EPERM / EACCES — Windows file lock,
|
|
441
|
+
// AV real-time scan, concurrent helper invocation). Hard errors
|
|
442
|
+
// (ENOENT, etc.) fall through immediately. Once 5 distinct files have
|
|
443
|
+
// exhausted retries the circuit opens and the tail of the sync runs
|
|
444
|
+
// with maxAttempts=1 so a sick host (AV mid-scan over node_modules)
|
|
445
|
+
// doesn't compound the wall-clock cost. Async setTimeout — never
|
|
446
|
+
// busy-wait in a session-start hook (CPU pinning during EBUSY backoff
|
|
447
|
+
// is the worst possible response when the OS is the bottleneck).
|
|
448
|
+
const TRANSIENT_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
|
|
449
|
+
const RETRY_BACKOFF_MS = [50, 200, 800];
|
|
450
|
+
const CIRCUIT_BREAK_THRESHOLD = 5;
|
|
451
|
+
let circuitOpen = false;
|
|
452
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
453
|
+
async function syncWithRetry(operation) {
|
|
454
|
+
const maxAttempts = circuitOpen ? 1 : RETRY_BACKOFF_MS.length + 1;
|
|
455
|
+
let lastErr = null;
|
|
456
|
+
let lastCode = null;
|
|
457
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
458
|
+
if (attempt > 0) await sleep(RETRY_BACKOFF_MS[attempt - 1]);
|
|
459
|
+
try {
|
|
460
|
+
operation();
|
|
461
|
+
return { ok: true };
|
|
462
|
+
} catch (err) {
|
|
463
|
+
lastErr = err;
|
|
464
|
+
lastCode = err && err.code ? err.code : null;
|
|
465
|
+
if (!TRANSIENT_CODES.has(lastCode)) break;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (!circuitOpen && syncFailures.length + 1 >= CIRCUIT_BREAK_THRESHOLD) {
|
|
469
|
+
circuitOpen = true;
|
|
470
|
+
}
|
|
471
|
+
return { ok: false, err: lastErr, code: lastCode };
|
|
472
|
+
}
|
|
375
473
|
|
|
376
|
-
/** Copy src → dest if src exists, record in manifest.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
474
|
+
/** Copy src → dest if src exists, record `{path, size}` in manifest.
|
|
475
|
+
* Retries the transient error class with backoff (#854); failures land
|
|
476
|
+
* in syncFailures for the post-block stderr summary. The recorded size
|
|
477
|
+
* is read from the just-written destination so a subsequent launcher
|
|
478
|
+
* can detect content drift via size mismatch. */
|
|
479
|
+
function recordManifestEntry(manifestKey, dest) {
|
|
480
|
+
let size = null;
|
|
481
|
+
try { size = statSync(dest).size; } catch { /* size left null — drift check still works on file-existence */ }
|
|
482
|
+
currentManifest.push({ path: manifestKey, size });
|
|
483
|
+
}
|
|
484
|
+
async function syncFile(src, dest, manifestKey) {
|
|
485
|
+
if (!existsSync(src)) return;
|
|
486
|
+
const result = await syncWithRetry(() => copyFileSync(src, dest));
|
|
487
|
+
if (result.ok) {
|
|
488
|
+
recordManifestEntry(manifestKey, dest);
|
|
489
|
+
return;
|
|
380
490
|
}
|
|
491
|
+
const tail = TRANSIENT_CODES.has(result.code)
|
|
492
|
+
? ` (retried ${RETRY_BACKOFF_MS.length}× after ${result.code}${circuitOpen ? '; circuit open' : ''})`
|
|
493
|
+
: '';
|
|
494
|
+
syncFailures.push({ key: manifestKey, message: `${errMessage(result.err)}${tail}` });
|
|
381
495
|
}
|
|
382
496
|
|
|
383
497
|
// Version changed — sync scripts from bin/
|
|
384
498
|
if (autoUpdateConfig.scripts) {
|
|
385
499
|
const scriptsDir = resolve(projectRoot, '.claude/scripts');
|
|
500
|
+
// Ensure the destination dir exists — first-install consumers may
|
|
501
|
+
// not have it yet, in which case every copyFileSync below would
|
|
502
|
+
// silently ENOENT (#854).
|
|
503
|
+
if (!existsSync(scriptsDir)) mkdirSync(scriptsDir, { recursive: true });
|
|
386
504
|
const scriptFiles = [
|
|
387
505
|
'hooks.mjs', 'session-start-launcher.mjs', 'index-guidance.mjs',
|
|
388
506
|
'build-embeddings.mjs', 'generate-code-map.mjs', 'semantic-search.mjs',
|
|
@@ -390,7 +508,7 @@ try {
|
|
|
390
508
|
'setup-project.mjs', 'run-migrations.mjs',
|
|
391
509
|
];
|
|
392
510
|
for (const file of scriptFiles) {
|
|
393
|
-
syncFile(resolve(binDir, file), resolve(scriptsDir, file), `.claude/scripts/${file}`);
|
|
511
|
+
await syncFile(resolve(binDir, file), resolve(scriptsDir, file), `.claude/scripts/${file}`);
|
|
394
512
|
}
|
|
395
513
|
|
|
396
514
|
// Sync lib/ subdirectory (process-manager.mjs, registry-cleanup.cjs, etc.)
|
|
@@ -401,7 +519,7 @@ try {
|
|
|
401
519
|
if (existsSync(libSrcDir)) {
|
|
402
520
|
if (!existsSync(libDestDir)) mkdirSync(libDestDir, { recursive: true });
|
|
403
521
|
for (const file of readdirSync(libSrcDir)) {
|
|
404
|
-
syncFile(resolve(libSrcDir, file), resolve(libDestDir, file), `.claude/scripts/lib/${file}`);
|
|
522
|
+
await syncFile(resolve(libSrcDir, file), resolve(libDestDir, file), `.claude/scripts/lib/${file}`);
|
|
405
523
|
}
|
|
406
524
|
}
|
|
407
525
|
|
|
@@ -415,7 +533,8 @@ try {
|
|
|
415
533
|
let migrationEntries;
|
|
416
534
|
try {
|
|
417
535
|
migrationEntries = readdirSync(migrationsSrcDir, { recursive: true, withFileTypes: true });
|
|
418
|
-
} catch {
|
|
536
|
+
} catch (err) {
|
|
537
|
+
emitWarning(`migrations source readdir failed (${errMessage(err)})`);
|
|
419
538
|
migrationEntries = [];
|
|
420
539
|
}
|
|
421
540
|
for (const entry of migrationEntries) {
|
|
@@ -424,8 +543,10 @@ try {
|
|
|
424
543
|
const absSrc = resolve(parent, entry.name);
|
|
425
544
|
const rel = absSrc.slice(migrationsSrcDir.length + 1).split(/[\\/]/).join('/');
|
|
426
545
|
const absDest = resolve(migrationsDestDir, rel);
|
|
427
|
-
try { mkdirSync(dirname(absDest), { recursive: true }); } catch {
|
|
428
|
-
|
|
546
|
+
try { mkdirSync(dirname(absDest), { recursive: true }); } catch (err) {
|
|
547
|
+
emitWarning(`migrations subdir mkdir failed for ${rel} (${errMessage(err)})`);
|
|
548
|
+
}
|
|
549
|
+
await syncFile(absSrc, absDest, `.claude/scripts/migrations/${rel}`);
|
|
429
550
|
}
|
|
430
551
|
}
|
|
431
552
|
}
|
|
@@ -440,7 +561,7 @@ try {
|
|
|
440
561
|
'gate.cjs', 'gate-hook.mjs', 'prompt-hook.mjs', 'hook-handler.cjs',
|
|
441
562
|
];
|
|
442
563
|
for (const file of binHelperFiles) {
|
|
443
|
-
syncFile(resolve(binDir, file), resolve(helpersDir, file), `.claude/helpers/${file}`);
|
|
564
|
+
await syncFile(resolve(binDir, file), resolve(helpersDir, file), `.claude/helpers/${file}`);
|
|
444
565
|
}
|
|
445
566
|
|
|
446
567
|
// Other helpers from .claude/helpers/ and CLI .claude/helpers/
|
|
@@ -458,7 +579,19 @@ try {
|
|
|
458
579
|
for (const srcDir of helperSources) {
|
|
459
580
|
const src = resolve(srcDir, file);
|
|
460
581
|
if (existsSync(src)) {
|
|
461
|
-
|
|
582
|
+
const inlineResult = await syncWithRetry(() => copyFileSync(src, dest));
|
|
583
|
+
if (inlineResult.ok) {
|
|
584
|
+
recordManifestEntry(`.claude/helpers/${file}`, dest);
|
|
585
|
+
} else {
|
|
586
|
+
const code = inlineResult.code;
|
|
587
|
+
const tail = TRANSIENT_CODES.has(code)
|
|
588
|
+
? ` (retried ${RETRY_BACKOFF_MS.length}× after ${code}${circuitOpen ? '; circuit open' : ''})`
|
|
589
|
+
: '';
|
|
590
|
+
syncFailures.push({
|
|
591
|
+
key: `.claude/helpers/${file}`,
|
|
592
|
+
message: `${errMessage(inlineResult.err)}${tail}`,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
462
595
|
break; // first source wins
|
|
463
596
|
}
|
|
464
597
|
}
|
|
@@ -477,26 +610,31 @@ try {
|
|
|
477
610
|
const dest = resolve(guidanceDir, file);
|
|
478
611
|
const content = readFileSync(src, 'utf-8');
|
|
479
612
|
writeFileSync(dest, sessionStartMirrorHeader(file) + content);
|
|
480
|
-
|
|
613
|
+
recordManifestEntry(`.claude/guidance/${file}`, dest);
|
|
481
614
|
}
|
|
482
|
-
} catch {
|
|
615
|
+
} catch (err) {
|
|
616
|
+
emitWarning(`shipped guidance sync failed (${errMessage(err)})`);
|
|
617
|
+
}
|
|
483
618
|
}
|
|
484
619
|
|
|
485
620
|
// ── Clean up files we installed previously but no longer ship ──
|
|
486
621
|
// Only remove files that are in the OLD manifest but NOT in the new one.
|
|
487
622
|
// This ensures we never delete user-created or runtime-generated files.
|
|
623
|
+
// Both v1 (string) and v2 ({path,size}) old entries are normalized to
|
|
624
|
+
// entries by readInstallManifest; we only need the path for cleanup.
|
|
488
625
|
let removedFiles = 0;
|
|
489
626
|
if (previousManifest.length > 0) {
|
|
490
|
-
const currentSet = new Set(currentManifest);
|
|
491
|
-
for (const rel of previousManifest) {
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
627
|
+
const currentSet = new Set(currentManifest.map((e) => e.path));
|
|
628
|
+
for (const { path: rel } of previousManifest) {
|
|
629
|
+
if (currentSet.has(rel)) continue;
|
|
630
|
+
const abs = resolve(projectRoot, rel);
|
|
631
|
+
try {
|
|
632
|
+
if (existsSync(abs)) {
|
|
633
|
+
unlinkSync(abs);
|
|
634
|
+
removedFiles++;
|
|
635
|
+
}
|
|
636
|
+
} catch (err) {
|
|
637
|
+
emitWarning(`cleanup unlink failed for ${rel} (${errMessage(err)})`);
|
|
500
638
|
}
|
|
501
639
|
}
|
|
502
640
|
}
|
|
@@ -509,6 +647,18 @@ try {
|
|
|
509
647
|
// session-start` will spawn a fresh daemon under the current moflo
|
|
510
648
|
// image once 3g writes the version stamp.
|
|
511
649
|
|
|
650
|
+
// Surface per-file copy failures so the user / Claude can see what
|
|
651
|
+
// didn't sync (#854). The file isn't in the manifest either, so the
|
|
652
|
+
// next-upgrade cleanup pass can never reconcile it on its own —
|
|
653
|
+
// direct the user at `flo doctor --fix` as the compensating healer.
|
|
654
|
+
if (syncFailures.length > 0) {
|
|
655
|
+
const sample = syncFailures.slice(0, 5).map((f) => ` - ${f.key}: ${f.message}`).join('\n');
|
|
656
|
+
const more = syncFailures.length > 5 ? `\n …and ${syncFailures.length - 5} more` : '';
|
|
657
|
+
emitWarning(
|
|
658
|
+
`${plural(syncFailures.length, 'file')} failed to sync during upgrade — run 'flo doctor --fix' to repair:\n${sample}${more}`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
512
662
|
// Manifest reflects synced files immediately; version stamp is deferred
|
|
513
663
|
// to 3g so an aborted launcher re-runs upgrade detection (#730).
|
|
514
664
|
try {
|
|
@@ -516,11 +666,19 @@ try {
|
|
|
516
666
|
if (!existsSync(cfDir)) mkdirSync(cfDir, { recursive: true });
|
|
517
667
|
writeFileSync(manifestPath, JSON.stringify(currentManifest, null, 2));
|
|
518
668
|
pendingVersionStampWrite = { path: versionStampPath, version: installedVersion };
|
|
519
|
-
} catch {
|
|
669
|
+
} catch (err) {
|
|
670
|
+
// #854: manifest write must surface — without it the next launcher
|
|
671
|
+
// can't tell what was installed and the version stamp never gets
|
|
672
|
+
// queued for 3g.
|
|
673
|
+
emitWarning(`manifest write failed (${errMessage(err)})`);
|
|
674
|
+
}
|
|
520
675
|
}
|
|
521
676
|
}
|
|
522
|
-
} catch {
|
|
523
|
-
//
|
|
677
|
+
} catch (err) {
|
|
678
|
+
// #854: bare catches here hid upgrade regressions across multiple 4.8.x
|
|
679
|
+
// bumps. We keep the catch so a single throw doesn't crash the launcher,
|
|
680
|
+
// but we never silence it.
|
|
681
|
+
emitWarning(`upgrade section failed (${errMessage(err)})`);
|
|
524
682
|
}
|
|
525
683
|
|
|
526
684
|
// ── 3a-pre. Recycle daemons started before the current moflo install ────────
|
|
@@ -667,14 +825,19 @@ try {
|
|
|
667
825
|
settingsChanges.push(`repaired ${plural(repaired.length, 'hook wiring')}`);
|
|
668
826
|
}
|
|
669
827
|
}
|
|
670
|
-
} catch {
|
|
828
|
+
} catch (err) {
|
|
829
|
+
emitWarning(`hook-wiring repair skipped (${errMessage(err)})`);
|
|
830
|
+
}
|
|
671
831
|
|
|
672
832
|
if (dirty) {
|
|
673
833
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
674
834
|
emitMutation('updated .claude/settings.json', settingsChanges.join(', '));
|
|
675
835
|
}
|
|
676
836
|
}
|
|
677
|
-
} catch {
|
|
837
|
+
} catch (err) {
|
|
838
|
+
// #854: silent fail-loop hid hook breakage — surface so the user can act.
|
|
839
|
+
emitWarning(`settings.json migration failed (${errMessage(err)})`);
|
|
840
|
+
}
|
|
678
841
|
|
|
679
842
|
// ── 3b. Ensure shipped guidance files exist (even without version change) ──
|
|
680
843
|
// Subagents need these files on disk for direct reads without memory search.
|
|
@@ -719,7 +882,9 @@ try {
|
|
|
719
882
|
}
|
|
720
883
|
}
|
|
721
884
|
}
|
|
722
|
-
} catch {
|
|
885
|
+
} catch (err) {
|
|
886
|
+
emitWarning(`shipped guidance restore failed (${errMessage(err)})`);
|
|
887
|
+
}
|
|
723
888
|
|
|
724
889
|
// ── 3b-714. Retire legacy `.swarm/vector-stats.json` parallel write (#714) ─
|
|
725
890
|
// `.moflo/vector-stats.json` is canonical post-#699; pre-#714 builds also
|
|
@@ -909,11 +1074,15 @@ if (upgradeNoticeContext) {
|
|
|
909
1074
|
|
|
910
1075
|
// ── 3g. Commit deferred version stamp (#730) ────────────────────────────────
|
|
911
1076
|
// Written LAST so an abort above leaves the stamp unchanged and the next
|
|
912
|
-
// launcher re-detects the upgrade.
|
|
1077
|
+
// launcher re-detects the upgrade. Failure here is surfaced (#854) so a
|
|
1078
|
+
// permanently-broken stamp write (filesystem permissions, AV holds) doesn't
|
|
1079
|
+
// silently strand consumers in re-detect-on-every-session loops.
|
|
913
1080
|
if (pendingVersionStampWrite) {
|
|
914
1081
|
try {
|
|
915
1082
|
writeFileSync(pendingVersionStampWrite.path, pendingVersionStampWrite.version);
|
|
916
|
-
} catch {
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
emitWarning(`version stamp write failed (${errMessage(err)}) — next launcher will re-detect the upgrade`);
|
|
1085
|
+
}
|
|
917
1086
|
}
|
|
918
1087
|
|
|
919
1088
|
// Bypasses emitMutation — framing, not a mutation, so it must not inflate the count.
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.4",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
|
|
5
5
|
"main": "dist/src/cli/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -39,14 +39,15 @@
|
|
|
39
39
|
"!.claude/**/*.map",
|
|
40
40
|
"README.md",
|
|
41
41
|
"LICENSE",
|
|
42
|
-
"scripts/prune-native-binaries.mjs"
|
|
42
|
+
"scripts/prune-native-binaries.mjs",
|
|
43
|
+
"scripts/post-install-notice.mjs"
|
|
43
44
|
],
|
|
44
45
|
"scripts": {
|
|
45
46
|
"dev": "tsx watch src/cli/index.ts",
|
|
46
47
|
"prebuild": "node scripts/sync-version.mjs && node scripts/clean-dist.mjs",
|
|
47
48
|
"build": "tsc",
|
|
48
49
|
"prepublishOnly": "npm run build",
|
|
49
|
-
"postinstall": "node scripts/prune-native-binaries.mjs",
|
|
50
|
+
"postinstall": "node scripts/prune-native-binaries.mjs && node scripts/post-install-notice.mjs",
|
|
50
51
|
"test": "node scripts/test-runner.mjs",
|
|
51
52
|
"test:ui": "vitest --ui",
|
|
52
53
|
"test:smoke": "node harness/consumer-smoke/run.mjs",
|
|
@@ -79,7 +80,7 @@
|
|
|
79
80
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
80
81
|
"@typescript-eslint/parser": "^7.18.0",
|
|
81
82
|
"eslint": "^8.0.0",
|
|
82
|
-
"moflo": "^4.9.
|
|
83
|
+
"moflo": "^4.9.3",
|
|
83
84
|
"tsx": "^4.21.0",
|
|
84
85
|
"typescript": "^5.9.3",
|
|
85
86
|
"vitest": "^4.0.0"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Postinstall restart-nudge banner.
|
|
4
|
+
*
|
|
5
|
+
* When `npm install` runs inside Claude Code (typically because the user
|
|
6
|
+
* asked Claude to upgrade moflo), the just-installed bits are sitting on
|
|
7
|
+
* disk but the running session still has the OLD launcher, hooks, MCP
|
|
8
|
+
* server, and statusline loaded. The session-start launcher only re-reads
|
|
9
|
+
* them on the NEXT session-start — so the upgrade is inert until the user
|
|
10
|
+
* exits and reopens Claude Code.
|
|
11
|
+
*
|
|
12
|
+
* This script prints a banner that npm relays back to Claude as the install
|
|
13
|
+
* stdout. The phrasing names Claude Code explicitly so the assistant
|
|
14
|
+
* surfaces it to the user as a restart prompt.
|
|
15
|
+
*
|
|
16
|
+
* Gating:
|
|
17
|
+
* - Only prints when CLAUDE_PROJECT_DIR or CLAUDECODE is set (avoids
|
|
18
|
+
* noise on CI and non-Claude installs).
|
|
19
|
+
* - Dedupes by version: only prints once per (consumer-project, version)
|
|
20
|
+
* pair, so unrelated `npm install` runs that re-trigger postinstall
|
|
21
|
+
* don't re-spam the banner. Tracker lives at
|
|
22
|
+
* `<project>/.moflo/last-install-banner.json`.
|
|
23
|
+
*
|
|
24
|
+
* Failure posture: never blocks an install. Errors are swallowed; exit 0.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
28
|
+
import { dirname, join, resolve } from 'node:path';
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
32
|
+
|
|
33
|
+
function isClaudeSession() {
|
|
34
|
+
return Boolean(process.env.CLAUDE_PROJECT_DIR || process.env.CLAUDECODE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function consumerProjectRoot() {
|
|
38
|
+
// npm sets INIT_CWD to the original directory where the user ran `npm
|
|
39
|
+
// install` — the consumer's project root, regardless of which package's
|
|
40
|
+
// postinstall is running.
|
|
41
|
+
return process.env.INIT_CWD || process.cwd();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function installedVersion() {
|
|
45
|
+
// package.json sits one level above scripts/.
|
|
46
|
+
const pkgPath = resolve(dirname(SCRIPT_PATH), '..', 'package.json');
|
|
47
|
+
if (!existsSync(pkgPath)) return null;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(readFileSync(pkgPath, 'utf-8')).version || null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readLastBanner(trackerPath) {
|
|
56
|
+
if (!existsSync(trackerPath)) return null;
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(trackerPath, 'utf-8')).version || null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeLastBanner(trackerPath, version) {
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(dirname(trackerPath), { recursive: true });
|
|
67
|
+
writeFileSync(trackerPath, JSON.stringify({
|
|
68
|
+
version,
|
|
69
|
+
shownAt: new Date().toISOString(),
|
|
70
|
+
}, null, 2));
|
|
71
|
+
} catch { /* non-fatal */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function printBanner(version) {
|
|
75
|
+
// Phrased to name Claude Code by name so the assistant surfaces it as a
|
|
76
|
+
// restart instruction rather than a generic install log line.
|
|
77
|
+
const lines = [
|
|
78
|
+
'',
|
|
79
|
+
'═══════════════════════════════════════════════════════════════════',
|
|
80
|
+
` MoFlo ${version} installed.`,
|
|
81
|
+
'',
|
|
82
|
+
' ⚠ Claude Code: please restart your session to load the new MoFlo.',
|
|
83
|
+
'',
|
|
84
|
+
' Hooks, MCP tools, statusline, and the session-start launcher are',
|
|
85
|
+
' loaded once at session-start — the running session is still on',
|
|
86
|
+
' the previous moflo until you exit and reopen Claude Code.',
|
|
87
|
+
'═══════════════════════════════════════════════════════════════════',
|
|
88
|
+
'',
|
|
89
|
+
];
|
|
90
|
+
process.stdout.write(lines.join('\n'));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function run() {
|
|
94
|
+
if (!isClaudeSession()) return { fired: false, reason: 'not-claude' };
|
|
95
|
+
|
|
96
|
+
const version = installedVersion();
|
|
97
|
+
if (!version) return { fired: false, reason: 'no-version' };
|
|
98
|
+
|
|
99
|
+
const projectRoot = consumerProjectRoot();
|
|
100
|
+
const trackerPath = join(projectRoot, '.moflo', 'last-install-banner.json');
|
|
101
|
+
const lastShown = readLastBanner(trackerPath);
|
|
102
|
+
if (lastShown === version) return { fired: false, reason: 'already-shown' };
|
|
103
|
+
|
|
104
|
+
printBanner(version);
|
|
105
|
+
writeLastBanner(trackerPath, version);
|
|
106
|
+
return { fired: true, version };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
110
|
+
try {
|
|
111
|
+
run();
|
|
112
|
+
} catch { /* never block install */ }
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|