moflo 4.10.11 → 4.10.13

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.
@@ -3,12 +3,12 @@
3
3
  * config files, statusLine, daemon, MCP servers, moflo.yaml compliance,
4
4
  * test directories.
5
5
  */
6
- import { existsSync, readFileSync, statSync } from 'fs';
7
- import { join } from 'path';
6
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
7
+ import { join, relative } from 'path';
8
8
  import os from 'os';
9
9
  import { findProjectDaemonPids, getDaemonLockHolder, getDaemonLockPayload, } from '../services/daemon-lock.js';
10
10
  import { resolveClientPort, LEGACY_DEFAULT_PORT, probeDaemonHealthWithRetry as probeDaemonHealthIdentity, normalizeProjectRoot, } from '../services/daemon-port.js';
11
- import { LEGACY_SWARM_DIR, memoryDbCandidatePaths, memoryDbPath, } from '../services/moflo-paths.js';
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
13
  import { findProjectRoot } from '../services/project-root.js';
14
14
  import { errorDetail } from '../shared/utils/error-detail.js';
@@ -272,6 +272,12 @@ export async function checkMemoryDatabase() {
272
272
  * - `memory.db` / `memory.db.bak` — stale once `.moflo/moflo.db` exists.
273
273
  * - `q-learning-model.json` / `model-router-state.json` — live router state
274
274
  * that pre-dates the `.moflo/movector/` defaults; migrate, don't delete.
275
+ * - `lora-weights.json` / `moe-weights.json` — LoRA + MoE weights (#1168
276
+ * moved the writers to `.moflo/movector/`).
277
+ * - `ewc-fisher.json` / `sona-patterns.json` — neural runtime state (#1168
278
+ * moved the writers to `.moflo/neural/`).
279
+ * - `state.json` — `flo swarm init` snapshot (#1168 → `.moflo/swarm/`).
280
+ * - `code-map-hash.txt` — `flo memory code-map` cache (#1168 → `.moflo/memory/`).
275
281
  * - `hooks.log` / `background.log` — diagnostic logs the launcher used to
276
282
  * route to `.swarm/`; relocate to `.moflo/logs/`.
277
283
  *
@@ -291,6 +297,16 @@ export async function checkSwarmResidue() {
291
297
  'memory.db.bak',
292
298
  'q-learning-model.json',
293
299
  'model-router-state.json',
300
+ 'lora-weights.json',
301
+ 'moe-weights.json',
302
+ 'ewc-fisher.json',
303
+ 'sona-patterns.json',
304
+ 'state.json',
305
+ 'code-map-hash.txt',
306
+ // patterns-hash.txt + tests-hash.txt are pre-#699 residue (writers now
307
+ // target `.moflo/` directly). Surfaced + migrated by #1170.
308
+ 'patterns-hash.txt',
309
+ 'tests-hash.txt',
294
310
  'hooks.log',
295
311
  'background.log',
296
312
  ];
@@ -305,6 +321,128 @@ export async function checkSwarmResidue() {
305
321
  fix: 'flo healer --fix -c swarm-residue',
306
322
  };
307
323
  }
324
+ /**
325
+ * Cap on `found.length` so a pathological monorepo (or adversarial layout)
326
+ * can't accumulate an unbounded array. 50 islands is already 50× more than
327
+ * any legitimate consumer should ever have; surfacing more would just bury
328
+ * the actionable signal in noise. Setting `truncated = true` signals the
329
+ * walker stopped early so the doctor message can hint at re-running with
330
+ * something more targeted.
331
+ */
332
+ const NESTED_BFS_MAX_FOUND = 50;
333
+ function scanNestedMofloDirs(root, maxDepth = 5) {
334
+ const found = [];
335
+ let truncated = false;
336
+ function walk(dir, depth) {
337
+ if (found.length >= NESTED_BFS_MAX_FOUND) {
338
+ truncated = true;
339
+ return;
340
+ }
341
+ let entries;
342
+ try {
343
+ entries = readdirSync(dir, { withFileTypes: true });
344
+ }
345
+ catch {
346
+ return;
347
+ }
348
+ for (const entry of entries) {
349
+ if (!entry.isDirectory())
350
+ continue;
351
+ const lower = entry.name.toLowerCase();
352
+ if (COMMON_WALK_SKIP_NAMES.has(lower))
353
+ continue;
354
+ // Skip any .moflo* directory — both the canonical `.moflo` (we're
355
+ // looking for nested ones, not this level's own) and archived
356
+ // `.moflo-archived-*` produced by `flo doctor --fix`.
357
+ if (lower.startsWith('.moflo'))
358
+ continue;
359
+ if (entry.name.startsWith('.') && depth > 0)
360
+ continue;
361
+ const childDir = join(dir, entry.name);
362
+ if (existsSync(join(childDir, '.moflo', 'moflo.db'))) {
363
+ found.push(childDir);
364
+ if (found.length >= NESTED_BFS_MAX_FOUND) {
365
+ truncated = true;
366
+ return;
367
+ }
368
+ // Don't recurse below a nested island — its own descendants would
369
+ // be conflated under that residue.
370
+ continue;
371
+ }
372
+ if (depth + 1 > maxDepth) {
373
+ truncated = true;
374
+ continue;
375
+ }
376
+ walk(childDir, depth + 1);
377
+ if (found.length >= NESTED_BFS_MAX_FOUND)
378
+ return;
379
+ }
380
+ }
381
+ walk(root, 0);
382
+ return { islands: found, truncated };
383
+ }
384
+ function findNestedMofloDirs(root, maxDepth = 5) {
385
+ return scanNestedMofloDirs(root, maxDepth).islands;
386
+ }
387
+ /**
388
+ * Public wrapper for the BFS used by `checkNestedMofloIslands`. Exposed so
389
+ * `doctor-fixes.ts:fixNestedMofloIslands` can enumerate the same set without
390
+ * duplicating the skip-list / depth-bound logic. Returns parent directories
391
+ * (the consumer joins `.moflo` itself).
392
+ */
393
+ export function findNestedMofloDirsForFix(root) {
394
+ return findNestedMofloDirs(root);
395
+ }
396
+ /**
397
+ * Surface nested `.moflo/moflo.db` directories — every one of them is a daemon
398
+ * island in a monorepo (#1174). The MCP server, daemon, and CLI tools each
399
+ * resolve their own anchor via cwd; pre-#1174 the resolver returned the
400
+ * *nearest* ancestor, so subdirectory invocations silently spawned isolated
401
+ * daemons with separate sockets, ports, registries, and vector state.
402
+ *
403
+ * Status semantics:
404
+ * - `pass` — no nested `.moflo/` directories under the canonical project root.
405
+ * - `warn` — one or more nested `.moflo/` directories detected; lists each
406
+ * with its relative path. `fix` points at the auto-fix that archives them.
407
+ *
408
+ * Auto-fix (`fixNestedMofloIslands` in doctor-fixes.ts) renames each nested
409
+ * `.moflo/` to `.moflo-archived-<ISO>/` so the user can manually inspect or
410
+ * restore them. Daemons running out of those nested directories are stopped
411
+ * first.
412
+ */
413
+ export async function checkNestedMofloIslands(cwd) {
414
+ const root = cwd ?? findProjectRoot();
415
+ let scan;
416
+ try {
417
+ scan = scanNestedMofloDirs(root);
418
+ }
419
+ catch (e) {
420
+ return {
421
+ name: 'Nested .moflo/ Islands',
422
+ status: 'warn',
423
+ message: `Walk failed: ${errorDetail(e, { firstLineOnly: true })}`,
424
+ };
425
+ }
426
+ const { islands, truncated } = scan;
427
+ if (islands.length === 0) {
428
+ const baseMsg = 'No nested .moflo/ directories detected';
429
+ return {
430
+ name: 'Nested .moflo/ Islands',
431
+ status: truncated ? 'warn' : 'pass',
432
+ message: truncated
433
+ ? `${baseMsg} within depth-5 walk — deeper subtrees not inspected`
434
+ : baseMsg,
435
+ };
436
+ }
437
+ const rels = islands.map(p => relative(root, p) || '.');
438
+ const truncNote = truncated ? ' (walk truncated at depth 5 — deeper islands may exist)' : '';
439
+ return {
440
+ name: 'Nested .moflo/ Islands',
441
+ status: 'warn',
442
+ message: `${islands.length} nested .moflo/ ${islands.length === 1 ? 'directory' : 'directories'} (#1174): ${rels.join(', ')}${truncNote}`,
443
+ fix: 'flo healer --fix -c nested-moflo',
444
+ };
445
+ }
308
446
  /**
309
447
  * Tier-1 corruption probe for `.moflo/moflo.db`. Runs `PRAGMA integrity_check`
310
448
  * via a raw node:sqlite readonly handle — bypasses `openBackend` because that
@@ -11,7 +11,7 @@ import { output } from '../output.js';
11
11
  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
- import { getDaemonLockHolder } from '../services/daemon-lock.js';
14
+ import { findProjectDaemonPids, getDaemonLockHolder } from '../services/daemon-lock.js';
15
15
  import { findProjectRoot } 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';
@@ -152,10 +152,32 @@ async function fixSwarmLegacyResidue() {
152
152
  allMigrated = false;
153
153
  }
154
154
  }
155
- // (2) router state JSONs — rename into .moflo/movector/.
155
+ // (2) router state + neural state JSONs — rename into .moflo/{movector,neural,swarm,memory}/.
156
+ //
157
+ // q-learning-model.json + model-router-state.json: shipped at #727.
158
+ // lora-weights.json + moe-weights.json: writer relocation in #1168
159
+ // (lora-adapter.ts, moe-router.ts).
160
+ // ewc-fisher.json + sona-patterns.json: writer relocation in #1168
161
+ // (ewc-consolidation.ts, sona-optimizer.ts).
162
+ // state.json + code-map-hash.txt: writer relocation in #1168
163
+ // (commands/swarm.ts, commands/memory.ts).
164
+ const neuralDir = join(moflo, 'neural');
165
+ const swarmStateDir = join(moflo, 'swarm');
166
+ const memoryStateDir = join(moflo, 'memory');
167
+ // patterns-hash.txt + tests-hash.txt: writers in bin/index-patterns.mjs +
168
+ // bin/index-tests.mjs already target `.moflo/` directly (post-#699). Any
169
+ // `.swarm/` copies are pre-#699 residue with no active writer (#1170).
156
170
  const stateFiles = [
157
171
  { name: 'q-learning-model.json', dest: movectorDir },
158
172
  { name: 'model-router-state.json', dest: movectorDir },
173
+ { name: 'lora-weights.json', dest: movectorDir },
174
+ { name: 'moe-weights.json', dest: movectorDir },
175
+ { name: 'ewc-fisher.json', dest: neuralDir },
176
+ { name: 'sona-patterns.json', dest: neuralDir },
177
+ { name: 'state.json', dest: swarmStateDir },
178
+ { name: 'code-map-hash.txt', dest: memoryStateDir },
179
+ { name: 'patterns-hash.txt', dest: moflo },
180
+ { name: 'tests-hash.txt', dest: moflo },
159
181
  ];
160
182
  for (const { name, dest } of stateFiles) {
161
183
  const src = join(swarmDir, name);
@@ -220,6 +242,169 @@ async function fixSwarmLegacyResidue() {
220
242
  }
221
243
  return allMigrated;
222
244
  }
245
+ /**
246
+ * Stop any moflo daemons owned by `subRoot` before archiving its `.moflo/`.
247
+ *
248
+ * Pre-#1174 nested-daemon PIDs are not visible to the canonical orphan reap
249
+ * (which uses the topmost projectRoot's CLI candidate paths). Calling
250
+ * `findProjectDaemonPids` with the SUB-root finds them. SIGTERM first, wait a
251
+ * brief grace, SIGKILL if still alive. On Windows, daemons holding files in
252
+ * `.moflo/` would otherwise cause `renameSync` to fail with EBUSY; on POSIX,
253
+ * the rename succeeds but the daemon's open FDs keep pointing at the inode.
254
+ */
255
+ /**
256
+ * `process.kill` failure modes worth distinguishing:
257
+ * - `ESRCH` — no such process (already exited). Treat as success.
258
+ * - `EPERM` — caller lacks permission (POSIX: daemon owned by another user;
259
+ * Windows: insufficient ACLs). The signal was NOT delivered. We must
260
+ * report this as "remaining" so the caller doesn't archive `.moflo/`
261
+ * thinking the daemon is gone.
262
+ * - other — surface as remaining + log; don't crash the fix path.
263
+ */
264
+ function killOutcome(err) {
265
+ const code = err?.code;
266
+ if (code === 'ESRCH')
267
+ return 'gone';
268
+ if (code === 'EPERM')
269
+ return 'denied';
270
+ return 'unknown';
271
+ }
272
+ async function stopNestedDaemons(subRoot) {
273
+ let pids = [];
274
+ try {
275
+ pids = findProjectDaemonPids(subRoot);
276
+ }
277
+ catch {
278
+ return { stopped: [], remaining: [], denied: [] };
279
+ }
280
+ if (pids.length === 0)
281
+ return { stopped: [], remaining: [], denied: [] };
282
+ const stopped = [];
283
+ const denied = [];
284
+ for (const pid of pids) {
285
+ try {
286
+ process.kill(pid, 'SIGTERM');
287
+ stopped.push(pid);
288
+ }
289
+ catch (err) {
290
+ const outcome = killOutcome(err);
291
+ if (outcome === 'gone')
292
+ continue; // already exited
293
+ if (outcome === 'denied')
294
+ denied.push(pid); // wrong-owner daemon
295
+ else
296
+ denied.push(pid); // unknown — treat as undelivered
297
+ }
298
+ }
299
+ // Poll for exit with a 1s deadline (matches the daemon-service stop loop in
300
+ // commands/daemon.ts). Async-by-default — busy-waiting here would pin CPU
301
+ // during the very contention we're waiting out (see feedback memory).
302
+ const deadline = Date.now() + 1000;
303
+ while (Date.now() < deadline) {
304
+ const alive = stopped.filter(pid => {
305
+ try {
306
+ process.kill(pid, 0);
307
+ return true;
308
+ }
309
+ catch {
310
+ return false;
311
+ }
312
+ });
313
+ if (alive.length === 0)
314
+ break;
315
+ await new Promise(resolve => setTimeout(resolve, 50));
316
+ }
317
+ const remaining = [];
318
+ for (const pid of stopped) {
319
+ let stillAlive = false;
320
+ try {
321
+ process.kill(pid, 0);
322
+ stillAlive = true;
323
+ }
324
+ catch { /* gone */ }
325
+ if (!stillAlive)
326
+ continue;
327
+ // Still alive — escalate.
328
+ try {
329
+ process.kill(pid, 'SIGKILL');
330
+ }
331
+ catch (err) {
332
+ if (killOutcome(err) === 'denied') {
333
+ denied.push(pid);
334
+ continue;
335
+ }
336
+ // Other errors (incl. ESRCH on race) — re-probe below.
337
+ }
338
+ try {
339
+ process.kill(pid, 0);
340
+ remaining.push(pid);
341
+ }
342
+ catch { /* gone */ }
343
+ }
344
+ return { stopped, remaining, denied };
345
+ }
346
+ /**
347
+ * Archive nested `.moflo/` directories that fragment monorepo state (#1174).
348
+ *
349
+ * Each nested `.moflo/` is renamed to `.moflo-archived-<ISO>` in place. Never
350
+ * deletes — sub-daemon vector state can be uniquely useful (subworkspace-
351
+ * specific learnings) and silently dropping it would be the wrong default.
352
+ * The user can manually inspect or restore archived directories.
353
+ *
354
+ * Daemon reap: before each rename, `findProjectDaemonPids(island)` enumerates
355
+ * any moflo daemons whose cmdline references the sub-root, then SIGTERMs them
356
+ * (escalates to SIGKILL after 1s). This is required for both platforms:
357
+ * - Windows: `renameSync` fails with EBUSY if any file is open. Without the
358
+ * reap, archive silently fails until the user manually stops the daemon.
359
+ * - POSIX: rename succeeds but the daemon's open FDs keep pointing at the
360
+ * inode; the daemon keeps writing to a now-archived path until it exits.
361
+ */
362
+ async function fixNestedMofloIslands() {
363
+ const root = findProjectRoot();
364
+ let islands;
365
+ try {
366
+ const { findNestedMofloDirsForFix } = await import('./doctor-checks-config.js');
367
+ islands = findNestedMofloDirsForFix(root);
368
+ }
369
+ catch (e) {
370
+ output.writeln(output.warning(` Unable to enumerate nested .moflo/: ${errorDetail(e)}`));
371
+ return false;
372
+ }
373
+ if (islands.length === 0)
374
+ return true;
375
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
376
+ let allArchived = true;
377
+ for (const island of islands) {
378
+ const src = join(island, '.moflo');
379
+ const dst = join(island, `.moflo-archived-${ts}`);
380
+ if (!existsSync(src))
381
+ continue;
382
+ // Step 1: stop any sub-daemon. The canonical projectRoot-based orphan
383
+ // scan can't see nested daemons; we have to enumerate from the sub-root.
384
+ const { stopped, remaining, denied } = await stopNestedDaemons(island);
385
+ if (stopped.length > 0) {
386
+ output.writeln(output.dim(` ${island}: stopped daemon PID${stopped.length === 1 ? '' : 's'} ${stopped.join(', ')}.`));
387
+ }
388
+ if (denied.length > 0) {
389
+ output.writeln(output.warning(` ${island}: permission denied stopping PID${denied.length === 1 ? '' : 's'} ${denied.join(', ')} — daemon owned by another user; stop it manually before re-running this fix.`));
390
+ allArchived = false;
391
+ }
392
+ if (remaining.length > 0) {
393
+ output.writeln(output.warning(` ${island}: PID${remaining.length === 1 ? '' : 's'} ${remaining.join(', ')} survived SIGKILL — manual intervention required.`));
394
+ allArchived = false;
395
+ }
396
+ try {
397
+ renameSync(src, dst);
398
+ output.writeln(output.success(` Archived: ${island}/.moflo → .moflo-archived-${ts}/`));
399
+ }
400
+ catch (e) {
401
+ output.writeln(output.warning(` Failed to archive ${island}/.moflo: ${errorDetail(e)}. ` +
402
+ `If a process still holds files open, stop it manually and re-run \`flo doctor --fix -c nested-moflo\`.`));
403
+ allArchived = false;
404
+ }
405
+ }
406
+ return allArchived;
407
+ }
223
408
  /**
224
409
  * Execute the fix for a failed/warned health check.
225
410
  * Returns true if the fix succeeded (re-check should pass).
@@ -230,10 +415,11 @@ export async function autoFixCheck(check) {
230
415
  // Map checks to programmatic fixes (not just shell commands)
231
416
  const fixActions = {
232
417
  'Memory Database': async () => {
418
+ // Canonical DB lives at `.moflo/moflo.db`; `initializeMemoryDatabase`
419
+ // creates the parent dir itself. The pre-#1168 fix also `mkdirSync`'d
420
+ // `.swarm/` — vestigial residue that fought the 'Swarm Residue' fix in
421
+ // the same healer pass. Removed.
233
422
  try {
234
- const swarmDir = join(process.cwd(), '.swarm');
235
- if (!existsSync(swarmDir))
236
- mkdirSync(swarmDir, { recursive: true });
237
423
  const { initializeMemoryDatabase } = await import('../memory/memory-initializer.js');
238
424
  const result = await initializeMemoryDatabase({ force: true, verbose: false });
239
425
  return result.success;
@@ -243,12 +429,11 @@ export async function autoFixCheck(check) {
243
429
  }
244
430
  },
245
431
  'Embeddings': async () => {
432
+ // Same fix as Memory Database — ensure the canonical DB exists, then
433
+ // populate embeddings. Pre-#1168 wrote to `.swarm/memory.db` directly,
434
+ // contradicting the post-#727 layout; that branch is removed.
246
435
  try {
247
- const swarmDir = join(process.cwd(), '.swarm');
248
- if (!existsSync(swarmDir))
249
- mkdirSync(swarmDir, { recursive: true });
250
- const dbPath = join(swarmDir, 'memory.db');
251
- if (!existsSync(dbPath)) {
436
+ if (!existsSync(memoryDbPath(findProjectRoot()))) {
252
437
  const { initializeMemoryDatabase } = await import('../memory/memory-initializer.js');
253
438
  await initializeMemoryDatabase({ force: true, verbose: false });
254
439
  }
@@ -572,6 +757,13 @@ export async function autoFixCheck(check) {
572
757
  'Swarm Residue': async () => {
573
758
  return fixSwarmLegacyResidue();
574
759
  },
760
+ // Archive nested `.moflo/` directories that fragment monorepo state
761
+ // (#1174). Rename, never delete — sub-daemon vector state can be unique
762
+ // and silently losing it would be the wrong default. The archive name
763
+ // includes an ISO timestamp so re-runs don't collide.
764
+ 'Nested .moflo/ Islands': async () => {
765
+ return fixNestedMofloIslands();
766
+ },
575
767
  'Status Line': async () => {
576
768
  const settingsPath = join(process.cwd(), '.claude', 'settings.json');
577
769
  if (!existsSync(settingsPath))
@@ -12,7 +12,7 @@ import { checkWritersAudit } from './doctor-checks-writers-audit.js';
12
12
  import { checkSwarmFunctional, checkHiveMindFunctional, } from './doctor-checks-swarm.js';
13
13
  import { checkMemoryAccessFunctional } from './doctor-checks-memory-access.js';
14
14
  import { checkBuildTools, checkClaudeCode, checkDiskSpace, checkGit, checkGitRepo, checkNodeVersion, checkNpmVersion, } from './doctor-checks-runtime.js';
15
- import { checkConfigFile, checkDaemonIdentity, checkDaemonOrphan, checkDaemonStatus, checkDaemonWriteRouting, checkMcpServers, checkMemoryDatabase, checkMemoryDbIntegrity, checkMofloYamlCompliance, checkStatusLine, checkSwarmResidue, checkTestDirs, } from './doctor-checks-config.js';
15
+ import { checkConfigFile, checkDaemonIdentity, checkDaemonOrphan, checkDaemonStatus, checkDaemonWriteRouting, checkMcpServers, checkMemoryDatabase, checkMemoryDbIntegrity, checkMofloYamlCompliance, checkNestedMofloIslands, checkStatusLine, checkSwarmResidue, checkTestDirs, } from './doctor-checks-config.js';
16
16
  import { checkSpellEngine, checkSandboxTier } from './doctor-checks-platform.js';
17
17
  import { checkEmbeddings, checkSemanticQuality, } from './doctor-checks-memory.js';
18
18
  import { checkIntelligence } from './doctor-checks-intelligence.js';
@@ -42,6 +42,10 @@ export const allChecks = [
42
42
  checkDaemonWriteRouting,
43
43
  checkWritersAudit,
44
44
  checkMemoryDatabase,
45
+ // Surfaces nested `.moflo/moflo.db` directories — every nested instance is
46
+ // a daemon island in a monorepo (#1174). Runs cheap (depth-bounded BFS,
47
+ // statSync only) and independent of memory-DB integrity probes.
48
+ checkNestedMofloIslands,
45
49
  // Surfaces leftover `.swarm/` artifacts (memory.db, router state, logs) so
46
50
  // the auto-fix can relocate or delete them. Independent of the canonical
47
51
  // DB checks — runs cheap (statSync only).
@@ -111,6 +115,10 @@ export const componentMap = {
111
115
  'writers-audit': checkWritersAudit,
112
116
  'writers': checkWritersAudit,
113
117
  'memory': checkMemoryDatabase,
118
+ 'nested-moflo': checkNestedMofloIslands,
119
+ 'nested': checkNestedMofloIslands,
120
+ 'islands': checkNestedMofloIslands,
121
+ 'monorepo': checkNestedMofloIslands,
114
122
  'swarm-residue': checkSwarmResidue,
115
123
  'residue': checkSwarmResidue,
116
124
  'memory-db-integrity': checkMemoryDbIntegrity,
@@ -7,6 +7,7 @@ import { confirm, select, multiSelect, input } from '../prompt.js';
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import { errorDetail } from '../shared/utils/error-detail.js';
10
+ import { findAncestorMofloRoot } from '../services/project-root.js';
10
11
  import { executeInit, executeUpgrade, executeUpgradeWithMissing, DEFAULT_INIT_OPTIONS, MINIMAL_INIT_OPTIONS, FULL_INIT_OPTIONS, } from '../init/index.js';
11
12
  // Check if project is already initialized
12
13
  function isInitialized(cwd) {
@@ -25,6 +26,38 @@ const initAction = async (ctx) => {
25
26
  const skipClaude = ctx.flags.skipClaude;
26
27
  const onlyClaude = ctx.flags.onlyClaude;
27
28
  const cwd = ctx.cwd;
29
+ // ── Monorepo nested-.moflo guard (#1174) ───────────────────────────
30
+ // Initializing inside a sub-directory whose ancestor already has
31
+ // .moflo/moflo.db creates a daemon island: the MCP server, daemon, and CLI
32
+ // tools end up bound to different daemons depending on cwd. Default to
33
+ // using the ancestor's state; --force lets the user opt in to a nested
34
+ // setup (rare, almost always a misconfiguration).
35
+ const ancestorMoflo = findAncestorMofloRoot(cwd);
36
+ if (ancestorMoflo && !force) {
37
+ output.printWarning('Monorepo detected: ancestor moflo project found.');
38
+ output.printInfo(` Ancestor: ${ancestorMoflo}`);
39
+ output.printInfo(` This directory: ${cwd}`);
40
+ output.writeln();
41
+ output.writeln('Initializing here would create a nested .moflo/ — the MCP server and CLI tools');
42
+ output.writeln('would silently route to different daemons depending on cwd (issue #1174). Use the');
43
+ output.writeln('ancestor\'s state by running moflo commands from the ancestor instead.');
44
+ output.writeln();
45
+ if (ctx.interactive && !ctx.flags.yes) {
46
+ const proceed = await confirm({
47
+ message: 'Initialize anyway (creates a nested .moflo/)?',
48
+ default: false,
49
+ });
50
+ if (!proceed) {
51
+ output.printInfo('Aborted — use the ancestor moflo project.');
52
+ return { success: true, message: 'aborted by user (ancestor moflo project)' };
53
+ }
54
+ }
55
+ else {
56
+ output.printError('Refusing to create a nested .moflo/. Pass --force to override, or re-run from the ancestor.');
57
+ return { success: false, message: 'refused: nested .moflo/ in monorepo', exitCode: 1 };
58
+ }
59
+ }
60
+ // ── End monorepo guard ─────────────────────────────────────────────
28
61
  // ── MoFlo Project Setup ────────────────────────────────────────────
29
62
  // Always run MoFlo init to ensure moflo.yaml, hooks, skill, and
30
63
  // CLAUDE.md are set up, regardless of other init options.
@@ -9,6 +9,7 @@ import { select, confirm, input } from '../prompt.js';
9
9
  import { callMCPTool, MCPClientError } from '../mcp-client.js';
10
10
  import { openDaemonDatabase } from '../memory/daemon-backend.js';
11
11
  import { errorDetail } from '../shared/utils/error-detail.js';
12
+ import { legacySwarmPath, runtimePath } from '../services/moflo-paths.js';
12
13
  // Memory backends
13
14
  const BACKENDS = [
14
15
  { value: 'agentdb', label: 'AgentDB', hint: 'Vector database with HNSW indexing (150x-12,500x faster)' },
@@ -2129,7 +2130,10 @@ const codeMapCommand = {
2129
2130
  const { execSync } = await import('child_process');
2130
2131
  const { createHash } = await import('crypto');
2131
2132
  const cwd = ctx.cwd || process.cwd();
2132
- const hashCachePath = pathModule.join(cwd, '.swarm', 'code-map-hash.txt');
2133
+ // Post-#1168: canonical at `.moflo/memory/code-map-hash.txt`. Legacy
2134
+ // `.swarm/code-map-hash.txt` is read-only fallback for upgrade scenarios.
2135
+ const hashCachePath = runtimePath('memory', 'code-map-hash.txt');
2136
+ const legacyHashCachePath = legacySwarmPath('code-map-hash.txt');
2133
2137
  output.writeln();
2134
2138
  output.writeln(output.bold('Generating Code Map'));
2135
2139
  output.writeln(output.dim('─'.repeat(50)));
@@ -2168,9 +2172,12 @@ const codeMapCommand = {
2168
2172
  output.writeln(`File list hash: ${currentHash.slice(0, 12)}...`);
2169
2173
  return { success: true };
2170
2174
  }
2171
- // Check if unchanged
2172
- if (!forceRegen && fs.existsSync(hashCachePath)) {
2173
- const cached = fs.readFileSync(hashCachePath, 'utf-8').trim();
2175
+ // Check if unchanged — canonical first, then legacy `.swarm/` fallback.
2176
+ const cachedReadPath = fs.existsSync(hashCachePath)
2177
+ ? hashCachePath
2178
+ : (fs.existsSync(legacyHashCachePath) ? legacyHashCachePath : null);
2179
+ if (!forceRegen && cachedReadPath) {
2180
+ const cached = fs.readFileSync(cachedReadPath, 'utf-8').trim();
2174
2181
  if (cached === currentHash) {
2175
2182
  const { db } = await openDb(cwd);
2176
2183
  const stmt = db.prepare(`SELECT COUNT(*) as cnt FROM memory_entries WHERE namespace = ?`);
@@ -7,14 +7,26 @@ import { select, confirm } from '../prompt.js';
7
7
  import { callMCPTool, MCPClientError } from '../mcp-client.js';
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
- import { memoryDbCandidatePaths } from '../services/moflo-paths.js';
10
+ import { LEGACY_SWARM_DIR, memoryDbCandidatePaths, mofloDir } from '../services/moflo-paths.js';
11
+ import { findProjectRoot } from '../services/project-root.js';
11
12
  // Get dynamic swarm status from memory/session files
12
13
  function getSwarmStatus(swarmId) {
13
- const swarmDir = path.join(process.cwd(), '.swarm');
14
+ const projectRoot = findProjectRoot();
15
+ // `.moflo/swarm/state.json` is canonical post-#1168; `.swarm/state.json`
16
+ // is a read-only fallback so a consumer who initialised on an older moflo
17
+ // still sees their swarm. The pre-#1168 agents/tasks JSON probe blocks
18
+ // were removed — no current writer creates those directories, so they
19
+ // always produced 0 counts. The coordinator-backed MCP tools
20
+ // (agent_list / task_list) are the live source of truth.
21
+ const canonicalSwarmDir = path.join(mofloDir(projectRoot), 'swarm');
22
+ const legacySwarmDir = path.join(projectRoot, LEGACY_SWARM_DIR);
14
23
  const sessionDir = path.join(process.cwd(), '.claude', 'sessions');
15
24
  const memoryPaths = memoryDbCandidatePaths(process.cwd());
16
- // Check for active swarm state file
17
- const swarmStateFile = path.join(swarmDir, 'state.json');
25
+ // Check for active swarm state file — canonical first, then legacy.
26
+ let swarmStateFile = path.join(canonicalSwarmDir, 'state.json');
27
+ if (!fs.existsSync(swarmStateFile)) {
28
+ swarmStateFile = path.join(legacySwarmDir, 'state.json');
29
+ }
18
30
  let swarmState = null;
19
31
  if (fs.existsSync(swarmStateFile)) {
20
32
  try {
@@ -24,30 +36,14 @@ function getSwarmStatus(swarmId) {
24
36
  // Ignore parse errors
25
37
  }
26
38
  }
27
- // Count active agents from process files
28
- let activeAgents = 0;
29
- let totalAgents = 0;
30
- const agentsDir = path.join(swarmDir, 'agents');
31
- if (fs.existsSync(agentsDir)) {
32
- try {
33
- const agentFiles = fs.readdirSync(agentsDir).filter(f => f.endsWith('.json'));
34
- totalAgents = agentFiles.length;
35
- for (const file of agentFiles) {
36
- try {
37
- const agent = JSON.parse(fs.readFileSync(path.join(agentsDir, file), 'utf-8'));
38
- if (agent.status === 'active' || agent.status === 'running') {
39
- activeAgents++;
40
- }
41
- }
42
- catch {
43
- // Ignore
44
- }
45
- }
46
- }
47
- catch {
48
- // Ignore
49
- }
50
- }
39
+ // agents/tasks counters: no file-store readers post-#1168. Coordinator
40
+ // MCP tools own the live counts; getSwarmStatus surfaces a static summary
41
+ // of the persisted state file plus session/memory rough indicators.
42
+ const activeAgents = 0;
43
+ const totalAgents = 0;
44
+ const completedTasks = 0;
45
+ const inProgressTasks = 0;
46
+ const pendingTasks = 0;
51
47
  // Get session count
52
48
  let sessionCount = 0;
53
49
  if (fs.existsSync(sessionDir)) {
@@ -71,36 +67,6 @@ function getSwarmStatus(swarmId) {
71
67
  }
72
68
  }
73
69
  }
74
- // Count task files if they exist
75
- let completedTasks = 0;
76
- let inProgressTasks = 0;
77
- let pendingTasks = 0;
78
- const tasksDir = path.join(swarmDir, 'tasks');
79
- if (fs.existsSync(tasksDir)) {
80
- try {
81
- const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json'));
82
- for (const file of taskFiles) {
83
- try {
84
- const task = JSON.parse(fs.readFileSync(path.join(tasksDir, file), 'utf-8'));
85
- if (task.status === 'completed' || task.status === 'done') {
86
- completedTasks++;
87
- }
88
- else if (task.status === 'in_progress' || task.status === 'running') {
89
- inProgressTasks++;
90
- }
91
- else {
92
- pendingTasks++;
93
- }
94
- }
95
- catch {
96
- // Ignore
97
- }
98
- }
99
- }
100
- catch {
101
- // Ignore
102
- }
103
- }
104
70
  // Calculate dynamic progress based on actual state
105
71
  // If no swarm state, show 0%. Otherwise calculate from completed tasks
106
72
  const totalTasks = completedTasks + inProgressTasks + pendingTasks;
@@ -279,8 +245,11 @@ const initCommand = {
279
245
  });
280
246
  output.writeln();
281
247
  output.printSuccess('Swarm initialized successfully');
282
- // Save swarm state locally for status command to read
283
- const swarmDir = path.join(process.cwd(), '.swarm');
248
+ // Save swarm state locally for status command to read. Post-#1168 the
249
+ // canonical home is `<root>/.moflo/swarm/state.json`; the legacy
250
+ // `.swarm/state.json` path is preserved as a read-only fallback in
251
+ // `getSwarmStatus`.
252
+ const swarmDir = path.join(mofloDir(findProjectRoot()), 'swarm');
284
253
  try {
285
254
  if (!fs.existsSync(swarmDir)) {
286
255
  fs.mkdirSync(swarmDir, { recursive: true });