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.
@@ -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, also list which fixes were applied vs which need manual action.
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 actually killed.
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
- if (stalePid !== null) {
376
- try { process.kill(stalePid, 'SIGTERM'); } catch { /* already dead */ }
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 stalePid !== null;
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 ruflo-aspirational agents in #932 or
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 (upstream Ruflo) keep working
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 || 'ruflo' in servers || 'ruflo_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 ruflo -- npx -y ruflo@latest mcp start' };
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
- pushDetail(ctx.details, { id: `${ctx.idPrefix}.search-finds-key`, mcpTool: 'memory_search', expected: `result containing key=${key}` }, top ? { topKey: top.key, similarity: top.similarity } : { allKeys: searchOut.results?.map(r => r.key) }, top ? null : `stored key ${key} not in results (got: ${searchOut?.results?.map(r => r.key).join(', ') ?? 'none'})`);
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
- const groups = [];
159
- const result = db.exec(`SELECT
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
- GROUP BY model`);
166
- if (!result || result.length === 0)
167
- return [];
168
- const rows = result[0]?.values ?? [];
169
- for (const row of rows) {
170
- groups.push({
171
- model: String(row[0]),
172
- count: Number(row[1]),
173
- hasNullEmbedding: Number(row[2]) > 0,
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
- export async function runKillZombiesBanner() {
23
- output.writeln(output.bold('Zombie Process Scan'));
24
- output.writeln();
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 (found === 0) {
33
- if (registryKilled === 0) {
34
- output.writeln(output.success(' No orphaned moflo processes found'));
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
- if (result.killed < found) {
46
- output.writeln(output.warning(` ${found - result.killed} process(es) could not be killed`));
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
- output.writeln();
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. Skips auto-fix entirely —
57
- * --json is read-only by intent so CI gates can consume it without
58
- * mutating the working tree.
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
- process.stdout.write(JSON.stringify({
82
+ const payload = {
68
83
  summary: { passed, warnings, failed },
69
84
  strict: strict ? { strictMode: true, warningsTriggeringFail: strictWarningFailures } : { strictMode: false },
70
85
  results,
71
- }, null, 2) + '\n');
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
- /** Re-runs Claude Code CLI install + check if --install was passed and the prior result wasn't pass. */
75
- export async function maybeAutoInstallClaudeCode(results, fixes) {
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
- output.writeln(formatCheck(newCheck));
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
- /** Auto-fix loop, including the post-fix re-run. Mutates `results` and `fixes` in place when fixes succeed. */
107
- export async function runAutoFix(results, fixes, checksToRun) {
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
- output.writeln();
111
- output.writeln(output.bold('Auto-fixing issues...'));
112
- output.writeln();
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
- let fixed = 0;
115
- const unfixed = [];
155
+ const fixesApplied = [];
116
156
  for (const check of fixableResults) {
117
157
  const success = await autoFixCheck(check);
118
- if (success) {
119
- fixed++;
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
- else {
122
- unfixed.push(`${check.name}: ${check.fix}`);
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 > 0) {
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.success(`Auto-fixed ${fixed} issue${fixed > 1 ? 's' : ''}`));
128
- }
129
- if (unfixed.length > 0) {
184
+ output.writeln(output.dim('Re-checking...'));
130
185
  output.writeln();
131
- output.writeln(output.bold('Manual fixes needed:'));
132
- for (const fix of unfixed) {
133
- output.writeln(output.dim(` ${fix}`));
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 (sr.value.status === 'warn')
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
- output.writeln();
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' || pkg.name === 'ruflo')) {
30
+ (pkg.name === 'moflo' || pkg.name === 'claude-flow')) {
31
31
  return pkg.version;
32
32
  }
33
33
  }