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.
@@ -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:
@@ -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: 5000 });
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 { /* already gone */ }
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 { /* non-fatal — use defaults (all true) */ }
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, even
260
- // when version stamp matches. Guards against out-of-band deletions (manual
261
- // rm, botched merges, dedup commits, etc.) that would otherwise silently
262
- // leave .claude/scripts/ incomplete until the next moflo upgrade.
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
- let manifestDrifted = false;
265
- try {
266
- const prev = JSON.parse(readFileSync(manifestPath, 'utf-8'));
267
- if (Array.isArray(prev)) {
268
- manifestDrifted = prev.some(f => !existsSync(resolve(projectRoot, f)));
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
- } catch { /* no manifest yet — version check handles first install */ }
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
- let previousManifest = [];
371
- try { previousManifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); } catch { /* ok */ }
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
- function syncFile(src, dest, manifestKey) {
378
- if (existsSync(src)) {
379
- try { copyFileSync(src, dest); currentManifest.push(manifestKey); } catch { /* non-fatal */ }
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 { /* non-fatal */ }
428
- syncFile(absSrc, absDest, `.claude/scripts/migrations/${rel}`);
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
- try { copyFileSync(src, dest); currentManifest.push(`.claude/helpers/${file}`); } catch { /* non-fatal */ }
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
- currentManifest.push(`.claude/guidance/${file}`);
613
+ recordManifestEntry(`.claude/guidance/${file}`, dest);
481
614
  }
482
- } catch { /* non-fatal */ }
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 (!currentSet.has(rel)) {
493
- const abs = resolve(projectRoot, rel);
494
- try {
495
- if (existsSync(abs)) {
496
- unlinkSync(abs);
497
- removedFiles++;
498
- }
499
- } catch { /* non-fatal */ }
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
- // Non-fatal scripts will still work, just may be stale
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 { /* non-fatal — doctor can still fix later */ }
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 { /* non-fatal — stale hooks won't block session, just emit warnings */ }
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 { /* non-fatal */ }
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 { /* non-fatal — next launcher re-detects + retries the upgrade */ }
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.
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.9.2';
5
+ export const VERSION = '4.9.4';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.2",
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.1",
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
+ }