moflo 4.10.8 → 4.10.10

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.
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: luminarium
3
+ description: |
4
+ Print the localhost URL for The Luminarium — moflo's daemon dashboard — for the current project.
5
+ Use when the user asks for "the luminarium link", "the moflo dashboard", "the daemon UI", or anything synonymous.
6
+ Each project gets a deterministic port in 33000–33999; the actual bound port is recorded in `.moflo/daemon.lock`.
7
+ ---
8
+
9
+ # /luminarium — Project Dashboard Link
10
+
11
+ Surface the URL for The Luminarium (the moflo daemon's localhost UI) for the project that this session is running in. No prompts, no confirmations — print the link and stop.
12
+
13
+ ## Procedure
14
+
15
+ 1. **Find the project root.** Walk up from `process.cwd()` looking for a `.moflo/` directory. The session's cwd is almost always the project root, so check there first.
16
+
17
+ 2. **Read `.moflo/daemon.lock`.** It's a JSON file written by the daemon at bind time. The dashboard port is the `port` field:
18
+
19
+ ```json
20
+ { "pid": 12345, "port": 33421, "startedAt": "...", ... }
21
+ ```
22
+
23
+ - If the file exists and `port` is a valid number → the daemon is running and bound. Use that port.
24
+ - If the file is missing or `port` is absent/invalid → the daemon is not running. See step 4.
25
+
26
+ 3. **Print the link** in a single line, with the path verbatim — Claude Code renders it as clickable:
27
+
28
+ ```
29
+ The Luminarium: http://localhost:<port>
30
+ ```
31
+
32
+ Nothing else. No banner, no follow-up question, no "what would you like to do?".
33
+
34
+ 4. **If the daemon isn't running** (no lock file, or unparseable), say so in one line and offer the start command — don't run it:
35
+
36
+ ```
37
+ The moflo daemon isn't running for this project. Start it with: npx flo daemon start
38
+ ```
39
+
40
+ ## Why read the lock, not compute the port
41
+
42
+ The port is project-deterministic (sha256(projectRoot) mapped into 33000–33999), but if the deterministic port was already taken at bind time the daemon scans forward and binds an alternate. The lock file is the only source of truth for what's actually bound. Do not compute the hash yourself — read the file.
43
+
44
+ ## Don't
45
+
46
+ - Don't fall back to any hardcoded port — there is no project-agnostic dashboard port; a literal would route to a foreign daemon on a multi-project machine. If the lock is missing, report "not running".
47
+ - Don't compute the deterministic port and report it as the link when the lock is missing — the daemon may be down, or bound to an alternate port. Report "not running" instead.
48
+ - Don't run `flo daemon start` automatically — the user asked for a link, not for daemon management. Leave starting to `/healer` or the user.
49
+ - Don't open a browser. Print the URL; let the user click.
50
+
51
+ ## Output
52
+
53
+ A single line. Examples:
54
+
55
+ ```
56
+ The Luminarium: http://localhost:33421
57
+ ```
58
+
59
+ ```
60
+ The moflo daemon isn't running for this project. Start it with: npx flo daemon start
61
+ ```
62
+
63
+ ## See Also
64
+
65
+ - `/healer` — diagnoses and (with `--fix`) starts the daemon if it's not running.
66
+ - `src/cli/services/daemon-port.ts` (and its JS twin `bin/lib/daemon-port.mjs`) — canonical port-resolution helpers; `resolveClientPort()` is what the rest of moflo uses.
package/bin/hooks.mjs CHANGED
@@ -36,14 +36,15 @@ const __dirname = dirname(__filename);
36
36
  // projects, so __dirname-relative paths break. findProjectRoot() works
37
37
  // everywhere and resolves identically to the TS bridge (see lib/moflo-paths.mjs).
38
38
  const projectRoot = findProjectRoot();
39
- const logFile = resolve(projectRoot, '.swarm/hooks.log');
39
+ const logFile = resolve(projectRoot, '.moflo', 'logs', 'hooks.log');
40
+ try { mkdirSync(dirname(logFile), { recursive: true }); } catch { /* best effort */ }
40
41
  const pm = createProcessManager(projectRoot);
41
42
 
42
43
  // Parse command line args
43
44
  const args = process.argv.slice(2);
44
45
  const hookType = args[0];
45
46
 
46
- // Simple log function - writes to .swarm/hooks.log
47
+ // Simple log function - writes to .moflo/logs/hooks.log
47
48
  function log(level, message) {
48
49
  const timestamp = new Date().toISOString();
49
50
  const line = `[${timestamp}] [${level.toUpperCase()}] [${hookType || 'unknown'}] ${message}\n`;
package/bin/index-all.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  * Spawned as a single detached background process by hooks.mjs session-start.
14
14
  */
15
15
 
16
- import { existsSync, appendFileSync, readFileSync } from 'fs';
16
+ import { existsSync, appendFileSync, readFileSync, mkdirSync } from 'fs';
17
17
  import { resolve, dirname } from 'path';
18
18
  import { fileURLToPath } from 'url';
19
19
  import { spawn, spawnSync } from 'child_process';
@@ -43,7 +43,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
43
43
  // so __dirname-relative paths break. findProjectRoot() (lib/moflo-paths.mjs)
44
44
  // works in both locations and resolves identically to the TS bridge.
45
45
  const projectRoot = findProjectRoot();
46
- const LOG_PATH = resolve(projectRoot, '.swarm/hooks.log');
46
+ const LOG_PATH = resolve(projectRoot, '.moflo', 'logs', 'hooks.log');
47
+ try { mkdirSync(dirname(LOG_PATH), { recursive: true }); } catch { /* best effort */ }
47
48
 
48
49
  function log(msg) {
49
50
  const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
@@ -845,16 +845,16 @@ if (!skipEmbeddings && needsEmbeddings) {
845
845
 
846
846
  if (embeddingScript) {
847
847
  // Register the spawn with the shared ProcessManager (#886). Stdout/stderr
848
- // route through `.swarm/background.log` (pm.spawn default) instead of the
849
- // bespoke `.moflo/logs/embeddings.log` so the registry, dedup, and
850
- // session-end drain stay consistent with every other tracked spawn.
848
+ // route through `.moflo/logs/background.log` (pm.spawn default) so the
849
+ // registry, dedup, and session-end drain stay consistent with every other
850
+ // tracked spawn.
851
851
  const pm = createProcessManager(projectRoot);
852
852
  const result = pm.spawn('node', [embeddingScript, '--namespace', NAMESPACE], `build-embeddings-${NAMESPACE}`);
853
853
  if (result.skipped) {
854
854
  log(`Background embedding already running (PID: ${result.pid})`);
855
855
  } else if (result.pid) {
856
856
  log(`Background embedding started (PID: ${result.pid})`);
857
- log(`Log file: .swarm/background.log`);
857
+ log(`Log file: .moflo/logs/background.log`);
858
858
  } else {
859
859
  log('⚠️ Failed to spawn background embedding');
860
860
  }
@@ -164,9 +164,9 @@ export function createProcessManager(root) {
164
164
  // This ensures errors from background indexers/pretrain are captured
165
165
  let stdio = 'ignore';
166
166
  try {
167
- const swarmDir = resolve(projectRoot, '.swarm');
168
- ensureDir(swarmDir);
169
- const logPath = resolve(swarmDir, 'background.log');
167
+ const logsDir = resolve(projectRoot, '.moflo', 'logs');
168
+ ensureDir(logsDir);
169
+ const logPath = resolve(logsDir, 'background.log');
170
170
  const fd = openSync(logPath, 'a');
171
171
  stdio = ['ignore', fd, fd];
172
172
  } catch {
@@ -7,8 +7,8 @@ import { existsSync, readFileSync, statSync } from 'fs';
7
7
  import { join } from 'path';
8
8
  import os from 'os';
9
9
  import { findProjectDaemonPids, getDaemonLockHolder, getDaemonLockPayload, } from '../services/daemon-lock.js';
10
- import { resolveClientPort, LEGACY_DEFAULT_PORT, probeDaemonHealth as probeDaemonHealthIdentity, normalizeProjectRoot, } from '../services/daemon-port.js';
11
- import { legacyMemoryDbPath, memoryDbCandidatePaths, memoryDbPath, } from '../services/moflo-paths.js';
10
+ import { resolveClientPort, LEGACY_DEFAULT_PORT, probeDaemonHealthWithRetry as probeDaemonHealthIdentity, normalizeProjectRoot, } from '../services/daemon-port.js';
11
+ import { LEGACY_SWARM_DIR, memoryDbCandidatePaths, memoryDbPath, } from '../services/moflo-paths.js';
12
12
  import { probeDbIntegrity } from '../services/memory-db-integrity-repair.js';
13
13
  import { findProjectRoot } from '../services/project-root.js';
14
14
  import { errorDetail } from '../shared/utils/error-detail.js';
@@ -253,14 +253,10 @@ export async function checkMemoryDatabase() {
253
253
  }
254
254
  const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
255
255
  if (dbPath === canonical) {
256
- let message = `.moflo/moflo.db (${sizeMB} MB)`;
257
- // Unfinished migration tail: source still present means the launcher's
258
- // rename-to-.bak step failed (Windows lock most often). Flag so the user
259
- // knows to clear the stale source.
260
- if (existsSync(legacyMemoryDbPath(root))) {
261
- message += ' — legacy .swarm/memory.db still present (delete it after confirming canonical is healthy)';
262
- }
263
- return { name: 'Memory Database', status: 'pass', message };
256
+ // Legacy `.swarm/memory.db` residue is owned by the separate
257
+ // `checkSwarmResidue` check so we keep this check focused on the
258
+ // canonical DB. That check carries the auto-fix.
259
+ return { name: 'Memory Database', status: 'pass', message: `.moflo/moflo.db (${sizeMB} MB)` };
264
260
  }
265
261
  return {
266
262
  name: 'Memory Database',
@@ -271,6 +267,44 @@ export async function checkMemoryDatabase() {
271
267
  }
272
268
  return { name: 'Memory Database', status: 'warn', message: 'Not initialized', fix: 'claude-flow memory configure --backend hybrid' };
273
269
  }
270
+ /**
271
+ * Catches `.swarm/` residue that survived past the canonical migration:
272
+ * - `memory.db` / `memory.db.bak` — stale once `.moflo/moflo.db` exists.
273
+ * - `q-learning-model.json` / `model-router-state.json` — live router state
274
+ * that pre-dates the `.moflo/movector/` defaults; migrate, don't delete.
275
+ * - `hooks.log` / `background.log` — diagnostic logs the launcher used to
276
+ * route to `.swarm/`; relocate to `.moflo/logs/`.
277
+ *
278
+ * Passes when `.swarm/` is absent OR contains nothing the migrator recognises.
279
+ * Otherwise warns with `fix: 'flo healer --fix -c swarm-residue'` so the auto-fix
280
+ * dispatcher (`fixSwarmLegacyResidue` in doctor-fixes.ts) can clean it up in
281
+ * one pass.
282
+ */
283
+ export async function checkSwarmResidue() {
284
+ const root = findProjectRoot();
285
+ const swarmDir = join(root, LEGACY_SWARM_DIR);
286
+ if (!existsSync(swarmDir)) {
287
+ return { name: 'Swarm Residue', status: 'pass', message: 'No .swarm/ directory present' };
288
+ }
289
+ const artifacts = [
290
+ 'memory.db',
291
+ 'memory.db.bak',
292
+ 'q-learning-model.json',
293
+ 'model-router-state.json',
294
+ 'hooks.log',
295
+ 'background.log',
296
+ ];
297
+ const present = artifacts.filter(name => existsSync(join(swarmDir, name)));
298
+ if (present.length === 0) {
299
+ return { name: 'Swarm Residue', status: 'pass', message: '.swarm/ present but no known residue' };
300
+ }
301
+ return {
302
+ name: 'Swarm Residue',
303
+ status: 'warn',
304
+ message: `${present.length} legacy artifact(s) in .swarm/: ${present.join(', ')}`,
305
+ fix: 'flo healer --fix -c swarm-residue',
306
+ };
307
+ }
274
308
  /**
275
309
  * Tier-1 corruption probe for `.moflo/moflo.db`. Runs `PRAGMA integrity_check`
276
310
  * via a raw node:sqlite readonly handle — bypasses `openBackend` because that
@@ -5,7 +5,7 @@
5
5
  * shell-out where possible). Falls back to running the check's `fix` string
6
6
  * if it looks like an `npx`/`npm`/`claude` command.
7
7
  */
8
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { output } from '../output.js';
11
11
  import { errorDetail } from '../shared/utils/error-detail.js';
@@ -13,6 +13,7 @@ import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
13
13
  import { repairHookWiring } from '../services/hook-wiring.js';
14
14
  import { getDaemonLockHolder } from '../services/daemon-lock.js';
15
15
  import { findProjectRoot } from '../services/project-root.js';
16
+ import { legacyMemoryDbPath, legacyMemoryDbBakPath, memoryDbPath, mofloDir } from '../services/moflo-paths.js';
16
17
  import { findZombieProcesses } from './doctor-zombies.js';
17
18
  import { inspectMcpConfigs } from './doctor-checks-config.js';
18
19
  import { installClaudeCode, runCommand } from './doctor-checks-runtime.js';
@@ -94,6 +95,131 @@ async function fixGateHealthHooks() {
94
95
  }
95
96
  return driftFixed && wiringFixed;
96
97
  }
98
+ /**
99
+ * Migrate `.swarm/` residue to its canonical home and remove the legacy directory.
100
+ *
101
+ * Three categories of artifact:
102
+ * 1. `memory.db` (+ `.bak`) — stale once `.moflo/moflo.db` exists; delete.
103
+ * 2. `q-learning-model.json` / `model-router-state.json` — live RL state.
104
+ * Rename into `.moflo/movector/`. If the canonical target already exists
105
+ * (consumer ran moflo on the new defaults before the auto-fix), the
106
+ * canonical wins and the `.swarm/` copy is unlinked.
107
+ * 3. `hooks.log` / `background.log` — diagnostic logs. Relocate to
108
+ * `.moflo/logs/`; append into the canonical file if it already exists
109
+ * so we don't drop history. Best-effort — log loss is acceptable.
110
+ *
111
+ * Finally `rmdir .swarm/` if and only if it's empty. Anything we didn't
112
+ * recognise is left in place rather than silently deleted.
113
+ *
114
+ * Cross-platform: uses `fs.rename`/`rmdir` (Node primitives), forward-slash-free
115
+ * joins via `path.join`. Works on Windows + POSIX.
116
+ *
117
+ * Returns true if the directory was fully retired OR if there was nothing to
118
+ * migrate; false if any relocation throws or `.swarm/` survives with unknown
119
+ * contents.
120
+ */
121
+ async function fixSwarmLegacyResidue() {
122
+ const root = findProjectRoot();
123
+ const swarmDir = join(root, '.swarm');
124
+ if (!existsSync(swarmDir))
125
+ return true;
126
+ const canonicalDb = memoryDbPath(root);
127
+ const moflo = mofloDir(root);
128
+ const movectorDir = join(moflo, 'movector');
129
+ const logsDir = join(moflo, 'logs');
130
+ let allMigrated = true;
131
+ // (1) memory.db + .bak — both are migration artifacts of the launcher's
132
+ // copy-verify-rename step; if the canonical isn't yet in place neither
133
+ // source is safe to delete. The launcher creates the `.bak` only AFTER
134
+ // canonical exists, so this guard is conservative but correct.
135
+ const legacyDbPaths = [
136
+ ['memory.db', legacyMemoryDbPath(root)],
137
+ ['memory.db.bak', legacyMemoryDbBakPath(root)],
138
+ ];
139
+ for (const [name, src] of legacyDbPaths) {
140
+ if (!existsSync(src))
141
+ continue;
142
+ if (!existsSync(canonicalDb)) {
143
+ output.writeln(output.warning(` Skipping ${name}: .moflo/moflo.db absent — run \`flo memory init\` first.`));
144
+ allMigrated = false;
145
+ continue;
146
+ }
147
+ try {
148
+ unlinkSync(src);
149
+ }
150
+ catch (e) {
151
+ output.writeln(output.warning(` Failed to delete ${name}: ${errorDetail(e)}`));
152
+ allMigrated = false;
153
+ }
154
+ }
155
+ // (2) router state JSONs — rename into .moflo/movector/.
156
+ const stateFiles = [
157
+ { name: 'q-learning-model.json', dest: movectorDir },
158
+ { name: 'model-router-state.json', dest: movectorDir },
159
+ ];
160
+ for (const { name, dest } of stateFiles) {
161
+ const src = join(swarmDir, name);
162
+ if (!existsSync(src))
163
+ continue;
164
+ const target = join(dest, name);
165
+ try {
166
+ mkdirSync(dest, { recursive: true });
167
+ if (existsSync(target)) {
168
+ // Canonical already populated by a fresh save on the new defaults.
169
+ // Keep the canonical, drop the legacy copy.
170
+ unlinkSync(src);
171
+ }
172
+ else {
173
+ renameSync(src, target);
174
+ }
175
+ }
176
+ catch (e) {
177
+ output.writeln(output.warning(` Failed to relocate ${name}: ${errorDetail(e)}`));
178
+ allMigrated = false;
179
+ }
180
+ }
181
+ // (3) logs — best-effort move. Append into canonical if it already exists
182
+ // (don't drop history). Hook + background logs are bounded to kilobytes in
183
+ // practice so the read-into-memory cost is acceptable.
184
+ const logFiles = ['hooks.log', 'background.log'];
185
+ for (const name of logFiles) {
186
+ const src = join(swarmDir, name);
187
+ if (!existsSync(src))
188
+ continue;
189
+ const target = join(logsDir, name);
190
+ try {
191
+ mkdirSync(logsDir, { recursive: true });
192
+ if (existsSync(target)) {
193
+ appendFileSync(target, readFileSync(src));
194
+ unlinkSync(src);
195
+ }
196
+ else {
197
+ renameSync(src, target);
198
+ }
199
+ }
200
+ catch (e) {
201
+ output.writeln(output.warning(` Failed to relocate ${name}: ${errorDetail(e)}`));
202
+ allMigrated = false;
203
+ }
204
+ }
205
+ // (4) rmdir .swarm/ if it's empty. Anything left is unrecognised — leave it
206
+ // for the user to inspect rather than silently delete.
207
+ try {
208
+ const remaining = readdirSync(swarmDir);
209
+ if (remaining.length === 0) {
210
+ rmdirSync(swarmDir);
211
+ }
212
+ else {
213
+ output.writeln(output.dim(` .swarm/ kept (${remaining.length} unrecognised entr${remaining.length === 1 ? 'y' : 'ies'}): ${remaining.join(', ')}`));
214
+ allMigrated = false;
215
+ }
216
+ }
217
+ catch (e) {
218
+ output.writeln(output.warning(` Failed to remove .swarm/: ${errorDetail(e)}`));
219
+ allMigrated = false;
220
+ }
221
+ return allMigrated;
222
+ }
97
223
  /**
98
224
  * Execute the fix for a failed/warned health check.
99
225
  * Returns true if the fix succeeded (re-check should pass).
@@ -440,6 +566,12 @@ export async function autoFixCheck(check) {
440
566
  return false;
441
567
  }
442
568
  },
569
+ // Migrate `.swarm/` residue (legacy memory.db, RL state JSONs, hook/bg logs)
570
+ // into their canonical `.moflo/` homes and rmdir the directory once empty.
571
+ // See `fixSwarmLegacyResidue` for the per-artifact policy.
572
+ 'Swarm Residue': async () => {
573
+ return fixSwarmLegacyResidue();
574
+ },
443
575
  'Status Line': async () => {
444
576
  const settingsPath = join(process.cwd(), '.claude', 'settings.json');
445
577
  if (!existsSync(settingsPath))
@@ -12,7 +12,7 @@ import { checkWritersAudit } from './doctor-checks-writers-audit.js';
12
12
  import { checkSwarmFunctional, checkHiveMindFunctional, } from './doctor-checks-swarm.js';
13
13
  import { checkMemoryAccessFunctional } from './doctor-checks-memory-access.js';
14
14
  import { checkBuildTools, checkClaudeCode, checkDiskSpace, checkGit, checkGitRepo, checkNodeVersion, checkNpmVersion, } from './doctor-checks-runtime.js';
15
- import { checkConfigFile, checkDaemonIdentity, checkDaemonOrphan, checkDaemonStatus, checkDaemonWriteRouting, checkMcpServers, checkMemoryDatabase, checkMemoryDbIntegrity, checkMofloYamlCompliance, checkStatusLine, checkTestDirs, } from './doctor-checks-config.js';
15
+ import { checkConfigFile, checkDaemonIdentity, checkDaemonOrphan, checkDaemonStatus, checkDaemonWriteRouting, checkMcpServers, checkMemoryDatabase, checkMemoryDbIntegrity, checkMofloYamlCompliance, checkStatusLine, checkSwarmResidue, checkTestDirs, } from './doctor-checks-config.js';
16
16
  import { checkSpellEngine, checkSandboxTier } from './doctor-checks-platform.js';
17
17
  import { checkEmbeddings, checkSemanticQuality, } from './doctor-checks-memory.js';
18
18
  import { checkIntelligence } from './doctor-checks-intelligence.js';
@@ -42,6 +42,10 @@ export const allChecks = [
42
42
  checkDaemonWriteRouting,
43
43
  checkWritersAudit,
44
44
  checkMemoryDatabase,
45
+ // Surfaces leftover `.swarm/` artifacts (memory.db, router state, logs) so
46
+ // the auto-fix can relocate or delete them. Independent of the canonical
47
+ // DB checks — runs cheap (statSync only).
48
+ checkSwarmResidue,
45
49
  // Owns the corruption signal so downstream checks (Embeddings, Semantic
46
50
  // Quality, Memory Access Functional) don't surface it as the synthetic
47
51
  // "Check" failure (doctor.ts:214). MUST run after checkMemoryDatabase
@@ -107,6 +111,8 @@ export const componentMap = {
107
111
  'writers-audit': checkWritersAudit,
108
112
  'writers': checkWritersAudit,
109
113
  'memory': checkMemoryDatabase,
114
+ 'swarm-residue': checkSwarmResidue,
115
+ 'residue': checkSwarmResidue,
110
116
  'memory-db-integrity': checkMemoryDbIntegrity,
111
117
  'integrity': checkMemoryDbIntegrity,
112
118
  'memory-integrity': checkMemoryDbIntegrity,
@@ -35,6 +35,7 @@ export const SKILLS_MAP = {
35
35
  'guidance',
36
36
  'healer',
37
37
  'flo-simplify',
38
+ 'luminarium',
38
39
  'reasoningbank-intelligence',
39
40
  ],
40
41
  memory: [
@@ -80,7 +80,7 @@ const DEFAULT_CONFIG = {
80
80
  maxUncertainty: 0.15,
81
81
  enableCircuitBreaker: true,
82
82
  circuitBreakerThreshold: 5,
83
- statePath: '.swarm/model-router-state.json',
83
+ statePath: '.moflo/movector/model-router-state.json',
84
84
  autoSaveInterval: 1, // Save after every decision for CLI persistence
85
85
  enableCostOptimization: true,
86
86
  preferSpeed: true,
@@ -9,7 +9,7 @@
9
9
  * - Optimized state space with feature hashing
10
10
  * - Epsilon decay with exponential annealing
11
11
  * - Experience replay buffer for stable learning
12
- * - Model persistence to .swarm/q-learning-model.json
12
+ * - Model persistence to .moflo/movector/q-learning-model.json
13
13
  *
14
14
  * @module q-learning-router
15
15
  */
@@ -32,7 +32,7 @@ const DEFAULT_CONFIG = {
32
32
  enableReplay: true,
33
33
  cacheSize: 256,
34
34
  cacheTTL: 300000,
35
- modelPath: '.swarm/q-learning-model.json',
35
+ modelPath: '.moflo/movector/q-learning-model.json',
36
36
  autoSaveInterval: 100,
37
37
  stateSpaceDim: 64,
38
38
  };
@@ -798,6 +798,14 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
798
798
  .btn-primary { background: #238636; border-color: #2ea043; color: #fff; }
799
799
  .btn-primary:hover { background: #2ea043; border-color: #3fb950; }
800
800
  .dim { color: #484f58; font-size: 0.75rem; font-style: italic; }
801
+ /* Loading state for tabs whose data is slow on first paint (currently
802
+ Claude Stats, which walks the user's transcript dir — can take 10–15s
803
+ on a long history). Pure-CSS spinner; no image, no framework. */
804
+ .loading-block { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 48px 16px; color: #8b949e; }
805
+ .loading-block .spinner { width: 28px; height: 28px; border: 3px solid #30363d; border-top-color: #58a6ff; border-radius: 50%; animation: lum-spin 0.85s linear infinite; }
806
+ .loading-block .msg { font-size: 0.9rem; color: #c9d1d9; }
807
+ .loading-block .hint { font-size: 0.8rem; color: #8b949e; font-style: italic; max-width: 480px; text-align: center; line-height: 1.5; }
808
+ @keyframes lum-spin { to { transform: rotate(360deg); } }
801
809
  </style>
802
810
  </head>
803
811
  <body>
@@ -811,7 +819,7 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
811
819
  <div id="panel-schedules" class="panel" style="display:none"><div id="schedules-active"></div><div id="schedules-events"></div></div>
812
820
  <div id="panel-executions" class="panel" style="display:none"></div>
813
821
  <div id="panel-memory" class="panel" style="display:none"></div>
814
- <div id="panel-claude-stats" class="panel" style="display:none"></div>
822
+ <div id="panel-claude-stats" class="panel" style="display:none"><div class="loading-block" role="status" aria-label="Loading Claude Code transcripts"><div class="spinner"></div><div class="msg">Reading Claude Code transcripts…</div><div class="hint">First load can take 10–15 seconds — moflo walks every session file in this project's transcript directory. Subsequent loads in this tab are much faster.</div></div></div>
815
823
  <div id="poll-indicator" class="poll-indicator"></div>
816
824
  <script>
817
825
  // Tab navigation — plain DOM, no framework
@@ -1139,7 +1147,19 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
1139
1147
  };
1140
1148
  function renderClaudeStats(cs) {
1141
1149
  const el = document.getElementById('panel-claude-stats');
1142
- if (!cs) { el.innerHTML = '<div class="empty">Loading...</div>'; return; }
1150
+ // cs is null on first paint AND on fetch error (Promise chain uses
1151
+ // .catch(() => null)). Render the spinner block on both so the user
1152
+ // sees motion during the 10–15s transcript walk and during a transient
1153
+ // network blip — better than a static "Loading..." that looks frozen.
1154
+ if (!cs) {
1155
+ el.innerHTML =
1156
+ '<div class="loading-block" role="status" aria-label="Loading Claude Code transcripts">' +
1157
+ '<div class="spinner"></div>' +
1158
+ '<div class="msg">Reading Claude Code transcripts…</div>' +
1159
+ '<div class="hint">First load can take 10–15 seconds — moflo walks every session file in this project\\'s transcript directory. Subsequent loads in this tab are much faster.</div>' +
1160
+ '</div>';
1161
+ return;
1162
+ }
1143
1163
 
1144
1164
  // Always-visible disclaimer banner — keeps the scope and limits in
1145
1165
  // view so the numbers aren't read as account-wide truth.
@@ -214,4 +214,39 @@ export function probeDaemonHealth(port, timeoutMs) {
214
214
  req.on('timeout', () => { req.destroy(); finish({ kind: 'unreachable' }); });
215
215
  });
216
216
  }
217
+ /** Retry backoff schedule for HTTP liveness probes (#1163 — Windows CI race). */
218
+ const PROBE_RETRY_BACKOFF_MS = [50, 200, 800];
219
+ /**
220
+ * {@link probeDaemonHealth} with retry on transient `unreachable` results.
221
+ *
222
+ * The bare one-shot probe was tripping in CI when a daemon was mid-boot on
223
+ * Windows: the lockfile said v4.10.8 was alive but the HTTP server hadn't
224
+ * accepted /api/health yet, and the doctor check raised "unreachable" inside
225
+ * the 1500ms window. This wrapper retries 3× at 50/200/800 ms — total
226
+ * worst-case ~1s extra — and only on `unreachable`. `identity` and `legacy`
227
+ * are terminal: a daemon answering with a different project root or 404 is
228
+ * a real signal, not a race.
229
+ *
230
+ * Mirrors the retry pattern from `bin/lib/file-sync.mjs:syncWithRetry`
231
+ * (`feedback_transient_retry_circuit_breaker` — every transient-error op
232
+ * uses 50/200/800ms backoff).
233
+ *
234
+ * Worst-case elapsed = (PROBE_RETRY_BACKOFF_MS.length + 1) × timeoutMs
235
+ * + sum(PROBE_RETRY_BACKOFF_MS). For doctor's 1500ms timeout that's
236
+ * 4 × 1500 + 1050 ≈ 7s, fully off the hot path; callers picking a tighter
237
+ * timeout should account for the 4× multiplier.
238
+ */
239
+ export async function probeDaemonHealthWithRetry(port, timeoutMs) {
240
+ let last = { kind: 'unreachable' };
241
+ const maxAttempts = PROBE_RETRY_BACKOFF_MS.length + 1;
242
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
243
+ if (attempt > 0) {
244
+ await new Promise((resolve) => setTimeout(resolve, PROBE_RETRY_BACKOFF_MS[attempt - 1]));
245
+ }
246
+ last = await probeDaemonHealth(port, timeoutMs);
247
+ if (last.kind !== 'unreachable')
248
+ return last;
249
+ }
250
+ return last;
251
+ }
217
252
  //# sourceMappingURL=daemon-port.js.map
@@ -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.8';
5
+ export const VERSION = '4.10.10';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.10.8",
3
+ "version": "4.10.10",
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.7",
98
+ "moflo": "^4.10.9",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"