moflo 4.12.2 → 4.12.3-rc.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 (36) hide show
  1. package/bin/hooks.mjs +5 -2
  2. package/bin/index-all.mjs +38 -0
  3. package/bin/lib/moflo-paths.mjs +37 -1
  4. package/bin/lib/process-manager.mjs +16 -1
  5. package/bin/lib/registry-cleanup.cjs +9 -1
  6. package/bin/session-start-launcher.mjs +5 -2
  7. package/dist/src/cli/commands/agent.js +7 -2
  8. package/dist/src/cli/commands/benchmark.js +5 -2
  9. package/dist/src/cli/commands/daemon.js +81 -9
  10. package/dist/src/cli/commands/doctor-checks-config.js +55 -19
  11. package/dist/src/cli/commands/doctor-checks-memory.js +3 -2
  12. package/dist/src/cli/commands/doctor-checks-writers-audit.js +5 -1
  13. package/dist/src/cli/commands/doctor-embedding-hygiene.js +2 -1
  14. package/dist/src/cli/commands/doctor-fixes.js +20 -5
  15. package/dist/src/cli/commands/doctor-zombies.js +4 -3
  16. package/dist/src/cli/commands/embeddings.js +10 -5
  17. package/dist/src/cli/commands/hooks.js +11 -8
  18. package/dist/src/cli/commands/swarm.js +2 -1
  19. package/dist/src/cli/index.js +18 -6
  20. package/dist/src/cli/memory/bridge-core.js +8 -2
  21. package/dist/src/cli/memory/daemon-write-client.js +5 -5
  22. package/dist/src/cli/memory/database-provider.js +14 -5
  23. package/dist/src/cli/memory/entries-read.js +5 -4
  24. package/dist/src/cli/memory/entries-write.js +3 -2
  25. package/dist/src/cli/memory/hnsw-singleton.js +2 -1
  26. package/dist/src/cli/memory/init.js +14 -8
  27. package/dist/src/cli/memory/learnings-overview.js +2 -1
  28. package/dist/src/cli/runtime/headless.js +8 -3
  29. package/dist/src/cli/services/daemon-dashboard.js +4 -4
  30. package/dist/src/cli/services/embeddings-migration.js +2 -1
  31. package/dist/src/cli/services/ephemeral-namespace-purge.js +3 -2
  32. package/dist/src/cli/services/project-root.js +108 -1
  33. package/dist/src/cli/services/soft-delete-purge.js +2 -1
  34. package/dist/src/cli/services/worker-daemon.js +67 -1
  35. package/dist/src/cli/version.js +1 -1
  36. package/package.json +8 -7
@@ -12,7 +12,7 @@ import { errorDetail } from '../shared/utils/error-detail.js';
12
12
  import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
13
13
  import { repairHookWiring } from '../services/hook-wiring.js';
14
14
  import { findProjectDaemonPids, getDaemonLockHolder } from '../services/daemon-lock.js';
15
- import { findProjectRoot } from '../services/project-root.js';
15
+ import { findProjectRoot, resolveStateRoot } from '../services/project-root.js';
16
16
  import { legacyMemoryDbPath, legacyMemoryDbBakPath, memoryDbPath, mofloDir } from '../services/moflo-paths.js';
17
17
  import { findZombieProcesses } from './doctor-zombies.js';
18
18
  import { inspectMcpConfigs } from './doctor-checks-config.js';
@@ -274,9 +274,20 @@ async function stopNestedDaemons(subRoot) {
274
274
  try {
275
275
  pids = findProjectDaemonPids(subRoot);
276
276
  }
277
- catch {
278
- return { stopped: [], remaining: [], denied: [] };
277
+ catch { /* cmdline scan unavailable — the lock-file probe below still runs */ }
278
+ // #1315 the cmdline scan alone cannot see a HUSK daemon. `projectCliCandidates`
279
+ // matches processes whose command line invokes `<subRoot>/node_modules/moflo/bin/cli.js`,
280
+ // but a husk daemon resolved its binary from the ROOT install and only its
281
+ // CWD was the sub-directory — so nothing matches, nothing is reaped, and the
282
+ // archive that follows either fails EBUSY on Windows (open daemon.log fd) or
283
+ // succeeds on POSIX only for the daemon's next tick to recreate the dir.
284
+ // The lock file is the dependable handle: a husk always contains one.
285
+ try {
286
+ const holder = getDaemonLockHolder(subRoot);
287
+ if (holder !== null && !pids.includes(holder))
288
+ pids.push(holder);
279
289
  }
290
+ catch { /* no lock or unreadable — nothing more we can do */ }
280
291
  if (pids.length === 0)
281
292
  return { stopped: [], remaining: [], denied: [] };
282
293
  const stopped = [];
@@ -360,7 +371,11 @@ async function stopNestedDaemons(subRoot) {
360
371
  * inode; the daemon keeps writing to a now-archived path until it exits.
361
372
  */
362
373
  async function fixNestedMofloIslands() {
363
- const root = findProjectRoot();
374
+ // #1315 `resolveStateRoot`, not `findProjectRoot`. The latter returns
375
+ // `CLAUDE_PROJECT_DIR` unconditionally, so running the healer from a
376
+ // sub-workspace session would scan for islands starting AT a sub-workspace —
377
+ // missing the real ones above it, and treating that directory as canonical.
378
+ const root = resolveStateRoot();
364
379
  let islands;
365
380
  try {
366
381
  const { findNestedMofloDirsForFix } = await import('./doctor-checks-config.js');
@@ -445,7 +460,7 @@ export async function autoFixCheck(check) {
445
460
  },
446
461
  'Config File': async () => {
447
462
  try {
448
- const cfDir = join(process.cwd(), '.moflo');
463
+ const cfDir = join(resolveStateRoot(), '.moflo');
449
464
  if (!existsSync(cfDir))
450
465
  mkdirSync(cfDir, { recursive: true });
451
466
  return runFixCommand('npx moflo config init');
@@ -11,6 +11,7 @@ import { execSync } from 'child_process';
11
11
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import { getDaemonLockHolder, isDaemonProcess, releaseDaemonLock } from '../services/daemon-lock.js';
14
+ import { resolveStateRoot } from '../services/project-root.js';
14
15
  // Cmdline capture/display caps + scan timeouts. Hoisted so the values
15
16
  // used to size buffers and format messages are visible in one place.
16
17
  const ZOMBIE_CMDLINE_CAPTURE_LEN = 200;
@@ -41,8 +42,8 @@ function isProcessAlive(pid) {
41
42
  // Fast path: kill processes tracked in the shared ProcessManager registry.
42
43
  // This avoids the expensive OS-level process scan for known background tasks.
43
44
  export function killTrackedProcesses() {
44
- const registryFile = join(process.cwd(), '.moflo', 'background-pids.json');
45
- const lockFile = join(process.cwd(), '.moflo', 'spawn.lock');
45
+ const registryFile = join(resolveStateRoot(), '.moflo', 'background-pids.json');
46
+ const lockFile = join(resolveStateRoot(), '.moflo', 'spawn.lock');
46
47
  let killed = 0;
47
48
  try {
48
49
  if (existsSync(registryFile)) {
@@ -80,7 +81,7 @@ export function killTrackedProcesses() {
80
81
  // findZombieProcesses() flags every running indexer step as a zombie.
81
82
  function readTrackedBackgroundPids() {
82
83
  const result = new Set();
83
- const registryFile = join(process.cwd(), '.moflo', 'background-pids.json');
84
+ const registryFile = join(resolveStateRoot(), '.moflo', 'background-pids.json');
84
85
  try {
85
86
  if (!existsSync(registryFile))
86
87
  return result;
@@ -18,6 +18,7 @@ import { memoryDbPath, MEMORY_DB_FILE, MOFLO_DIR } from '../services/moflo-paths
18
18
  import * as embeddings from '../embeddings/index.js';
19
19
  import { openDaemonDatabase } from '../memory/daemon-backend.js';
20
20
  import { errorDetail } from '../shared/utils/error-detail.js';
21
+ import { resolveStateRoot } from '../services/project-root.js';
21
22
  const DEFAULT_DB_PATH_FLAG = `${MOFLO_DIR}/${MEMORY_DB_FILE}`;
22
23
  // Generate subcommand - REAL implementation
23
24
  const generateCommand = {
@@ -110,7 +111,7 @@ const searchCommand = {
110
111
  const namespace = ctx.flags.collection || 'default';
111
112
  const limit = parseInt(ctx.flags.limit || '10', 10);
112
113
  const threshold = parseFloat(ctx.flags.threshold || '0.5');
113
- const dbPath = ctx.flags.dbPath || memoryDbPath(process.cwd());
114
+ const dbPath = ctx.flags.dbPath || memoryDbPath(resolveStateRoot());
114
115
  if (!query) {
115
116
  output.printError('Query is required');
116
117
  return { success: false, exitCode: 1 };
@@ -355,7 +356,7 @@ const collectionsCommand = {
355
356
  ],
356
357
  action: async (ctx) => {
357
358
  const action = ctx.flags.action || 'list';
358
- const dbPath = ctx.flags.dbPath || memoryDbPath(process.cwd());
359
+ const dbPath = ctx.flags.dbPath || memoryDbPath(resolveStateRoot());
359
360
  output.writeln();
360
361
  output.writeln(output.bold('Embedding Collections (Namespaces)'));
361
362
  output.writeln(output.dim('─'.repeat(60)));
@@ -605,7 +606,10 @@ export const initCommand = {
605
606
  try {
606
607
  const fs = await import('fs');
607
608
  const path = await import('path');
608
- const configDir = path.join(process.cwd(), '.moflo');
609
+ // #1315 resolved root, not cwd. This mkdirs below, so `flo embeddings
610
+ // init` from a subdirectory minted an island holding an embeddings config
611
+ // the rest of the stack would never look at.
612
+ const configDir = path.join(resolveStateRoot(), '.moflo');
609
613
  const configPath = path.join(configDir, 'embeddings.json');
610
614
  // Second-session path: config already exists. Skip download / rewrite,
611
615
  // but still run the migration check so users upgrading moflo mid-project
@@ -942,7 +946,8 @@ const neuralCommand = {
942
946
  // Check if embeddings config exists
943
947
  const fs = await import('fs');
944
948
  const path = await import('path');
945
- const configPath = path.join(process.cwd(), '.moflo', 'embeddings.json');
949
+ // #1315 must read from the root `init` writes to.
950
+ const configPath = path.join(resolveStateRoot(), '.moflo', 'embeddings.json');
946
951
  if (!fs.existsSync(configPath)) {
947
952
  output.printWarning('Embeddings not initialized');
948
953
  output.printInfo('Run "embeddings init" first to configure ONNX model');
@@ -1475,7 +1480,7 @@ const migrateCommand = {
1475
1480
  { command: 'flo embeddings migrate -d .custom/mem.db', description: 'Migrate a custom DB' },
1476
1481
  ],
1477
1482
  action: async (ctx) => {
1478
- const dbPath = ctx.flags.db || memoryDbPath(process.cwd());
1483
+ const dbPath = ctx.flags.db || memoryDbPath(resolveStateRoot());
1479
1484
  try {
1480
1485
  const ran = await runEmbeddingsMigrationIfNeeded({ dbPath });
1481
1486
  return { success: true, data: { ran } };
@@ -8,6 +8,9 @@ import { confirm } from '../prompt.js';
8
8
  import { callMCPTool, MCPClientError } from '../mcp-client.js';
9
9
  import { memoryDbCandidatePaths } from '../services/moflo-paths.js';
10
10
  import { errorDetail } from '../shared/utils/error-detail.js';
11
+ // #1315 — the intelligence/maturity probes below read `.moflo/` state; anchor
12
+ // them at the resolved root so a subdirectory invocation doesn't report zeroes.
13
+ import { resolveStateRoot } from '../services/project-root.js';
11
14
  // Hook types
12
15
  const HOOK_TYPES = [
13
16
  { value: 'pre-edit', label: 'Pre-Edit', hint: 'Get context before editing files' },
@@ -2501,7 +2504,7 @@ const statuslineCommand = {
2501
2504
  const { execSync } = await import('child_process');
2502
2505
  // Get learning stats from memory database
2503
2506
  function getLearningStats() {
2504
- const memoryPaths = memoryDbCandidatePaths(process.cwd());
2507
+ const memoryPaths = memoryDbCandidatePaths(resolveStateRoot());
2505
2508
  let patterns = 0;
2506
2509
  let sessions = 0;
2507
2510
  let trajectories = 0;
@@ -2611,7 +2614,7 @@ const statuslineCommand = {
2611
2614
  let intelligencePct = 0;
2612
2615
  // 1. Check learning.json for REAL intelligence metrics first
2613
2616
  const learningJsonPaths = [
2614
- path.join(process.cwd(), '.moflo', 'learning.json'),
2617
+ path.join(resolveStateRoot(), '.moflo', 'learning.json'),
2615
2618
  path.join(process.cwd(), '.claude', '.moflo', 'learning.json'),
2616
2619
  path.join(process.cwd(), '.swarm', 'learning.json'),
2617
2620
  ];
@@ -2639,7 +2642,7 @@ const statuslineCommand = {
2639
2642
  // Check for key project files/dirs
2640
2643
  if (fs.existsSync(path.join(process.cwd(), '.claude')))
2641
2644
  maturityScore += 15;
2642
- if (fs.existsSync(path.join(process.cwd(), '.moflo')))
2645
+ if (fs.existsSync(path.join(resolveStateRoot(), '.moflo')))
2643
2646
  maturityScore += 15;
2644
2647
  if (fs.existsSync(path.join(process.cwd(), 'CLAUDE.md')))
2645
2648
  maturityScore += 10;
@@ -2754,10 +2757,10 @@ const statuslineCommand = {
2754
2757
  const agentdbStats = { vectorCount: 0, dbSizeKB: 0, hasHnsw: false };
2755
2758
  // Check for direct database files first. Canonical first, legacies after.
2756
2759
  const dbPaths = [
2757
- ...memoryDbCandidatePaths(process.cwd()),
2760
+ ...memoryDbCandidatePaths(resolveStateRoot()),
2758
2761
  path.join(process.cwd(), 'memory.db'),
2759
2762
  path.join(process.cwd(), '.agentdb', 'memory.db'),
2760
- path.join(process.cwd(), '.moflo', 'memory', 'agentdb.db'),
2763
+ path.join(resolveStateRoot(), '.moflo', 'memory', 'agentdb.db'),
2761
2764
  ];
2762
2765
  for (const dbPath of dbPaths) {
2763
2766
  if (fs.existsSync(dbPath)) {
@@ -2774,7 +2777,7 @@ const statuslineCommand = {
2774
2777
  // Check for AgentDB directories if no direct db found
2775
2778
  if (agentdbStats.vectorCount === 0) {
2776
2779
  const agentdbDirs = [
2777
- path.join(process.cwd(), '.moflo', 'agentdb'),
2780
+ path.join(resolveStateRoot(), '.moflo', 'agentdb'),
2778
2781
  path.join(process.cwd(), '.swarm', 'agentdb'),
2779
2782
  path.join(process.cwd(), 'data', 'agentdb'),
2780
2783
  path.join(process.cwd(), '.agentdb'),
@@ -2801,7 +2804,7 @@ const statuslineCommand = {
2801
2804
  }
2802
2805
  // Check for HNSW index files
2803
2806
  const hnswPaths = [
2804
- path.join(process.cwd(), '.moflo', 'hnsw'),
2807
+ path.join(resolveStateRoot(), '.moflo', 'hnsw'),
2805
2808
  path.join(process.cwd(), '.swarm', 'hnsw'),
2806
2809
  path.join(process.cwd(), 'data', 'hnsw'),
2807
2810
  ];
@@ -2822,7 +2825,7 @@ const statuslineCommand = {
2822
2825
  }
2823
2826
  }
2824
2827
  // Check for vectors.json file
2825
- const vectorsPath = path.join(process.cwd(), '.moflo', 'vectors.json');
2828
+ const vectorsPath = path.join(resolveStateRoot(), '.moflo', 'vectors.json');
2826
2829
  if (fs.existsSync(vectorsPath) && agentdbStats.vectorCount === 0) {
2827
2830
  try {
2828
2831
  const data = JSON.parse(fs.readFileSync(vectorsPath, 'utf-8'));
@@ -9,6 +9,7 @@ import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { LEGACY_SWARM_DIR, memoryDbCandidatePaths, mofloDir } from '../services/moflo-paths.js';
11
11
  import { findProjectRoot } from '../services/project-root.js';
12
+ import { resolveStateRoot } from '../services/project-root.js';
12
13
  // Get dynamic swarm status from memory/session files
13
14
  function getSwarmStatus(swarmId) {
14
15
  const projectRoot = findProjectRoot();
@@ -21,7 +22,7 @@ function getSwarmStatus(swarmId) {
21
22
  const canonicalSwarmDir = path.join(mofloDir(projectRoot), 'swarm');
22
23
  const legacySwarmDir = path.join(projectRoot, LEGACY_SWARM_DIR);
23
24
  const sessionDir = path.join(process.cwd(), '.claude', 'sessions');
24
- const memoryPaths = memoryDbCandidatePaths(process.cwd());
25
+ const memoryPaths = memoryDbCandidatePaths(resolveStateRoot());
25
26
  // Check for active swarm state file — canonical first, then legacy.
26
27
  let swarmStateFile = path.join(canonicalSwarmDir, 'state.json');
27
28
  if (!fs.existsSync(swarmStateFile)) {
@@ -12,6 +12,7 @@ import { runStartupUpdateCheck } from './update/index.js';
12
12
  import { loadMofloConfig } from './config/moflo-config.js';
13
13
  import { getDaemonLockHolder } from './services/daemon-lock.js';
14
14
  import { registerBackgroundPid } from './services/process-registry.js';
15
+ import { resolveStateRoot } from './services/project-root.js';
15
16
  import { VERSION } from './version.js';
16
17
  export { VERSION };
17
18
  const LONG_RUNNING_COMMANDS = ['mcp', 'daemon'];
@@ -479,25 +480,36 @@ export class CLI {
479
480
  // wants autostart can delete it in its own beforeEach, same as the others.
480
481
  if (process.env.MOFLO_TEST_SKIP_DAEMON_AUTOSTART === '1')
481
482
  return;
482
- const config = loadMofloConfig(process.cwd());
483
+ // #1315 anchor on the resolved project root, NOT cwd. Pre-fix, a `flo`
484
+ // invocation from a sub-workspace read the config from there, looked for a
485
+ // lock there (never finding the root daemon's, so the "already running?"
486
+ // check below always passed), and then spawned a second daemon bound to a
487
+ // freshly-minted `.moflo/` island.
488
+ const projectRoot = resolveStateRoot();
489
+ const config = loadMofloConfig(projectRoot);
483
490
  if (!config.daemon.auto_start)
484
491
  return;
485
- // Already running?
486
- const holder = getDaemonLockHolder(process.cwd());
492
+ // Already running? Checked at the resolved root so a subdirectory
493
+ // invocation sees the root daemon instead of spawning a rival.
494
+ const holder = getDaemonLockHolder(projectRoot);
487
495
  if (holder)
488
496
  return;
489
497
  // Dynamically import to avoid circular deps and keep CLI startup fast
490
498
  const { spawn } = await import('child_process');
491
499
  const { join } = await import('path');
492
- const { existsSync, openSync, mkdirSync } = await import('fs');
500
+ const { existsSync, openSync } = await import('fs');
493
501
  const { locateMofloCliBin } = await import('./services/moflo-require.js');
494
502
  const cliPath = locateMofloCliBin();
495
503
  if (!cliPath)
496
504
  return;
497
- const projectRoot = process.cwd();
498
505
  const stateDir = join(projectRoot, '.moflo');
506
+ // #1315 — an implicit auto-start must never MINT state. If there's no
507
+ // `.moflo/` at the resolved root this isn't an initialized moflo project,
508
+ // and creating one here is what produced stray islands seven levels deep
509
+ // inside consumer source trees. `flo init` and the explicit `daemon start`
510
+ // path still create it; auto-start only ever attaches to what exists.
499
511
  if (!existsSync(stateDir))
500
- mkdirSync(stateDir, { recursive: true });
512
+ return;
501
513
  const logFile = join(stateDir, 'daemon.log');
502
514
  const isWin = process.platform === 'win32';
503
515
  const spawnArgs = [cliPath, 'daemon', 'start', '--foreground', '--quiet'];
@@ -9,7 +9,7 @@ import * as fs from 'fs';
9
9
  import * as crypto from 'crypto';
10
10
  import { atomicWriteFileSync } from '../services/atomic-file-write.js';
11
11
  import { legacyMemoryDbPath, memoryDbPath, MOFLO_DIR, } from '../services/moflo-paths.js';
12
- import { findProjectRoot } from '../services/project-root.js';
12
+ import { resolveStateRoot } from '../services/project-root.js';
13
13
  // When run via npx, CWD may be node_modules/moflo — walk up to find actual project
14
14
  let _projectRoot;
15
15
  /**
@@ -66,7 +66,13 @@ export function searchCandidateCap() {
66
66
  function getProjectRoot() {
67
67
  if (_projectRoot)
68
68
  return _projectRoot;
69
- _projectRoot = findProjectRoot();
69
+ // #1315 — MUST be the same resolver `entries-read`/`entries-write` use, or
70
+ // the bridge and the entry writers open DIFFERENT `.moflo/moflo.db` files.
71
+ // `findProjectRoot()` returns `CLAUDE_PROJECT_DIR` unvalidated, so in a
72
+ // Claude Code session rooted at a sub-workspace the bridge would anchor
73
+ // there while the writers anchored at the true root — the split-brain this
74
+ // module's own header warns about.
75
+ _projectRoot = resolveStateRoot();
70
76
  return _projectRoot;
71
77
  }
72
78
  import { ControllerRegistry } from './controller-registry.js';
@@ -37,14 +37,14 @@
37
37
  * @module cli/memory/daemon-write-client
38
38
  */
39
39
  import * as http from 'node:http';
40
- import { findProjectRoot } from '../services/project-root.js';
40
+ import { resolveStateRoot } from '../services/project-root.js';
41
41
  import { resolveClientPort, LEGACY_DEFAULT_PORT, probeDaemonHealth as probeDaemonHealthIdentity, normalizeProjectRoot, } from '../services/daemon-port.js';
42
42
  // ============================================================================
43
43
  // Constants
44
44
  // ============================================================================
45
45
  /**
46
46
  * Read-only legacy default exported for tests; the actual port comes from
47
- * `getDaemonPort()` which delegates to `resolveClientPort(findProjectRoot())`.
47
+ * `getDaemonPort()` which delegates to `resolveClientPort(resolveStateRoot())`.
48
48
  * Routes through `LEGACY_DEFAULT_PORT` so no literal port number lives in
49
49
  * this file — see `daemon-port.ts` and the no-fixed-port regression guard.
50
50
  */
@@ -72,7 +72,7 @@ export function _resetForTest() {
72
72
  /**
73
73
  * Resolve the daemon HTTP port for this project.
74
74
  *
75
- * Delegates to `resolveClientPort(findProjectRoot())`:
75
+ * Delegates to `resolveClientPort(resolveStateRoot())`:
76
76
  * 1. `MOFLO_DAEMON_PORT` env override (consumer pin)
77
77
  * 2. `port` field in `<projectRoot>/.moflo/daemon.lock` (server records
78
78
  * the actual bound port after startup — #1145)
@@ -85,7 +85,7 @@ export function _resetForTest() {
85
85
  */
86
86
  let _portCache = null;
87
87
  function getDaemonPort() {
88
- const projectRoot = findProjectRoot();
88
+ const projectRoot = resolveStateRoot();
89
89
  if (_portCache && _portCache.projectRoot === projectRoot)
90
90
  return _portCache.port;
91
91
  const port = resolveClientPort(projectRoot);
@@ -173,7 +173,7 @@ export async function isDaemonAvailable() {
173
173
  */
174
174
  async function isDaemonIdentityMatch(port) {
175
175
  const now = Date.now();
176
- const ourProjectRoot = findProjectRoot();
176
+ const ourProjectRoot = resolveStateRoot();
177
177
  if (identityCache &&
178
178
  identityCache.ourProjectRoot === ourProjectRoot &&
179
179
  (now - identityCache.checkedAt) < HEALTH_CACHE_TTL_MS) {
@@ -11,6 +11,7 @@
11
11
  import { platform } from 'node:os';
12
12
  import { existsSync } from 'node:fs';
13
13
  import { SqliteBackend } from './sqlite-backend.js';
14
+ import { resolveStateRoot } from '../services/project-root.js';
14
15
  /**
15
16
  * Canonical label returned in MCP `backend` fields and other consumer-visible
16
17
  * surfaces. Single source of truth so a future engine swap is a one-line edit
@@ -201,19 +202,27 @@ export function getPlatformInfo() {
201
202
  * Wrapped in a dynamic import so the memory subtree doesn't pull
202
203
  * `js-yaml` / `fs` into hot paths (e.g. the in-memory test backend).
203
204
  *
204
- * Memoised per (cwd, process) — a test suite or daemon that opens many
205
- * DBs in sequence parses moflo.yaml once. Keyed on cwd so a test that
206
- * `chdir`s into a temp dir gets a fresh resolution.
205
+ * Memoised per (resolved state root, process) — a test suite or daemon that
206
+ * opens many DBs in sequence parses moflo.yaml once. Keyed on the resolved
207
+ * root so a test that `chdir`s into a temp dir still gets a fresh resolution
208
+ * (an un-initialized temp dir resolves to itself).
209
+ *
210
+ * #1315 — this reads the BACKEND while the rest of the memory layer resolves
211
+ * the DB PATH. Both must anchor on the same root or they disagree: with the
212
+ * path fixed to `<root>/.moflo/moflo.db` but the config still read from cwd, a
213
+ * `flo` invocation from a sub-directory found no moflo.yaml, fell back to the
214
+ * DEFAULT backend, and opened the root's database with a different engine than
215
+ * the daemon was using it with.
207
216
  */
208
217
  const _resolvedProviderCache = new Map();
209
218
  async function preferredProviderFromConfig(verbose) {
210
- const key = process.cwd();
219
+ const key = resolveStateRoot();
211
220
  if (_resolvedProviderCache.has(key)) {
212
221
  return _resolvedProviderCache.get(key) ?? null;
213
222
  }
214
223
  try {
215
224
  const { loadMofloConfig, resolveDatabaseProvider } = await import('../config/moflo-config.js');
216
- const cfg = loadMofloConfig();
225
+ const cfg = loadMofloConfig(key);
217
226
  const resolved = resolveDatabaseProvider(cfg.memory.backend);
218
227
  if (verbose) {
219
228
  console.log(`[DatabaseProvider] moflo.yaml memory.backend="${cfg.memory.backend}" → ${resolved}`);
@@ -19,6 +19,7 @@ import { getBridge } from './bridge-loader.js';
19
19
  import { tryDaemonGet, tryDaemonSearch, tryDaemonList } from './daemon-write-client.js';
20
20
  import { searchCandidateCap } from './bridge-core.js';
21
21
  import { cosineSim, logRoutingFault } from './entries-shared.js';
22
+ import { resolveStateRoot } from '../services/project-root.js';
22
23
  /**
23
24
  * Search entries via node:sqlite with vector similarity.
24
25
  * Uses HNSW approximate-nearest-neighbor (ANN) index for search when available.
@@ -65,7 +66,7 @@ export async function searchEntries(options) {
65
66
  }
66
67
  // Fallback: direct node:sqlite write via the unified factory.
67
68
  const { query, namespace = 'default', limit = 10, threshold = 0.3, dbPath: customPath } = options;
68
- const dbPath = customPath || memoryDbPath(process.cwd());
69
+ const dbPath = customPath || memoryDbPath(resolveStateRoot());
69
70
  const startTime = Date.now();
70
71
  try {
71
72
  if (!fs.existsSync(dbPath)) {
@@ -186,7 +187,7 @@ export async function listEntries(options) {
186
187
  }
187
188
  // Fallback: direct node:sqlite write via the unified factory.
188
189
  const { namespace, limit = 20, offset = 0, dbPath: customPath } = options;
189
- const dbPath = customPath || memoryDbPath(process.cwd());
190
+ const dbPath = customPath || memoryDbPath(resolveStateRoot());
190
191
  try {
191
192
  if (!fs.existsSync(dbPath)) {
192
193
  return { success: false, entries: [], total: 0, error: 'Database not found' };
@@ -272,7 +273,7 @@ export async function getEntry(options) {
272
273
  }
273
274
  // Fallback: direct node:sqlite write via the unified factory.
274
275
  const { key, namespace = 'default', dbPath: customPath } = options;
275
- const dbPath = customPath || memoryDbPath(process.cwd());
276
+ const dbPath = customPath || memoryDbPath(resolveStateRoot());
276
277
  try {
277
278
  if (!fs.existsSync(dbPath)) {
278
279
  return { success: false, found: false, error: 'Database not found' };
@@ -350,7 +351,7 @@ export async function getEntry(options) {
350
351
  * wrong-answer this fix is for.
351
352
  */
352
353
  export async function getNamespaceCounts(dbPath) {
353
- const resolvedPath = dbPath || memoryDbPath(process.cwd());
354
+ const resolvedPath = dbPath || memoryDbPath(resolveStateRoot());
354
355
  if (!fs.existsSync(resolvedPath)) {
355
356
  return { namespaces: {}, total: 0, withEmbeddings: 0 };
356
357
  }
@@ -25,6 +25,7 @@ import { toFloat32 } from './controllers/_shared.js';
25
25
  import { serialiseMetadata } from './bridge-entries.js';
26
26
  import { logRoutingFault, writeVectorStatsCache } from './entries-shared.js';
27
27
  import { writeThroughDurable } from '../services/durable-sync.js';
28
+ import { resolveStateRoot } from '../services/project-root.js';
28
29
  /**
29
30
  * Propagate a just-persisted durable write to the shared store (#1232). The
30
31
  * caller passes the project root the row actually landed under so the flush
@@ -139,7 +140,7 @@ export async function storeEntry(options) {
139
140
  }
140
141
  // Fallback: direct node:sqlite write via the unified factory.
141
142
  const { key, value, namespace = 'default', generateEmbeddingFlag = true, tags = [], ttl, dbPath: customPath, upsert = false } = options;
142
- const dbPath = customPath || memoryDbPath(process.cwd());
143
+ const dbPath = customPath || memoryDbPath(resolveStateRoot());
143
144
  try {
144
145
  if (!fs.existsSync(dbPath)) {
145
146
  return { success: false, id: '', error: 'Database not initialized. Run: flo memory init' };
@@ -362,7 +363,7 @@ export async function deleteEntry(options) {
362
363
  }
363
364
  // Fallback: direct node:sqlite write via the unified factory.
364
365
  const { key, namespace = 'default', dbPath: customPath } = options;
365
- const dbPath = customPath || memoryDbPath(process.cwd());
366
+ const dbPath = customPath || memoryDbPath(resolveStateRoot());
366
367
  try {
367
368
  if (!fs.existsSync(dbPath)) {
368
369
  return {
@@ -20,6 +20,7 @@ import { parseEmbeddingJson } from './controllers/_shared.js';
20
20
  import { memoryDbPath } from '../services/moflo-paths.js';
21
21
  import { openDaemonDatabase } from './daemon-backend.js';
22
22
  import { getBridge, isBridgeLoaded } from './bridge-loader.js';
23
+ import { resolveStateRoot } from '../services/project-root.js';
23
24
  let hnswIndex = null;
24
25
  let hnswInitializing = false;
25
26
  /**
@@ -44,7 +45,7 @@ export async function getHNSWIndex(options) {
44
45
  try {
45
46
  // Use HnswLite pure TS implementation (no native dependencies).
46
47
  // Persistent storage paths — colocated with the canonical memory DB.
47
- const dbPath = options?.dbPath || memoryDbPath(process.cwd());
48
+ const dbPath = options?.dbPath || memoryDbPath(resolveStateRoot());
48
49
  const dbDir = path.dirname(dbPath);
49
50
  if (!fs.existsSync(dbDir)) {
50
51
  fs.mkdirSync(dbDir, { recursive: true });
@@ -15,6 +15,7 @@ import { MOFLO_DIR, legacyMemoryDbPath, memoryDbPath, } from '../services/moflo-
15
15
  import { openDaemonDatabase } from './daemon-backend.js';
16
16
  import { getBridge } from './bridge-loader.js';
17
17
  import { MEMORY_SCHEMA_V3, getInitialMetadata } from './schema.js';
18
+ import { resolveStateRoot } from '../services/project-root.js';
18
19
  /**
19
20
  * Check for legacy database installations and migrate if needed
20
21
  */
@@ -25,12 +26,17 @@ export async function checkAndMigrateLegacy(options) {
25
26
  // probe still catches consumers whose launcher migration deferred. The
26
27
  // bare `memory.db` and `.claude/memory.db`/`data/memory.db` entries are
27
28
  // older still.
29
+ // #1315 — every probe anchors on the RESOLVED root, not cwd. These look for
30
+ // a pre-#727 database to migrate from, and that database lives at the
31
+ // project root; probing cwd meant a `flo` run from any sub-directory found
32
+ // nothing and silently skipped the migration.
33
+ const stateRoot = resolveStateRoot();
28
34
  const legacyPaths = [
29
- legacyMemoryDbPath(process.cwd()),
30
- path.join(process.cwd(), MOFLO_DIR, 'memory.db'),
31
- path.join(process.cwd(), 'memory.db'),
32
- path.join(process.cwd(), '.claude', 'memory.db'),
33
- path.join(process.cwd(), 'data', 'memory.db'),
35
+ legacyMemoryDbPath(stateRoot),
36
+ path.join(stateRoot, MOFLO_DIR, 'memory.db'),
37
+ path.join(stateRoot, 'memory.db'),
38
+ path.join(stateRoot, '.claude', 'memory.db'),
39
+ path.join(stateRoot, 'data', 'memory.db'),
34
40
  ];
35
41
  for (const legacyPath of legacyPaths) {
36
42
  if (fs.existsSync(legacyPath) && legacyPath !== dbPath) {
@@ -148,7 +154,7 @@ function unlinkWithRetry(filePath) {
148
154
  */
149
155
  export async function initializeMemoryDatabase(options) {
150
156
  const { backend = 'hybrid', dbPath: customPath, force = false, verbose = false, migrate = true } = options;
151
- const dbPath = customPath || memoryDbPath(process.cwd());
157
+ const dbPath = customPath || memoryDbPath(resolveStateRoot());
152
158
  const dbDir = path.dirname(dbPath);
153
159
  try {
154
160
  // Create directory if needed
@@ -290,7 +296,7 @@ export async function initializeMemoryDatabase(options) {
290
296
  * Check if memory database is properly initialized
291
297
  */
292
298
  export async function checkMemoryInitialization(dbPath) {
293
- const path_ = dbPath || memoryDbPath(process.cwd());
299
+ const path_ = dbPath || memoryDbPath(resolveStateRoot());
294
300
  if (!fs.existsSync(path_)) {
295
301
  return { initialized: false };
296
302
  }
@@ -334,7 +340,7 @@ export async function checkMemoryInitialization(dbPath) {
334
340
  * Reduces confidence of patterns that haven't been used recently
335
341
  */
336
342
  export async function applyTemporalDecay(dbPath) {
337
- const path_ = dbPath || memoryDbPath(process.cwd());
343
+ const path_ = dbPath || memoryDbPath(resolveStateRoot());
338
344
  try {
339
345
  const db = openDaemonDatabase(path_);
340
346
  // Apply decay: confidence *= exp(-decay_rate * days_since_last_use)
@@ -18,6 +18,7 @@
18
18
  import * as fs from 'fs';
19
19
  import { memoryDbPath } from '../services/moflo-paths.js';
20
20
  import { openDaemonDatabase } from './daemon-backend.js';
21
+ import { resolveStateRoot } from '../services/project-root.js';
21
22
  /** The namespace this overview is scoped to. */
22
23
  const LEARNINGS_NAMESPACE = 'learnings';
23
24
  /** Default number of most-recent learnings returned with their bodies. */
@@ -87,7 +88,7 @@ export async function getLearningsOverview(options) {
87
88
  // finite positive integer so a stray float/NaN/≤0 can't produce bad SQL.
88
89
  const recentLimit = toPositiveInt(options?.recentLimit, DEFAULT_RECENT_LIMIT);
89
90
  const bodyCap = toPositiveInt(options?.bodyCap, DEFAULT_BODY_CAP);
90
- const resolvedPath = options?.dbPath || memoryDbPath(process.cwd());
91
+ const resolvedPath = options?.dbPath || memoryDbPath(resolveStateRoot());
91
92
  if (!fs.existsSync(resolvedPath)) {
92
93
  return { ...EMPTY };
93
94
  }
@@ -18,6 +18,7 @@ import { HeadlessWorkerExecutor, HEADLESS_WORKER_TYPES } from '../services/headl
18
18
  import { getDaemon, startDaemon, stopDaemon } from '../services/worker-daemon.js';
19
19
  import { attachSignalHandlers } from '../shared/resilience/signal-handlers.js';
20
20
  import { errorDetail } from '../shared/utils/error-detail.js';
21
+ import { resolveStateRoot } from '../services/project-root.js';
21
22
  import { initializeIntelligence, benchmarkAdaptation, getIntelligenceStats } from '../memory/intelligence.js';
22
23
  import { getHNSWStatus, batchCosineSim, flashAttentionSearch } from '../memory/memory-initializer.js';
23
24
  import { readMofloEnv } from '../services/env-compat.js';
@@ -100,7 +101,10 @@ Examples:
100
101
  */
101
102
  async function runWorker(workerType, timeout) {
102
103
  console.log(`[Headless] Starting worker: ${workerType}`);
103
- const executor = new HeadlessWorkerExecutor(process.cwd(), {
104
+ // #1315 the HeadlessWorkerExecutor constructor unconditionally creates
105
+ // `<root>/.moflo/reports` and `<root>/.moflo/logs/headless`, so handing it
106
+ // raw cwd minted an island wherever the headless runtime happened to start.
107
+ const executor = new HeadlessWorkerExecutor(resolveStateRoot(), {
104
108
  maxConcurrent: 1,
105
109
  defaultTimeoutMs: timeout
106
110
  });
@@ -131,8 +135,9 @@ async function runWorker(workerType, timeout) {
131
135
  */
132
136
  async function runDaemon() {
133
137
  console.log('[Headless] Starting daemon mode...');
134
- // Start the daemon
135
- const daemon = await startDaemon(process.cwd());
138
+ // Start the daemon. #1315 — resolved root, not cwd: `startDaemon` builds a
139
+ // WorkerDaemon and that constructor creates the state dir it is handed.
140
+ const daemon = await startDaemon(resolveStateRoot());
136
141
  console.log('[Headless] Daemon started');
137
142
  console.log('[Headless] Press Ctrl+C to stop');
138
143
  attachSignalHandlers(async (sig) => {
@@ -18,7 +18,7 @@ import { handleMemoryStore, handleMemoryDelete, handleMemoryBatch, handleMemoryG
18
18
  import { aggregateClaudeStats, emptyClaudeStatsShape } from './claude-stats.js';
19
19
  import { serverPortCandidates, LEGACY_DEFAULT_PORT } from './daemon-port.js';
20
20
  import { writeLockPort } from './daemon-lock.js';
21
- import { findProjectRoot } from './project-root.js';
21
+ import { resolveStateRoot } from './project-root.js';
22
22
  import { readOwnMofloVersion } from './daemon-lock.js';
23
23
  /**
24
24
  * Legacy default port retained as a re-export of {@link LEGACY_DEFAULT_PORT}
@@ -144,7 +144,7 @@ function tryParseSafe(s) {
144
144
  * Build the `/api/health` response (#1145).
145
145
  *
146
146
  * Identity payload — clients compare `projectRoot` against their own
147
- * `findProjectRoot()` and refuse to route to this daemon on mismatch.
147
+ * `resolveStateRoot()` and refuse to route to this daemon on mismatch.
148
148
  * Also surfaces `pid`, `version`, and `uptimeMs` for healer-class
149
149
  * diagnostics and orphan-daemon detection.
150
150
  *
@@ -155,7 +155,7 @@ function handleHealth(daemon, opts) {
155
155
  const startedAt = status.startedAt instanceof Date ? status.startedAt : null;
156
156
  return {
157
157
  status: 'ok',
158
- projectRoot: opts.projectRoot ?? findProjectRoot(),
158
+ projectRoot: opts.projectRoot ?? resolveStateRoot(),
159
159
  pid: status.pid ?? process.pid,
160
160
  version: readOwnMofloVersion() ?? null,
161
161
  uptimeMs: startedAt ? Date.now() - startedAt.getTime() : 0,
@@ -664,7 +664,7 @@ const MAX_PORT_ATTEMPTS = 10;
664
664
  * @returns handle whose `.port` field reflects the actually bound port
665
665
  */
666
666
  export async function startDashboard(daemon, opts) {
667
- const projectRoot = opts.projectRoot ?? findProjectRoot();
667
+ const projectRoot = opts.projectRoot ?? resolveStateRoot();
668
668
  const candidates = buildBindCandidates(opts.port, projectRoot, MAX_PORT_ATTEMPTS);
669
669
  let lastErr = null;
670
670
  for (let i = 0; i < candidates.length; i++) {