moflo 4.12.2 → 4.12.3-rc.1

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.
Files changed (36) hide show
  1. package/bin/hooks.mjs +5 -2
  2. package/bin/index-all.mjs +38 -0
  3. package/bin/lib/moflo-paths.mjs +37 -1
  4. package/bin/lib/process-manager.mjs +16 -1
  5. package/bin/lib/registry-cleanup.cjs +9 -1
  6. package/bin/session-start-launcher.mjs +5 -2
  7. package/dist/src/cli/commands/agent.js +7 -2
  8. package/dist/src/cli/commands/benchmark.js +5 -2
  9. package/dist/src/cli/commands/daemon.js +81 -9
  10. package/dist/src/cli/commands/doctor-checks-config.js +55 -19
  11. package/dist/src/cli/commands/doctor-checks-memory.js +3 -2
  12. package/dist/src/cli/commands/doctor-checks-writers-audit.js +5 -1
  13. package/dist/src/cli/commands/doctor-embedding-hygiene.js +2 -1
  14. package/dist/src/cli/commands/doctor-fixes.js +20 -5
  15. package/dist/src/cli/commands/doctor-zombies.js +4 -3
  16. package/dist/src/cli/commands/embeddings.js +10 -5
  17. package/dist/src/cli/commands/hooks.js +11 -8
  18. package/dist/src/cli/commands/swarm.js +2 -1
  19. package/dist/src/cli/index.js +18 -6
  20. package/dist/src/cli/memory/bridge-core.js +8 -2
  21. package/dist/src/cli/memory/daemon-write-client.js +5 -5
  22. package/dist/src/cli/memory/database-provider.js +14 -5
  23. package/dist/src/cli/memory/entries-read.js +5 -4
  24. package/dist/src/cli/memory/entries-write.js +3 -2
  25. package/dist/src/cli/memory/hnsw-singleton.js +2 -1
  26. package/dist/src/cli/memory/init.js +14 -8
  27. package/dist/src/cli/memory/learnings-overview.js +2 -1
  28. package/dist/src/cli/runtime/headless.js +8 -3
  29. package/dist/src/cli/services/daemon-dashboard.js +4 -4
  30. package/dist/src/cli/services/embeddings-migration.js +2 -1
  31. package/dist/src/cli/services/ephemeral-namespace-purge.js +3 -2
  32. package/dist/src/cli/services/project-root.js +108 -1
  33. package/dist/src/cli/services/soft-delete-purge.js +2 -1
  34. package/dist/src/cli/services/worker-daemon.js +67 -1
  35. package/dist/src/cli/version.js +1 -1
  36. package/package.json +8 -7
@@ -21,6 +21,7 @@ import { openDaemonDatabase } from '../memory/daemon-backend.js';
21
21
  // eagerly is cheap. The heavy imports (fastembed wrapper, upgrade renderer,
22
22
  // etc.) stay deferred behind the early returns so session-start stays fast.
23
23
  import { CANONICAL_EMBEDDING_MODEL, EMBEDDINGS_VERSION, } from '../embeddings/migration/types.js';
24
+ import { resolveStateRoot } from './project-root.js';
24
25
  /**
25
26
  * Run the migration if and only if it's needed.
26
27
  *
@@ -33,7 +34,7 @@ import { CANONICAL_EMBEDDING_MODEL, EMBEDDINGS_VERSION, } from '../embeddings/mi
33
34
  export async function runEmbeddingsMigrationIfNeeded(options = {}) {
34
35
  const fs = await import('fs');
35
36
  const path = await import('path');
36
- const dbPath = path.resolve(options.dbPath ?? memoryDbPath(process.cwd()));
37
+ const dbPath = path.resolve(options.dbPath ?? memoryDbPath(resolveStateRoot()));
37
38
  if (!fs.existsSync(dbPath))
38
39
  return false;
39
40
  // node:sqlite via the unified factory (Phase 5 / #1084). The migration
@@ -30,6 +30,7 @@
30
30
  import { PURGE_ON_SESSION_START_NAMESPACES, PURGE_ON_SESSION_START_PREFIXES, TASKLIST_RETENTION_CAP, } from '../memory/bridge-embedder.js';
31
31
  import { memoryDbPath } from './moflo-paths.js';
32
32
  import { openDaemonDatabase } from '../memory/daemon-backend.js';
33
+ import { resolveStateRoot } from './project-root.js';
33
34
  /**
34
35
  * Hard-delete rows in {@link PURGE_ON_SESSION_START_NAMESPACES} and trim the
35
36
  * `tasklist` namespace to its retention cap, then VACUUM. Returns
@@ -41,7 +42,7 @@ import { openDaemonDatabase } from '../memory/daemon-backend.js';
41
42
  export async function purgeEphemeralNamespaces(options = {}) {
42
43
  const fs = await import('fs');
43
44
  const path = await import('path');
44
- const dbPath = path.resolve(options.dbPath ?? memoryDbPath(process.cwd()));
45
+ const dbPath = path.resolve(options.dbPath ?? memoryDbPath(resolveStateRoot()));
45
46
  if (!fs.existsSync(dbPath))
46
47
  return { purged: 0, trimmed: 0 };
47
48
  // node:sqlite via the unified factory (Phase 5 / #1084). WAL persists each
@@ -128,7 +129,7 @@ export async function purgeEphemeralNamespaces(options = {}) {
128
129
  export async function purgeMemoryProbeNamespaces(options = {}) {
129
130
  const fs = await import('fs');
130
131
  const path = await import('path');
131
- const dbPath = path.resolve(options.dbPath ?? memoryDbPath(process.cwd()));
132
+ const dbPath = path.resolve(options.dbPath ?? memoryDbPath(resolveStateRoot()));
132
133
  if (!fs.existsSync(dbPath))
133
134
  return { purged: 0 };
134
135
  const prefixes = Array.from(PURGE_ON_SESSION_START_PREFIXES);
@@ -43,7 +43,7 @@
43
43
  * changed Pass A from nearest-wins to topmost-wins to fix monorepo daemon
44
44
  * fragmentation.
45
45
  */
46
- import { existsSync } from 'node:fs';
46
+ import { existsSync, realpathSync } from 'node:fs';
47
47
  import { resolve, dirname, parse, join, basename } from 'node:path';
48
48
  /**
49
49
  * Walk strictly upward from `dir` (exclusive) and return the nearest ancestor
@@ -75,6 +75,113 @@ export function findAncestorMofloRoot(dir) {
75
75
  }
76
76
  return null;
77
77
  }
78
+ /**
79
+ * Memo for {@link resolveStateRoot}, keyed on the two inputs that can change
80
+ * within a process: the starting directory and `CLAUDE_PROJECT_DIR`.
81
+ *
82
+ * Only MARKER-PROVEN results are cached — i.e. resolutions that landed on a
83
+ * directory actually holding `.moflo/moflo.db`. That distinction is what makes
84
+ * the cache safe: an existing `moflo.db` does not move during a process
85
+ * lifetime, whereas a fall-through result (Pass B/C, no moflo state anywhere)
86
+ * is exactly the case `flo init` is about to invalidate by creating one. So
87
+ * the un-cached path stays correct and the cached path stays stable.
88
+ *
89
+ * Motivation: a single `flo doctor` run resolves independently from ~6 check
90
+ * modules, each re-walking the ancestor chain.
91
+ */
92
+ const stateRootCache = new Map();
93
+ /** Clear the {@link resolveStateRoot} memo. Test seam only. */
94
+ export function _resetStateRootCacheForTest() {
95
+ stateRootCache.clear();
96
+ }
97
+ /**
98
+ * Resolve the anchor that any code READING OR WRITING `.moflo/` state must use
99
+ * (#1315). Use this instead of `process.cwd()` anywhere a `.moflo/` path is
100
+ * built — the daemon, the healer, and any command that persists metrics,
101
+ * benchmarks, or reports.
102
+ *
103
+ * `findProjectRoot()` short-circuits on `CLAUDE_PROJECT_DIR` before Pass A.
104
+ * That is correct for most callers, but wrong for state anchoring: Claude Code
105
+ * sets the variable to the SESSION's directory, hooks inherit it, and a spawned
106
+ * daemon inherits it again via `daemonEnv` (`commands/daemon.ts`). In a
107
+ * monorepo session rooted at a sub-workspace the whole chain therefore agreed
108
+ * the project root was the sub-workspace, and `daemon start` minted a fresh
109
+ * `.moflo/` there — re-seeding the daemon islands #1174 was closed for.
110
+ *
111
+ * The rule here: an existing `CLAUDE_PROJECT_DIR` wins outright — it is what
112
+ * Claude Code says the project is, and the marker walk is never allowed to
113
+ * climb above it. Otherwise walk up from cwd and take the TOPMOST
114
+ * `.moflo/moflo.db`, which is the canonical root for a monorepo. That walk is
115
+ * the actual #1315 fix: the state sites previously used `process.cwd()` raw,
116
+ * so every sub-directory invocation minted an island where it stood.
117
+ *
118
+ * Why the env is not merely a starting point: the walk has no upper bound, so
119
+ * climbing past an explicit anchor lets any stray ancestor marker capture every
120
+ * project beneath it. See the note in `resolveStateRootUncached`.
121
+ *
122
+ * Deliberately a separate export rather than a change inside `findProjectRoot`
123
+ * itself: that resolver is shared with the MCP server, the memory bridge, and
124
+ * the gate hooks, whose env-override semantics are load-bearing elsewhere.
125
+ * Callers that merely need "which project am I in" should keep using
126
+ * `findProjectRoot`; callers that are about to CREATE something use this.
127
+ */
128
+ export function resolveStateRoot(opts) {
129
+ const envDir = process.env.CLAUDE_PROJECT_DIR;
130
+ const cacheKey = `${opts?.cwd ?? process.cwd()}\0${envDir ?? ''}`;
131
+ const cached = stateRootCache.get(cacheKey);
132
+ if (cached !== undefined)
133
+ return cached;
134
+ const resolved = resolveStateRootUncached(opts, envDir);
135
+ // Cache only a marker-proven anchor — see the note on `stateRootCache`.
136
+ if (existsSync(join(resolved, '.moflo', 'moflo.db'))) {
137
+ stateRootCache.set(cacheKey, resolved);
138
+ }
139
+ return resolved;
140
+ }
141
+ function resolveStateRootUncached(opts, envDir) {
142
+ // An explicitly-set `CLAUDE_PROJECT_DIR` is AUTHORITATIVE and is never
143
+ // climbed above. Claude Code sets it to the project the user opened, and the
144
+ // marker walk has no upper bound — it runs to the filesystem root and takes
145
+ // the TOPMOST `.moflo/moflo.db`. Letting it climb past an explicit anchor
146
+ // means any stray ancestor marker captures every project beneath it: one
147
+ // accidental `flo init` in `$HOME` silently re-roots everything, and a
148
+ // scratch project created inside another checkout resolves to that checkout
149
+ // and mutates it. (Verified, not hypothetical: with the climb enabled the
150
+ // session-start launcher operated on this repo instead of its fixture.)
151
+ //
152
+ // A typo'd or stale value naming a directory that does not exist is ignored
153
+ // — callers mkdir what we return, so honoring it would materialize `.moflo/`
154
+ // at the typo path.
155
+ const envAbs = envDir ? resolve(envDir) : undefined;
156
+ if (envAbs && existsSync(envAbs)) {
157
+ return canonicalize(envAbs);
158
+ }
159
+ // No usable env anchor: walk up from cwd. This is the #1315 fix proper —
160
+ // the state-anchoring sites used to take `process.cwd()` raw, so any
161
+ // sub-directory invocation minted an island there. Pass A's topmost-wins
162
+ // walk lands on the canonical root instead.
163
+ const resolved = findProjectRoot({ honorEnv: false, cwd: opts?.cwd });
164
+ return canonicalize(resolved);
165
+ }
166
+ /**
167
+ * Absolutize + realpath a resolved root.
168
+ *
169
+ * The env value is caller-supplied and may be relative or carry a trailing
170
+ * separator, and on macOS `/var/folders/...` must collapse to
171
+ * `/private/var/folders/...` or this root won't compare equal to the realpath'd
172
+ * one `daemon-port.ts:normalizeProjectRoot` derives for same-project matching
173
+ * (CLAUDE.md Rule #1 §2). Falls back to the un-canonicalized path when it
174
+ * doesn't exist, which is the right degradation — never throw from a resolver.
175
+ */
176
+ function canonicalize(p) {
177
+ const abs = resolve(p);
178
+ try {
179
+ return realpathSync(abs);
180
+ }
181
+ catch {
182
+ return abs;
183
+ }
184
+ }
78
185
  export function findProjectRoot(opts) {
79
186
  const honorEnv = opts?.honorEnv !== false;
80
187
  if (honorEnv && process.env.CLAUDE_PROJECT_DIR) {
@@ -19,6 +19,7 @@
19
19
  /* eslint-disable @typescript-eslint/no-explicit-any */
20
20
  import { memoryDbPath } from './moflo-paths.js';
21
21
  import { openDaemonDatabase } from '../memory/daemon-backend.js';
22
+ import { resolveStateRoot } from './project-root.js';
22
23
  /**
23
24
  * Hard-delete all `status='deleted'` rows from the memory DB and VACUUM.
24
25
  *
@@ -30,7 +31,7 @@ import { openDaemonDatabase } from '../memory/daemon-backend.js';
30
31
  export async function purgeSoftDeletedEntries(options = {}) {
31
32
  const fs = await import('fs');
32
33
  const path = await import('path');
33
- const dbPath = path.resolve(options.dbPath ?? memoryDbPath(process.cwd()));
34
+ const dbPath = path.resolve(options.dbPath ?? memoryDbPath(resolveStateRoot()));
34
35
  if (!fs.existsSync(dbPath))
35
36
  return { purged: 0 };
36
37
  // node:sqlite via the unified factory (Phase 5 / #1084). WAL persists each
@@ -23,7 +23,7 @@
23
23
  * findings UI, not a default-on background loop.
24
24
  */
25
25
  import { EventEmitter } from 'events';
26
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
26
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'fs';
27
27
  import { atomicWriteFileSync } from './atomic-file-write.js';
28
28
  import { mofloDir } from './moflo-paths.js';
29
29
  import { cpus } from 'os';
@@ -34,6 +34,7 @@ import { attachSignalHandlers } from '../shared/resilience/signal-handlers.js';
34
34
  import { calculateDelay } from '../production/retry.js';
35
35
  import { CircuitBreaker } from '../production/circuit-breaker.js';
36
36
  import { errorDetail } from '../shared/utils/error-detail.js';
37
+ import { readMofloEnv } from './env-compat.js';
37
38
  /**
38
39
  * Runtime allow-list of known {@link WorkerType} values. Used by
39
40
  * `initializeWorkerStates` to silently drop entries from a stale
@@ -557,6 +558,68 @@ export class WorkerDaemon extends EventEmitter {
557
558
  config: this.config,
558
559
  };
559
560
  }
561
+ /**
562
+ * #1315 — exit when the project root has been deleted out from under us.
563
+ *
564
+ * A daemon started inside a git worktree outlived `git worktree remove`: the
565
+ * directory was gone, `git worktree list`/`prune` both reported clean, yet
566
+ * every tick the worker paths called `mkdirSync(stateDir, {recursive:true})`
567
+ * and RECREATED the deleted tree just to write `daemon-state.json` and
568
+ * `metrics/` into it. Deleting the husk by hand simply got it back on the
569
+ * next tick, because nothing ever asked whether the root still existed.
570
+ *
571
+ * Checked at the top of each tick, before any writer runs.
572
+ *
573
+ * Cross-platform (CLAUDE.md Rule #1): `existsSync` is platform-neutral, but
574
+ * the SCENARIO is mostly POSIX — Windows refuses to remove a directory that
575
+ * is a live process's cwd (sharing violation), and the daemon is spawned with
576
+ * `cwd: projectRoot`. So on Windows this check simply never fires, which is
577
+ * correct rather than a gap: the husk-recreation it guards against cannot
578
+ * happen there. No `process.platform` branch is warranted.
579
+ *
580
+ * @returns true when shutdown was initiated (caller must stop immediately).
581
+ */
582
+ async shutdownIfRootVanished() {
583
+ // `statSync`, NOT `existsSync`. `existsSync` swallows every error and
584
+ // returns false, so EACCES, EIO, a disconnected SMB share, or a sleeping
585
+ // network volume would be indistinguishable from "deleted" and would exit
586
+ // a perfectly healthy daemon on one bad sample. Only ENOENT means gone.
587
+ try {
588
+ statSync(this.projectRoot);
589
+ return false; // still there
590
+ }
591
+ catch (err) {
592
+ if (err?.code !== 'ENOENT') {
593
+ // Can't tell — assume present. A false positive kills a healthy
594
+ // daemon, which is far worse than one more tick of an orphan.
595
+ return false;
596
+ }
597
+ }
598
+ this.log('warn', `Project root ${this.projectRoot} no longer exists — shutting down instead of recreating it`);
599
+ try {
600
+ await this.stop();
601
+ }
602
+ catch {
603
+ /* best effort — we're leaving either way */
604
+ }
605
+ // Only a genuine daemon process may take the process down with it. A CLI
606
+ // command or a test that happens to hold a WorkerDaemon must not be
607
+ // exited out from under itself. `MOFLO_TEST_NO_DAEMON_SELF_EXIT` is the
608
+ // test-runner kill switch (vitest.setup.ts) — without it a vitest worker
609
+ // that inherited MOFLO_DAEMON would exit(0) mid-run and report every
610
+ // remaining test as passed.
611
+ // `readMofloEnv('DAEMON')`, not raw `process.env.MOFLO_DAEMON` — it also
612
+ // honors the legacy `CLAUDE_FLOW_DAEMON` prefix, which is what OS service
613
+ // units written by older moflo versions still set. Reading only the modern
614
+ // name left those daemons clearing their timers but never exiting: a live
615
+ // process still holding its dashboard port. Matches how `daemon.ts`
616
+ // identifies a daemon process.
617
+ const isDaemonProcess = readMofloEnv('DAEMON') === '1';
618
+ if (isDaemonProcess && process.env.MOFLO_TEST_NO_DAEMON_SELF_EXIT !== '1') {
619
+ process.exit(0);
620
+ }
621
+ return true;
622
+ }
560
623
  /**
561
624
  * Schedule a worker to run at intervals with staggered start
562
625
  */
@@ -574,6 +637,9 @@ export class WorkerDaemon extends EventEmitter {
574
637
  const runAndReschedule = async () => {
575
638
  if (!this.running)
576
639
  return;
640
+ // #1315 — before any writer runs, confirm we still have a project.
641
+ if (await this.shutdownIfRootVanished())
642
+ return;
577
643
  // Use concurrency-controlled execution (P0 fix)
578
644
  await this.executeWorkerWithConcurrencyControl(workerConfig);
579
645
  if (this.running && workerConfig.enabled && !state.disabledByOverrun) {
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.12.2';
5
+ export const VERSION = '4.12.3-rc.1';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.12.2",
3
+ "version": "4.12.3-rc.1",
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",
@@ -56,7 +56,7 @@
56
56
  "test:smoke": "node harness/consumer-smoke/run.mjs",
57
57
  "test:smoke:populated": "node harness/consumer-smoke/run-populated.mjs",
58
58
  "bench": "vitest run --config vitest.bench.config.ts",
59
- "lint": "eslint src/ bin/ .claude/scripts/ --ext .ts,.tsx,.mts,.cts,.js,.mjs,.cjs --max-warnings 0",
59
+ "lint": "eslint src/ bin/ .claude/scripts/ --max-warnings 0",
60
60
  "security:audit": "npm audit --omit=dev --audit-level high",
61
61
  "security:fix": "npm audit fix",
62
62
  "version": "node scripts/sync-version.mjs && git add src/cli/version.ts",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@anush008/tokenizers": "^0.6.0",
67
- "@modelcontextprotocol/sdk": "^1.0.0",
67
+ "@modelcontextprotocol/sdk": "^1.30.0",
68
68
  "js-yaml": "^5.2.0",
69
69
  "lru-cache": "^11.3.5",
70
70
  "onnxruntime-node": "^1.27.0",
@@ -92,10 +92,11 @@
92
92
  },
93
93
  "devDependencies": {
94
94
  "@types/node": "^24.12.2",
95
- "@typescript-eslint/eslint-plugin": "^7.18.0",
96
- "@typescript-eslint/parser": "^7.18.0",
97
- "eslint": "^8.0.0",
98
- "moflo": "^4.12.1",
95
+ "@typescript-eslint/eslint-plugin": "^8.65.0",
96
+ "@typescript-eslint/parser": "^8.65.0",
97
+ "eslint": "^10.8.0",
98
+ "glob": "^11.1.0",
99
+ "moflo": "^4.12.2",
99
100
  "tsx": "^4.21.0",
100
101
  "typescript": "^5.9.3",
101
102
  "vitest": "^4.0.0"