moflo 4.9.36 → 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 (79) hide show
  1. package/.claude/guidance/shipped/moflo-agent-rules.md +12 -0
  2. package/.claude/guidance/shipped/moflo-memory-protocol.md +34 -0
  3. package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
  4. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
  5. package/.claude/guidance/shipped/moflo-subagents.md +4 -0
  6. package/.claude/helpers/gate.cjs +3 -3
  7. package/.claude/helpers/statusline.cjs +69 -33
  8. package/.claude/helpers/subagent-bootstrap.json +1 -1
  9. package/.claude/helpers/subagent-start.cjs +1 -1
  10. package/.claude/skills/eldar/SKILL.md +8 -0
  11. package/bin/build-embeddings.mjs +6 -20
  12. package/bin/cli.js +5 -0
  13. package/bin/gate.cjs +3 -3
  14. package/bin/generate-code-map.mjs +4 -24
  15. package/bin/hooks.mjs +3 -12
  16. package/bin/index-all.mjs +3 -13
  17. package/bin/index-guidance.mjs +59 -119
  18. package/bin/index-patterns.mjs +6 -24
  19. package/bin/index-tests.mjs +4 -23
  20. package/bin/lib/db-repair.mjs +4 -25
  21. package/bin/lib/get-backend.mjs +306 -0
  22. package/bin/lib/incremental-write.mjs +27 -7
  23. package/bin/lib/moflo-paths.mjs +64 -4
  24. package/bin/lib/suppress-sqlite-warning.mjs +57 -0
  25. package/bin/migrations/knowledge-purge.mjs +7 -8
  26. package/bin/migrations/knowledge-to-learnings.mjs +7 -9
  27. package/bin/migrations/purge-doc-entries.mjs +52 -0
  28. package/bin/migrations/strip-context-preambles.mjs +95 -0
  29. package/bin/run-migrations.mjs +1 -10
  30. package/bin/semantic-search.mjs +11 -19
  31. package/bin/session-start-launcher.mjs +102 -100
  32. package/bin/simplify-classify.cjs +38 -17
  33. package/dist/src/cli/commands/daemon.js +38 -11
  34. package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
  35. package/dist/src/cli/commands/doctor-checks-memory-access.js +244 -5
  36. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  37. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  38. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  39. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  40. package/dist/src/cli/commands/doctor-fixes.js +30 -0
  41. package/dist/src/cli/commands/doctor-registry.js +14 -0
  42. package/dist/src/cli/commands/doctor.js +1 -1
  43. package/dist/src/cli/commands/embeddings.js +17 -22
  44. package/dist/src/cli/commands/memory.js +54 -75
  45. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  46. package/dist/src/cli/init/claudemd-generator.js +4 -0
  47. package/dist/src/cli/init/moflo-init.js +40 -0
  48. package/dist/src/cli/mcp-tools/memory-tools.js +177 -32
  49. package/dist/src/cli/memory/bridge-core.js +256 -30
  50. package/dist/src/cli/memory/bridge-entries.js +76 -8
  51. package/dist/src/cli/memory/controller-registry.js +7 -2
  52. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  53. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  54. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  55. package/dist/src/cli/memory/daemon-backend.js +400 -0
  56. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  57. package/dist/src/cli/memory/database-provider.js +57 -40
  58. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  59. package/dist/src/cli/memory/index.js +0 -1
  60. package/dist/src/cli/memory/memory-bridge.js +40 -8
  61. package/dist/src/cli/memory/memory-initializer.js +286 -220
  62. package/dist/src/cli/memory/rvf-migration.js +25 -11
  63. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  64. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  65. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  66. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  67. package/dist/src/cli/services/daemon-lock.js +58 -1
  68. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  69. package/dist/src/cli/services/embeddings-migration.js +9 -12
  70. package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
  71. package/dist/src/cli/services/learning-service.js +12 -20
  72. package/dist/src/cli/services/project-root.js +69 -9
  73. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  74. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  75. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  76. package/dist/src/cli/shared/events/event-store.js +26 -55
  77. package/dist/src/cli/version.js +1 -1
  78. package/package.json +2 -4
  79. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Migration: strip the legacy `[Context from previous section:]` /
3
+ * `[Context from next section:]` preamble blocks from every existing chunk
4
+ * (#1053 S5). The chunker no longer writes them — they were a workaround for
5
+ * missing traversal, and once memory_get_neighbors is wired (S2),
6
+ * prevChunk/nextChunk metadata + a real call is the alternative path.
7
+ *
8
+ * For every chunk whose content carries a preamble marker:
9
+ * 1. Strip the preamble block(s) in place
10
+ * 2. NULL the embedding column so build-embeddings regenerates it from the
11
+ * cleaned content on the next indexer pass
12
+ *
13
+ * Idempotent: chunks already in the new shape (no preamble markers) are
14
+ * untouched.
15
+ *
16
+ * @module bin/migrations/strip-context-preambles
17
+ */
18
+
19
+ import { existsSync } from 'fs';
20
+ import { memoryDbPath } from '../lib/moflo-paths.mjs';
21
+ import { openBackend } from '../lib/get-backend.mjs';
22
+
23
+ export const name = 'strip-context-preambles';
24
+ // Run after purge-doc-entries (which itself has order=0 default). Explicit
25
+ // ordering keeps this independent of fs sort order.
26
+ export const order = 20;
27
+
28
+ // Validated against real chunks; the back-to-back `---` runs that earlier
29
+ // drafts mishandled are absorbed by the trailing `(?:---\n\n)*` / leading
30
+ // `(?:\n\n---)+` greediness.
31
+ const PREV_PREAMBLE = /\[Context from previous section:\][\s\S]*?\n\n---\n\n(?:---\n\n)*/g;
32
+ const NEXT_PREAMBLE = /(?:\n\n---)+\n\n\[Context from next section:\][\s\S]*$/g;
33
+
34
+ function strip(content) {
35
+ // Reset lastIndex defensively — global regex state can leak across calls
36
+ // when reused on a hot path.
37
+ PREV_PREAMBLE.lastIndex = 0;
38
+ NEXT_PREAMBLE.lastIndex = 0;
39
+ return content.replace(PREV_PREAMBLE, '').replace(NEXT_PREAMBLE, '');
40
+ }
41
+
42
+ /**
43
+ * @param {string} projectRoot
44
+ * @returns {Promise<{stripped:number, untouched:number}>}
45
+ */
46
+ export async function run(projectRoot) {
47
+ const dbPath = memoryDbPath(projectRoot);
48
+ if (!existsSync(dbPath)) return { stripped: 0, untouched: 0 };
49
+
50
+ const db = await openBackend(projectRoot, { create: false });
51
+
52
+ // Only chunks can carry the preamble — the chunker is the only writer of
53
+ // those markers. Filter on key prefix to keep the LIKE selective; manual
54
+ // memory entries containing the literal string are extremely unlikely and
55
+ // the strip is a no-op for them anyway.
56
+ const stmt = db.prepare(
57
+ `SELECT id, content FROM memory_entries WHERE key LIKE 'chunk-%' AND status = 'active'`,
58
+ );
59
+ const rows = [];
60
+ while (stmt.step()) rows.push(stmt.getAsObject());
61
+ stmt.free();
62
+
63
+ if (rows.length === 0) {
64
+ db.close();
65
+ return { stripped: 0, untouched: 0 };
66
+ }
67
+
68
+ let stripped = 0;
69
+ let untouched = 0;
70
+ const update = db.prepare(`UPDATE memory_entries SET content = ?, embedding = NULL WHERE id = ?`);
71
+ try {
72
+ for (const row of rows) {
73
+ const original = String(row.content || '');
74
+ // Cheap prefix-check to avoid running the regex on chunks that have no
75
+ // preamble — covers the common idempotent re-run case in O(1).
76
+ if (!original.includes('[Context from previous section:]') && !original.includes('[Context from next section:]')) {
77
+ untouched++;
78
+ continue;
79
+ }
80
+ const cleaned = strip(original);
81
+ if (cleaned === original) {
82
+ untouched++;
83
+ continue;
84
+ }
85
+ update.run([cleaned, row.id]);
86
+ stripped++;
87
+ }
88
+ } finally {
89
+ update.free();
90
+ }
91
+
92
+ if (stripped > 0) db.save();
93
+ db.close();
94
+ return { stripped, untouched };
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 = {}) {
@@ -164,6 +153,7 @@ async function semanticSearch(queryText, options = {}) {
164
153
  preview: entry.content.substring(0, 150).replace(/\n/g, ' '),
165
154
  type: metadata.type || 'unknown',
166
155
  parentDoc: metadata.parentDoc || null,
156
+ parentPath: metadata.parentPath || null,
167
157
  chunkTitle: metadata.chunkTitle || null,
168
158
  });
169
159
  } catch (err) {
@@ -262,7 +252,9 @@ async function main() {
262
252
  console.log(` Key: ${top.key}`);
263
253
  console.log(` Score: ${top.score.toFixed(4)}`);
264
254
  if (top.chunkTitle) console.log(` Section: ${top.chunkTitle}`);
265
- if (top.parentDoc) console.log(` Parent: ${top.parentDoc}`);
255
+ // #1053 S4: doc-* retired — parentPath is the actionable source location.
256
+ if (top.parentPath) console.log(` Parent: ${top.parentPath}`);
257
+ else if (top.parentDoc) console.log(` Parent: ${top.parentDoc}`);
266
258
  console.log(` Preview: ${top.preview}...`);
267
259
  } catch (err) {
268
260
  console.error(`[semantic-search] Error: ${err.message}`);
@@ -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,71 +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
- };
1609
-
1610
- for (const line of raw.split('\n')) {
1611
- const m = line.match(/^\[migrations\]\s+([\w-]+):\s+done\s+in\s+\d+ms\s*(.*)$/);
1612
- if (!m) continue;
1613
- const migrationName = m[1];
1614
- let parsed = null;
1615
- try { parsed = m[2] ? JSON.parse(m[2]) : null; } catch { parsed = null; }
1616
-
1617
- // Silent fast-path: don't announce zero-work runs (no point telling the
1618
- // user the launcher did nothing). If every numeric detail field is 0,
1619
- // skip the emit. Stamped migrations don't even reach this loop because
1620
- // the runner short-circuits via the manifest.
1621
- if (parsed) {
1622
- const nums = Object.values(parsed).filter((v) => typeof v === 'number');
1623
- if (nums.length > 0 && nums.every((v) => v === 0)) continue;
1624
- }
1625
-
1626
- let detail = '';
1627
- if (parsed) {
1628
- if (typeof parsed.purged === 'number') detail = `${parsed.purged} ${parsed.purged === 1 ? 'row' : 'rows'}`;
1629
- else if (typeof parsed.rowsMigrated === 'number') detail = `${parsed.rowsMigrated} ${parsed.rowsMigrated === 1 ? 'entry' : 'entries'}`;
1630
- }
1631
-
1632
- const label = labels[migrationName] || `migration ${migrationName}`;
1633
- emitMutation(label, detail);
1634
- }
1635
- }
1636
-
1637
1639
  // Patches are now baked into moflo@4.0.0 source — no runtime patching needed.
1638
1640
 
1639
1641
  // ── 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