moflo 4.10.12 → 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 +129 -3
- package/dist/src/cli/commands/doctor-fixes.js +176 -1
- package/dist/src/cli/commands/doctor-registry.js +9 -1
- package/dist/src/cli/commands/init.js +33 -0
- 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/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 +16 -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';
|
|
@@ -303,6 +303,10 @@ export async function checkSwarmResidue() {
|
|
|
303
303
|
'sona-patterns.json',
|
|
304
304
|
'state.json',
|
|
305
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',
|
|
306
310
|
'hooks.log',
|
|
307
311
|
'background.log',
|
|
308
312
|
];
|
|
@@ -317,6 +321,128 @@ export async function checkSwarmResidue() {
|
|
|
317
321
|
fix: 'flo healer --fix -c swarm-residue',
|
|
318
322
|
};
|
|
319
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
|
+
}
|
|
320
446
|
/**
|
|
321
447
|
* Tier-1 corruption probe for `.moflo/moflo.db`. Runs `PRAGMA integrity_check`
|
|
322
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';
|
|
@@ -164,6 +164,9 @@ async function fixSwarmLegacyResidue() {
|
|
|
164
164
|
const neuralDir = join(moflo, 'neural');
|
|
165
165
|
const swarmStateDir = join(moflo, 'swarm');
|
|
166
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).
|
|
167
170
|
const stateFiles = [
|
|
168
171
|
{ name: 'q-learning-model.json', dest: movectorDir },
|
|
169
172
|
{ name: 'model-router-state.json', dest: movectorDir },
|
|
@@ -173,6 +176,8 @@ async function fixSwarmLegacyResidue() {
|
|
|
173
176
|
{ name: 'sona-patterns.json', dest: neuralDir },
|
|
174
177
|
{ name: 'state.json', dest: swarmStateDir },
|
|
175
178
|
{ name: 'code-map-hash.txt', dest: memoryStateDir },
|
|
179
|
+
{ name: 'patterns-hash.txt', dest: moflo },
|
|
180
|
+
{ name: 'tests-hash.txt', dest: moflo },
|
|
176
181
|
];
|
|
177
182
|
for (const { name, dest } of stateFiles) {
|
|
178
183
|
const src = join(swarmDir, name);
|
|
@@ -237,6 +242,169 @@ async function fixSwarmLegacyResidue() {
|
|
|
237
242
|
}
|
|
238
243
|
return allMigrated;
|
|
239
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
|
+
}
|
|
240
408
|
/**
|
|
241
409
|
* Execute the fix for a failed/warned health check.
|
|
242
410
|
* Returns true if the fix succeeded (re-check should pass).
|
|
@@ -589,6 +757,13 @@ export async function autoFixCheck(check) {
|
|
|
589
757
|
'Swarm Residue': async () => {
|
|
590
758
|
return fixSwarmLegacyResidue();
|
|
591
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
|
+
},
|
|
592
767
|
'Status Line': async () => {
|
|
593
768
|
const settingsPath = join(process.cwd(), '.claude', 'settings.json');
|
|
594
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.
|
|
@@ -29,11 +29,11 @@ function mofloSection() {
|
|
|
29
29
|
|
|
30
30
|
### FIRST ACTION ON EVERY PROMPT: Search Memory
|
|
31
31
|
|
|
32
|
-
Your first tool call MUST be \`mcp__moflo__memory_search\` — before any Glob/Grep/Read.
|
|
32
|
+
Your first tool call MUST be \`mcp__moflo__memory_search\` — before any Glob/Grep/Read. Pick the namespace by question shape: \`code-map\` for "where is symbol X defined", \`tests\` for "what tests cover Y", \`patterns\` for "what's our pattern for Z", \`guidance\` for project rules, \`learnings\` for "did we hit this before". Pivot on the bare symbol/keyword (not a natural-language question), and trust similarity ≥ 0.80 as a confident hit. When the user says "remember this", call \`mcp__moflo__memory_store\` with namespace \`learnings\`.
|
|
33
33
|
|
|
34
34
|
### Traverse chunks, don't bulk-retrieve
|
|
35
35
|
|
|
36
|
-
Search results carry a compact \`navigation\` crumb (parentDoc, prev/next, chunkTitle). For adjacent/sibling/hierarchical context use \`mcp__moflo__memory_get_neighbors\`; for full chunk content use \`mcp__moflo__memory_retrieve\`; \`Read\` the source doc only via \`parentPath\` when truly needed. Full protocol: \`.claude/guidance/moflo-memory-protocol.md\`.
|
|
36
|
+
Search results carry a compact \`navigation\` crumb (parentDoc, prev/next, chunkTitle). For adjacent/sibling/hierarchical context use \`mcp__moflo__memory_get_neighbors\`; for full chunk content use \`mcp__moflo__memory_retrieve\`; \`Read\` the source doc only via \`parentPath\` when truly needed. Full protocol + worked examples per namespace: \`.claude/guidance/moflo-memory-protocol.md\`.
|
|
37
37
|
|
|
38
38
|
### Auto-enforced gates
|
|
39
39
|
|
|
@@ -48,6 +48,10 @@ Prefer MCP (\`mcp__moflo__*\` — memory, swarm, agent, task, hooks, hive-mind,
|
|
|
48
48
|
|
|
49
49
|
After \`npm install\` touches moflo, check \`.moflo/restart-pending.json\` — if present, surface its \`message\` field to the user verbatim, then delete the file. (Claude Code only loads new hooks/MCP/launcher at session start.)
|
|
50
50
|
|
|
51
|
+
### Monorepos
|
|
52
|
+
|
|
53
|
+
Moflo state lives at the monorepo root \`.moflo/\` — never run \`flo init\` inside a sub-workspace of an existing moflo project, or the MCP server and CLI silently bind to different daemons (issue #1174).
|
|
54
|
+
|
|
51
55
|
### Full Reference
|
|
52
56
|
|
|
53
57
|
- Universal agent rules (memory protocol, git/PR conventions, file org, build/test): \`.claude/guidance/moflo-agent-rules.md\`
|
|
@@ -276,11 +276,28 @@ var config = loadGateConfig();
|
|
|
276
276
|
var command = process.argv[2];
|
|
277
277
|
|
|
278
278
|
var EXEMPT = ['.claude/', '.claude\\\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
|
|
279
|
-
|
|
279
|
+
// #1171 — DANGEROUS gained PS additions to match the matcher widening that now
|
|
280
|
+
// routes the PowerShell tool through check-dangerous-command. See bin/gate.cjs.
|
|
281
|
+
var DANGEROUS = ['rm -rf /', 'format c:', 'del /s /q c:\\\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda', 'remove-item -recurse -force c:\\\\', 'remove-item -recurse -force /', 'remove-item -recurse -force ~', 'format-volume', 'clear-disk'];
|
|
280
282
|
// #1132 — Bash memory-first gate regexes. See bin/gate.cjs for documentation.
|
|
283
|
+
// #1171 — READ_LIKE extended with PS-native exploration forms (Get-ChildItem -Recurse,
|
|
284
|
+
// dir /s, Format-Hex). Plain Get-ChildItem stays uncovered (ls-equivalent).
|
|
281
285
|
var CREDIT_MEMORY_SEARCH_RE = /semantic-search|memory search|memory retrieve|memory-search/;
|
|
282
|
-
var READ_LIKE_BASH_RE = /^\\s*(?:cat|head|tail|less|more|bat|xxd|od|hexdump)\\b|^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd)\\b|^\\s*sed\\s+-n\\b|^\\s*awk\\s+(?!.*<<)|^\\s*type\\s+\\S*[\\\\/.]|^\\s*(?:Get-Content|gc|Select-String|sls)\\b/i;
|
|
286
|
+
var READ_LIKE_BASH_RE = /^\\s*(?:cat|head|tail|less|more|bat|xxd|od|hexdump)\\b|^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd)\\b|^\\s*sed\\s+-n\\b|^\\s*awk\\s+(?!.*<<)|^\\s*type\\s+\\S*[\\\\/.]|^\\s*(?:Get-Content|gc|Select-String|sls)\\b|^\\s*(?:Get-ChildItem|gci)\\b[^|]*-Recurse\\b|^\\s*dir\\b[^|]*\\s\\/[sS]\\b|^\\s*Format-Hex\\b/i;
|
|
283
287
|
var BASH_CARVE_OUT_RE = /^\\s*(npm|npx|pnpm|yarn|bun|node|deno|tsx|ts-node)\\s|^\\s*(git|gh|hub)\\s|^\\s*(docker|kubectl|helm|terraform)\\s|^\\s*(curl|wget|http|fetch)\\s|^\\s*(jq|yq|xq)\\s|^\\s*(echo|printf|true|false|sleep|test|\\[)\\s|^\\s*cat\\s+(<<|<<<)|^\\s*cat\\s+[^|]*\\s*>|^\\s*tee\\b|^\\s*find\\s+.+?-(delete|exec\\s+rm)\\b/;
|
|
288
|
+
// #1171 follow-up — strip quoted bodies + heredocs before DANGEROUS substring
|
|
289
|
+
// match so git commit messages with dangerous-shaped text in quoted bodies do
|
|
290
|
+
// not trip the gate. See bin/gate.cjs for the full rationale. Command-sub
|
|
291
|
+
// bodies are intentionally not stripped (those execute).
|
|
292
|
+
function stripQuotedAndHeredocs(cmd) {
|
|
293
|
+
var out = cmd;
|
|
294
|
+
out = out.replace(/<<-?\\s*['"]?[\\w-]+['"]?[\\s\\S]*$/, '');
|
|
295
|
+
out = out.replace(/<<<\\s*\\S+/g, '');
|
|
296
|
+
out = out.replace(/'[^']*'/g, "''");
|
|
297
|
+
out = out.replace(/"(?:[^"\\\\]|\\\\.)*"/g, '""');
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
284
301
|
var DIRECTIVE_RE = /^(yes|no|yeah|yep|nope|sure|ok|okay|correct|right|exactly|perfect)\\b/i;
|
|
285
302
|
var TASK_RE = /\\b(fix|bug|error|implement|add|create|build|write|refactor|debug|test|feature|issue|security|optimi)\\b/i;
|
|
286
303
|
|
|
@@ -585,7 +602,10 @@ switch (command) {
|
|
|
585
602
|
process.exit(2);
|
|
586
603
|
}
|
|
587
604
|
case 'check-dangerous-command': {
|
|
588
|
-
|
|
605
|
+
// #1171 follow-up — strip quoted bodies + heredocs before substring match.
|
|
606
|
+
// See bin/gate.cjs for full rationale.
|
|
607
|
+
var raw = process.env.TOOL_INPUT_command || '';
|
|
608
|
+
var cmd = stripQuotedAndHeredocs(raw).toLowerCase();
|
|
589
609
|
for (var i = 0; i < DANGEROUS.length; i++) {
|
|
590
610
|
if (cmd.indexOf(DANGEROUS[i]) >= 0) {
|
|
591
611
|
console.log('[BLOCKED] Dangerous command: ' + DANGEROUS[i]);
|
|
@@ -219,7 +219,8 @@ function generateHooks(root, force, answers) {
|
|
|
219
219
|
"hooks": [{ "type": "command", "command": gateHook('check-before-read'), "timeout": 3000 }]
|
|
220
220
|
},
|
|
221
221
|
{
|
|
222
|
-
|
|
222
|
+
// #1171 — widened to cover the dedicated `PowerShell` tool.
|
|
223
|
+
"matcher": "^(Bash|PowerShell)$",
|
|
223
224
|
"hooks": [
|
|
224
225
|
{ "type": "command", "command": gateHook('check-dangerous-command'), "timeout": 2000 },
|
|
225
226
|
{ "type": "command", "command": gateHook('check-before-pr'), "timeout": 2000 }
|
|
@@ -253,7 +254,8 @@ function generateHooks(root, force, answers) {
|
|
|
253
254
|
"hooks": [{ "type": "command", "command": gate('record-task-created'), "timeout": 2000 }]
|
|
254
255
|
},
|
|
255
256
|
{
|
|
256
|
-
|
|
257
|
+
// #1171 — widened to cover the dedicated `PowerShell` tool.
|
|
258
|
+
"matcher": "^(Bash|PowerShell)$",
|
|
257
259
|
"hooks": [
|
|
258
260
|
{ "type": "command", "command": gateHook('check-bash-memory'), "timeout": 2000 },
|
|
259
261
|
{ "type": "command", "command": gateHook('record-test-run'), "timeout": 2000 }
|
|
@@ -229,12 +229,16 @@ function generateHooksConfig(config) {
|
|
|
229
229
|
hooks: [{ type: 'command', command: gateHookCmd('check-before-read'), timeout: 3000 }],
|
|
230
230
|
},
|
|
231
231
|
{
|
|
232
|
-
|
|
232
|
+
// #1171 — widened from `^Bash$` to also cover the dedicated `PowerShell`
|
|
233
|
+
// tool that Claude Code exposes on Windows. Without this, PS-tool calls
|
|
234
|
+
// route around every Bash-anchored gate (dangerous-command, pr, memory).
|
|
235
|
+
matcher: '^(Bash|PowerShell)$',
|
|
233
236
|
hooks: [
|
|
234
237
|
{ type: 'command', command: gateHookCmd('check-dangerous-command'), timeout: 2000 },
|
|
235
238
|
{ type: 'command', command: gateHookCmd('check-before-pr'), timeout: 2000 },
|
|
236
239
|
// #1132 — moved from PostToolUse so process.exit(2) actually blocks
|
|
237
|
-
// read-like
|
|
240
|
+
// read-like shell commands that bypass the Read/Glob/Grep gates.
|
|
241
|
+
// Name kept for backwards compat; covers PowerShell readers too.
|
|
238
242
|
{ type: 'command', command: gateHookCmd('check-bash-memory'), timeout: 2000 },
|
|
239
243
|
],
|
|
240
244
|
},
|
|
@@ -273,7 +277,9 @@ function generateHooksConfig(config) {
|
|
|
273
277
|
hooks: [{ type: 'command', command: gateCmd('record-task-created'), timeout: 2000 }],
|
|
274
278
|
},
|
|
275
279
|
{
|
|
276
|
-
|
|
280
|
+
// #1171 — widened to cover the `PowerShell` tool so PS-invoked
|
|
281
|
+
// `npm test` / `pytest` etc. credit the testing gate the same as Bash.
|
|
282
|
+
matcher: '^(Bash|PowerShell)$',
|
|
277
283
|
hooks: [
|
|
278
284
|
// #1132 — check-bash-memory moved to PreToolUse (above).
|
|
279
285
|
{ type: 'command', command: gateHookCmd('record-test-run'), timeout: 2000 },
|