moflo 4.10.5 → 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.
Files changed (42) hide show
  1. package/.claude/agents/analysis/analyze-code-quality.md +14 -0
  2. package/.claude/agents/analysis/code-analyzer.md +14 -0
  3. package/.claude/agents/architecture/system-design/arch-system-design.md +14 -0
  4. package/.claude/agents/base-template-generator.md +14 -0
  5. package/.claude/agents/core/coder.md +14 -0
  6. package/.claude/agents/core/planner.md +14 -0
  7. package/.claude/agents/core/researcher.md +14 -0
  8. package/.claude/agents/core/reviewer.md +14 -0
  9. package/.claude/agents/core/tester.md +14 -0
  10. package/.claude/agents/custom/test-long-runner.md +14 -0
  11. package/.claude/agents/development/dev-backend-api.md +14 -0
  12. package/.claude/agents/development/dev-database.md +13 -0
  13. package/.claude/agents/development/dev-frontend.md +13 -0
  14. package/.claude/agents/devops/ci-cd/ops-cicd-github.md +14 -0
  15. package/.claude/agents/documentation/api-docs/docs-api-openapi.md +14 -0
  16. package/.claude/agents/security/security-auditor.md +13 -0
  17. package/.claude/guidance/shipped/moflo-claude-swarm-cohesion.md +5 -3
  18. package/.claude/guidance/shipped/moflo-cli-reference.md +17 -31
  19. package/.claude/guidance/shipped/moflo-task-icons.md +10 -6
  20. package/.claude/guidance/shipped/moflo-yaml-reference.md +16 -2
  21. package/.claude/helpers/gate.cjs +101 -1
  22. package/.claude/helpers/subagent-bootstrap.json +1 -1
  23. package/.claude/helpers/subagent-start.cjs +1 -1
  24. package/bin/gate.cjs +101 -1
  25. package/bin/session-start-launcher.mjs +205 -16
  26. package/bin/setup-project.mjs +38 -58
  27. package/dist/src/cli/commands/daemon.js +31 -10
  28. package/dist/src/cli/commands/doctor-checks-deep.js +105 -0
  29. package/dist/src/cli/commands/doctor-fixes.js +24 -0
  30. package/dist/src/cli/commands/doctor-registry.js +5 -1
  31. package/dist/src/cli/commands/retire.js +22 -17
  32. package/dist/src/cli/config/moflo-config.js +11 -0
  33. package/dist/src/cli/init/claudemd-generator.js +6 -2
  34. package/dist/src/cli/init/helpers-generator.js +36 -1
  35. package/dist/src/cli/init/moflo-init.js +13 -21
  36. package/dist/src/cli/init/settings-generator.js +4 -1
  37. package/dist/src/cli/services/claudemd-injection.js +173 -0
  38. package/dist/src/cli/services/hook-block-hash.js +8 -2
  39. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  40. package/dist/src/cli/version.js +1 -1
  41. package/package.json +2 -2
  42. package/retired-files.json +305 -112
@@ -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,
@@ -42,7 +42,7 @@ function findMofloRepoRoot(start) {
42
42
  }
43
43
  export const retireCommand = {
44
44
  name: 'retire',
45
- description: 'Record a retired shipped file in retired-files.json (moflo dev only) — usage: flo retire <path> [--retired-by #nnn]',
45
+ description: 'Record a retired shipped file in retired-files.json (moflo dev only) — usage: flo retire <path> [--retired-by #nnn] | flo retire --rebuild-hashes',
46
46
  hidden: true,
47
47
  options: [
48
48
  {
@@ -56,15 +56,16 @@ export const retireCommand = {
56
56
  type: 'string',
57
57
  },
58
58
  {
59
- name: 'hashes',
60
- description: 'Maximum number of historical content hashes to record (default 3)',
61
- type: 'number',
62
- default: 3,
59
+ name: 'rebuild-hashes',
60
+ description: 'Recompute knownContentHashes[] for every existing entry from full git history (#1133 backfill)',
61
+ type: 'boolean',
62
+ default: false,
63
63
  },
64
64
  ],
65
65
  examples: [
66
66
  { command: 'flo retire .claude/agents/v3/performance-engineer.md --retired-by #932', description: 'Record a retirement' },
67
67
  { command: 'flo retire .claude/skills/skill-builder/SKILL.md --retired-by #945 --retired-in 4.9.21', description: 'Pin retiredIn' },
68
+ { command: 'flo retire --rebuild-hashes', description: 'Backfill every entry from full git history' },
68
69
  ],
69
70
  action: async (ctx) => {
70
71
  const repoRoot = findMofloRepoRoot(__filename) || findMofloRepoRoot(ctx.cwd);
@@ -73,11 +74,6 @@ export const retireCommand = {
73
74
  output.printInfo('retired-files.json lives at the moflo package root and does not ship to consumer projects');
74
75
  return { success: false, message: 'not in moflo repo', exitCode: 1 };
75
76
  }
76
- const path = ctx.args[0];
77
- if (!path) {
78
- output.printError('Missing required argument: <path>');
79
- return { success: false, message: 'missing path', exitCode: 2 };
80
- }
81
77
  const scriptPath = resolve(repoRoot, 'scripts', 'build-retired-files.mjs');
82
78
  if (!existsSync(scriptPath)) {
83
79
  output.printError(`scripts/build-retired-files.mjs not found at ${scriptPath}`);
@@ -86,13 +82,22 @@ export const retireCommand = {
86
82
  // Parser normalises kebab-case flag names to camelCase before storing
87
83
  // (#787). Read as ctx.flags.<camelCase> — bracket-with-kebab is always
88
84
  // undefined and ESLint blocks that pattern.
89
- const args = ['--add', path];
90
- if (ctx.flags.retiredBy)
91
- args.push('--retired-by', String(ctx.flags.retiredBy));
92
- if (ctx.flags.retiredIn)
93
- args.push('--retired-in', String(ctx.flags.retiredIn));
94
- if (ctx.flags.hashes)
95
- args.push('--hashes', String(ctx.flags.hashes));
85
+ let args;
86
+ if (ctx.flags.rebuildHashes) {
87
+ args = ['--rebuild-hashes'];
88
+ }
89
+ else {
90
+ const path = ctx.args[0];
91
+ if (!path) {
92
+ output.printError('Missing required argument: <path> (or pass --rebuild-hashes)');
93
+ return { success: false, message: 'missing path', exitCode: 2 };
94
+ }
95
+ args = ['--add', path];
96
+ if (ctx.flags.retiredBy)
97
+ args.push('--retired-by', String(ctx.flags.retiredBy));
98
+ if (ctx.flags.retiredIn)
99
+ args.push('--retired-in', String(ctx.flags.retiredIn));
100
+ }
96
101
  const result = spawnSync('node', [scriptPath, ...args], {
97
102
  cwd: repoRoot,
98
103
  stdio: 'inherit',
@@ -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';
@@ -277,6 +277,10 @@ var command = process.argv[2];
277
277
 
278
278
  var EXEMPT = ['.claude/', '.claude\\\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
279
279
  var DANGEROUS = ['rm -rf /', 'format c:', 'del /s /q c:\\\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda'];
280
+ // #1132 — Bash memory-first gate regexes. See bin/gate.cjs for documentation.
281
+ var CREDIT_MEMORY_SEARCH_RE = /semantic-search|memory search|memory retrieve|memory-search/;
282
+ var READ_LIKE_BASH_RE = /^\\s*(?:cat|head|tail|less|more|bat|xxd|od|hexdump)\\b|^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd)\\b|^\\s*sed\\s+-n\\b|^\\s*awk\\s+(?!.*<<)|^\\s*type\\s+\\S*[\\\\/.]|^\\s*(?:Get-Content|gc|Select-String|sls)\\b/i;
283
+ var BASH_CARVE_OUT_RE = /^\\s*(npm|npx|pnpm|yarn|bun|node|deno|tsx|ts-node)\\s|^\\s*(git|gh|hub)\\s|^\\s*(docker|kubectl|helm|terraform)\\s|^\\s*(curl|wget|http|fetch)\\s|^\\s*(jq|yq|xq)\\s|^\\s*(echo|printf|true|false|sleep|test|\\[)\\s|^\\s*cat\\s+(<<|<<<)|^\\s*cat\\s+[^|]*\\s*>|^\\s*tee\\b|^\\s*find\\s+.+?-(delete|exec\\s+rm)\\b/;
280
284
  var DIRECTIVE_RE = /^(yes|no|yeah|yep|nope|sure|ok|okay|correct|right|exactly|perfect)\\b/i;
281
285
  var TASK_RE = /\\b(fix|bug|error|implement|add|create|build|write|refactor|debug|test|feature|issue|security|optimi)\\b/i;
282
286
 
@@ -340,6 +344,19 @@ function classifyNamespaceHint(promptText) {
340
344
  return '';
341
345
  }
342
346
 
347
+ // #1132 — command-shape namespace classifier for the bash-BLOCK message.
348
+ // SYNC: duplicated verbatim in bin/gate.cjs. See that file for rationale.
349
+ function classifyBashNamespaceHint(cmd) {
350
+ if (/^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd|Select-String|sls)\\b/i.test(cmd)) {
351
+ return 'Memory namespace hint: use "code-map" for codebase navigation.';
352
+ }
353
+ if (/^\\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\\b.*\\.(?:md|mdx|rst|txt)\\b/i.test(cmd)
354
+ || /^\\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\\b.*\\b(?:README|CLAUDE|CHANGELOG|CONTRIBUTING|LICENSE)\\b/i.test(cmd)) {
355
+ return 'Memory namespace hint: search "guidance" and "learnings" for project rules and decisions.';
356
+ }
357
+ return '';
358
+ }
359
+
343
360
  function applyPromptStateReset(state, promptText) {
344
361
  state.memorySearched = false;
345
362
  state.memorySearchedBy = {};
@@ -467,11 +484,29 @@ switch (command) {
467
484
  break;
468
485
  }
469
486
  case 'check-bash-memory': {
487
+ // #1132 — credit + block. See bin/gate.cjs for full documentation.
470
488
  var cmd = process.env.TOOL_INPUT_command || '';
471
- if (/semantic-search|memory search|memory retrieve|memory-search/.test(cmd)) {
489
+ if (CREDIT_MEMORY_SEARCH_RE.test(cmd)) {
472
490
  var s = readState();
473
491
  if (markMemorySearched(s)) writeState(s);
492
+ break;
474
493
  }
494
+ if (!config.memory_first) break;
495
+ if (!READ_LIKE_BASH_RE.test(cmd)) break;
496
+ if (BASH_CARVE_OUT_RE.test(cmd)) break;
497
+ var s2 = readState();
498
+ if (!s2.memoryRequired || isMemorySearchedFor(s2)) break;
499
+ // Hint precedence: prompt classification → command-shape classification.
500
+ // See bin/gate.cjs check-bash-memory for full rationale.
501
+ var hint = s2.lastNamespaceHint || classifyBashNamespaceHint(cmd) || '';
502
+ process.stderr.write(
503
+ 'BLOCKED: Search memory before reading files via Bash.\\n' +
504
+ 'Example: mcp__moflo__memory_search { query: "<topic>", namespace: "<one of: guidance | code-map | patterns | learnings | tests>" }\\n' +
505
+ (hint ? hint + '\\n' : '') +
506
+ 'On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\\n' +
507
+ 'Disable per-gate via moflo.yaml: gates: memory_first: false\\n'
508
+ );
509
+ process.exit(2);
475
510
  break;
476
511
  }
477
512
  case 'check-task-transition': {
@@ -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
  }
@@ -233,6 +233,9 @@ function generateHooksConfig(config) {
233
233
  hooks: [
234
234
  { type: 'command', command: gateHookCmd('check-dangerous-command'), timeout: 2000 },
235
235
  { type: 'command', command: gateHookCmd('check-before-pr'), timeout: 2000 },
236
+ // #1132 — moved from PostToolUse so process.exit(2) actually blocks
237
+ // read-like Bash that bypasses the Read/Glob/Grep gates via the shell.
238
+ { type: 'command', command: gateHookCmd('check-bash-memory'), timeout: 2000 },
236
239
  ],
237
240
  },
238
241
  // #931 — TaskCreate REMINDER + namespace hint moved here from
@@ -272,7 +275,7 @@ function generateHooksConfig(config) {
272
275
  {
273
276
  matcher: '^Bash$',
274
277
  hooks: [
275
- { type: 'command', command: gateHookCmd('check-bash-memory'), timeout: 2000 },
278
+ // #1132 check-bash-memory moved to PreToolUse (above).
276
279
  { type: 'command', command: gateHookCmd('record-test-run'), timeout: 2000 },
277
280
  ],
278
281
  },
@@ -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
@@ -57,7 +57,12 @@ export function getReferenceHookBlock() {
57
57
  { matcher: '^Read$', hooks: [gateHook('check-before-read', 3000)] },
58
58
  {
59
59
  matcher: '^Bash$',
60
- hooks: [gateHook('check-dangerous-command', 2000), gateHook('check-before-pr', 2000)],
60
+ hooks: [
61
+ gateHook('check-dangerous-command', 2000),
62
+ gateHook('check-before-pr', 2000),
63
+ // #1132 — moved from PostToolUse so process.exit(2) actually blocks.
64
+ gateHook('check-bash-memory', 2000),
65
+ ],
61
66
  },
62
67
  // #931 — TaskCreate REMINDER + namespace hint advisory at Agent-spawn time.
63
68
  // Routed via gate-hook.mjs so HOOK_SESSION_ID is forwarded for per-actor
@@ -72,8 +77,9 @@ export function getReferenceHookBlock() {
72
77
  { matcher: '^Agent$', hooks: [handler('post-task', 5000)] },
73
78
  { matcher: '^TaskCreate$', hooks: [gateCjs('record-task-created', 2000)] },
74
79
  {
80
+ // #1132 — check-bash-memory moved to PreToolUse (above).
75
81
  matcher: '^Bash$',
76
- hooks: [gateHook('check-bash-memory', 2000), gateHook('record-test-run', 2000)],
82
+ hooks: [gateHook('record-test-run', 2000)],
77
83
  },
78
84
  { matcher: '^Skill$', hooks: [gateHook('record-skill-run', 2000)] },
79
85
  { matcher: '^mcp__moflo__memory_(search|retrieve|list|stats|store)$', hooks: [gateHook('record-memory-searched', 3000)] },
@@ -21,7 +21,7 @@ const BOOTSTRAP_JSON_REL = '.claude/helpers/subagent-bootstrap.json';
21
21
  // Defense-in-depth copy of the canonical directive in subagent-bootstrap.json.
22
22
  // Kept as a single-line literal so the parity test can verify it matches the
23
23
  // JSON via plain substring containment.
24
- const FALLBACK_DIRECTIVE = 'MANDATORY FIRST ACTION: Your very first tool call MUST be mcp__moflo__memory_search (any query, any namespace). The memory-first gate WILL BLOCK all Glob, Grep, and Read calls until you do this. After memory search, follow `.claude/guidance/moflo-subagents.md` protocol. When a search hit carries `navigation`, you MUST call mcp__moflo__memory_get_neighbors to traverse — calling mcp__moflo__memory_retrieve on every hit is a protocol violation. See `.claude/guidance/moflo-memory-protocol.md`.';
24
+ const FALLBACK_DIRECTIVE = 'MANDATORY FIRST ACTION: Your very first tool call MUST be mcp__moflo__memory_search (any query, any namespace). The memory-first gate WILL BLOCK all Glob, Grep, Read, and read-like Bash (cat/head/tail/grep/find/sed/awk and the Windows/PowerShell equivalents) calls until you do this. Pick the namespace by task: `guidance` for rules and conventions, `code-map` for file structure, `patterns` for proven solutions, `learnings` for past corrections, `tests` for test inventory. After memory search, follow `.claude/guidance/moflo-subagents.md` protocol. When a search hit carries `navigation`, you MUST call mcp__moflo__memory_get_neighbors to traverse — calling mcp__moflo__memory_retrieve on every hit is a protocol violation. See `.claude/guidance/moflo-memory-protocol.md`.';
25
25
  function loadDirective() {
26
26
  const jsonPath = locateMofloRootPath(BOOTSTRAP_JSON_REL);
27
27
  if (!jsonPath) {
@@ -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.5';
5
+ export const VERSION = '4.10.7';
6
6
  //# sourceMappingURL=version.js.map