moflo 4.9.13 → 4.9.15

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 (29) hide show
  1. package/.claude/guidance/shipped/moflo-core-guidance.md +1 -0
  2. package/.claude/guidance/shipped/moflo-verbose-command-filtering.md +45 -0
  3. package/.claude/helpers/gate.cjs +21 -5
  4. package/.claude/helpers/simplify-classify.cjs +211 -0
  5. package/.claude/skills/eldar/SKILL.md +13 -8
  6. package/.claude/skills/fl/phases.md +18 -2
  7. package/.claude/skills/guidance/SKILL.md +1 -1
  8. package/.claude/skills/simplify/SKILL.md +35 -48
  9. package/.claude/skills/spell-schedule/SKILL.md +1 -1
  10. package/bin/gate.cjs +21 -5
  11. package/bin/session-start-launcher.mjs +1 -1
  12. package/bin/simplify-classify.cjs +211 -0
  13. package/dist/src/cli/commands/doctor-checks-config.js +246 -0
  14. package/dist/src/cli/commands/doctor-checks-deep.js +40 -2
  15. package/dist/src/cli/commands/doctor-checks-intelligence.js +197 -0
  16. package/dist/src/cli/commands/doctor-checks-memory.js +207 -0
  17. package/dist/src/cli/commands/doctor-checks-platform.js +138 -0
  18. package/dist/src/cli/commands/doctor-checks-runtime.js +170 -0
  19. package/dist/src/cli/commands/doctor-fixes.js +165 -0
  20. package/dist/src/cli/commands/doctor-registry.js +109 -0
  21. package/dist/src/cli/commands/doctor-render.js +203 -0
  22. package/dist/src/cli/commands/doctor-types.js +9 -0
  23. package/dist/src/cli/commands/doctor-version.js +134 -0
  24. package/dist/src/cli/commands/doctor-zombies.js +201 -0
  25. package/dist/src/cli/commands/doctor.js +35 -1706
  26. package/dist/src/cli/init/helpers-generator.js +21 -5
  27. package/dist/src/cli/version.js +1 -1
  28. package/package.json +2 -2
  29. package/scripts/post-install-bootstrap.mjs +1 -0
@@ -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
@@ -432,6 +432,31 @@ const REQUIRED_GATE_CASES = [
432
432
  // session-start-launcher.mjs in consumer projects without transitive failures.
433
433
  import { REQUIRED_HOOK_WIRING } from '../services/hook-wiring.js';
434
434
  export { REQUIRED_HOOK_WIRING };
435
+ /**
436
+ * Detect "expected pre-publish drift" — source `bin/gate.cjs` is ahead of the
437
+ * installed `node_modules/moflo/bin/gate.cjs`, but the deployed
438
+ * `.claude/helpers/gate.cjs` still matches the installed version. This is the
439
+ * steady state in the moflo dogfood repo while a PR has landed but no
440
+ * `npm install moflo@<new>` has rotated the package.
441
+ *
442
+ * Returns true only when both are true:
443
+ * - helper content equals installed bin content (helper is correctly synced
444
+ * to what's installed)
445
+ * - installed bin content differs from source bin content (source is ahead)
446
+ *
447
+ * If `node_modules/moflo/bin/gate.cjs` is missing (consumer never installed
448
+ * moflo, or path is unusual) we conservatively return false so other drift
449
+ * detection still applies.
450
+ */
451
+ export function isExpectedPrePublishDrift(installedBinGate, helperContent, sourceBinContent) {
452
+ try {
453
+ const installedContent = readFileSync(installedBinGate, 'utf8');
454
+ return installedContent === helperContent && installedContent !== sourceBinContent;
455
+ }
456
+ catch {
457
+ return false;
458
+ }
459
+ }
435
460
  /**
436
461
  * Verify gate infrastructure health:
437
462
  * 1. gate.cjs exists and contains all required cases
@@ -474,14 +499,27 @@ export async function checkGateHealth() {
474
499
  issues.push(`gate.cjs missing cases: ${missingCases.join(', ')}`);
475
500
  }
476
501
  // 2. Check bin/gate.cjs sync
502
+ //
503
+ // The launcher syncs `node_modules/moflo/bin/gate.cjs` → `.claude/helpers/gate.cjs`
504
+ // on version change. Source `bin/gate.cjs` is only present in the moflo dogfood
505
+ // repo. During the dogfood publish window — between a PR landing and the next
506
+ // `npm install moflo@<new>` — source bin/ legitimately moves ahead of the
507
+ // installed bin/, while the helper continues to mirror the installed version.
508
+ // That's the expected steady state, not a bug; downgrade it to `warn` and skip
509
+ // the `fix` field so `--fix` doesn't paint a false success (#913).
477
510
  const binGate = join(projectDir, 'bin', 'gate.cjs');
511
+ const installedBinGate = join(projectDir, 'node_modules', 'moflo', 'bin', 'gate.cjs');
478
512
  if (existsSync(binGate)) {
479
513
  try {
480
514
  const binContent = readFileSync(binGate, 'utf8');
481
515
  if (binContent !== gateContent) {
482
- // Check if it's a size difference (likely out of sync) vs whitespace
483
516
  const sizeDiff = Math.abs(binContent.length - gateContent.length);
484
- if (sizeDiff > 10) {
517
+ const prePublishDrift = isExpectedPrePublishDrift(installedBinGate, gateContent, binContent);
518
+ if (prePublishDrift) {
519
+ warnings.push(`source bin/gate.cjs is ${sizeDiff} chars ahead of node_modules/moflo/bin/gate.cjs ` +
520
+ '(expected pre-publish drift; resolves on next `npm install moflo@<new>`)');
521
+ }
522
+ else if (sizeDiff > 10) {
485
523
  issues.push(`bin/gate.cjs out of sync with .claude/helpers/gate.cjs (${sizeDiff} chars differ)`);
486
524
  }
487
525
  else {