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.
- package/.claude/guidance/shipped/moflo-core-guidance.md +1 -0
- package/.claude/guidance/shipped/moflo-verbose-command-filtering.md +45 -0
- package/.claude/helpers/gate.cjs +21 -5
- package/.claude/helpers/simplify-classify.cjs +211 -0
- package/.claude/skills/eldar/SKILL.md +13 -8
- package/.claude/skills/fl/phases.md +18 -2
- package/.claude/skills/guidance/SKILL.md +1 -1
- package/.claude/skills/simplify/SKILL.md +35 -48
- package/.claude/skills/spell-schedule/SKILL.md +1 -1
- package/bin/gate.cjs +21 -5
- package/bin/session-start-launcher.mjs +1 -1
- 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 +40 -2
- 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 -1706
- package/dist/src/cli/init/helpers-generator.js +21 -5
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- package/scripts/post-install-bootstrap.mjs +1 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version freshness check for `flo doctor`.
|
|
3
|
+
*
|
|
4
|
+
* Uses fetch() against the npm registry rather than `npm view` because the
|
|
5
|
+
* latter shells out to npm-cli.js, which is briefly orphaned on Windows after
|
|
6
|
+
* its parent chain reaps and gets flagged by findZombieProcesses' "moflo"
|
|
7
|
+
* regex.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'fs';
|
|
10
|
+
import { dirname, join } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
13
|
+
// Cold-connect TLS+DNS can eat most of npm's old 5s budget when doctor's
|
|
14
|
+
// parallel checks saturate the event loop, hence 10s.
|
|
15
|
+
const REGISTRY_FETCH_TIMEOUT_MS = 10_000;
|
|
16
|
+
function readCurrentVersion() {
|
|
17
|
+
// Walk up from the current file's directory until we find the moflo
|
|
18
|
+
// package.json (or a tolerated legacy upstream name during migration).
|
|
19
|
+
// Walk until dirname(dir) === dir (filesystem root on any platform).
|
|
20
|
+
try {
|
|
21
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
22
|
+
let dir = dirname(thisFile);
|
|
23
|
+
for (;;) {
|
|
24
|
+
const candidate = join(dir, 'package.json');
|
|
25
|
+
try {
|
|
26
|
+
if (existsSync(candidate)) {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
28
|
+
if (pkg.version &&
|
|
29
|
+
typeof pkg.name === 'string' &&
|
|
30
|
+
(pkg.name === 'moflo' || pkg.name === 'claude-flow' || pkg.name === 'ruflo')) {
|
|
31
|
+
return pkg.version;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Unreadable/invalid JSON -- skip and keep walking up
|
|
37
|
+
}
|
|
38
|
+
const parent = dirname(dir);
|
|
39
|
+
if (parent === dir)
|
|
40
|
+
break; // reached root
|
|
41
|
+
dir = parent;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Fall back to a default
|
|
46
|
+
}
|
|
47
|
+
return '0.0.0';
|
|
48
|
+
}
|
|
49
|
+
function parseVersion(v) {
|
|
50
|
+
const match = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-[a-zA-Z]+\.(\d+))?/);
|
|
51
|
+
if (!match)
|
|
52
|
+
return { major: 0, minor: 0, patch: 0, prerelease: 0 };
|
|
53
|
+
return {
|
|
54
|
+
major: parseInt(match[1], 10) || 0,
|
|
55
|
+
minor: parseInt(match[2], 10) || 0,
|
|
56
|
+
patch: parseInt(match[3], 10) || 0,
|
|
57
|
+
prerelease: parseInt(match[4], 10) || 0,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function isOutdated(current, latest) {
|
|
61
|
+
return (latest.major > current.major ||
|
|
62
|
+
(latest.major === current.major && latest.minor > current.minor) ||
|
|
63
|
+
(latest.major === current.major && latest.minor === current.minor && latest.patch > current.patch) ||
|
|
64
|
+
(latest.major === current.major && latest.minor === current.minor && latest.patch === current.patch && latest.prerelease > current.prerelease));
|
|
65
|
+
}
|
|
66
|
+
// Manual AbortController (NOT AbortSignal.timeout): the latter leaves
|
|
67
|
+
// a libuv timer alive past process exit on Node 24 / Windows and trips
|
|
68
|
+
// an `!(handle->flags & UV_HANDLE_CLOSING)` assertion in src/win/async.c.
|
|
69
|
+
async function fetchLatestVersion() {
|
|
70
|
+
const ac = new AbortController();
|
|
71
|
+
const timer = setTimeout(() => ac.abort(), REGISTRY_FETCH_TIMEOUT_MS);
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch('https://registry.npmjs.org/moflo/latest', {
|
|
74
|
+
headers: { Accept: 'application/json' },
|
|
75
|
+
signal: ac.signal,
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok)
|
|
78
|
+
throw new Error(`registry HTTP ${response.status}`);
|
|
79
|
+
const info = (await response.json());
|
|
80
|
+
if (typeof info.version !== 'string' || !info.version) {
|
|
81
|
+
throw new Error('registry response missing version');
|
|
82
|
+
}
|
|
83
|
+
return info.version;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export async function checkVersionFreshness() {
|
|
90
|
+
try {
|
|
91
|
+
const currentVersion = readCurrentVersion();
|
|
92
|
+
// Check if running via npx (look for _npx in process path or argv)
|
|
93
|
+
const isNpx = process.argv[1]?.includes('_npx') ||
|
|
94
|
+
process.env.npm_execpath?.includes('npx') ||
|
|
95
|
+
process.cwd().includes('_npx');
|
|
96
|
+
let latestVersion;
|
|
97
|
+
try {
|
|
98
|
+
latestVersion = await fetchLatestVersion();
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
return {
|
|
102
|
+
name: 'Version Freshness',
|
|
103
|
+
status: 'warn',
|
|
104
|
+
message: `v${currentVersion} (cannot check registry: ${errorDetail(e, { firstLineOnly: true })})`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (isOutdated(parseVersion(currentVersion), parseVersion(latestVersion))) {
|
|
108
|
+
const fix = isNpx
|
|
109
|
+
? (process.platform === 'win32'
|
|
110
|
+
? 'npx -y moflo (or clear %LocalAppData%\\npm-cache\\_npx manually)'
|
|
111
|
+
: 'rm -rf ~/.npm/_npx/* && npx -y moflo')
|
|
112
|
+
: 'npm update moflo';
|
|
113
|
+
return {
|
|
114
|
+
name: 'Version Freshness',
|
|
115
|
+
status: 'warn',
|
|
116
|
+
message: `v${currentVersion} (latest: v${latestVersion})${isNpx ? ' [npx cache stale]' : ''}`,
|
|
117
|
+
fix,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
name: 'Version Freshness',
|
|
122
|
+
status: 'pass',
|
|
123
|
+
message: `v${currentVersion} (up to date)`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return {
|
|
128
|
+
name: 'Version Freshness',
|
|
129
|
+
status: 'warn',
|
|
130
|
+
message: `Unable to check version freshness: ${errorDetail(error)}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=doctor-version.js.map
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zombie process detection + cleanup for `flo doctor`.
|
|
3
|
+
*
|
|
4
|
+
* "Orphaned" means the parent is no longer alive — nothing will clean it up.
|
|
5
|
+
* MCP servers spawned by a live Claude Code session have a live parent
|
|
6
|
+
* (claude.exe) and must NOT be flagged. The shared ProcessManager registry
|
|
7
|
+
* (.moflo/background-pids.json) is treated as an allowlist for legitimate
|
|
8
|
+
* detached background tasks (sequential indexer chain, daemon, MCP servers).
|
|
9
|
+
*/
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { getDaemonLockHolder, isDaemonProcess, releaseDaemonLock } from '../services/daemon-lock.js';
|
|
14
|
+
// Cmdline capture/display caps + scan timeouts. Hoisted so the values
|
|
15
|
+
// used to size buffers and format messages are visible in one place.
|
|
16
|
+
const ZOMBIE_CMDLINE_CAPTURE_LEN = 200;
|
|
17
|
+
const ZOMBIE_CMDLINE_DISPLAY_LEN = 100;
|
|
18
|
+
const ZOMBIE_SCAN_TIMEOUT_MS_WIN = 10_000;
|
|
19
|
+
const ZOMBIE_SCAN_TIMEOUT_MS_POSIX = 5_000;
|
|
20
|
+
const ZOMBIE_KILL_TIMEOUT_MS = 5_000;
|
|
21
|
+
const NODE_PREFIX_RE = /^"?[^"\s]*node(?:\.exe)?"?\s+/i;
|
|
22
|
+
export function formatCmdline(raw) {
|
|
23
|
+
const cleaned = raw.replace(NODE_PREFIX_RE, '').trim();
|
|
24
|
+
return cleaned.length > ZOMBIE_CMDLINE_DISPLAY_LEN
|
|
25
|
+
? cleaned.slice(0, ZOMBIE_CMDLINE_DISPLAY_LEN - 1) + '…'
|
|
26
|
+
: cleaned;
|
|
27
|
+
}
|
|
28
|
+
export function formatZombieDetail(d) {
|
|
29
|
+
return `pid=${d.pid} ppid=${d.ppid} cmd=${formatCmdline(d.cmdline)}`;
|
|
30
|
+
}
|
|
31
|
+
// Cross-platform liveness probe via signal 0 — Node abstracts the platform.
|
|
32
|
+
function isProcessAlive(pid) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Fast path: kill processes tracked in the shared ProcessManager registry.
|
|
42
|
+
// This avoids the expensive OS-level process scan for known background tasks.
|
|
43
|
+
export function killTrackedProcesses() {
|
|
44
|
+
const registryFile = join(process.cwd(), '.moflo', 'background-pids.json');
|
|
45
|
+
const lockFile = join(process.cwd(), '.moflo', 'spawn.lock');
|
|
46
|
+
let killed = 0;
|
|
47
|
+
try {
|
|
48
|
+
if (existsSync(registryFile)) {
|
|
49
|
+
const entries = JSON.parse(readFileSync(registryFile, 'utf-8'));
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (!isProcessAlive(entry.pid))
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
if (process.platform === 'win32') {
|
|
55
|
+
execSync(`taskkill /F /PID ${entry.pid}`, { timeout: 5000, windowsHide: true });
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
process.kill(entry.pid, 'SIGKILL');
|
|
59
|
+
}
|
|
60
|
+
killed++;
|
|
61
|
+
}
|
|
62
|
+
catch { /* already gone */ }
|
|
63
|
+
}
|
|
64
|
+
writeFileSync(registryFile, '[]');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch { /* non-fatal */ }
|
|
68
|
+
try {
|
|
69
|
+
if (existsSync(lockFile))
|
|
70
|
+
unlinkSync(lockFile);
|
|
71
|
+
}
|
|
72
|
+
catch { /* ok */ }
|
|
73
|
+
return killed;
|
|
74
|
+
}
|
|
75
|
+
// Read the set of moflo background PIDs registered with the shared
|
|
76
|
+
// ProcessManager (.moflo/background-pids.json). These are legitimate tracked
|
|
77
|
+
// background tasks (sequential indexer chain, daemon, MCP servers spawned by
|
|
78
|
+
// session-start) — they are detached:true by design so their parents have
|
|
79
|
+
// already exited, but they are NOT orphans. Without this allow-set,
|
|
80
|
+
// findZombieProcesses() flags every running indexer step as a zombie.
|
|
81
|
+
function readTrackedBackgroundPids() {
|
|
82
|
+
const result = new Set();
|
|
83
|
+
const registryFile = join(process.cwd(), '.moflo', 'background-pids.json');
|
|
84
|
+
try {
|
|
85
|
+
if (!existsSync(registryFile))
|
|
86
|
+
return result;
|
|
87
|
+
const entries = JSON.parse(readFileSync(registryFile, 'utf-8'));
|
|
88
|
+
if (!Array.isArray(entries))
|
|
89
|
+
return result;
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (entry && typeof entry.pid === 'number' && entry.pid > 0) {
|
|
92
|
+
result.add(entry.pid);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch { /* malformed or unreadable — treat as empty */ }
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
// Find and optionally kill orphaned moflo/claude-flow node processes.
|
|
100
|
+
export async function findZombieProcesses(kill = false) {
|
|
101
|
+
const legitimatePid = getDaemonLockHolder(process.cwd());
|
|
102
|
+
const trackedPids = readTrackedBackgroundPids();
|
|
103
|
+
const currentPid = process.pid;
|
|
104
|
+
const parentPid = process.ppid;
|
|
105
|
+
const details = [];
|
|
106
|
+
let killed = 0;
|
|
107
|
+
const candidates = [];
|
|
108
|
+
try {
|
|
109
|
+
if (process.platform === 'win32') {
|
|
110
|
+
// CSV output preserves full CommandLine; Format-Table truncates to console width.
|
|
111
|
+
const result = execSync('powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"Name=\'node.exe\'\\" | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"', { encoding: 'utf-8', timeout: ZOMBIE_SCAN_TIMEOUT_MS_WIN, windowsHide: true });
|
|
112
|
+
const lines = result.split(/\r?\n/);
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (!/moflo|claude-flow|flo\s+(hooks|gate|mcp|daemon)/i.test(line))
|
|
115
|
+
continue;
|
|
116
|
+
const m = line.match(/^"(\d+)","(\d+)","?(.*?)"?$/);
|
|
117
|
+
if (m) {
|
|
118
|
+
candidates.push({
|
|
119
|
+
pid: parseInt(m[1], 10),
|
|
120
|
+
ppid: parseInt(m[2], 10),
|
|
121
|
+
cmdline: m[3].replace(/""/g, '"').slice(0, ZOMBIE_CMDLINE_CAPTURE_LEN),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// ps -ww disables width truncation so cmdline is captured intact.
|
|
128
|
+
const result = execSync('ps -ww -eo pid,ppid,command | grep -E "node.*(moflo|claude-flow)" | grep -v grep', { encoding: 'utf-8', timeout: ZOMBIE_SCAN_TIMEOUT_MS_POSIX });
|
|
129
|
+
const lines = result.trim().split(/\r?\n/);
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
132
|
+
if (m) {
|
|
133
|
+
candidates.push({
|
|
134
|
+
pid: parseInt(m[1], 10),
|
|
135
|
+
ppid: parseInt(m[2], 10),
|
|
136
|
+
cmdline: m[3].slice(0, ZOMBIE_CMDLINE_CAPTURE_LEN),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// No matches (grep exits non-zero) or scan command failed.
|
|
144
|
+
}
|
|
145
|
+
for (const cand of candidates) {
|
|
146
|
+
const { pid, ppid } = cand;
|
|
147
|
+
if (pid === currentPid || pid === parentPid || pid === legitimatePid)
|
|
148
|
+
continue;
|
|
149
|
+
if (trackedPids.has(pid))
|
|
150
|
+
continue;
|
|
151
|
+
if (isProcessAlive(ppid))
|
|
152
|
+
continue;
|
|
153
|
+
// Defense-in-depth: detached daemons have dead parents by design even
|
|
154
|
+
// when the lock file is missing/corrupted.
|
|
155
|
+
if (isDaemonProcess(pid))
|
|
156
|
+
continue;
|
|
157
|
+
details.push(cand);
|
|
158
|
+
}
|
|
159
|
+
if (kill && details.length > 0) {
|
|
160
|
+
for (const { pid } of details) {
|
|
161
|
+
try {
|
|
162
|
+
if (process.platform === 'win32') {
|
|
163
|
+
execSync(`taskkill /F /PID ${pid}`, { timeout: ZOMBIE_KILL_TIMEOUT_MS, windowsHide: true });
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
process.kill(pid, 'SIGKILL');
|
|
167
|
+
}
|
|
168
|
+
killed++;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Already exited.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (legitimatePid && details.some(d => d.pid === legitimatePid)) {
|
|
175
|
+
releaseDaemonLock(process.cwd(), legitimatePid, true);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { killed, details };
|
|
179
|
+
}
|
|
180
|
+
// HealthCheck wrapper around findZombieProcesses for the orchestrated check list.
|
|
181
|
+
// Surfaces each orphan's cmdline so spawn-discipline regressions are diagnosable
|
|
182
|
+
// from a single doctor run.
|
|
183
|
+
export async function checkZombieProcesses() {
|
|
184
|
+
try {
|
|
185
|
+
const scan = await findZombieProcesses(false);
|
|
186
|
+
if (scan.details.length === 0) {
|
|
187
|
+
return { name: 'Zombie Processes', status: 'pass', message: 'No orphaned processes' };
|
|
188
|
+
}
|
|
189
|
+
const detail = scan.details.map(formatZombieDetail).join(' | ');
|
|
190
|
+
return {
|
|
191
|
+
name: 'Zombie Processes',
|
|
192
|
+
status: 'warn',
|
|
193
|
+
message: `${scan.details.length} orphaned process(es): ${detail}`,
|
|
194
|
+
fix: 'moflo doctor --kill-zombies',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return { name: 'Zombie Processes', status: 'pass', message: 'Check skipped' };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=doctor-zombies.js.map
|