moflo 4.12.2 → 4.12.3

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
package/bin/hooks.mjs CHANGED
@@ -26,7 +26,7 @@ import { fileURLToPath, pathToFileURL } from 'url';
26
26
  import { createProcessManager } from './lib/process-manager.mjs';
27
27
  import { shouldDaemonAutoStart } from './lib/daemon-config.mjs';
28
28
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
29
- import { findProjectRoot } from './lib/moflo-paths.mjs';
29
+ import { resolveStateRoot } from './lib/moflo-paths.mjs';
30
30
 
31
31
  const __filename = fileURLToPath(import.meta.url);
32
32
  const __dirname = dirname(__filename);
@@ -35,7 +35,10 @@ const __dirname = dirname(__filename);
35
35
  // in bin/ during development but gets synced to .claude/scripts/ in consumer
36
36
  // projects, so __dirname-relative paths break. findProjectRoot() works
37
37
  // everywhere and resolves identically to the TS bridge (see lib/moflo-paths.mjs).
38
- const projectRoot = findProjectRoot();
38
+ // #1315 resolveStateRoot, NOT findProjectRoot: this layer mkdirs
39
+ // `.moflo/` state, and findProjectRoot honors CLAUDE_PROJECT_DIR verbatim,
40
+ // so a sub-workspace-rooted session minted an island here every start.
41
+ const projectRoot = resolveStateRoot();
39
42
  const logFile = resolve(projectRoot, '.moflo', 'logs', 'hooks.log');
40
43
  try { mkdirSync(dirname(logFile), { recursive: true }); } catch { /* best effort */ }
41
44
  const pm = createProcessManager(projectRoot);
package/bin/index-all.mjs CHANGED
@@ -111,6 +111,41 @@ function killProcessTree(child) {
111
111
  }
112
112
  }
113
113
 
114
+ // The step currently in flight, so a termination signal can reap it (#1317).
115
+ let currentChild = null;
116
+ let shuttingDown = false;
117
+
118
+ // Reap on SIGTERM/SIGINT (#1317). hooks.mjs session-end calls pm.killAll(),
119
+ // which SIGTERMs THIS process. Without a handler Node's default action kills us
120
+ // instantly and the in-flight step is orphaned: it lives in its OWN process
121
+ // group (detached:true in runStep, below), so the signal aimed at us never
122
+ // reaches it, and no group-kill of our group can either. It reparents to init
123
+ // and keeps burning CPU and writing .moflo/moflo.db after the session is gone —
124
+ // build-embeddings most often, the same ~2 GB fastembed process as #744.
125
+ // killProcessTree is the teardown primitive #744 already built; it was simply
126
+ // never wired to the signal path.
127
+ function handleTerminationSignal(signal) {
128
+ if (shuttingDown) return; // second signal while tearing down — ignore
129
+ shuttingDown = true;
130
+ log(`SIGNAL ${signal} — reaping in-flight step before exit`);
131
+ // Synchronous on both platforms: process.kill delivers before returning,
132
+ // spawnSync('taskkill') blocks. Safe to process.exit() immediately after.
133
+ killProcessTree(currentChild);
134
+ currentChild = null;
135
+ process.exit(signal === 'SIGINT' ? 130 : 143);
136
+ }
137
+
138
+ // 'SIGTERM' is never delivered on Windows — Node permits the listener but it
139
+ // cannot fire, and Windows is already correct via taskkill /T. Registering it
140
+ // unconditionally is therefore harmless, not a portability assumption.
141
+ // 'SIGBREAK' is the Windows-only console-termination signal.
142
+ for (const sig of ['SIGTERM', 'SIGINT']) {
143
+ try { process.on(sig, () => handleTerminationSignal(sig)); } catch { /* signal unsupported here */ }
144
+ }
145
+ if (platform() === 'win32') {
146
+ try { process.on('SIGBREAK', () => handleTerminationSignal('SIGBREAK')); } catch { /* ignore */ }
147
+ }
148
+
114
149
  function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
115
150
  return new Promise((resolveStep) => {
116
151
  const start = Date.now();
@@ -122,6 +157,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
122
157
  detached: platform() !== 'win32', // POSIX: own process group for tree-kill
123
158
  env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
124
159
  });
160
+ currentChild = child;
125
161
  let timedOut = false;
126
162
  const timer = setTimeout(() => {
127
163
  timedOut = true;
@@ -129,6 +165,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
129
165
  }, timeoutMs);
130
166
  child.once('exit', (code, signal) => {
131
167
  clearTimeout(timer);
168
+ if (currentChild === child) currentChild = null;
132
169
  const elapsed = ((Date.now() - start) / 1000).toFixed(1);
133
170
  if (timedOut) {
134
171
  log(`FAIL ${label} (${elapsed}s): timed out after ${timeoutMs}ms, child killed`);
@@ -143,6 +180,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
143
180
  });
144
181
  child.once('error', (err) => {
145
182
  clearTimeout(timer);
183
+ if (currentChild === child) currentChild = null;
146
184
  const elapsed = ((Date.now() - start) / 1000).toFixed(1);
147
185
  log(`FAIL ${label} (${elapsed}s): ${err.message?.split('\n')[0] || 'unknown'}`);
148
186
  resolveStep(false);
@@ -12,7 +12,7 @@
12
12
  * helpers no longer ship: the version-bump-gated cherry-pick lives entirely
13
13
  * in the launcher and the TS service `cli/services/cherry-pick-learnings.ts`.
14
14
  */
15
- import { existsSync } from 'node:fs';
15
+ import { existsSync, realpathSync } from 'node:fs';
16
16
  import { basename, dirname, join, parse, resolve } from 'node:path';
17
17
 
18
18
  export const MOFLO_DIR = '.moflo';
@@ -127,6 +127,42 @@ export function findAncestorMofloRoot(dir) {
127
127
  * @param {{ cwd?: string; honorEnv?: boolean }} [opts]
128
128
  * @returns {string} absolute project root
129
129
  */
130
+ /**
131
+ * Pure-JS twin of `src/cli/services/project-root.ts:resolveStateRoot()` (#1315).
132
+ *
133
+ * Use this — never `findProjectRoot()` — anywhere the bin/hook layer is about
134
+ * to CREATE `.moflo/` state. `findProjectRoot` returns `CLAUDE_PROJECT_DIR`
135
+ * verbatim, and Claude Code sets that to the SESSION directory; in a monorepo
136
+ * session rooted at a sub-workspace the launcher therefore mkdir'd a `.moflo/`
137
+ * island there every session. That made the healer's nested-island fix a
138
+ * perpetual loop: archive it, next session re-mints it.
139
+ *
140
+ * Semantics (kept in lockstep with the TS side): an EXISTING
141
+ * `CLAUDE_PROJECT_DIR` wins outright and is never climbed above — the marker
142
+ * walk has no upper bound, so climbing past an explicit anchor would let a
143
+ * stray ancestor `.moflo/moflo.db` capture every project beneath it. Otherwise
144
+ * walk up from cwd and take the topmost marker, which is what stops a
145
+ * sub-directory invocation from minting an island where it stands. A value
146
+ * naming a directory that does not exist is ignored rather than materialized.
147
+ *
148
+ * @param {{ cwd?: string }} [opts]
149
+ * @returns {string} absolute project root
150
+ */
151
+ export function resolveStateRoot(opts) {
152
+ const envDir = process.env.CLAUDE_PROJECT_DIR;
153
+ const envAbs = envDir ? resolve(envDir) : undefined;
154
+ const resolved = envAbs && existsSync(envAbs)
155
+ ? envAbs
156
+ : findProjectRoot({ honorEnv: false, cwd: opts?.cwd });
157
+ // Canonicalize so this compares equal to the realpath'd root the TS side
158
+ // derives (macOS /var → /private/var). Falls back when the path is absent.
159
+ try {
160
+ return realpathSync(resolve(resolved));
161
+ } catch {
162
+ return resolve(resolved);
163
+ }
164
+ }
165
+
130
166
  export function findProjectRoot(opts) {
131
167
  const honorEnv = opts?.honorEnv !== false;
132
168
  if (honorEnv && process.env.CLAUDE_PROJECT_DIR) {
@@ -219,7 +219,22 @@ export function createProcessManager(root) {
219
219
  if (process.platform === 'win32') {
220
220
  execFileSync('taskkill', ['/T', '/F', '/PID', String(entry.pid)], { windowsHide: true, timeout: 10000 });
221
221
  } else {
222
- process.kill(entry.pid, 'SIGTERM');
222
+ // POSIX tree-kill (#1317). Windows walks the tree via taskkill /T;
223
+ // a bare SIGTERM here only hit the one PID, so anything the tracked
224
+ // process left in its group survived session-end. Everything spawn()
225
+ // launches is detached (setsid), so the tracked PID leads its own
226
+ // group and the negative PID reaches those children.
227
+ //
228
+ // NOTE this does NOT reach grandchildren that called setsid again —
229
+ // index-all's steps do exactly that, which is why index-all reaps
230
+ // them itself on SIGTERM. This is defence in depth, not the whole fix.
231
+ try {
232
+ process.kill(-entry.pid, 'SIGTERM');
233
+ } catch {
234
+ // No such process group — e.g. an entry registered by
235
+ // registerBackgroundPid() whose process doesn't lead one.
236
+ process.kill(entry.pid, 'SIGTERM');
237
+ }
223
238
  }
224
239
  killed++;
225
240
  } catch {
@@ -32,7 +32,15 @@ function killTrackedSync(projectDir) {
32
32
  if (process.platform === 'win32') {
33
33
  childProcess.execFileSync('taskkill', ['/T', '/F', '/PID', String(entries[i].pid)], { windowsHide: true, timeout: 5000 });
34
34
  } else {
35
- process.kill(entries[i].pid, 'SIGTERM');
35
+ // POSIX tree-kill — mirrors process-manager.mjs killAll (#1317).
36
+ // This is the second reaper over the same registry (session-end via
37
+ // hook-handler.cjs), so it must not be left behind on the bare-PID
38
+ // kill or session-end orphans depend on which path ran.
39
+ try {
40
+ process.kill(-entries[i].pid, 'SIGTERM');
41
+ } catch (groupErr) {
42
+ process.kill(entries[i].pid, 'SIGTERM');
43
+ }
36
44
  }
37
45
  killed++;
38
46
  } catch (e) { /* ok */ }
@@ -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, findProjectRoot, findAncestorMofloRoot, COMMON_WALK_SKIP_NAMES } from './lib/moflo-paths.mjs';
14
+ import { mofloDir, resolveStateRoot, findAncestorMofloRoot, COMMON_WALK_SKIP_NAMES } 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, writeRetainedRecord, reconcileRetainedRecord, RETAINED_RECORD_REL } from './lib/retired-files.mjs';
@@ -62,7 +62,10 @@ function sessionStartMirrorHeader(file) {
62
62
  // first, then walk up for .moflo/moflo.db / .swarm/memory.db / CLAUDE.md+pkg /
63
63
  // package.json / .git. Inline walks here have caused N writers to land on
64
64
  // different DBs than the bridge reads from — never reintroduce one.
65
- const projectRoot = findProjectRoot();
65
+ // #1315 resolveStateRoot, NOT findProjectRoot: this layer mkdirs
66
+ // `.moflo/` state, and findProjectRoot honors CLAUDE_PROJECT_DIR verbatim,
67
+ // so a sub-workspace-rooted session minted an island here every start.
68
+ const projectRoot = resolveStateRoot();
66
69
 
67
70
  // Monorepo nested-.moflo guard (#1174). After the resolver fix, findProjectRoot
68
71
  // returns the topmost ancestor with .moflo/moflo.db, so an ancestor still
@@ -8,6 +8,7 @@ import { select, confirm, input } from '../prompt.js';
8
8
  import { callMCPTool, MCPClientError } from '../mcp-client.js';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
+ import { resolveStateRoot } from '../services/project-root.js';
11
12
  /**
12
13
  * Update swarm-activity.json metrics with absolute agent count.
13
14
  * The statusline reads this file to display the swarm agent count.
@@ -17,7 +18,10 @@ import * as path from 'path';
17
18
  */
18
19
  function syncSwarmAgentCount(absoluteCount) {
19
20
  try {
20
- const metricsDir = path.join(process.cwd(), '.moflo', 'metrics');
21
+ // #1315 resolved root, not cwd. This mkdirs, so a subdirectory
22
+ // invocation minted a `.moflo/metrics/` island; and the statusline reads
23
+ // this file from the ROOT, so a cwd-anchored write was invisible to it.
24
+ const metricsDir = path.join(resolveStateRoot(), '.moflo', 'metrics');
21
25
  const activityPath = path.join(metricsDir, 'swarm-activity.json');
22
26
  let data = {
23
27
  timestamp: new Date().toISOString(),
@@ -48,7 +52,8 @@ function syncSwarmAgentCount(absoluteCount) {
48
52
  */
49
53
  function readSwarmAgentCount() {
50
54
  try {
51
- const activityPath = path.join(process.cwd(), '.moflo', 'metrics', 'swarm-activity.json');
55
+ // #1315 must read from the same root `syncSwarmAgentCount` writes to.
56
+ const activityPath = path.join(resolveStateRoot(), '.moflo', 'metrics', 'swarm-activity.json');
52
57
  if (!fs.existsSync(activityPath))
53
58
  return 0;
54
59
  const data = JSON.parse(fs.readFileSync(activityPath, 'utf-8'));
@@ -8,6 +8,9 @@ import { output } from '../output.js';
8
8
  import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { errorDetail } from '../shared/utils/error-detail.js';
11
+ // #1315 — benchmark results are `.moflo/` state; anchor them at the resolved
12
+ // root so a subdirectory run doesn't mint an island to save into.
13
+ import { resolveStateRoot } from '../services/project-root.js';
11
14
  // ============================================================================
12
15
  // Pretrain Benchmark Subcommand
13
16
  // ============================================================================
@@ -46,7 +49,7 @@ const pretrainCommand = {
46
49
  }
47
50
  // Save to file if requested
48
51
  if (saveFile) {
49
- const resultsDir = join(process.cwd(), '.moflo', 'benchmarks');
52
+ const resultsDir = join(resolveStateRoot(), '.moflo', 'benchmarks');
50
53
  if (!existsSync(resultsDir)) {
51
54
  mkdirSync(resultsDir, { recursive: true });
52
55
  }
@@ -397,7 +400,7 @@ const allCommand = {
397
400
  // Save if requested
398
401
  const saveFile = ctx.flags.save;
399
402
  if (saveFile) {
400
- const resultsDir = join(process.cwd(), '.moflo', 'benchmarks');
403
+ const resultsDir = join(resolveStateRoot(), '.moflo', 'benchmarks');
401
404
  if (!existsSync(resultsDir)) {
402
405
  mkdirSync(resultsDir, { recursive: true });
403
406
  }
@@ -11,10 +11,11 @@ import { startDashboard, createDashboardMemoryAccessor, DEFAULT_DASHBOARD_PORT }
11
11
  import { bootstrapDaemonScheduler } from '../services/daemon-scheduler-bootstrap.js';
12
12
  import { loadMofloConfig } from '../config/moflo-config.js';
13
13
  import { spawn, execFileSync } from 'child_process';
14
- import { join, resolve } from 'path';
14
+ import { join, resolve, isAbsolute } from 'path';
15
15
  import * as fs from 'fs';
16
16
  import { errorDetail } from '../shared/utils/error-detail.js';
17
17
  import { readMofloEnv } from '../services/env-compat.js';
18
+ import { resolveStateRoot } from '../services/project-root.js';
18
19
  /**
19
20
  * Resolve the dashboard port from CLI flag and env, in that precedence order.
20
21
  *
@@ -68,7 +69,13 @@ const startCommand = {
68
69
  const foreground = ctx.flags.foreground;
69
70
  const noDashboard = ctx.flags.noDashboard;
70
71
  const rawDashboardPort = ctx.flags.dashboardPort;
71
- const projectRoot = process.cwd();
72
+ // #1315 the shared chokepoint. Every daemon-start path lands here:
73
+ // `maybeAutoStartDaemon`, the session-start launcher, bin/hooks.mjs, the
74
+ // daemon recycler, the scheduler, and the OS service definitions. Each one
75
+ // spawns with a cwd it inherited from the caller, so anchoring on
76
+ // `process.cwd()` let ANY of them mint a `.moflo/` island in a
77
+ // sub-workspace while resolving its own binary from the root install.
78
+ const projectRoot = resolveStateRoot();
72
79
  const isDaemonProcess = readMofloEnv('DAEMON') === '1';
73
80
  // Resolve dashboard port; see `resolveDashboardPort` for precedence.
74
81
  const portResult = resolveDashboardPort(rawDashboardPort, process.env.MOFLO_DAEMON_PORT);
@@ -314,13 +321,35 @@ function validatePath(path, label) {
314
321
  }
315
322
  }
316
323
  }
324
+ /**
325
+ * Validate the resolved project root (#1315).
326
+ *
327
+ * Separate from `validatePath` because containment is meaningless for the root
328
+ * itself — it IS the boundary every other path is checked against. Passing it
329
+ * to `validatePath` would either compare it against `process.cwd()` (which now
330
+ * fails legitimately, since the root is an ANCESTOR of cwd for any
331
+ * sub-workspace invocation) or against itself (a tautology that silently
332
+ * retires the check). Assert the properties that actually matter instead:
333
+ * injection-free and absolute.
334
+ */
335
+ function validateProjectRoot(root) {
336
+ if (root.includes('\0')) {
337
+ throw new Error('Project root contains null bytes');
338
+ }
339
+ if (/[;&|`$<>]/.test(root)) {
340
+ throw new Error('Project root contains shell metacharacters');
341
+ }
342
+ if (!isAbsolute(root)) {
343
+ throw new Error(`Project root is not absolute: ${root}`);
344
+ }
345
+ }
317
346
  /**
318
347
  * Start daemon as a detached background process
319
348
  */
320
349
  async function startBackgroundDaemon(projectRoot, quiet, maxCpuLoad, minFreeMemory, dashboardPort, noDashboard) {
321
350
  // Validate and resolve project root
322
351
  const resolvedRoot = resolve(projectRoot);
323
- validatePath(resolvedRoot, 'Project root');
352
+ validateProjectRoot(resolvedRoot);
324
353
  const stateDir = join(resolvedRoot, '.moflo');
325
354
  const logFile = join(stateDir, 'daemon.log');
326
355
  // Validate all paths
@@ -374,6 +403,13 @@ async function startBackgroundDaemon(projectRoot, quiet, maxCpuLoad, minFreeMemo
374
403
  }
375
404
  const daemonEnv = {
376
405
  ...process.env,
406
+ // #1315 — PIN the child's project dir to the root we just resolved.
407
+ // Without this the daemon inherited the SESSION's `CLAUDE_PROJECT_DIR`
408
+ // (a sub-workspace), and so did everything it spawns — headless Claude,
409
+ // hooks, MCP — all of which resolve through `findProjectRoot()` and would
410
+ // anchor back on the sub-workspace, re-minting the island this fix
411
+ // removes. Pinning it closes the whole inherited-env class in one place.
412
+ CLAUDE_PROJECT_DIR: resolvedRoot,
377
413
  MOFLO_DAEMON: '1',
378
414
  // #981 — daemon process must skip its own write-routing client.
379
415
  MOFLO_IS_DAEMON: '1',
@@ -454,7 +490,10 @@ const stopCommand = {
454
490
  ],
455
491
  action: async (ctx) => {
456
492
  const quiet = ctx.flags.quiet;
457
- const projectRoot = process.cwd();
493
+ // #1315 must match `start`'s anchor. Anchoring on cwd here would look for
494
+ // the lock in a sub-workspace, miss the root daemon, and report "not
495
+ // running" while it kept going.
496
+ const projectRoot = resolveStateRoot();
458
497
  try {
459
498
  if (!quiet) {
460
499
  const spinner = output.createSpinner({ text: 'Stopping worker daemon...', spinner: 'dots' });
@@ -584,7 +623,12 @@ const statusCommand = {
584
623
  action: async (ctx) => {
585
624
  const verbose = ctx.flags.verbose;
586
625
  const showModes = ctx.flags.showModes;
587
- const projectRoot = process.cwd();
626
+ // #1315 `getDaemon` constructs a WorkerDaemon, and that constructor
627
+ // unconditionally mkdirs `<root>/.moflo` and `<root>/.moflo/logs`
628
+ // (`worker-daemon.ts`). Anchoring on cwd meant a read-only-looking
629
+ // `flo daemon status` MINTED a state-dir island in whatever directory it
630
+ // was run from — a creation path entirely separate from `start`.
631
+ const projectRoot = resolveStateRoot();
588
632
  try {
589
633
  const daemon = getDaemon(projectRoot);
590
634
  const status = daemon.getStatus();
@@ -721,7 +765,9 @@ const triggerCommand = {
721
765
  return { success: false, exitCode: 1 };
722
766
  }
723
767
  try {
724
- const daemon = getDaemon(process.cwd());
768
+ // #1315 resolved root, not cwd: the WorkerDaemon constructor creates
769
+ // the state dir it is pointed at.
770
+ const daemon = getDaemon(resolveStateRoot());
725
771
  const spinner = output.createSpinner({ text: `Running ${workerType} worker...`, spinner: 'dots' });
726
772
  spinner.start();
727
773
  const result = await daemon.triggerWorker(workerType);
@@ -764,7 +810,9 @@ const enableCommand = {
764
810
  return { success: false, exitCode: 1 };
765
811
  }
766
812
  try {
767
- const daemon = getDaemon(process.cwd());
813
+ // #1315 resolved root, not cwd (see `trigger`). Enabling a worker on a
814
+ // cwd-anchored daemon also wrote its state to the wrong `.moflo`.
815
+ const daemon = getDaemon(resolveStateRoot());
768
816
  daemon.setWorkerEnabled(workerType, !disable);
769
817
  output.printSuccess(`Worker ${workerType} ${disable ? 'disabled' : 'enabled'}`);
770
818
  return { success: true };
@@ -787,7 +835,11 @@ const installCommand = {
787
835
  ],
788
836
  action: async (ctx) => {
789
837
  const quiet = ctx.flags.quiet;
790
- const projectRoot = process.cwd();
838
+ // #1315 the highest-stakes anchor of the family. `installDaemonService`
839
+ // BAKES this path into a launchd plist / systemd unit / schtasks entry
840
+ // (`daemon-service.ts`), so a cwd-anchored install persisted an island
841
+ // across reboots — the one variant that outlives `flo doctor --fix`.
842
+ const projectRoot = resolveStateRoot();
791
843
  try {
792
844
  const result = installDaemonService(projectRoot);
793
845
  if (!quiet) {
@@ -821,9 +873,29 @@ const uninstallCommand = {
821
873
  ],
822
874
  action: async (ctx) => {
823
875
  const quiet = ctx.flags.quiet;
824
- const projectRoot = process.cwd();
876
+ // #1315 must match `install`'s anchor, or uninstall looks for a service
877
+ // registered under a different root and silently leaves it in place.
878
+ const projectRoot = resolveStateRoot();
825
879
  try {
826
880
  const result = uninstallDaemonService(projectRoot);
881
+ // #1315 — also sweep the LEGACY cwd-anchored registration. The service
882
+ // name is `sha256(resolvedRoot)`, so a consumer who ran `flo daemon
883
+ // install` from a sub-directory before this fix has a plist/unit/task
884
+ // named for THAT path. Resolving correctly now makes it unreachable:
885
+ // uninstall would report success while the old service stayed enabled
886
+ // and re-spawned a sub-root daemon at every login, with no supported way
887
+ // to remove it. Best-effort and silent — on the overwhelmingly common
888
+ // path cwd IS the root and this is a no-op.
889
+ const legacyRoot = resolve(process.cwd());
890
+ if (legacyRoot !== projectRoot) {
891
+ try {
892
+ const legacy = uninstallDaemonService(legacyRoot);
893
+ if (legacy.success && !quiet) {
894
+ output.printSuccess(`Also removed a legacy service registered at ${legacyRoot}`);
895
+ }
896
+ }
897
+ catch { /* nothing registered there — expected */ }
898
+ }
827
899
  if (!quiet) {
828
900
  if (result.success) {
829
901
  output.printSuccess(result.message);
@@ -10,7 +10,7 @@ import { findProjectDaemonPids, getDaemonLockHolder, getDaemonLockPayload, } fro
10
10
  import { resolveClientPort, LEGACY_DEFAULT_PORT, probeDaemonHealthWithRetry as probeDaemonHealthIdentity, normalizeProjectRoot, } from '../services/daemon-port.js';
11
11
  import { COMMON_WALK_SKIP_NAMES, LEGACY_SWARM_DIR, memoryDbCandidatePaths, memoryDbPath, } from '../services/moflo-paths.js';
12
12
  import { probeDbIntegrity } from '../services/memory-db-integrity-repair.js';
13
- import { findProjectRoot } from '../services/project-root.js';
13
+ import { findProjectRoot, resolveStateRoot } from '../services/project-root.js';
14
14
  import { errorDetail } from '../shared/utils/error-detail.js';
15
15
  export async function checkConfigFile() {
16
16
  // JSON configs (parse-validated). LEGACY-CONFIG: `.claude-flow.json` and
@@ -382,11 +382,23 @@ export async function checkSwarmResidue() {
382
382
  * something more targeted.
383
383
  */
384
384
  const NESTED_BFS_MAX_FOUND = 50;
385
- function scanNestedMofloDirs(root, maxDepth = 5) {
386
- const found = [];
385
+ /**
386
+ * Depth bound for the nested-`.moflo/` walk.
387
+ *
388
+ * #1315 raised this from 5 to 8. The waxstak report found a stray seven levels
389
+ * deep (`back-office/ui/src/layout/Dashboard/Header/HeaderContent/.moflo`) —
390
+ * inside a React component folder, because ANY cwd could mint one. At depth 5
391
+ * the healer silently walked past it and reported a clean tree. The walk skips
392
+ * `node_modules`/`.git`/build output via `COMMON_WALK_SKIP_NAMES`, so the extra
393
+ * three levels cost little on a real repo.
394
+ */
395
+ const NESTED_SCAN_MAX_DEPTH = 8;
396
+ function scanNestedMofloDirs(root, maxDepth = NESTED_SCAN_MAX_DEPTH) {
397
+ const islands = [];
398
+ const husks = [];
387
399
  let truncated = false;
388
400
  function walk(dir, depth) {
389
- if (found.length >= NESTED_BFS_MAX_FOUND) {
401
+ if (islands.length + husks.length >= NESTED_BFS_MAX_FOUND) {
390
402
  truncated = true;
391
403
  return;
392
404
  }
@@ -411,9 +423,17 @@ function scanNestedMofloDirs(root, maxDepth = 5) {
411
423
  if (entry.name.startsWith('.') && depth > 0)
412
424
  continue;
413
425
  const childDir = join(dir, entry.name);
414
- if (existsSync(join(childDir, '.moflo', 'moflo.db'))) {
415
- found.push(childDir);
416
- if (found.length >= NESTED_BFS_MAX_FOUND) {
426
+ // #1315 — key on the `.moflo/` DIRECTORY, then classify by whether it
427
+ // holds `moflo.db`. Keying on `moflo.db` alone (the pre-#1315 predicate)
428
+ // made every daemon-minted husk invisible.
429
+ if (existsSync(join(childDir, '.moflo'))) {
430
+ if (existsSync(join(childDir, '.moflo', 'moflo.db'))) {
431
+ islands.push(childDir);
432
+ }
433
+ else {
434
+ husks.push(childDir);
435
+ }
436
+ if (islands.length + husks.length >= NESTED_BFS_MAX_FOUND) {
417
437
  truncated = true;
418
438
  return;
419
439
  }
@@ -426,15 +446,12 @@ function scanNestedMofloDirs(root, maxDepth = 5) {
426
446
  continue;
427
447
  }
428
448
  walk(childDir, depth + 1);
429
- if (found.length >= NESTED_BFS_MAX_FOUND)
449
+ if (islands.length + husks.length >= NESTED_BFS_MAX_FOUND)
430
450
  return;
431
451
  }
432
452
  }
433
453
  walk(root, 0);
434
- return { islands: found, truncated };
435
- }
436
- function findNestedMofloDirs(root, maxDepth = 5) {
437
- return scanNestedMofloDirs(root, maxDepth).islands;
454
+ return { islands, husks, truncated };
438
455
  }
439
456
  /**
440
457
  * Public wrapper for the BFS used by `checkNestedMofloIslands`. Exposed so
@@ -443,7 +460,11 @@ function findNestedMofloDirs(root, maxDepth = 5) {
443
460
  * (the consumer joins `.moflo` itself).
444
461
  */
445
462
  export function findNestedMofloDirsForFix(root) {
446
- return findNestedMofloDirs(root);
463
+ // #1315 — husks are archived alongside stateful islands. Archiving is a
464
+ // rename to `.moflo-archived-<ISO>/`, so it is non-destructive either way,
465
+ // and leaving husks in place lets the daemons bound to them keep running.
466
+ const { islands, husks } = scanNestedMofloDirs(root);
467
+ return [...islands, ...husks];
447
468
  }
448
469
  /**
449
470
  * Surface nested `.moflo/moflo.db` directories — every one of them is a daemon
@@ -463,7 +484,9 @@ export function findNestedMofloDirsForFix(root) {
463
484
  * first.
464
485
  */
465
486
  export async function checkNestedMofloIslands(cwd) {
466
- const root = cwd ?? findProjectRoot();
487
+ // #1315 must match the anchor `fixNestedMofloIslands` uses, or the check
488
+ // and its own auto-fix disagree about which tree they are talking about.
489
+ const root = cwd ?? resolveStateRoot();
467
490
  let scan;
468
491
  try {
469
492
  scan = scanNestedMofloDirs(root);
@@ -475,20 +498,33 @@ export async function checkNestedMofloIslands(cwd) {
475
498
  message: `Walk failed: ${errorDetail(e, { firstLineOnly: true })}`,
476
499
  };
477
500
  }
478
- const { islands, truncated } = scan;
479
- if (islands.length === 0) {
501
+ const { islands, husks, truncated } = scan;
502
+ if (islands.length === 0 && husks.length === 0) {
480
503
  return {
481
504
  name: 'Nested .moflo/ Islands',
482
505
  status: 'pass',
483
506
  message: 'No nested .moflo/ directories detected',
484
507
  };
485
508
  }
486
- const rels = islands.map(p => relative(root, p) || '.');
487
- const truncNote = truncated ? ' (walk truncated at depth 5 — deeper islands may exist)' : '';
509
+ const rel = (p) => relative(root, p) || '.';
510
+ const parts = [];
511
+ if (islands.length) {
512
+ // Stateful: created by `flo init` in a sub-workspace (#1174).
513
+ parts.push(`${islands.length} with state (#1174): ${islands.map(rel).join(', ')}`);
514
+ }
515
+ if (husks.length) {
516
+ // Stateless: minted by the daemon or a cwd-anchored command (#1315). Worth
517
+ // naming separately because the remedy differs — these mean something is
518
+ // still resolving its root from cwd, not that someone ran `flo init` wrong.
519
+ parts.push(`${husks.length} daemon-minted, no moflo.db (#1315): ${husks.map(rel).join(', ')}`);
520
+ }
521
+ const truncNote = truncated
522
+ ? ` (walk truncated at depth ${NESTED_SCAN_MAX_DEPTH} or ${NESTED_BFS_MAX_FOUND} results — more may exist)`
523
+ : '';
488
524
  return {
489
525
  name: 'Nested .moflo/ Islands',
490
526
  status: 'warn',
491
- message: `${islands.length} nested .moflo/ ${islands.length === 1 ? 'directory' : 'directories'} (#1174): ${rels.join(', ')}${truncNote}`,
527
+ message: `${parts.join('; ')}${truncNote}`,
492
528
  fix: 'flo healer --fix -c nested-moflo',
493
529
  };
494
530
  }
@@ -9,6 +9,7 @@ import { join } from 'path';
9
9
  import { memoryDbCandidatePaths } from '../services/moflo-paths.js';
10
10
  import { errorDetail } from '../shared/utils/error-detail.js';
11
11
  import { openDaemonDatabase } from '../memory/daemon-backend.js';
12
+ import { resolveStateRoot } from '../services/project-root.js';
12
13
  /** Skew (cached / live count delta) above which the cache is treated as stale. */
13
14
  const VECTOR_STATS_SKEW_WARN_THRESHOLD = 0.2;
14
15
  /**
@@ -35,9 +36,9 @@ async function countEmbeddedRowsFromDb(dbPath) {
35
36
  }
36
37
  }
37
38
  export async function checkEmbeddings() {
38
- const liveDbPath = memoryDbCandidatePaths(process.cwd()).find((p) => existsSync(p));
39
+ const liveDbPath = memoryDbCandidatePaths(resolveStateRoot()).find((p) => existsSync(p));
39
40
  // 1. Fast path: read cached vector-stats.json if available
40
- const statsPath = join(process.cwd(), '.moflo', 'vector-stats.json');
41
+ const statsPath = join(resolveStateRoot(), '.moflo', 'vector-stats.json');
41
42
  try {
42
43
  if (existsSync(statsPath)) {
43
44
  const stats = JSON.parse(readFileSync(statsPath, 'utf8'));
@@ -24,6 +24,7 @@ import { existsSync, readFileSync } from 'fs';
24
24
  import { join } from 'path';
25
25
  import { getDaemonLockHolder } from '../services/daemon-lock.js';
26
26
  import { errorDetail } from '../shared/utils/error-detail.js';
27
+ import { resolveStateRoot } from '../services/project-root.js';
27
28
  const SCAN_TIMEOUT_MS_WIN = 10_000;
28
29
  const SCAN_TIMEOUT_MS_POSIX = 5_000;
29
30
  const CMDLINE_CAPTURE_LEN = 300;
@@ -125,7 +126,10 @@ export function findForeignWriters(procs, daemonPid, trackedPids, selfPid) {
125
126
  * running a known cross-process writer. Lists every offender so the user can
126
127
  * SIGKILL them by PID.
127
128
  */
128
- export async function checkWritersAudit(cwd = process.cwd()) {
129
+ // #1315 the default anchor is the RESOLVED root, not cwd. Running the healer
130
+ // from a sub-workspace previously audited whatever `.moflo` happened to sit
131
+ // under that directory (often a stray) while reporting on "the project".
132
+ export async function checkWritersAudit(cwd = resolveStateRoot()) {
129
133
  const name = 'Writers Audit';
130
134
  try {
131
135
  const daemonPid = getDaemonLockHolder(cwd);
@@ -34,6 +34,7 @@ import { CANONICAL_EMBEDDING_MODEL } from '../embeddings/migration/types.js';
34
34
  import { memoryDbCandidatePaths } from '../services/moflo-paths.js';
35
35
  import { openDaemonDatabase } from '../memory/daemon-backend.js';
36
36
  import { EPHEMERAL_NAMESPACES, EPHEMERAL_NAMESPACE_PREFIXES, } from '../memory/bridge-embedder.js';
37
+ import { resolveStateRoot } from '../services/project-root.js';
37
38
  /**
38
39
  * Known neural-model labels that all share the all-MiniLM-L6-v2 384-dim
39
40
  * vector space. The Story-2 migration retags any of these to the
@@ -132,7 +133,7 @@ export async function checkEmbeddingHygiene() {
132
133
  };
133
134
  }
134
135
  function resolveMemoryDb() {
135
- return memoryDbCandidatePaths(process.cwd()).find((p) => existsSync(p)) ?? null;
136
+ return memoryDbCandidatePaths(resolveStateRoot()).find((p) => existsSync(p)) ?? null;
136
137
  }
137
138
  async function loadModelGroups(dbPath) {
138
139
  let db;