moflo 4.9.37 → 4.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.claude/guidance/shipped/moflo-memory-protocol.md +5 -1
  2. package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
  3. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
  4. package/.claude/helpers/statusline.cjs +69 -33
  5. package/.claude/helpers/subagent-bootstrap.json +1 -1
  6. package/.claude/helpers/subagent-start.cjs +1 -1
  7. package/bin/build-embeddings.mjs +6 -20
  8. package/bin/cli.js +5 -0
  9. package/bin/generate-code-map.mjs +4 -24
  10. package/bin/hooks.mjs +3 -12
  11. package/bin/index-all.mjs +3 -13
  12. package/bin/index-guidance.mjs +36 -85
  13. package/bin/index-patterns.mjs +6 -24
  14. package/bin/index-tests.mjs +4 -23
  15. package/bin/lib/db-repair.mjs +358 -62
  16. package/bin/lib/get-backend.mjs +306 -0
  17. package/bin/lib/incremental-write.mjs +27 -7
  18. package/bin/lib/moflo-paths.mjs +64 -4
  19. package/bin/lib/suppress-sqlite-warning.mjs +57 -0
  20. package/bin/migrations/knowledge-purge.mjs +7 -8
  21. package/bin/migrations/knowledge-to-learnings.mjs +7 -9
  22. package/bin/migrations/purge-doc-entries.mjs +7 -8
  23. package/bin/migrations/strip-context-preambles.mjs +4 -6
  24. package/bin/run-migrations.mjs +1 -10
  25. package/bin/semantic-search.mjs +7 -18
  26. package/bin/session-start-launcher.mjs +144 -108
  27. package/bin/simplify-classify.cjs +38 -17
  28. package/dist/src/cli/commands/daemon.js +38 -11
  29. package/dist/src/cli/commands/doctor-checks-config.js +60 -0
  30. package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
  31. package/dist/src/cli/commands/doctor-checks-memory-access.js +146 -86
  32. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  33. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  34. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  35. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  36. package/dist/src/cli/commands/doctor-fixes.js +87 -0
  37. package/dist/src/cli/commands/doctor-registry.js +24 -1
  38. package/dist/src/cli/commands/doctor.js +1 -1
  39. package/dist/src/cli/commands/embeddings.js +17 -22
  40. package/dist/src/cli/commands/memory.js +13 -23
  41. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  42. package/dist/src/cli/init/moflo-init.js +40 -0
  43. package/dist/src/cli/mcp-tools/memory-tools.js +10 -3
  44. package/dist/src/cli/memory/bridge-core.js +256 -30
  45. package/dist/src/cli/memory/bridge-embedder.js +84 -3
  46. package/dist/src/cli/memory/bridge-entries.js +70 -6
  47. package/dist/src/cli/memory/controller-registry.js +7 -2
  48. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  49. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  50. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  51. package/dist/src/cli/memory/daemon-backend.js +400 -0
  52. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  53. package/dist/src/cli/memory/database-provider.js +57 -40
  54. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  55. package/dist/src/cli/memory/index.js +0 -1
  56. package/dist/src/cli/memory/memory-bridge.js +40 -8
  57. package/dist/src/cli/memory/memory-initializer.js +271 -211
  58. package/dist/src/cli/memory/rvf-migration.js +25 -11
  59. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  60. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  61. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  62. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  63. package/dist/src/cli/services/daemon-lock.js +58 -1
  64. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  65. package/dist/src/cli/services/embeddings-migration.js +9 -12
  66. package/dist/src/cli/services/ephemeral-namespace-purge.js +21 -16
  67. package/dist/src/cli/services/learning-service.js +12 -20
  68. package/dist/src/cli/services/memory-db-integrity-repair.js +119 -0
  69. package/dist/src/cli/services/project-root.js +69 -9
  70. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  71. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  72. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  73. package/dist/src/cli/shared/events/event-store.js +26 -55
  74. package/dist/src/cli/version.js +1 -1
  75. package/package.json +2 -4
  76. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -17,23 +17,12 @@
17
17
  * flo-search "query" --threshold 0.3
18
18
  */
19
19
 
20
- import { existsSync, readFileSync } from 'fs';
21
- import { resolve, dirname } from 'path';
22
- import { mofloResolveURL, mofloInternalURL } from './lib/moflo-resolve.mjs';
23
- import { memoryDbPath } from './lib/moflo-paths.mjs';
24
- const initSqlJs = (await import(mofloResolveURL('sql.js'))).default;
20
+ import { existsSync } from 'fs';
21
+ import { mofloInternalURL } from './lib/moflo-resolve.mjs';
22
+ import { memoryDbPath, findProjectRoot } from './lib/moflo-paths.mjs';
23
+ import { openBackend } from './lib/get-backend.mjs';
25
24
  const FASTEMBED_INLINE = 'dist/src/cli/embeddings/fastembed-inline/index.js';
26
25
 
27
- function findProjectRoot() {
28
- let dir = process.cwd();
29
- const root = resolve(dir, '/');
30
- while (dir !== root) {
31
- if (existsSync(resolve(dir, 'package.json'))) return dir;
32
- dir = dirname(dir);
33
- }
34
- return process.cwd();
35
- }
36
-
37
26
  const projectRoot = findProjectRoot();
38
27
  const DB_PATH = memoryDbPath(projectRoot);
39
28
 
@@ -108,9 +97,9 @@ async function getDb() {
108
97
  if (!existsSync(DB_PATH)) {
109
98
  throw new Error(`Database not found: ${DB_PATH}`);
110
99
  }
111
- const SQL = await initSqlJs();
112
- const buffer = readFileSync(DB_PATH);
113
- return new SQL.Database(buffer);
100
+ // Read-only: search must never trigger WAL creation in a freshly-cloned
101
+ // consumer repo, and the factory guarantees the same shape across engines.
102
+ return openBackend(projectRoot, { create: false, readOnly: true });
114
103
  }
115
104
 
116
105
  async function semanticSearch(queryText, options = {}) {
@@ -11,7 +11,7 @@ import { spawn, execFileSync } from 'child_process';
11
11
  import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, mkdirSync, statSync } from 'fs';
12
12
  import { resolve, dirname, join } from 'path';
13
13
  import { fileURLToPath, pathToFileURL } from 'url';
14
- import { mofloDir } from './lib/moflo-paths.mjs';
14
+ import { mofloDir, findProjectRoot } from './lib/moflo-paths.mjs';
15
15
  import { repairMemoryDbIfCorrupt } from './lib/db-repair.mjs';
16
16
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
17
17
  import { applyRetiredPrune } from './lib/retired-files.mjs';
@@ -39,20 +39,13 @@ function sessionStartMirrorHeader(file) {
39
39
  return `${SESSION_START_MIRROR_MARKER} Do not edit — changes will be overwritten. -->\n<!-- Source: node_modules/moflo/.claude/guidance/shipped/${file} -->\n\n`;
40
40
  }
41
41
 
42
- // Detect project root by walking up from cwd to find package.json.
43
42
  // IMPORTANT: Do NOT use resolve(__dirname, '..') or '../..' — this script lives
44
43
  // in bin/ during development but gets synced to .claude/scripts/ in consumer
45
- // projects, so __dirname-relative paths break. findProjectRoot() works everywhere.
46
- function findProjectRoot() {
47
- let dir = process.cwd();
48
- const root = resolve(dir, '/');
49
- while (dir !== root) {
50
- if (existsSync(resolve(dir, 'package.json'))) return dir;
51
- dir = dirname(dir);
52
- }
53
- return process.cwd();
54
- }
55
-
44
+ // projects, so __dirname-relative paths break. findProjectRoot() (lib/moflo-
45
+ // paths.mjs) resolves identically to the TS bridge (#1057): CLAUDE_PROJECT_DIR
46
+ // first, then walk up for .moflo/moflo.db / .swarm/memory.db / CLAUDE.md+pkg /
47
+ // package.json / .git. Inline walks here have caused N writers to land on
48
+ // different DBs than the bridge reads from — never reintroduce one.
56
49
  const projectRoot = findProjectRoot();
57
50
 
58
51
  // Dogfood guard (#928). When this launcher runs inside the moflo repo itself,
@@ -275,15 +268,51 @@ try {
275
268
  try {
276
269
  const repair = await repairMemoryDbIfCorrupt(projectRoot);
277
270
  if (repair?.repaired) {
278
- emitMutation(
279
- 'repaired memory db index',
280
- `${plural(repair.errors, 'index error')} fixed via REINDEX`,
281
- );
271
+ // Three recovery tiers, three messages. Tier surfaces what level of
272
+ // damage the DB had so the user (and any downstream telemetry) knows
273
+ // whether row data was lost. See bin/lib/db-repair.mjs for the cascade.
274
+ if (repair.tier === 'reindex') {
275
+ emitMutation(
276
+ 'repaired memory db index',
277
+ `${plural(repair.errors, 'index error')} fixed via REINDEX`,
278
+ );
279
+ } else if (repair.tier === 'vacuum') {
280
+ emitMutation(
281
+ 'rebuilt memory db',
282
+ `${plural(repair.errors, 'integrity violation')} fixed via VACUUM INTO; corrupt original kept at ${repair.corruptBackup ?? '.moflo/moflo.db.corrupt.*'}`,
283
+ );
284
+ } else if (repair.tier === 'salvage') {
285
+ // Row-level salvage may have dropped rows; summarise loss so the
286
+ // user sees what's gone before downstream consumers (indexer,
287
+ // embeddings) re-process the survivors.
288
+ let lossSummary = '';
289
+ if (repair.lossStats) {
290
+ const losses = Object.entries(repair.lossStats)
291
+ .map(([tbl, s]) => {
292
+ const lost = Math.max(0, s.read - s.written);
293
+ return lost > 0 ? `${tbl} ${s.written}/${s.read}` : null;
294
+ })
295
+ .filter(Boolean);
296
+ if (losses.length > 0) lossSummary = ` (rows preserved: ${losses.join(', ')})`;
297
+ }
298
+ emitMutation(
299
+ 'salvaged memory db',
300
+ `${plural(repair.errors, 'integrity violation')} recovered via row-level salvage${lossSummary}; corrupt original kept at ${repair.corruptBackup ?? '.moflo/moflo.db.corrupt.*'}`,
301
+ );
302
+ } else {
303
+ // Older db-repair without a `tier` field — fall back to legacy text.
304
+ emitMutation(
305
+ 'repaired memory db',
306
+ `${plural(repair.errors, 'integrity violation')} fixed`,
307
+ );
308
+ }
282
309
  } else if (repair?.persistent) {
283
310
  // Surface to stderr — Claude additionalContext + the user both see this.
284
- // Manual `flo memory rebuild-index` is the next step.
311
+ // Every recovery tier exhausted; user options are destructive only.
285
312
  process.stderr.write(
286
- `moflo: memory db has ${plural(repair.errors, 'index error')} REINDEX could not fix — run 'flo memory rebuild-index'\n`,
313
+ `moflo: memory db has ${plural(repair.errors, 'integrity violation')} ` +
314
+ `that REINDEX / VACUUM INTO / row-level salvage could not fix — ` +
315
+ `run 'flo memory rebuild-index' (destructive) or restore from backup\n`,
287
316
  );
288
317
  }
289
318
  } catch {
@@ -884,37 +913,42 @@ try {
884
913
  // longer exists in source, calling a require helper that prints the warning
885
914
  // every time `neural_predict` / `neural_patterns` fires.
886
915
  //
887
- // Fix: compare the daemon-lock's `startedAt` against `node_modules/moflo/`'s
888
- // install mtime. If the daemon predates the current install, recycle it. The
889
- // install mtime is a stable proxy because npm rewrites the package.json on
890
- // every `npm install`, even when the resolved version is unchanged.
916
+ // Fix (epic #1054): compare the daemon-lock's reported moflo `version` against
917
+ // the installed `node_modules/moflo/package.json` version. If they differ
918
+ // or the lock predates #1054 and has no `version` field at all — recycle the
919
+ // daemon. This is exact (not a heuristic margin like the prior mtime-based
920
+ // check) and named explicitly so the doctor's Daemon Version Skew check
921
+ // (#1059) can share the diagnosis.
891
922
  //
892
- // Margin absorbs clock skew between npm's mtime write and the daemon-lock
893
- // `startedAt` clock within this window the daemon is likely the post-install
894
- // daemon, not a stale predecessor.
895
- const STALE_DAEMON_MTIME_SKEW_MS = 5_000;
923
+ // Pre-#1054 daemons have no `version` in their lock payload treated as a
924
+ // mismatch by definition because by construction they were launched before
925
+ // version publishing existed.
896
926
  try {
897
927
  const mofloPkgPathForRecycle = resolve(projectRoot, 'node_modules/moflo/package.json');
898
928
  const lockFile = resolve(projectRoot, '.moflo', 'daemon.lock');
899
- // Cheap stat first — if the daemon-lock or package.json is gone we're done.
900
- // statSync throws ENOENT on a missing file; the outer catch absorbs it.
901
- const installedAt = statSync(mofloPkgPathForRecycle).mtimeMs;
902
- const lockMtime = statSync(lockFile).mtimeMs;
903
- // Quick reject: if the lock file itself is younger than the install, the
904
- // daemon was started after install — no read of lock contents needed.
905
- if (installedAt - lockMtime > STALE_DAEMON_MTIME_SKEW_MS) {
906
- let daemonStartedAt = 0;
929
+ // Cheap stat first — if either file is gone, no skew check is possible.
930
+ if (existsSync(mofloPkgPathForRecycle) && existsSync(lockFile)) {
931
+ const installedVersion = JSON.parse(readFileSync(mofloPkgPathForRecycle, 'utf-8')).version;
932
+ let daemonVersion;
907
933
  try {
908
934
  const lock = JSON.parse(readFileSync(lockFile, 'utf-8'));
909
- if (typeof lock?.startedAt === 'number') daemonStartedAt = lock.startedAt;
910
- } catch { /* corrupt lock — fall through, recycleDaemon will unlink it */ }
911
- if (daemonStartedAt > 0 && (installedAt - daemonStartedAt) > STALE_DAEMON_MTIME_SKEW_MS) {
912
- if (recycleDaemon(lockFile, 'daemon-stale-recycle')) {
913
- emitMutation('recycled stale daemon', 'predates current install');
935
+ if (typeof lock?.version === 'string') daemonVersion = lock.version;
936
+ } catch { /* corrupt lock — recycleDaemon will unlink it */ }
937
+ if (daemonVersion !== installedVersion) {
938
+ if (recycleDaemon(lockFile, 'daemon-version-skew')) {
939
+ const observed = daemonVersion ?? '<pre-1054 / unknown>';
940
+ emitMutation(
941
+ 'recycled stale daemon',
942
+ `version skew: installed ${installedVersion}, daemon ${observed}`,
943
+ );
914
944
  }
915
945
  }
916
946
  }
917
- } catch { /* non-fatal — best-effort stale-daemon detection */ }
947
+ } catch (err) {
948
+ // Non-fatal; surface via emitWarning per feedback_no_layered_workarounds —
949
+ // no silent catch on the upgrade path (#854).
950
+ emitWarning(`daemon version-skew check failed: ${errMessage(err)}`);
951
+ }
918
952
 
919
953
  // ── 3a. Auto-migrate settings.json (npx flo → node helpers, PATH setup) ────
920
954
  // Existing users may have stale settings.json with `npx flo` hooks that break
@@ -1490,6 +1524,75 @@ try {
1490
1524
  } catch { /* writing the failure itself must not throw */ }
1491
1525
  }
1492
1526
 
1527
+ // ── 3e-1057. Run unmet schema migrations BEFORE daemon spawn ────────────────
1528
+ // run-migrations.mjs walks `bin/migrations/*.mjs` and invokes each that has
1529
+ // not been recorded in `.moflo/migrations.json`. Each migration opens sql.js
1530
+ // directly and persists with atomicWriteFileSync. They MUST run before the
1531
+ // daemon spawns or the daemon's in-RAM snapshot races their on-disk writes
1532
+ // — the bug class S2 detects (#1056 version-skew kill) and S3 (#1057)
1533
+ // closes for good. Pre-#1057 this ran in Section 4 AFTER `hooks session-
1534
+ // start` fired off the daemon, which is exactly the race fixed here.
1535
+ //
1536
+ // Synchronous on purpose: blocking by ≤30s on a one-time migration is the
1537
+ // right trade vs. an inconsistent post-upgrade DB. The runner short-circuits
1538
+ // to a no-op when nothing is pending, so steady-state cost is just the node
1539
+ // startup + ESM graph (~80ms on a warm fs).
1540
+ const runMigrations = resolveMofloBin(projectRoot, null, 'run-migrations.mjs');
1541
+ if (runMigrations) {
1542
+ runMigrationsAndAnnounce(runMigrations);
1543
+ }
1544
+
1545
+ function runMigrationsAndAnnounce(runnerPath) {
1546
+ let raw;
1547
+ try {
1548
+ raw = execFileSync('node', [runnerPath], {
1549
+ cwd: projectRoot,
1550
+ timeout: 30_000,
1551
+ encoding: 'utf-8',
1552
+ stdio: ['ignore', 'pipe', 'inherit'],
1553
+ });
1554
+ } catch (err) {
1555
+ // Migrations are best-effort — a failure here must never block session
1556
+ // start. But silent swallowing hides hangs (30s timeout) and corrupted
1557
+ // DBs from the user, so leave a stderr crumb.
1558
+ process.stderr.write(`moflo: migration runner failed (${err.code || err.message}); will retry next session\n`);
1559
+ return;
1560
+ }
1561
+
1562
+ const labels = {
1563
+ 'knowledge-to-learnings': 'consolidated knowledge → learnings',
1564
+ 'knowledge-purge': 'removed legacy knowledge namespace rows',
1565
+ 'purge-doc-entries': 'pruned legacy doc-* rows (chunk-only RAG, #1053)',
1566
+ 'strip-context-preambles': 'stripped chunk preambles; embeddings will rebuild on next index pass (#1053)',
1567
+ };
1568
+
1569
+ for (const line of raw.split(/\r?\n/)) {
1570
+ const m = line.match(/^\[migrations\]\s+([\w-]+):\s+done\s+in\s+\d+ms\s*(.*)$/);
1571
+ if (!m) continue;
1572
+ const migrationName = m[1];
1573
+ let parsed = null;
1574
+ try { parsed = m[2] ? JSON.parse(m[2]) : null; } catch { parsed = null; }
1575
+
1576
+ // Silent fast-path: don't announce zero-work runs (no point telling the
1577
+ // user the launcher did nothing). If every numeric detail field is 0,
1578
+ // skip the emit. Stamped migrations don't even reach this loop because
1579
+ // the runner short-circuits via the manifest.
1580
+ if (parsed) {
1581
+ const nums = Object.values(parsed).filter((v) => typeof v === 'number');
1582
+ if (nums.length > 0 && nums.every((v) => v === 0)) continue;
1583
+ }
1584
+
1585
+ let detail = '';
1586
+ if (parsed) {
1587
+ if (typeof parsed.purged === 'number') detail = `${parsed.purged} ${parsed.purged === 1 ? 'row' : 'rows'}`;
1588
+ else if (typeof parsed.rowsMigrated === 'number') detail = `${parsed.rowsMigrated} ${parsed.rowsMigrated === 1 ? 'entry' : 'entries'}`;
1589
+ }
1590
+
1591
+ const label = labels[migrationName] || `migration ${migrationName}`;
1592
+ emitMutation(label, detail);
1593
+ }
1594
+ }
1595
+
1493
1596
  // ── 3f. Flip the upgrade notice to "completed" (#636, #738) ─────────────────
1494
1597
  // See the TTL rationale at the constants above for why we switch to a
1495
1598
  // short-TTL completed badge instead of clearing the file.
@@ -1569,73 +1672,6 @@ if (hooksScript) {
1569
1672
  fireAndForget('node', [hooksScript, 'session-start'], 'hooks session-start');
1570
1673
  }
1571
1674
 
1572
- // Migration runner — consults `.moflo/migrations.json` and runs only
1573
- // migrations that haven't been recorded. Fast-paths to a no-op when the
1574
- // manifest is current; the runner module loads with lazy sql.js init in
1575
- // each migration, so a stamped session pays only node startup + ESM graph.
1576
- //
1577
- // Prefer the npm-package path so first-install consumers run unmet
1578
- // migrations without waiting for a script-sync round-trip.
1579
- //
1580
- // Run synchronously (capture stdout) so each completed migration surfaces
1581
- // through emitMutation — Claude's session-start hook captures launcher
1582
- // stdout and that's the only channel that reaches the user.
1583
- const runMigrations = resolveMofloBin(projectRoot, null, 'run-migrations.mjs');
1584
- if (runMigrations) {
1585
- runMigrationsAndAnnounce(runMigrations);
1586
- }
1587
-
1588
- function runMigrationsAndAnnounce(runnerPath) {
1589
- let raw;
1590
- try {
1591
- raw = execFileSync('node', [runnerPath], {
1592
- cwd: projectRoot,
1593
- timeout: 30_000,
1594
- encoding: 'utf-8',
1595
- stdio: ['ignore', 'pipe', 'inherit'],
1596
- });
1597
- } catch (err) {
1598
- // Migrations are best-effort — a failure here must never block session
1599
- // start. But silent swallowing hides hangs (30s timeout) and corrupted
1600
- // DBs from the user, so leave a stderr crumb.
1601
- process.stderr.write(`moflo: migration runner failed (${err.code || err.message}); will retry next session\n`);
1602
- return;
1603
- }
1604
-
1605
- const labels = {
1606
- 'knowledge-to-learnings': 'consolidated knowledge → learnings',
1607
- 'knowledge-purge': 'removed legacy knowledge namespace rows',
1608
- 'purge-doc-entries': 'pruned legacy doc-* rows (chunk-only RAG, #1053)',
1609
- 'strip-context-preambles': 'stripped chunk preambles; embeddings will rebuild on next index pass (#1053)',
1610
- };
1611
-
1612
- for (const line of raw.split('\n')) {
1613
- const m = line.match(/^\[migrations\]\s+([\w-]+):\s+done\s+in\s+\d+ms\s*(.*)$/);
1614
- if (!m) continue;
1615
- const migrationName = m[1];
1616
- let parsed = null;
1617
- try { parsed = m[2] ? JSON.parse(m[2]) : null; } catch { parsed = null; }
1618
-
1619
- // Silent fast-path: don't announce zero-work runs (no point telling the
1620
- // user the launcher did nothing). If every numeric detail field is 0,
1621
- // skip the emit. Stamped migrations don't even reach this loop because
1622
- // the runner short-circuits via the manifest.
1623
- if (parsed) {
1624
- const nums = Object.values(parsed).filter((v) => typeof v === 'number');
1625
- if (nums.length > 0 && nums.every((v) => v === 0)) continue;
1626
- }
1627
-
1628
- let detail = '';
1629
- if (parsed) {
1630
- if (typeof parsed.purged === 'number') detail = `${parsed.purged} ${parsed.purged === 1 ? 'row' : 'rows'}`;
1631
- else if (typeof parsed.rowsMigrated === 'number') detail = `${parsed.rowsMigrated} ${parsed.rowsMigrated === 1 ? 'entry' : 'entries'}`;
1632
- }
1633
-
1634
- const label = labels[migrationName] || `migration ${migrationName}`;
1635
- emitMutation(label, detail);
1636
- }
1637
- }
1638
-
1639
1675
  // Patches are now baked into moflo@4.0.0 source — no runtime patching needed.
1640
1676
 
1641
1677
  // ── 5. Done — exit immediately ──────────────────────────────────────────────
@@ -45,34 +45,55 @@ const SECURITY_PATHS = [
45
45
  /(?:^|[\\\/])\.claude[\\\/]helpers[\\\/]gate/i,
46
46
  ];
47
47
 
48
- function safeExec(cmd) {
49
- try { return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); }
50
- catch { return ''; }
48
+ function safeExec(cmd, opts) {
49
+ try {
50
+ return execSync(cmd, {
51
+ encoding: 'utf-8',
52
+ stdio: ['pipe', 'pipe', 'pipe'],
53
+ ...(opts && opts.cwd ? { cwd: opts.cwd } : {}),
54
+ });
55
+ } catch { return ''; }
51
56
  }
52
57
 
53
58
  // Detect the consumer's default branch. Hardcoding 'main' silently miscalibrates
54
59
  // classification on repos that use 'master', 'develop', etc. — empty diff →
55
60
  // TRIVIAL → gate stamps clean without any real review.
56
61
  let _cachedDefaultBranch = null;
57
- function detectDefaultBranch() {
58
- if (_cachedDefaultBranch !== null) return _cachedDefaultBranch;
62
+ function detectDefaultBranch(cwd) {
63
+ // Cache by cwd so tests probing multiple repos in-process don't return a
64
+ // single stale value; CLI use passes no cwd and benefits from the cache.
65
+ if (cwd === undefined && _cachedDefaultBranch !== null) return _cachedDefaultBranch;
66
+ const opts = cwd ? { cwd } : undefined;
59
67
 
60
68
  // Preferred: origin/HEAD points to whatever the remote considers default.
61
- const symbolic = safeExec('git symbolic-ref --short refs/remotes/origin/HEAD').trim();
62
- if (symbolic.startsWith('origin/')) return (_cachedDefaultBranch = symbolic.slice('origin/'.length));
69
+ const symbolic = safeExec('git symbolic-ref --short refs/remotes/origin/HEAD', opts).trim();
70
+ if (symbolic.startsWith('origin/')) {
71
+ const v = symbolic.slice('origin/'.length);
72
+ if (cwd === undefined) _cachedDefaultBranch = v;
73
+ return v;
74
+ }
63
75
 
64
76
  // Fallback: local init.defaultBranch (set by `git init -b <name>` or config).
65
- const configured = safeExec('git config --get init.defaultBranch').trim();
66
- if (configured) return (_cachedDefaultBranch = configured);
77
+ const configured = safeExec('git config --get init.defaultBranch', opts).trim();
78
+ if (configured) {
79
+ if (cwd === undefined) _cachedDefaultBranch = configured;
80
+ return configured;
81
+ }
67
82
 
68
83
  // Last resort: 'main' (most common modern default).
69
- return (_cachedDefaultBranch = 'main');
84
+ if (cwd === undefined) _cachedDefaultBranch = 'main';
85
+ return 'main';
86
+ }
87
+
88
+ function _resetCacheForTest() {
89
+ _cachedDefaultBranch = null;
70
90
  }
71
91
 
72
- function readDiffFromGit(base) {
92
+ function readDiffFromGit(base, cwd) {
93
+ const opts = cwd ? { cwd } : undefined;
73
94
  // Combined diff: committed-since-base + working-tree
74
- const committed = safeExec(`git diff ${base}...HEAD`);
75
- const working = safeExec('git diff HEAD');
95
+ const committed = safeExec(`git diff ${base}...HEAD`, opts);
96
+ const working = safeExec('git diff HEAD', opts);
76
97
  return committed + (working ? '\n' + working : '');
77
98
  }
78
99
 
@@ -206,9 +227,9 @@ function classifyDiff(diffText) {
206
227
  return decide(parseDiff(diffText));
207
228
  }
208
229
 
209
- function classifyFromGit(base) {
210
- const resolved = base || detectDefaultBranch();
211
- return classifyDiff(readDiffFromGit(resolved));
230
+ function classifyFromGit(base, cwd) {
231
+ const resolved = base || detectDefaultBranch(cwd);
232
+ return classifyDiff(readDiffFromGit(resolved, cwd));
212
233
  }
213
234
 
214
235
  if (require.main === module) {
@@ -232,4 +253,4 @@ if (require.main === module) {
232
253
  }
233
254
  }
234
255
 
235
- module.exports = { parseDiff, decide, classifyDiff, classifyFromGit, detectDefaultBranch };
256
+ module.exports = { parseDiff, decide, classifyDiff, classifyFromGit, detectDefaultBranch, _resetCacheForTest };
@@ -14,6 +14,32 @@ import { spawn, execFileSync } from 'child_process';
14
14
  import { join, resolve } from 'path';
15
15
  import * as fs from 'fs';
16
16
  import { errorDetail } from '../shared/utils/error-detail.js';
17
+ /**
18
+ * Resolve the dashboard port from CLI flag and env, in that precedence order.
19
+ *
20
+ * Precedence (highest first):
21
+ * 1. `--dashboard-port` flag (explicit caller intent)
22
+ * 2. `MOFLO_DAEMON_PORT` env (shared contract with `daemon-write-client.ts`)
23
+ * 3. `DEFAULT_DASHBOARD_PORT` (3117)
24
+ *
25
+ * The env fallback (#1067) eliminates the client/server asymmetry: prior to
26
+ * this, the client honored `MOFLO_DAEMON_PORT` but the server only read
27
+ * `--dashboard-port`. A consumer pinning the env (e.g. the smoke harness)
28
+ * would point clients at one port while the server bound the default.
29
+ *
30
+ * Exported for unit testing — the command handler calls this once per start.
31
+ */
32
+ export function resolveDashboardPort(flagValue, envValue) {
33
+ const source = flagValue ?? envValue;
34
+ if (!source)
35
+ return { ok: true, port: DEFAULT_DASHBOARD_PORT };
36
+ const parsed = parseInt(source, 10);
37
+ if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
38
+ const label = flagValue ? 'dashboard port' : 'MOFLO_DAEMON_PORT';
39
+ return { ok: false, error: `Invalid ${label}: ${source} (must be 1-65535)` };
40
+ }
41
+ return { ok: true, port: parsed };
42
+ }
17
43
  // Start daemon subcommand
18
44
  const startCommand = {
19
45
  name: 'start',
@@ -43,16 +69,13 @@ const startCommand = {
43
69
  const rawDashboardPort = ctx.flags.dashboardPort;
44
70
  const projectRoot = process.cwd();
45
71
  const isDaemonProcess = process.env.CLAUDE_FLOW_DAEMON === '1';
46
- // Parse dashboard port
47
- let dashboardPort = DEFAULT_DASHBOARD_PORT;
48
- if (rawDashboardPort) {
49
- const parsed = parseInt(rawDashboardPort, 10);
50
- if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
51
- output.printError(`Invalid dashboard port: ${rawDashboardPort} (must be 1-65535)`);
52
- return { success: false, exitCode: 1 };
53
- }
54
- dashboardPort = parsed;
72
+ // Resolve dashboard port; see `resolveDashboardPort` for precedence.
73
+ const portResult = resolveDashboardPort(rawDashboardPort, process.env.MOFLO_DAEMON_PORT);
74
+ if (!portResult.ok) {
75
+ output.printError(portResult.error);
76
+ return { success: false, exitCode: 1 };
55
77
  }
78
+ const dashboardPort = portResult.port;
56
79
  // Parse resource threshold overrides from CLI flags
57
80
  const config = {};
58
81
  const rawMaxCpu = ctx.flags.maxCpuLoad;
@@ -433,9 +456,13 @@ const stopCommand = {
433
456
  },
434
457
  };
435
458
  /**
436
- * Kill background daemon process using lock file
459
+ * Kill background daemon process using lock file.
460
+ *
461
+ * Exported so `memory init --force` can stop the daemon before unlinking
462
+ * moflo.db — on Windows the daemon's open file handle otherwise blocks
463
+ * unlinkSync with EBUSY (#1098).
437
464
  */
438
- async function killBackgroundDaemon(projectRoot) {
465
+ export async function killBackgroundDaemon(projectRoot) {
439
466
  const holderPid = getDaemonLockHolder(projectRoot);
440
467
  if (!holderPid) {
441
468
  // No live daemon — clean up any stale lock
@@ -8,6 +8,7 @@ import { join } from 'path';
8
8
  import os from 'os';
9
9
  import { getDaemonLockHolder } from '../services/daemon-lock.js';
10
10
  import { legacyMemoryDbPath, memoryDbCandidatePaths, memoryDbPath, } from '../services/moflo-paths.js';
11
+ import { probeDbIntegrity } from '../services/memory-db-integrity-repair.js';
11
12
  import { errorDetail } from '../shared/utils/error-detail.js';
12
13
  export async function checkConfigFile() {
13
14
  // JSON configs (parse-validated). LEGACY-CONFIG: `.claude-flow.json` and
@@ -131,6 +132,65 @@ export async function checkMemoryDatabase() {
131
132
  }
132
133
  return { name: 'Memory Database', status: 'warn', message: 'Not initialized', fix: 'claude-flow memory configure --backend hybrid' };
133
134
  }
135
+ /**
136
+ * Tier-1 corruption probe for `.moflo/moflo.db`. Runs `PRAGMA integrity_check`
137
+ * via a raw node:sqlite readonly handle — bypasses `openBackend` because that
138
+ * path sets WAL pragmas on open and those throw on deeply-corrupt files,
139
+ * masking the real failure as a generic "Check" error (doctor.ts:214).
140
+ *
141
+ * Owns the corruption signal so downstream checks (Embeddings, Semantic
142
+ * Quality, Memory Access Functional, etc.) don't end up doing it implicitly
143
+ * via their own swallow-all error paths. The companion fix in
144
+ * doctor-fixes.ts coordinates daemon stop + tiered repair via the JS-side
145
+ * `repairMemoryDbIfCorrupt` (bin/lib/db-repair.mjs).
146
+ *
147
+ * Status semantics:
148
+ * - `pass` — DB absent OR `integrity_check` returns 'ok'.
149
+ * - `fail` — corruption detected. `fix` field points at the healer's
150
+ * auto-recovery path (which runs REINDEX → VACUUM INTO → row-level
151
+ * salvage in order of escalation).
152
+ * - `warn` — probe itself crashed (rare; surfaces the diagnostic rather
153
+ * than masking it).
154
+ */
155
+ export async function checkMemoryDbIntegrity(cwd = process.cwd()) {
156
+ const dbPath = memoryDbPath(cwd);
157
+ if (!existsSync(dbPath)) {
158
+ return { name: 'Memory DB Integrity', status: 'pass', message: 'DB absent (no integrity probe needed)' };
159
+ }
160
+ // Delegate to the single readonly-no-PRAGMAs probe in
161
+ // `bin/lib/db-repair.mjs` (via the TS service bridge). Avoids re-deriving
162
+ // the same DatabaseSync({ readOnly: true }) + integrity_check sequence in
163
+ // two places and keeps the "what counts as healthy" semantics in one file.
164
+ try {
165
+ const probe = await probeDbIntegrity(dbPath);
166
+ if (probe.ok) {
167
+ return { name: 'Memory DB Integrity', status: 'pass', message: 'PRAGMA integrity_check: ok' };
168
+ }
169
+ const message = probe.openFailed
170
+ ? 'Unable to probe DB (readonly open failed — likely deep corruption)'
171
+ : `${probe.errors} integrity violation(s) detected`;
172
+ return {
173
+ name: 'Memory DB Integrity',
174
+ status: 'fail',
175
+ message,
176
+ fix: 'flo healer --fix -c memory-db-integrity',
177
+ };
178
+ }
179
+ catch (e) {
180
+ // The probe itself maps "readonly open failed" to `openFailed: true`
181
+ // and we surface that as `fail` above. Reaching the catch means the
182
+ // probe *module* couldn't be loaded — `findMofloPackageRoot()` returned
183
+ // null (broken install / wrong cwd) or the dynamic import threw. Both
184
+ // are first-class diagnostic failures — a broken install must not be
185
+ // silently downgraded to `warn` and hidden from the healer summary.
186
+ return {
187
+ name: 'Memory DB Integrity',
188
+ status: 'fail',
189
+ message: `Integrity probe unavailable: ${errorDetail(e)}`,
190
+ fix: 'flo healer --fix -c memory-db-integrity',
191
+ };
192
+ }
193
+ }
134
194
  /**
135
195
  * Standard MCP-config search paths: home (Claude Desktop on macOS/Linux),
136
196
  * XDG config dir, project-local `.mcp.json`, and APPDATA on Windows.