@arvorco/relentless 0.3.1 → 0.4.2
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/commands/relentless.convert.md +25 -0
- package/.claude/skills/analyze/SKILL.md +113 -40
- package/.claude/skills/analyze/templates/analysis-report.md +138 -0
- package/.claude/skills/checklist/SKILL.md +143 -51
- package/.claude/skills/checklist/templates/checklist.md +43 -11
- package/.claude/skills/clarify/SKILL.md +70 -11
- package/.claude/skills/constitution/SKILL.md +61 -3
- package/.claude/skills/constitution/templates/constitution.md +241 -160
- package/.claude/skills/constitution/templates/prompt.md +150 -20
- package/.claude/skills/convert/SKILL.md +248 -0
- package/.claude/skills/implement/SKILL.md +82 -34
- package/.claude/skills/plan/SKILL.md +136 -27
- package/.claude/skills/plan/templates/plan.md +92 -9
- package/.claude/skills/specify/SKILL.md +110 -19
- package/.claude/skills/specify/templates/spec.md +40 -5
- package/.claude/skills/tasks/SKILL.md +75 -1
- package/.claude/skills/tasks/templates/tasks.md +5 -4
- package/CHANGELOG.md +63 -1
- package/MANUAL.md +40 -0
- package/README.md +262 -10
- package/bin/relentless.ts +292 -5
- package/package.json +2 -2
- package/relentless/config.json +46 -2
- package/relentless/constitution.md +2 -2
- package/relentless/prompt.md +97 -18
- package/src/agents/amp.ts +53 -13
- package/src/agents/claude.ts +70 -15
- package/src/agents/codex.ts +73 -14
- package/src/agents/droid.ts +68 -14
- package/src/agents/exec.ts +96 -0
- package/src/agents/gemini.ts +59 -16
- package/src/agents/opencode.ts +188 -9
- package/src/cli/fallback-order.ts +210 -0
- package/src/cli/index.ts +63 -0
- package/src/cli/mode-flag.ts +198 -0
- package/src/cli/review-flags.ts +192 -0
- package/src/config/loader.ts +16 -1
- package/src/config/schema.ts +157 -2
- package/src/execution/runner.ts +144 -21
- package/src/init/scaffolder.ts +285 -25
- package/src/prd/parser.ts +92 -1
- package/src/prd/types.ts +136 -0
- package/src/review/index.ts +92 -0
- package/src/review/prompt.ts +293 -0
- package/src/review/runner.ts +337 -0
- package/src/review/tasks/docs.ts +529 -0
- package/src/review/tasks/index.ts +80 -0
- package/src/review/tasks/lint.ts +436 -0
- package/src/review/tasks/quality.ts +760 -0
- package/src/review/tasks/security.ts +452 -0
- package/src/review/tasks/test.ts +456 -0
- package/src/review/tasks/typecheck.ts +323 -0
- package/src/review/types.ts +139 -0
- package/src/routing/cascade.ts +310 -0
- package/src/routing/classifier.ts +338 -0
- package/src/routing/estimate.ts +270 -0
- package/src/routing/fallback.ts +512 -0
- package/src/routing/index.ts +124 -0
- package/src/routing/registry.ts +501 -0
- package/src/routing/report.ts +570 -0
- package/src/routing/router.ts +287 -0
- package/src/tui/App.tsx +2 -0
- package/src/tui/TUIRunner.tsx +103 -8
- package/src/tui/components/CurrentStory.tsx +23 -1
- package/src/tui/hooks/useTUI.ts +1 -0
- package/src/tui/types.ts +9 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mode-Model Matrix Router (US-010)
|
|
3
|
+
*
|
|
4
|
+
* Routes tasks to optimal harness/model combinations based on
|
|
5
|
+
* user-selected cost optimization mode and task complexity.
|
|
6
|
+
*
|
|
7
|
+
* Key concepts:
|
|
8
|
+
* - MODE_MODEL_MATRIX: 4 modes x 4 complexity levels = 16 routing rules
|
|
9
|
+
* - routeTask(): Main function that classifies task and determines routing
|
|
10
|
+
* - Token estimation: Formula-based cost prediction
|
|
11
|
+
*
|
|
12
|
+
* @module src/routing/router
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import type { UserStory } from "../prd/types";
|
|
17
|
+
import type { AutoModeConfig, Mode, Complexity, HarnessName } from "../config/schema";
|
|
18
|
+
import { HarnessNameSchema, ModeSchema, ComplexitySchema, DEFAULT_CONFIG } from "../config/schema";
|
|
19
|
+
import { classifyTask } from "./classifier";
|
|
20
|
+
import { getHarnessForModel, getModelById } from "./registry";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A routing rule specifying harness and model for a mode/complexity combination.
|
|
24
|
+
*/
|
|
25
|
+
export const RoutingRuleSchema = z.object({
|
|
26
|
+
/** Harness to use */
|
|
27
|
+
harness: HarnessNameSchema,
|
|
28
|
+
/** Model ID to use */
|
|
29
|
+
model: z.string(),
|
|
30
|
+
});
|
|
31
|
+
export type RoutingRule = z.infer<typeof RoutingRuleSchema>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Complete routing decision including complexity, mode, cost, and reasoning.
|
|
35
|
+
*/
|
|
36
|
+
export const RoutingDecisionSchema = z.object({
|
|
37
|
+
/** Selected harness */
|
|
38
|
+
harness: HarnessNameSchema,
|
|
39
|
+
/** Selected model ID */
|
|
40
|
+
model: z.string(),
|
|
41
|
+
/** Classified task complexity */
|
|
42
|
+
complexity: ComplexitySchema,
|
|
43
|
+
/** Active cost optimization mode */
|
|
44
|
+
mode: ModeSchema,
|
|
45
|
+
/** Estimated cost in USD based on token estimation */
|
|
46
|
+
estimatedCost: z.number(),
|
|
47
|
+
/** Human-readable explanation of routing decision */
|
|
48
|
+
reasoning: z.string(),
|
|
49
|
+
});
|
|
50
|
+
export type RoutingDecision = z.infer<typeof RoutingDecisionSchema>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mode-Model Matrix defining routing rules for each mode/complexity combination.
|
|
54
|
+
*
|
|
55
|
+
* 4 modes x 4 complexity levels = 16 routing rules:
|
|
56
|
+
*
|
|
57
|
+
* | Mode | Simple | Medium | Complex | Expert |
|
|
58
|
+
* |--------|------------------|------------------|------------------|------------------|
|
|
59
|
+
* | free | opencode/glm-4.7 | opencode/glm-4.7 | opencode/grok-code-fast-1 | opencode/grok-code-fast-1 |
|
|
60
|
+
* | cheap | claude/haiku | gemini/flash | codex/gpt-5.2-low| codex/gpt-5.2-low|
|
|
61
|
+
* | good | claude/sonnet | claude/sonnet | claude/opus | claude/opus |
|
|
62
|
+
* | genius | claude/opus | claude/opus | claude/opus | claude/opus |
|
|
63
|
+
*/
|
|
64
|
+
export const MODE_MODEL_MATRIX: Record<Mode, Record<Complexity, RoutingRule>> = {
|
|
65
|
+
/**
|
|
66
|
+
* Free mode: Use only zero-cost models.
|
|
67
|
+
* Best for learning, experimentation, or tight budgets.
|
|
68
|
+
*/
|
|
69
|
+
free: {
|
|
70
|
+
simple: { harness: "opencode", model: "glm-4.7" },
|
|
71
|
+
medium: { harness: "opencode", model: "glm-4.7" },
|
|
72
|
+
complex: { harness: "opencode", model: "grok-code-fast-1" },
|
|
73
|
+
expert: { harness: "opencode", model: "grok-code-fast-1" },
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Cheap mode: Use low-cost models for most tasks, escalate only for expert tasks.
|
|
78
|
+
* Saves 50-70% vs SOTA pricing.
|
|
79
|
+
*/
|
|
80
|
+
cheap: {
|
|
81
|
+
simple: { harness: "claude", model: "haiku-4.5" },
|
|
82
|
+
medium: { harness: "gemini", model: "gemini-3-flash" },
|
|
83
|
+
complex: { harness: "codex", model: "gpt-5.2-low" },
|
|
84
|
+
expert: { harness: "codex", model: "gpt-5.2-low" },
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Good mode: Balanced quality/cost with smart routing.
|
|
89
|
+
* Default mode - good for most production work.
|
|
90
|
+
*/
|
|
91
|
+
good: {
|
|
92
|
+
simple: { harness: "claude", model: "sonnet-4.5" },
|
|
93
|
+
medium: { harness: "claude", model: "sonnet-4.5" },
|
|
94
|
+
complex: { harness: "claude", model: "opus-4.5" },
|
|
95
|
+
expert: { harness: "claude", model: "opus-4.5" },
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Genius mode: Use SOTA models for all tasks.
|
|
100
|
+
* Maximum quality, no cost optimization.
|
|
101
|
+
*/
|
|
102
|
+
genius: {
|
|
103
|
+
simple: { harness: "claude", model: "opus-4.5" },
|
|
104
|
+
medium: { harness: "claude", model: "opus-4.5" },
|
|
105
|
+
complex: { harness: "claude", model: "opus-4.5" },
|
|
106
|
+
expert: { harness: "claude", model: "opus-4.5" },
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Estimate the number of tokens needed for a task.
|
|
112
|
+
*
|
|
113
|
+
* Formula: (contentLength / 4) * 1.5
|
|
114
|
+
* - Divide by 4: Average chars per token in English
|
|
115
|
+
* - Multiply by 1.5: Account for agent response (input + output)
|
|
116
|
+
*
|
|
117
|
+
* @param story - The user story to estimate tokens for
|
|
118
|
+
* @returns Estimated token count
|
|
119
|
+
*/
|
|
120
|
+
export function estimateTokens(story: UserStory): number {
|
|
121
|
+
const title = story.title || "";
|
|
122
|
+
const description = story.description || "";
|
|
123
|
+
const criteria = (story.acceptanceCriteria || []).join(" ");
|
|
124
|
+
|
|
125
|
+
const contentLength = title.length + description.length + criteria.length;
|
|
126
|
+
|
|
127
|
+
// Formula: (content.length / 4) * 1.5
|
|
128
|
+
// 4 chars per token average, 1.5x multiplier for agent response
|
|
129
|
+
return Math.ceil((contentLength / 4) * 1.5);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Calculate the estimated cost for a task based on model pricing.
|
|
134
|
+
*
|
|
135
|
+
* Cost formula: (inputTokens * inputCost + outputTokens * outputCost) / 1_000_000
|
|
136
|
+
* - Assumes output is ~1.5x input for agent tasks
|
|
137
|
+
* - Costs are per million tokens
|
|
138
|
+
*
|
|
139
|
+
* @param modelId - The model identifier
|
|
140
|
+
* @param estimatedTokens - Estimated input token count
|
|
141
|
+
* @returns Estimated cost in USD
|
|
142
|
+
*/
|
|
143
|
+
export function calculateCost(modelId: string, estimatedTokens: number): number {
|
|
144
|
+
const model = getModelById(modelId);
|
|
145
|
+
|
|
146
|
+
if (!model) {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Free tier models have zero cost
|
|
151
|
+
if (model.tier === "free") {
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Assume output is roughly 1.5x input for agent tasks
|
|
156
|
+
const inputTokens = estimatedTokens;
|
|
157
|
+
const outputTokens = Math.ceil(estimatedTokens * 1.5);
|
|
158
|
+
|
|
159
|
+
// Costs are per million tokens
|
|
160
|
+
const inputCost = (inputTokens * model.inputCost) / 1_000_000;
|
|
161
|
+
const outputCost = (outputTokens * model.outputCost) / 1_000_000;
|
|
162
|
+
|
|
163
|
+
return inputCost + outputCost;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Route a task to the optimal harness/model combination.
|
|
168
|
+
*
|
|
169
|
+
* This function:
|
|
170
|
+
* 1. Classifies the task complexity using the hybrid classifier
|
|
171
|
+
* 2. Looks up the routing rule from MODE_MODEL_MATRIX
|
|
172
|
+
* 3. Calculates estimated cost
|
|
173
|
+
* 4. Returns a complete RoutingDecision
|
|
174
|
+
*
|
|
175
|
+
* @param story - The user story to route
|
|
176
|
+
* @param config - Auto mode configuration
|
|
177
|
+
* @param modeOverride - Optional mode to override config.defaultMode
|
|
178
|
+
* @returns RoutingDecision with harness, model, complexity, mode, cost, and reasoning
|
|
179
|
+
*/
|
|
180
|
+
export async function routeTask(
|
|
181
|
+
story: UserStory,
|
|
182
|
+
config: AutoModeConfig,
|
|
183
|
+
modeOverride?: Mode
|
|
184
|
+
): Promise<RoutingDecision> {
|
|
185
|
+
// Determine the active mode
|
|
186
|
+
const mode = modeOverride ?? config.defaultMode;
|
|
187
|
+
|
|
188
|
+
// Classify task complexity
|
|
189
|
+
const classification = await classifyTask(story);
|
|
190
|
+
const complexity = classification.complexity;
|
|
191
|
+
|
|
192
|
+
// Look up routing rule from matrix and apply config overrides
|
|
193
|
+
const rule = resolveRoutingRule(config, mode, complexity);
|
|
194
|
+
const harness = rule.harness;
|
|
195
|
+
const model = rule.model;
|
|
196
|
+
|
|
197
|
+
// Calculate estimated cost
|
|
198
|
+
const tokens = estimateTokens(story);
|
|
199
|
+
const estimatedCost = calculateCost(model, tokens);
|
|
200
|
+
|
|
201
|
+
// Generate reasoning
|
|
202
|
+
const reasoning = buildReasoning({
|
|
203
|
+
complexity,
|
|
204
|
+
mode,
|
|
205
|
+
harness,
|
|
206
|
+
model,
|
|
207
|
+
classification,
|
|
208
|
+
tokens,
|
|
209
|
+
estimatedCost,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
harness,
|
|
214
|
+
model,
|
|
215
|
+
complexity,
|
|
216
|
+
mode,
|
|
217
|
+
estimatedCost,
|
|
218
|
+
reasoning,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function hasCustomModeModels(config: AutoModeConfig): boolean {
|
|
223
|
+
const defaults = DEFAULT_CONFIG.autoMode.modeModels;
|
|
224
|
+
return (
|
|
225
|
+
config.modeModels.simple !== defaults.simple ||
|
|
226
|
+
config.modeModels.medium !== defaults.medium ||
|
|
227
|
+
config.modeModels.complex !== defaults.complex ||
|
|
228
|
+
config.modeModels.expert !== defaults.expert
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function resolveRoutingRule(
|
|
233
|
+
config: AutoModeConfig,
|
|
234
|
+
mode: Mode,
|
|
235
|
+
complexity: Complexity
|
|
236
|
+
): RoutingRule {
|
|
237
|
+
const rule = MODE_MODEL_MATRIX[mode][complexity];
|
|
238
|
+
|
|
239
|
+
// Only apply modeModels override for "good" mode
|
|
240
|
+
// Other modes (free, cheap, genius) should always use the matrix
|
|
241
|
+
if (mode !== "good" || !hasCustomModeModels(config)) {
|
|
242
|
+
return rule;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const overrideModel = config.modeModels[complexity];
|
|
246
|
+
const overrideProfile = getModelById(overrideModel);
|
|
247
|
+
if (!overrideProfile) {
|
|
248
|
+
return rule;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const overrideHarness = getHarnessForModel(overrideModel);
|
|
252
|
+
return {
|
|
253
|
+
harness: overrideHarness ?? rule.harness,
|
|
254
|
+
model: overrideModel,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Build a human-readable reasoning string for a routing decision.
|
|
260
|
+
*/
|
|
261
|
+
function buildReasoning(params: {
|
|
262
|
+
complexity: Complexity;
|
|
263
|
+
mode: Mode;
|
|
264
|
+
harness: HarnessName;
|
|
265
|
+
model: string;
|
|
266
|
+
classification: { confidence: number; reasoning: string };
|
|
267
|
+
tokens: number;
|
|
268
|
+
estimatedCost: number;
|
|
269
|
+
}): string {
|
|
270
|
+
const {
|
|
271
|
+
complexity,
|
|
272
|
+
mode,
|
|
273
|
+
harness,
|
|
274
|
+
model,
|
|
275
|
+
classification,
|
|
276
|
+
tokens,
|
|
277
|
+
estimatedCost,
|
|
278
|
+
} = params;
|
|
279
|
+
|
|
280
|
+
const costStr = estimatedCost === 0 ? "free" : `$${estimatedCost.toFixed(4)}`;
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
`Task classified as ${complexity} (confidence: ${(classification.confidence * 100).toFixed(0)}%). ` +
|
|
284
|
+
`Using ${mode} mode, routing to ${harness}/${model}. ` +
|
|
285
|
+
`Estimated tokens: ${tokens}, cost: ${costStr}.`
|
|
286
|
+
);
|
|
287
|
+
}
|
package/src/tui/App.tsx
CHANGED
package/src/tui/TUIRunner.tsx
CHANGED
|
@@ -10,10 +10,11 @@ import { App } from "./App.js";
|
|
|
10
10
|
import type { TUIState, Story, AgentState } from "./types.js";
|
|
11
11
|
import type { AgentName } from "../agents/types.js";
|
|
12
12
|
import type { PRD } from "../prd/types.js";
|
|
13
|
-
import type { RelentlessConfig } from "../config/schema.js";
|
|
13
|
+
import type { RelentlessConfig, Mode } from "../config/schema.js";
|
|
14
14
|
import { getAgent, getInstalledAgents } from "../agents/registry.js";
|
|
15
15
|
import { loadPRD, getNextStory, isComplete, countStories } from "../prd/index.js";
|
|
16
|
-
import {
|
|
16
|
+
import { routeTask } from "../routing/router.js";
|
|
17
|
+
import { getModelForHarnessAndMode, getFreeModeHarnesses } from "../routing/fallback.js";
|
|
17
18
|
import { loadQueueForTUI, watchQueueFile, stopWatchingQueue, QUEUE_PANEL_REFRESH_INTERVAL } from "./components/QueuePanel.js";
|
|
18
19
|
import { handleQueueKeypress, submitToQueue } from "./components/QueueInput.js";
|
|
19
20
|
import { handleQueueDeletionKeypress, removeQueueItem, clearQueueItems } from "./components/QueueRemoval.js";
|
|
@@ -29,6 +30,14 @@ export interface TUIRunnerOptions {
|
|
|
29
30
|
feature: string;
|
|
30
31
|
config: RelentlessConfig;
|
|
31
32
|
dryRun?: boolean;
|
|
33
|
+
/** Cost optimization mode */
|
|
34
|
+
mode?: "free" | "cheap" | "good" | "genius";
|
|
35
|
+
/** Harness fallback order */
|
|
36
|
+
fallbackOrder?: ("claude" | "codex" | "droid" | "opencode" | "amp" | "gemini")[];
|
|
37
|
+
/** Skip final review phase */
|
|
38
|
+
skipReview?: boolean;
|
|
39
|
+
/** Review quality mode (can differ from execution mode) */
|
|
40
|
+
reviewMode?: "free" | "cheap" | "good" | "genius";
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
interface TUIRunnerProps extends TUIRunnerOptions {
|
|
@@ -44,9 +53,17 @@ function TUIRunnerComponent({
|
|
|
44
53
|
feature,
|
|
45
54
|
config,
|
|
46
55
|
dryRun = false,
|
|
56
|
+
mode,
|
|
57
|
+
fallbackOrder,
|
|
47
58
|
onComplete,
|
|
48
59
|
}: TUIRunnerProps): React.ReactElement {
|
|
49
60
|
const { exit } = useApp();
|
|
61
|
+
const autoModeEnabled = preferredAgent === "auto";
|
|
62
|
+
const autoModeConfig = config.autoMode;
|
|
63
|
+
let autoMode = (mode ?? autoModeConfig.defaultMode) as Mode;
|
|
64
|
+
let effectiveFallbackOrder: AgentName[] = autoModeEnabled
|
|
65
|
+
? getFreeModeHarnesses((fallbackOrder ?? autoModeConfig.fallbackOrder) as AgentName[])
|
|
66
|
+
: config.fallback.priority;
|
|
50
67
|
const [state, setState] = useState<TUIState>({
|
|
51
68
|
feature,
|
|
52
69
|
project: "",
|
|
@@ -56,9 +73,11 @@ function TUIRunnerComponent({
|
|
|
56
73
|
maxIterations,
|
|
57
74
|
currentStory: null,
|
|
58
75
|
currentAgent: null,
|
|
76
|
+
currentRouting: undefined,
|
|
59
77
|
agents: [],
|
|
60
78
|
outputLines: [],
|
|
61
79
|
elapsedSeconds: 0,
|
|
80
|
+
idleSeconds: 0,
|
|
62
81
|
isRunning: false,
|
|
63
82
|
isComplete: false,
|
|
64
83
|
queueItems: [],
|
|
@@ -72,6 +91,7 @@ function TUIRunnerComponent({
|
|
|
72
91
|
// Queue file watcher ref
|
|
73
92
|
const queueWatcherRef = React.useRef<FSWatcher | null>(null);
|
|
74
93
|
const featurePathRef = React.useRef<string>("");
|
|
94
|
+
const lastOutputAtRef = React.useRef<number>(Date.now());
|
|
75
95
|
|
|
76
96
|
// Load queue items function
|
|
77
97
|
const loadQueueItems = useCallback(async () => {
|
|
@@ -240,14 +260,16 @@ function TUIRunnerComponent({
|
|
|
240
260
|
}
|
|
241
261
|
});
|
|
242
262
|
|
|
243
|
-
// Timer for elapsed time
|
|
263
|
+
// Timer for elapsed and idle time
|
|
244
264
|
useEffect(() => {
|
|
245
265
|
if (!state.isRunning) return;
|
|
246
266
|
|
|
247
267
|
const interval = setInterval(() => {
|
|
268
|
+
const now = Date.now();
|
|
248
269
|
setState((prev) => ({
|
|
249
270
|
...prev,
|
|
250
271
|
elapsedSeconds: prev.elapsedSeconds + 1,
|
|
272
|
+
idleSeconds: Math.floor((now - lastOutputAtRef.current) / 1000),
|
|
251
273
|
}));
|
|
252
274
|
}, 1000);
|
|
253
275
|
|
|
@@ -261,6 +283,7 @@ function TUIRunnerComponent({
|
|
|
261
283
|
|
|
262
284
|
if (cleanLines.length === 0) return; // Skip empty lines
|
|
263
285
|
|
|
286
|
+
lastOutputAtRef.current = Date.now();
|
|
264
287
|
setState((prev) => ({
|
|
265
288
|
...prev,
|
|
266
289
|
outputLines: [...prev.outputLines.slice(-100), ...cleanLines], // Keep last 100 lines
|
|
@@ -275,6 +298,24 @@ function TUIRunnerComponent({
|
|
|
275
298
|
try {
|
|
276
299
|
// Load PRD
|
|
277
300
|
const prd = await loadPRD(prdPath);
|
|
301
|
+
const prdRoutingPreference = prd.routingPreference;
|
|
302
|
+
const preferredMode =
|
|
303
|
+
prdRoutingPreference?.type === "auto" ? prdRoutingPreference.mode : undefined;
|
|
304
|
+
autoMode = (mode ?? preferredMode ?? autoModeConfig.defaultMode) as Mode;
|
|
305
|
+
const allowFree =
|
|
306
|
+
prdRoutingPreference?.type === "auto"
|
|
307
|
+
? prdRoutingPreference.allowFree !== false
|
|
308
|
+
: true;
|
|
309
|
+
const autoFallbackOrder = (fallbackOrder ?? autoModeConfig.fallbackOrder) as AgentName[];
|
|
310
|
+
const filteredFallbackOrder =
|
|
311
|
+
allowFree || autoMode === "free"
|
|
312
|
+
? autoFallbackOrder
|
|
313
|
+
: autoFallbackOrder.filter((h) => h !== "opencode");
|
|
314
|
+
effectiveFallbackOrder = autoModeEnabled
|
|
315
|
+
? autoMode === "free"
|
|
316
|
+
? getFreeModeHarnesses(filteredFallbackOrder)
|
|
317
|
+
: filteredFallbackOrder
|
|
318
|
+
: config.fallback.priority;
|
|
278
319
|
|
|
279
320
|
// Get installed agents
|
|
280
321
|
const installed = await getInstalledAgents();
|
|
@@ -310,6 +351,10 @@ function TUIRunnerComponent({
|
|
|
310
351
|
addOutput("DRY RUN - not executing agents");
|
|
311
352
|
}
|
|
312
353
|
|
|
354
|
+
if (autoModeEnabled && !autoModeConfig.enabled) {
|
|
355
|
+
addOutput("Auto mode routing enabled via CLI even though config.autoMode.enabled is false.");
|
|
356
|
+
}
|
|
357
|
+
|
|
313
358
|
// Track rate-limited agents
|
|
314
359
|
const limitedAgents = new Map<AgentName, { resetTime?: Date; detectedAt: Date }>();
|
|
315
360
|
|
|
@@ -348,15 +393,41 @@ function TUIRunnerComponent({
|
|
|
348
393
|
phase: story.phase,
|
|
349
394
|
},
|
|
350
395
|
elapsedSeconds: 0,
|
|
396
|
+
idleSeconds: 0,
|
|
397
|
+
currentRouting: autoModeEnabled ? prev.currentRouting : undefined,
|
|
351
398
|
}));
|
|
399
|
+
lastOutputAtRef.current = Date.now();
|
|
352
400
|
|
|
353
401
|
addOutput(`--- Iteration ${i}/${maxIterations} ---`);
|
|
354
402
|
addOutput(`Story: ${story.id} - ${story.title}`);
|
|
355
403
|
|
|
356
404
|
// Select agent
|
|
357
405
|
let agentName: AgentName;
|
|
406
|
+
let autoRoutingDecision: Awaited<ReturnType<typeof routeTask>> | null = null;
|
|
407
|
+
let autoRoutingModel: string | undefined;
|
|
408
|
+
|
|
358
409
|
if (preferredAgent === "auto") {
|
|
359
|
-
|
|
410
|
+
autoRoutingDecision = await routeTask(story, autoModeConfig, autoMode);
|
|
411
|
+
if (!autoRoutingDecision) {
|
|
412
|
+
throw new Error("Failed to get routing decision");
|
|
413
|
+
}
|
|
414
|
+
const decision = autoRoutingDecision;
|
|
415
|
+
agentName = decision.harness as AgentName;
|
|
416
|
+
autoRoutingModel = decision.model;
|
|
417
|
+
|
|
418
|
+
setState((prev) => ({
|
|
419
|
+
...prev,
|
|
420
|
+
currentRouting: {
|
|
421
|
+
mode: autoMode,
|
|
422
|
+
complexity: decision.complexity,
|
|
423
|
+
harness: agentName,
|
|
424
|
+
model: decision.model,
|
|
425
|
+
},
|
|
426
|
+
}));
|
|
427
|
+
|
|
428
|
+
addOutput(
|
|
429
|
+
`Routing: ${autoMode}/${decision.complexity} -> ${agentName}/${decision.model}`
|
|
430
|
+
);
|
|
360
431
|
} else {
|
|
361
432
|
agentName = preferredAgent;
|
|
362
433
|
}
|
|
@@ -372,12 +443,34 @@ function TUIRunnerComponent({
|
|
|
372
443
|
limitedAgents.delete(agentName);
|
|
373
444
|
} else {
|
|
374
445
|
// Try fallback
|
|
375
|
-
for (const fallbackName of
|
|
446
|
+
for (const fallbackName of effectiveFallbackOrder) {
|
|
376
447
|
if (!limitedAgents.has(fallbackName)) {
|
|
377
448
|
const installed = agentStates.find((a) => a.name === fallbackName);
|
|
378
449
|
if (installed) {
|
|
379
450
|
agentName = fallbackName;
|
|
380
|
-
|
|
451
|
+
if (autoRoutingDecision && autoModeEnabled) {
|
|
452
|
+
const fallbackModel = getModelForHarnessAndMode(
|
|
453
|
+
fallbackName,
|
|
454
|
+
autoMode,
|
|
455
|
+
autoRoutingDecision.complexity,
|
|
456
|
+
autoModeConfig
|
|
457
|
+
);
|
|
458
|
+
autoRoutingModel = fallbackModel;
|
|
459
|
+
setState((prev) => ({
|
|
460
|
+
...prev,
|
|
461
|
+
currentRouting: {
|
|
462
|
+
mode: autoMode,
|
|
463
|
+
complexity: autoRoutingDecision.complexity,
|
|
464
|
+
harness: fallbackName,
|
|
465
|
+
model: fallbackModel,
|
|
466
|
+
},
|
|
467
|
+
}));
|
|
468
|
+
addOutput(
|
|
469
|
+
`Switched to fallback agent: ${fallbackName} (model ${fallbackModel})`
|
|
470
|
+
);
|
|
471
|
+
} else {
|
|
472
|
+
addOutput(`Switched to fallback agent: ${fallbackName}`);
|
|
473
|
+
}
|
|
381
474
|
break;
|
|
382
475
|
}
|
|
383
476
|
}
|
|
@@ -419,7 +512,8 @@ function TUIRunnerComponent({
|
|
|
419
512
|
const stream = agent.invokeStream(prompt, {
|
|
420
513
|
workingDirectory,
|
|
421
514
|
dangerouslyAllowAll: config.agents[agent.name]?.dangerouslyAllowAll ?? true,
|
|
422
|
-
model: config.agents[agent.name]?.model,
|
|
515
|
+
model: autoModeEnabled ? autoRoutingModel : config.agents[agent.name]?.model,
|
|
516
|
+
timeout: config.execution.timeout,
|
|
423
517
|
});
|
|
424
518
|
|
|
425
519
|
let result;
|
|
@@ -475,7 +569,8 @@ function TUIRunnerComponent({
|
|
|
475
569
|
const result = await agent.invoke(prompt, {
|
|
476
570
|
workingDirectory,
|
|
477
571
|
dangerouslyAllowAll: config.agents[agent.name]?.dangerouslyAllowAll ?? true,
|
|
478
|
-
model: config.agents[agent.name]?.model,
|
|
572
|
+
model: autoModeEnabled ? autoRoutingModel : config.agents[agent.name]?.model,
|
|
573
|
+
timeout: config.execution.timeout,
|
|
479
574
|
});
|
|
480
575
|
|
|
481
576
|
// Add output preview
|
|
@@ -14,6 +14,13 @@ interface CurrentStoryProps {
|
|
|
14
14
|
story: Story | null;
|
|
15
15
|
elapsedSeconds: number;
|
|
16
16
|
isRunning: boolean;
|
|
17
|
+
idleSeconds: number;
|
|
18
|
+
routing?: {
|
|
19
|
+
mode: "free" | "cheap" | "good" | "genius";
|
|
20
|
+
complexity: "simple" | "medium" | "complex" | "expert";
|
|
21
|
+
harness: string;
|
|
22
|
+
model: string;
|
|
23
|
+
};
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
function formatTime(seconds: number): string {
|
|
@@ -29,6 +36,8 @@ export function CurrentStory({
|
|
|
29
36
|
story,
|
|
30
37
|
elapsedSeconds,
|
|
31
38
|
isRunning,
|
|
39
|
+
idleSeconds,
|
|
40
|
+
routing,
|
|
32
41
|
}: CurrentStoryProps): React.ReactElement {
|
|
33
42
|
if (!story) {
|
|
34
43
|
return (
|
|
@@ -59,8 +68,21 @@ export function CurrentStory({
|
|
|
59
68
|
) : (
|
|
60
69
|
<Text color={colors.dim}>{symbols.pending} Waiting</Text>
|
|
61
70
|
)}
|
|
62
|
-
<Text color={colors.dim}>
|
|
71
|
+
<Text color={colors.dim}>
|
|
72
|
+
{" "}
|
|
73
|
+
[elapsed: {formatTime(elapsedSeconds)}, idle: {formatTime(idleSeconds)}]
|
|
74
|
+
</Text>
|
|
63
75
|
</Box>
|
|
76
|
+
{routing && (
|
|
77
|
+
<Box>
|
|
78
|
+
<Text color={colors.dim}>Routing: </Text>
|
|
79
|
+
<Text color={colors.warning}>
|
|
80
|
+
{routing.mode}/{routing.complexity}
|
|
81
|
+
</Text>
|
|
82
|
+
<Text color={colors.dim}> → </Text>
|
|
83
|
+
<Text>{routing.harness}/{routing.model}</Text>
|
|
84
|
+
</Box>
|
|
85
|
+
)}
|
|
64
86
|
</Box>
|
|
65
87
|
);
|
|
66
88
|
}
|
package/src/tui/hooks/useTUI.ts
CHANGED
package/src/tui/types.ts
CHANGED
|
@@ -42,12 +42,21 @@ export interface TUIState {
|
|
|
42
42
|
currentStory: Story | null;
|
|
43
43
|
/** Current agent */
|
|
44
44
|
currentAgent: AgentState | null;
|
|
45
|
+
/** Current routing decision (auto mode) */
|
|
46
|
+
currentRouting?: {
|
|
47
|
+
mode: "free" | "cheap" | "good" | "genius";
|
|
48
|
+
complexity: "simple" | "medium" | "complex" | "expert";
|
|
49
|
+
harness: AgentName;
|
|
50
|
+
model: string;
|
|
51
|
+
};
|
|
45
52
|
/** All agents with their states */
|
|
46
53
|
agents: AgentState[];
|
|
47
54
|
/** Agent output lines */
|
|
48
55
|
outputLines: string[];
|
|
49
56
|
/** Elapsed time in seconds */
|
|
50
57
|
elapsedSeconds: number;
|
|
58
|
+
/** Idle time in seconds since last output */
|
|
59
|
+
idleSeconds: number;
|
|
51
60
|
/** Is running */
|
|
52
61
|
isRunning: boolean;
|
|
53
62
|
/** Is complete */
|