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.
- package/.claude/guidance/shipped/moflo-cli-reference.md +1 -1
- package/.claude/guidance/shipped/moflo-memory-strategy.md +1 -1
- package/.claude/guidance/shipped/moflo-yaml-reference.md +19 -5
- package/.claude/skills/memory-optimization/SKILL.md +1 -1
- package/.claude/skills/memory-patterns/SKILL.md +3 -3
- package/.claude/skills/vector-search/SKILL.md +2 -2
- package/README.md +5 -5
- package/bin/lib/daemon-port.mjs +66 -0
- package/bin/session-start-launcher.mjs +189 -15
- package/bin/setup-project.mjs +38 -58
- package/dist/src/cli/commands/daemon.js +31 -10
- package/dist/src/cli/commands/doctor-checks-config.js +139 -1
- package/dist/src/cli/commands/doctor-checks-deep.js +105 -0
- package/dist/src/cli/commands/doctor-fixes.js +99 -2
- package/dist/src/cli/commands/doctor-registry.js +15 -2
- package/dist/src/cli/commands/memory.js +8 -8
- package/dist/src/cli/commands/neural.js +8 -6
- package/dist/src/cli/config/moflo-config.js +79 -3
- package/dist/src/cli/index.js +18 -19
- package/dist/src/cli/init/claudemd-generator.js +6 -2
- package/dist/src/cli/init/moflo-init.js +13 -21
- package/dist/src/cli/init/moflo-yaml-template.js +1 -1
- package/dist/src/cli/mcp-server.js +59 -10
- package/dist/src/cli/mcp-tools/memory-tools.js +46 -27
- package/dist/src/cli/memory/auto-memory-bridge.js +1 -1
- package/dist/src/cli/memory/controllers/attestation-log.js +1 -1
- package/dist/src/cli/memory/controllers/causal-graph.js +1 -1
- package/dist/src/cli/memory/daemon-write-client.js +178 -49
- package/dist/src/cli/memory/database-provider.js +58 -3
- package/dist/src/cli/memory/intelligence.js +54 -26
- package/dist/src/cli/memory/memory-initializer.js +21 -11
- package/dist/src/cli/services/claudemd-injection.js +173 -0
- package/dist/src/cli/services/daemon-dashboard.js +94 -25
- package/dist/src/cli/services/daemon-lock.js +390 -3
- package/dist/src/cli/services/daemon-port.js +217 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- 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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
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
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
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
|
|
601
|
-
|
|
602
|
-
|
|
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' &&
|
|
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
|
-
//
|
|
617
|
-
throw new Error(`All dashboard ports ${
|
|
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.
|