moflo 4.10.1 → 4.10.3
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/skills/healer/SKILL.md +3 -1
- package/bin/session-start-launcher.mjs +112 -5
- package/dist/src/cli/commands/doctor-checks-config.js +4 -4
- package/dist/src/cli/commands/doctor-checks-memory-access.js +27 -1
- package/dist/src/cli/commands/doctor-embedding-hygiene.js +48 -12
- package/dist/src/cli/commands/doctor-render.js +118 -74
- package/dist/src/cli/commands/doctor-version.js +1 -1
- package/dist/src/cli/commands/doctor.js +70 -25
- package/dist/src/cli/commands/index.js +0 -6
- package/dist/src/cli/init/executor.js +2 -2
- package/dist/src/cli/mcp-tools/swarm-tools.js +3 -4
- package/dist/src/cli/memory/bridge-core.js +36 -0
- package/dist/src/cli/services/moflo-paths.js +6 -5
- package/dist/src/cli/services/moflo-require.js +2 -2
- package/dist/src/cli/shared/core/config/loader.js +2 -2
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- package/dist/src/cli/appliance/gguf-engine.js +0 -425
- package/dist/src/cli/appliance/ruvllm-bridge.js +0 -231
- package/dist/src/cli/appliance/rvfa-builder.js +0 -325
- package/dist/src/cli/appliance/rvfa-distribution.js +0 -370
- package/dist/src/cli/appliance/rvfa-format.js +0 -393
- package/dist/src/cli/appliance/rvfa-runner.js +0 -238
- package/dist/src/cli/appliance/rvfa-signing.js +0 -351
- package/dist/src/cli/commands/appliance-advanced.js +0 -213
- package/dist/src/cli/commands/appliance.js +0 -404
|
@@ -30,7 +30,9 @@ Thin wrapper around the `flo healer` CLI. All check + fix logic lives in the CLI
|
|
|
30
30
|
- `✓ N passing` (count only)
|
|
31
31
|
- `⚠ warnings` — list `name: message`; flag with `[auto-fixable]` when the result has a `fix` field
|
|
32
32
|
- `✗ failures` — same
|
|
33
|
-
- If `--fix` mode,
|
|
33
|
+
- If `--fix` mode, read `fixesApplied[]` from the JSON payload and list `{name, applied}` per entry — applied=true → "fixed", applied=false → "needs manual action". The `results[]` array is post-fix state (re-evaluated), so report the final status.
|
|
34
|
+
- If `--install` was passed, surface `claudeCodeInstall.installed` from the payload.
|
|
35
|
+
- If `--kill-zombies` was passed, surface `zombieScan.killed` / `zombieScan.found` from the payload.
|
|
34
36
|
|
|
35
37
|
4. **Nudge based on what changed.** Only mention next steps for state that *actually* changed:
|
|
36
38
|
- Daemon restarted → `Statusline should refresh within ~5s.`
|
|
@@ -358,10 +358,67 @@ function fireAndForget(cmd, args, label) {
|
|
|
358
358
|
}
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
// Cross-platform sync sleep — Atomics.wait parks the thread at the OS level
|
|
362
|
+
// without burning CPU (same primitive as src/cli/shared/utils/atomic-file-
|
|
363
|
+
// write.ts:131). Used by stopDaemon's liveness polling between graceful and
|
|
364
|
+
// forced termination so we never unlink the lockfile while the daemon is
|
|
365
|
+
// still running.
|
|
366
|
+
const STOP_SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
|
|
367
|
+
function sleepSyncMs(ms) {
|
|
368
|
+
Atomics.wait(STOP_SLEEP_BUF, 0, 0, ms);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// PID liveness check. EPERM means the process exists but is owned by another
|
|
372
|
+
// user — treat as alive (matches the canonical isAlive in process-manager.mjs
|
|
373
|
+
// after #1061; the prior `catch { return false; }` falsely reported foreign-
|
|
374
|
+
// owned daemons as dead and let the lockfile be unlinked under them).
|
|
375
|
+
//
|
|
376
|
+
// Linux zombie handling: on Linux, `kill(pid, 0)` returns success for zombie
|
|
377
|
+
// processes (exited but not yet reaped by their parent). A zombie can't write
|
|
378
|
+
// to the DB, hold locks, or do anything else stopDaemon cares about — treating
|
|
379
|
+
// it as alive exhausts the kill budget polling a corpse, then preserves the
|
|
380
|
+
// lockfile under a dead process. Production never hits this (the daemon is
|
|
381
|
+
// detached and reaped by init/systemd within ~ms), but a misbehaving parent
|
|
382
|
+
// can keep a daemon zombified, and the launcher's vitest harness reproduces
|
|
383
|
+
// the case deterministically (#1083 CI failure on ubuntu-latest). Read
|
|
384
|
+
// /proc/<pid>/stat (fixed-format, cheap) and treat 'Z' as dead.
|
|
385
|
+
function isDaemonPidAlive(pid) {
|
|
386
|
+
try {
|
|
387
|
+
process.kill(pid, 0);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
return err && err.code === 'EPERM';
|
|
390
|
+
}
|
|
391
|
+
if (process.platform === 'linux') {
|
|
392
|
+
try {
|
|
393
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
|
|
394
|
+
// Format: "pid (comm) state ..." — comm can contain spaces/parens, so
|
|
395
|
+
// parse from the LAST ')' to skip it safely.
|
|
396
|
+
const lastParen = stat.lastIndexOf(')');
|
|
397
|
+
if (lastParen !== -1 && stat.charAt(lastParen + 2) === 'Z') return false;
|
|
398
|
+
} catch (err) {
|
|
399
|
+
// ENOENT = pid vanished between kill(0) and the read — already dead.
|
|
400
|
+
if (err && err.code === 'ENOENT') return false;
|
|
401
|
+
// Anything else (e.g. /proc unavailable) — keep the kill(0) verdict.
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
361
407
|
// Stop the daemon recorded in `lockFile` (if any) without restarting. Used by
|
|
362
408
|
// the upgrade flow before any DB work — the daemon must not be holding old
|
|
363
409
|
// path resolution in memory, and a concurrent sql.js flush would clobber the
|
|
364
|
-
// cherry-picked rows. Returns true when a live PID was
|
|
410
|
+
// cherry-picked rows. Returns true when a live PID was confirmed dead (or the
|
|
411
|
+
// PID was already gone when we read the lockfile).
|
|
412
|
+
//
|
|
413
|
+
// Escalation mirrors src/cli/commands/daemon.ts:killBackgroundDaemon so the
|
|
414
|
+
// launcher's upgrade path and `flo daemon stop` behave identically: graceful
|
|
415
|
+
// signal → wait → liveness check → force kill. The prior implementation sent
|
|
416
|
+
// bare `process.kill(pid, 'SIGTERM')` on every platform, which on Windows
|
|
417
|
+
// either silently force-kills or fails entirely depending on the process; in
|
|
418
|
+
// either case the catch swallowed the outcome and the lockfile was unlinked.
|
|
419
|
+
// The daemon (if it survived) then re-wrote the lockfile with its stale PID +
|
|
420
|
+
// pre-upgrade version, defeating the section-3a-pre version-skew recovery and
|
|
421
|
+
// leaving the statusline stuck on `📊 ?` until manual `flo daemon restart`.
|
|
365
422
|
//
|
|
366
423
|
// Section 4's `hooks.mjs session-start` spawn is responsible for starting a
|
|
367
424
|
// fresh daemon under the current code; this function intentionally does not.
|
|
@@ -372,11 +429,61 @@ function stopDaemon(lockFile) {
|
|
|
372
429
|
const lock = JSON.parse(readFileSync(lockFile, 'utf-8'));
|
|
373
430
|
if (typeof lock?.pid === 'number' && lock.pid > 0) stalePid = lock.pid;
|
|
374
431
|
} catch { /* malformed lock — fall through to unlink */ }
|
|
375
|
-
|
|
376
|
-
|
|
432
|
+
|
|
433
|
+
let killed = false;
|
|
434
|
+
if (stalePid !== null && isDaemonPidAlive(stalePid)) {
|
|
435
|
+
// Graceful signal — platform-aware. On Windows, `process.kill(pid, 'SIGTERM')`
|
|
436
|
+
// silently force-kills (skipping the daemon's shutdown handlers that flush
|
|
437
|
+
// sql.js + release lock cleanly), so use bare `taskkill` (no /F) for a
|
|
438
|
+
// close-event signal.
|
|
439
|
+
try {
|
|
440
|
+
if (process.platform === 'win32') {
|
|
441
|
+
execFileSync('taskkill', ['/PID', String(stalePid)], { windowsHide: true, timeout: 5000 });
|
|
442
|
+
} else {
|
|
443
|
+
process.kill(stalePid, 'SIGTERM');
|
|
444
|
+
}
|
|
445
|
+
} catch { /* signal/spawn failed — fall through to liveness poll + force */ }
|
|
446
|
+
|
|
447
|
+
// Poll for death up to 3s. The daemon's shutdown handler does a final
|
|
448
|
+
// sql.js dump + lock release, which under load can take ~1s.
|
|
449
|
+
const gracefulDeadline = Date.now() + 3000;
|
|
450
|
+
while (Date.now() < gracefulDeadline) {
|
|
451
|
+
if (!isDaemonPidAlive(stalePid)) { killed = true; break; }
|
|
452
|
+
sleepSyncMs(100);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Force-kill if still alive.
|
|
456
|
+
if (!killed) {
|
|
457
|
+
try {
|
|
458
|
+
if (process.platform === 'win32') {
|
|
459
|
+
execFileSync('taskkill', ['/F', '/T', '/PID', String(stalePid)], { windowsHide: true, timeout: 5000 });
|
|
460
|
+
} else {
|
|
461
|
+
process.kill(stalePid, 'SIGKILL');
|
|
462
|
+
}
|
|
463
|
+
} catch { /* dead or unreachable */ }
|
|
464
|
+
// Short grace period for OS reap.
|
|
465
|
+
const forceDeadline = Date.now() + 1000;
|
|
466
|
+
while (Date.now() < forceDeadline) {
|
|
467
|
+
if (!isDaemonPidAlive(stalePid)) { killed = true; break; }
|
|
468
|
+
sleepSyncMs(100);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!killed) {
|
|
473
|
+
// Daemon survived both signals. Leave the lockfile in place so the next
|
|
474
|
+
// session can see the stale PID and retry — unlinking now would let the
|
|
475
|
+
// surviving daemon re-write the lockfile with its stale PID + version,
|
|
476
|
+
// perpetuating the loop this fix exists to break.
|
|
477
|
+
emitWarning(`stopDaemon: PID ${stalePid} did not exit after SIGTERM+force-kill; lockfile preserved`);
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
} else if (stalePid !== null) {
|
|
481
|
+
// PID was in the lockfile but the process is already gone — clean unlink.
|
|
482
|
+
killed = true;
|
|
377
483
|
}
|
|
484
|
+
|
|
378
485
|
try { unlinkSync(lockFile); } catch { /* non-fatal */ }
|
|
379
|
-
return
|
|
486
|
+
return killed;
|
|
380
487
|
}
|
|
381
488
|
|
|
382
489
|
// Stop-and-restart helper for the stale-daemon branch (section 3a-pre). The
|
|
@@ -720,7 +827,7 @@ try {
|
|
|
720
827
|
|
|
721
828
|
// ── Sync .claude/agents/ + .claude/skills/ recursively (#948) ──────
|
|
722
829
|
// Pre-#948, agents and skills weren't manifest-tracked at all, so any
|
|
723
|
-
// file moflo retired (e.g. the 49
|
|
830
|
+
// file moflo retired (e.g. the 49 aspirational agents in #932 or
|
|
724
831
|
// skill-builder in #945) would linger forever in consumer projects —
|
|
725
832
|
// Claude Code kept loading them on every prompt, paying the per-prompt
|
|
726
833
|
// roster tokens we just spent #932 fixing. Walking these dirs through
|
|
@@ -13,7 +13,7 @@ import { errorDetail } from '../shared/utils/error-detail.js';
|
|
|
13
13
|
export async function checkConfigFile() {
|
|
14
14
|
// JSON configs (parse-validated). LEGACY-CONFIG: `.claude-flow.json` and
|
|
15
15
|
// `claude-flow.config.json` filenames are still recognised so consumers
|
|
16
|
-
// upgrading from pre-#699 moflo builds
|
|
16
|
+
// upgrading from pre-#699 moflo builds keep working
|
|
17
17
|
// without manual rename. Drift guard exempts these via LEGACY-CONFIG marker.
|
|
18
18
|
const jsonPaths = [
|
|
19
19
|
'.moflo/config.json',
|
|
@@ -233,18 +233,18 @@ export async function checkMcpServers() {
|
|
|
233
233
|
const content = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
234
234
|
const servers = content.mcpServers || content.servers || {};
|
|
235
235
|
const count = Object.keys(servers).length;
|
|
236
|
-
const hasClaudeFlow = 'moflo' in servers || 'claude-flow' in servers || 'claude-flow_alpha' in servers
|
|
236
|
+
const hasClaudeFlow = 'moflo' in servers || 'claude-flow' in servers || 'claude-flow_alpha' in servers;
|
|
237
237
|
if (hasClaudeFlow) {
|
|
238
238
|
return { name: 'MCP Servers', status: 'pass', message: `${count} servers (flo configured)` };
|
|
239
239
|
}
|
|
240
|
-
return { name: 'MCP Servers', status: 'warn', message: `${count} servers (flo not found)`, fix: 'claude mcp add
|
|
240
|
+
return { name: 'MCP Servers', status: 'warn', message: `${count} servers (flo not found)`, fix: 'claude mcp add moflo -- npx -y moflo mcp start' };
|
|
241
241
|
}
|
|
242
242
|
catch {
|
|
243
243
|
// continue to next path
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
|
-
return { name: 'MCP Servers', status: 'warn', message: 'No MCP config found', fix: 'claude mcp add moflo npx moflo mcp start' };
|
|
247
|
+
return { name: 'MCP Servers', status: 'warn', message: 'No MCP config found', fix: 'claude mcp add moflo -- npx -y moflo mcp start' };
|
|
248
248
|
}
|
|
249
249
|
// Catches three failure modes (#895):
|
|
250
250
|
// 1. File missing — session-start should have created it; warn user that
|
|
@@ -144,7 +144,33 @@ async function runMemoryRoundTrip(ctx) {
|
|
|
144
144
|
}
|
|
145
145
|
else {
|
|
146
146
|
const top = searchOut.results?.find(r => r.key === key);
|
|
147
|
-
|
|
147
|
+
if (top) {
|
|
148
|
+
pushDetail(ctx.details, { id: `${ctx.idPrefix}.search-finds-key`, mcpTool: 'memory_search', expected: `result containing key=${key}` }, { topKey: top.key, similarity: top.similarity }, null);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// #1120: search returned results but our just-stored key wasn't among
|
|
152
|
+
// them. Mirrors the #1111 empty-HNSW fallback for the non-zero case:
|
|
153
|
+
// if the row IS reachable by literal key, demote to warn — memory
|
|
154
|
+
// access works, the HNSW index just hasn't propagated the new write
|
|
155
|
+
// yet (stale-neighbor race when healer runs 2+ times in one session
|
|
156
|
+
// against accumulated probe rows). If literal retrieve also fails,
|
|
157
|
+
// surface the original fail unchanged.
|
|
158
|
+
const otherKeys = searchOut?.results?.map(r => r.key).join(', ') ?? 'none';
|
|
159
|
+
const retrievable = await literalKeyReachable(ctx.memoryTools, key, namespace);
|
|
160
|
+
if (retrievable) {
|
|
161
|
+
ctx.details.push({
|
|
162
|
+
id: `${ctx.idPrefix}.search-finds-key`,
|
|
163
|
+
mcpTool: 'memory_search',
|
|
164
|
+
status: 'warn',
|
|
165
|
+
observed: { topKeys: searchOut?.results?.map(r => r.key), retrievable: true },
|
|
166
|
+
expected: `result containing key=${key}`,
|
|
167
|
+
message: `search returned results but our key was not among them (got: ${otherKeys}); row IS reachable by literal retrieve — HNSW stale-neighbor race (newly-written row not yet propagated to the index). Memory access path works.`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
pushDetail(ctx.details, { id: `${ctx.idPrefix}.search-finds-key`, mcpTool: 'memory_search', expected: `result containing key=${key}` }, { allKeys: searchOut.results?.map(r => r.key) }, `stored key ${key} not in results (got: ${otherKeys})`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
148
174
|
}
|
|
149
175
|
// 4. memory_retrieve returns the full value (search content is truncated
|
|
150
176
|
// to a 60-char snippet). Catches write clobber and namespace bleed — we
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
* vectors. The Story-2 self-healing migration converges every active
|
|
17
17
|
* row on the canonical label; this check verifies it actually did.
|
|
18
18
|
*
|
|
19
|
+
* Story #729 carve-out: ephemeral-namespace rows (tasklist, hive-mind,
|
|
20
|
+
* epic-state, test-bridge-fix, plus EPHEMERAL_NAMESPACE_PREFIXES) are
|
|
21
|
+
* intentionally written with `embedding IS NULL AND embedding_model IS
|
|
22
|
+
* NULL`. They are excluded from the count so they don't trip branch (4)
|
|
23
|
+
* "unrecognised embedding_model" on every publish — see bridge-embedder.ts
|
|
24
|
+
* for the writer-side rationale.
|
|
25
|
+
*
|
|
19
26
|
* Lives next to the doctor command rather than in `doctor.ts` to keep that
|
|
20
27
|
* file under the 500-line decomposition target.
|
|
21
28
|
*
|
|
@@ -26,6 +33,7 @@ import { existsSync } from 'fs';
|
|
|
26
33
|
import { CANONICAL_EMBEDDING_MODEL } from '../embeddings/migration/types.js';
|
|
27
34
|
import { memoryDbCandidatePaths } from '../services/moflo-paths.js';
|
|
28
35
|
import { openDaemonDatabase } from '../memory/daemon-backend.js';
|
|
36
|
+
import { EPHEMERAL_NAMESPACES, EPHEMERAL_NAMESPACE_PREFIXES, } from '../memory/bridge-embedder.js';
|
|
29
37
|
/**
|
|
30
38
|
* Known neural-model labels that all share the all-MiniLM-L6-v2 384-dim
|
|
31
39
|
* vector space. The Story-2 migration retags any of these to the
|
|
@@ -155,23 +163,51 @@ async function loadModelGroups(dbPath) {
|
|
|
155
163
|
}
|
|
156
164
|
if (!hasSchema)
|
|
157
165
|
return [];
|
|
158
|
-
|
|
159
|
-
|
|
166
|
+
// Story #729: ephemeral-namespace rows (tasklist, hive-mind, epic-state,
|
|
167
|
+
// …) are intentionally written with `embedding IS NULL AND
|
|
168
|
+
// embedding_model IS NULL`. Without this exclusion every spell run that
|
|
169
|
+
// logs to `tasklist` re-trips branch (4) "unrecognised embedding_model"
|
|
170
|
+
// on the next publish, even though the writer is doing the right thing.
|
|
171
|
+
const ephemeralNames = [...EPHEMERAL_NAMESPACES];
|
|
172
|
+
const ephemeralPrefixes = [...EPHEMERAL_NAMESPACE_PREFIXES];
|
|
173
|
+
const matchClauses = [];
|
|
174
|
+
const params = [];
|
|
175
|
+
if (ephemeralNames.length > 0) {
|
|
176
|
+
matchClauses.push(`namespace IN (${ephemeralNames.map(() => '?').join(', ')})`);
|
|
177
|
+
params.push(...ephemeralNames);
|
|
178
|
+
}
|
|
179
|
+
for (const prefix of ephemeralPrefixes) {
|
|
180
|
+
matchClauses.push(`namespace LIKE ?`);
|
|
181
|
+
params.push(`${prefix}%`);
|
|
182
|
+
}
|
|
183
|
+
const ephemeralExclusion = matchClauses.length > 0
|
|
184
|
+
? `AND NOT (embedding IS NULL AND embedding_model IS NULL AND (${matchClauses.join(' OR ')}))`
|
|
185
|
+
: '';
|
|
186
|
+
const sql = `SELECT
|
|
160
187
|
COALESCE(embedding_model, 'NULL') AS model,
|
|
161
188
|
COUNT(*) AS n,
|
|
162
189
|
SUM(CASE WHEN embedding IS NULL THEN 1 ELSE 0 END) AS null_count
|
|
163
190
|
FROM memory_entries
|
|
164
191
|
WHERE status = 'active'
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
192
|
+
${ephemeralExclusion}
|
|
193
|
+
GROUP BY model`;
|
|
194
|
+
const groups = [];
|
|
195
|
+
const stmt = db.prepare(sql);
|
|
196
|
+
try {
|
|
197
|
+
stmt.bind(params);
|
|
198
|
+
while (stmt.step()) {
|
|
199
|
+
const row = stmt.get();
|
|
200
|
+
if (Array.isArray(row)) {
|
|
201
|
+
groups.push({
|
|
202
|
+
model: String(row[0]),
|
|
203
|
+
count: Number(row[1]),
|
|
204
|
+
hasNullEmbedding: Number(row[2]) > 0,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
stmt.free();
|
|
175
211
|
}
|
|
176
212
|
return groups;
|
|
177
213
|
}
|
|
@@ -19,66 +19,95 @@ function tally(results) {
|
|
|
19
19
|
failed: results.filter(r => r.status === 'fail').length,
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Run the kill-zombies scan, with optional rendering. Issue #1122: in JSON
|
|
24
|
+
* mode the prose banner would corrupt the single-document contract, so the
|
|
25
|
+
* caller passes `silent: true` and surfaces the structured result inside the
|
|
26
|
+
* JSON payload instead.
|
|
27
|
+
*/
|
|
28
|
+
export async function runKillZombies(opts = {}) {
|
|
29
|
+
const silent = !!opts.silent;
|
|
30
|
+
if (!silent) {
|
|
31
|
+
output.writeln(output.bold('Zombie Process Scan'));
|
|
32
|
+
output.writeln();
|
|
33
|
+
}
|
|
25
34
|
const registryKilled = killTrackedProcesses();
|
|
26
|
-
if (registryKilled > 0) {
|
|
35
|
+
if (!silent && registryKilled > 0) {
|
|
27
36
|
output.writeln(output.success(` Killed ${registryKilled} tracked background process(es) from registry`));
|
|
28
37
|
}
|
|
29
38
|
// Single OS-level scan + kill — the previous flow scanned twice.
|
|
30
39
|
const result = await findZombieProcesses(true);
|
|
31
40
|
const found = result.details.length;
|
|
32
|
-
if (
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
else {
|
|
38
|
-
output.writeln(output.warning(` Found ${found} additional orphaned process(es):`));
|
|
39
|
-
for (const d of result.details) {
|
|
40
|
-
output.writeln(output.dim(` ${formatZombieDetail(d)}`));
|
|
41
|
-
}
|
|
42
|
-
if (result.killed > 0) {
|
|
43
|
-
output.writeln(output.success(` Killed ${result.killed} zombie process(es)`));
|
|
41
|
+
if (!silent) {
|
|
42
|
+
if (found === 0) {
|
|
43
|
+
if (registryKilled === 0) {
|
|
44
|
+
output.writeln(output.success(' No orphaned moflo processes found'));
|
|
45
|
+
}
|
|
44
46
|
}
|
|
45
|
-
|
|
46
|
-
output.writeln(output.warning(` ${found
|
|
47
|
+
else {
|
|
48
|
+
output.writeln(output.warning(` Found ${found} additional orphaned process(es):`));
|
|
49
|
+
for (const d of result.details) {
|
|
50
|
+
output.writeln(output.dim(` ${formatZombieDetail(d)}`));
|
|
51
|
+
}
|
|
52
|
+
if (result.killed > 0) {
|
|
53
|
+
output.writeln(output.success(` Killed ${result.killed} zombie process(es)`));
|
|
54
|
+
}
|
|
55
|
+
if (result.killed < found) {
|
|
56
|
+
output.writeln(output.warning(` ${found - result.killed} process(es) could not be killed`));
|
|
57
|
+
}
|
|
47
58
|
}
|
|
59
|
+
output.writeln();
|
|
60
|
+
output.writeln(output.dim('─'.repeat(50)));
|
|
61
|
+
output.writeln();
|
|
48
62
|
}
|
|
49
|
-
|
|
50
|
-
output.writeln(output.dim('─'.repeat(50)));
|
|
51
|
-
output.writeln();
|
|
63
|
+
return { registryKilled, found, killed: result.killed, details: result.details };
|
|
52
64
|
}
|
|
53
65
|
/**
|
|
54
66
|
* Issue #818: machine-readable output. Emits a single JSON document with
|
|
55
67
|
* per-check fields (and any FunctionalCheckDetail entries from the swarm/
|
|
56
|
-
* hive checks) and exits with the right code.
|
|
57
|
-
*
|
|
58
|
-
*
|
|
68
|
+
* hive checks) and exits with the right code.
|
|
69
|
+
*
|
|
70
|
+
* Issue #1122: action flags (`--fix`, `--install`, `--kill-zombies`) now run
|
|
71
|
+
* before this is called and their outcomes are passed in so automation can
|
|
72
|
+
* tell what changed without re-parsing prose. `results` reflects post-fix
|
|
73
|
+
* state when `fixesApplied` includes any successful fix.
|
|
59
74
|
*/
|
|
60
|
-
export function emitJsonOutput({ results, strict, allowWarnList }) {
|
|
75
|
+
export function emitJsonOutput({ results, strict, allowWarnList, fixesApplied, zombieScan, claudeCodeInstall, }) {
|
|
61
76
|
const { passed, warnings, failed } = tally(results);
|
|
62
77
|
const allowSet = new Set(allowWarnList);
|
|
63
78
|
const strictWarningFailures = strict
|
|
64
79
|
? results.filter(r => r.status === 'warn' && !allowSet.has(r.name)).map(r => r.name)
|
|
65
80
|
: [];
|
|
66
81
|
const exitCode = failed > 0 || strictWarningFailures.length > 0 ? 1 : 0;
|
|
67
|
-
|
|
82
|
+
const payload = {
|
|
68
83
|
summary: { passed, warnings, failed },
|
|
69
84
|
strict: strict ? { strictMode: true, warningsTriggeringFail: strictWarningFailures } : { strictMode: false },
|
|
70
85
|
results,
|
|
71
|
-
}
|
|
86
|
+
};
|
|
87
|
+
if (fixesApplied !== undefined)
|
|
88
|
+
payload.fixesApplied = fixesApplied;
|
|
89
|
+
if (zombieScan !== undefined)
|
|
90
|
+
payload.zombieScan = zombieScan;
|
|
91
|
+
if (claudeCodeInstall !== undefined)
|
|
92
|
+
payload.claudeCodeInstall = claudeCodeInstall;
|
|
93
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
72
94
|
return { success: exitCode === 0, exitCode, data: { passed, warnings, failed, results } };
|
|
73
95
|
}
|
|
74
|
-
/**
|
|
75
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Re-runs Claude Code CLI install + check if --install was passed and the
|
|
98
|
+
* prior result wasn't pass. Issue #1122: accepts `{silent}` so the JSON path
|
|
99
|
+
* runs the install without writing prose to the corrupted stdout, and
|
|
100
|
+
* returns a structured outcome for inclusion in the JSON document.
|
|
101
|
+
*/
|
|
102
|
+
export async function maybeAutoInstallClaudeCode(results, fixes, opts = {}) {
|
|
103
|
+
const silent = !!opts.silent;
|
|
76
104
|
const claudeCodeResult = results.find(r => r.name === 'Claude Code CLI');
|
|
77
|
-
if (!claudeCodeResult || claudeCodeResult.status === 'pass')
|
|
78
|
-
return;
|
|
105
|
+
if (!claudeCodeResult || claudeCodeResult.status === 'pass') {
|
|
106
|
+
return { attempted: false, installed: false };
|
|
107
|
+
}
|
|
79
108
|
const installed = await installClaudeCode();
|
|
80
109
|
if (!installed)
|
|
81
|
-
return;
|
|
110
|
+
return { attempted: true, installed: false };
|
|
82
111
|
const newCheck = await checkClaudeCode();
|
|
83
112
|
const idx = results.findIndex(r => r.name === 'Claude Code CLI');
|
|
84
113
|
if (idx !== -1) {
|
|
@@ -88,7 +117,9 @@ export async function maybeAutoInstallClaudeCode(results, fixes) {
|
|
|
88
117
|
fixes.splice(fixIdx, 1);
|
|
89
118
|
}
|
|
90
119
|
}
|
|
91
|
-
|
|
120
|
+
if (!silent)
|
|
121
|
+
output.writeln(formatCheck(newCheck));
|
|
122
|
+
return { attempted: true, installed: true, postCheck: newCheck };
|
|
92
123
|
}
|
|
93
124
|
export function renderSummary(results) {
|
|
94
125
|
const counts = tally(results);
|
|
@@ -103,62 +134,75 @@ export function renderSummary(results) {
|
|
|
103
134
|
output.writeln(`Summary: ${summaryParts.join(', ')}`);
|
|
104
135
|
return counts;
|
|
105
136
|
}
|
|
106
|
-
/**
|
|
107
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Auto-fix loop, including the post-fix re-run. Mutates `results` and `fixes`
|
|
139
|
+
* in place when fixes succeed and returns a structured outcome.
|
|
140
|
+
*
|
|
141
|
+
* Issue #1122: accepts `{silent}` so the JSON path can run the same fix work
|
|
142
|
+
* without writing prose to a stubbed stdout, and emit `fixesApplied` +
|
|
143
|
+
* post-fix `results` from the returned data.
|
|
144
|
+
*/
|
|
145
|
+
export async function runAutoFix(results, fixes, checksToRun, opts = {}) {
|
|
146
|
+
const silent = !!opts.silent;
|
|
108
147
|
if (fixes.length === 0)
|
|
109
|
-
return;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
148
|
+
return { fixesApplied: [], reEvaluated: null };
|
|
149
|
+
if (!silent) {
|
|
150
|
+
output.writeln();
|
|
151
|
+
output.writeln(output.bold('Auto-fixing issues...'));
|
|
152
|
+
output.writeln();
|
|
153
|
+
}
|
|
113
154
|
const fixableResults = results.filter(r => r.fix && (r.status === 'fail' || r.status === 'warn'));
|
|
114
|
-
|
|
115
|
-
const unfixed = [];
|
|
155
|
+
const fixesApplied = [];
|
|
116
156
|
for (const check of fixableResults) {
|
|
117
157
|
const success = await autoFixCheck(check);
|
|
118
|
-
|
|
119
|
-
|
|
158
|
+
fixesApplied.push({ name: check.name, applied: success });
|
|
159
|
+
}
|
|
160
|
+
const fixed = fixesApplied.filter(f => f.applied).length;
|
|
161
|
+
const unfixed = fixesApplied.filter(f => !f.applied);
|
|
162
|
+
if (!silent) {
|
|
163
|
+
if (fixed > 0) {
|
|
164
|
+
output.writeln();
|
|
165
|
+
output.writeln(output.success(`Auto-fixed ${fixed} issue${fixed > 1 ? 's' : ''}`));
|
|
120
166
|
}
|
|
121
|
-
|
|
122
|
-
|
|
167
|
+
if (unfixed.length > 0) {
|
|
168
|
+
output.writeln();
|
|
169
|
+
output.writeln(output.bold('Manual fixes needed:'));
|
|
170
|
+
const fixByName = new Map(fixableResults.map(r => [r.name, r.fix ?? '']));
|
|
171
|
+
for (const f of unfixed) {
|
|
172
|
+
output.writeln(output.dim(` ${f.name}: ${fixByName.get(f.name) ?? ''}`));
|
|
173
|
+
}
|
|
123
174
|
}
|
|
124
175
|
}
|
|
125
|
-
if (fixed
|
|
176
|
+
if (fixed === 0)
|
|
177
|
+
return { fixesApplied, reEvaluated: null };
|
|
178
|
+
const reSettled = await Promise.allSettled(checksToRun.map(check => check()));
|
|
179
|
+
const reEvaluated = reSettled.map((sr) => sr.status === 'fulfilled'
|
|
180
|
+
? sr.value
|
|
181
|
+
: { name: 'Check', status: 'fail', message: sr.reason?.message ?? 'Unknown error' });
|
|
182
|
+
if (!silent) {
|
|
126
183
|
output.writeln();
|
|
127
|
-
output.writeln(output.
|
|
128
|
-
}
|
|
129
|
-
if (unfixed.length > 0) {
|
|
184
|
+
output.writeln(output.dim('Re-checking...'));
|
|
130
185
|
output.writeln();
|
|
131
|
-
|
|
132
|
-
for (const
|
|
133
|
-
output.writeln(
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
if (fixed === 0)
|
|
137
|
-
return;
|
|
138
|
-
output.writeln();
|
|
139
|
-
output.writeln(output.dim('Re-checking...'));
|
|
140
|
-
output.writeln();
|
|
141
|
-
const reResults = await Promise.allSettled(checksToRun.map(check => check()));
|
|
142
|
-
let rePassed = 0, reWarnings = 0, reFailed = 0;
|
|
143
|
-
for (const sr of reResults) {
|
|
144
|
-
if (sr.status === 'fulfilled') {
|
|
145
|
-
output.writeln(formatCheck(sr.value));
|
|
146
|
-
if (sr.value.status === 'pass')
|
|
186
|
+
let rePassed = 0, reWarnings = 0, reFailed = 0;
|
|
187
|
+
for (const r of reEvaluated) {
|
|
188
|
+
output.writeln(formatCheck(r));
|
|
189
|
+
if (r.status === 'pass')
|
|
147
190
|
rePassed++;
|
|
148
|
-
else if (
|
|
191
|
+
else if (r.status === 'warn')
|
|
149
192
|
reWarnings++;
|
|
150
193
|
else
|
|
151
194
|
reFailed++;
|
|
152
195
|
}
|
|
196
|
+
output.writeln();
|
|
197
|
+
output.writeln(output.dim('─'.repeat(50)));
|
|
198
|
+
const reSummary = [
|
|
199
|
+
output.success(`${rePassed} passed`),
|
|
200
|
+
reWarnings > 0 ? output.warning(`${reWarnings} warnings`) : null,
|
|
201
|
+
reFailed > 0 ? output.error(`${reFailed} failed`) : null,
|
|
202
|
+
].filter(Boolean);
|
|
203
|
+
output.writeln(`After fix: ${reSummary.join(', ')}`);
|
|
153
204
|
}
|
|
154
|
-
|
|
155
|
-
output.writeln(output.dim('─'.repeat(50)));
|
|
156
|
-
const reSummary = [
|
|
157
|
-
output.success(`${rePassed} passed`),
|
|
158
|
-
reWarnings > 0 ? output.warning(`${reWarnings} warnings`) : null,
|
|
159
|
-
reFailed > 0 ? output.error(`${reFailed} failed`) : null,
|
|
160
|
-
].filter(Boolean);
|
|
161
|
-
output.writeln(`After fix: ${reSummary.join(', ')}`);
|
|
205
|
+
return { fixesApplied, reEvaluated };
|
|
162
206
|
}
|
|
163
207
|
/**
|
|
164
208
|
* Build the final CommandResult based on pass/warn/fail counts and --strict
|
|
@@ -27,7 +27,7 @@ function readCurrentVersion() {
|
|
|
27
27
|
const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
28
28
|
if (pkg.version &&
|
|
29
29
|
typeof pkg.name === 'string' &&
|
|
30
|
-
(pkg.name === 'moflo' || pkg.name === 'claude-flow'
|
|
30
|
+
(pkg.name === 'moflo' || pkg.name === 'claude-flow')) {
|
|
31
31
|
return pkg.version;
|
|
32
32
|
}
|
|
33
33
|
}
|