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/bin/gate.cjs CHANGED
@@ -81,7 +81,21 @@ var config = loadGateConfig();
81
81
  var command = process.argv[2];
82
82
 
83
83
  var EXEMPT = ['.claude/', '.claude\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
84
- var DANGEROUS = ['rm -rf /', 'format c:', 'del /s /q c:\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda'];
84
+ // #1171 DANGEROUS gained PowerShell additions to match the matcher widening
85
+ // that now routes the dedicated `PowerShell` tool through check-dangerous-command.
86
+ // POSIX entries still apply because PS will execute them when invoked. Substring
87
+ // match (case-insensitive) inside the gate.
88
+ var DANGEROUS = [
89
+ 'rm -rf /', 'format c:', 'del /s /q c:\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda',
90
+ // PowerShell destructive patterns. Won't catch every adversarial spelling
91
+ // (PS aliases let `ri -r -force C:\` mean the same thing) but covers the
92
+ // common-typo destruction class — symmetric to the POSIX list's intent.
93
+ 'remove-item -recurse -force c:\\',
94
+ 'remove-item -recurse -force /',
95
+ 'remove-item -recurse -force ~',
96
+ 'format-volume',
97
+ 'clear-disk',
98
+ ];
85
99
 
86
100
  // #1132 — Bash memory-first gate.
87
101
  //
@@ -94,6 +108,9 @@ var CREDIT_MEMORY_SEARCH_RE = /semantic-search|memory search|memory retrieve|mem
94
108
  // check-before-scan gates by going through the shell. Anchored to the start of
95
109
  // the line so subcommands inside pipelines or `npm install grep` don't trip.
96
110
  // Covers POSIX read/search tools, Windows cmd `type`, and PowerShell readers.
111
+ // #1171 — extended with PowerShell-native exploration forms now that the matcher
112
+ // widens to the `PowerShell` tool. Plain `Get-ChildItem` without -Recurse stays
113
+ // uncovered (it's `ls`-equivalent and plain `ls` is allowed).
97
114
  var READ_LIKE_BASH_RE = new RegExp([
98
115
  '^\\s*(?:cat|head|tail|less|more|bat|xxd|od|hexdump)\\b',
99
116
  '^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd)\\b',
@@ -108,6 +125,17 @@ var READ_LIKE_BASH_RE = new RegExp([
108
125
  // primary risk pattern is leaking past the gate via `type src\foo.ts`.
109
126
  '^\\s*type\\s+\\S*[\\\\/.]',
110
127
  '^\\s*(?:Get-Content|gc|Select-String|sls)\\b',
128
+ // #1171 — PowerShell recursive exploration (parallel to POSIX `find`/`fd`).
129
+ // The `-Recurse` flag is what makes it expensive enough to gate; plain
130
+ // `Get-ChildItem` is `ls`-shaped and intentionally not blocked.
131
+ '^\\s*(?:Get-ChildItem|gci)\\b[^|]*-Recurse\\b',
132
+ // #1171 — cmd-style recursive listing (`dir /s` or `dir /S`). Only the
133
+ // Windows `/s` form, NOT POSIX `dir -s` (sort-by-size, where `dir` is aliased
134
+ // to `ls -l` on many distros) — false-positive blocking that would break
135
+ // legitimate POSIX listings.
136
+ '^\\s*dir\\b[^|]*\\s\\/[sS]\\b',
137
+ // #1171 — PowerShell hex dump, parallel to POSIX `xxd`/`hexdump`.
138
+ '^\\s*Format-Hex\\b',
111
139
  ].join('|'), 'i');
112
140
  // CARVE-OUT: commands that LOOK read-like but are operational. Anchored to the
113
141
  // LEADING command — the pipe-filter case (`npm test | grep FAIL`) is already
@@ -129,6 +157,32 @@ var BASH_CARVE_OUT_RE = new RegExp([
129
157
  // `find` commands that lack a `-delete` / `-exec rm` suffix.
130
158
  '^\\s*find\\s+.+?-(delete|exec\\s+rm)\\b',
131
159
  ].join('|'));
160
+ // #1171 follow-up — strip quoted string bodies and heredoc bodies from a shell
161
+ // command for purposes of dangerous-pattern substring matching. Used by
162
+ // check-dangerous-command. Does NOT strip $(...) or `...` because those bodies
163
+ // execute. Double-quoted strings handle escaped quotes (`\"`) correctly so
164
+ // `git commit -m "fix \"X\""` strips the whole quoted body, not just the first
165
+ // `\"` pair. Single quotes don't have escapes in bash/sh — `'[^']*'` is exact.
166
+ function stripQuotedAndHeredocs(cmd) {
167
+ var out = cmd;
168
+ // Heredoc tail: `<<TOKEN`, `<<-TOKEN`, `<<'TOKEN'`, `<<"TOKEN"` through end-of-input.
169
+ // Bash heredocs are multi-line; in single-line tool inputs they show up as the
170
+ // tail after `<<TOKEN`. Conservative tail-strip — benign content after a heredoc
171
+ // body on the same logical line is also stripped, harmless for this gate.
172
+ // Token class includes `-` so hyphenated heredoc tags (`<<END-OF-DOC`) match
173
+ // the full token, not just the leading word — without this the strip would
174
+ // halt at `<<END` and leave `-OF-DOC` plus the body as literal text.
175
+ out = out.replace(/<<-?\s*['"]?[\w-]+['"]?[\s\S]*$/, '');
176
+ // Here-string `<<<word` — strip the word.
177
+ out = out.replace(/<<<\s*\S+/g, '');
178
+ // Single-quoted strings — no escapes inside single quotes in sh/bash.
179
+ out = out.replace(/'[^']*'/g, "''");
180
+ // Double-quoted strings — `(?:[^"\\]|\\.)*` matches anything except an
181
+ // unescaped `"`, so escaped `\"` mid-string doesn't terminate the strip early.
182
+ out = out.replace(/"(?:[^"\\]|\\.)*"/g, '""');
183
+ return out;
184
+ }
185
+
132
186
  var DIRECTIVE_RE = /^(yes|no|yeah|yep|nope|sure|ok|okay|correct|right|exactly|perfect)\b/i;
133
187
  var TASK_RE = /\b(fix|bug|error|implement|add|create|build|write|refactor|debug|test|feature|issue|security|optimi)\b/i;
134
188
 
@@ -254,14 +308,27 @@ var TEST_RUNNER_RE = /(?:^|[^a-z])(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?(?:test|t)(
254
308
  // Edits to these don't change runtime behaviour, so they don't invalidate prior test/simplify runs.
255
309
  // Lock files and .gitignore are tracked but inert; package.json/*.yaml ARE source — they reset.
256
310
  var EDIT_RESET_SKIP_BOTH_RE = /\.(md|markdown|txt|rst|adoc|lock|gitignore)$|(?:^|[\\\/])(CHANGELOG(?:\.md)?|\.env\.example|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/i;
311
+ // #1176 — path-based inert markers. The extension-based RE above can't cover
312
+ // `.github/workflows/*.yml` without also exempting `moflo.yaml` / `tsconfig.yaml`
313
+ // (which ARE source). Anchor on the GitHub-meta directories that hold CI config
314
+ // and template scaffolds — editing those doesn't expose new runtime surface, so
315
+ // they shouldn't reset testsRun/simplifyRun the way a real source edit does.
316
+ // Trailing terminator includes `.` so the single-file template form
317
+ // `.github/PULL_REQUEST_TEMPLATE.md` matches alongside the directory form.
318
+ var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\/])\.github[\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\/.]|$)/i;
257
319
  // Test files: invalidate the testing gate (tests are stale once test code changes)
258
320
  // but NOT the simplify gate — /simplify already reviewed the production code; touching
259
321
  // a test file or fixture doesn't expose new untested surface for code review (#908).
260
322
  var EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE = /(?:^|[\\\/])(__tests__|__mocks__|tests?|spec|specs|cypress|e2e|fixtures?)[\\\/]|\.(test|spec)\.[mc]?[jt]sx?$|\.fixture\.[mc]?[jt]sx?$/i;
323
+ // #1176 — source-file extensions used by the no-source-files PR exemption.
324
+ // When the cumulative branch diff has zero files matching this RE (i.e. only
325
+ // YAML/MD/JSON/lockfiles/images/templates), the testing/simplify/learnings
326
+ // gates auto-pass at `check-before-pr`. Lists every language moflo ships
327
+ // against — additions here should match TEST_RUNNER_RE's language coverage.
328
+ var SOURCE_FILE_RE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|sh|bash|ps1)$/i;
261
329
  // Docs-only PR exemption: text/markup/image extensions that cannot change runtime behaviour.
262
- // If EVERY file in the PR diff matches this, skip testing/simplify/learnings gates.
263
- // Anchored to end-of-path so e.g. `foo.md.js` does not match. Excludes lock files / configs
264
- // on purpose — those are inert for edit-reset (above) but not "documentation".
330
+ // Retained for the transparency message when the diff is *purely* docs (no YAML/JSON either)
331
+ // gives a more specific reason than "no source files" in that subset.
265
332
  var DOCS_ONLY_RE = /\.(md|markdown|txt|rst|adoc|html?|pdf|png|jpe?g|gif|svg|webp|ico|bmp)$/i;
266
333
 
267
334
  // Classifier-aware simplify gate skip. Returns a string reason if the gate
@@ -285,13 +352,24 @@ function classifyForGateSkip(state) {
285
352
  } catch (e) { return null; }
286
353
  if (typeof classify !== 'function') return null;
287
354
 
288
- function tryClassify(diffText, label) {
355
+ function tryClassify(diffText, label, allowSmallReviewFix) {
289
356
  try {
290
357
  var dec = classify(diffText);
291
358
  if (dec.tier === 'TRIVIAL') {
292
359
  var loc = (dec.stats.added || 0) + (dec.stats.deleted || 0);
293
360
  return label + ' is TRIVIAL (' + loc + ' LOC, ' + (dec.stats.fileCount || 0) + ' file(s))';
294
361
  }
362
+ // #1176 — SMALL review-fix shape (snapshot path only). A ≤30-LOC delta with
363
+ // zero new declarations on top of an already-reviewed branch is the typical
364
+ // "apply 3 review fixes" cycle — re-running /flo-simplify against the same
365
+ // surface plus a few-line tweak adds no new signal. Baseline path stays
366
+ // TRIVIAL-only so brand-new SMALL features still get reviewed.
367
+ if (allowSmallReviewFix && dec.tier === 'SMALL') {
368
+ var totalLoc = (dec.stats.added || 0) + (dec.stats.deleted || 0);
369
+ if (totalLoc <= 30 && (dec.stats.declAdded || 0) === 0) {
370
+ return label + ' is SMALL review-fix shape (' + totalLoc + ' LOC, no new declarations)';
371
+ }
372
+ }
295
373
  } catch (e) { /* fall through */ }
296
374
  return null;
297
375
  }
@@ -311,7 +389,9 @@ function classifyForGateSkip(state) {
311
389
  var workTreeA = gitDiff(['diff', 'HEAD']) || '';
312
390
  if (snapDiff !== null) {
313
391
  var combined = snapDiff + (workTreeA ? '\n' + workTreeA : '');
314
- var hit = tryClassify(combined, 'delta since last /simplify');
392
+ // Snapshot path: allow SMALL review-fix shape because the original /simplify
393
+ // already covered the surface and only tiny no-decl-touching tweaks followed.
394
+ var hit = tryClassify(combined, 'delta since last /simplify', true);
315
395
  if (hit) return hit;
316
396
  }
317
397
  }
@@ -475,6 +555,11 @@ switch (command) {
475
555
  // #1132 — preserve CREDIT side-effect AND add a BLOCK arm for read-like
476
556
  // Bash commands. Wired as PreToolUse[Bash] (was PostToolUse before #1132)
477
557
  // so process.exit(2) actually prevents the read from reaching the shell.
558
+ //
559
+ // #1171 — the case name is historical. The matcher now also covers the
560
+ // dedicated `PowerShell` tool, and READ_LIKE_BASH_RE already matched PS
561
+ // readers (Get-Content/Select-String/Get-ChildItem -Recurse/Format-Hex).
562
+ // Treat this case as shell-agnostic read-gate logic.
478
563
  var cmd = process.env.TOOL_INPUT_command || '';
479
564
 
480
565
  // 1) CREDIT — preserved behavior. A real memory-search invocation flips
@@ -531,6 +616,15 @@ switch (command) {
531
616
  s.testsRun = true;
532
617
  writeState(s);
533
618
  }
619
+ } else if (cmd) {
620
+ // #1176 — emit a stderr crumb when invoked with a non-empty command that
621
+ // doesn't match the test-runner pattern. Common pitfall: users run the
622
+ // stamp manually from a terminal to "satisfy the gate"; the silent no-op
623
+ // looks indistinguishable from success. gate-hook.mjs drops stderr from
624
+ // exit-0 invocations, so this only surfaces to direct CLI use — exactly
625
+ // the case where the friction lives.
626
+ var preview = cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd;
627
+ process.stderr.write('gate: record-test-run no-op — TOOL_INPUT_command="' + preview + '" did not match TEST_RUNNER_RE\n');
534
628
  }
535
629
  break;
536
630
  }
@@ -551,13 +645,21 @@ switch (command) {
551
645
  if (sha && s.simplifySnapshotSha !== sha) { s.simplifySnapshotSha = sha; changed = true; }
552
646
  } catch (e) { /* no git or detached state — skip snapshot, gate still works */ }
553
647
  if (changed) writeState(s);
648
+ } else if (skName) {
649
+ // #1176 — same rationale as record-test-run. A no-op stamp on a non-simplify
650
+ // skill name is silent to hooks (gate-hook.mjs drops exit-0 stderr) but
651
+ // visible when a user runs the stamp directly to "satisfy the gate" and
652
+ // wonders why simplifyRun stays false.
653
+ process.stderr.write('gate: record-skill-run no-op — TOOL_INPUT_skill="' + skName + '" is not simplify/flo-simplify\n');
554
654
  }
555
655
  break;
556
656
  }
557
657
  case 'reset-edit-gates': {
558
658
  var fp = process.env.TOOL_INPUT_file_path || '';
559
- // Inert files (markdown, lockfiles, CHANGELOG, .env.example): no gate reset.
560
- if (fp && EDIT_RESET_SKIP_BOTH_RE.test(fp)) break;
659
+ // Inert files (markdown, lockfiles, CHANGELOG, .env.example) AND inert paths
660
+ // (.github/workflows/, .github/ISSUE_TEMPLATE/, .github/PULL_REQUEST_TEMPLATE/, #1176):
661
+ // no gate reset — editing these doesn't expose new runtime surface.
662
+ if (fp && (EDIT_RESET_SKIP_BOTH_RE.test(fp) || EDIT_RESET_SKIP_PATH_RE.test(fp))) break;
561
663
  var s = readState();
562
664
  // Test-only edits invalidate testsRun but preserve simplifyRun (#908).
563
665
  var isTestOnly = fp && EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE.test(fp);
@@ -580,14 +682,27 @@ switch (command) {
580
682
  // optional ENV=val prefix segment catches `GH_TOKEN=x gh pr create`.
581
683
  var cmd = process.env.TOOL_INPUT_command || '';
582
684
  if (!/(?:^|&&\s*|\|\|\s*|;\s*)\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)*gh\s+pr\s+create\b/.test(cmd)) break;
583
- // Docs-only exemption: if every file changed vs the merge-base is a docs/image
584
- // file (no runtime-behaviour surface), skip the testing/simplify/learnings gates
685
+ // No-source-files exemption (#1176, supersedes the original docs-only path).
686
+ // If every file changed vs the merge-base is either a docs/image file or a
687
+ // path-inert file (.github/workflows/, ISSUE_TEMPLATE/, PULL_REQUEST_TEMPLATE/)
688
+ // — i.e. NO source files in the diff — skip testing/simplify/learnings gates
585
689
  // and surface a one-line transparency note. Falls through to the standard gate
586
690
  // on any failure (no base, no diff, exec error) — fail-safe by design.
691
+ //
692
+ // Source-file detection is the inverse of the inert checks: a file is "source"
693
+ // when it matches SOURCE_FILE_RE AND is not inside an inert path. This catches
694
+ // `.github/workflows/foo.sh` (sh extension but path-inert → no source).
587
695
  var changed = getChangedFilesVsBase();
588
- if (changed && changed.length > 0 && changed.every(function(f) { return DOCS_ONLY_RE.test(f); })) {
589
- process.stdout.write('Docs-only PR (' + changed.length + ' file' + (changed.length === 1 ? '' : 's') + ') — skipping testing/simplify/learnings gates.\n');
590
- break;
696
+ if (changed && changed.length > 0) {
697
+ var hasSource = changed.some(function(f) {
698
+ return SOURCE_FILE_RE.test(f) && !EDIT_RESET_SKIP_PATH_RE.test(f);
699
+ });
700
+ if (!hasSource) {
701
+ var allDocs = changed.every(function(f) { return DOCS_ONLY_RE.test(f); });
702
+ var reason = allDocs ? 'Docs-only' : 'No source files in branch diff';
703
+ process.stdout.write(reason + ' (' + changed.length + ' file' + (changed.length === 1 ? '' : 's') + ') — skipping testing/simplify/learnings gates.\n');
704
+ break;
705
+ }
591
706
  }
592
707
  var s = readState();
593
708
  // Classifier-aware skip: if delta-since-snapshot or whole-branch diff is
@@ -620,7 +735,17 @@ switch (command) {
620
735
  process.exit(2);
621
736
  }
622
737
  case 'check-dangerous-command': {
623
- var cmd = (process.env.TOOL_INPUT_command || '').toLowerCase();
738
+ // #1171 follow-up strip quoted string bodies and heredoc bodies before
739
+ // substring-matching DANGEROUS. Without this, `git commit -m "...remove-item
740
+ // -recurse -force c:\..."` blocks because the literal pattern appears in
741
+ // the quoted message body. Quoted text isn't executing — the gate's job is
742
+ // to catch typo-class destruction in the actual command, not text mentions
743
+ // inside arguments. Trade-off: `bash -c "rm -rf /"` also bypasses now; the
744
+ // gate is a typo safety net, not a security boundary, so this is acceptable.
745
+ // Command substitutions `$(...)` and backticks are NOT stripped — those
746
+ // bodies execute and dangerous content there is real.
747
+ var raw = process.env.TOOL_INPUT_command || '';
748
+ var cmd = stripQuotedAndHeredocs(raw).toLowerCase();
624
749
  for (var i = 0; i < DANGEROUS.length; i++) {
625
750
  if (cmd.indexOf(DANGEROUS[i]) >= 0) {
626
751
  console.log('[BLOCKED] Dangerous command: ' + DANGEROUS[i]);
@@ -52,6 +52,25 @@ export function legacyMemoryDbBakPath(projectRoot) {
52
52
  return join(projectRoot, LEGACY_SWARM_DIR, `${LEGACY_MEMORY_DB_FILE}${LEGACY_MEMORY_DB_BAK_SUFFIX}`);
53
53
  }
54
54
 
55
+ /**
56
+ * Common skip-list for any walk that enumerates a project's children looking
57
+ * for moflo state. Shared by `bin/session-start-launcher.mjs` (depth-1 walk)
58
+ * and `src/cli/commands/doctor-checks-config.ts` (depth-5 BFS) so the two
59
+ * can't silently diverge. Matched case-insensitively at the call site —
60
+ * Windows NTFS + macOS APFS are case-insensitive by default.
61
+ *
62
+ * Categories: VCS metadata, build/cache outputs, language-specific output
63
+ * dirs, IDE state, virtualenv dirs. NOT included: `.moflo*` — every walk
64
+ * filters that separately so archived residue from `flo doctor --fix` can be
65
+ * recognised by prefix.
66
+ */
67
+ export const COMMON_WALK_SKIP_NAMES = Object.freeze(new Set([
68
+ 'node_modules', '.git', '.svn', '.hg',
69
+ 'dist', 'build', 'out', 'target', '.next', '.nuxt', '.cache',
70
+ 'coverage', '.idea', '.vscode', '.turbo', '.svelte-kit',
71
+ 'vendor', '__pycache__', '.venv', 'venv', '.tox',
72
+ ]));
73
+
55
74
  export function memoryDbCandidatePaths(projectRoot) {
56
75
  return [
57
76
  memoryDbPath(projectRoot),
@@ -61,6 +80,40 @@ export function memoryDbCandidatePaths(projectRoot) {
61
80
  ];
62
81
  }
63
82
 
83
+ /**
84
+ * Walk strictly upward from `dir` (exclusive) and return the nearest ancestor
85
+ * that has `.moflo/moflo.db`, or `null` if none exists below the filesystem
86
+ * root.
87
+ *
88
+ * Used by the launcher and `flo init` to detect nested-.moflo/ situations
89
+ * (#1174). Post-resolver-fix `findProjectRoot` already returns the topmost
90
+ * memory marker, so encountering an ancestor here means either:
91
+ * 1. `CLAUDE_PROJECT_DIR` explicitly overrode to a sub-directory (legitimate
92
+ * user action — log a warning but don't refuse), or
93
+ * 2. The launcher is running before any `.moflo/moflo.db` exists at the
94
+ * current root (e.g. fresh init in a sub-workspace).
95
+ *
96
+ * In either case the caller wants to surface a clear diagnostic so the user
97
+ * can run `flo doctor --fix` to consolidate.
98
+ *
99
+ * @param {string} dir absolute path to walk up from
100
+ * @returns {string | null} ancestor directory containing `.moflo/moflo.db`
101
+ */
102
+ export function findAncestorMofloRoot(dir) {
103
+ const start = resolve(dir);
104
+ const fsRoot = parse(start).root;
105
+ let cursor = dirname(start);
106
+ while (cursor !== fsRoot) {
107
+ if (existsSync(join(cursor, '.moflo', 'moflo.db'))) {
108
+ return cursor;
109
+ }
110
+ const parent = dirname(cursor);
111
+ if (parent === cursor) break;
112
+ cursor = parent;
113
+ }
114
+ return null;
115
+ }
116
+
64
117
  /**
65
118
  * Resolve the project root the same way the TS bridge does. Every bin/
66
119
  * script that touches `.moflo/moflo.db` (or any sibling state under
@@ -83,15 +136,32 @@ export function findProjectRoot(opts) {
83
136
  const start = resolve(startDir);
84
137
  const fsRoot = parse(start).root;
85
138
 
86
- // High-priority pass: memory markers + CLAUDE.md/package.json pair.
139
+ // Pass A memory markers, topmost wins (#1174). Walks the FULL ancestor
140
+ // chain, returns the highest ancestor with .moflo/moflo.db or .swarm/memory.db.
141
+ // Guarantees the root daemon is canonical in a monorepo with nested residue.
142
+ let topmostMemoryMarker = null;
87
143
  let dir = start;
88
144
  while (dir !== fsRoot) {
89
145
  if (basename(dir) === 'node_modules') {
90
146
  dir = dirname(dir);
91
147
  continue;
92
148
  }
93
- if (existsSync(join(dir, '.moflo', 'moflo.db'))) return dir;
94
- if (existsSync(join(dir, '.swarm', 'memory.db'))) return dir;
149
+ if (existsSync(join(dir, '.moflo', 'moflo.db')) || existsSync(join(dir, '.swarm', 'memory.db'))) {
150
+ topmostMemoryMarker = dir;
151
+ }
152
+ const parent = dirname(dir);
153
+ if (parent === dir) break;
154
+ dir = parent;
155
+ }
156
+ if (topmostMemoryMarker) return topmostMemoryMarker;
157
+
158
+ // Pass B — CLAUDE.md/package.json pair, nearest wins.
159
+ dir = start;
160
+ while (dir !== fsRoot) {
161
+ if (basename(dir) === 'node_modules') {
162
+ dir = dirname(dir);
163
+ continue;
164
+ }
95
165
  if (existsSync(join(dir, 'CLAUDE.md')) && existsSync(join(dir, 'package.json'))) {
96
166
  return dir;
97
167
  }
@@ -100,7 +170,7 @@ export function findProjectRoot(opts) {
100
170
  dir = parent;
101
171
  }
102
172
 
103
- // Low-priority pass: bare package.json or .git.
173
+ // Pass C bare package.json or .git, nearest wins.
104
174
  dir = start;
105
175
  while (dir !== fsRoot) {
106
176
  if (basename(dir) === 'node_modules') {
@@ -11,7 +11,7 @@ import { spawn, execFileSync } from 'child_process';
11
11
  import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, mkdirSync, statSync } from 'fs';
12
12
  import { resolve, dirname, join } from 'path';
13
13
  import { fileURLToPath, pathToFileURL } from 'url';
14
- import { mofloDir, findProjectRoot } from './lib/moflo-paths.mjs';
14
+ import { mofloDir, findProjectRoot, findAncestorMofloRoot, COMMON_WALK_SKIP_NAMES } from './lib/moflo-paths.mjs';
15
15
  import { repairMemoryDbIfCorrupt } from './lib/db-repair.mjs';
16
16
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
17
17
  import { applyRetiredPrune } from './lib/retired-files.mjs';
@@ -48,6 +48,23 @@ function sessionStartMirrorHeader(file) {
48
48
  // different DBs than the bridge reads from — never reintroduce one.
49
49
  const projectRoot = findProjectRoot();
50
50
 
51
+ // Monorepo nested-.moflo guard (#1174). After the resolver fix, findProjectRoot
52
+ // returns the topmost ancestor with .moflo/moflo.db, so an ancestor still
53
+ // having one of those files means CLAUDE_PROJECT_DIR pinned us inside a
54
+ // sub-workspace, or someone reintroduced an inline walk-up. Either way, the
55
+ // daemon spawned from here would NOT be the same one a sibling cwd in the
56
+ // same monorepo resolves to. Emit a clear stderr warning so the user can run
57
+ // `flo doctor --fix` to consolidate.
58
+ try {
59
+ const ancestor = findAncestorMofloRoot(projectRoot);
60
+ if (ancestor) {
61
+ process.stderr.write(
62
+ `moflo: nested .moflo/ detected — using "${projectRoot}" while ancestor "${ancestor}" also has .moflo/moflo.db. ` +
63
+ `This fragments monorepo state across multiple daemons (#1174). Run "flo doctor --fix" to consolidate.\n`,
64
+ );
65
+ }
66
+ } catch { /* diagnostic-only — must never block session start */ }
67
+
51
68
  // Dogfood guard (#928). When this launcher runs inside the moflo repo itself,
52
69
  // .claude/scripts/, .claude/helpers/, and .claude/guidance/ are committed git
53
70
  // files — they ARE moflo's source of truth, not destinations to be re-synced
@@ -111,6 +128,82 @@ function errMessage(err) {
111
128
  return err && err.message ? err.message : String(err);
112
129
  }
113
130
 
131
+ // Post-upgrade monorepo consolidation notice (#1174). The resolver change in
132
+ // 4.10.13+ flips project-root resolution from "nearest .moflo/" to "topmost
133
+ // .moflo/". Consumers with pre-fix nested .moflo/ residue will see their
134
+ // effective projectRoot move upward, orphaning sub-daemons and changing the
135
+ // per-project daemon port hash. Write a one-time restart-pending.json so the
136
+ // user (via Claude's CLAUDE.md surface-and-delete rule) sees the guidance and
137
+ // runs `flo doctor --fix -c nested-moflo` before the gate hooks start failing.
138
+ //
139
+ // Sentinel state-machine — re-arms after consolidation:
140
+ // !sentinel && nested → notify (write json + sentinel, emit mutation)
141
+ // sentinel && nested → silent (already notified)
142
+ // sentinel && !nested → cleanup sentinel (user consolidated; re-arm)
143
+ // !sentinel && !nested → silent (nothing to do)
144
+ //
145
+ // Best-effort — never blocks session start.
146
+ try {
147
+ const nestedNoticeSentinel = join(mofloDir(projectRoot), 'nested-moflo-notice-shown');
148
+
149
+ // Cheap, depth-1 downward walk: just check the immediate children of
150
+ // projectRoot for `.moflo/moflo.db`. The full `flo doctor` BFS is depth-5
151
+ // — that's for the diagnostic. Here we only need a fast signal so the
152
+ // launcher stays under its perf budget.
153
+ //
154
+ // Skip-list is shared with the doctor BFS via COMMON_WALK_SKIP_NAMES to
155
+ // prevent divergence. Only `.moflo*`-prefixed dirs are filtered separately
156
+ // — a `.packages/` or `.workspaces/` directory with its own moflo state is
157
+ // legitimate (rare, but possible) and should still be detected.
158
+ let firstNested = null;
159
+ try {
160
+ const entries = readdirSync(projectRoot, { withFileTypes: true });
161
+ for (const entry of entries) {
162
+ if (!entry.isDirectory()) continue;
163
+ const name = entry.name;
164
+ if (COMMON_WALK_SKIP_NAMES.has(name.toLowerCase())) continue;
165
+ if (name.toLowerCase().startsWith('.moflo')) continue;
166
+ const childDir = join(projectRoot, name);
167
+ if (existsSync(join(childDir, '.moflo', 'moflo.db'))) {
168
+ firstNested = childDir;
169
+ break;
170
+ }
171
+ }
172
+ } catch { /* depth-1 walk failure — fall through, no notice */ }
173
+
174
+ const sentinelExists = existsSync(nestedNoticeSentinel);
175
+
176
+ if (firstNested && !sentinelExists) {
177
+ try {
178
+ mkdirSync(mofloDir(projectRoot), { recursive: true });
179
+ const pendingPath = join(mofloDir(projectRoot), 'restart-pending.json');
180
+ writeFileSync(pendingPath, JSON.stringify({
181
+ // schemaVersion (not the moflo package version) lets future readers
182
+ // branch on the notice shape if it ever evolves. The launcher's §0d
183
+ // version-match cleanup compares `pending.version === installedVersion`
184
+ // (moflo semver), so this schema field is safely ignored there.
185
+ schemaVersion: 1,
186
+ kind: 'nested-moflo',
187
+ message:
188
+ `moflo consolidates monorepo state at the repo root (#1174). Nested .moflo/ ` +
189
+ `directories detected (e.g. ${firstNested}). Run \`flo doctor --fix -c nested-moflo\` ` +
190
+ `to archive them and stop any orphaned sub-daemons.`,
191
+ createdAt: new Date().toISOString(),
192
+ }, null, 2));
193
+ writeFileSync(nestedNoticeSentinel, new Date().toISOString());
194
+ emitMutation('nested .moflo/ detected — restart-pending.json written with consolidation guidance');
195
+ } catch (err) {
196
+ emitWarning(`nested-moflo notice write failed: ${errMessage(err)}`);
197
+ }
198
+ } else if (!firstNested && sentinelExists) {
199
+ // User has consolidated. Clear the sentinel so a future re-introduction
200
+ // of nested .moflo/ re-arms the notice path.
201
+ try {
202
+ unlinkSync(nestedNoticeSentinel);
203
+ } catch { /* may have just been removed concurrently */ }
204
+ }
205
+ } catch { /* diagnostic-only — must never block session start */ }
206
+
114
207
  // Manifest schema (#854 hardening). Originally `string[]`; now `{path,size}[]`
115
208
  // so the launcher can detect *content* drift, not just *missing-file* drift.
116
209
  // Reading accepts both forms — a legacy v1 manifest is reported via
@@ -189,7 +282,7 @@ function writeUpgradeNotice(status) {
189
282
  buildAndWriteNotice(upgradeNoticeContext, status);
190
283
  }
191
284
 
192
- // ── 0-pre. Drop any stale upgrade notice (#738, #743) ───────────────────────
285
+ // ── 0-pre. Drop any stale upgrade notice (#738, #743, #1173) ────────────────
193
286
  // `upgrade-notice.json` is a transient handshake between launcher and
194
287
  // statusline — it should never survive past the launcher run that wrote it.
195
288
  // Pre-#738 launchers wrote a 1-hour-TTL "complete" notice after upgrade work
@@ -199,9 +292,46 @@ function writeUpgradeNotice(status) {
199
292
  // notice (legacy file, aborted launcher, future writer mistake) gets dropped
200
293
  // before the statusline can see it. The in-progress notice for THIS session,
201
294
  // if any, is written later in section 3 and cleared in section 3f.
202
- try {
203
- unlinkSync(join(mofloDir(projectRoot), 'upgrade-notice.json'));
204
- } catch { /* non-fatal file usually doesn't exist */ }
295
+ //
296
+ // #1173: capture the prior notice's parsed contents BEFORE deleting so §3 can
297
+ // detect "prior session completed upgrade work but never committed the stamp"
298
+ // and recover via eager-stamp (Option B). Without this read, the indefinite
299
+ // re-detect loop reported in #1173 has no signal to break on. Wrapped in an
300
+ // existsSync guard so the common no-upgrade path doesn't pay for an ENOENT
301
+ // throw + stack unwind on every session-start.
302
+ let priorUpgradeNotice = null;
303
+ if (existsSync(UPGRADE_NOTICE_PATH())) {
304
+ try {
305
+ priorUpgradeNotice = JSON.parse(readFileSync(UPGRADE_NOTICE_PATH(), 'utf-8'));
306
+ } catch { /* unparseable — fall through; treat as no signal */ }
307
+ try {
308
+ unlinkSync(UPGRADE_NOTICE_PATH());
309
+ } catch { /* deleted between stat and unlink — fine */ }
310
+ }
311
+
312
+ // #1173 Option D: defensive cleanup of in-progress upgrade notice if the
313
+ // launcher aborts before §3f writes 'completed'. Without this, a mid-flight
314
+ // abort leaves the (updating…) badge on the statusline until the 5-min TTL
315
+ // expires, even though no upgrade is actually in progress.
316
+ //
317
+ // Coverage matrix:
318
+ // - normal exit / process.exit() / uncaught exception → 'exit' fires
319
+ // - POSIX SIGTERM/SIGINT (Claude Code's 5s hook-timeout kill, Ctrl+C) →
320
+ // kernel terminates without running 'exit'; explicit handlers cover this
321
+ // path and call process.exit() so we leave with the conventional 128+sig
322
+ // status. The 'exit' listener also fires on those process.exit() calls,
323
+ // but the cleanup already ran and the guard returns early.
324
+ // - Windows TerminateProcess (no signal equivalent for POSIX SIGKILL) →
325
+ // no Node handler runs; the 5-min in-progress notice TTL is the safety
326
+ // net for this path.
327
+ let upgradeNoticeFinalized = false;
328
+ function clearAbortedUpgradeNotice() {
329
+ if (!upgradeNoticeContext || upgradeNoticeFinalized) return;
330
+ try { unlinkSync(UPGRADE_NOTICE_PATH()); } catch { /* already gone — fine */ }
331
+ }
332
+ process.on('exit', clearAbortedUpgradeNotice);
333
+ process.on('SIGTERM', () => { clearAbortedUpgradeNotice(); process.exit(143); });
334
+ process.on('SIGINT', () => { clearAbortedUpgradeNotice(); process.exit(130); });
205
335
 
206
336
  // ── 0-bootstrap-sentinel. Surface partial-bootstrap failures (#975) ─────────
207
337
  // `scripts/post-install-bootstrap.mjs` writes `.moflo/bootstrap-failed.json`
@@ -706,6 +836,37 @@ try {
706
836
  // Dogfood (#928): never drift-heal moflo's own committed copies.
707
837
  if (isMofloDogfood) manifestDrifted = false;
708
838
 
839
+ // #1173 Option B: eager-stamp recovery. A prior session reached §3f and
840
+ // wrote the 'completed' notice but was killed before §3g committed the
841
+ // stamp (5s SessionStart hook timeout, post-§3f exception, ...). The
842
+ // upgrade work is already done; we just need to land the stamp so
843
+ // subsequent sessions stop re-entering this branch. Heuristic: notice
844
+ // captured at §0-pre shows status='completed' AND to===installedVersion
845
+ // AND no manifest drift this session (drift means real new work to do).
846
+ // On success, promote cachedVersion in-memory so the next condition sees
847
+ // "no upgrade needed" and skips the full upgrade work block naturally —
848
+ // no else-if chain with a dead `if` body that confuses future readers.
849
+ // Stamp recovery throws → cachedVersion stays stale → fall through to
850
+ // the existing full-upgrade path as a safety net.
851
+ if (
852
+ installedVersion !== cachedVersion
853
+ && !manifestDrifted
854
+ && priorUpgradeNotice?.status === 'completed'
855
+ && priorUpgradeNotice?.to === installedVersion
856
+ ) {
857
+ try {
858
+ mkdirSync(dirname(versionStampPath), { recursive: true });
859
+ writeFileSync(versionStampPath, installedVersion);
860
+ cachedVersion = installedVersion;
861
+ emitMutation(
862
+ `recovered version stamp at ${installedVersion}`,
863
+ 'prior session completed upgrade work but stamp write was missed (#1173)',
864
+ );
865
+ } catch (err) {
866
+ emitWarning(`stamp recovery failed (${errMessage(err)}) — running full upgrade as fallback`);
867
+ }
868
+ }
869
+
709
870
  if (installedVersion !== cachedVersion || manifestDrifted) {
710
871
  if (installedVersion !== cachedVersion) {
711
872
  upgradeNoticeContext = {
@@ -1973,8 +2134,15 @@ function runMigrationsAndAnnounce(runnerPath) {
1973
2134
  // ── 3f. Flip the upgrade notice to "completed" (#636, #738) ─────────────────
1974
2135
  // See the TTL rationale at the constants above for why we switch to a
1975
2136
  // short-TTL completed badge instead of clearing the file.
2137
+ //
2138
+ // #1173: setting upgradeNoticeFinalized signals the exit handler (Option D
2139
+ // above) that the notice reached its terminal 'completed' state cleanly, so
2140
+ // the handler should NOT clear it on launcher exit. Without this flag the
2141
+ // exit cleanup would race with the statusline reader and drop the short-TTL
2142
+ // 'completed' badge the user is supposed to see.
1976
2143
  if (upgradeNoticeContext) {
1977
2144
  writeUpgradeNotice('completed');
2145
+ upgradeNoticeFinalized = true;
1978
2146
  }
1979
2147
 
1980
2148
  // ── 3g. Commit deferred version stamp (#730) ────────────────────────────────