moflo 4.9.11 → 4.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/hooks.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  import { spawn } from 'child_process';
23
23
  import { existsSync, appendFileSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
24
24
  import { resolve, dirname } from 'path';
25
- import { fileURLToPath } from 'url';
25
+ import { fileURLToPath, pathToFileURL } from 'url';
26
26
  import { createProcessManager } from './lib/process-manager.mjs';
27
27
  import { shouldDaemonAutoStart } from './lib/daemon-config.mjs';
28
28
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
@@ -520,7 +520,7 @@ let _getDaemonLockHolder = null;
520
520
  try {
521
521
  const daemonLockPath = resolve(__dirname, '..', 'src', '@claude-flow', 'cli', 'dist', 'src', 'services', 'daemon-lock.js');
522
522
  if (existsSync(daemonLockPath)) {
523
- const mod = await import('file://' + daemonLockPath.replace(/\\/g, '/'));
523
+ const mod = await import(pathToFileURL(daemonLockPath).href);
524
524
  _getDaemonLockHolder = mod.getDaemonLockHolder;
525
525
  }
526
526
  } catch { /* fallback below */ }
@@ -28,6 +28,7 @@ import { fileURLToPath } from 'url';
28
28
  import { mofloResolveURL } from './lib/moflo-resolve.mjs';
29
29
  import { memoryDbPath } from './lib/moflo-paths.mjs';
30
30
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
31
+ import { createProcessManager } from './lib/process-manager.mjs';
31
32
  const initSqlJs = (await import(mofloResolveURL('sql.js'))).default;
32
33
 
33
34
 
@@ -872,36 +873,25 @@ if (!skipEmbeddings && needsEmbeddings) {
872
873
  console.log('');
873
874
  log('Spawning embedding generation in background...');
874
875
 
875
- const { spawn } = await import('child_process');
876
-
877
876
  const embeddingScript = resolveMofloBin(
878
877
  projectRoot, 'flo-embeddings', 'build-embeddings.mjs', { includeDevFallback: true },
879
878
  );
880
879
 
881
880
  if (embeddingScript) {
882
- const embeddingArgs = ['--namespace', NAMESPACE];
883
-
884
- // Create log file for background process output
885
- const logDir = resolve(projectRoot, '.moflo/logs');
886
- if (!existsSync(logDir)) {
887
- mkdirSync(logDir, { recursive: true });
881
+ // Register the spawn with the shared ProcessManager (#886). Stdout/stderr
882
+ // route through `.swarm/background.log` (pm.spawn default) instead of the
883
+ // bespoke `.moflo/logs/embeddings.log` so the registry, dedup, and
884
+ // session-end drain stay consistent with every other tracked spawn.
885
+ const pm = createProcessManager(projectRoot);
886
+ const result = pm.spawn('node', [embeddingScript, '--namespace', NAMESPACE], `build-embeddings-${NAMESPACE}`);
887
+ if (result.skipped) {
888
+ log(`Background embedding already running (PID: ${result.pid})`);
889
+ } else if (result.pid) {
890
+ log(`Background embedding started (PID: ${result.pid})`);
891
+ log(`Log file: .swarm/background.log`);
892
+ } else {
893
+ log('⚠️ Failed to spawn background embedding');
888
894
  }
889
- const logFile = resolve(logDir, 'embeddings.log');
890
- const { openSync } = await import('fs');
891
- const out = openSync(logFile, 'a');
892
- const err = openSync(logFile, 'a');
893
-
894
- // Spawn in background - don't wait for completion
895
- const proc = spawn('node', [embeddingScript, ...embeddingArgs], {
896
- stdio: ['ignore', out, err],
897
- cwd: projectRoot,
898
- detached: true,
899
- windowsHide: true // Suppress command windows on Windows
900
- });
901
- proc.unref(); // Allow parent to exit independently
902
-
903
- log(`Background embedding started (PID: ${proc.pid})`);
904
- log(`Log file: .moflo/logs/embeddings.log`);
905
895
  } else {
906
896
  log('⚠️ Embedding script not found, skipping embedding generation');
907
897
  }
@@ -27,11 +27,11 @@
27
27
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
28
28
  import { resolve, dirname, relative, basename, extname } from 'path';
29
29
  import { fileURLToPath } from 'url';
30
- import { spawn } from 'child_process';
31
30
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
32
31
  import { mofloResolveURL } from './lib/moflo-resolve.mjs';
33
32
  import { memoryDbPath, MOFLO_DIR } from './lib/moflo-paths.mjs';
34
33
  import { applyIncrementalChunks, computeContentListHash } from './lib/incremental-write.mjs';
34
+ import { createProcessManager } from './lib/process-manager.mjs';
35
35
 
36
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
37
37
 
@@ -342,20 +342,23 @@ async function main() {
342
342
  // Save hash
343
343
  writeFileSync(HASH_CACHE_PATH, currentHash, 'utf-8');
344
344
 
345
- // Trigger embedding generation in background
345
+ // Trigger embedding generation in background. Register with the shared
346
+ // ProcessManager (#886) so doctor's zombie scan allowlists it and the
347
+ // session-end / smoke-teardown drain reaps it. Stable label dedupes against
348
+ // the index-all chain's later `build-embeddings` step when both run within
349
+ // the same lock window.
346
350
  try {
347
351
  const embeddingScript = resolveMofloBin(
348
352
  projectRoot, 'flo-embeddings', 'build-embeddings.mjs', { includeDevFallback: true },
349
353
  );
350
354
  if (embeddingScript) {
351
- const child = spawn('node', [embeddingScript, '--namespace', NAMESPACE], {
352
- cwd: projectRoot,
353
- detached: true,
354
- stdio: 'ignore',
355
- windowsHide: true,
356
- });
357
- child.unref();
358
- debug('Embedding generation started in background');
355
+ const pm = createProcessManager(projectRoot);
356
+ const result = pm.spawn('node', [embeddingScript, '--namespace', NAMESPACE], `build-embeddings-${NAMESPACE}`);
357
+ if (result.skipped) {
358
+ debug(`Embedding generation already running (PID: ${result.pid})`);
359
+ } else if (result.pid) {
360
+ debug(`Embedding generation started in background (PID: ${result.pid})`);
361
+ }
359
362
  }
360
363
  } catch { /* ignore */ }
361
364
 
@@ -10,7 +10,7 @@
10
10
  import { spawn, execFileSync } from 'child_process';
11
11
  import { existsSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, readdirSync, mkdirSync, statSync } from 'fs';
12
12
  import { resolve, dirname, join } from 'path';
13
- import { fileURLToPath } from 'url';
13
+ import { fileURLToPath, pathToFileURL } from 'url';
14
14
  import { mofloDir } from './lib/moflo-paths.mjs';
15
15
  import { repairMemoryDbIfCorrupt } from './lib/db-repair.mjs';
16
16
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
@@ -218,11 +218,17 @@ try {
218
218
  // own errors if the DB is still broken.
219
219
  }
220
220
 
221
- // ── 0d. Clear post-install restart notice when version is current (#867) ───
221
+ // ── 0d. Silently clear post-install restart notice when version is current (#867, #887)
222
222
  // scripts/post-install-notice.mjs drops `.moflo/restart-pending.json` on every
223
223
  // `npm install moflo`. The UserPromptSubmit hook surfaces it on every prompt
224
224
  // until cleared, so this session only sees the message between install and
225
225
  // the FIRST restart that actually picks up the new bits.
226
+ //
227
+ // Cleanup is silent (#887): the user already saw + acted on the restart prompt
228
+ // — surfacing a "cleared notice" line on the very next session reads like an
229
+ // error in additionalContext and inflates mutationCount, which would also fire
230
+ // the closing "starting background tasks" framing. Both are noise on a
231
+ // successful post-restart session.
226
232
  try {
227
233
  const pendingPath = join(mofloDir(projectRoot), 'restart-pending.json');
228
234
  const pkgPath = resolve(projectRoot, 'node_modules/moflo/package.json');
@@ -231,7 +237,6 @@ try {
231
237
  if (pending && typeof pending.version === 'string' && pending.version === installedVersion) {
232
238
  unlinkSync(pendingPath);
233
239
  try { unlinkSync(join(mofloDir(projectRoot), 'last-install-banner.json')); } catch { /* tracker may not exist */ }
234
- emitMutation('cleared post-install restart notice', `${installedVersion} now running`);
235
240
  }
236
241
  } catch { /* file missing or malformed — silent fast-path */ }
237
242
 
@@ -304,7 +309,7 @@ try {
304
309
  // Controlled by `auto_update.enabled` in moflo.yaml (default: true).
305
310
  // When moflo is upgraded (npm install), scripts and helpers may be stale.
306
311
  // Detect version change and sync from source before running hooks.
307
- let autoUpdateConfig = { enabled: true, scripts: true, helpers: true };
312
+ let autoUpdateConfig = { enabled: true, scripts: true, helpers: true, hookBlockDrift: 'warn' };
308
313
  try {
309
314
  const mofloYaml = resolve(projectRoot, 'moflo.yaml');
310
315
  if (existsSync(mofloYaml)) {
@@ -313,9 +318,12 @@ try {
313
318
  const enabledMatch = yamlContent.match(/auto_update:\s*\n\s+enabled:\s*(true|false)/);
314
319
  const scriptsMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+scripts:\s*(true|false)/);
315
320
  const helpersMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+helpers:\s*(true|false)/);
321
+ // #881: hook-block drift detector (warn | regenerate | off; default warn)
322
+ const driftMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+hook_block_drift:\s*(warn|regenerate|off)/);
316
323
  if (enabledMatch) autoUpdateConfig.enabled = enabledMatch[1] === 'true';
317
324
  if (scriptsMatch) autoUpdateConfig.scripts = scriptsMatch[1] === 'true';
318
325
  if (helpersMatch) autoUpdateConfig.helpers = helpersMatch[1] === 'true';
326
+ if (driftMatch) autoUpdateConfig.hookBlockDrift = driftMatch[1];
319
327
  }
320
328
  } catch (err) {
321
329
  // Defaults (all true) keep the upgrade flow alive but the user should
@@ -401,7 +409,7 @@ try {
401
409
  ];
402
410
  const cherryPickPath = cherryPickPaths.find((p) => existsSync(p));
403
411
  if (cherryPickPath) {
404
- const mod = await import(`file://${cherryPickPath.replace(/\\/g, '/')}`);
412
+ const mod = await import(pathToFileURL(cherryPickPath).href);
405
413
  if (typeof mod.cherryPickLearningsFromLegacy === 'function') {
406
414
  const result = await mod.cherryPickLearningsFromLegacy({ projectRoot });
407
415
  if (result.copied > 0) {
@@ -850,7 +858,7 @@ try {
850
858
  ];
851
859
  const hwPath = hwPaths.find(p => existsSync(p));
852
860
  if (hwPath) {
853
- const mod = await import(`file://${hwPath.replace(/\\/g, '/')}`);
861
+ const mod = await import(pathToFileURL(hwPath).href);
854
862
  if (typeof mod.rewriteIncorrectHookWiring === 'function') {
855
863
  const { rewrites } = mod.rewriteIncorrectHookWiring(settings);
856
864
  if (rewrites.length > 0) {
@@ -881,6 +889,158 @@ try {
881
889
  emitWarning(`settings.json migration failed (${errMessage(err)})`);
882
890
  }
883
891
 
892
+ // ── 3a-vi. Hook-block drift detection (#881) ───────────────────────────────
893
+ // Hash the consumer's settings.json hook block against the reference block
894
+ // `generateHooksConfig()` would produce for this moflo version. Catches
895
+ // drift the per-bug `repairHookWiring` / `rewriteIncorrectHookWiring` rules
896
+ // don't cover (future hook events, partial migrations, hand-edited commands).
897
+ // Runs every session under `auto_update.enabled`, not only on version change.
898
+ //
899
+ // Modes (`auto_update.hook_block_drift` in moflo.yaml):
900
+ // warn — print a one-line summary + diff to stdout (default)
901
+ // regenerate — additively add missing hooks; falls back to warn when the
902
+ // consumer has extra (custom) hooks, to avoid clobbering
903
+ // off — skip entirely
904
+ //
905
+ // Also respects a `claudeFlow.hooks.locked: true` sentinel in settings.json
906
+ // — if set, the user has explicitly opted out of drift surfacing.
907
+ // Fast-path: `.moflo/hook-drift-cache.json` records the last clean run. If
908
+ // settings.json + the dist module both still match the cached mtimes and the
909
+ // cached check was clean (consumerHash === referenceHash), skip readFile +
910
+ // JSON.parse + dynamic import entirely. This block runs every session; the
911
+ // cache makes it ~free in the steady state.
912
+ //
913
+ // Returns the values to persist on the slow path, or null when skipped
914
+ // (cache hit, no settings.json, no dist module, locked, etc.). Pulled out
915
+ // to keep the guard chain flat — the original inline form was 9 levels deep.
916
+ async function runHookBlockDriftCheck() {
917
+ const settingsPath = resolve(projectRoot, '.claude', 'settings.json');
918
+ let settingsStat;
919
+ try { settingsStat = statSync(settingsPath); } catch { return null; }
920
+
921
+ // statSync each candidate doubles as existence check + provides the mtime
922
+ // we need for the cache key, avoiding the existsSync→import TOCTOU pattern.
923
+ const hbhCandidates = [
924
+ resolve(projectRoot, 'node_modules/moflo/dist/src/cli/services/hook-block-hash.js'),
925
+ resolve(projectRoot, 'dist/src/cli/services/hook-block-hash.js'),
926
+ ];
927
+ let hbhPath = null;
928
+ let hbhStat = null;
929
+ for (const p of hbhCandidates) {
930
+ try { hbhStat = statSync(p); hbhPath = p; break; } catch { /* try next */ }
931
+ }
932
+ if (!hbhPath) return null;
933
+
934
+ // Fast-path requires consumerHash === referenceHash (a previously *clean*
935
+ // run). A drifted-but-cached state still needs to re-emit the warning each
936
+ // session, so we always re-do the work in that case.
937
+ const cachePath = join(mofloDir(projectRoot), 'hook-drift-cache.json');
938
+ let cached = null;
939
+ try { cached = JSON.parse(readFileSync(cachePath, 'utf-8')); } catch { /* missing or corrupt */ }
940
+ if (
941
+ cached &&
942
+ cached.settingsMtimeMs === settingsStat.mtimeMs &&
943
+ cached.moduleMtimeMs === hbhStat.mtimeMs &&
944
+ cached.consumerHash === cached.referenceHash
945
+ ) return null;
946
+
947
+ // Try-catch around the dynamic import handles the file disappearing
948
+ // between statSync and import (TOCTOU); module-load errors fall through.
949
+ let mod = null;
950
+ try { mod = await import(pathToFileURL(hbhPath).href); } catch { /* TOCTOU or load error — skip */ return null; }
951
+ if (typeof mod.computeHookBlockDrift !== 'function') return null;
952
+
953
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
954
+ if (typeof mod.isHookBlockLocked === 'function' && mod.isHookBlockLocked(settings)) return null;
955
+
956
+ const report = mod.computeHookBlockDrift(settings.hooks || {});
957
+ let regenerated = false;
958
+
959
+ if (report.drifted) {
960
+ const wantRegenerate = autoUpdateConfig.hookBlockDrift === 'regenerate';
961
+ // #896: regenerate is wholesale when available — the additive variant
962
+ // can't drop stale extras (e.g. the `gate.cjs session-reset` SessionStart
963
+ // hook removed in #842), which is the very case consumers hit. Older
964
+ // moflo installs without `applyWholesaleRegeneration` fall back to the
965
+ // additive path, which still heals purely-additive drift.
966
+ const wholesale = wantRegenerate && typeof mod.applyWholesaleRegeneration === 'function';
967
+ const additiveSafe = wantRegenerate && !wholesale &&
968
+ report.extra.length === 0 && typeof mod.applyAdditiveRegeneration === 'function';
969
+ if (wholesale) {
970
+ const { added, removed } = mod.applyWholesaleRegeneration(settings, report);
971
+ if (added > 0 || removed > 0) {
972
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
973
+ regenerated = true;
974
+ const parts = [];
975
+ if (added > 0) parts.push(`added ${plural(added, 'missing entry')}`);
976
+ if (removed > 0) parts.push(`removed ${plural(removed, 'stale entry')}`);
977
+ emitMutation(
978
+ 'regenerated hook block',
979
+ `${parts.join(', ')} (drift ${report.consumerHash} → ${report.referenceHash})`,
980
+ );
981
+ }
982
+ } else if (additiveSafe) {
983
+ const { added } = mod.applyAdditiveRegeneration(settings, report);
984
+ if (added > 0) {
985
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
986
+ regenerated = true;
987
+ emitMutation(
988
+ 'regenerated hook block',
989
+ `added ${plural(added, 'missing hook entry')} (drift ${report.consumerHash} → ${report.referenceHash})`,
990
+ );
991
+ }
992
+ } else {
993
+ const parts = [];
994
+ if (report.missing.length > 0) parts.push(plural(report.missing.length, 'missing entry'));
995
+ if (report.extra.length > 0) parts.push(`${plural(report.extra.length, 'custom hook')} preserved`);
996
+ const reason = parts.join(', ') || 'reordered';
997
+ // stdout (not stderr) so Claude sees this in `additionalContext` and
998
+ // surfaces it to the user — not a mutation since we didn't change anything.
999
+ try {
1000
+ process.stdout.write(
1001
+ `moflo: hook block drift (${reason}); run \`flo doctor hook-drift\` or set auto_update.hook_block_drift: regenerate in moflo.yaml\n`,
1002
+ );
1003
+ } catch { /* broken stdout — non-fatal */ }
1004
+ }
1005
+ }
1006
+
1007
+ // Regeneration mutated settings.json — re-stat for the fresh mtime so next
1008
+ // session's fast-path matches; otherwise reuse the stat we already have.
1009
+ let finalSettingsMtime = settingsStat.mtimeMs;
1010
+ if (regenerated) {
1011
+ try { finalSettingsMtime = statSync(settingsPath).mtimeMs; } catch { /* keep prior */ }
1012
+ }
1013
+ // After successful regeneration consumerHash matches referenceHash by construction.
1014
+ const finalConsumerHash = regenerated ? report.referenceHash : report.consumerHash;
1015
+
1016
+ return {
1017
+ cachePath,
1018
+ settingsMtimeMs: finalSettingsMtime,
1019
+ moduleMtimeMs: hbhStat.mtimeMs,
1020
+ consumerHash: finalConsumerHash,
1021
+ referenceHash: report.referenceHash,
1022
+ };
1023
+ }
1024
+
1025
+ try {
1026
+ if (autoUpdateConfig.enabled && autoUpdateConfig.hookBlockDrift !== 'off') {
1027
+ const result = await runHookBlockDriftCheck();
1028
+ if (result) {
1029
+ try {
1030
+ mkdirSync(mofloDir(projectRoot), { recursive: true });
1031
+ writeFileSync(result.cachePath, JSON.stringify({
1032
+ settingsMtimeMs: result.settingsMtimeMs,
1033
+ moduleMtimeMs: result.moduleMtimeMs,
1034
+ consumerHash: result.consumerHash,
1035
+ referenceHash: result.referenceHash,
1036
+ }));
1037
+ } catch { /* cache is opportunistic — non-fatal */ }
1038
+ }
1039
+ }
1040
+ } catch (err) {
1041
+ emitWarning(`hook-block drift check skipped (${errMessage(err)})`);
1042
+ }
1043
+
884
1044
  // ── 3b. Ensure shipped guidance files exist (even without version change) ──
885
1045
  // Subagents need these files on disk for direct reads without memory search.
886
1046
  // Also prunes top-level mirrors whose source no longer exists in shipped/
@@ -962,6 +1122,40 @@ try {
962
1122
  }
963
1123
  } catch { /* non-fatal */ }
964
1124
 
1125
+ // ── 3d-yaml-create. Create moflo.yaml if missing (#895) ────────────────────
1126
+ // Sibling self-heal to 3d-yaml: that block APPENDS new sections to existing
1127
+ // yaml; this block CREATES the file from the canonical template when no yaml
1128
+ // exists at all. Without it, consumers picking up new defaults via npm-install
1129
+ // (e.g. PR #894 flipped model_routing.enabled to true) have no surface to
1130
+ // see/tune them — the DEFAULT_CONFIG fallback is invisible.
1131
+ //
1132
+ // Runs BEFORE 3d-yaml so the appender has a file to work with on the same
1133
+ // session. Steady-state hot path: the existsSync check below short-circuits
1134
+ // before any module load, so consumers with an existing yaml pay one stat call.
1135
+ try {
1136
+ if (!existsSync(resolve(projectRoot, 'moflo.yaml'))) {
1137
+ const tplPaths = [
1138
+ resolve(projectRoot, 'node_modules/moflo/dist/src/cli/init/moflo-yaml-template.js'),
1139
+ resolve(projectRoot, 'dist/src/cli/init/moflo-yaml-template.js'),
1140
+ ];
1141
+ const tplPath = tplPaths.find((p) => existsSync(p));
1142
+ if (tplPath) {
1143
+ const { ensureMofloYamlExists } = await import(pathToFileURL(tplPath).href);
1144
+ const result = ensureMofloYamlExists(projectRoot);
1145
+ if (result?.created) {
1146
+ emitMutation(
1147
+ 'created moflo.yaml',
1148
+ 'review defaults — model routing, sandbox, gates, hooks',
1149
+ );
1150
+ }
1151
+ }
1152
+ }
1153
+ } catch (err) {
1154
+ // Non-fatal — DEFAULT_CONFIG fallback still gives correct behavior; user
1155
+ // just loses the visible/tunable surface until next session retries (#854).
1156
+ emitWarning(`moflo.yaml create skipped (${errMessage(err)})`);
1157
+ }
1158
+
965
1159
  // ── 3d-yaml. Append missing top-level sections to moflo.yaml ───────────────
966
1160
  // Users must never be required to re-run `moflo init` after a version bump.
967
1161
  // When moflo ships a new top-level config section (e.g. sandbox:), append it
@@ -976,7 +1170,7 @@ try {
976
1170
  const upgraderPath = upgraderPaths.find((p) => existsSync(p));
977
1171
  const mofloYaml = resolve(projectRoot, 'moflo.yaml');
978
1172
  if (upgraderPath && existsSync(mofloYaml)) {
979
- const { ensureYamlSections } = await import(`file://${upgraderPath.replace(/\\/g, '/')}`);
1173
+ const { ensureYamlSections } = await import(pathToFileURL(upgraderPath).href);
980
1174
  const appended = ensureYamlSections(mofloYaml);
981
1175
  if (Array.isArray(appended) && appended.length > 0) {
982
1176
  emitMutation(
@@ -995,7 +1189,7 @@ try {
995
1189
  const localShimLib = resolve(projectRoot, 'bin/lib/install-global-shim.mjs');
996
1190
  const shimPath = existsSync(shimLib) ? shimLib : existsSync(localShimLib) ? localShimLib : null;
997
1191
  if (shimPath) {
998
- const { installGlobalShim } = await import(`file://${shimPath.replace(/\\/g, '/')}`);
1192
+ const { installGlobalShim } = await import(pathToFileURL(shimPath).href);
999
1193
  const shimResult = installGlobalShim({ silent: true });
1000
1194
  if (shimResult?.installed) {
1001
1195
  emitMutation('installed global flo shim', 'bare `flo` now resolves to project install');
@@ -1022,7 +1216,7 @@ try {
1022
1216
  ];
1023
1217
  const migrationPath = migrationPaths.find((p) => existsSync(p));
1024
1218
  if (migrationPath) {
1025
- const mod = await import(`file://${migrationPath.replace(/\\/g, '/')}`);
1219
+ const mod = await import(pathToFileURL(migrationPath).href);
1026
1220
  if (typeof mod.runEmbeddingsMigrationIfNeeded === 'function') {
1027
1221
  await mod.runEmbeddingsMigrationIfNeeded({
1028
1222
  out: process.stderr,
@@ -1058,7 +1252,7 @@ try {
1058
1252
  ];
1059
1253
  const purgePath = purgePaths.find((p) => existsSync(p));
1060
1254
  if (purgePath) {
1061
- const { purgeSoftDeletedEntries } = await import(`file://${purgePath.replace(/\\/g, '/')}`);
1255
+ const { purgeSoftDeletedEntries } = await import(pathToFileURL(purgePath).href);
1062
1256
  const result = await purgeSoftDeletedEntries();
1063
1257
  if (result?.purged > 0) {
1064
1258
  emitMutation(
@@ -1090,7 +1284,7 @@ try {
1090
1284
  ];
1091
1285
  const purgePath = purgePaths.find((p) => existsSync(p));
1092
1286
  if (purgePath) {
1093
- const { purgeEphemeralNamespaces } = await import(`file://${purgePath.replace(/\\/g, '/')}`);
1287
+ const { purgeEphemeralNamespaces } = await import(pathToFileURL(purgePath).href);
1094
1288
  const result = await purgeEphemeralNamespaces();
1095
1289
  if (result?.purged > 0) {
1096
1290
  emitMutation(
@@ -552,4 +552,80 @@ export async function checkGateHealth() {
552
552
  message: `${caseCount} gate cases, ${hookCount} hook bindings, state file OK`,
553
553
  };
554
554
  }
555
+ /**
556
+ * Hash-based hook-block drift check (#881). Complements `checkGateHealth`'s
557
+ * required-pattern probe by detecting drift in *any* direction — missing
558
+ * events, modified commands, future hook events not yet covered by
559
+ * `REQUIRED_HOOK_WIRING`. Uses the self-contained `hook-block-hash` module so
560
+ * the same logic runs in `flo doctor`, the launcher, and unit tests.
561
+ *
562
+ * Reports `pass` when no drift, `warn` with a count summary when drift exists.
563
+ * Never `fail` — drift is informational; the user (or `regenerate` mode) is
564
+ * responsible for deciding what to do.
565
+ */
566
+ export async function checkHookBlockDrift() {
567
+ const projectDir = findConsumerProjectDir();
568
+ const settingsPath = join(projectDir, '.claude', 'settings.json');
569
+ if (!existsSync(settingsPath)) {
570
+ return {
571
+ name: 'Hook Block Drift',
572
+ status: 'warn',
573
+ message: '.claude/settings.json not found',
574
+ fix: 'npx moflo init',
575
+ };
576
+ }
577
+ let settings;
578
+ try {
579
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
580
+ }
581
+ catch (e) {
582
+ return {
583
+ name: 'Hook Block Drift',
584
+ status: 'warn',
585
+ message: `cannot parse .claude/settings.json: ${errorDetail(e)}`,
586
+ };
587
+ }
588
+ const { computeHookBlockDrift, isHookBlockLocked } = await import('../services/hook-block-hash.js');
589
+ if (isHookBlockLocked(settings)) {
590
+ return {
591
+ name: 'Hook Block Drift',
592
+ status: 'pass',
593
+ message: 'drift check skipped — claudeFlow.hooks.locked: true',
594
+ };
595
+ }
596
+ // #896: respect `auto_update.hook_block_drift: off` — opt-out for consumers
597
+ // who explicitly don't want drift surfaced (mirrors the launcher's behaviour).
598
+ try {
599
+ const { loadMofloConfig } = await import('../config/moflo-config.js');
600
+ const cfg = loadMofloConfig(projectDir);
601
+ if (cfg.auto_update.hook_block_drift === 'off') {
602
+ return {
603
+ name: 'Hook Block Drift',
604
+ status: 'pass',
605
+ message: 'drift check skipped — auto_update.hook_block_drift: off',
606
+ };
607
+ }
608
+ }
609
+ catch { /* config read failure — fall through to drift check */ }
610
+ const report = computeHookBlockDrift(settings.hooks ?? {});
611
+ if (!report.drifted) {
612
+ return {
613
+ name: 'Hook Block Drift',
614
+ status: 'pass',
615
+ message: `hook block matches reference (${report.consumerHash})`,
616
+ };
617
+ }
618
+ const parts = [];
619
+ parts.push(`drift ${report.consumerHash} vs ${report.referenceHash}`);
620
+ if (report.missing.length > 0)
621
+ parts.push(`${report.missing.length} missing`);
622
+ if (report.extra.length > 0)
623
+ parts.push(`${report.extra.length} custom`);
624
+ return {
625
+ name: 'Hook Block Drift',
626
+ status: 'warn',
627
+ message: parts.join(', '),
628
+ fix: 'set auto_update.hook_block_drift: regenerate in moflo.yaml, or claudeFlow.hooks.locked: true to suppress',
629
+ };
630
+ }
555
631
  //# sourceMappingURL=doctor-checks-deep.js.map
@@ -12,7 +12,7 @@ import { execSync, exec } from 'child_process';
12
12
  import { promisify } from 'util';
13
13
  import os from 'os';
14
14
  import { getDaemonLockHolder, releaseDaemonLock, isDaemonProcess } from '../services/daemon-lock.js';
15
- import { checkSubagentHealth, checkSpellExecution, checkMcpToolInvocation, checkHookExecution, checkMcpSpellIntegration, checkGateHealth, checkMofloDbBridge, getMofloRoot, } from './doctor-checks-deep.js';
15
+ import { checkSubagentHealth, checkSpellExecution, checkMcpToolInvocation, checkHookExecution, checkMcpSpellIntegration, checkGateHealth, checkHookBlockDrift, checkMofloDbBridge, getMofloRoot, } from './doctor-checks-deep.js';
16
16
  import { checkEmbeddingHygiene } from './doctor-embedding-hygiene.js';
17
17
  import { checkSwarmFunctional, checkHiveMindFunctional, } from './doctor-checks-swarm.js';
18
18
  import { checkMemoryAccessFunctional } from './doctor-checks-memory-access.js';
@@ -773,6 +773,52 @@ async function fixGateHealthHooks() {
773
773
  return false;
774
774
  }
775
775
  }
776
+ // Check moflo.yaml exists and contains all required top-level sections (#895).
777
+ // Catches three failure modes:
778
+ // 1. File missing — session-start should have created it; warn user that
779
+ // defaults are invisible/untunable.
780
+ // 2. File empty / unreadable — corrupted by half-write or filesystem error.
781
+ // 3. Top-level sections missing — partial yaml from manual edit or stale
782
+ // copy from a moflo version that didn't ship a section yet. The
783
+ // session-start yaml-upgrader would normally backfill these, but the
784
+ // diagnostic surfaces it for users who never restarted.
785
+ //
786
+ // Exported so tests can exercise it end-to-end against a temp project root
787
+ // without mutating process.cwd() (which fights vitest's parallel test runner).
788
+ export async function checkMofloYamlCompliance(cwd = process.cwd()) {
789
+ const yamlPath = join(cwd, 'moflo.yaml');
790
+ // Lazy-import the validator so doctor doesn't pull in fs walks on the
791
+ // happy path of unrelated checks.
792
+ const { validateMofloYaml } = await import('../init/moflo-yaml-template.js');
793
+ const result = validateMofloYaml(yamlPath);
794
+ if (!result.exists) {
795
+ return {
796
+ name: 'moflo.yaml',
797
+ status: 'warn',
798
+ message: 'moflo.yaml not found — defaults are in effect but not visible/tunable',
799
+ fix: 'Restart Claude Code (session-start auto-creates) or run `npx moflo init`',
800
+ };
801
+ }
802
+ if (result.valid) {
803
+ return { name: 'moflo.yaml', status: 'pass', message: `Compliant (${yamlPath})` };
804
+ }
805
+ const parseIssue = result.issues.find((i) => i.kind !== 'missing-section');
806
+ if (parseIssue) {
807
+ return {
808
+ name: 'moflo.yaml',
809
+ status: 'fail',
810
+ message: `${parseIssue.kind}: ${parseIssue.detail}`,
811
+ fix: 'Inspect/repair moflo.yaml, or `mv moflo.yaml moflo.yaml.bak && npx moflo init`',
812
+ };
813
+ }
814
+ // Missing sections — recoverable on next session-start via yaml-upgrader.
815
+ return {
816
+ name: 'moflo.yaml',
817
+ status: 'warn',
818
+ message: `Missing sections: ${result.missingSections.join(', ')}`,
819
+ fix: 'Restart Claude Code (yaml-upgrader auto-appends) or `npx moflo init --force`',
820
+ };
821
+ }
776
822
  // Check test directories configured in moflo.yaml
777
823
  async function checkTestDirs() {
778
824
  const yamlPath = join(process.cwd(), 'moflo.yaml');
@@ -1529,6 +1575,7 @@ export const doctorCommand = {
1529
1575
  checkGit,
1530
1576
  checkGitRepo,
1531
1577
  checkConfigFile,
1578
+ checkMofloYamlCompliance,
1532
1579
  checkStatusLine,
1533
1580
  checkDaemonStatus,
1534
1581
  checkMemoryDatabase,
@@ -1548,6 +1595,7 @@ export const doctorCommand = {
1548
1595
  checkMcpSpellIntegration,
1549
1596
  checkHookExecution,
1550
1597
  checkGateHealth,
1598
+ checkHookBlockDrift,
1551
1599
  checkMofloDbBridge,
1552
1600
  // Issue #818 / epic #798 — coordinator-path tripwires. They share the
1553
1601
  // singleton coordinator with checkSubagentHealth above and assert by
@@ -1569,6 +1617,8 @@ export const doctorCommand = {
1569
1617
  'npm': checkNpmVersion,
1570
1618
  'claude': checkClaudeCode,
1571
1619
  'config': checkConfigFile,
1620
+ 'yaml': checkMofloYamlCompliance,
1621
+ 'moflo-yaml': checkMofloYamlCompliance,
1572
1622
  'statusline': checkStatusLine,
1573
1623
  'status-line': checkStatusLine,
1574
1624
  'daemon': checkDaemonStatus,
@@ -1595,6 +1645,8 @@ export const doctorCommand = {
1595
1645
  'hooks': checkHookExecution,
1596
1646
  'gates': checkGateHealth,
1597
1647
  'gate': checkGateHealth,
1648
+ 'hook-drift': checkHookBlockDrift,
1649
+ 'drift': checkHookBlockDrift,
1598
1650
  'sandbox': checkSandboxTier,
1599
1651
  'sandbox-tier': checkSandboxTier,
1600
1652
  'moflodb': checkMofloDbBridge,