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.
- package/.claude/helpers/statusline.cjs +66 -23
- package/README.md +9 -6
- package/bin/session-start-launcher.mjs +19 -19
- package/dist/src/cli/commands/doctor.js +2 -2
- package/dist/src/cli/init/executor.js +18 -18
- package/dist/src/cli/init/moflo-init.js +20 -12
- package/dist/src/cli/spells/core/platform-sandbox.js +80 -59
- package/dist/src/cli/spells/core/prerequisite-checker.js +8 -3
- package/dist/src/cli/spells/core/runner.js +1 -1
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
606
|
-
// work
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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\
|
|
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
|
|
914
|
+
system,
|
|
872
915
|
adrs: getADRStatus(),
|
|
873
916
|
hooks: getHooksStatus(),
|
|
874
|
-
|
|
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
|
|
297
|
-
flo epic
|
|
298
|
-
flo epic
|
|
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
|
|
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
|
|
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
|
|
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
|
|
67
|
-
//
|
|
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
|
|
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
|
|
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 +
|
|
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 —
|
|
317
|
-
//
|
|
318
|
-
|
|
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.
|
|
859
|
-
//
|
|
860
|
-
//
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
|
422
|
-
const srcPath = path.join(binDir,
|
|
423
|
-
const destPath = path.join(scriptsDir,
|
|
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/${
|
|
438
|
+
result.updated.push(`.claude/scripts/${name}`);
|
|
439
439
|
}
|
|
440
440
|
else {
|
|
441
|
-
result.created.push(`.claude/scripts/${
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
'
|
|
710
|
-
'
|
|
711
|
-
'index-
|
|
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
|
|
735
|
-
const srcPath = path.join(binDir,
|
|
736
|
-
const destPath = path.join(scriptsDir,
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (!
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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 (
|
|
192
|
-
if (!
|
|
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' && !
|
|
205
|
-
throw new Error(`Sandbox tier "full" requires an OS sandbox but none was detected on ${
|
|
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 (!
|
|
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 (${
|
|
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: ${
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
23
|
-
|
|
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) {
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.0-rc.
|
|
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.
|
|
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"
|