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.
- package/.claude/helpers/gate.cjs +21 -5
- package/.claude/skills/eldar/SKILL.md +305 -0
- package/.claude/skills/fl/phases.md +18 -2
- package/.claude/skills/simplify/SKILL.md +35 -48
- package/README.md +25 -0
- package/bin/gate.cjs +21 -5
- 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 +64 -10
- package/bin/simplify-classify.cjs +211 -0
- package/dist/src/cli/commands/doctor-checks-config.js +246 -0
- package/dist/src/cli/commands/doctor-checks-deep.js +14 -0
- package/dist/src/cli/commands/doctor-checks-intelligence.js +197 -0
- package/dist/src/cli/commands/doctor-checks-memory.js +207 -0
- package/dist/src/cli/commands/doctor-checks-platform.js +138 -0
- package/dist/src/cli/commands/doctor-checks-runtime.js +170 -0
- package/dist/src/cli/commands/doctor-fixes.js +165 -0
- package/dist/src/cli/commands/doctor-registry.js +109 -0
- package/dist/src/cli/commands/doctor-render.js +203 -0
- package/dist/src/cli/commands/doctor-types.js +9 -0
- package/dist/src/cli/commands/doctor-version.js +134 -0
- package/dist/src/cli/commands/doctor-zombies.js +201 -0
- package/dist/src/cli/commands/doctor.js +35 -1657
- package/dist/src/cli/init/helpers-generator.js +21 -5
- package/dist/src/cli/init/moflo-init.js +20 -268
- 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 +23 -2
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- package/scripts/post-install-bootstrap.mjs +1 -0
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
|
|
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
962
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|