moflo 4.10.6 → 4.10.8

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 (38) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +1 -1
  2. package/.claude/guidance/shipped/moflo-memory-strategy.md +1 -1
  3. package/.claude/guidance/shipped/moflo-yaml-reference.md +19 -5
  4. package/.claude/skills/memory-optimization/SKILL.md +1 -1
  5. package/.claude/skills/memory-patterns/SKILL.md +3 -3
  6. package/.claude/skills/vector-search/SKILL.md +2 -2
  7. package/README.md +5 -5
  8. package/bin/lib/daemon-port.mjs +66 -0
  9. package/bin/session-start-launcher.mjs +189 -15
  10. package/bin/setup-project.mjs +38 -58
  11. package/dist/src/cli/commands/daemon.js +31 -10
  12. package/dist/src/cli/commands/doctor-checks-config.js +139 -1
  13. package/dist/src/cli/commands/doctor-checks-deep.js +105 -0
  14. package/dist/src/cli/commands/doctor-fixes.js +99 -2
  15. package/dist/src/cli/commands/doctor-registry.js +15 -2
  16. package/dist/src/cli/commands/memory.js +8 -8
  17. package/dist/src/cli/commands/neural.js +8 -6
  18. package/dist/src/cli/config/moflo-config.js +79 -3
  19. package/dist/src/cli/index.js +18 -19
  20. package/dist/src/cli/init/claudemd-generator.js +6 -2
  21. package/dist/src/cli/init/moflo-init.js +13 -21
  22. package/dist/src/cli/init/moflo-yaml-template.js +1 -1
  23. package/dist/src/cli/mcp-server.js +59 -10
  24. package/dist/src/cli/mcp-tools/memory-tools.js +46 -27
  25. package/dist/src/cli/memory/auto-memory-bridge.js +1 -1
  26. package/dist/src/cli/memory/controllers/attestation-log.js +1 -1
  27. package/dist/src/cli/memory/controllers/causal-graph.js +1 -1
  28. package/dist/src/cli/memory/daemon-write-client.js +178 -49
  29. package/dist/src/cli/memory/database-provider.js +58 -3
  30. package/dist/src/cli/memory/intelligence.js +54 -26
  31. package/dist/src/cli/memory/memory-initializer.js +21 -11
  32. package/dist/src/cli/services/claudemd-injection.js +173 -0
  33. package/dist/src/cli/services/daemon-dashboard.js +94 -25
  34. package/dist/src/cli/services/daemon-lock.js +390 -3
  35. package/dist/src/cli/services/daemon-port.js +217 -0
  36. package/dist/src/cli/version.js +1 -1
  37. package/package.json +2 -2
  38. package/dist/src/cli/config-adapter.js +0 -182
@@ -0,0 +1,173 @@
1
+ /**
2
+ * CLAUDE.md injection drift detection + replacement (#1142).
3
+ *
4
+ * Detects when a consumer's `<root>/CLAUDE.md` carries a MoFlo-injected block
5
+ * whose content has drifted from what the current generator produces. Catches
6
+ * the case where a consumer upgrades moflo (so guidance files refresh) but the
7
+ * CLAUDE.md injection — only rewritten by explicit `flo init` / `flo-setup` —
8
+ * stays frozen at the prior version's content, sometimes pointing at paths
9
+ * that no longer exist (e.g. `.claude/guidance/shipped/...` before the
10
+ * flat-layout cleanup).
11
+ *
12
+ * IMPORTANT: This module must remain self-contained with ZERO imports from
13
+ * other moflo modules (mirrors the constraint on `services/hook-block-hash.ts`
14
+ * and `services/hook-wiring.ts`). It is dynamically imported at runtime by
15
+ * `bin/session-start-launcher.mjs` in consumer projects, where transitive
16
+ * dependencies may not resolve.
17
+ *
18
+ * The MoFlo block markers are duplicated from `init/claudemd-generator.ts` on
19
+ * purpose — the launcher cannot pull in TS dist of init/types.js at runtime,
20
+ * and a unit test asserts the two stay in sync.
21
+ */
22
+ // ────────────────────────────────────────────────────────────────────────────
23
+ // Marker constants — kept in sync with init/claudemd-generator.ts
24
+ // ────────────────────────────────────────────────────────────────────────────
25
+ export const MARKER_START = '<!-- MOFLO:INJECTED:START -->';
26
+ export const MARKER_END = '<!-- MOFLO:INJECTED:END -->';
27
+ // Legacy markers from earlier moflo versions — detected on drift checks so we
28
+ // can offer to replace the legacy block with the current marker pair.
29
+ export const LEGACY_MARKER_STARTS = [
30
+ '<!-- MOFLO:START -->',
31
+ '<!-- MOFLO:SUBAGENT-PROTOCOL:START -->',
32
+ ];
33
+ export const LEGACY_MARKER_ENDS = [
34
+ '<!-- MOFLO:END -->',
35
+ '<!-- MOFLO:SUBAGENT-PROTOCOL:END -->',
36
+ ];
37
+ // ────────────────────────────────────────────────────────────────────────────
38
+ // Block extraction
39
+ // ────────────────────────────────────────────────────────────────────────────
40
+ /**
41
+ * Locate the MoFlo-injected block in `claudeMdContents`, normalising line
42
+ * endings so a CRLF file matches an LF canonical block (Windows consumers
43
+ * regularly hit this — git autocrlf can flip the source bytes on checkout).
44
+ *
45
+ * Returns null when `contents` is null/undefined/empty, or when no marker
46
+ * pair is found. Includes the marker strings themselves in the extracted
47
+ * block, matching `MARKER_START…MARKER_END` exactly so a byte-for-byte
48
+ * compare against the canonical block works.
49
+ */
50
+ export function extractInjectedBlock(claudeMdContents) {
51
+ if (!claudeMdContents)
52
+ return null;
53
+ const normalised = claudeMdContents.replace(/\r\n/g, '\n');
54
+ // Try the current marker pair first, then each legacy pair. markerIndex:
55
+ // 0 → current MARKER_START/MARKER_END
56
+ // 1+ → LEGACY_MARKER_STARTS[markerIndex - 1] / LEGACY_MARKER_ENDS[markerIndex - 1]
57
+ const starts = [MARKER_START, ...LEGACY_MARKER_STARTS];
58
+ const ends = [MARKER_END, ...LEGACY_MARKER_ENDS];
59
+ for (let i = 0; i < starts.length; i++) {
60
+ const startIdx = normalised.indexOf(starts[i]);
61
+ if (startIdx < 0)
62
+ continue;
63
+ const endIdx = normalised.indexOf(ends[i], startIdx + starts[i].length);
64
+ if (endIdx <= startIdx)
65
+ continue;
66
+ const endInclusive = endIdx + ends[i].length;
67
+ return {
68
+ block: normalised.substring(startIdx, endInclusive),
69
+ start: startIdx,
70
+ end: endInclusive,
71
+ markerIndex: i,
72
+ };
73
+ }
74
+ return null;
75
+ }
76
+ // ────────────────────────────────────────────────────────────────────────────
77
+ // Drift detection
78
+ // ────────────────────────────────────────────────────────────────────────────
79
+ /**
80
+ * Trim `canonical` to the bytes between (and including) the current MoFlo
81
+ * markers. `generateClaudeMd()` appends a trailing newline that callers
82
+ * commonly include in the result; the in-file block does not carry that
83
+ * newline, so we strip trailing whitespace before comparing.
84
+ */
85
+ function canonicalBlock(canonical) {
86
+ return canonical.replace(/\r\n/g, '\n').trimEnd();
87
+ }
88
+ /**
89
+ * Classify a consumer's CLAUDE.md against the canonical injected block.
90
+ *
91
+ * `claudeMdContents` should be the result of `readFileSync(<root>/CLAUDE.md)`
92
+ * or null/undefined when the file is absent. `canonical` is the output of
93
+ * `generateClaudeMd({})` from `init/claudemd-generator.ts`.
94
+ */
95
+ export function computeInjectionDrift(claudeMdContents, canonical) {
96
+ if (claudeMdContents === null || claudeMdContents === undefined) {
97
+ return { state: 'no-file' };
98
+ }
99
+ const extracted = extractInjectedBlock(claudeMdContents);
100
+ if (!extracted) {
101
+ return { state: 'no-marker' };
102
+ }
103
+ if (extracted.markerIndex > 0) {
104
+ return { state: 'legacy-marker', legacyMarkerIndex: extracted.markerIndex - 1 };
105
+ }
106
+ const currentBlock = extracted.block;
107
+ const wantBlock = canonicalBlock(canonical);
108
+ if (currentBlock === wantBlock) {
109
+ return { state: 'in-sync' };
110
+ }
111
+ return { state: 'drifted' };
112
+ }
113
+ // ────────────────────────────────────────────────────────────────────────────
114
+ // Replacement
115
+ // ────────────────────────────────────────────────────────────────────────────
116
+ /**
117
+ * Apply the canonical block to `claudeMdContents`, returning the new
118
+ * contents and a `changed` flag indicating whether any bytes differ. The
119
+ * caller writes the file (or persists in-memory state) — this function does
120
+ * no I/O so it's safe to call from any execution context.
121
+ *
122
+ * Behavior by input state:
123
+ * - `no-file` → returns `{ contents: canonical, changed: true }` so the
124
+ * caller can write a fresh CLAUDE.md (e.g. `flo init` first-run).
125
+ * - `no-marker` → APPENDS the canonical block to the end of the existing
126
+ * contents (matches `bin/setup-project.mjs:updateClaudeMd` append path).
127
+ * - `legacy-marker` → REPLACES the legacy block in-place with the canonical block.
128
+ * - `in-sync` → no change.
129
+ * - `drifted` → REPLACES the existing block in-place with the canonical block.
130
+ */
131
+ export function applyInjectionReplacement(claudeMdContents, canonical) {
132
+ const want = canonicalBlock(canonical);
133
+ if (claudeMdContents === null || claudeMdContents === undefined) {
134
+ return { contents: `# Project Configuration\n\n${want}\n`, changed: true, state: 'in-sync' };
135
+ }
136
+ const extracted = extractInjectedBlock(claudeMdContents);
137
+ if (!extracted) {
138
+ // No marker — append the canonical block to the end (idempotent for
139
+ // future runs because the appended block will then be located on
140
+ // subsequent extractions).
141
+ const sep = claudeMdContents.endsWith('\n') ? '\n' : '\n\n';
142
+ const next = claudeMdContents + sep + want + '\n';
143
+ return { contents: next, changed: true, state: 'in-sync' };
144
+ }
145
+ // We located a marker pair (current or legacy). If content already matches
146
+ // the canonical block, nothing to do.
147
+ if (extracted.markerIndex === 0 && extracted.block === want) {
148
+ return { contents: claudeMdContents, changed: false, state: 'in-sync' };
149
+ }
150
+ // Operate on the line-ending-normalised view so the byte offsets we record
151
+ // line up with the actual replacement window. The output keeps LF endings
152
+ // — the launcher and setup-project both write LF.
153
+ const normalised = claudeMdContents.replace(/\r\n/g, '\n');
154
+ const next = normalised.substring(0, extracted.start) + want + normalised.substring(extracted.end);
155
+ return { contents: next, changed: true, state: 'in-sync' };
156
+ }
157
+ // ────────────────────────────────────────────────────────────────────────────
158
+ // Human-readable status for healer + launcher output
159
+ // ────────────────────────────────────────────────────────────────────────────
160
+ /**
161
+ * Short one-line summary describing a drift state. Used by `flo doctor` and
162
+ * the session-start launcher when reporting status to the user.
163
+ */
164
+ export function formatInjectionDriftStatus(report) {
165
+ switch (report.state) {
166
+ case 'no-file': return 'CLAUDE.md not found';
167
+ case 'no-marker': return 'CLAUDE.md has no moflo injection block';
168
+ case 'legacy-marker': return 'CLAUDE.md uses a legacy moflo marker pair';
169
+ case 'in-sync': return 'CLAUDE.md injection block matches reference';
170
+ case 'drifted': return 'CLAUDE.md injection block has drifted from reference';
171
+ }
172
+ }
173
+ //# sourceMappingURL=claudemd-injection.js.map
@@ -16,7 +16,18 @@ import { createServer } from 'node:http';
16
16
  import { errorDetail } from '../shared/utils/error-detail.js';
17
17
  import { handleMemoryStore, handleMemoryDelete, handleMemoryBatch, handleMemoryGet, handleMemorySearch, handleMemoryList, matchMemoryRpcRoute, } from './daemon-memory-rpc.js';
18
18
  import { aggregateClaudeStats, emptyClaudeStatsShape } from './claude-stats.js';
19
- export const DEFAULT_DASHBOARD_PORT = 3117;
19
+ import { serverPortCandidates, LEGACY_DEFAULT_PORT } from './daemon-port.js';
20
+ import { writeLockPort } from './daemon-lock.js';
21
+ import { findProjectRoot } from './project-root.js';
22
+ import { readOwnMofloVersion } from './daemon-lock.js';
23
+ /**
24
+ * Legacy default port retained as a re-export of {@link LEGACY_DEFAULT_PORT}
25
+ * for backward compat with existing importers (`commands/daemon.ts`,
26
+ * `__tests__/daemon-dashboard.test.ts`). The actual port a daemon binds is
27
+ * now resolved deterministically per project via `serverPortCandidates()` —
28
+ * see `daemon-port.ts` and `docs/internal/1145-daemon-port-collision-analysis.md`.
29
+ */
30
+ export const DEFAULT_DASHBOARD_PORT = LEGACY_DEFAULT_PORT;
20
31
  /**
21
32
  * Process-wide promise for the shared MemoryAccessor. Memoized as a *promise*
22
33
  * (not the resolved value) so concurrent first-callers share a single init
@@ -129,6 +140,27 @@ function tryParseSafe(s) {
129
140
  return s;
130
141
  }
131
142
  }
143
+ /**
144
+ * Build the `/api/health` response (#1145).
145
+ *
146
+ * Identity payload — clients compare `projectRoot` against their own
147
+ * `findProjectRoot()` and refuse to route to this daemon on mismatch.
148
+ * Also surfaces `pid`, `version`, and `uptimeMs` for healer-class
149
+ * diagnostics and orphan-daemon detection.
150
+ *
151
+ * Read-only, no-auth, localhost-only (the dashboard binds 127.0.0.1).
152
+ */
153
+ function handleHealth(daemon, opts) {
154
+ const status = daemon.getStatus();
155
+ const startedAt = status.startedAt instanceof Date ? status.startedAt : null;
156
+ return {
157
+ status: 'ok',
158
+ projectRoot: opts.projectRoot ?? findProjectRoot(),
159
+ pid: status.pid ?? process.pid,
160
+ version: readOwnMofloVersion() ?? null,
161
+ uptimeMs: startedAt ? Date.now() - startedAt.getTime() : 0,
162
+ };
163
+ }
132
164
  function handleStatus(daemon) {
133
165
  const status = daemon.getStatus();
134
166
  // Index config rows by worker type so the row renderer can show a
@@ -244,15 +276,18 @@ function tryParse(s) {
244
276
  }
245
277
  }
246
278
  async function handleMemoryStats() {
247
- // Single GROUP BY query — no hardcoded namespace list, no row fetching
248
- try {
249
- const { getNamespaceCounts } = await import('../memory/memory-initializer.js');
250
- const { namespaces, total } = await getNamespaceCounts();
251
- return { namespaces, totalEntries: total, available: total > 0 || Object.keys(namespaces).length > 0 };
252
- }
253
- catch {
254
- return { namespaces: {}, totalEntries: 0, available: false };
255
- }
279
+ // Single GROUP BY query — no hardcoded namespace list, no row fetching.
280
+ // Errors propagate to the request handler's outer try/catch → 500, so
281
+ // MCP clients see a real failure instead of a silent `totalEntries: 0`.
282
+ const { getNamespaceCounts } = await import('../memory/memory-initializer.js');
283
+ const { namespaces, total, withEmbeddings } = await getNamespaceCounts();
284
+ return {
285
+ ok: true,
286
+ namespaces,
287
+ totalEntries: total,
288
+ withEmbeddings,
289
+ available: total > 0 || Object.keys(namespaces).length > 0,
290
+ };
256
291
  }
257
292
  /**
258
293
  * Build the `/api/claude-stats` response (#1044).
@@ -433,6 +468,11 @@ async function handleRequest(req, res, daemon, opts) {
433
468
  if (url === '/') {
434
469
  sendHtml(res, DASHBOARD_HTML);
435
470
  }
471
+ else if (url === '/api/health') {
472
+ // #1145 — identity probe. Clients use this to confirm they're talking
473
+ // to the daemon for their OWN project before routing memory ops here.
474
+ sendJson(res, 200, handleHealth(daemon, opts));
475
+ }
436
476
  else if (url === '/api/status') {
437
477
  sendJson(res, 200, handleStatus(daemon));
438
478
  }
@@ -588,33 +628,62 @@ const MAX_PORT_ATTEMPTS = 10;
588
628
  /**
589
629
  * Start the dashboard HTTP server.
590
630
  *
591
- * Tries the requested port first, then falls back to port+1, port+2, ...
592
- * up to MAX_PORT_ATTEMPTS to avoid crashing the daemon when another
593
- * project's daemon already holds the default port.
631
+ * Port selection (#1145):
632
+ * 1. `opts.port`, if explicitly set (CLI `--dashboard-port` flag).
633
+ * 2. Otherwise `serverPortCandidates(projectRoot)` deterministic per-
634
+ * project port + collision-fallback range.
635
+ * Both honor `MOFLO_DAEMON_PORT` (collapses the candidate list to one).
636
+ *
637
+ * On successful bind the bound port is stamped into `.moflo/daemon.lock`
638
+ * via `writeLockPort()` so clients can discover it without guessing.
594
639
  *
595
- * @param daemon - WorkerDaemon instance for status data
596
- * @param opts - Dashboard configuration
597
- * @returns A handle to stop the server (port reflects the actual bound port)
640
+ * On bind exhaustion (every candidate in use) the server throws — the
641
+ * caller is expected to surface the failure rather than stay half-alive
642
+ * (the silent-trap pattern that produced #1145).
643
+ *
644
+ * @returns handle whose `.port` field reflects the actually bound port
598
645
  */
599
646
  export async function startDashboard(daemon, opts) {
600
- const basePort = opts.port;
601
- for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
602
- const port = basePort + attempt;
647
+ const projectRoot = opts.projectRoot ?? findProjectRoot();
648
+ const candidates = buildBindCandidates(opts.port, projectRoot, MAX_PORT_ATTEMPTS);
649
+ let lastErr = null;
650
+ for (let i = 0; i < candidates.length; i++) {
651
+ const port = candidates[i];
603
652
  try {
604
- const handle = await tryListenOnPort(daemon, opts, port);
653
+ const handle = await tryListenOnPort(daemon, { ...opts, projectRoot }, port);
654
+ // Stamp the bound port into the lock so clients discover us reliably.
655
+ // Best-effort: a missing/locked-by-another-pid lock means stamping
656
+ // is a no-op — the deterministic fallback still works.
657
+ try {
658
+ writeLockPort(projectRoot, handle.port);
659
+ }
660
+ catch { /* ignore */ }
605
661
  return handle;
606
662
  }
607
663
  catch (err) {
664
+ lastErr = err;
608
665
  const code = err && typeof err === 'object' && 'code' in err ? err.code : '';
609
- if (code === 'EADDRINUSE' && attempt < MAX_PORT_ATTEMPTS - 1) {
610
- // Port taken — try the next one
666
+ if (code === 'EADDRINUSE' && i < candidates.length - 1)
611
667
  continue;
612
- }
613
668
  throw err;
614
669
  }
615
670
  }
616
- // Should be unreachable, but satisfies the type checker
617
- throw new Error(`All dashboard ports ${basePort}–${basePort + MAX_PORT_ATTEMPTS - 1} are in use`);
671
+ // Bind exhaustion surface so the daemon can hard-fail (#1145 §9.4).
672
+ throw lastErr ?? new Error(`All dashboard ports (${candidates[0]}…${candidates[candidates.length - 1]}) are in use`);
673
+ }
674
+ /**
675
+ * Build the ordered list of ports to try.
676
+ *
677
+ * When the caller pinned a port (CLI flag), respect it without any
678
+ * fallback — the consumer pinned it on purpose. When they didn't, use
679
+ * the deterministic per-project candidates so two projects never collide
680
+ * silently on a fixed default.
681
+ */
682
+ function buildBindCandidates(explicitPort, projectRoot, maxAttempts) {
683
+ if (typeof explicitPort === 'number' && explicitPort > 0 && explicitPort < 65536) {
684
+ return [explicitPort];
685
+ }
686
+ return serverPortCandidates(projectRoot, maxAttempts);
618
687
  }
619
688
  /**
620
689
  * Attempt to bind the dashboard server to a specific port.