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/.claude/commands/simplify.md +78 -30
- package/.claude/skills/eldar/SKILL.md +305 -0
- package/.claude/skills/simplify/SKILL.md +90 -21
- package/README.md +25 -0
- package/bin/hooks.mjs +2 -2
- package/bin/index-guidance.mjs +14 -24
- package/bin/index-patterns.mjs +13 -10
- package/bin/session-start-launcher.mjs +205 -11
- package/dist/src/cli/commands/doctor-checks-deep.js +76 -0
- package/dist/src/cli/commands/doctor.js +53 -1
- package/dist/src/cli/config/moflo-config.js +14 -3
- package/dist/src/cli/init/moflo-init.js +20 -266
- package/dist/src/cli/init/moflo-yaml-template.js +370 -0
- package/dist/src/cli/mcp-tools/hooks-tools.js +3 -1
- package/dist/src/cli/movector/model-router.js +66 -20
- package/dist/src/cli/services/hook-block-hash.js +341 -0
- package/dist/src/cli/services/index.js +2 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
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(
|
|
523
|
+
const mod = await import(pathToFileURL(daemonLockPath).href);
|
|
524
524
|
_getDaemonLockHolder = mod.getDaemonLockHolder;
|
|
525
525
|
}
|
|
526
526
|
} catch { /* fallback below */ }
|
package/bin/index-guidance.mjs
CHANGED
|
@@ -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
|
-
|
|
883
|
-
|
|
884
|
-
//
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
}
|
package/bin/index-patterns.mjs
CHANGED
|
@@ -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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|