moflo 4.12.5 → 4.12.6
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.
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* shell-out where possible). Falls back to running the check's `fix` string
|
|
6
6
|
* if it looks like an `npx`/`npm`/`claude` command.
|
|
7
7
|
*/
|
|
8
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync } from 'fs';
|
|
9
|
-
import { join } from 'path';
|
|
8
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync } from 'fs';
|
|
9
|
+
import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'path';
|
|
10
10
|
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
14
|
import { findProjectDaemonPids, getDaemonLockHolder } from '../services/daemon-lock.js';
|
|
15
|
-
import { findProjectRoot, resolveStateRoot } from '../services/project-root.js';
|
|
15
|
+
import { findProjectRoot, hasMofloStateMarker, resolveStateRoot } 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';
|
|
18
18
|
import { loadToolArrays, getTool } from './doctor-checks-functional-shared.js';
|
|
@@ -29,6 +29,86 @@ async function runFixCommand(cmd) {
|
|
|
29
29
|
return false;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/** `realpathSync` if the path exists, else the absolutized string. */
|
|
33
|
+
function canonical(p) {
|
|
34
|
+
const abs = resolve(p);
|
|
35
|
+
try {
|
|
36
|
+
return realpathSync(abs);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return abs;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Nearest moflo root at or above `from` (inclusive), or null.
|
|
44
|
+
*
|
|
45
|
+
* Shares `hasMofloStateMarker` with `findProjectRoot`'s Pass A rather than
|
|
46
|
+
* restating the marker set: a guard that tests a narrower signal than the
|
|
47
|
+
* resolver it guards is blind exactly where it matters, which is #1431's
|
|
48
|
+
* fourth defect. `findAncestorMofloRoot` is deliberately not reused — it tests
|
|
49
|
+
* only `.moflo/moflo.db`, and it is exclusive of its starting directory.
|
|
50
|
+
*/
|
|
51
|
+
function nearestMofloRoot(from) {
|
|
52
|
+
const start = resolve(from);
|
|
53
|
+
const fsRoot = parse(start).root;
|
|
54
|
+
let dir = start;
|
|
55
|
+
while (dir !== fsRoot) {
|
|
56
|
+
// Same skip as Pass A — a marker inside `node_modules` is a vendored
|
|
57
|
+
// package's state, not a project root.
|
|
58
|
+
if (basename(dir) === 'node_modules') {
|
|
59
|
+
dir = dirname(dir);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (hasMofloStateMarker(dir))
|
|
63
|
+
return dir;
|
|
64
|
+
const parent = dirname(dir);
|
|
65
|
+
if (parent === dir)
|
|
66
|
+
break;
|
|
67
|
+
dir = parent;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the project root the daemon auto-fixes may act on, or null to refuse.
|
|
73
|
+
*
|
|
74
|
+
* The fixes SIGTERM processes and unlink lock files, so an over-broad root is
|
|
75
|
+
* not a cosmetic error. `findProjectRoot()` Pass A deliberately returns the
|
|
76
|
+
* TOPMOST ancestor carrying `.moflo/moflo.db` (#1174, so a monorepo's root
|
|
77
|
+
* daemon is canonical). For a checkout nested inside an unrelated project that
|
|
78
|
+
* walk climbs past our own root and lands on the parent's — whose daemons are
|
|
79
|
+
* not ours to kill. These handlers were previously rooted at `process.cwd()`,
|
|
80
|
+
* where that case degraded to a harmless no-op (`pids` came back empty), so
|
|
81
|
+
* aligning them with `checkDaemonOrphan` must not convert a no-op into a kill.
|
|
82
|
+
*
|
|
83
|
+
* Refuse whenever a NEARER moflo root sits at or above the cwd and differs
|
|
84
|
+
* from the resolved root: that is the nested-checkout signature. Both sides are
|
|
85
|
+
* realpath'd before comparison (Rule #1 §2) — `findProjectRoot` may hand back a
|
|
86
|
+
* raw `CLAUDE_PROJECT_DIR` while the walk yields a resolved path, and on macOS
|
|
87
|
+
* `/var/folders` vs `/private/var/folders` would otherwise compare unequal.
|
|
88
|
+
*/
|
|
89
|
+
function daemonFixRoot() {
|
|
90
|
+
const root = findProjectRoot();
|
|
91
|
+
const rootC = canonical(root);
|
|
92
|
+
const cwdC = canonical(process.cwd());
|
|
93
|
+
// Containment first: the cwd must live inside the root we are about to reap
|
|
94
|
+
// in. `findProjectRoot` returns `CLAUDE_PROJECT_DIR` verbatim when set, and
|
|
95
|
+
// that variable is inherited by hooks, test runners, and spawned tools whose
|
|
96
|
+
// cwd may be somewhere else entirely — a temp fixture, `/`, another repo.
|
|
97
|
+
// Without this check the handler happily reaps the daemons of whatever
|
|
98
|
+
// project the environment names, from a cwd with no relationship to it.
|
|
99
|
+
// `relative` + `isAbsolute` rather than string prefixing: on Windows two
|
|
100
|
+
// different drive letters yield an absolute result, which must count as
|
|
101
|
+
// "outside" (Rule #1).
|
|
102
|
+
const rel = relative(rootC, cwdC);
|
|
103
|
+
if (rel !== '' && (rel.startsWith('..') || isAbsolute(rel)))
|
|
104
|
+
return null;
|
|
105
|
+
const nearest = nearestMofloRoot(cwdC);
|
|
106
|
+
// No moflo state anywhere at or above cwd: nothing to disambiguate, and the
|
|
107
|
+
// scan will find no same-project daemons anyway.
|
|
108
|
+
if (nearest === null)
|
|
109
|
+
return root;
|
|
110
|
+
return canonical(nearest) === rootC ? root : null;
|
|
111
|
+
}
|
|
32
112
|
/**
|
|
33
113
|
* Fix the `Config File` check by creating the project's JSON config.
|
|
34
114
|
*
|
|
@@ -601,9 +681,13 @@ export async function autoFixCheck(check) {
|
|
|
601
681
|
// Also reaps any same-project orphans whose PIDs aren't recorded in the
|
|
602
682
|
// lock — those are the daemons that survived prior buggy fixes.
|
|
603
683
|
'Daemon Status': async () => {
|
|
604
|
-
|
|
684
|
+
// #1431 — same subdirectory-mismatch class as `Daemon Orphan`; this one
|
|
685
|
+
// also reaps and unlinks, so it takes the same guarded root.
|
|
686
|
+
const root = daemonFixRoot();
|
|
687
|
+
if (root === null)
|
|
688
|
+
return false;
|
|
605
689
|
const { getDaemonLockPayload, reapSameProjectOrphans } = await import('../services/daemon-lock.js');
|
|
606
|
-
const payload = getDaemonLockPayload(
|
|
690
|
+
const payload = getDaemonLockPayload(root);
|
|
607
691
|
if (payload?.pid && payload.pid > 0) {
|
|
608
692
|
try {
|
|
609
693
|
process.kill(payload.pid, 'SIGTERM');
|
|
@@ -611,9 +695,9 @@ export async function autoFixCheck(check) {
|
|
|
611
695
|
catch { /* already dead */ }
|
|
612
696
|
}
|
|
613
697
|
// Wipe other same-project daemons that the lock doesn't account for.
|
|
614
|
-
reapSameProjectOrphans(
|
|
615
|
-
const lockFile = join(
|
|
616
|
-
const pidFile = join(
|
|
698
|
+
reapSameProjectOrphans(root);
|
|
699
|
+
const lockFile = join(root, '.moflo', 'daemon.lock');
|
|
700
|
+
const pidFile = join(root, '.moflo', 'daemon.pid');
|
|
617
701
|
try {
|
|
618
702
|
if (existsSync(lockFile))
|
|
619
703
|
unlinkSync(lockFile);
|
|
@@ -629,16 +713,19 @@ export async function autoFixCheck(check) {
|
|
|
629
713
|
// bin/session-start-launcher.mjs so the auto-fix matches the launcher's
|
|
630
714
|
// behavior exactly.
|
|
631
715
|
'Daemon Version Skew': async () => {
|
|
632
|
-
|
|
716
|
+
// #1431 — same subdirectory-mismatch class as `Daemon Orphan`.
|
|
717
|
+
const root = daemonFixRoot();
|
|
718
|
+
if (root === null)
|
|
719
|
+
return false;
|
|
633
720
|
const { getDaemonLockPayload } = await import('../services/daemon-lock.js');
|
|
634
|
-
const payload = getDaemonLockPayload(
|
|
721
|
+
const payload = getDaemonLockPayload(root);
|
|
635
722
|
if (payload?.pid && payload.pid > 0) {
|
|
636
723
|
try {
|
|
637
724
|
process.kill(payload.pid, 'SIGTERM');
|
|
638
725
|
}
|
|
639
726
|
catch { /* already dead */ }
|
|
640
727
|
}
|
|
641
|
-
const lockFile = join(
|
|
728
|
+
const lockFile = join(root, '.moflo', 'daemon.lock');
|
|
642
729
|
try {
|
|
643
730
|
if (existsSync(lockFile))
|
|
644
731
|
unlinkSync(lockFile);
|
|
@@ -653,20 +740,27 @@ export async function autoFixCheck(check) {
|
|
|
653
740
|
// threaded into `reapSameProjectOrphans` so we don't re-run the
|
|
654
741
|
// OS process scan inside it.
|
|
655
742
|
'Daemon Orphan': async () => {
|
|
656
|
-
|
|
743
|
+
// #1431 — resolve the root the way `checkDaemonOrphan` does, not with
|
|
744
|
+
// `process.cwd()`. The check walks up to the project root; the fix used
|
|
745
|
+
// the raw cwd, so `flo doctor --fix` from a subdirectory scanned a root
|
|
746
|
+
// with no daemons, fell through `pids.length <= 1`, and reported
|
|
747
|
+
// "Fixed: Daemon Orphan" without reaping anything.
|
|
748
|
+
const root = daemonFixRoot();
|
|
749
|
+
if (root === null)
|
|
750
|
+
return false;
|
|
657
751
|
const { findProjectDaemonPids, getDaemonLockHolder, reapSameProjectOrphans } = await import('../services/daemon-lock.js');
|
|
658
|
-
const pids = findProjectDaemonPids(
|
|
752
|
+
const pids = findProjectDaemonPids(root);
|
|
659
753
|
if (pids.length <= 1)
|
|
660
754
|
return true; // already healthy
|
|
661
|
-
const lockHolder = getDaemonLockHolder(
|
|
755
|
+
const lockHolder = getDaemonLockHolder(root);
|
|
662
756
|
if (lockHolder != null && pids.includes(lockHolder)) {
|
|
663
|
-
const { survived } = reapSameProjectOrphans(
|
|
757
|
+
const { survived } = reapSameProjectOrphans(root, process.pid, lockHolder, pids);
|
|
664
758
|
return survived.length === 0;
|
|
665
759
|
}
|
|
666
760
|
// No identifiable canonical daemon — kill them all, clear the lock,
|
|
667
761
|
// respawn fresh.
|
|
668
|
-
const { survived } = reapSameProjectOrphans(
|
|
669
|
-
const lockFile = join(
|
|
762
|
+
const { survived } = reapSameProjectOrphans(root, process.pid, undefined, pids);
|
|
763
|
+
const lockFile = join(root, '.moflo', 'daemon.lock');
|
|
670
764
|
try {
|
|
671
765
|
if (existsSync(lockFile))
|
|
672
766
|
unlinkSync(lockFile);
|
|
@@ -682,16 +776,21 @@ export async function autoFixCheck(check) {
|
|
|
682
776
|
// daemon binds the per-project deterministic port and stamps it into
|
|
683
777
|
// the lock — clients can discover it without guessing.
|
|
684
778
|
'Daemon Identity Match': async () => {
|
|
685
|
-
|
|
779
|
+
// #1431 — same resolver as `checkDaemonIdentityMatch`; see the note on
|
|
780
|
+
// the `Daemon Orphan` handler above. Reading the lock from a raw cwd
|
|
781
|
+
// would SIGTERM nothing and unlink a path the daemon never wrote.
|
|
782
|
+
const root = daemonFixRoot();
|
|
783
|
+
if (root === null)
|
|
784
|
+
return false;
|
|
686
785
|
const { getDaemonLockPayload } = await import('../services/daemon-lock.js');
|
|
687
|
-
const payload = getDaemonLockPayload(
|
|
786
|
+
const payload = getDaemonLockPayload(root);
|
|
688
787
|
if (payload?.pid && payload.pid > 0) {
|
|
689
788
|
try {
|
|
690
789
|
process.kill(payload.pid, 'SIGTERM');
|
|
691
790
|
}
|
|
692
791
|
catch { /* already dead */ }
|
|
693
792
|
}
|
|
694
|
-
const lockFile = join(
|
|
793
|
+
const lockFile = join(root, '.moflo', 'daemon.lock');
|
|
695
794
|
try {
|
|
696
795
|
if (existsSync(lockFile))
|
|
697
796
|
unlinkSync(lockFile);
|
|
@@ -60,6 +60,22 @@ import { resolve, dirname, parse, join, basename } from 'node:path';
|
|
|
60
60
|
*
|
|
61
61
|
* Algorithmic twin of `bin/lib/moflo-paths.mjs:findAncestorMofloRoot()`.
|
|
62
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* Does `dir` carry moflo state that makes it a project root?
|
|
65
|
+
*
|
|
66
|
+
* The single source of truth for Pass A's marker set, exported so callers that
|
|
67
|
+
* must agree with the resolver cannot drift from it. #1431 is exactly that
|
|
68
|
+
* drift: `doctor-fixes.ts` guarded its daemon reaps by looking for
|
|
69
|
+
* `.moflo/moflo.db` alone, went blind to a nested checkout carrying only the
|
|
70
|
+
* legacy `.swarm/memory.db`, and allowed the parent project's daemons to be
|
|
71
|
+
* killed. Any new marker belongs here and nowhere else.
|
|
72
|
+
*
|
|
73
|
+
* (`bin/lib/moflo-paths.mjs` holds a plain-JS twin for the launcher, which
|
|
74
|
+
* cannot import TypeScript — keep the two in step.)
|
|
75
|
+
*/
|
|
76
|
+
export function hasMofloStateMarker(dir) {
|
|
77
|
+
return existsSync(join(dir, '.moflo', 'moflo.db')) || existsSync(join(dir, '.swarm', 'memory.db'));
|
|
78
|
+
}
|
|
63
79
|
export function findAncestorMofloRoot(dir) {
|
|
64
80
|
const start = resolve(dir);
|
|
65
81
|
const fsRoot = parse(start).root;
|
|
@@ -201,7 +217,7 @@ export function findProjectRoot(opts) {
|
|
|
201
217
|
dir = dirname(dir);
|
|
202
218
|
continue;
|
|
203
219
|
}
|
|
204
|
-
if (
|
|
220
|
+
if (hasMofloStateMarker(dir)) {
|
|
205
221
|
topmostMemoryMarker = dir;
|
|
206
222
|
}
|
|
207
223
|
const parent = dirname(dir);
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.12.
|
|
3
|
+
"version": "4.12.6",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
|
|
5
5
|
"main": "dist/src/cli/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
"@typescript-eslint/parser": "^8.65.0",
|
|
99
99
|
"eslint": "^10.8.0",
|
|
100
100
|
"glob": "^11.1.0",
|
|
101
|
-
"moflo": "^4.12.
|
|
101
|
+
"moflo": "^4.12.5",
|
|
102
102
|
"tsx": "^4.21.0",
|
|
103
103
|
"typescript": "^5.9.3",
|
|
104
104
|
"vitest": "^4.0.0"
|