moflo 4.10.1 → 4.10.2

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.`
@@ -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
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { output } from '../output.js';
14
14
  import { allChecks, componentMap, zombieScanCheck } from './doctor-registry.js';
15
- import { emitJsonOutput, finalize, formatCheck, maybeAutoInstallClaudeCode, renderSummary, runAutoFix, runKillZombiesBanner, } from './doctor-render.js';
15
+ import { emitJsonOutput, finalize, formatCheck, maybeAutoInstallClaudeCode, renderSummary, runAutoFix, runKillZombies, } from './doctor-render.js';
16
16
  import { checkEmbeddings } from './doctor-checks-memory.js';
17
17
  import { checkMofloYamlCompliance } from './doctor-checks-config.js';
18
18
  // Re-export for tests + external consumers (#639 stale-vector-stats test
@@ -125,24 +125,21 @@ export const doctorCommand = {
125
125
  output.writeln(output.warning('--allow-warn requires --strict; ignoring (warnings are tolerated by default).'));
126
126
  output.writeln();
127
127
  }
128
- if (killZombies) {
129
- await runKillZombiesBanner();
130
- }
131
128
  const checksToRun = component && componentMap[component]
132
129
  ? [componentMap[component]]
133
130
  : allChecks;
134
131
  const results = [];
135
132
  const fixes = [];
136
- // OPTIMIZATION: Run all checks in parallel for 3-5x faster execution
137
- const spinner = jsonOutput
138
- ? null
139
- : output.createSpinner({ text: 'Running health checks in parallel...', spinner: 'dots' });
140
- spinner?.start();
133
+ let zombieScan;
134
+ let claudeCodeInstall;
135
+ let fixesApplied;
141
136
  // Issue #818: in --json mode, several deep checks (spell engine probe,
142
137
  // mcp-spell bridge, etc.) write `[spell] ...` log lines straight to
143
138
  // stdout — that breaks the single-JSON-document contract. Capture and
144
- // discard stdout writes while checks run; restore in `finally` so a
145
- // throw can't leave the process with a stubbed stdout.
139
+ // discard stdout writes while checks AND post-check actions run; restore
140
+ // in `finally` so a throw can't leave the process with a stubbed stdout.
141
+ // Issue #1122: extended to wrap zombie-kill banner, --install, and
142
+ // --fix work so each runs on the JSON path with prose suppressed.
146
143
  const realStdoutWrite = process.stdout.write.bind(process.stdout);
147
144
  const restoreStdout = () => {
148
145
  if (jsonOutput) {
@@ -153,7 +150,18 @@ export const doctorCommand = {
153
150
  process.stdout.write =
154
151
  (..._args) => true;
155
152
  }
153
+ // OPTIMIZATION: Run all checks in parallel for 3-5x faster execution
154
+ const spinner = jsonOutput
155
+ ? null
156
+ : output.createSpinner({ text: 'Running health checks in parallel...', spinner: 'dots' });
156
157
  try {
158
+ // Issue #1122: kill-zombies prose used to write BEFORE the JSON
159
+ // suppression activated, corrupting the JSON document. Now runs
160
+ // under suppression and feeds a structured result into the payload.
161
+ if (killZombies) {
162
+ zombieScan = await runKillZombies({ silent: jsonOutput });
163
+ }
164
+ spinner?.start();
157
165
  let checkResults;
158
166
  try {
159
167
  checkResults = await Promise.allSettled(checksToRun.map(check => check()));
@@ -174,7 +182,6 @@ export const doctorCommand = {
174
182
  }
175
183
  finally {
176
184
  spinner?.stop();
177
- restoreStdout();
178
185
  }
179
186
  for (const settledResult of checkResults) {
180
187
  if (settledResult.status === 'fulfilled') {
@@ -197,26 +204,64 @@ export const doctorCommand = {
197
204
  output.writeln(formatCheck(errorResult));
198
205
  }
199
206
  }
207
+ // Issue #1122: action flags must run on BOTH the JSON path and the
208
+ // formatted path. Previously the JSON branch early-returned before
209
+ // any of this ran, so `--json --fix` (and `--json --install`) silently
210
+ // no-op'd. Now they execute under stdout suppression and their
211
+ // outcomes feed the JSON payload below.
212
+ if (autoInstall) {
213
+ claudeCodeInstall = await maybeAutoInstallClaudeCode(results, fixes, { silent: jsonOutput });
214
+ }
215
+ if (!jsonOutput)
216
+ renderSummary(results);
217
+ if (showFix && fixes.length > 0) {
218
+ const outcome = await runAutoFix(results, fixes, checksToRun, { silent: jsonOutput });
219
+ fixesApplied = outcome.fixesApplied;
220
+ // Replace `results` with post-fix state so JSON consumers see the
221
+ // re-evaluated truth, not the pre-fix snapshot. Mirror the #992
222
+ // post-parallel zombie-scan append so the post-fix shape matches
223
+ // pre-fix shape (otherwise `--json --fix` silently drops the
224
+ // Zombie Processes entry from the JSON `results[]`).
225
+ if (outcome.reEvaluated) {
226
+ const finalChecks = [...outcome.reEvaluated];
227
+ if (!component) {
228
+ try {
229
+ finalChecks.push(await zombieScanCheck());
230
+ }
231
+ catch (reason) {
232
+ finalChecks.push({
233
+ name: 'Zombie Processes',
234
+ status: 'fail',
235
+ message: reason?.message ?? 'Unknown error',
236
+ });
237
+ }
238
+ }
239
+ results.length = 0;
240
+ results.push(...finalChecks);
241
+ }
242
+ }
243
+ else if (fixes.length > 0 && !showFix && !jsonOutput) {
244
+ output.writeln();
245
+ output.writeln(output.dim(`Run with --fix to auto-fix ${fixes.length} issue${fixes.length > 1 ? 's' : ''}`));
246
+ }
200
247
  }
201
248
  catch {
202
249
  spinner?.stop();
203
- restoreStdout();
204
250
  if (!jsonOutput)
205
251
  output.writeln(output.error('Failed to run health checks'));
206
252
  }
207
- if (jsonOutput) {
208
- return emitJsonOutput({ results, strict, allowWarnList });
209
- }
210
- if (autoInstall) {
211
- await maybeAutoInstallClaudeCode(results, fixes);
212
- }
213
- renderSummary(results);
214
- if (showFix && fixes.length > 0) {
215
- await runAutoFix(results, fixes, checksToRun);
253
+ finally {
254
+ restoreStdout();
216
255
  }
217
- else if (fixes.length > 0 && !showFix) {
218
- output.writeln();
219
- output.writeln(output.dim(`Run with --fix to auto-fix ${fixes.length} issue${fixes.length > 1 ? 's' : ''}`));
256
+ if (jsonOutput) {
257
+ return emitJsonOutput({
258
+ results,
259
+ strict,
260
+ allowWarnList,
261
+ fixesApplied,
262
+ zombieScan,
263
+ claudeCodeInstall,
264
+ });
220
265
  }
221
266
  return finalize({ results, strict, allowWarnList });
222
267
  },
@@ -95,6 +95,27 @@ export function logBridgeError(context, err, opts) {
95
95
  const msg = errorDetail(err);
96
96
  console.error(`[moflo] ${context}: ${msg}`);
97
97
  }
98
+ /**
99
+ * Recognises the node:sqlite "operation on closed handle" error shape.
100
+ *
101
+ * #1123 — A concurrent `withDb` call's `checkBridgeCoherence` can fire
102
+ * `shutdownBridge()` between our `getDb(registry)` and `fn(ctx, registry)`,
103
+ * closing the underlying `DatabaseSync`. Our previously-captured `ctx.db`
104
+ * then throws `ERR_INVALID_STATE: database is not open` on the next op.
105
+ *
106
+ * The operation hadn't started its mutation yet, so a single retry against a
107
+ * fresh registry is safe (matches the `withBusyRetry` shape for SQLITE_BUSY).
108
+ * Bounded to one retry so a *genuinely* broken DB still surfaces — we don't
109
+ * want to mask a registry that can't be re-acquired.
110
+ */
111
+ function isStaleHandleError(err) {
112
+ if (!err || typeof err !== 'object')
113
+ return false;
114
+ const e = err;
115
+ if (e.code === 'ERR_INVALID_STATE')
116
+ return true;
117
+ return typeof e.message === 'string' && /database is not open/i.test(e.message);
118
+ }
98
119
  /**
99
120
  * Treats an error as a SQLITE_BUSY lock-contention failure if either the
100
121
  * error code or message indicates it. Belt-and-suspenders around node:sqlite,
@@ -456,6 +477,9 @@ async function checkBridgeCoherence(dbPath) {
456
477
  * self-fire is suppressed.
457
478
  */
458
479
  export async function withDb(dbPath, fn) {
480
+ return withDbInner(dbPath, fn, 0);
481
+ }
482
+ async function withDbInner(dbPath, fn, attempt) {
459
483
  await checkBridgeCoherence(dbPath);
460
484
  const registry = await getRegistry(dbPath);
461
485
  if (!registry)
@@ -510,6 +534,18 @@ export async function withDb(dbPath, fn) {
510
534
  return result;
511
535
  }
512
536
  catch (err) {
537
+ // #1123 — stale-handle race: a concurrent withDb's coherence check tore
538
+ // the registry down between our getDb() and fn() execution, closing the
539
+ // underlying DatabaseSync. Drop the dead handle and retry once against a
540
+ // freshly-acquired registry. The first attempt threw BEFORE its mutation
541
+ // landed (node:sqlite errors at prepare/exec time, not mid-statement), so
542
+ // a retry is idempotent. Bounded to one retry so a genuinely-unrecoverable
543
+ // bridge (e.g. corrupt file, missing module) still surfaces as a null
544
+ // return + logged error, not an infinite loop.
545
+ if (attempt === 0 && isStaleHandleError(err)) {
546
+ await shutdownBridge();
547
+ return await withDbInner(dbPath, fn, attempt + 1);
548
+ }
513
549
  logBridgeError('bridge operation failed', err);
514
550
  return null;
515
551
  }
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.10.1';
5
+ export const VERSION = '4.10.2';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.10.1",
3
+ "version": "4.10.2",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "type": "module",
@@ -95,7 +95,7 @@
95
95
  "@typescript-eslint/eslint-plugin": "^7.18.0",
96
96
  "@typescript-eslint/parser": "^7.18.0",
97
97
  "eslint": "^8.0.0",
98
- "moflo": "^4.10.0",
98
+ "moflo": "^4.10.1",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"