moflo 4.9.37 → 4.10.0

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 (73) 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 +4 -25
  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 +102 -102
  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-coverage-truth.js +136 -0
  30. package/dist/src/cli/commands/doctor-checks-memory-access.js +146 -86
  31. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  32. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  33. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  34. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  35. package/dist/src/cli/commands/doctor-fixes.js +30 -0
  36. package/dist/src/cli/commands/doctor-registry.js +14 -0
  37. package/dist/src/cli/commands/doctor.js +1 -1
  38. package/dist/src/cli/commands/embeddings.js +17 -22
  39. package/dist/src/cli/commands/memory.js +13 -23
  40. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  41. package/dist/src/cli/init/moflo-init.js +40 -0
  42. package/dist/src/cli/mcp-tools/memory-tools.js +10 -3
  43. package/dist/src/cli/memory/bridge-core.js +256 -30
  44. package/dist/src/cli/memory/bridge-entries.js +70 -6
  45. package/dist/src/cli/memory/controller-registry.js +7 -2
  46. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  47. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  48. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  49. package/dist/src/cli/memory/daemon-backend.js +400 -0
  50. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  51. package/dist/src/cli/memory/database-provider.js +57 -40
  52. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  53. package/dist/src/cli/memory/index.js +0 -1
  54. package/dist/src/cli/memory/memory-bridge.js +40 -8
  55. package/dist/src/cli/memory/memory-initializer.js +269 -209
  56. package/dist/src/cli/memory/rvf-migration.js +25 -11
  57. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  58. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  59. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  60. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  61. package/dist/src/cli/services/daemon-lock.js +58 -1
  62. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  63. package/dist/src/cli/services/embeddings-migration.js +9 -12
  64. package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
  65. package/dist/src/cli/services/learning-service.js +12 -20
  66. package/dist/src/cli/services/project-root.js +69 -9
  67. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  68. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  69. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  70. package/dist/src/cli/shared/events/event-store.js +26 -55
  71. package/dist/src/cli/version.js +1 -1
  72. package/package.json +2 -4
  73. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -66,16 +66,30 @@ export function computeContentListHash(files) {
66
66
  }
67
67
 
68
68
  /**
69
- * Load `key → content` for every active row in the namespace.
69
+ * Load `key → content` for every active row in the namespace, optionally
70
+ * scoped to keys starting with `keyPrefix` (one doc's chunks at a time —
71
+ * lets per-file indexers like `index-guidance.mjs` content-diff without
72
+ * loading every chunk across every file).
73
+ *
70
74
  * @param {object} db - sql.js Database
71
75
  * @param {string} namespace
76
+ * @param {string} [keyPrefix] — when set, restricts the scan to `key LIKE '<prefix>%'`.
77
+ * The same prefix scopes the orphan sweep in {@link applyIncrementalChunks}.
72
78
  * @returns {Map<string,string>}
73
79
  */
74
- export function loadExistingContent(db, namespace) {
75
- const stmt = db.prepare(
76
- `SELECT key, content FROM memory_entries WHERE namespace = ? AND status = 'active'`,
77
- );
78
- stmt.bind([namespace]);
80
+ export function loadExistingContent(db, namespace, keyPrefix) {
81
+ const stmt = keyPrefix
82
+ ? db.prepare(
83
+ `SELECT key, content FROM memory_entries WHERE namespace = ? AND key LIKE ? AND status = 'active'`,
84
+ )
85
+ : db.prepare(
86
+ `SELECT key, content FROM memory_entries WHERE namespace = ? AND status = 'active'`,
87
+ );
88
+ if (keyPrefix) {
89
+ stmt.bind([namespace, `${keyPrefix}%`]);
90
+ } else {
91
+ stmt.bind([namespace]);
92
+ }
79
93
  const map = new Map();
80
94
  while (stmt.step()) {
81
95
  const row = stmt.getAsObject();
@@ -95,11 +109,17 @@ export function loadExistingContent(db, namespace) {
95
109
  * @param {object} [opts]
96
110
  * @param {boolean} [opts.serialize=true] - JSON.stringify metadata/tags before
97
111
  * writing. Set false when callers already pass strings.
112
+ * @param {string} [opts.keyPrefix] — when set, the existing-content load AND
113
+ * the orphan sweep are restricted to keys matching `<prefix>%`. Use this
114
+ * when processing a single file's chunks at a time (e.g. index-guidance.mjs
115
+ * iterates files independently) — without it the sweep would delete every
116
+ * chunk from every OTHER file as an orphan on each call.
98
117
  * @returns {{inserted:number, updated:number, unchanged:number, removed:number}}
99
118
  */
100
119
  export function applyIncrementalChunks(db, namespace, chunks, opts = {}) {
101
120
  const serialize = opts.serialize !== false;
102
- const existing = loadExistingContent(db, namespace);
121
+ const keyPrefix = opts.keyPrefix;
122
+ const existing = loadExistingContent(db, namespace, keyPrefix);
103
123
  const newKeys = new Set();
104
124
  let inserted = 0;
105
125
  let updated = 0;
@@ -1,16 +1,19 @@
1
1
  /**
2
- * Pure-JS counterpart to src/cli/services/moflo-paths.ts.
2
+ * Pure-JS counterpart to src/cli/services/moflo-paths.ts and the
3
+ * findProjectRoot helper in src/cli/services/project-root.ts.
3
4
  *
4
5
  * Lives in bin/lib because session-start-launcher.mjs and other bin/ scripts
5
6
  * run before any TS compilation has happened — they can't import the .ts
6
- * source. The TS version is the canonical programmatic API; this version
7
- * exposes the same path constants + helpers.
7
+ * source. The TS versions are the canonical programmatic API; this file
8
+ * exposes the same path constants + helpers and MUST stay algorithmically
9
+ * identical (see tests/system/project-root-twin.test.ts).
8
10
  *
9
11
  * Per #851, the legacy `.claude-flow/` rename + `.swarm/memory.db` byte-copy
10
12
  * helpers no longer ship: the version-bump-gated cherry-pick lives entirely
11
13
  * in the launcher and the TS service `cli/services/cherry-pick-learnings.ts`.
12
14
  */
13
- import { join } from 'node:path';
15
+ import { existsSync } from 'node:fs';
16
+ import { basename, dirname, join, parse, resolve } from 'node:path';
14
17
 
15
18
  export const MOFLO_DIR = '.moflo';
16
19
  export const MEMORY_DB_FILE = 'moflo.db';
@@ -57,3 +60,60 @@ export function memoryDbCandidatePaths(projectRoot) {
57
60
  join(projectRoot, '.claude', LEGACY_MEMORY_DB_FILE),
58
61
  ];
59
62
  }
63
+
64
+ /**
65
+ * Resolve the project root the same way the TS bridge does. Every bin/
66
+ * script that touches `.moflo/moflo.db` (or any sibling state under
67
+ * `.moflo/`) MUST go through this so its writes land on the SAME file the
68
+ * bridge reads from.
69
+ *
70
+ * Algorithmic twin of `src/cli/services/project-root.ts:findProjectRoot()`
71
+ * and `src/cli/memory/bridge-core.ts:getProjectRoot()`. See those files for
72
+ * the canonical algorithm comment.
73
+ *
74
+ * @param {{ cwd?: string; honorEnv?: boolean }} [opts]
75
+ * @returns {string} absolute project root
76
+ */
77
+ export function findProjectRoot(opts) {
78
+ const honorEnv = opts?.honorEnv !== false;
79
+ if (honorEnv && process.env.CLAUDE_PROJECT_DIR) {
80
+ return process.env.CLAUDE_PROJECT_DIR;
81
+ }
82
+ const startDir = opts?.cwd ?? process.cwd();
83
+ const start = resolve(startDir);
84
+ const fsRoot = parse(start).root;
85
+
86
+ // High-priority pass: memory markers + CLAUDE.md/package.json pair.
87
+ let dir = start;
88
+ while (dir !== fsRoot) {
89
+ if (basename(dir) === 'node_modules') {
90
+ dir = dirname(dir);
91
+ continue;
92
+ }
93
+ if (existsSync(join(dir, '.moflo', 'moflo.db'))) return dir;
94
+ if (existsSync(join(dir, '.swarm', 'memory.db'))) return dir;
95
+ if (existsSync(join(dir, 'CLAUDE.md')) && existsSync(join(dir, 'package.json'))) {
96
+ return dir;
97
+ }
98
+ const parent = dirname(dir);
99
+ if (parent === dir) break;
100
+ dir = parent;
101
+ }
102
+
103
+ // Low-priority pass: bare package.json or .git.
104
+ dir = start;
105
+ while (dir !== fsRoot) {
106
+ if (basename(dir) === 'node_modules') {
107
+ dir = dirname(dir);
108
+ continue;
109
+ }
110
+ if (existsSync(join(dir, 'package.json')) || existsSync(join(dir, '.git'))) {
111
+ return dir;
112
+ }
113
+ const parent = dirname(dir);
114
+ if (parent === dir) break;
115
+ dir = parent;
116
+ }
117
+
118
+ return startDir;
119
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Filter out Node's `(node:NNN) ExperimentalWarning: SQLite is an experimental
3
+ * feature and might change at any time` line that fires once per process the
4
+ * first time `node:sqlite` is loaded.
5
+ *
6
+ * Why we suppress: moflo committed to node:sqlite as the only backend in
7
+ * Phase 5 (#1084). The warning is informational from Node's perspective but
8
+ * actively harmful for us because:
9
+ * 1. It lands on stderr → the consumer-smoke harness's 200-char stderr
10
+ * tails (`recordExit`) get filled with the warning prefix, hiding the
11
+ * real failure message behind it (#1098 — `Memory Access Functional`
12
+ * failures looked like ExperimentalWarning failures in CI logs).
13
+ * 2. It appears in every consumer's `flo doctor` / `flo memory init` /
14
+ * `flo daemon start` invocation, polluting their terminal output for
15
+ * something they can't act on.
16
+ *
17
+ * Implementation: replace `process.emitWarning` with a thin filter ONLY for
18
+ * the SQLite warning. Every other warning passes through unchanged — we
19
+ * specifically don't want to broadly mute `--no-warnings`-style suppression
20
+ * because that would also hide e.g. import-attributes ExperimentalWarning
21
+ * which IS something we'd want to know about.
22
+ *
23
+ * The filter is idempotent: re-importing the module is a no-op. Must run
24
+ * BEFORE the first `import 'node:sqlite'` anywhere in the process tree;
25
+ * called from `bin/cli.js`, `bin/lib/get-backend.mjs`, and the daemon-
26
+ * backend module loader.
27
+ *
28
+ * @module bin/lib/suppress-sqlite-warning
29
+ */
30
+
31
+ const INSTALLED = Symbol.for('moflo.suppressSqliteWarning.installed');
32
+
33
+ function shouldSuppress(message) {
34
+ if (typeof message === 'string') {
35
+ return message.includes('SQLite is an experimental feature');
36
+ }
37
+ if (message && typeof message === 'object') {
38
+ return typeof message.message === 'string' && message.message.includes('SQLite is an experimental feature');
39
+ }
40
+ return false;
41
+ }
42
+
43
+ if (!globalThis[INSTALLED]) {
44
+ globalThis[INSTALLED] = true;
45
+ const originalEmitWarning = process.emitWarning;
46
+ process.emitWarning = function (warning, ...args) {
47
+ // Two emit signatures: (message, type, code, ctor) and (message, options).
48
+ // Inspect both `warning` and `args[0]` (which is `type` in the legacy
49
+ // form or `options.type` in the new form) before deciding.
50
+ if (shouldSuppress(warning)) return;
51
+ const typeArg = typeof args[0] === 'string'
52
+ ? args[0]
53
+ : (args[0] && typeof args[0] === 'object' ? args[0].type : undefined);
54
+ if (typeArg === 'ExperimentalWarning' && typeof warning === 'string' && warning.includes('SQLite')) return;
55
+ return originalEmitWarning.apply(this, [warning, ...args]);
56
+ };
57
+ }
@@ -15,9 +15,9 @@
15
15
  * @module bin/migrations/knowledge-purge
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, writeFileSync } from 'fs';
19
- import { mofloResolveURL } from '../lib/moflo-resolve.mjs';
18
+ import { existsSync } from 'fs';
20
19
  import { memoryDbPath } from '../lib/moflo-paths.mjs';
20
+ import { openBackend } from '../lib/get-backend.mjs';
21
21
  import { hasMigrationRun } from '../lib/migrations.mjs';
22
22
  import { MIGRATED_FROM_KNOWLEDGE } from './lib/markers.mjs';
23
23
 
@@ -44,11 +44,10 @@ export async function run(projectRoot) {
44
44
  const dbPath = memoryDbPath(projectRoot);
45
45
  if (!existsSync(dbPath)) return { purged: 0, skipped: 0 };
46
46
 
47
- // Lazy-load sql.js — keeps the manifest-stamped no-op path off the WASM
48
- // init cost (~30ms cold).
49
- const initSqlJs = (await import(mofloResolveURL('sql.js'))).default;
50
- const SQL = await initSqlJs();
51
- const db = new SQL.Database(readFileSync(dbPath));
47
+ // Lazy-load via the backend factory — keeps the manifest-stamped no-op
48
+ // path off the WASM init cost (~30ms cold). Engine selection lives in
49
+ // openBackend() (default: node:sqlite as of #1083 Phase 4).
50
+ const db = await openBackend(projectRoot, { create: false });
52
51
 
53
52
  const knowledgeStmt = db.prepare(
54
53
  `SELECT id, key, status FROM memory_entries
@@ -101,7 +100,7 @@ export async function run(projectRoot) {
101
100
  deleteStmt.free();
102
101
  }
103
102
 
104
- if (purged > 0) writeFileSync(dbPath, Buffer.from(db.export()));
103
+ if (purged > 0) db.save();
105
104
  db.close();
106
105
  return { purged, skipped };
107
106
  }
@@ -10,11 +10,10 @@
10
10
  * @module bin/migrations/knowledge-to-learnings
11
11
  */
12
12
 
13
- import { existsSync, readFileSync } from 'fs';
14
- import { writeFileSync } from 'fs';
13
+ import { existsSync } from 'fs';
15
14
  import { randomBytes } from 'crypto';
16
- import { mofloResolveURL } from '../lib/moflo-resolve.mjs';
17
15
  import { memoryDbPath } from '../lib/moflo-paths.mjs';
16
+ import { openBackend } from '../lib/get-backend.mjs';
18
17
  import { MIGRATED_FROM_KNOWLEDGE } from './lib/markers.mjs';
19
18
 
20
19
  export const name = 'knowledge-to-learnings';
@@ -45,11 +44,10 @@ export async function run(projectRoot) {
45
44
  const dbPath = memoryDbPath(projectRoot);
46
45
  if (!existsSync(dbPath)) return { rowsMigrated: 0, rowsSkipped: 0 };
47
46
 
48
- // Lazy-load sql.js — top-level await would pay ~30ms WASM init even on the
49
- // no-op fast-path where the manifest already records this migration as done.
50
- const initSqlJs = (await import(mofloResolveURL('sql.js'))).default;
51
- const SQL = await initSqlJs();
52
- const db = new SQL.Database(readFileSync(dbPath));
47
+ // Lazy-load via the backend factory — top-level await would pay the engine
48
+ // init cost even on the no-op fast-path where the manifest already records
49
+ // this migration as done.
50
+ const db = await openBackend(projectRoot, { create: false });
53
51
 
54
52
  const sourceStmt = db.prepare(
55
53
  `SELECT id, key, content, type, metadata, tags, embedding, embedding_dimensions,
@@ -119,7 +117,7 @@ export async function run(projectRoot) {
119
117
  insertStmt.free();
120
118
  }
121
119
 
122
- if (migrated > 0) writeFileSync(dbPath, Buffer.from(db.export()));
120
+ if (migrated > 0) db.save();
123
121
  db.close();
124
122
  return { rowsMigrated: migrated, rowsSkipped: skipped };
125
123
  }
@@ -9,9 +9,9 @@
9
9
  * @module bin/migrations/purge-doc-entries
10
10
  */
11
11
 
12
- import { existsSync, readFileSync, writeFileSync } from 'fs';
13
- import { mofloResolveURL } from '../lib/moflo-resolve.mjs';
12
+ import { existsSync } from 'fs';
14
13
  import { memoryDbPath } from '../lib/moflo-paths.mjs';
14
+ import { openBackend } from '../lib/get-backend.mjs';
15
15
 
16
16
  export const name = 'purge-doc-entries';
17
17
 
@@ -23,11 +23,10 @@ export async function run(projectRoot) {
23
23
  const dbPath = memoryDbPath(projectRoot);
24
24
  if (!existsSync(dbPath)) return { purged: 0 };
25
25
 
26
- // Lazy-load sql.js — keeps the manifest-stamped no-op path off the WASM
27
- // init cost (~30ms cold).
28
- const initSqlJs = (await import(mofloResolveURL('sql.js'))).default;
29
- const SQL = await initSqlJs();
30
- const db = new SQL.Database(readFileSync(dbPath));
26
+ // Lazy-load via the backend factory — keeps the manifest-stamped no-op
27
+ // path off the WASM init cost (~30ms cold). Engine selection lives in
28
+ // openBackend() (default: node:sqlite as of #1083 Phase 4).
29
+ const db = await openBackend(projectRoot, { create: false });
31
30
 
32
31
  // Scope: every namespace, since both `flo memory index-guidance` and
33
32
  // `bin/index-guidance.mjs` historically wrote doc-* across whatever
@@ -47,7 +46,7 @@ export async function run(projectRoot) {
47
46
  db.run(`DELETE FROM memory_entries WHERE key LIKE 'doc-%'`);
48
47
  const purged = db.getRowsModified?.() ?? beforeCount;
49
48
 
50
- if (purged > 0) writeFileSync(dbPath, Buffer.from(db.export()));
49
+ if (purged > 0) db.save();
51
50
  db.close();
52
51
  return { purged };
53
52
  }
@@ -16,9 +16,9 @@
16
16
  * @module bin/migrations/strip-context-preambles
17
17
  */
18
18
 
19
- import { existsSync, readFileSync, writeFileSync } from 'fs';
20
- import { mofloResolveURL } from '../lib/moflo-resolve.mjs';
19
+ import { existsSync } from 'fs';
21
20
  import { memoryDbPath } from '../lib/moflo-paths.mjs';
21
+ import { openBackend } from '../lib/get-backend.mjs';
22
22
 
23
23
  export const name = 'strip-context-preambles';
24
24
  // Run after purge-doc-entries (which itself has order=0 default). Explicit
@@ -47,9 +47,7 @@ export async function run(projectRoot) {
47
47
  const dbPath = memoryDbPath(projectRoot);
48
48
  if (!existsSync(dbPath)) return { stripped: 0, untouched: 0 };
49
49
 
50
- const initSqlJs = (await import(mofloResolveURL('sql.js'))).default;
51
- const SQL = await initSqlJs();
52
- const db = new SQL.Database(readFileSync(dbPath));
50
+ const db = await openBackend(projectRoot, { create: false });
53
51
 
54
52
  // Only chunks can carry the preamble — the chunker is the only writer of
55
53
  // those markers. Filter on key prefix to keep the LIKE selective; manual
@@ -91,7 +89,7 @@ export async function run(projectRoot) {
91
89
  update.free();
92
90
  }
93
91
 
94
- if (stripped > 0) writeFileSync(dbPath, Buffer.from(db.export()));
92
+ if (stripped > 0) db.save();
95
93
  db.close();
96
94
  return { stripped, untouched };
97
95
  }
@@ -23,19 +23,10 @@ import { existsSync, readdirSync } from 'fs';
23
23
  import { resolve, dirname } from 'path';
24
24
  import { fileURLToPath, pathToFileURL } from 'url';
25
25
  import { hasMigrationRun, markMigrationDone, listMigrations, clearMigration } from './lib/migrations.mjs';
26
+ import { findProjectRoot } from './lib/moflo-paths.mjs';
26
27
 
27
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
28
29
 
29
- function findProjectRoot() {
30
- let dir = process.cwd();
31
- const root = resolve(dir, '/');
32
- while (dir !== root) {
33
- if (existsSync(resolve(dir, 'package.json'))) return dir;
34
- dir = dirname(dir);
35
- }
36
- return process.cwd();
37
- }
38
-
39
30
  const projectRoot = findProjectRoot();
40
31
  const args = process.argv.slice(2);
41
32
  const verbose = args.includes('--verbose') || args.includes('-v');
@@ -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,
@@ -884,37 +877,42 @@ try {
884
877
  // longer exists in source, calling a require helper that prints the warning
885
878
  // every time `neural_predict` / `neural_patterns` fires.
886
879
  //
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.
880
+ // Fix (epic #1054): compare the daemon-lock's reported moflo `version` against
881
+ // the installed `node_modules/moflo/package.json` version. If they differ
882
+ // or the lock predates #1054 and has no `version` field at all — recycle the
883
+ // daemon. This is exact (not a heuristic margin like the prior mtime-based
884
+ // check) and named explicitly so the doctor's Daemon Version Skew check
885
+ // (#1059) can share the diagnosis.
891
886
  //
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;
887
+ // Pre-#1054 daemons have no `version` in their lock payload treated as a
888
+ // mismatch by definition because by construction they were launched before
889
+ // version publishing existed.
896
890
  try {
897
891
  const mofloPkgPathForRecycle = resolve(projectRoot, 'node_modules/moflo/package.json');
898
892
  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;
893
+ // Cheap stat first — if either file is gone, no skew check is possible.
894
+ if (existsSync(mofloPkgPathForRecycle) && existsSync(lockFile)) {
895
+ const installedVersion = JSON.parse(readFileSync(mofloPkgPathForRecycle, 'utf-8')).version;
896
+ let daemonVersion;
907
897
  try {
908
898
  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');
899
+ if (typeof lock?.version === 'string') daemonVersion = lock.version;
900
+ } catch { /* corrupt lock — recycleDaemon will unlink it */ }
901
+ if (daemonVersion !== installedVersion) {
902
+ if (recycleDaemon(lockFile, 'daemon-version-skew')) {
903
+ const observed = daemonVersion ?? '<pre-1054 / unknown>';
904
+ emitMutation(
905
+ 'recycled stale daemon',
906
+ `version skew: installed ${installedVersion}, daemon ${observed}`,
907
+ );
914
908
  }
915
909
  }
916
910
  }
917
- } catch { /* non-fatal — best-effort stale-daemon detection */ }
911
+ } catch (err) {
912
+ // Non-fatal; surface via emitWarning per feedback_no_layered_workarounds —
913
+ // no silent catch on the upgrade path (#854).
914
+ emitWarning(`daemon version-skew check failed: ${errMessage(err)}`);
915
+ }
918
916
 
919
917
  // ── 3a. Auto-migrate settings.json (npx flo → node helpers, PATH setup) ────
920
918
  // Existing users may have stale settings.json with `npx flo` hooks that break
@@ -1490,6 +1488,75 @@ try {
1490
1488
  } catch { /* writing the failure itself must not throw */ }
1491
1489
  }
1492
1490
 
1491
+ // ── 3e-1057. Run unmet schema migrations BEFORE daemon spawn ────────────────
1492
+ // run-migrations.mjs walks `bin/migrations/*.mjs` and invokes each that has
1493
+ // not been recorded in `.moflo/migrations.json`. Each migration opens sql.js
1494
+ // directly and persists with atomicWriteFileSync. They MUST run before the
1495
+ // daemon spawns or the daemon's in-RAM snapshot races their on-disk writes
1496
+ // — the bug class S2 detects (#1056 version-skew kill) and S3 (#1057)
1497
+ // closes for good. Pre-#1057 this ran in Section 4 AFTER `hooks session-
1498
+ // start` fired off the daemon, which is exactly the race fixed here.
1499
+ //
1500
+ // Synchronous on purpose: blocking by ≤30s on a one-time migration is the
1501
+ // right trade vs. an inconsistent post-upgrade DB. The runner short-circuits
1502
+ // to a no-op when nothing is pending, so steady-state cost is just the node
1503
+ // startup + ESM graph (~80ms on a warm fs).
1504
+ const runMigrations = resolveMofloBin(projectRoot, null, 'run-migrations.mjs');
1505
+ if (runMigrations) {
1506
+ runMigrationsAndAnnounce(runMigrations);
1507
+ }
1508
+
1509
+ function runMigrationsAndAnnounce(runnerPath) {
1510
+ let raw;
1511
+ try {
1512
+ raw = execFileSync('node', [runnerPath], {
1513
+ cwd: projectRoot,
1514
+ timeout: 30_000,
1515
+ encoding: 'utf-8',
1516
+ stdio: ['ignore', 'pipe', 'inherit'],
1517
+ });
1518
+ } catch (err) {
1519
+ // Migrations are best-effort — a failure here must never block session
1520
+ // start. But silent swallowing hides hangs (30s timeout) and corrupted
1521
+ // DBs from the user, so leave a stderr crumb.
1522
+ process.stderr.write(`moflo: migration runner failed (${err.code || err.message}); will retry next session\n`);
1523
+ return;
1524
+ }
1525
+
1526
+ const labels = {
1527
+ 'knowledge-to-learnings': 'consolidated knowledge → learnings',
1528
+ 'knowledge-purge': 'removed legacy knowledge namespace rows',
1529
+ 'purge-doc-entries': 'pruned legacy doc-* rows (chunk-only RAG, #1053)',
1530
+ 'strip-context-preambles': 'stripped chunk preambles; embeddings will rebuild on next index pass (#1053)',
1531
+ };
1532
+
1533
+ for (const line of raw.split(/\r?\n/)) {
1534
+ const m = line.match(/^\[migrations\]\s+([\w-]+):\s+done\s+in\s+\d+ms\s*(.*)$/);
1535
+ if (!m) continue;
1536
+ const migrationName = m[1];
1537
+ let parsed = null;
1538
+ try { parsed = m[2] ? JSON.parse(m[2]) : null; } catch { parsed = null; }
1539
+
1540
+ // Silent fast-path: don't announce zero-work runs (no point telling the
1541
+ // user the launcher did nothing). If every numeric detail field is 0,
1542
+ // skip the emit. Stamped migrations don't even reach this loop because
1543
+ // the runner short-circuits via the manifest.
1544
+ if (parsed) {
1545
+ const nums = Object.values(parsed).filter((v) => typeof v === 'number');
1546
+ if (nums.length > 0 && nums.every((v) => v === 0)) continue;
1547
+ }
1548
+
1549
+ let detail = '';
1550
+ if (parsed) {
1551
+ if (typeof parsed.purged === 'number') detail = `${parsed.purged} ${parsed.purged === 1 ? 'row' : 'rows'}`;
1552
+ else if (typeof parsed.rowsMigrated === 'number') detail = `${parsed.rowsMigrated} ${parsed.rowsMigrated === 1 ? 'entry' : 'entries'}`;
1553
+ }
1554
+
1555
+ const label = labels[migrationName] || `migration ${migrationName}`;
1556
+ emitMutation(label, detail);
1557
+ }
1558
+ }
1559
+
1493
1560
  // ── 3f. Flip the upgrade notice to "completed" (#636, #738) ─────────────────
1494
1561
  // See the TTL rationale at the constants above for why we switch to a
1495
1562
  // short-TTL completed badge instead of clearing the file.
@@ -1569,73 +1636,6 @@ if (hooksScript) {
1569
1636
  fireAndForget('node', [hooksScript, 'session-start'], 'hooks session-start');
1570
1637
  }
1571
1638
 
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
1639
  // Patches are now baked into moflo@4.0.0 source — no runtime patching needed.
1640
1640
 
1641
1641
  // ── 5. Done — exit immediately ──────────────────────────────────────────────