moflo 4.9.0-rc.13 → 4.9.0-rc.14

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.
@@ -382,7 +382,7 @@ function getSwarmStatus() {
382
382
  function getSystemMetrics() {
383
383
  const memoryMB = Math.floor(process.memoryUsage().heapUsed / 1024 / 1024);
384
384
  const learning = getLearningStats();
385
- const agentdb = getAgentDBStats();
385
+ const embeddings = getEmbeddingsStats();
386
386
 
387
387
  // Intelligence from learning.json
388
388
  const learningData = readJSON(path.join(CWD, '.moflo', 'metrics', 'learning.json'));
@@ -393,7 +393,7 @@ function getSystemMetrics() {
393
393
  intelligencePct = Math.min(100, Math.floor(learningData.intelligence.score));
394
394
  } else {
395
395
  const fromPatterns = learning.patterns > 0 ? Math.min(100, Math.floor(learning.patterns / 10)) : 0;
396
- const fromVectors = agentdb.vectorCount > 0 ? Math.min(100, Math.floor(agentdb.vectorCount / 100)) : 0;
396
+ const fromVectors = embeddings.vectorCount > 0 ? Math.min(100, Math.floor(embeddings.vectorCount / 100)) : 0;
397
397
  intelligencePct = Math.max(fromPatterns, fromVectors);
398
398
  }
399
399
 
@@ -423,7 +423,7 @@ function getSystemMetrics() {
423
423
  subAgents = activityData.processes.estimated_agents;
424
424
  }
425
425
 
426
- return { memoryMB, contextPct, intelligencePct, subAgents };
426
+ return { memoryMB, contextPct, intelligencePct, subAgents, embeddings };
427
427
  }
428
428
 
429
429
  // ADR status (count files only — don't read contents)
@@ -484,9 +484,9 @@ function getHooksStatus() {
484
484
  return { enabled, total };
485
485
  }
486
486
 
487
- // AgentDB stats — reads from cache file written by embedding/memory operations.
487
+ // Embeddings stats — reads from cache file written by embedding/memory ops.
488
488
  // No subprocess spawning. Falls back to DB file size estimate if cache is missing.
489
- function getAgentDBStats() {
489
+ function getEmbeddingsStats() {
490
490
  let vectorCount = 0;
491
491
  let dbSizeKB = 0;
492
492
  let namespaces = 0;
@@ -601,20 +601,25 @@ function getIntegrationStatus() {
601
601
  return { mcpServers, hasDatabase, hasApi };
602
602
  }
603
603
 
604
- // Upgrade notice (#636, #738, #743) — written by the session-start launcher
605
- // ONLY while upgrade work is in flight; the launcher deletes the file when
606
- // work completes. We render it strictly for status='in-progress' so a stale
607
- // notice (legacy "complete" file from pre-#738 launchers, zombie write from
608
- // an aborted launcher, future writer mistakes) cannot turn the statusline
609
- // segment into a permanent column. The launcher's section 0-pre also drops
610
- // any leftover file at session start as a second line of defence.
604
+ // Upgrade notice (#636, #738, #743) — written by the session-start launcher.
605
+ // status='in-progress' work is running; rendered with "(updating…)".
606
+ // status='completed' — work just finished; short-TTL post-upgrade badge so
607
+ // the user sees something on the very next render
608
+ // (Claude Code only paints the statusline AFTER the
609
+ // SessionStart hook returns, so the in-progress badge
610
+ // has effectively zero visibility window).
611
+ // Anything else is dropped (legacy "complete" pre-#738 files, zombie writes,
612
+ // future writer mistakes) so a stale notice can never turn the segment into a
613
+ // permanent column. Section 0-pre of the launcher also wipes any leftover at
614
+ // session start as a second line of defence.
611
615
  function getUpgradeNotice() {
612
616
  const data = readJSON(path.join(CWD, '.moflo', 'upgrade-notice.json'));
613
617
  if (!data || typeof data !== 'object') return null;
614
- if (data.status !== 'in-progress') return null;
618
+ if (data.status !== 'in-progress' && data.status !== 'completed') return null;
615
619
  const expiresAt = data.expiresAt ? new Date(data.expiresAt).getTime() : 0;
616
620
  if (!expiresAt || Date.now() > expiresAt) return null;
617
621
  return {
622
+ status: data.status,
618
623
  kind: data.kind === 'repair' ? 'repair' : 'upgrade',
619
624
  from: typeof data.from === 'string' ? data.from : '',
620
625
  to: typeof data.to === 'string' ? data.to : '',
@@ -623,14 +628,20 @@ function getUpgradeNotice() {
623
628
 
624
629
  function formatUpgradeNoticeSegment(notice) {
625
630
  if (!notice) return '';
626
- const suffix = ` ${c.dim}(updating…)${c.reset}`;
631
+ const inFlight = notice.status === 'in-progress';
632
+ const suffix = inFlight ? ` ${c.dim}(updating…)${c.reset}` : '';
633
+ // Pick body text: repair > in-flight version range > completed "upgraded to"
634
+ // > bare "upgraded" fallback when no version is known.
635
+ let body;
627
636
  if (notice.kind === 'repair') {
628
- return `${c.brightYellow}📦 install repaired${c.reset}${suffix}`;
637
+ body = 'install repaired';
638
+ } else if (inFlight) {
639
+ body = notice.from && notice.to ? `${notice.from} → ${notice.to}` : (notice.to || 'upgraded');
640
+ } else {
641
+ const target = notice.to || notice.from || '';
642
+ body = target ? `upgraded to ${target}` : 'upgraded';
629
643
  }
630
- const versions = notice.from && notice.to
631
- ? `${notice.from} → ${notice.to}`
632
- : (notice.to || 'upgraded');
633
- return `${c.brightYellow}📦 ${versions}${c.reset}${suffix}`;
644
+ return `${c.brightYellow}📦 ${body}${c.reset}${suffix}`;
634
645
  }
635
646
 
636
647
  // Session stats (pure file reads)
@@ -784,6 +795,25 @@ function generateDashboard() {
784
795
  );
785
796
  }
786
797
 
798
+ // Embeddings line \u2014 vector store stats from .moflo/vector-stats.json.
799
+ // Reuses `system.embeddings` (already computed by getSystemMetrics()) instead
800
+ // of re-probing the cache file on every render.
801
+ {
802
+ const vec = system.embeddings;
803
+ if (vec.vectorCount > 0) {
804
+ const hnswInd = vec.hasHnsw ? `${c.brightGreen}\u26A1${c.reset}` : '';
805
+ const sizeDisp = vec.dbSizeKB >= 1024 ? `${(vec.dbSizeKB / 1024).toFixed(1)}MB` : `${vec.dbSizeKB}KB`;
806
+ const eParts = [
807
+ `${c.cyan}Vectors${c.reset} ${c.brightGreen}\u25CF${vec.vectorCount}${c.reset}${hnswInd}`,
808
+ `${c.cyan}Size${c.reset} ${c.brightWhite}${sizeDisp}${c.reset}`,
809
+ ];
810
+ if (vec.namespaces > 0) {
811
+ eParts.push(`${c.cyan}NS${c.reset} ${c.brightWhite}${vec.namespaces}${c.reset}`);
812
+ }
813
+ lines.push(`${c.brightCyan}\uD83D\uDCCA Embeddings${c.reset} ${eParts.join(` ${c.dim}\u2502${c.reset} `)}`);
814
+ }
815
+ }
816
+
787
817
  // MCP line
788
818
  if (SL_CONFIG.show_mcp) {
789
819
  const parts = [];
@@ -795,7 +825,7 @@ function generateDashboard() {
795
825
  }
796
826
  if (integration.hasDatabase) parts.push(`${c.brightGreen}\u25C6${c.reset}DB`);
797
827
  if (parts.length > 0) {
798
- lines.push(`${c.brightCyan}\uD83D\uDCCA MCP${c.reset} ${parts.join(` ${c.dim}\u2502${c.reset} `)}`);
828
+ lines.push(`${c.brightCyan}\uD83D\uDD0C MCP${c.reset} ${parts.join(` ${c.dim}\u2502${c.reset} `)}`);
799
829
  }
800
830
  }
801
831
 
@@ -835,7 +865,7 @@ function generateCompactDashboard() {
835
865
  pushUpgradeNoticeSegment(lines);
836
866
  lines.push(header);
837
867
 
838
- // Combined swarm + mcp line
868
+ // Combined swarm + embeddings + mcp line
839
869
  const segments = [];
840
870
  if (SL_CONFIG.show_swarm) {
841
871
  const swarm = getSwarmStatus();
@@ -845,6 +875,18 @@ function generateCompactDashboard() {
845
875
  `${c.brightYellow}\uD83E\uDD16${c.reset} ${swarmInd}[${agentsColor}${swarm.activeAgents}${c.reset}/${c.brightWhite}${swarm.maxAgents}${c.reset}]`
846
876
  );
847
877
  }
878
+ // Embeddings \u2014 always-on when vectorCount > 0; self-hides on a fresh install.
879
+ // Compact doesn't call getSystemMetrics() so this is the only probe per render.
880
+ {
881
+ const vec = getEmbeddingsStats();
882
+ if (vec.vectorCount > 0) {
883
+ const hnswInd = vec.hasHnsw ? '\u26A1' : '';
884
+ const sizeDisp = vec.dbSizeKB >= 1024 ? `${(vec.dbSizeKB / 1024).toFixed(1)}MB` : `${vec.dbSizeKB}KB`;
885
+ segments.push(
886
+ `${c.brightCyan}\uD83D\uDCCA${c.reset} ${c.brightGreen}${vec.vectorCount}${hnswInd}${c.reset} ${c.dim}(${sizeDisp})${c.reset}`
887
+ );
888
+ }
889
+ }
848
890
  if (SL_CONFIG.show_mcp) {
849
891
  const integration = getIntegrationStatus();
850
892
  if (integration.mcpServers.total > 0) {
@@ -863,15 +905,16 @@ function generateCompactDashboard() {
863
905
  // JSON output
864
906
  function generateJSON() {
865
907
  const git = getGitInfo();
908
+ const system = getSystemMetrics();
866
909
  return {
867
910
  user: { name: git.name, gitBranch: git.gitBranch, modelName: getModelName() },
868
911
  v3Progress: getV3Progress(),
869
912
  security: getSecurityStatus(),
870
913
  swarm: getSwarmStatus(),
871
- system: getSystemMetrics(),
914
+ system,
872
915
  adrs: getADRStatus(),
873
916
  hooks: getHooksStatus(),
874
- agentdb: getAgentDBStats(),
917
+ embeddings: system.embeddings,
875
918
  tests: getTestStats(),
876
919
  git: { modified: git.modified, untracked: git.untracked, staged: git.staged, ahead: git.ahead, behind: git.behind },
877
920
  upgradeNotice: getUpgradeNotice(),
package/README.md CHANGED
@@ -293,16 +293,16 @@ For simple epics with independent stories, `/flo <epic>` is all you need. For co
293
293
  `flo epic` is the robust epic runner — it adds persistent state, resume from failure, and per-story auto-merge on top of `/flo`. It takes a GitHub epic issue number:
294
294
 
295
295
  ```bash
296
- flo epic run 42 # Fetch epic #42, run all stories sequentially
297
- flo epic run 42 --dry-run # Preview execution plan without running
298
- flo epic run 42 --strategy auto-merge # Per-story PRs with auto-merge between stories
296
+ flo epic 42 # Fetch epic #42, run all stories sequentially
297
+ flo epic 42 --dry-run # Preview execution plan without running
298
+ flo epic 42 --strategy auto-merge # Per-story PRs with auto-merge between stories
299
299
  flo epic status 42 # Check progress (which stories passed/failed)
300
300
  flo epic reset 42 # Reset state for re-run
301
301
  ```
302
302
 
303
- `flo epic` fetches the epic from GitHub, extracts child stories from checklists, numbered references, and `## Stories` / `## Tasks` sections, then runs each through `/flo` with state tracking. If a story fails, you can fix the issue and `flo epic run 42` again — it resumes from where it left off, skipping already-passed stories.
303
+ `flo epic` fetches the epic from GitHub, extracts child stories from checklists, numbered references, and `## Stories` / `## Tasks` sections, then runs each through `/flo` with state tracking. If a story fails, you can fix the issue and re-run `flo epic 42` — it resumes from where it left off, skipping already-passed stories. (`flo epic run 42` is an explicit alias for the same shorthand.)
304
304
 
305
- | | `/flo <epic>` | `flo epic run <epic>` |
305
+ | | `/flo <epic>` | `flo epic <epic>` |
306
306
  |---|---|---|
307
307
  | **State tracking** | No | Yes (`epic-state` memory namespace) |
308
308
  | **Resume from failure** | No | Yes (skips passed stories) |
@@ -583,7 +583,7 @@ flo --version # Show version
583
583
 
584
584
  ### Hooks (enabled OOTB)
585
585
 
586
- Hooks are shell commands that Claude Code runs automatically at specific points in its lifecycle. MoFlo installs 20 hook bindings across 8 lifecycle events. You don't invoke these — they fire automatically.
586
+ Hooks are shell commands that Claude Code runs automatically at specific points in its lifecycle. MoFlo installs 23 hook bindings across 8 lifecycle events. You don't invoke these — they fire automatically.
587
587
 
588
588
  | Hook Event | What fires | What it does | Enabled OOTB |
589
589
  |------------|-----------|-------------|:---:|
@@ -593,9 +593,12 @@ Hooks are shell commands that Claude Code runs automatically at specific points
593
593
  | **PreToolUse: Bash** | `flo gate check-dangerous-command` | Safety check on shell commands | Yes |
594
594
  | **PreToolUse: Bash** | `flo gate check-before-pr` | Validates PR readiness before `gh pr create` | Yes |
595
595
  | **PostToolUse: Write/Edit** | `flo hooks post-edit` | Records edit outcome, optionally trains neural patterns | Yes |
596
+ | **PostToolUse: Write/Edit** | `flo gate reset-edit-gates` | Resets edit-related gate state after the write completes | Yes |
596
597
  | **PostToolUse: Agent** | `flo hooks post-task` | Records task completion, feeds outcome into routing learner | Yes |
597
598
  | **PostToolUse: TaskCreate** | `flo gate record-task-created` | Records that a task was registered (clears TaskCreate gate) | Yes |
598
599
  | **PostToolUse: Bash** | `flo gate check-bash-memory` | Detects memory search commands in Bash (clears memory gate) | Yes |
600
+ | **PostToolUse: Bash** | `flo gate record-test-run` | Records test runs from Bash for the test-output gate | Yes |
601
+ | **PostToolUse: Skill** | `flo gate record-skill-run` | Records that a skill was invoked (clears skill-related gates) | Yes |
599
602
  | **PostToolUse: memory_search** | `flo gate record-memory-searched` | Records that memory was searched (clears memory-first gate) | Yes |
600
603
  | **PostToolUse: TaskUpdate** | `flo gate check-task-transition` | Validates task state transitions (prevents skipping states) | Yes |
601
604
  | **PostToolUse: memory_store** | `flo gate record-learnings-stored` | Records that learnings were persisted to memory | Yes |
@@ -63,35 +63,36 @@ let upgradeNoticeContext = null;
63
63
  let pendingVersionStampWrite = null;
64
64
 
65
65
  // 5-min TTL is a safety net for zombie launchers (statusline ignores past-TTL
66
- // files). The launcher deletes the notice when upgrade work finishes no
67
- // "complete" state lingers, see #738.
66
+ // files). The 2-min "completed" TTL lets the user see the post-upgrade badge
67
+ // briefly in the next session render (Claude Code renders the statusline only
68
+ // AFTER the SessionStart hook returns, so the in-progress badge has effectively
69
+ // zero visibility window). The next session-start's section 0-pre wipes any
70
+ // leftover, so a stale completed notice can't linger past one session.
68
71
  const UPGRADE_NOTICE_INPROGRESS_TTL_MS = 5 * 60 * 1000;
72
+ const UPGRADE_NOTICE_COMPLETED_TTL_MS = 2 * 60 * 1000;
69
73
  const UPGRADE_NOTICE_PATH = () => join(mofloDir(projectRoot), 'upgrade-notice.json');
70
74
 
71
- function writeInProgressUpgradeNotice() {
75
+ function writeUpgradeNotice(status) {
72
76
  if (!upgradeNoticeContext) return;
77
+ const ttlMs = status === 'completed'
78
+ ? UPGRADE_NOTICE_COMPLETED_TTL_MS
79
+ : UPGRADE_NOTICE_INPROGRESS_TTL_MS;
73
80
  try {
74
81
  mkdirSync(mofloDir(projectRoot), { recursive: true });
75
82
  const now = Date.now();
76
83
  const notice = {
77
- status: 'in-progress',
84
+ status,
78
85
  kind: upgradeNoticeContext.kind,
79
86
  from: upgradeNoticeContext.from,
80
87
  to: upgradeNoticeContext.to,
81
88
  at: new Date(now).toISOString(),
82
- expiresAt: new Date(now + UPGRADE_NOTICE_INPROGRESS_TTL_MS).toISOString(),
89
+ expiresAt: new Date(now + ttlMs).toISOString(),
83
90
  changes: 0,
84
91
  };
85
92
  writeFileSync(UPGRADE_NOTICE_PATH(), JSON.stringify(notice, null, 2));
86
93
  } catch { /* non-fatal — statusline just won't show the segment */ }
87
94
  }
88
95
 
89
- function clearUpgradeNotice() {
90
- try {
91
- unlinkSync(UPGRADE_NOTICE_PATH());
92
- } catch { /* non-fatal — already gone or never existed */ }
93
- }
94
-
95
96
  // ── 0-pre. Drop any stale upgrade notice (#738, #743) ───────────────────────
96
97
  // `upgrade-notice.json` is a transient handshake between launcher and
97
98
  // statusline — it should never survive past the launcher run that wrote it.
@@ -313,9 +314,9 @@ try {
313
314
  }
314
315
  // Surface a transient "(updating…)" badge in the statusline before the
315
316
  // long-running upgrade work (manifest sync, daemon recycle, embeddings
316
- // migration). See #738 — the launcher clears this file after work
317
- // completes, so the badge naturally disappears once the user is unblocked.
318
- writeInProgressUpgradeNotice();
317
+ // migration). See #738 — section 3f flips this to a 2-min "completed"
318
+ // badge once work finishes (TTL rationale at the constants above).
319
+ writeUpgradeNotice('in-progress');
319
320
  const binDir = resolve(projectRoot, 'node_modules/moflo/bin');
320
321
 
321
322
  // ── Manifest-based auto-update ──────────────────────────────────────
@@ -855,12 +856,11 @@ try {
855
856
  } catch { /* writing the failure itself must not throw */ }
856
857
  }
857
858
 
858
- // ── 3f. Clear the in-progress upgrade notice (#636, #738) ───────────────────
859
- // Upgrade work is finished; drop the notice so the statusline badge disappears
860
- // immediately. Change summary is already in stdout emits (Claude's
861
- // `additionalContext`); a lingering "you upgraded a while ago" badge is noise.
859
+ // ── 3f. Flip the upgrade notice to "completed" (#636, #738) ─────────────────
860
+ // See the TTL rationale at the constants above for why we switch to a
861
+ // short-TTL completed badge instead of clearing the file.
862
862
  if (upgradeNoticeContext) {
863
- clearUpgradeNotice();
863
+ writeUpgradeNotice('completed');
864
864
  }
865
865
 
866
866
  // ── 3g. Commit deferred version stamp (#730) ────────────────────────────────
@@ -1416,7 +1416,7 @@ export const doctorCommand = {
1416
1416
  async function checkSandboxTier() {
1417
1417
  try {
1418
1418
  const { detectSandboxCapability, loadSandboxConfigFromProject, resolveEffectiveSandbox, } = await import('../spells/index.js');
1419
- const cap = detectSandboxCapability();
1419
+ const cap = await detectSandboxCapability();
1420
1420
  const config = await loadSandboxConfigFromProject(process.cwd());
1421
1421
  // If sandboxing isn't enabled in moflo.yaml, just report capability.
1422
1422
  if (!config.enabled) {
@@ -1441,7 +1441,7 @@ export const doctorCommand = {
1441
1441
  }
1442
1442
  // Sandboxing is enabled — run the real resolver and surface any error.
1443
1443
  try {
1444
- const effective = resolveEffectiveSandbox(config);
1444
+ const effective = await resolveEffectiveSandbox(config);
1445
1445
  if (effective.useOsSandbox) {
1446
1446
  const imageHint = effective.config.dockerImage ? `, ${effective.config.dockerImage}` : '';
1447
1447
  return {
@@ -403,24 +403,24 @@ export async function executeUpgrade(targetDir, _upgradeSettings = false) {
403
403
  // Must mirror the list in bin/session-start-launcher.mjs — divergence
404
404
  // here means the launcher's drift-repair will delete files this upgrade
405
405
  // didn't track, even though they ship in the package (#777).
406
- const UPGRADE_SCRIPT_MAP = {
407
- 'hooks.mjs': 'hooks.mjs',
408
- 'session-start-launcher.mjs': 'session-start-launcher.mjs',
409
- 'index-guidance.mjs': 'index-guidance.mjs',
410
- 'build-embeddings.mjs': 'build-embeddings.mjs',
411
- 'generate-code-map.mjs': 'generate-code-map.mjs',
412
- 'semantic-search.mjs': 'semantic-search.mjs',
413
- 'index-tests.mjs': 'index-tests.mjs',
414
- 'index-patterns.mjs': 'index-patterns.mjs',
415
- 'index-all.mjs': 'index-all.mjs',
416
- 'setup-project.mjs': 'setup-project.mjs',
417
- 'run-migrations.mjs': 'run-migrations.mjs',
418
- };
406
+ const UPGRADE_SCRIPT_MAP = [
407
+ 'hooks.mjs',
408
+ 'session-start-launcher.mjs',
409
+ 'index-guidance.mjs',
410
+ 'build-embeddings.mjs',
411
+ 'generate-code-map.mjs',
412
+ 'semantic-search.mjs',
413
+ 'index-tests.mjs',
414
+ 'index-patterns.mjs',
415
+ 'index-all.mjs',
416
+ 'setup-project.mjs',
417
+ 'run-migrations.mjs',
418
+ ];
419
419
  const binDir = findMofloBinDir();
420
420
  if (binDir) {
421
- for (const [destName, srcName] of Object.entries(UPGRADE_SCRIPT_MAP)) {
422
- const srcPath = path.join(binDir, srcName);
423
- const destPath = path.join(scriptsDir, destName);
421
+ for (const name of UPGRADE_SCRIPT_MAP) {
422
+ const srcPath = path.join(binDir, name);
423
+ const destPath = path.join(scriptsDir, name);
424
424
  if (!fs.existsSync(srcPath))
425
425
  continue;
426
426
  try {
@@ -435,10 +435,10 @@ export async function executeUpgrade(targetDir, _upgradeSettings = false) {
435
435
  fs.copyFileSync(srcPath, destPath);
436
436
  }
437
437
  if (destExists) {
438
- result.updated.push(`.claude/scripts/${destName}`);
438
+ result.updated.push(`.claude/scripts/${name}`);
439
439
  }
440
440
  else {
441
- result.created.push(`.claude/scripts/${destName}`);
441
+ result.created.push(`.claude/scripts/${name}`);
442
442
  }
443
443
  }
444
444
  catch {
@@ -701,15 +701,23 @@ ${MOFLO_MARKER_END}
701
701
  // These scripts are used by session-start hooks for indexing, code map, etc.
702
702
  // Always overwrite to keep them in sync with the installed moflo version.
703
703
  // ============================================================================
704
- const SCRIPT_MAP = {
705
- 'hooks.mjs': 'hooks.mjs',
706
- 'session-start-launcher.mjs': 'session-start-launcher.mjs',
707
- 'index-guidance.mjs': 'index-guidance.mjs',
708
- 'build-embeddings.mjs': 'build-embeddings.mjs',
709
- 'generate-code-map.mjs': 'generate-code-map.mjs',
710
- 'semantic-search.mjs': 'semantic-search.mjs',
711
- 'index-tests.mjs': 'index-tests.mjs',
712
- };
704
+ // Must mirror UPGRADE_SCRIPT_MAP in src/cli/init/executor.ts and the
705
+ // scriptFiles array in bin/session-start-launcher.mjs — first-init drops any
706
+ // script missing here, and the launcher's manifest cleanup later treats it as
707
+ // orphan residue and deletes it (#777, feedback_scriptfiles_sync.md).
708
+ const SCRIPT_MAP = [
709
+ 'hooks.mjs',
710
+ 'session-start-launcher.mjs',
711
+ 'index-guidance.mjs',
712
+ 'build-embeddings.mjs',
713
+ 'generate-code-map.mjs',
714
+ 'semantic-search.mjs',
715
+ 'index-tests.mjs',
716
+ 'index-patterns.mjs',
717
+ 'index-all.mjs',
718
+ 'setup-project.mjs',
719
+ 'run-migrations.mjs',
720
+ ];
713
721
  function syncScripts(root, force) {
714
722
  const scriptsDir = path.join(root, '.claude', 'scripts');
715
723
  if (!fs.existsSync(scriptsDir)) {
@@ -731,9 +739,9 @@ function syncScripts(root, force) {
731
739
  return { name: '.claude/scripts/', status: 'skipped', detail: 'moflo bin/ not found' };
732
740
  }
733
741
  let copied = 0;
734
- for (const [dest, src] of Object.entries(SCRIPT_MAP)) {
735
- const srcPath = path.join(binDir, src);
736
- const destPath = path.join(scriptsDir, dest);
742
+ for (const name of SCRIPT_MAP) {
743
+ const srcPath = path.join(binDir, name);
744
+ const destPath = path.join(scriptsDir, name);
737
745
  if (!fs.existsSync(srcPath))
738
746
  continue;
739
747
  // Always overwrite scripts to keep in sync (they're derived, not user-edited)
@@ -11,11 +11,12 @@
11
11
  *
12
12
  * @see https://github.com/eric-cielo/moflo/issues/409
13
13
  */
14
- import { execSync } from 'node:child_process';
14
+ import { spawn } from 'node:child_process';
15
15
  import { existsSync, readFileSync } from 'node:fs';
16
16
  import { platform } from 'node:os';
17
17
  import { join } from 'node:path';
18
- import { escapeShellArg } from './shell.js';
18
+ import { commandExists } from './prerequisite-checker.js';
19
+ import { execFileAsync } from './shell.js';
19
20
  export const DEFAULT_SANDBOX_CONFIG = {
20
21
  enabled: false,
21
22
  tier: 'auto',
@@ -25,7 +26,15 @@ export const RECOMMENDED_DOCKER_IMAGE = 'ghcr.io/eric-cielo/moflo-sandbox:latest
25
26
  // ============================================================================
26
27
  // Detection (cached)
27
28
  // ============================================================================
28
- let _cached;
29
+ // Detection runs once per process and is cached, so timeouts can be generous.
30
+ // Cold-start `docker info` on Windows can take 5-10s on the named-pipe
31
+ // handshake, so a tight budget would falsely report the daemon down.
32
+ const BINARY_EXISTS_TIMEOUT_MS = 10_000;
33
+ const DOCKER_DAEMON_TIMEOUT_MS = 15_000;
34
+ // Cache the in-flight Promise rather than the resolved value so concurrent
35
+ // callers share one detection probe (avoids racing two `docker info` calls if
36
+ // e.g. doctor and runner ask within the same tick).
37
+ let _cachedDetection;
29
38
  /**
30
39
  * Detect the available sandbox tool for the current platform.
31
40
  *
@@ -33,17 +42,22 @@ let _cached;
33
42
  * are expensive and the answer doesn't change mid-process.
34
43
  */
35
44
  export function detectSandboxCapability() {
36
- if (_cached)
37
- return _cached;
38
- _cached = detectUncached();
39
- return _cached;
45
+ if (!_cachedDetection) {
46
+ // Don't cache rejection — sticky failure across the process lifetime would
47
+ // be a hard-to-diagnose footgun if a caller ever surfaces an error.
48
+ _cachedDetection = detectUncached().catch((err) => {
49
+ _cachedDetection = undefined;
50
+ throw err;
51
+ });
52
+ }
53
+ return _cachedDetection;
40
54
  }
41
55
  /** Reset the cached result (for testing). */
42
56
  export function resetSandboxCache() {
43
- _cached = undefined;
57
+ _cachedDetection = undefined;
44
58
  _imageExistsCache.clear();
45
59
  }
46
- function detectUncached() {
60
+ async function detectUncached() {
47
61
  const os = platform();
48
62
  if (os === 'darwin') {
49
63
  return detectMacOS();
@@ -68,8 +82,8 @@ function detectMacOS() {
68
82
  };
69
83
  }
70
84
  // ── Linux ──────────────────────────────────────────────────────────────
71
- function detectLinux() {
72
- const available = binaryExists('bwrap');
85
+ async function detectLinux() {
86
+ const available = await commandExists('bwrap', { timeoutMs: BINARY_EXISTS_TIMEOUT_MS });
73
87
  return {
74
88
  platform: 'linux',
75
89
  available,
@@ -78,11 +92,11 @@ function detectLinux() {
78
92
  };
79
93
  }
80
94
  // ── Windows ────────────────────────────────────────────────────────────
81
- function detectWindows() {
82
- if (!binaryExists('docker')) {
95
+ async function detectWindows() {
96
+ if (!(await commandExists('docker', { timeoutMs: BINARY_EXISTS_TIMEOUT_MS }))) {
83
97
  return { platform: 'win32', available: false, tool: null, overhead: null };
84
98
  }
85
- if (!dockerDaemonRunning()) {
99
+ if (!(await dockerDaemonRunning())) {
86
100
  return { platform: 'win32', available: false, tool: null, overhead: null };
87
101
  }
88
102
  return {
@@ -95,25 +109,13 @@ function detectWindows() {
95
109
  // ============================================================================
96
110
  // Helpers
97
111
  // ============================================================================
98
- function binaryExists(name) {
99
- try {
100
- const cmd = platform() === 'win32' ? `where ${name}` : `which ${name}`;
101
- execSync(cmd, { stdio: 'ignore', timeout: 5000 });
102
- return true;
103
- }
104
- catch {
105
- return false;
106
- }
107
- }
108
- function dockerDaemonRunning() {
109
- try {
110
- execSync('docker info', { stdio: 'ignore', timeout: 4000 });
111
- return true;
112
- }
113
- catch {
114
- return false;
115
- }
112
+ // Single source of truth for docker probe outcome semantics — `execFileAsync`
113
+ // from shell.ts never throws, so we branch on exitCode instead of try/catch.
114
+ async function dockerProbe(args) {
115
+ const { exitCode } = await execFileAsync('docker', args, DOCKER_DAEMON_TIMEOUT_MS);
116
+ return exitCode === 0;
116
117
  }
118
+ const dockerDaemonRunning = () => dockerProbe(['info']);
117
119
  // ============================================================================
118
120
  // Config Resolution
119
121
  // ============================================================================
@@ -174,13 +176,14 @@ export async function loadSandboxConfigFromProject(projectRoot) {
174
176
  * @returns EffectiveSandbox — includes display status for spell-start logging.
175
177
  * @throws Error if tier is 'full' but no OS sandbox is available.
176
178
  */
177
- export function resolveEffectiveSandbox(config, capability = detectSandboxCapability()) {
179
+ export async function resolveEffectiveSandbox(config, capability) {
180
+ const cap = capability ?? await detectSandboxCapability();
178
181
  let resolved = config;
179
182
  // Config disabled or tier is denylist-only => no OS sandbox
180
183
  if (!resolved.enabled || resolved.tier === 'denylist-only') {
181
184
  return {
182
185
  useOsSandbox: false,
183
- capability,
186
+ capability: cap,
184
187
  config: resolved,
185
188
  displayStatus: `OS sandbox: disabled (denylist active)`,
186
189
  };
@@ -188,36 +191,36 @@ export function resolveEffectiveSandbox(config, capability = detectSandboxCapabi
188
191
  // Windows: Docker is required for OS sandboxing. If Docker is available,
189
192
  // auto-default the image and auto-pull it on first use so the user doesn't
190
193
  // have to do manual setup. Only throw if Docker itself isn't installed/running.
191
- if (capability.platform === 'win32') {
192
- if (!capability.available) {
194
+ if (cap.platform === 'win32') {
195
+ if (!cap.available) {
193
196
  throw new Error(formatWindowsDockerNotReadyMessage());
194
197
  }
195
198
  const image = resolved.dockerImage || RECOMMENDED_DOCKER_IMAGE;
196
199
  if (!resolved.dockerImage) {
197
200
  resolved = { ...resolved, dockerImage: image };
198
201
  }
199
- if (!dockerImageExists(image)) {
200
- dockerPullImage(image);
202
+ if (!(await dockerImageExists(image))) {
203
+ await dockerPullImage(image);
201
204
  }
202
205
  }
203
206
  // tier: full — require OS sandbox on non-Windows platforms
204
- if (resolved.tier === 'full' && !capability.available) {
205
- throw new Error(`Sandbox tier "full" requires an OS sandbox but none was detected on ${capability.platform}. ` +
207
+ if (resolved.tier === 'full' && !cap.available) {
208
+ throw new Error(`Sandbox tier "full" requires an OS sandbox but none was detected on ${cap.platform}. ` +
206
209
  `Install bubblewrap (Linux) or set sandbox.tier to "auto".`);
207
210
  }
208
- if (!capability.available) {
211
+ if (!cap.available) {
209
212
  return {
210
213
  useOsSandbox: false,
211
- capability,
214
+ capability: cap,
212
215
  config: resolved,
213
- displayStatus: `OS sandbox: not available (${capability.platform})`,
216
+ displayStatus: `OS sandbox: not available (${cap.platform})`,
214
217
  };
215
218
  }
216
219
  return {
217
220
  useOsSandbox: true,
218
- capability,
221
+ capability: cap,
219
222
  config: resolved,
220
- displayStatus: `OS sandbox: ${capability.tool} (${capability.platform})`,
223
+ displayStatus: `OS sandbox: ${cap.tool} (${cap.platform})`,
221
224
  };
222
225
  }
223
226
  const _imageExistsCache = new Map();
@@ -225,35 +228,53 @@ const _imageExistsCache = new Map();
225
228
  * Check whether a Docker image is available locally (already pulled).
226
229
  * Result is cached per image name for the process lifetime.
227
230
  */
228
- function dockerImageExists(image) {
231
+ async function dockerImageExists(image) {
229
232
  const cached = _imageExistsCache.get(image);
230
233
  if (cached !== undefined)
231
234
  return cached;
232
- try {
233
- execSync(`docker image inspect ${escapeShellArg(image)}`, { stdio: 'ignore', timeout: 5000 });
235
+ // `dockerProbe` shares the cold-start timeout/error-swallow policy with
236
+ // `dockerDaemonRunning` both hit the Docker Desktop named-pipe handshake.
237
+ // Only positive outcomes are cached: a missing image must re-probe after
238
+ // `dockerPullImage` populates the cache, so symmetric caching would break
239
+ // the pull-then-recheck flow.
240
+ const ok = await dockerProbe(['image', 'inspect', image]);
241
+ if (ok)
234
242
  _imageExistsCache.set(image, true);
235
- return true;
236
- }
237
- catch {
238
- return false;
239
- }
243
+ return ok;
240
244
  }
241
245
  /**
242
246
  * Pull a Docker image, printing a one-time setup banner so the user knows
243
247
  * what's happening and why. Throws if the pull fails.
244
248
  */
245
- function dockerPullImage(image) {
249
+ async function dockerPullImage(image) {
246
250
  console.log(`[spell] One-time setup: pulling Docker image ${image} for sandboxing...\n` +
247
251
  ` This only happens once — Docker caches the image afterwards.`);
252
+ // `spawn` (not execFileAsync) so the docker pull progress streams live to
253
+ // the user's terminal. execFileAsync buffers stdout, defeating the banner.
248
254
  try {
249
- execSync(`docker pull ${escapeShellArg(image)}`, { stdio: 'inherit', timeout: 300_000 });
250
- _imageExistsCache.set(image, true);
251
- console.log(`[spell] Docker image ${image} is ready.`);
255
+ await new Promise((resolve, reject) => {
256
+ let timedOut = false;
257
+ const proc = spawn('docker', ['pull', image], { stdio: 'inherit' });
258
+ const timer = setTimeout(() => { timedOut = true; proc.kill('SIGTERM'); }, 300_000);
259
+ proc.on('error', (err) => { clearTimeout(timer); reject(err); });
260
+ proc.on('exit', (code) => {
261
+ clearTimeout(timer);
262
+ if (code === 0)
263
+ resolve();
264
+ else if (timedOut)
265
+ reject(new Error('docker pull timed out after 5 minutes'));
266
+ else
267
+ reject(new Error(`docker pull exited with code ${code}`));
268
+ });
269
+ });
252
270
  }
253
- catch {
254
- throw new Error(`Failed to pull Docker image "${image}".\n\n` +
271
+ catch (err) {
272
+ const detail = err instanceof Error ? err.message : String(err);
273
+ throw new Error(`Failed to pull Docker image "${image}": ${detail}\n\n` +
255
274
  'Make sure Docker Desktop is running and you have internet access, then try again.');
256
275
  }
276
+ _imageExistsCache.set(image, true);
277
+ console.log(`[spell] Docker image ${image} is ready.`);
257
278
  }
258
279
  // ── Beginner-friendly setup messages (Windows) ──────────────────────────
259
280
  function formatWindowsDockerNotReadyMessage() {
@@ -19,11 +19,16 @@ import { promisify } from 'node:util';
19
19
  import { acquireTTYLock } from './tty-lock.js';
20
20
  import { readLineFromStdin } from './stdin-reader.js';
21
21
  const execFileAsync = promisify(execFile);
22
- /** Check whether a CLI command is available on the system PATH. */
23
- export async function commandExists(cmd) {
22
+ /**
23
+ * Check whether a CLI command is available on the system PATH.
24
+ *
25
+ * `timeoutMs` caps the lookup probe — important for callers that probe under
26
+ * fork/GC pressure where `where`/`which` can stall (see platform-sandbox).
27
+ */
28
+ export async function commandExists(cmd, opts) {
24
29
  try {
25
30
  const bin = process.platform === 'win32' ? 'where' : 'which';
26
- await execFileAsync(bin, [cmd]);
31
+ await execFileAsync(bin, [cmd], opts?.timeoutMs ? { timeout: opts.timeoutMs } : undefined);
27
32
  return true;
28
33
  }
29
34
  catch {
@@ -139,7 +139,7 @@ export class SpellCaster {
139
139
  let effectiveSandbox;
140
140
  try {
141
141
  const sandboxCfg = options.sandboxConfig ?? DEFAULT_SANDBOX_CONFIG;
142
- effectiveSandbox = resolveEffectiveSandbox(sandboxCfg);
142
+ effectiveSandbox = await resolveEffectiveSandbox(sandboxCfg);
143
143
  console.log(formatSandboxLog(effectiveSandbox));
144
144
  }
145
145
  catch (err) {
@@ -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.9.0-rc.13';
5
+ export const VERSION = '4.9.0-rc.14';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.0-rc.13",
3
+ "version": "4.9.0-rc.14",
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",
@@ -79,7 +79,7 @@
79
79
  "@typescript-eslint/eslint-plugin": "^7.18.0",
80
80
  "@typescript-eslint/parser": "^7.18.0",
81
81
  "eslint": "^8.0.0",
82
- "moflo": "^4.9.0-rc.12",
82
+ "moflo": "^4.9.0-rc.13",
83
83
  "tsx": "^4.21.0",
84
84
  "typescript": "^5.9.3",
85
85
  "vitest": "^4.0.0"