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.
@@ -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:` block, it is auto-appended on the next session start — you never need to re-run `moflo init` after a version bump.
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 = { enabled: true, scripts: true, helpers: true, hookBlockDrift: 'warn' };
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
- try {
1418
- if (autoUpdateConfig.enabled && autoUpdateConfig.hookBlockDrift !== 'off') {
1419
- const result = await runHookBlockDriftCheck();
1420
- if (result) {
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
- mkdirSync(mofloDir(projectRoot), { recursive: true });
1423
- writeFileSync(result.cachePath, JSON.stringify({
1424
- settingsMtimeMs: result.settingsMtimeMs,
1425
- moduleMtimeMs: result.moduleMtimeMs,
1426
- consumerHash: result.consumerHash,
1427
- referenceHash: result.referenceHash,
1428
- }));
1429
- } catch { /* cache is opportunistic non-fatal */ }
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
- } catch (err) {
1433
- emitWarning(`hook-block drift check skipped (${errMessage(err)})`);
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) ──
@@ -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
- // Use the shared mofloInternalURL helper so the script works identically when
42
- // invoked from bin/ (canonical) or from .claude/scripts/ (synced copy).
43
- const {
44
- generateClaudeMd,
45
- MARKER_START,
46
- MARKER_END,
47
- LEGACY_MARKER_STARTS,
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
- if (!existsSync(claudeMdPath)) {
155
- if (checkOnly) {
156
- log('⚠️ No CLAUDE.md found');
157
- return false;
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
- const content = readFileSync(claudeMdPath, 'utf-8');
165
-
166
- // Check for current or legacy markers and replace
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 has no moflo section (run without --update to add)');
200
- return false;
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 missing subagent protocol section');
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
- // Append section to end of CLAUDE.md
209
- const separator = content.endsWith('\n') ? '\n' : '\n\n';
210
- writeFileSync(claudeMdPath, content + separator + CLAUDE_MD_SECTION + '\n', 'utf-8');
211
- log('📝 Added subagent protocol section to CLAUDE.md');
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
- * Template parameter is accepted for backward compatibility but ignored —
64
- * all templates now produce the same minimal injection.
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, MARKER_START, MARKER_END, LEGACY_MARKER_STARTS, LEGACY_MARKER_ENDS, } from './claudemd-generator.js';
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
- let existing = '';
404
- if (fs.existsSync(claudeMdPath)) {
405
- existing = fs.readFileSync(claudeMdPath, 'utf-8');
406
- // Strip current or legacy MoFlo block so we can re-inject the latest content.
407
- const allStartMarkers = [MARKER_START, ...LEGACY_MARKER_STARTS];
408
- const allEndMarkers = [MARKER_END, ...LEGACY_MARKER_ENDS];
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 finalContent = existing.trimEnd() + '\n\n' + canonical;
422
- fs.writeFileSync(claudeMdPath, finalContent, 'utf-8');
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: existing ? 'updated' : 'created',
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
@@ -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.10.6';
5
+ export const VERSION = '4.10.7';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.10.6",
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.5",
98
+ "moflo": "^4.10.6",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"