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
@@ -22,7 +22,7 @@ const path = require('path');
22
22
  // Defense-in-depth copy of the canonical directive in subagent-bootstrap.json.
23
23
  // Kept as a single-line literal so the parity test in tests/bin/subagent-start.test.ts
24
24
  // can verify it matches the JSON via plain substring containment.
25
- 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`.';
25
+ 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`.';
26
26
 
27
27
  function loadDirective() {
28
28
  const jsonPath = path.join(__dirname, 'subagent-bootstrap.json');
package/bin/gate.cjs CHANGED
@@ -82,6 +82,53 @@ var command = process.argv[2];
82
82
 
83
83
  var EXEMPT = ['.claude/', '.claude\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
84
84
  var DANGEROUS = ['rm -rf /', 'format c:', 'del /s /q c:\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda'];
85
+
86
+ // #1132 — Bash memory-first gate.
87
+ //
88
+ // CREDIT: the legacy detector that marks the gate satisfied when Claude
89
+ // manually invokes a memory-search CLI (flo-search, the moflo MCP search via
90
+ // shell, etc.). Preserved verbatim from the pre-#1132 behaviour so existing
91
+ // recipes keep crediting the gate.
92
+ var CREDIT_MEMORY_SEARCH_RE = /semantic-search|memory search|memory retrieve|memory-search/;
93
+ // BLOCK: read-like Bash commands that bypass the existing check-before-read /
94
+ // check-before-scan gates by going through the shell. Anchored to the start of
95
+ // the line so subcommands inside pipelines or `npm install grep` don't trip.
96
+ // Covers POSIX read/search tools, Windows cmd `type`, and PowerShell readers.
97
+ var READ_LIKE_BASH_RE = new RegExp([
98
+ '^\\s*(?:cat|head|tail|less|more|bat|xxd|od|hexdump)\\b',
99
+ '^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd)\\b',
100
+ '^\\s*sed\\s+-n\\b',
101
+ '^\\s*awk\\s+(?!.*<<)',
102
+ // `type <path>` on Windows. No `$` anchor so a piped form
103
+ // (`type src\foo.ts | grep x`) still matches and gets blocked. The argument
104
+ // must contain a slash, backslash, or dot — otherwise it's the shell-builtin
105
+ // command-lookup form (`type ls`, `type cd`) which the gate has no business
106
+ // blocking. False-negative trade: extension-less filenames like `type Makefile`
107
+ // pass through. Acceptable — source files all have extensions, and the
108
+ // primary risk pattern is leaking past the gate via `type src\foo.ts`.
109
+ '^\\s*type\\s+\\S*[\\\\/.]',
110
+ '^\\s*(?:Get-Content|gc|Select-String|sls)\\b',
111
+ ].join('|'), 'i');
112
+ // CARVE-OUT: commands that LOOK read-like but are operational. Anchored to the
113
+ // LEADING command — the pipe-filter case (`npm test | grep FAIL`) is already
114
+ // handled by READ_LIKE's `^\s*` anchor never matching the leading `npm`, so
115
+ // there is intentionally no pipe arm here: catching the leading command lets
116
+ // `grep -r TODO src/ | head -5` reach the BLOCK exit (which it must, that's
117
+ // the gap the ticket exists to close). #1132.
118
+ var BASH_CARVE_OUT_RE = new RegExp([
119
+ '^\\s*(npm|npx|pnpm|yarn|bun|node|deno|tsx|ts-node)\\s',
120
+ '^\\s*(git|gh|hub)\\s',
121
+ '^\\s*(docker|kubectl|helm|terraform)\\s',
122
+ '^\\s*(curl|wget|http|fetch)\\s',
123
+ '^\\s*(jq|yq|xq)\\s',
124
+ '^\\s*(echo|printf|true|false|sleep|test|\\[)\\s',
125
+ '^\\s*cat\\s+(<<|<<<)',
126
+ '^\\s*cat\\s+[^|]*\\s*>',
127
+ '^\\s*tee\\b',
128
+ // Lazy `.+?` instead of `.+\s` to avoid catastrophic backtracking on long
129
+ // `find` commands that lack a `-delete` / `-exec rm` suffix.
130
+ '^\\s*find\\s+.+?-(delete|exec\\s+rm)\\b',
131
+ ].join('|'));
85
132
  var DIRECTIVE_RE = /^(yes|no|yeah|yep|nope|sure|ok|okay|correct|right|exactly|perfect)\b/i;
86
133
  var TASK_RE = /\b(fix|bug|error|implement|add|create|build|write|refactor|debug|test|feature|issue|security|optimi)\b/i;
87
134
 
@@ -146,6 +193,29 @@ function classifyNamespaceHint(promptText) {
146
193
  return '';
147
194
  }
148
195
 
196
+ // #1132 — command-shape namespace classifier for the bash-BLOCK message.
197
+ // Used when the prompt-derived `lastNamespaceHint` is empty (e.g. subagents,
198
+ // which never see the user prompt) so the block message still routes to a
199
+ // useful namespace rather than the generic "pick one of five" list. Returns a
200
+ // full sentence in the same shape as classifyNamespaceHint so the BLOCK arm
201
+ // can write either source's hint without branching on format.
202
+ //
203
+ // SYNC: duplicated verbatim in src/cli/init/helpers-generator.ts.
204
+ function classifyBashNamespaceHint(cmd) {
205
+ // Search-like tools — the user is hunting for a symbol/file, code-map wins.
206
+ if (/^\s*(?:grep|rg|ag|fgrep|egrep|find|fd|Select-String|sls)\b/i.test(cmd)) {
207
+ return 'Memory namespace hint: use "code-map" for codebase navigation.';
208
+ }
209
+ // Reading a .md / RST / TXT, or a well-known doc file — guidance/learnings win.
210
+ // `.*` (not `\S*`) so flag-prefixed forms like `head -50 README.md` match.
211
+ // Anchored on the leading reader so a piped `cmd | grep foo.md` doesn't trip.
212
+ if (/^\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\b.*\.(?:md|mdx|rst|txt)\b/i.test(cmd)
213
+ || /^\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\b.*\b(?:README|CLAUDE|CHANGELOG|CONTRIBUTING|LICENSE)\b/i.test(cmd)) {
214
+ return 'Memory namespace hint: search "guidance" and "learnings" for project rules and decisions.';
215
+ }
216
+ return '';
217
+ }
218
+
149
219
  // Apply per-prompt state reset shared by `prompt-reminder` (full) and
150
220
  // `prompt-state-reset` (defensive safety-net, no emission). Idempotent — both
151
221
  // UserPromptSubmit hooks can run it without compounding any field. Caller
@@ -402,11 +472,41 @@ switch (command) {
402
472
  break;
403
473
  }
404
474
  case 'check-bash-memory': {
475
+ // #1132 — preserve CREDIT side-effect AND add a BLOCK arm for read-like
476
+ // Bash commands. Wired as PreToolUse[Bash] (was PostToolUse before #1132)
477
+ // so process.exit(2) actually prevents the read from reaching the shell.
405
478
  var cmd = process.env.TOOL_INPUT_command || '';
406
- if (/semantic-search|memory search|memory retrieve|memory-search/.test(cmd)) {
479
+
480
+ // 1) CREDIT — preserved behavior. A real memory-search invocation flips
481
+ // the gate flag so subsequent Read/Grep/Glob within this prompt pass.
482
+ if (CREDIT_MEMORY_SEARCH_RE.test(cmd)) {
407
483
  var s = readState();
408
484
  if (markMemorySearched(s)) writeState(s);
485
+ break;
409
486
  }
487
+
488
+ // 2) BLOCK — new behavior. Cheap regex checks come BEFORE readState() so
489
+ // the overwhelming majority of Bash invocations (git/npm/curl/echo/etc.)
490
+ // never touch the filesystem. Order: config flag → command-shape regexes
491
+ // → state read → memory gate.
492
+ if (!config.memory_first) break;
493
+ if (!READ_LIKE_BASH_RE.test(cmd)) break;
494
+ if (BASH_CARVE_OUT_RE.test(cmd)) break;
495
+ var s2 = readState();
496
+ if (!s2.memoryRequired || isMemorySearchedFor(s2)) break;
497
+ // Hint precedence: prompt-derived classification (set by applyPromptStateReset
498
+ // from the user prompt text) → command-shape classification (works for
499
+ // subagents that never saw the user prompt). Either source returns a full
500
+ // "Memory namespace hint: ..." sentence so the BLOCK message stays uniform.
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);
410
510
  break;
411
511
  }
412
512
  case 'check-task-transition': {
@@ -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
@@ -1053,12 +1065,14 @@ try {
1053
1065
  // prune when the consumer's file matches a known-shipped hash —
1054
1066
  // customized files (different hash) get preserved with a one-line
1055
1067
  // notice the user can act on.
1068
+ let prunedRetiredPaths = new Set();
1056
1069
  try {
1057
1070
  const retiredManifestPath = resolve(
1058
1071
  projectRoot,
1059
1072
  'node_modules/moflo/retired-files.json',
1060
1073
  );
1061
1074
  const report = applyRetiredPrune(projectRoot, retiredManifestPath);
1075
+ prunedRetiredPaths = new Set(report.pruned);
1062
1076
  if (report.pruned.length > 0) {
1063
1077
  emitMutation(
1064
1078
  'pruned retired shipped files',
@@ -1110,10 +1124,23 @@ try {
1110
1124
 
1111
1125
  // Manifest reflects synced files immediately; version stamp is deferred
1112
1126
  // to 3g so an aborted launcher re-runs upgrade detection (#730).
1127
+ //
1128
+ // Exclude paths that `applyRetiredPrune` just deleted from disk —
1129
+ // recording a non-existent file in `installed-files.json` triggers
1130
+ // false drift detection on the next launcher run (`manifestDrifted`
1131
+ // flips true because the recorded path doesn't exist), which spuriously
1132
+ // re-fires the cherry-pick + manifest-sync pipeline. Widening
1133
+ // `retired-files.json`'s hash window in #1133 exposed this — more
1134
+ // legitimate matches → more pruned files → guaranteed false drift on
1135
+ // the next launcher and re-imported legacy rows (knowledge namespace
1136
+ // came back even though the migration deleted it).
1137
+ const persistedManifest = prunedRetiredPaths.size > 0
1138
+ ? currentManifest.filter((e) => !prunedRetiredPaths.has(e.path))
1139
+ : currentManifest;
1113
1140
  try {
1114
1141
  const cfDir = resolve(projectRoot, '.moflo');
1115
1142
  if (!existsSync(cfDir)) mkdirSync(cfDir, { recursive: true });
1116
- writeFileSync(manifestPath, JSON.stringify(currentManifest, null, 2));
1143
+ writeFileSync(manifestPath, JSON.stringify(persistedManifest, null, 2));
1117
1144
  pendingVersionStampWrite = { path: versionStampPath, version: installedVersion };
1118
1145
  } catch (err) {
1119
1146
  // #854: manifest write must surface — without it the next launcher
@@ -1399,23 +1426,185 @@ async function runHookBlockDriftCheck() {
1399
1426
  };
1400
1427
  }
1401
1428
 
1402
- try {
1403
- if (autoUpdateConfig.enabled && autoUpdateConfig.hookBlockDrift !== 'off') {
1404
- const result = await runHookBlockDriftCheck();
1405
- 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.
1537
+ try {
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') {
1406
1547
  try {
1407
- mkdirSync(mofloDir(projectRoot), { recursive: true });
1408
- writeFileSync(result.cachePath, JSON.stringify({
1409
- settingsMtimeMs: result.settingsMtimeMs,
1410
- moduleMtimeMs: result.moduleMtimeMs,
1411
- consumerHash: result.consumerHash,
1412
- referenceHash: result.referenceHash,
1413
- }));
1414
- } catch { /* cache is opportunistic — non-fatal */ }
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 */ }
1415
1552
  }
1416
1553
  }
1417
- } catch (err) {
1418
- 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
+ }
1419
1608
  }
1420
1609
 
1421
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
 
@@ -472,7 +472,7 @@ export async function killBackgroundDaemon(projectRoot) {
472
472
  try {
473
473
  // Platform-split shutdown. On Linux/macOS we try SIGTERM first so the
474
474
  // daemon's shutdown handlers (sql.js flush, lock release) can run; force-
475
- // kill only if it doesn't exit within ~1s.
475
+ // kill only if it doesn't exit within the graceful window.
476
476
  //
477
477
  // On Windows there is no SIGTERM equivalent for our headless detached
478
478
  // Node daemon — `taskkill /PID` (no /F) sends a window-close message
@@ -481,25 +481,46 @@ export async function killBackgroundDaemon(projectRoot) {
481
481
  // implementation invoked it anyway, ate the error in a bare catch, then
482
482
  // slept 1s before escalating to /F. Skip the dead step: go straight to
483
483
  // /F /T (tree-kill, in case a worker child outlived its parent) on Win.
484
+ //
485
+ // #1136: previously this used `setTimeout(1000)` after SIGTERM and
486
+ // skipped post-SIGKILL verification. Under load on a populated DB the
487
+ // daemon's shutdown handler (worker-daemon.ts:582 → scheduler.stop +
488
+ // saveState) can exceed 1s — SIGKILL hit mid-sql.js-dump and left
489
+ // torn pages in `.moflo/moflo.db` (the page-ref / rowid-order
490
+ // signature seen in the populated-consumer smoke). Bring the kill
491
+ // window in line with the launcher's stopDaemon
492
+ // (bin/session-start-launcher.mjs:425): 3s graceful poll + 1s force
493
+ // poll, both verifying liveness before returning.
484
494
  if (process.platform === 'win32') {
485
495
  try {
486
496
  execFileSync('taskkill', ['/F', '/T', '/PID', String(holderPid)], { windowsHide: true });
487
497
  }
488
498
  catch {
489
- // Already exiting / unreachable — process.kill(pid, 0) below verifies.
499
+ // Already exiting / unreachable — verified via poll below.
500
+ }
501
+ const forceDeadline = Date.now() + 1000;
502
+ while (Date.now() < forceDeadline && isProcessRunning(holderPid)) {
503
+ await new Promise(resolve => setTimeout(resolve, 100));
490
504
  }
491
505
  }
492
506
  else {
493
- process.kill(holderPid, 'SIGTERM');
494
- // Wait briefly so SIGTERM has a chance to land before checking liveness.
495
- await new Promise(resolve => setTimeout(resolve, 1000));
496
507
  try {
497
- process.kill(holderPid, 0);
498
- // Still alive — force kill.
499
- process.kill(holderPid, 'SIGKILL');
508
+ process.kill(holderPid, 'SIGTERM');
500
509
  }
501
- catch {
502
- // Process terminated
510
+ catch { /* already dead */ }
511
+ const gracefulDeadline = Date.now() + 3000;
512
+ while (Date.now() < gracefulDeadline && isProcessRunning(holderPid)) {
513
+ await new Promise(resolve => setTimeout(resolve, 100));
514
+ }
515
+ if (isProcessRunning(holderPid)) {
516
+ try {
517
+ process.kill(holderPid, 'SIGKILL');
518
+ }
519
+ catch { /* already dead */ }
520
+ const forceDeadline = Date.now() + 1000;
521
+ while (Date.now() < forceDeadline && isProcessRunning(holderPid)) {
522
+ await new Promise(resolve => setTimeout(resolve, 100));
523
+ }
503
524
  }
504
525
  }
505
526
  // Release lock