moflo 4.9.12 → 4.9.14

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 (33) hide show
  1. package/.claude/helpers/gate.cjs +21 -5
  2. package/.claude/skills/eldar/SKILL.md +305 -0
  3. package/.claude/skills/fl/phases.md +18 -2
  4. package/.claude/skills/simplify/SKILL.md +35 -48
  5. package/README.md +25 -0
  6. package/bin/gate.cjs +21 -5
  7. package/bin/hooks.mjs +2 -2
  8. package/bin/index-guidance.mjs +14 -24
  9. package/bin/index-patterns.mjs +13 -10
  10. package/bin/session-start-launcher.mjs +64 -10
  11. package/bin/simplify-classify.cjs +211 -0
  12. package/dist/src/cli/commands/doctor-checks-config.js +246 -0
  13. package/dist/src/cli/commands/doctor-checks-deep.js +14 -0
  14. package/dist/src/cli/commands/doctor-checks-intelligence.js +197 -0
  15. package/dist/src/cli/commands/doctor-checks-memory.js +207 -0
  16. package/dist/src/cli/commands/doctor-checks-platform.js +138 -0
  17. package/dist/src/cli/commands/doctor-checks-runtime.js +170 -0
  18. package/dist/src/cli/commands/doctor-fixes.js +165 -0
  19. package/dist/src/cli/commands/doctor-registry.js +109 -0
  20. package/dist/src/cli/commands/doctor-render.js +203 -0
  21. package/dist/src/cli/commands/doctor-types.js +9 -0
  22. package/dist/src/cli/commands/doctor-version.js +134 -0
  23. package/dist/src/cli/commands/doctor-zombies.js +201 -0
  24. package/dist/src/cli/commands/doctor.js +35 -1657
  25. package/dist/src/cli/init/helpers-generator.js +21 -5
  26. package/dist/src/cli/init/moflo-init.js +20 -268
  27. package/dist/src/cli/init/moflo-yaml-template.js +370 -0
  28. package/dist/src/cli/mcp-tools/hooks-tools.js +3 -1
  29. package/dist/src/cli/movector/model-router.js +66 -20
  30. package/dist/src/cli/services/hook-block-hash.js +23 -2
  31. package/dist/src/cli/version.js +1 -1
  32. package/package.json +2 -2
  33. package/scripts/post-install-bootstrap.mjs +1 -0
@@ -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
 
@@ -409,7 +409,7 @@ try {
409
409
  ];
410
410
  const cherryPickPath = cherryPickPaths.find((p) => existsSync(p));
411
411
  if (cherryPickPath) {
412
- const mod = await import(`file://${cherryPickPath.replace(/\\/g, '/')}`);
412
+ const mod = await import(pathToFileURL(cherryPickPath).href);
413
413
  if (typeof mod.cherryPickLearningsFromLegacy === 'function') {
414
414
  const result = await mod.cherryPickLearningsFromLegacy({ projectRoot });
415
415
  if (result.copied > 0) {
@@ -595,7 +595,7 @@ try {
595
595
 
596
596
  // Gate and hook helpers — shipped as static files in bin/
597
597
  const binHelperFiles = [
598
- 'gate.cjs', 'gate-hook.mjs', 'prompt-hook.mjs', 'hook-handler.cjs',
598
+ 'gate.cjs', 'gate-hook.mjs', 'prompt-hook.mjs', 'hook-handler.cjs', 'simplify-classify.cjs',
599
599
  ];
600
600
  for (const file of binHelperFiles) {
601
601
  await syncFile(resolve(binDir, file), resolve(helpersDir, file), `.claude/helpers/${file}`);
@@ -858,7 +858,7 @@ try {
858
858
  ];
859
859
  const hwPath = hwPaths.find(p => existsSync(p));
860
860
  if (hwPath) {
861
- const mod = await import(`file://${hwPath.replace(/\\/g, '/')}`);
861
+ const mod = await import(pathToFileURL(hwPath).href);
862
862
  if (typeof mod.rewriteIncorrectHookWiring === 'function') {
863
863
  const { rewrites } = mod.rewriteIncorrectHookWiring(settings);
864
864
  if (rewrites.length > 0) {
@@ -958,8 +958,28 @@ async function runHookBlockDriftCheck() {
958
958
 
959
959
  if (report.drifted) {
960
960
  const wantRegenerate = autoUpdateConfig.hookBlockDrift === 'regenerate';
961
- const safeToRegenerate = wantRegenerate && report.extra.length === 0;
962
- if (safeToRegenerate && typeof mod.applyAdditiveRegeneration === 'function') {
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) {
963
983
  const { added } = mod.applyAdditiveRegeneration(settings, report);
964
984
  if (added > 0) {
965
985
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
@@ -1102,6 +1122,40 @@ try {
1102
1122
  }
1103
1123
  } catch { /* non-fatal */ }
1104
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
+
1105
1159
  // ── 3d-yaml. Append missing top-level sections to moflo.yaml ───────────────
1106
1160
  // Users must never be required to re-run `moflo init` after a version bump.
1107
1161
  // When moflo ships a new top-level config section (e.g. sandbox:), append it
@@ -1116,7 +1170,7 @@ try {
1116
1170
  const upgraderPath = upgraderPaths.find((p) => existsSync(p));
1117
1171
  const mofloYaml = resolve(projectRoot, 'moflo.yaml');
1118
1172
  if (upgraderPath && existsSync(mofloYaml)) {
1119
- const { ensureYamlSections } = await import(`file://${upgraderPath.replace(/\\/g, '/')}`);
1173
+ const { ensureYamlSections } = await import(pathToFileURL(upgraderPath).href);
1120
1174
  const appended = ensureYamlSections(mofloYaml);
1121
1175
  if (Array.isArray(appended) && appended.length > 0) {
1122
1176
  emitMutation(
@@ -1135,7 +1189,7 @@ try {
1135
1189
  const localShimLib = resolve(projectRoot, 'bin/lib/install-global-shim.mjs');
1136
1190
  const shimPath = existsSync(shimLib) ? shimLib : existsSync(localShimLib) ? localShimLib : null;
1137
1191
  if (shimPath) {
1138
- const { installGlobalShim } = await import(`file://${shimPath.replace(/\\/g, '/')}`);
1192
+ const { installGlobalShim } = await import(pathToFileURL(shimPath).href);
1139
1193
  const shimResult = installGlobalShim({ silent: true });
1140
1194
  if (shimResult?.installed) {
1141
1195
  emitMutation('installed global flo shim', 'bare `flo` now resolves to project install');
@@ -1162,7 +1216,7 @@ try {
1162
1216
  ];
1163
1217
  const migrationPath = migrationPaths.find((p) => existsSync(p));
1164
1218
  if (migrationPath) {
1165
- const mod = await import(`file://${migrationPath.replace(/\\/g, '/')}`);
1219
+ const mod = await import(pathToFileURL(migrationPath).href);
1166
1220
  if (typeof mod.runEmbeddingsMigrationIfNeeded === 'function') {
1167
1221
  await mod.runEmbeddingsMigrationIfNeeded({
1168
1222
  out: process.stderr,
@@ -1198,7 +1252,7 @@ try {
1198
1252
  ];
1199
1253
  const purgePath = purgePaths.find((p) => existsSync(p));
1200
1254
  if (purgePath) {
1201
- const { purgeSoftDeletedEntries } = await import(`file://${purgePath.replace(/\\/g, '/')}`);
1255
+ const { purgeSoftDeletedEntries } = await import(pathToFileURL(purgePath).href);
1202
1256
  const result = await purgeSoftDeletedEntries();
1203
1257
  if (result?.purged > 0) {
1204
1258
  emitMutation(
@@ -1230,7 +1284,7 @@ try {
1230
1284
  ];
1231
1285
  const purgePath = purgePaths.find((p) => existsSync(p));
1232
1286
  if (purgePath) {
1233
- const { purgeEphemeralNamespaces } = await import(`file://${purgePath.replace(/\\/g, '/')}`);
1287
+ const { purgeEphemeralNamespaces } = await import(pathToFileURL(purgePath).href);
1234
1288
  const result = await purgeEphemeralNamespaces();
1235
1289
  if (result?.purged > 0) {
1236
1290
  emitMutation(
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * /simplify diff classifier — issue #908.
4
+ *
5
+ * Decides which review tier the current diff warrants and returns a JSON
6
+ * dispatch decision. The /simplify skill MUST call this first so routing is
7
+ * deterministic and unit-testable instead of a prose decision Claude makes
8
+ * over and over per run.
9
+ *
10
+ * Rule (per user direction): default to single-agent Sonnet review. Only
11
+ * escalate to a 3-agent fan-out when diff signals genuinely warrant it.
12
+ * Opus is never selected — the existing skill already documents that.
13
+ *
14
+ * Outputs JSON:
15
+ * {
16
+ * "tier": "TRIVIAL" | "SMALL" | "NORMAL",
17
+ * "model": "sonnet",
18
+ * "agentCount": 0 | 1 | 3,
19
+ * "reasoning": [string, ...],
20
+ * "stats": { added, deleted, fileCount, declAdded, declRemoved, ... }
21
+ * }
22
+ *
23
+ * Usage:
24
+ * node bin/simplify-classify.cjs [--base main]
25
+ * node bin/simplify-classify.cjs --diff <unified-diff-on-stdin>
26
+ *
27
+ * The --diff stdin form exists so unit tests can drive the classifier
28
+ * with synthetic diffs (no git repo required).
29
+ */
30
+ 'use strict';
31
+
32
+ const { execSync } = require('child_process');
33
+
34
+ // Paths where new logic warrants the 3-agent fan-out (issue #908).
35
+ // Mechanical edits inside these paths are still SMALL; only adding/removing
36
+ // declarations triggers escalation.
37
+ const SECURITY_PATHS = [
38
+ /(?:^|[\\\/])aidefence[\\\/]/i,
39
+ /(?:^|[\\\/])swarm[\\\/]consensus[\\\/]/i,
40
+ /(?:^|[\\\/])hooks?[\\\/](?:handlers?|gate|wiring)/i,
41
+ /(?:^|[\\\/])services[\\\/]daemon-lock\.ts$/i,
42
+ /(?:^|[\\\/])bin[\\\/]gate\./i,
43
+ /(?:^|[\\\/])bin[\\\/]session-start-launcher\./i,
44
+ /(?:^|[\\\/])\.claude[\\\/]helpers[\\\/]gate/i,
45
+ ];
46
+
47
+ function safeExec(cmd) {
48
+ try { return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); }
49
+ catch { return ''; }
50
+ }
51
+
52
+ function readDiffFromGit(base) {
53
+ // Combined diff: committed-since-base + working-tree
54
+ const committed = safeExec(`git diff ${base}...HEAD`);
55
+ const working = safeExec('git diff HEAD');
56
+ return committed + (working ? '\n' + working : '');
57
+ }
58
+
59
+ /**
60
+ * Parse a unified-diff string into per-file stats and aggregate signals.
61
+ * No git/I/O — pure function over the diff text. Test-friendly.
62
+ */
63
+ function parseDiff(diff) {
64
+ const lines = diff.split('\n');
65
+ const files = new Map(); // filename → { added, deleted, declAdded, declRemoved, isNew, isRenamed }
66
+ let current = null;
67
+
68
+ // Match function/class/export-const-arrow/method declarations being
69
+ // added or removed. Conservative — biased toward false negatives so we
70
+ // don't over-escalate.
71
+ const DECL_RE = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type)\s+\w/;
72
+ const ARROW_DECL_RE = /^(?:export\s+)?(?:const|let|var)\s+\w+\s*[:=].*=>\s*\{?$/;
73
+
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const ln = lines[i];
76
+
77
+ // File header: `diff --git a/path b/path`
78
+ let m = ln.match(/^diff --git (?:a\/)?(.+?) (?:b\/)?(.+)$/);
79
+ if (m) {
80
+ const filename = m[2];
81
+ current = { filename, added: 0, deleted: 0, declAdded: 0, declRemoved: 0, isNew: false, isRenamed: false };
82
+ files.set(filename, current);
83
+ continue;
84
+ }
85
+ if (!current) continue;
86
+
87
+ if (ln.startsWith('new file mode')) current.isNew = true;
88
+ if (ln.startsWith('rename from') || ln.startsWith('rename to') || ln.startsWith('similarity index')) current.isRenamed = true;
89
+
90
+ // Skip diff headers
91
+ if (ln.startsWith('+++') || ln.startsWith('---') || ln.startsWith('@@') || ln.startsWith('index ')) continue;
92
+
93
+ if (ln.startsWith('+') && !ln.startsWith('+++')) {
94
+ current.added++;
95
+ const body = ln.slice(1).trim();
96
+ if (DECL_RE.test(body) || ARROW_DECL_RE.test(body)) current.declAdded++;
97
+ } else if (ln.startsWith('-') && !ln.startsWith('---')) {
98
+ current.deleted++;
99
+ const body = ln.slice(1).trim();
100
+ if (DECL_RE.test(body) || ARROW_DECL_RE.test(body)) current.declRemoved++;
101
+ }
102
+ }
103
+
104
+ // Aggregate
105
+ let added = 0, deleted = 0, declAdded = 0, declRemoved = 0;
106
+ let newFiles = 0, renamedFiles = 0;
107
+ let securityHit = false;
108
+ for (const f of files.values()) {
109
+ added += f.added;
110
+ deleted += f.deleted;
111
+ declAdded += f.declAdded;
112
+ declRemoved += f.declRemoved;
113
+ if (f.isNew) newFiles++;
114
+ if (f.isRenamed) renamedFiles++;
115
+ if (SECURITY_PATHS.some(rx => rx.test(f.filename))) securityHit = true;
116
+ }
117
+
118
+ return {
119
+ added, deleted, declAdded, declRemoved,
120
+ netDecls: declAdded - declRemoved,
121
+ fileCount: files.size,
122
+ newFiles, renamedFiles,
123
+ securityHit,
124
+ files: [...files.keys()],
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Pure decision function. Takes parsed stats, returns dispatch decision.
130
+ * No I/O. Easy to unit-test with synthetic stats.
131
+ */
132
+ function decide(stats) {
133
+ const reasoning = [];
134
+ const totalChange = stats.added + stats.deleted;
135
+
136
+ if (totalChange === 0) {
137
+ return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, reasoning: ['empty diff — nothing to review'], stats };
138
+ }
139
+
140
+ // TRIVIAL: tiny diff, no declarations changed
141
+ if (totalChange <= 10 && stats.fileCount <= 1 && stats.netDecls === 0 && stats.declAdded === 0 && stats.declRemoved === 0) {
142
+ reasoning.push(`≤10 LOC in 1 file with no declaration changes`);
143
+ return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, reasoning, stats };
144
+ }
145
+
146
+ // Mechanical relocation detection — the #906 case.
147
+ // If declarations were both ADDED and REMOVED at roughly matching rates,
148
+ // it's a structural move, not net-new logic. Judge by declaration balance,
149
+ // not raw LOC balance — formatting/blank-line differences between source
150
+ // and destination files easily push raw LOC out of balance even when the
151
+ // semantic change is purely "moved 5 functions across 5 new files".
152
+ // Mechanical relocations are SMALL even when many files / many lines.
153
+ const declTouched = stats.declAdded + stats.declRemoved;
154
+ const isMostlyRelocation = stats.declAdded >= 2
155
+ && stats.declRemoved >= 2
156
+ && Math.abs(stats.netDecls) <= Math.max(2, Math.floor(declTouched * 0.30));
157
+
158
+ if (isMostlyRelocation) {
159
+ reasoning.push(
160
+ `mostly relocation: ${stats.declAdded} decls added, ${stats.declRemoved} removed, net ${stats.netDecls >= 0 ? '+' : ''}${stats.netDecls}`,
161
+ );
162
+ return { tier: 'SMALL', model: 'sonnet', agentCount: 1, reasoning, stats };
163
+ }
164
+
165
+ // Escalation triggers — any one trips NORMAL (3 agents).
166
+ // Always Sonnet — Opus is never the right model for /simplify per skill rule.
167
+ const triggers = [];
168
+ if (totalChange > 500) triggers.push(`>500 LOC changed (${totalChange})`);
169
+ if (stats.fileCount >= 5 && stats.netDecls >= 3) triggers.push(`${stats.fileCount} files with ${stats.netDecls} net new declarations`);
170
+ if (stats.securityHit && stats.netDecls > 0) triggers.push('security-sensitive path with new logic');
171
+ if (stats.newFiles >= 3 && stats.declAdded >= 5) triggers.push(`${stats.newFiles} new files with ${stats.declAdded} new declarations`);
172
+
173
+ if (triggers.length > 0) {
174
+ return { tier: 'NORMAL', model: 'sonnet', agentCount: 3, reasoning: triggers, stats };
175
+ }
176
+
177
+ // Default: SMALL — single sonnet agent
178
+ reasoning.push(`small/medium diff: ${totalChange} LOC across ${stats.fileCount} file(s), +${stats.declAdded}/-${stats.declRemoved} decls`);
179
+ return { tier: 'SMALL', model: 'sonnet', agentCount: 1, reasoning, stats };
180
+ }
181
+
182
+ function classifyDiff(diffText) {
183
+ return decide(parseDiff(diffText));
184
+ }
185
+
186
+ function classifyFromGit(base = 'main') {
187
+ return classifyDiff(readDiffFromGit(base));
188
+ }
189
+
190
+ if (require.main === module) {
191
+ const args = process.argv.slice(2);
192
+ const baseIdx = args.indexOf('--base');
193
+ const base = baseIdx >= 0 ? args[baseIdx + 1] : 'main';
194
+ const stdinDiff = args.includes('--diff') || args.includes('--stdin');
195
+
196
+ let result;
197
+ if (stdinDiff) {
198
+ let buf = '';
199
+ process.stdin.setEncoding('utf-8');
200
+ process.stdin.on('data', (d) => { buf += d; });
201
+ process.stdin.on('end', () => {
202
+ result = classifyDiff(buf);
203
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
204
+ });
205
+ } else {
206
+ result = classifyFromGit(base);
207
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
208
+ }
209
+ }
210
+
211
+ module.exports = { parseDiff, decide, classifyDiff, classifyFromGit };
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Configuration & service-discovery checks for `flo doctor`:
3
+ * config files, statusLine, daemon, MCP servers, moflo.yaml compliance,
4
+ * test directories.
5
+ */
6
+ import { existsSync, readFileSync, statSync } from 'fs';
7
+ import { join } from 'path';
8
+ import os from 'os';
9
+ import { getDaemonLockHolder } from '../services/daemon-lock.js';
10
+ import { legacyMemoryDbPath, memoryDbCandidatePaths, memoryDbPath, } from '../services/moflo-paths.js';
11
+ import { errorDetail } from '../shared/utils/error-detail.js';
12
+ export async function checkConfigFile() {
13
+ // JSON configs (parse-validated). LEGACY-CONFIG: `.claude-flow.json` and
14
+ // `claude-flow.config.json` filenames are still recognised so consumers
15
+ // upgrading from pre-#699 moflo builds (upstream Ruflo) keep working
16
+ // without manual rename. Drift guard exempts these via LEGACY-CONFIG marker.
17
+ const jsonPaths = [
18
+ '.moflo/config.json',
19
+ 'moflo.config.json',
20
+ 'claude-flow.config.json', // LEGACY-CONFIG: pre-#699 fallback
21
+ '.claude-flow.json', // LEGACY-CONFIG: pre-#699 fallback
22
+ ];
23
+ for (const configPath of jsonPaths) {
24
+ if (existsSync(configPath)) {
25
+ try {
26
+ const content = readFileSync(configPath, 'utf8');
27
+ JSON.parse(content);
28
+ return { name: 'Config File', status: 'pass', message: `Found: ${configPath}` };
29
+ }
30
+ catch {
31
+ return { name: 'Config File', status: 'fail', message: `Invalid JSON: ${configPath}`, fix: 'Fix JSON syntax in config file' };
32
+ }
33
+ }
34
+ }
35
+ // YAML configs (existence-checked only — no heavy yaml parser dependency).
36
+ const yamlPaths = [
37
+ '.moflo/config.yaml',
38
+ '.moflo/config.yml',
39
+ 'moflo.config.yaml',
40
+ 'claude-flow.config.yaml', // LEGACY-CONFIG: pre-#699 fallback
41
+ ];
42
+ for (const configPath of yamlPaths) {
43
+ if (existsSync(configPath)) {
44
+ return { name: 'Config File', status: 'pass', message: `Found: ${configPath}` };
45
+ }
46
+ }
47
+ return { name: 'Config File', status: 'warn', message: 'No config file (using defaults)', fix: 'claude-flow config init' };
48
+ }
49
+ export async function checkStatusLine() {
50
+ const settingsPath = join(process.cwd(), '.claude', 'settings.json');
51
+ if (!existsSync(settingsPath)) {
52
+ return { name: 'Status Line', status: 'warn', message: 'No .claude/settings.json found', fix: 'npx moflo init' };
53
+ }
54
+ try {
55
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
56
+ if (settings.statusLine && settings.statusLine.command) {
57
+ if (settings.statusLine.command.includes('statusline.cjs')) {
58
+ return { name: 'Status Line', status: 'pass', message: 'Wired in settings.json' };
59
+ }
60
+ return { name: 'Status Line', status: 'pass', message: 'Custom statusLine configured' };
61
+ }
62
+ return { name: 'Status Line', status: 'fail', message: 'statusLine not configured in settings.json', fix: 'Add statusLine config to .claude/settings.json' };
63
+ }
64
+ catch {
65
+ return { name: 'Status Line', status: 'fail', message: 'Failed to parse .claude/settings.json', fix: 'Fix JSON syntax in .claude/settings.json' };
66
+ }
67
+ }
68
+ // Delegates to daemon-lock module for proper PID + command-line verification
69
+ // (avoids Windows PID-recycling false positives).
70
+ export async function checkDaemonStatus() {
71
+ try {
72
+ // Retry up to 5 times with 1s delay — the daemon starts in the background
73
+ // during session-start and may not have acquired its lock file yet.
74
+ const MAX_RETRIES = 5;
75
+ const RETRY_DELAY_MS = 1000;
76
+ let holderPid = null;
77
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
78
+ holderPid = getDaemonLockHolder(process.cwd());
79
+ if (holderPid)
80
+ break;
81
+ if (attempt < MAX_RETRIES - 1) {
82
+ await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
83
+ }
84
+ }
85
+ if (holderPid) {
86
+ return { name: 'Daemon Status', status: 'pass', message: `Running (PID: ${holderPid})` };
87
+ }
88
+ // getDaemonLockHolder auto-cleans stale locks, but check for legacy PID file
89
+ const lockFile = '.moflo/daemon.lock';
90
+ if (existsSync(lockFile)) {
91
+ return { name: 'Daemon Status', status: 'warn', message: 'Stale lock file', fix: 'rm .moflo/daemon.lock && claude-flow daemon start' };
92
+ }
93
+ const pidFile = '.moflo/daemon.pid';
94
+ if (existsSync(pidFile)) {
95
+ return { name: 'Daemon Status', status: 'warn', message: 'Legacy PID file found', fix: 'rm .moflo/daemon.pid && claude-flow daemon start' };
96
+ }
97
+ return { name: 'Daemon Status', status: 'warn', message: 'Not running', fix: 'claude-flow daemon start' };
98
+ }
99
+ catch (e) {
100
+ return { name: 'Daemon Status', status: 'warn', message: `Unable to check: ${errorDetail(e)}`, fix: 'claude-flow daemon status' };
101
+ }
102
+ }
103
+ export async function checkMemoryDatabase() {
104
+ const root = process.cwd();
105
+ const canonical = memoryDbPath(root);
106
+ for (const dbPath of memoryDbCandidatePaths(root)) {
107
+ let stats;
108
+ try {
109
+ stats = statSync(dbPath);
110
+ }
111
+ catch {
112
+ continue;
113
+ }
114
+ const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
115
+ if (dbPath === canonical) {
116
+ let message = `.moflo/moflo.db (${sizeMB} MB)`;
117
+ // Unfinished migration tail: source still present means the launcher's
118
+ // rename-to-.bak step failed (Windows lock most often). Flag so the user
119
+ // knows to clear the stale source.
120
+ if (existsSync(legacyMemoryDbPath(root))) {
121
+ message += ' — legacy .swarm/memory.db still present (delete it after confirming canonical is healthy)';
122
+ }
123
+ return { name: 'Memory Database', status: 'pass', message };
124
+ }
125
+ return {
126
+ name: 'Memory Database',
127
+ status: 'warn',
128
+ message: `${dbPath} (${sizeMB} MB) — legacy location, will migrate to .moflo/moflo.db on next session start`,
129
+ fix: 'restart claude code session',
130
+ };
131
+ }
132
+ return { name: 'Memory Database', status: 'warn', message: 'Not initialized', fix: 'claude-flow memory configure --backend hybrid' };
133
+ }
134
+ export async function checkMcpServers() {
135
+ const mcpConfigPaths = [
136
+ join(os.homedir(), '.claude/claude_desktop_config.json'),
137
+ join(os.homedir(), '.config/claude/mcp.json'),
138
+ '.mcp.json',
139
+ // Windows: Claude Desktop stores config under %APPDATA%\Claude\
140
+ ...(process.platform === 'win32' && process.env.APPDATA
141
+ ? [join(process.env.APPDATA, 'Claude', 'claude_desktop_config.json')]
142
+ : []),
143
+ ];
144
+ for (const configPath of mcpConfigPaths) {
145
+ if (existsSync(configPath)) {
146
+ try {
147
+ const content = JSON.parse(readFileSync(configPath, 'utf8'));
148
+ const servers = content.mcpServers || content.servers || {};
149
+ const count = Object.keys(servers).length;
150
+ const hasClaudeFlow = 'moflo' in servers || 'claude-flow' in servers || 'claude-flow_alpha' in servers || 'ruflo' in servers || 'ruflo_alpha' in servers;
151
+ if (hasClaudeFlow) {
152
+ return { name: 'MCP Servers', status: 'pass', message: `${count} servers (flo configured)` };
153
+ }
154
+ return { name: 'MCP Servers', status: 'warn', message: `${count} servers (flo not found)`, fix: 'claude mcp add ruflo -- npx -y ruflo@latest mcp start' };
155
+ }
156
+ catch {
157
+ // continue to next path
158
+ }
159
+ }
160
+ }
161
+ return { name: 'MCP Servers', status: 'warn', message: 'No MCP config found', fix: 'claude mcp add moflo npx moflo mcp start' };
162
+ }
163
+ // Catches three failure modes (#895):
164
+ // 1. File missing — session-start should have created it; warn user that
165
+ // defaults are invisible/untunable.
166
+ // 2. File empty / unreadable — corrupted by half-write or filesystem error.
167
+ // 3. Top-level sections missing — partial yaml from manual edit or stale
168
+ // copy from a moflo version that didn't ship a section yet.
169
+ //
170
+ // Exported so tests can exercise it end-to-end against a temp project root
171
+ // without mutating process.cwd() (which fights vitest's parallel test runner).
172
+ export async function checkMofloYamlCompliance(cwd = process.cwd()) {
173
+ const yamlPath = join(cwd, 'moflo.yaml');
174
+ // Lazy-import the validator so doctor doesn't pull in fs walks on the
175
+ // happy path of unrelated checks.
176
+ const { validateMofloYaml } = await import('../init/moflo-yaml-template.js');
177
+ const result = validateMofloYaml(yamlPath);
178
+ if (!result.exists) {
179
+ return {
180
+ name: 'moflo.yaml',
181
+ status: 'warn',
182
+ message: 'moflo.yaml not found — defaults are in effect but not visible/tunable',
183
+ fix: 'Restart Claude Code (session-start auto-creates) or run `npx moflo init`',
184
+ };
185
+ }
186
+ if (result.valid) {
187
+ return { name: 'moflo.yaml', status: 'pass', message: `Compliant (${yamlPath})` };
188
+ }
189
+ const parseIssue = result.issues.find((i) => i.kind !== 'missing-section');
190
+ if (parseIssue) {
191
+ return {
192
+ name: 'moflo.yaml',
193
+ status: 'fail',
194
+ message: `${parseIssue.kind}: ${parseIssue.detail}`,
195
+ fix: 'Inspect/repair moflo.yaml, or `mv moflo.yaml moflo.yaml.bak && npx moflo init`',
196
+ };
197
+ }
198
+ return {
199
+ name: 'moflo.yaml',
200
+ status: 'warn',
201
+ message: `Missing sections: ${result.missingSections.join(', ')}`,
202
+ fix: 'Restart Claude Code (yaml-upgrader auto-appends) or `npx moflo init --force`',
203
+ };
204
+ }
205
+ export async function checkTestDirs() {
206
+ const yamlPath = join(process.cwd(), 'moflo.yaml');
207
+ if (!existsSync(yamlPath)) {
208
+ return { name: 'Test Directories', status: 'warn', message: 'No moflo.yaml — test indexing unconfigured', fix: 'npx moflo init' };
209
+ }
210
+ try {
211
+ const content = readFileSync(yamlPath, 'utf-8');
212
+ const testsBlock = content.match(/tests:\s*\n\s+directories:\s*\n((?:\s+-\s+.+\n?)+)/);
213
+ if (!testsBlock) {
214
+ return { name: 'Test Directories', status: 'warn', message: 'No tests section in moflo.yaml', fix: 'npx moflo init --force' };
215
+ }
216
+ const items = testsBlock[1].match(/-\s+(.+)/g);
217
+ if (!items || items.length === 0) {
218
+ return { name: 'Test Directories', status: 'warn', message: 'Empty test directories list' };
219
+ }
220
+ const dirs = items.map(item => item.replace(/^-\s+/, '').trim());
221
+ const existing = dirs.filter(d => existsSync(join(process.cwd(), d)));
222
+ const missing = dirs.filter(d => !existsSync(join(process.cwd(), d)));
223
+ const autoIndexMatch = content.match(/auto_index:\s*\n(?:.*\n)*?\s+tests:\s*(true|false)/);
224
+ const autoIndexEnabled = !autoIndexMatch || autoIndexMatch[1] !== 'false';
225
+ const indexLabel = autoIndexEnabled ? 'auto-index: on' : 'auto-index: off';
226
+ if (missing.length > 0 && existing.length === 0) {
227
+ return {
228
+ name: 'Test Directories',
229
+ status: 'warn',
230
+ message: `No configured test dirs exist: ${missing.join(', ')} (${indexLabel})`,
231
+ };
232
+ }
233
+ if (missing.length > 0) {
234
+ return {
235
+ name: 'Test Directories',
236
+ status: 'warn',
237
+ message: `${existing.length} OK, ${missing.length} missing: ${missing.join(', ')} (${indexLabel})`,
238
+ };
239
+ }
240
+ return { name: 'Test Directories', status: 'pass', message: `${existing.length} directories: ${existing.join(', ')} (${indexLabel})` };
241
+ }
242
+ catch (e) {
243
+ return { name: 'Test Directories', status: 'warn', message: `Unable to parse moflo.yaml: ${errorDetail(e)}` };
244
+ }
245
+ }
246
+ //# sourceMappingURL=doctor-checks-config.js.map
@@ -593,6 +593,20 @@ export async function checkHookBlockDrift() {
593
593
  message: 'drift check skipped — claudeFlow.hooks.locked: true',
594
594
  };
595
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 */ }
596
610
  const report = computeHookBlockDrift(settings.hooks ?? {});
597
611
  if (!report.drifted) {
598
612
  return {