moflo 4.9.11 → 4.9.13

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,370 @@
1
+ /**
2
+ * Canonical moflo.yaml renderer.
3
+ *
4
+ * Single source of truth shared by:
5
+ * - `flo init` (src/cli/init/moflo-init.ts § Step 1)
6
+ * - Session-start launcher self-heal (bin/session-start-launcher.mjs § 3d-yaml-create)
7
+ *
8
+ * The launcher path keeps consumers visible and tunable: when a project is
9
+ * upgraded to a moflo with new defaults but has no moflo.yaml at all, the
10
+ * sibling `bin/lib/yaml-upgrader.mjs` no-ops (it only appends to existing
11
+ * files). Without this module, the user has no surface to see/edit defaults
12
+ * after an `npm install moflo@*`. See issue #895.
13
+ *
14
+ * Companion: `bin/lib/yaml-upgrader.mjs` keeps EXISTING moflo.yaml files
15
+ * forward-compatible by appending new top-level sections. This module
16
+ * handles the MISSING case.
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
21
+ const WALK_SKIP_DIRS = new Set([
22
+ 'node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.reports',
23
+ '.swarm', '.moflo', 'packages',
24
+ ]);
25
+ const SOURCE_EXTENSIONS = [
26
+ '.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.rb', '.cs',
27
+ ];
28
+ /**
29
+ * Top-level candidates for source directories. Same list `flo init` uses; the
30
+ * walker also descends to find `src/` and `migrations/` up to 3 levels deep.
31
+ */
32
+ const SRC_TOP_LEVEL = ['packages', 'lib', 'app', 'apps', 'services', 'server', 'client'];
33
+ const SRC_NAMES = new Set(['src', 'migrations']);
34
+ export function discoverGuidanceDirs(root) {
35
+ const TOP_LEVEL = ['.claude/guidance', 'docs/guides', 'docs', 'architecture', 'adr', '.cursor/rules'];
36
+ const found = TOP_LEVEL.filter(d => fs.existsSync(path.join(root, d)));
37
+ function walk(dir, depth) {
38
+ if (depth > 3)
39
+ return;
40
+ try {
41
+ const entries = fs.readdirSync(path.join(root, dir), { withFileTypes: true });
42
+ for (const entry of entries) {
43
+ if (!entry.isDirectory() || WALK_SKIP_DIRS.has(entry.name))
44
+ continue;
45
+ const rel = dir ? `${dir}/${entry.name}` : entry.name;
46
+ const guidancePath = `${rel}/.claude/guidance`;
47
+ if (fs.existsSync(path.join(root, guidancePath))) {
48
+ try {
49
+ const files = fs.readdirSync(path.join(root, guidancePath));
50
+ if (files.some(f => f.endsWith('.md')))
51
+ found.push(guidancePath);
52
+ }
53
+ catch { /* skip unreadable */ }
54
+ }
55
+ else {
56
+ walk(rel, depth + 1);
57
+ }
58
+ }
59
+ }
60
+ catch { /* skip unreadable */ }
61
+ }
62
+ walk('', 0);
63
+ return found;
64
+ }
65
+ export function discoverTestDirs(root) {
66
+ const TOP_LEVEL = ['tests', 'test', '__tests__', 'spec', 'e2e'];
67
+ const found = TOP_LEVEL.filter(d => fs.existsSync(path.join(root, d)));
68
+ function walk(dir, depth) {
69
+ if (depth > 3)
70
+ return;
71
+ try {
72
+ const entries = fs.readdirSync(path.join(root, dir), { withFileTypes: true });
73
+ for (const entry of entries) {
74
+ if (!entry.isDirectory() || WALK_SKIP_DIRS.has(entry.name))
75
+ continue;
76
+ const rel = dir ? `${dir}/${entry.name}` : entry.name;
77
+ if (entry.name === '__tests__')
78
+ found.push(rel);
79
+ else
80
+ walk(rel, depth + 1);
81
+ }
82
+ }
83
+ catch { /* skip unreadable */ }
84
+ }
85
+ walk('', 0);
86
+ return found;
87
+ }
88
+ export function discoverSrcDirs(root) {
89
+ const found = [];
90
+ for (const d of SRC_TOP_LEVEL) {
91
+ if (fs.existsSync(path.join(root, d)))
92
+ found.push(d);
93
+ }
94
+ function walk(dir, depth) {
95
+ if (depth > 3)
96
+ return;
97
+ try {
98
+ const entries = fs.readdirSync(path.join(root, dir), { withFileTypes: true });
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory() || WALK_SKIP_DIRS.has(entry.name))
101
+ continue;
102
+ const rel = dir ? `${dir}/${entry.name}` : entry.name;
103
+ if (SRC_NAMES.has(entry.name)) {
104
+ try {
105
+ const files = fs.readdirSync(path.join(root, rel));
106
+ if (files.some(f => /\.(ts|tsx|js|jsx)$/.test(f)))
107
+ found.push(rel);
108
+ }
109
+ catch { /* skip unreadable */ }
110
+ }
111
+ else {
112
+ walk(rel, depth + 1);
113
+ }
114
+ }
115
+ }
116
+ catch { /* skip unreadable */ }
117
+ }
118
+ walk('', 0);
119
+ return found;
120
+ }
121
+ function scanExtensions(dir, extensions, depth, maxDepth) {
122
+ if (depth > maxDepth)
123
+ return;
124
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
125
+ for (const entry of entries.slice(0, 100)) {
126
+ if (entry.isDirectory() && !['node_modules', '.git', 'dist', 'build'].includes(entry.name)) {
127
+ scanExtensions(path.join(dir, entry.name), extensions, depth + 1, maxDepth);
128
+ }
129
+ else if (entry.isFile()) {
130
+ const ext = path.extname(entry.name);
131
+ if (SOURCE_EXTENSIONS.includes(ext))
132
+ extensions.add(ext);
133
+ }
134
+ }
135
+ }
136
+ export function detectExtensions(root, srcDirs) {
137
+ const extensions = new Set();
138
+ for (const dir of srcDirs) {
139
+ const fullDir = path.join(root, dir);
140
+ if (fs.existsSync(fullDir)) {
141
+ try {
142
+ scanExtensions(fullDir, extensions, 0, 3);
143
+ }
144
+ catch { /* skip */ }
145
+ }
146
+ }
147
+ return extensions.size > 0 ? [...extensions].sort() : ['.ts', '.tsx', '.js', '.jsx'];
148
+ }
149
+ export function defaultMofloYamlConfig(root) {
150
+ const guidanceDirs = discoverGuidanceDirs(root);
151
+ if (guidanceDirs.length === 0)
152
+ guidanceDirs.push('.claude/guidance');
153
+ const srcDirs = discoverSrcDirs(root);
154
+ if (srcDirs.length === 0)
155
+ srcDirs.push('src');
156
+ const testDirs = discoverTestDirs(root);
157
+ if (testDirs.length === 0)
158
+ testDirs.push('tests');
159
+ return {
160
+ projectName: path.basename(root),
161
+ guidanceDirs,
162
+ srcDirs,
163
+ testDirs,
164
+ detectedExts: detectExtensions(root, srcDirs),
165
+ guidance: true,
166
+ codeMap: true,
167
+ tests: true,
168
+ gates: true,
169
+ stopHook: true,
170
+ };
171
+ }
172
+ export function renderMofloYaml(config) {
173
+ const { projectName, guidanceDirs, srcDirs, testDirs, detectedExts, guidance, codeMap, tests, gates, stopHook } = config;
174
+ return `# MoFlo — Project Configuration
175
+ # Generated by: moflo init
176
+ # Docs: https://github.com/eric-cielo/moflo
177
+
178
+ project:
179
+ name: "${projectName}"
180
+
181
+ # Guidance/knowledge docs to index for semantic search
182
+ guidance:
183
+ directories:
184
+ ${guidanceDirs.map(d => ` - ${d}`).join('\n')}
185
+ namespace: guidance
186
+
187
+ # Source directories for code navigation map
188
+ code_map:
189
+ directories:
190
+ ${srcDirs.map(d => ` - ${d}`).join('\n')}
191
+ extensions: [${detectedExts.map(e => `"${e}"`).join(', ')}]
192
+ exclude: [node_modules, dist, .next, coverage, build, __pycache__, target, .git]
193
+ namespace: code-map
194
+
195
+ # Test file discovery and indexing
196
+ tests:
197
+ directories:
198
+ ${testDirs.map(d => ` - ${d}`).join('\n')}
199
+ patterns: ["*.test.*", "*.spec.*", "*.test-*"]
200
+ extensions: [".ts", ".tsx", ".js", ".jsx"]
201
+ exclude: [node_modules, coverage, dist]
202
+ namespace: tests
203
+
204
+ # Spell gates (enforced via Claude Code hooks)
205
+ gates:
206
+ memory_first: ${gates}
207
+ task_create_first: ${gates}
208
+ context_tracking: ${gates}
209
+
210
+ # Auto-index on session start
211
+ auto_index:
212
+ guidance: ${guidance}
213
+ code_map: ${codeMap}
214
+ tests: ${tests}
215
+
216
+ # Memory backend
217
+ memory:
218
+ backend: sql.js
219
+ embedding_model: Xenova/all-MiniLM-L6-v2
220
+ namespace: default
221
+
222
+ # Hook toggles (all on by default — disable to slim down)
223
+ hooks:
224
+ pre_edit: true # Track file edits for learning
225
+ post_edit: true # Record edit outcomes, train neural patterns
226
+ pre_task: true # Get agent routing before task spawn
227
+ post_task: true # Record task results for learning
228
+ gate: ${gates} # Spell gate enforcement (memory-first, task-create-first)
229
+ route: true # Intelligent task routing on each prompt
230
+ stop_hook: ${stopHook} # Session-end persistence and metric export
231
+ session_restore: true # Restore session state on start
232
+ notification: true # Hook into Claude Code notifications
233
+
234
+ # MCP server options
235
+ mcp:
236
+ tool_defer: deferred # Defer 150+ tool schemas; loaded on demand via ToolSearch
237
+ auto_start: false # Auto-start MCP server on session begin
238
+
239
+ # Spell step sandboxing (OS-level process isolation for bash steps)
240
+ # Platform support: macOS (sandbox-exec), Linux/WSL (bwrap). Windows has no OS sandbox.
241
+ # Tiers:
242
+ # auto — Use best available sandbox for this platform (recommended when enabled)
243
+ # denylist-only — Layer 1 only: block catastrophic commands, no OS isolation
244
+ # full — Require full OS isolation; throws if the sandbox tool is unavailable
245
+ sandbox:
246
+ enabled: false # Set to true to wrap bash steps in an OS sandbox
247
+ tier: auto # auto | denylist-only | full
248
+
249
+ # Status line display (shown at bottom of Claude Code)
250
+ # mode: "compact" (default), "single-line", or "dashboard" (full multi-line)
251
+ status_line:
252
+ enabled: true
253
+ mode: compact
254
+ branding: "MoFlo V4"
255
+ show_git: true
256
+ show_session: true
257
+ show_swarm: true
258
+ show_mcp: true
259
+
260
+ # Model preferences (haiku, sonnet, opus)
261
+ # These are static fallbacks. When model_routing.enabled is true (default),
262
+ # the dynamic router takes precedence based on task complexity.
263
+ models:
264
+ default: opus # Model for general tasks (kept high for unknowns)
265
+ research: sonnet # Model for research/exploration agents
266
+ review: sonnet # Code review never needs opus reasoning
267
+ test: sonnet # Model for test-writing agents
268
+
269
+ # Intelligent model routing (auto-selects haiku/sonnet/opus per task)
270
+ # When enabled, overrides the static model preferences above
271
+ # by analyzing task complexity and routing to the cheapest capable model.
272
+ model_routing:
273
+ enabled: true # Set to false to pin to the static models above
274
+ confidence_threshold: 0.85 # Min confidence before escalating to a more capable model
275
+ cost_optimization: true # Prefer cheaper models when confidence is high
276
+ circuit_breaker: true # Penalize models that fail repeatedly
277
+ # Per-agent overrides (set to "inherit" to use routing, or a specific model to pin)
278
+ # agent_overrides:
279
+ # security-architect: opus # Always use opus for security
280
+ # researcher: sonnet # Pin research to sonnet
281
+ `;
282
+ }
283
+ export function ensureMofloYamlExists(root) {
284
+ const configPath = path.join(root, 'moflo.yaml');
285
+ if (fs.existsSync(configPath)) {
286
+ return { created: false, path: configPath };
287
+ }
288
+ const config = defaultMofloYamlConfig(root);
289
+ // atomicWriteFileSync writes a tmp sidecar then renames — concurrent
290
+ // SessionStart launchers race to last-writer-wins. We layer a "never
291
+ // overwrite" invariant on top: re-check existence right before the
292
+ // rename so the loser of the race observes the winner's file and skips.
293
+ if (fs.existsSync(configPath)) {
294
+ return { created: false, path: configPath };
295
+ }
296
+ atomicWriteFileSync(configPath, renderMofloYaml(config));
297
+ return {
298
+ created: true,
299
+ path: configPath,
300
+ detail: `${config.srcDirs.join(', ')} | ${config.detectedExts.join(', ')}`,
301
+ };
302
+ }
303
+ /**
304
+ * Top-level sections every shipped moflo.yaml must define. Mirrors the keys
305
+ * `renderMofloYaml` emits — kept in sync via the test
306
+ * `init-moflo-yaml-template.test.ts § renderMofloYaml`.
307
+ */
308
+ export const REQUIRED_TOP_LEVEL_SECTIONS = [
309
+ 'project',
310
+ 'guidance',
311
+ 'code_map',
312
+ 'tests',
313
+ 'gates',
314
+ 'auto_index',
315
+ 'memory',
316
+ 'hooks',
317
+ 'mcp',
318
+ 'sandbox',
319
+ 'status_line',
320
+ 'models',
321
+ 'model_routing',
322
+ ];
323
+ // Precompiled at module load — validateMofloYaml can run from doctor probes
324
+ // or session-start hot paths, neither of which should rebuild N regexes per call.
325
+ const SECTION_REGEXES = REQUIRED_TOP_LEVEL_SECTIONS.map((section) => [section, new RegExp(`^${section}\\s*:`, 'm')]);
326
+ /**
327
+ * Lightweight compliance check for a moflo.yaml on disk. Avoids pulling a
328
+ * full YAML parser dependency — matches the regex-based approach already
329
+ * used by `doctor.ts § checkTestDirs` and `bin/lib/yaml-upgrader.mjs`.
330
+ * Never throws — the doctor probe and session-start self-heal both expect
331
+ * a verdict even when the file is corrupt.
332
+ */
333
+ export function validateMofloYaml(yamlPath) {
334
+ const result = {
335
+ exists: false,
336
+ valid: false,
337
+ issues: [],
338
+ missingSections: [],
339
+ };
340
+ if (!fs.existsSync(yamlPath)) {
341
+ result.issues.push({ kind: 'parse-error', detail: 'moflo.yaml does not exist' });
342
+ return result;
343
+ }
344
+ result.exists = true;
345
+ let content;
346
+ try {
347
+ content = fs.readFileSync(yamlPath, 'utf-8');
348
+ }
349
+ catch (err) {
350
+ result.issues.push({ kind: 'unreadable', detail: err.message ?? String(err) });
351
+ return result;
352
+ }
353
+ if (content.trim().length === 0) {
354
+ result.issues.push({ kind: 'empty', detail: 'file is empty' });
355
+ return result;
356
+ }
357
+ for (const [section, re] of SECTION_REGEXES) {
358
+ if (!re.test(content))
359
+ result.missingSections.push(section);
360
+ }
361
+ if (result.missingSections.length > 0) {
362
+ result.issues.push({
363
+ kind: 'missing-section',
364
+ detail: `missing top-level sections: ${result.missingSections.join(', ')}`,
365
+ });
366
+ }
367
+ result.valid = result.issues.length === 0;
368
+ return result;
369
+ }
370
+ //# sourceMappingURL=moflo-yaml-template.js.map
@@ -2739,6 +2739,8 @@ export const hooksModelRoute = {
2739
2739
  },
2740
2740
  handler: async (params) => {
2741
2741
  const task = params.task;
2742
+ const preferCost = params.preferCost;
2743
+ const preferSpeed = params.preferSpeed;
2742
2744
  const router = await getModelRouterInstance();
2743
2745
  if (!router) {
2744
2746
  // Fallback to simple heuristic
@@ -2751,7 +2753,7 @@ export const hooksModelRoute = {
2751
2753
  implementation: 'fallback',
2752
2754
  };
2753
2755
  }
2754
- const result = await router.route(task);
2756
+ const result = await router.route(task, { preferCost, preferSpeed });
2755
2757
  return {
2756
2758
  model: result.model,
2757
2759
  confidence: result.confidence,
@@ -67,6 +67,11 @@ export const COMPLEXITY_INDICATORS = {
67
67
  'delete', 'documentation', 'readme', 'config', 'version', 'bump',
68
68
  ],
69
69
  };
70
+ /**
71
+ * Score window (absolute score units) within which a model is considered
72
+ * "competitive" with the best — used by preferCost/preferSpeed tie-breaking.
73
+ */
74
+ const COMPETITIVE_SCORE_WINDOW = 0.1;
70
75
  // ============================================================================
71
76
  // Default Configuration
72
77
  // ============================================================================
@@ -101,18 +106,24 @@ export class ModelRouter {
101
106
  this.state = this.loadState();
102
107
  }
103
108
  /**
104
- * Route a task to the optimal model
109
+ * Route a task to the optimal model.
110
+ *
111
+ * Accepts `RouteOptions` (preferCost / preferSpeed / embedding) — the legacy
112
+ * `number[]` second-arg form is still accepted and treated as `embedding`.
105
113
  */
106
- async route(task, embedding) {
114
+ async route(task, optionsOrEmbedding) {
115
+ const options = Array.isArray(optionsOrEmbedding)
116
+ ? { embedding: optionsOrEmbedding }
117
+ : (optionsOrEmbedding ?? {});
107
118
  const startTime = performance.now();
108
119
  // Analyze task complexity
109
- const complexity = this.analyzeComplexity(task, embedding);
120
+ const complexity = this.analyzeComplexity(task, options.embedding);
110
121
  // Compute base model scores
111
122
  const scores = this.computeModelScores(complexity);
112
123
  // Apply circuit breaker adjustments
113
124
  const adjustedScores = this.applyCircuitBreaker(scores);
114
125
  // Select best model
115
- const { model, confidence, uncertainty } = this.selectModel(adjustedScores, complexity.score);
126
+ const { model, confidence, uncertainty } = this.selectModel(adjustedScores, complexity, options);
116
127
  const inferenceTimeUs = (performance.now() - startTime) * 1000;
117
128
  // Build result
118
129
  const result = {
@@ -199,10 +210,12 @@ export class ModelRouter {
199
210
  * Compute task scope from content analysis
200
211
  */
201
212
  computeTaskScope(task, words) {
202
- // Multi-file indicators
213
+ // Multi-file / multi-system indicators
203
214
  const multiFilePatterns = [
204
215
  /multiple files?/i, /across.*modules?/i, /refactor.*codebase/i,
205
216
  /all.*files/i, /entire.*project/i, /system.*wide/i,
217
+ /across\s+\S+\s*(services?|systems?|modules?|packages?|microservices?)/i,
218
+ /\b\d+\s+(services?|systems?|modules?|packages?|microservices?)\b/i,
206
219
  ];
207
220
  const hasMultiFile = multiFilePatterns.some(p => p.test(task)) ? 0.4 : 0;
208
221
  // Code generation indicators
@@ -263,27 +276,60 @@ export class ModelRouter {
263
276
  return adjusted;
264
277
  }
265
278
  /**
266
- * Select the best model from scores
279
+ * Select the best model from scores.
280
+ *
281
+ * Selection rules (in order):
282
+ * 1. If `preferCost`: among models within COMPETITIVE_SCORE_WINDOW of the
283
+ * best score, return the cheapest by `costMultiplier`.
284
+ * 2. If `preferSpeed`: same window, return the fastest by `speedMultiplier`.
285
+ * 3. Otherwise: return the highest-scoring model. Escalate one tier ONLY
286
+ * when there are 2+ high-complexity indicators (real architecture
287
+ * signal) — single-keyword tasks like "review" or "audit X" do not
288
+ * force a more expensive model.
289
+ *
290
+ * Note: prior versions force-escalated `sonnet → opus` whenever
291
+ * `uncertainty > maxUncertainty (0.15)`, which fired on essentially every
292
+ * code-review-shaped task and defeated the cost-saving point of the router.
267
293
  */
268
- selectModel(scores, complexityScore) {
269
- // Get sorted models by score
294
+ selectModel(scores, complexity, options = {}) {
270
295
  const sorted = Object.entries(scores)
271
296
  .filter(([m]) => m !== 'inherit')
272
297
  .sort((a, b) => b[1] - a[1]);
273
298
  const [bestModel, bestScore] = sorted[0];
274
- const [secondModel, secondScore] = sorted[1] || ['sonnet', 0];
275
- // Confidence is how much better the best is vs second
276
- const confidence = bestScore > 0 ? Math.min(1, bestScore / (bestScore + secondScore + 0.01)) : 0.5;
277
- // Uncertainty based on score spread and complexity
299
+ const secondScore = sorted[1]?.[1] ?? 0;
300
+ const confidence = bestScore > 0
301
+ ? Math.min(1, bestScore / (bestScore + secondScore + 0.01))
302
+ : 0.5;
278
303
  const scoreSpread = bestScore - secondScore;
279
304
  const uncertainty = Math.max(0, 1 - scoreSpread - confidence * 0.5);
280
- // Escalate if uncertainty is too high
281
- let model = bestModel;
282
- if (uncertainty > this.config.maxUncertainty && bestModel !== 'opus') {
283
- // Escalate to more capable model
284
- model = bestModel === 'haiku' ? 'sonnet' : 'opus';
305
+ if (options.preferCost) {
306
+ const threshold = bestScore - COMPETITIVE_SCORE_WINDOW;
307
+ const competitive = sorted
308
+ .filter(([, s]) => s >= threshold)
309
+ .sort((a, b) => MODEL_CAPABILITIES[a[0]].costMultiplier -
310
+ MODEL_CAPABILITIES[b[0]].costMultiplier);
311
+ return { model: competitive[0][0], confidence, uncertainty };
312
+ }
313
+ if (options.preferSpeed) {
314
+ const threshold = bestScore - COMPETITIVE_SCORE_WINDOW;
315
+ const competitive = sorted
316
+ .filter(([, s]) => s >= threshold)
317
+ .sort((a, b) => MODEL_CAPABILITIES[b[0]].speedMultiplier -
318
+ MODEL_CAPABILITIES[a[0]].speedMultiplier);
319
+ return { model: competitive[0][0], confidence, uncertainty };
320
+ }
321
+ // Real-architecture escalation: multiple high-complexity indicators
322
+ // (e.g. "architect" + "system", "design" + "distributed") signal work
323
+ // that's worth a more capable model even if the score curve favours sonnet.
324
+ const hasArchitectureSignal = complexity.indicators.high.length >= 2;
325
+ if (hasArchitectureSignal && bestModel !== 'opus') {
326
+ return {
327
+ model: bestModel === 'haiku' ? 'sonnet' : 'opus',
328
+ confidence,
329
+ uncertainty,
330
+ };
285
331
  }
286
- return { model, confidence, uncertainty };
332
+ return { model: bestModel, confidence, uncertainty };
287
333
  }
288
334
  /**
289
335
  * Build human-readable reasoning
@@ -460,9 +506,9 @@ export async function routeToModel(task) {
460
506
  /**
461
507
  * Route with full result
462
508
  */
463
- export async function routeToModelFull(task, embedding) {
509
+ export async function routeToModelFull(task, embeddingOrOptions) {
464
510
  const router = getModelRouter();
465
- return router.route(task, embedding);
511
+ return router.route(task, embeddingOrOptions);
466
512
  }
467
513
  /**
468
514
  * Analyze task complexity without routing