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.
@@ -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. Search \`guidance\`, \`patterns\`, and \`learnings\` every prompt; add \`code-map\` when navigating code, \`tests\` when looking for test inventory or coverage. When the user says "remember this", call \`mcp__moflo__memory_store\` with namespace \`learnings\`.
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
- var DANGEROUS = ['rm -rf /', 'format c:', 'del /s /q c:\\\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda'];
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
- var cmd = (process.env.TOOL_INPUT_command || '').toLowerCase();
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
- "matcher": "^Bash$",
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
- "matcher": "^Bash$",
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
- matcher: '^Bash$',
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 Bash that bypasses the Read/Glob/Grep gates via the shell.
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
- matcher: '^Bash$',
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 },