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.
- package/.claude/guidance/shipped/moflo-core-guidance.md +16 -0
- package/.claude/guidance/shipped/moflo-memory-protocol.md +171 -11
- package/.claude/helpers/gate.cjs +139 -14
- package/.claude/skills/publish/SKILL.md +46 -8
- package/bin/gate.cjs +139 -14
- package/bin/lib/moflo-paths.mjs +74 -4
- package/bin/session-start-launcher.mjs +173 -5
- package/dist/src/cli/commands/doctor-checks-config.js +141 -3
- package/dist/src/cli/commands/doctor-fixes.js +202 -10
- package/dist/src/cli/commands/doctor-registry.js +9 -1
- package/dist/src/cli/commands/init.js +33 -0
- package/dist/src/cli/commands/memory.js +11 -4
- package/dist/src/cli/commands/swarm.js +29 -60
- package/dist/src/cli/init/claudemd-generator.js +6 -2
- package/dist/src/cli/init/helpers-generator.js +23 -3
- package/dist/src/cli/init/moflo-init.js +4 -2
- package/dist/src/cli/init/settings-generator.js +9 -3
- package/dist/src/cli/mcp-server.js +104 -2
- package/dist/src/cli/memory/ewc-consolidation.js +22 -6
- package/dist/src/cli/memory/sona-optimizer.js +25 -7
- package/dist/src/cli/movector/lora-adapter.js +22 -7
- package/dist/src/cli/movector/moe-router.js +22 -6
- package/dist/src/cli/services/hook-block-hash.js +5 -2
- package/dist/src/cli/services/hook-wiring.js +38 -4
- package/dist/src/cli/services/moflo-paths.js +36 -0
- package/dist/src/cli/services/project-root.js +84 -25
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2173
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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 });
|