moflo 4.10.6 → 4.10.7
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-yaml-reference.md +15 -1
- package/bin/session-start-launcher.mjs +189 -15
- package/bin/setup-project.mjs +38 -58
- package/dist/src/cli/commands/doctor-checks-deep.js +105 -0
- package/dist/src/cli/commands/doctor-fixes.js +24 -0
- package/dist/src/cli/commands/doctor-registry.js +5 -1
- package/dist/src/cli/config/moflo-config.js +11 -0
- package/dist/src/cli/init/claudemd-generator.js +6 -2
- package/dist/src/cli/init/moflo-init.js +13 -21
- package/dist/src/cli/services/claudemd-injection.js +173 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -101,9 +101,17 @@ status_line:
|
|
|
101
101
|
sandbox:
|
|
102
102
|
enabled: false # Set to true to wrap bash steps in an OS sandbox
|
|
103
103
|
tier: auto # auto | denylist-only | full
|
|
104
|
+
|
|
105
|
+
# Auto-update on session start (refreshes consumer assets when moflo upgrades)
|
|
106
|
+
auto_update:
|
|
107
|
+
enabled: true # Master toggle for version-change auto-sync
|
|
108
|
+
scripts: true # Sync .claude/scripts/ from moflo bin/
|
|
109
|
+
helpers: true # Sync .claude/helpers/ from moflo source
|
|
110
|
+
hook_block_drift: warn # warn | regenerate | off
|
|
111
|
+
claudemd_injection_drift: regenerate # warn | regenerate | off
|
|
104
112
|
```
|
|
105
113
|
|
|
106
|
-
If your `moflo.yaml` predates the `sandbox:`
|
|
114
|
+
If your `moflo.yaml` predates the `sandbox:` or `auto_update:` blocks, they are auto-appended on the next session start — you never need to re-run `moflo init` after a version bump.
|
|
107
115
|
|
|
108
116
|
### Key Behaviors
|
|
109
117
|
|
|
@@ -128,6 +136,12 @@ If your `moflo.yaml` predates the `sandbox:` block, it is auto-appended on the n
|
|
|
128
136
|
| `sandbox.enabled: true` | Wrap bash steps in an OS sandbox (macOS/Linux/WSL) — absolute disable when `false`, regardless of tier |
|
|
129
137
|
| `sandbox.tier: full` | Require OS sandbox; throw at runtime if the platform tool is unavailable |
|
|
130
138
|
| `sandbox.tier: denylist-only` | Keep Layer 1 denylist only; skip OS isolation even when enabled |
|
|
139
|
+
| `auto_update.enabled: false` | Disable all on-session auto-sync (scripts, helpers, drift checks) |
|
|
140
|
+
| `auto_update.hook_block_drift: regenerate` | Auto-repair drift in `.claude/settings.json` hook block on session start (#881) |
|
|
141
|
+
| `auto_update.hook_block_drift: off` | Skip hook-block drift detection entirely |
|
|
142
|
+
| `auto_update.claudemd_injection_drift: regenerate` | Auto-refresh the MoFlo block in `CLAUDE.md` when it drifts from the current generator (#1142, default) |
|
|
143
|
+
| `auto_update.claudemd_injection_drift: warn` | Print a drift notice on session start but leave `CLAUDE.md` unchanged |
|
|
144
|
+
| `auto_update.claudemd_injection_drift: off` | Skip CLAUDE.md injection drift detection entirely |
|
|
131
145
|
|
|
132
146
|
---
|
|
133
147
|
|
|
@@ -640,7 +640,16 @@ try {
|
|
|
640
640
|
// Controlled by `auto_update.enabled` in moflo.yaml (default: true).
|
|
641
641
|
// When moflo is upgraded (npm install), scripts and helpers may be stale.
|
|
642
642
|
// Detect version change and sync from source before running hooks.
|
|
643
|
-
let autoUpdateConfig = {
|
|
643
|
+
let autoUpdateConfig = {
|
|
644
|
+
enabled: true,
|
|
645
|
+
scripts: true,
|
|
646
|
+
helpers: true,
|
|
647
|
+
hookBlockDrift: 'warn',
|
|
648
|
+
// #1142 — CLAUDE.md injection drift refresh mode (warn | regenerate | off,
|
|
649
|
+
// default regenerate). Defaults to regenerate because the consumer cannot
|
|
650
|
+
// refresh CLAUDE.md on their own — there is no other auto-refresh path.
|
|
651
|
+
claudemdInjectionDrift: 'regenerate',
|
|
652
|
+
};
|
|
644
653
|
try {
|
|
645
654
|
const mofloYaml = resolve(projectRoot, 'moflo.yaml');
|
|
646
655
|
if (existsSync(mofloYaml)) {
|
|
@@ -651,10 +660,13 @@ try {
|
|
|
651
660
|
const helpersMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+helpers:\s*(true|false)/);
|
|
652
661
|
// #881: hook-block drift detector (warn | regenerate | off; default warn)
|
|
653
662
|
const driftMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+hook_block_drift:\s*(warn|regenerate|off)/);
|
|
663
|
+
// #1142: CLAUDE.md injection drift detector (warn | regenerate | off; default regenerate)
|
|
664
|
+
const claudemdMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+claudemd_injection_drift:\s*(warn|regenerate|off)/);
|
|
654
665
|
if (enabledMatch) autoUpdateConfig.enabled = enabledMatch[1] === 'true';
|
|
655
666
|
if (scriptsMatch) autoUpdateConfig.scripts = scriptsMatch[1] === 'true';
|
|
656
667
|
if (helpersMatch) autoUpdateConfig.helpers = helpersMatch[1] === 'true';
|
|
657
668
|
if (driftMatch) autoUpdateConfig.hookBlockDrift = driftMatch[1];
|
|
669
|
+
if (claudemdMatch) autoUpdateConfig.claudemdInjectionDrift = claudemdMatch[1];
|
|
658
670
|
}
|
|
659
671
|
} catch (err) {
|
|
660
672
|
// Defaults (all true) keep the upgrade flow alive but the user should
|
|
@@ -1414,23 +1426,185 @@ async function runHookBlockDriftCheck() {
|
|
|
1414
1426
|
};
|
|
1415
1427
|
}
|
|
1416
1428
|
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1429
|
+
// ── 3a-vii. CLAUDE.md injection drift detection (#1142) ──────────────────
|
|
1430
|
+
// Refresh the consumer's `<root>/CLAUDE.md` MoFlo block when it has drifted
|
|
1431
|
+
// from what `claudemd-generator.ts` currently produces. The launcher's
|
|
1432
|
+
// stages 3/3b refresh shipped guidance files on every version change, but
|
|
1433
|
+
// CLAUDE.md was only rewritten by explicit `flo init` / `flo-setup` — so
|
|
1434
|
+
// consumers carried stale injection content (with the legacy `shipped/`
|
|
1435
|
+
// guidance paths, for example) for as long as they didn't re-run init.
|
|
1436
|
+
//
|
|
1437
|
+
// Modes (`auto_update.claudemd_injection_drift` in moflo.yaml):
|
|
1438
|
+
// regenerate — replace the drifted block in place (default; the consumer
|
|
1439
|
+
// has no other path to refresh CLAUDE.md)
|
|
1440
|
+
// warn — print a one-line drift summary to stdout
|
|
1441
|
+
// off — skip detection entirely
|
|
1442
|
+
//
|
|
1443
|
+
// Fast-path: `.moflo/claudemd-injection-cache.json` records the last clean
|
|
1444
|
+
// run. If CLAUDE.md + the generator module both still match the cached
|
|
1445
|
+
// mtimes, skip the readFile + dynamic import.
|
|
1446
|
+
async function runClaudeMdInjectionDriftCheck() {
|
|
1447
|
+
const claudeMdPath = resolve(projectRoot, 'CLAUDE.md');
|
|
1448
|
+
let claudeMdStat;
|
|
1449
|
+
try { claudeMdStat = statSync(claudeMdPath); } catch { return null; }
|
|
1450
|
+
|
|
1451
|
+
// Locate the generator and the drift service. Both must be present —
|
|
1452
|
+
// generator owns the canonical content, drift service owns the marker
|
|
1453
|
+
// logic. Use bin/lib/moflo-resolve.mjs path resolution semantics
|
|
1454
|
+
// (covers consumer node_modules + dev source tree). Two candidates each.
|
|
1455
|
+
const generatorCandidates = [
|
|
1456
|
+
resolve(projectRoot, 'node_modules/moflo/dist/src/cli/init/claudemd-generator.js'),
|
|
1457
|
+
resolve(projectRoot, 'dist/src/cli/init/claudemd-generator.js'),
|
|
1458
|
+
];
|
|
1459
|
+
const driftCandidates = [
|
|
1460
|
+
resolve(projectRoot, 'node_modules/moflo/dist/src/cli/services/claudemd-injection.js'),
|
|
1461
|
+
resolve(projectRoot, 'dist/src/cli/services/claudemd-injection.js'),
|
|
1462
|
+
];
|
|
1463
|
+
let generatorPath = null, generatorStat = null;
|
|
1464
|
+
for (const p of generatorCandidates) {
|
|
1465
|
+
try { generatorStat = statSync(p); generatorPath = p; break; } catch { /* try next */ }
|
|
1466
|
+
}
|
|
1467
|
+
let driftPath = null, driftStat = null;
|
|
1468
|
+
for (const p of driftCandidates) {
|
|
1469
|
+
try { driftStat = statSync(p); driftPath = p; break; } catch { /* try next */ }
|
|
1470
|
+
}
|
|
1471
|
+
if (!generatorPath || !driftPath) return null;
|
|
1472
|
+
|
|
1473
|
+
// Use the max mtime of the two modules so any update to either invalidates
|
|
1474
|
+
// the cache. Both are co-bumped on publish so this is normally one mtime.
|
|
1475
|
+
const moduleMtimeMs = Math.max(generatorStat.mtimeMs, driftStat.mtimeMs);
|
|
1476
|
+
|
|
1477
|
+
// Cache short-circuits any (claudeMdMtime, moduleMtime, state) triple
|
|
1478
|
+
// match — not just 'in-sync'. A drifted consumer in warn mode still emits
|
|
1479
|
+
// the nudge once on first detection, then stays silent until something
|
|
1480
|
+
// actually changes (CLAUDE.md mtime bumps from a user edit, or moflo
|
|
1481
|
+
// upgrade bumps moduleMtimeMs). Without this, every session re-does the
|
|
1482
|
+
// full slow path (3 statSync + readFile + 2 dynamic imports + generator
|
|
1483
|
+
// call) for non-in-sync consumers in perpetuity.
|
|
1484
|
+
const cachePath = join(mofloDir(projectRoot), 'claudemd-injection-cache.json');
|
|
1485
|
+
let cached = null;
|
|
1486
|
+
try { cached = JSON.parse(readFileSync(cachePath, 'utf-8')); } catch { /* missing or corrupt */ }
|
|
1487
|
+
if (
|
|
1488
|
+
cached &&
|
|
1489
|
+
cached.claudeMdMtimeMs === claudeMdStat.mtimeMs &&
|
|
1490
|
+
cached.moduleMtimeMs === moduleMtimeMs &&
|
|
1491
|
+
typeof cached.state === 'string'
|
|
1492
|
+
) return null;
|
|
1493
|
+
|
|
1494
|
+
// Try-catch around the dynamic imports handles the file disappearing
|
|
1495
|
+
// between statSync and import (TOCTOU); other load errors surface as
|
|
1496
|
+
// an emitWarning so a transitive dependency failure isn't invisible
|
|
1497
|
+
// (mirrors the silent-catch lesson — see feedback_consumer_blast_radius).
|
|
1498
|
+
let genMod = null, driftMod = null;
|
|
1499
|
+
try {
|
|
1500
|
+
genMod = await import(pathToFileURL(generatorPath).href);
|
|
1501
|
+
driftMod = await import(pathToFileURL(driftPath).href);
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
emitWarning(`CLAUDE.md drift check skipped (${errMessage(err)})`);
|
|
1504
|
+
return null;
|
|
1505
|
+
}
|
|
1506
|
+
if (typeof genMod.generateClaudeMd !== 'function') return null;
|
|
1507
|
+
if (typeof driftMod.computeInjectionDrift !== 'function') return null;
|
|
1508
|
+
if (typeof driftMod.applyInjectionReplacement !== 'function') return null;
|
|
1509
|
+
|
|
1510
|
+
const claudeMdContents = readFileSync(claudeMdPath, 'utf-8');
|
|
1511
|
+
const canonical = genMod.generateClaudeMd({});
|
|
1512
|
+
const report = driftMod.computeInjectionDrift(claudeMdContents, canonical);
|
|
1513
|
+
|
|
1514
|
+
let finalState = report.state;
|
|
1515
|
+
let finalMtime = claudeMdStat.mtimeMs;
|
|
1516
|
+
|
|
1517
|
+
// Treat both 'drifted' and 'legacy-marker' as repairable in regenerate mode.
|
|
1518
|
+
// 'no-marker' means the user removed the inject deliberately — don't re-add
|
|
1519
|
+
// it on every session start; that's a re-init operation, not a drift fix.
|
|
1520
|
+
// 'no-file' is unreachable here because statSync already succeeded.
|
|
1521
|
+
const repairable = report.state === 'drifted' || report.state === 'legacy-marker';
|
|
1522
|
+
if (repairable) {
|
|
1523
|
+
const wantRegenerate = autoUpdateConfig.claudemdInjectionDrift === 'regenerate';
|
|
1524
|
+
if (wantRegenerate) {
|
|
1525
|
+
const result = driftMod.applyInjectionReplacement(claudeMdContents, canonical);
|
|
1526
|
+
if (result.changed && typeof result.contents === 'string') {
|
|
1527
|
+
writeFileSync(claudeMdPath, result.contents);
|
|
1528
|
+
finalState = 'in-sync';
|
|
1529
|
+
try { finalMtime = statSync(claudeMdPath).mtimeMs; } catch { /* keep prior */ }
|
|
1530
|
+
emitMutation(
|
|
1531
|
+
'refreshed CLAUDE.md MoFlo block',
|
|
1532
|
+
`replaced ${report.state} block with current generator output`,
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
} else {
|
|
1536
|
+
// warn mode — surface a one-line summary on stdout for Claude/user.
|
|
1421
1537
|
try {
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1538
|
+
process.stdout.write(
|
|
1539
|
+
`moflo: CLAUDE.md injection ${report.state}; run \`flo doctor claudemd-drift\` or set auto_update.claudemd_injection_drift: regenerate in moflo.yaml\n`,
|
|
1540
|
+
);
|
|
1541
|
+
} catch { /* broken stdout — non-fatal */ }
|
|
1542
|
+
}
|
|
1543
|
+
} else if (report.state === 'no-marker') {
|
|
1544
|
+
// Distinct from the drift cases — surface once via warn channel so a
|
|
1545
|
+
// user who didn't run init still sees a nudge, but never auto-mutate.
|
|
1546
|
+
if (autoUpdateConfig.claudemdInjectionDrift !== 'off') {
|
|
1547
|
+
try {
|
|
1548
|
+
process.stdout.write(
|
|
1549
|
+
`moflo: CLAUDE.md has no MoFlo injection block; run \`npx flo-setup\` to add one\n`,
|
|
1550
|
+
);
|
|
1551
|
+
} catch { /* broken stdout — non-fatal */ }
|
|
1430
1552
|
}
|
|
1431
1553
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1554
|
+
|
|
1555
|
+
return {
|
|
1556
|
+
cachePath,
|
|
1557
|
+
claudeMdMtimeMs: finalMtime,
|
|
1558
|
+
moduleMtimeMs,
|
|
1559
|
+
state: finalState,
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Run the two drift detectors (settings.json hook block + CLAUDE.md
|
|
1564
|
+
// injection) in parallel. Both do their own statSync → cache compare →
|
|
1565
|
+
// dynamic import dance; the work is independent and the file targets are
|
|
1566
|
+
// different, so Promise.all halves cold-path latency on every session start.
|
|
1567
|
+
// Cache-hit fast paths return null with no work — Promise.all is still
|
|
1568
|
+
// trivially correct there.
|
|
1569
|
+
{
|
|
1570
|
+
const hookEnabled = autoUpdateConfig.enabled && autoUpdateConfig.hookBlockDrift !== 'off';
|
|
1571
|
+
const claudemdEnabled = autoUpdateConfig.enabled && autoUpdateConfig.claudemdInjectionDrift !== 'off';
|
|
1572
|
+
const [hookResult, claudemdResult] = await Promise.all([
|
|
1573
|
+
hookEnabled
|
|
1574
|
+
? runHookBlockDriftCheck().catch((err) => {
|
|
1575
|
+
emitWarning(`hook-block drift check skipped (${errMessage(err)})`);
|
|
1576
|
+
return null;
|
|
1577
|
+
})
|
|
1578
|
+
: Promise.resolve(null),
|
|
1579
|
+
claudemdEnabled
|
|
1580
|
+
? runClaudeMdInjectionDriftCheck().catch((err) => {
|
|
1581
|
+
emitWarning(`CLAUDE.md injection drift check skipped (${errMessage(err)})`);
|
|
1582
|
+
return null;
|
|
1583
|
+
})
|
|
1584
|
+
: Promise.resolve(null),
|
|
1585
|
+
]);
|
|
1586
|
+
|
|
1587
|
+
if (hookResult) {
|
|
1588
|
+
try {
|
|
1589
|
+
mkdirSync(mofloDir(projectRoot), { recursive: true });
|
|
1590
|
+
writeFileSync(hookResult.cachePath, JSON.stringify({
|
|
1591
|
+
settingsMtimeMs: hookResult.settingsMtimeMs,
|
|
1592
|
+
moduleMtimeMs: hookResult.moduleMtimeMs,
|
|
1593
|
+
consumerHash: hookResult.consumerHash,
|
|
1594
|
+
referenceHash: hookResult.referenceHash,
|
|
1595
|
+
}));
|
|
1596
|
+
} catch { /* cache is opportunistic — non-fatal */ }
|
|
1597
|
+
}
|
|
1598
|
+
if (claudemdResult) {
|
|
1599
|
+
try {
|
|
1600
|
+
mkdirSync(mofloDir(projectRoot), { recursive: true });
|
|
1601
|
+
writeFileSync(claudemdResult.cachePath, JSON.stringify({
|
|
1602
|
+
claudeMdMtimeMs: claudemdResult.claudeMdMtimeMs,
|
|
1603
|
+
moduleMtimeMs: claudemdResult.moduleMtimeMs,
|
|
1604
|
+
state: claudemdResult.state,
|
|
1605
|
+
}));
|
|
1606
|
+
} catch { /* cache is opportunistic — non-fatal */ }
|
|
1607
|
+
}
|
|
1434
1608
|
}
|
|
1435
1609
|
|
|
1436
1610
|
// ── 3b. Ensure shipped guidance files exist (even without version change) ──
|
package/bin/setup-project.mjs
CHANGED
|
@@ -37,16 +37,14 @@ import { mofloInternalURL } from './lib/moflo-resolve.mjs';
|
|
|
37
37
|
// works identically from bin/ (canonical) or from .claude/scripts/ (synced copy).
|
|
38
38
|
const mofloRoot = dirname(fileURLToPath(mofloInternalURL('package.json')));
|
|
39
39
|
|
|
40
|
-
// Single source of truth: claudemd-generator.ts owns the section content
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
LEGACY_MARKER_ENDS,
|
|
49
|
-
} = await import(mofloInternalURL('dist/src/cli/init/claudemd-generator.js'));
|
|
40
|
+
// Single source of truth: claudemd-generator.ts owns the section content,
|
|
41
|
+
// claudemd-injection.ts owns the marker-replace logic. Use the shared
|
|
42
|
+
// mofloInternalURL helper so the script works identically when invoked
|
|
43
|
+
// from bin/ (canonical) or from .claude/scripts/ (synced copy).
|
|
44
|
+
const { generateClaudeMd } = await import(mofloInternalURL('dist/src/cli/init/claudemd-generator.js'));
|
|
45
|
+
const { applyInjectionReplacement, computeInjectionDrift } = await import(
|
|
46
|
+
mofloInternalURL('dist/src/cli/services/claudemd-injection.js')
|
|
47
|
+
);
|
|
50
48
|
|
|
51
49
|
const args = process.argv.slice(2);
|
|
52
50
|
const updateOnly = args.includes('--update');
|
|
@@ -150,65 +148,47 @@ function cleanupLegacyBootstrap(projectRoot) {
|
|
|
150
148
|
|
|
151
149
|
function updateClaudeMd(projectRoot) {
|
|
152
150
|
const claudeMdPath = join(projectRoot, 'CLAUDE.md');
|
|
151
|
+
const existed = existsSync(claudeMdPath);
|
|
152
|
+
const content = existed ? readFileSync(claudeMdPath, 'utf-8') : null;
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
log('📝 Creating CLAUDE.md with subagent protocol section');
|
|
160
|
-
writeFileSync(claudeMdPath, `# Project Configuration\n\n${CLAUDE_MD_SECTION}\n`, 'utf-8');
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
154
|
+
// Single source of truth for the marker-replace logic lives in
|
|
155
|
+
// src/cli/services/claudemd-injection.ts. Classify state for logging,
|
|
156
|
+
// then apply (or report) the replacement.
|
|
157
|
+
const report = computeInjectionDrift(content, CLAUDE_MD_SECTION);
|
|
163
158
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const allStarts = [MARKER_START, ...LEGACY_MARKER_STARTS];
|
|
168
|
-
const allEnds = [MARKER_END, ...LEGACY_MARKER_ENDS];
|
|
169
|
-
|
|
170
|
-
for (let i = 0; i < allStarts.length; i++) {
|
|
171
|
-
if (content.includes(allStarts[i])) {
|
|
172
|
-
const startIdx = content.indexOf(allStarts[i]);
|
|
173
|
-
const endIdx = content.indexOf(allEnds[i]);
|
|
174
|
-
|
|
175
|
-
if (endIdx > startIdx) {
|
|
176
|
-
// If current markers and content matches, we're up to date
|
|
177
|
-
if (i === 0) {
|
|
178
|
-
const existingSection = content.substring(startIdx, endIdx + allEnds[i].length);
|
|
179
|
-
if (existingSection === CLAUDE_MD_SECTION) {
|
|
180
|
-
log('✅ CLAUDE.md moflo section is current');
|
|
181
|
-
return true;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Replace (current or legacy) with new section
|
|
186
|
-
if (!checkOnly) {
|
|
187
|
-
const updated = content.substring(0, startIdx) + CLAUDE_MD_SECTION + content.substring(endIdx + allEnds[i].length);
|
|
188
|
-
writeFileSync(claudeMdPath, updated, 'utf-8');
|
|
189
|
-
log(i === 0 ? '📝 Updated CLAUDE.md moflo section' : '📝 Replaced legacy CLAUDE.md section with minimal moflo injection');
|
|
190
|
-
} else {
|
|
191
|
-
log('⚠️ CLAUDE.md moflo section needs update');
|
|
192
|
-
}
|
|
193
|
-
return true;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
159
|
+
if (report.state === 'in-sync') {
|
|
160
|
+
log('✅ CLAUDE.md moflo section is current');
|
|
161
|
+
return true;
|
|
196
162
|
}
|
|
197
163
|
|
|
164
|
+
// `updateOnly` is informational — refresh the bootstrap mirror file but
|
|
165
|
+
// leave CLAUDE.md alone unless an inject already exists.
|
|
198
166
|
if (updateOnly) {
|
|
199
|
-
log('⚠️ CLAUDE.md
|
|
200
|
-
|
|
167
|
+
if (!existed) { log('⚠️ No CLAUDE.md found'); return false; }
|
|
168
|
+
if (report.state === 'no-marker') {
|
|
169
|
+
log('⚠️ CLAUDE.md has no moflo section (run without --update to add)');
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
// Existing block (current or legacy) + drift → fall through to write.
|
|
201
173
|
}
|
|
202
174
|
|
|
203
175
|
if (checkOnly) {
|
|
204
|
-
log('⚠️ CLAUDE.md
|
|
176
|
+
if (!existed) { log('⚠️ No CLAUDE.md found'); return false; }
|
|
177
|
+
if (report.state === 'no-marker') { log('⚠️ CLAUDE.md missing subagent protocol section'); return false; }
|
|
178
|
+
log('⚠️ CLAUDE.md moflo section needs update');
|
|
205
179
|
return false;
|
|
206
180
|
}
|
|
207
181
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
writeFileSync(claudeMdPath,
|
|
211
|
-
|
|
182
|
+
const result = applyInjectionReplacement(content, CLAUDE_MD_SECTION);
|
|
183
|
+
if (!result.changed) return true;
|
|
184
|
+
writeFileSync(claudeMdPath, result.contents, 'utf-8');
|
|
185
|
+
|
|
186
|
+
switch (report.state) {
|
|
187
|
+
case 'no-file': log('📝 Creating CLAUDE.md with subagent protocol section'); break;
|
|
188
|
+
case 'no-marker': log('📝 Added subagent protocol section to CLAUDE.md'); break;
|
|
189
|
+
case 'legacy-marker': log('📝 Replaced legacy CLAUDE.md section with minimal moflo injection'); break;
|
|
190
|
+
case 'drifted': log('📝 Updated CLAUDE.md moflo section'); break;
|
|
191
|
+
}
|
|
212
192
|
return true;
|
|
213
193
|
}
|
|
214
194
|
|
|
@@ -670,4 +670,109 @@ export async function checkHookBlockDrift() {
|
|
|
670
670
|
fix: 'set auto_update.hook_block_drift: regenerate in moflo.yaml, or claudeFlow.hooks.locked: true to suppress',
|
|
671
671
|
};
|
|
672
672
|
}
|
|
673
|
+
// ============================================================================
|
|
674
|
+
// 12. CLAUDE.md Injection Drift Check
|
|
675
|
+
// ============================================================================
|
|
676
|
+
/**
|
|
677
|
+
* Detect when the consumer's `<root>/CLAUDE.md` MoFlo-injected block has
|
|
678
|
+
* drifted from the canonical block the current `claudemd-generator` produces.
|
|
679
|
+
* Analogue of `Hook Block Drift` for CLAUDE.md content.
|
|
680
|
+
*
|
|
681
|
+
* The session-start launcher refreshes shipped guidance files on every
|
|
682
|
+
* version change, but the CLAUDE.md injection is only rewritten by explicit
|
|
683
|
+
* `flo init` / `flo-setup`. Without this check, consumers carry stale
|
|
684
|
+
* injection content (and stale guidance pointers) indefinitely.
|
|
685
|
+
*
|
|
686
|
+
* Five states map to four reportable statuses:
|
|
687
|
+
* no-file → warn (run `flo init`)
|
|
688
|
+
* no-marker → warn (run `flo init` / `flo-setup`)
|
|
689
|
+
* legacy-marker → warn (auto-fixable — replace legacy block)
|
|
690
|
+
* in-sync → pass
|
|
691
|
+
* drifted → warn (auto-fixable — refresh block)
|
|
692
|
+
*/
|
|
693
|
+
export async function checkClaudeMdInjectionDrift() {
|
|
694
|
+
const projectDir = findConsumerProjectDir();
|
|
695
|
+
const claudeMdPath = join(projectDir, 'CLAUDE.md');
|
|
696
|
+
// Respect `auto_update.claudemd_injection_drift: off` for consumers who
|
|
697
|
+
// explicitly opt out (mirrors the launcher's behaviour and the Hook Block
|
|
698
|
+
// Drift check). Read the config first so the off-mode skip is cheap.
|
|
699
|
+
try {
|
|
700
|
+
const { loadMofloConfig } = await import('../config/moflo-config.js');
|
|
701
|
+
const cfg = loadMofloConfig(projectDir);
|
|
702
|
+
if (cfg.auto_update.claudemd_injection_drift === 'off') {
|
|
703
|
+
return {
|
|
704
|
+
name: 'CLAUDE.md Injection Drift',
|
|
705
|
+
status: 'pass',
|
|
706
|
+
message: 'drift check skipped — auto_update.claudemd_injection_drift: off',
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
catch { /* config read failure — fall through to drift check */ }
|
|
711
|
+
if (!existsSync(claudeMdPath)) {
|
|
712
|
+
return {
|
|
713
|
+
name: 'CLAUDE.md Injection Drift',
|
|
714
|
+
status: 'warn',
|
|
715
|
+
message: 'CLAUDE.md not found',
|
|
716
|
+
fix: 'npx moflo init',
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
let contents;
|
|
720
|
+
try {
|
|
721
|
+
contents = readFileSync(claudeMdPath, 'utf-8');
|
|
722
|
+
}
|
|
723
|
+
catch (e) {
|
|
724
|
+
return {
|
|
725
|
+
name: 'CLAUDE.md Injection Drift',
|
|
726
|
+
status: 'warn',
|
|
727
|
+
message: `cannot read CLAUDE.md: ${errorDetail(e)}`,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
// Dynamic-import the generator + drift detector so the dist-vs-source
|
|
731
|
+
// path resolution stays consistent with the launcher.
|
|
732
|
+
const { generateClaudeMd } = await import('../init/claudemd-generator.js');
|
|
733
|
+
const { computeInjectionDrift } = await import('../services/claudemd-injection.js');
|
|
734
|
+
// Use `{}` (not DEFAULT_INIT_OPTIONS) to match the launcher's call —
|
|
735
|
+
// the generator ignores the argument, but matching call shape removes the
|
|
736
|
+
// possibility of a future generator change diverging the two surfaces.
|
|
737
|
+
const canonical = generateClaudeMd({});
|
|
738
|
+
const report = computeInjectionDrift(contents, canonical);
|
|
739
|
+
switch (report.state) {
|
|
740
|
+
case 'in-sync':
|
|
741
|
+
return {
|
|
742
|
+
name: 'CLAUDE.md Injection Drift',
|
|
743
|
+
status: 'pass',
|
|
744
|
+
message: 'CLAUDE.md injection block matches reference',
|
|
745
|
+
};
|
|
746
|
+
case 'no-marker':
|
|
747
|
+
return {
|
|
748
|
+
name: 'CLAUDE.md Injection Drift',
|
|
749
|
+
status: 'warn',
|
|
750
|
+
message: 'CLAUDE.md has no MOFLO:INJECTED:START block',
|
|
751
|
+
fix: 'npx flo-setup',
|
|
752
|
+
};
|
|
753
|
+
case 'legacy-marker':
|
|
754
|
+
return {
|
|
755
|
+
name: 'CLAUDE.md Injection Drift',
|
|
756
|
+
status: 'warn',
|
|
757
|
+
message: 'CLAUDE.md uses a legacy moflo marker pair (pre-MOFLO:INJECTED) — auto-fix replaces with current block',
|
|
758
|
+
fix: 'npx flo-setup --update',
|
|
759
|
+
};
|
|
760
|
+
case 'drifted':
|
|
761
|
+
return {
|
|
762
|
+
name: 'CLAUDE.md Injection Drift',
|
|
763
|
+
status: 'warn',
|
|
764
|
+
message: 'CLAUDE.md injection block has drifted from reference',
|
|
765
|
+
fix: 'npx flo-setup --update',
|
|
766
|
+
};
|
|
767
|
+
case 'no-file':
|
|
768
|
+
// Defensive — `existsSync` returned true above, so this branch is
|
|
769
|
+
// unreachable in practice. Return a sane status anyway.
|
|
770
|
+
return {
|
|
771
|
+
name: 'CLAUDE.md Injection Drift',
|
|
772
|
+
status: 'warn',
|
|
773
|
+
message: 'CLAUDE.md not found',
|
|
774
|
+
fix: 'npx moflo init',
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
}
|
|
673
778
|
//# sourceMappingURL=doctor-checks-deep.js.map
|
|
@@ -255,6 +255,30 @@ export async function autoFixCheck(check) {
|
|
|
255
255
|
'Gate Health': async () => {
|
|
256
256
|
return fixGateHealthHooks();
|
|
257
257
|
},
|
|
258
|
+
// Refresh the consumer's CLAUDE.md MoFlo block in place using the
|
|
259
|
+
// shared `applyInjectionReplacement` service. Idempotent: a re-run sees
|
|
260
|
+
// `state === 'in-sync'` and the autoFix dispatcher skips this entry.
|
|
261
|
+
'CLAUDE.md Injection Drift': async () => {
|
|
262
|
+
const projectRoot = findProjectRoot();
|
|
263
|
+
const claudeMdPath = join(projectRoot, 'CLAUDE.md');
|
|
264
|
+
try {
|
|
265
|
+
const { generateClaudeMd } = await import('../init/claudemd-generator.js');
|
|
266
|
+
const { applyInjectionReplacement } = await import('../services/claudemd-injection.js');
|
|
267
|
+
const canonical = generateClaudeMd({});
|
|
268
|
+
const existing = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, 'utf-8') : null;
|
|
269
|
+
const result = applyInjectionReplacement(existing, canonical);
|
|
270
|
+
if (!result.changed || !result.contents)
|
|
271
|
+
return false;
|
|
272
|
+
// atomicWriteFileSync guards against a concurrent reader (Claude Code
|
|
273
|
+
// re-scanning CLAUDE.md mid-fix) seeing a truncated file.
|
|
274
|
+
atomicWriteFileSync(claudeMdPath, result.contents);
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
output.writeln(output.warning(` CLAUDE.md repair failed: ${errorDetail(e)}`));
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
},
|
|
258
282
|
'Embedding hygiene': async () => {
|
|
259
283
|
// The session-start launcher already runs the same migration BEFORE
|
|
260
284
|
// daemon/MCP boot — that's where consumer autoheal happens. Running
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Kept separate from `doctor.ts` so the orchestration file stays small and the
|
|
5
5
|
* registry can be inspected/extended without re-touching command-action code.
|
|
6
6
|
*/
|
|
7
|
-
import { checkSubagentHealth, checkSpellExecution, checkMcpToolInvocation, checkHookExecution, checkMcpSpellIntegration, checkGateHealth, checkHookBlockDrift, checkMofloDbBridge, } from './doctor-checks-deep.js';
|
|
7
|
+
import { checkSubagentHealth, checkSpellExecution, checkMcpToolInvocation, checkHookExecution, checkMcpSpellIntegration, checkGateHealth, checkHookBlockDrift, checkClaudeMdInjectionDrift, checkMofloDbBridge, } from './doctor-checks-deep.js';
|
|
8
8
|
import { checkEmbeddingHygiene } from './doctor-embedding-hygiene.js';
|
|
9
9
|
import { checkDaemonVersionSkew } from './doctor-checks-version-skew.js';
|
|
10
10
|
import { checkEmbeddingCoverageTruth } from './doctor-checks-coverage-truth.js';
|
|
@@ -63,6 +63,7 @@ export const allChecks = [
|
|
|
63
63
|
checkHookExecution,
|
|
64
64
|
checkGateHealth,
|
|
65
65
|
checkHookBlockDrift,
|
|
66
|
+
checkClaudeMdInjectionDrift,
|
|
66
67
|
checkMofloDbBridge,
|
|
67
68
|
// Issue #818 / epic #798 — coordinator-path tripwires. They share the
|
|
68
69
|
// singleton coordinator with checkSubagentHealth above and assert by
|
|
@@ -127,6 +128,9 @@ export const componentMap = {
|
|
|
127
128
|
'gate': checkGateHealth,
|
|
128
129
|
'hook-drift': checkHookBlockDrift,
|
|
129
130
|
'drift': checkHookBlockDrift,
|
|
131
|
+
'claudemd-drift': checkClaudeMdInjectionDrift,
|
|
132
|
+
'claudemd': checkClaudeMdInjectionDrift,
|
|
133
|
+
'injection-drift': checkClaudeMdInjectionDrift,
|
|
130
134
|
'sandbox': checkSandboxTier,
|
|
131
135
|
'sandbox-tier': checkSandboxTier,
|
|
132
136
|
'moflodb': checkMofloDbBridge,
|
|
@@ -83,6 +83,7 @@ const DEFAULT_CONFIG = {
|
|
|
83
83
|
scripts: true,
|
|
84
84
|
helpers: true,
|
|
85
85
|
hook_block_drift: 'warn',
|
|
86
|
+
claudemd_injection_drift: 'regenerate',
|
|
86
87
|
},
|
|
87
88
|
sandbox: {
|
|
88
89
|
enabled: false,
|
|
@@ -212,6 +213,12 @@ function mergeConfig(raw, root) {
|
|
|
212
213
|
? v
|
|
213
214
|
: DEFAULT_CONFIG.auto_update.hook_block_drift;
|
|
214
215
|
})(),
|
|
216
|
+
claudemd_injection_drift: (() => {
|
|
217
|
+
const v = raw.auto_update?.claudemd_injection_drift ?? raw.autoUpdate?.claudemdInjectionDrift;
|
|
218
|
+
return v === 'regenerate' || v === 'off' || v === 'warn'
|
|
219
|
+
? v
|
|
220
|
+
: DEFAULT_CONFIG.auto_update.claudemd_injection_drift;
|
|
221
|
+
})(),
|
|
215
222
|
},
|
|
216
223
|
sandbox: {
|
|
217
224
|
enabled: raw.sandbox?.enabled ?? DEFAULT_CONFIG.sandbox.enabled,
|
|
@@ -409,6 +416,10 @@ auto_update:
|
|
|
409
416
|
# warn = print drift summary on session start (default)
|
|
410
417
|
# regenerate = auto-add missing hooks (only when no customisations)
|
|
411
418
|
# off = skip detection entirely
|
|
419
|
+
claudemd_injection_drift: regenerate # warn | regenerate | off
|
|
420
|
+
# regenerate = auto-refresh CLAUDE.md MoFlo block on drift (default)
|
|
421
|
+
# warn = print drift summary on session start
|
|
422
|
+
# off = skip detection entirely
|
|
412
423
|
|
|
413
424
|
# OS-level sandbox for spell bash steps
|
|
414
425
|
# Denylist always runs regardless of this setting
|
|
@@ -60,8 +60,12 @@ ${MARKER_END}`;
|
|
|
60
60
|
export { MARKER_START, MARKER_END, LEGACY_MARKER_STARTS, LEGACY_MARKER_ENDS };
|
|
61
61
|
/**
|
|
62
62
|
* Generate the MoFlo section to inject into CLAUDE.md.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
63
|
+
*
|
|
64
|
+
* Both parameters are accepted for backward compatibility but ignored — all
|
|
65
|
+
* templates produce the same minimal injection and the options shape is no
|
|
66
|
+
* longer consulted. Optional so callers from both the dev tree (TS) and the
|
|
67
|
+
* dogfood launcher (plain JS) can invoke as `generateClaudeMd()` /
|
|
68
|
+
* `generateClaudeMd({})` interchangeably.
|
|
65
69
|
*/
|
|
66
70
|
export function generateClaudeMd(_options, _template) {
|
|
67
71
|
return mofloSection() + '\n';
|
|
@@ -15,7 +15,8 @@ import { execSync } from 'child_process';
|
|
|
15
15
|
import { locateMofloRootPath } from '../services/moflo-require.js';
|
|
16
16
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
17
17
|
import { discoverGuidanceDirs, discoverSrcDirs, discoverTestDirs, detectExtensions, renderMofloYaml, } from './moflo-yaml-template.js';
|
|
18
|
-
import { generateClaudeMd as generateMofloSection
|
|
18
|
+
import { generateClaudeMd as generateMofloSection } from './claudemd-generator.js';
|
|
19
|
+
import { applyInjectionReplacement } from '../services/claudemd-injection.js';
|
|
19
20
|
import { DEFAULT_INIT_OPTIONS } from './types.js';
|
|
20
21
|
export { discoverTestDirs };
|
|
21
22
|
// ============================================================================
|
|
@@ -400,29 +401,20 @@ function generateSkill(root, force) {
|
|
|
400
401
|
// ============================================================================
|
|
401
402
|
function generateClaudeMd(root, _force) {
|
|
402
403
|
const claudeMdPath = path.join(root, 'CLAUDE.md');
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
for (let i = 0; i < allStartMarkers.length; i++) {
|
|
410
|
-
if (existing.includes(allStartMarkers[i])) {
|
|
411
|
-
const startIdx = existing.indexOf(allStartMarkers[i]);
|
|
412
|
-
const endIdx = existing.indexOf(allEndMarkers[i]);
|
|
413
|
-
if (endIdx > startIdx) {
|
|
414
|
-
existing = existing.substring(0, startIdx) + existing.substring(endIdx + allEndMarkers[i].length);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
// Single source of truth: claudemd-generator.ts owns the section content.
|
|
404
|
+
const existed = fs.existsSync(claudeMdPath);
|
|
405
|
+
const existing = existed ? fs.readFileSync(claudeMdPath, 'utf-8') : null;
|
|
406
|
+
// Single source of truth: claudemd-generator.ts owns the section content,
|
|
407
|
+
// claudemd-injection.ts owns the marker-replace logic. Replaces in place
|
|
408
|
+
// when a marker pair (current or legacy) already exists; otherwise creates
|
|
409
|
+
// a fresh CLAUDE.md or appends to a non-moflo one.
|
|
420
410
|
const canonical = generateMofloSection(DEFAULT_INIT_OPTIONS);
|
|
421
|
-
const
|
|
422
|
-
|
|
411
|
+
const result = applyInjectionReplacement(existing, canonical);
|
|
412
|
+
if (result.contents !== null && (result.changed || !existed)) {
|
|
413
|
+
fs.writeFileSync(claudeMdPath, result.contents, 'utf-8');
|
|
414
|
+
}
|
|
423
415
|
return {
|
|
424
416
|
name: 'CLAUDE.md',
|
|
425
|
-
status:
|
|
417
|
+
status: existed ? 'updated' : 'created',
|
|
426
418
|
detail: 'MoFlo section injected (~22 lines)',
|
|
427
419
|
};
|
|
428
420
|
}
|
|
@@ -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
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.10.
|
|
3
|
+
"version": "4.10.7",
|
|
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",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
96
96
|
"@typescript-eslint/parser": "^7.18.0",
|
|
97
97
|
"eslint": "^8.0.0",
|
|
98
|
-
"moflo": "^4.10.
|
|
98
|
+
"moflo": "^4.10.6",
|
|
99
99
|
"tsx": "^4.21.0",
|
|
100
100
|
"typescript": "^5.9.3",
|
|
101
101
|
"vitest": "^4.0.0"
|