moflo 4.9.12 → 4.9.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/gate.cjs +21 -5
- package/.claude/skills/eldar/SKILL.md +305 -0
- package/.claude/skills/fl/phases.md +18 -2
- package/.claude/skills/simplify/SKILL.md +35 -48
- package/README.md +25 -0
- package/bin/gate.cjs +21 -5
- package/bin/hooks.mjs +2 -2
- package/bin/index-guidance.mjs +14 -24
- package/bin/index-patterns.mjs +13 -10
- package/bin/session-start-launcher.mjs +64 -10
- package/bin/simplify-classify.cjs +211 -0
- package/dist/src/cli/commands/doctor-checks-config.js +246 -0
- package/dist/src/cli/commands/doctor-checks-deep.js +14 -0
- package/dist/src/cli/commands/doctor-checks-intelligence.js +197 -0
- package/dist/src/cli/commands/doctor-checks-memory.js +207 -0
- package/dist/src/cli/commands/doctor-checks-platform.js +138 -0
- package/dist/src/cli/commands/doctor-checks-runtime.js +170 -0
- package/dist/src/cli/commands/doctor-fixes.js +165 -0
- package/dist/src/cli/commands/doctor-registry.js +109 -0
- package/dist/src/cli/commands/doctor-render.js +203 -0
- package/dist/src/cli/commands/doctor-types.js +9 -0
- package/dist/src/cli/commands/doctor-version.js +134 -0
- package/dist/src/cli/commands/doctor-zombies.js +201 -0
- package/dist/src/cli/commands/doctor.js +35 -1657
- package/dist/src/cli/init/helpers-generator.js +21 -5
- package/dist/src/cli/init/moflo-init.js +20 -268
- package/dist/src/cli/init/moflo-yaml-template.js +370 -0
- package/dist/src/cli/mcp-tools/hooks-tools.js +3 -1
- package/dist/src/cli/movector/model-router.js +66 -20
- package/dist/src/cli/services/hook-block-hash.js +23 -2
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- package/scripts/post-install-bootstrap.mjs +1 -0
|
@@ -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,
|
|
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
|
|
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,
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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,
|
|
509
|
+
export async function routeToModelFull(task, embeddingOrOptions) {
|
|
464
510
|
const router = getModelRouter();
|
|
465
|
-
return router.route(task,
|
|
511
|
+
return router.route(task, embeddingOrOptions);
|
|
466
512
|
}
|
|
467
513
|
/**
|
|
468
514
|
* Analyze task complexity without routing
|
|
@@ -263,7 +263,7 @@ export function isHookBlockLocked(settings) {
|
|
|
263
263
|
*/
|
|
264
264
|
export function applyAdditiveRegeneration(settings, report) {
|
|
265
265
|
if (report.missing.length === 0)
|
|
266
|
-
return { settings, added: 0 };
|
|
266
|
+
return { settings, added: 0, removed: 0 };
|
|
267
267
|
const ref = getCachedReference().tree;
|
|
268
268
|
const hooks = (settings.hooks ?? {});
|
|
269
269
|
let added = 0;
|
|
@@ -289,7 +289,28 @@ export function applyAdditiveRegeneration(settings, report) {
|
|
|
289
289
|
}
|
|
290
290
|
if (added > 0)
|
|
291
291
|
settings.hooks = hooks;
|
|
292
|
-
return { settings, added };
|
|
292
|
+
return { settings, added, removed: 0 };
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Wholesale regeneration: replace `settings.hooks` with the canonical reference
|
|
296
|
+
* block. Drops extras (stale entries from previous moflo versions, e.g. the
|
|
297
|
+
* `gate.cjs session-reset` SessionStart hook removed in #842) AND adds missing
|
|
298
|
+
* entries — the additive variant only does the latter.
|
|
299
|
+
*
|
|
300
|
+
* The caller MUST check `isHookBlockLocked(settings)` first; if locked, the
|
|
301
|
+
* user has opted out and this function should not be called. Non-hooks fields
|
|
302
|
+
* on `settings` (permissions, env, claudeFlow.*, etc.) are preserved.
|
|
303
|
+
*
|
|
304
|
+
* Mutates `settings` in place; caller is responsible for writing the file.
|
|
305
|
+
*/
|
|
306
|
+
export function applyWholesaleRegeneration(settings, report) {
|
|
307
|
+
if (!report.drifted)
|
|
308
|
+
return { settings, added: 0, removed: 0 };
|
|
309
|
+
// Clone the cached reference so a later mutation of settings.hooks (by the
|
|
310
|
+
// launcher's settings.json migrations, doctor --fix, etc.) cannot corrupt
|
|
311
|
+
// the cached tree shared across `computeHookBlockDrift` calls in this process.
|
|
312
|
+
settings.hooks = structuredClone(getCachedReference().tree);
|
|
313
|
+
return { settings, added: report.missing.length, removed: report.extra.length };
|
|
293
314
|
}
|
|
294
315
|
/**
|
|
295
316
|
* Format a drift report for human-readable output (multi-line, no colour).
|
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.
|
|
3
|
+
"version": "4.9.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",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
82
82
|
"@typescript-eslint/parser": "^7.18.0",
|
|
83
83
|
"eslint": "^8.0.0",
|
|
84
|
-
"moflo": "^4.9.
|
|
84
|
+
"moflo": "^4.9.13",
|
|
85
85
|
"tsx": "^4.21.0",
|
|
86
86
|
"typescript": "^5.9.3",
|
|
87
87
|
"vitest": "^4.0.0"
|