moflo 4.10.11 → 4.10.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/guidance/shipped/moflo-core-guidance.md +16 -0
- package/.claude/guidance/shipped/moflo-memory-protocol.md +171 -11
- package/.claude/helpers/gate.cjs +139 -14
- package/.claude/skills/publish/SKILL.md +46 -8
- package/bin/gate.cjs +139 -14
- package/bin/lib/moflo-paths.mjs +74 -4
- package/bin/session-start-launcher.mjs +173 -5
- package/dist/src/cli/commands/doctor-checks-config.js +141 -3
- package/dist/src/cli/commands/doctor-fixes.js +202 -10
- package/dist/src/cli/commands/doctor-registry.js +9 -1
- package/dist/src/cli/commands/init.js +33 -0
- package/dist/src/cli/commands/memory.js +11 -4
- package/dist/src/cli/commands/swarm.js +29 -60
- package/dist/src/cli/init/claudemd-generator.js +6 -2
- package/dist/src/cli/init/helpers-generator.js +23 -3
- package/dist/src/cli/init/moflo-init.js +4 -2
- package/dist/src/cli/init/settings-generator.js +9 -3
- package/dist/src/cli/mcp-server.js +104 -2
- package/dist/src/cli/memory/ewc-consolidation.js +22 -6
- package/dist/src/cli/memory/sona-optimizer.js +25 -7
- package/dist/src/cli/movector/lora-adapter.js +22 -7
- package/dist/src/cli/movector/moe-router.js +22 -6
- package/dist/src/cli/services/hook-block-hash.js +5 -2
- package/dist/src/cli/services/hook-wiring.js +38 -4
- package/dist/src/cli/services/moflo-paths.js +36 -0
- package/dist/src/cli/services/project-root.js +84 -25
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
//
|
|
263
|
-
//
|
|
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
|
-
|
|
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)
|
|
560
|
-
|
|
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
|
-
//
|
|
584
|
-
// file
|
|
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
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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]);
|
package/bin/lib/moflo-paths.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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'))
|
|
94
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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) ────────────────────────────────
|