@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.
Files changed (66) hide show
  1. package/.claude/commands/relentless.convert.md +25 -0
  2. package/.claude/skills/analyze/SKILL.md +113 -40
  3. package/.claude/skills/analyze/templates/analysis-report.md +138 -0
  4. package/.claude/skills/checklist/SKILL.md +143 -51
  5. package/.claude/skills/checklist/templates/checklist.md +43 -11
  6. package/.claude/skills/clarify/SKILL.md +70 -11
  7. package/.claude/skills/constitution/SKILL.md +61 -3
  8. package/.claude/skills/constitution/templates/constitution.md +241 -160
  9. package/.claude/skills/constitution/templates/prompt.md +150 -20
  10. package/.claude/skills/convert/SKILL.md +248 -0
  11. package/.claude/skills/implement/SKILL.md +82 -34
  12. package/.claude/skills/plan/SKILL.md +136 -27
  13. package/.claude/skills/plan/templates/plan.md +92 -9
  14. package/.claude/skills/specify/SKILL.md +110 -19
  15. package/.claude/skills/specify/templates/spec.md +40 -5
  16. package/.claude/skills/tasks/SKILL.md +75 -1
  17. package/.claude/skills/tasks/templates/tasks.md +5 -4
  18. package/CHANGELOG.md +63 -1
  19. package/MANUAL.md +40 -0
  20. package/README.md +262 -10
  21. package/bin/relentless.ts +292 -5
  22. package/package.json +2 -2
  23. package/relentless/config.json +46 -2
  24. package/relentless/constitution.md +2 -2
  25. package/relentless/prompt.md +97 -18
  26. package/src/agents/amp.ts +53 -13
  27. package/src/agents/claude.ts +70 -15
  28. package/src/agents/codex.ts +73 -14
  29. package/src/agents/droid.ts +68 -14
  30. package/src/agents/exec.ts +96 -0
  31. package/src/agents/gemini.ts +59 -16
  32. package/src/agents/opencode.ts +188 -9
  33. package/src/cli/fallback-order.ts +210 -0
  34. package/src/cli/index.ts +63 -0
  35. package/src/cli/mode-flag.ts +198 -0
  36. package/src/cli/review-flags.ts +192 -0
  37. package/src/config/loader.ts +16 -1
  38. package/src/config/schema.ts +157 -2
  39. package/src/execution/runner.ts +144 -21
  40. package/src/init/scaffolder.ts +285 -25
  41. package/src/prd/parser.ts +92 -1
  42. package/src/prd/types.ts +136 -0
  43. package/src/review/index.ts +92 -0
  44. package/src/review/prompt.ts +293 -0
  45. package/src/review/runner.ts +337 -0
  46. package/src/review/tasks/docs.ts +529 -0
  47. package/src/review/tasks/index.ts +80 -0
  48. package/src/review/tasks/lint.ts +436 -0
  49. package/src/review/tasks/quality.ts +760 -0
  50. package/src/review/tasks/security.ts +452 -0
  51. package/src/review/tasks/test.ts +456 -0
  52. package/src/review/tasks/typecheck.ts +323 -0
  53. package/src/review/types.ts +139 -0
  54. package/src/routing/cascade.ts +310 -0
  55. package/src/routing/classifier.ts +338 -0
  56. package/src/routing/estimate.ts +270 -0
  57. package/src/routing/fallback.ts +512 -0
  58. package/src/routing/index.ts +124 -0
  59. package/src/routing/registry.ts +501 -0
  60. package/src/routing/report.ts +570 -0
  61. package/src/routing/router.ts +287 -0
  62. package/src/tui/App.tsx +2 -0
  63. package/src/tui/TUIRunner.tsx +103 -8
  64. package/src/tui/components/CurrentStory.tsx +23 -1
  65. package/src/tui/hooks/useTUI.ts +1 -0
  66. package/src/tui/types.ts +9 -0
@@ -20,11 +20,242 @@
20
20
  * - Does not modify project root files (CLAUDE.md, AGENTS.md, etc.)
21
21
  */
22
22
 
23
- import { existsSync, mkdirSync } from "node:fs";
23
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
24
24
  import { join } from "node:path";
25
25
  import chalk from "chalk";
26
26
  import { checkAgentHealth } from "../agents/registry";
27
- import { DEFAULT_CONFIG } from "../config/schema";
27
+ import { DEFAULT_CONFIG, type Mode } from "../config/schema";
28
+
29
+ /**
30
+ * Explanation of Auto Mode shown to users during init
31
+ */
32
+ export const AUTO_MODE_EXPLANATION = `
33
+ Smart Auto Mode automatically routes tasks to the most cost-effective model
34
+ based on complexity. Simple tasks use free/cheap models, complex tasks use
35
+ premium models. This typically saves 50-75% on API costs.
36
+ `;
37
+
38
+ /**
39
+ * Descriptions for each cost mode
40
+ */
41
+ export const MODE_DESCRIPTIONS: Record<Mode, string> = {
42
+ free: "Free tier models only (OpenCode) - Maximum savings",
43
+ cheap: "Budget models (Haiku, Gemini Flash, GPT-5.2 low effort) - High savings, good quality",
44
+ good: "Balanced models (Sonnet, GPT-5.2 medium effort) - Good savings, high quality (Recommended)",
45
+ genius: "Premium models (Opus, GPT-5.2 high effort) - No savings, maximum quality",
46
+ };
47
+
48
+ /**
49
+ * Estimated savings percentages for each mode
50
+ */
51
+ const MODE_SAVINGS: Record<Mode, string> = {
52
+ free: "~95%",
53
+ cheap: "~75%",
54
+ good: "~50%",
55
+ genius: "~0%",
56
+ };
57
+
58
+ /**
59
+ * Interface for Auto Mode configuration options
60
+ */
61
+ export interface InitAutoModeOptions {
62
+ /** Skip the interactive prompt and use provided values */
63
+ skipPrompt?: boolean;
64
+ /** Whether Auto Mode should be enabled */
65
+ enabled?: boolean;
66
+ /** Default mode to use */
67
+ defaultMode?: Mode;
68
+ /** Generate YAML config instead of JSON */
69
+ generateYaml?: boolean;
70
+ }
71
+
72
+ /**
73
+ * Interface for readline mock (for testing)
74
+ */
75
+ interface ReadlineInterface {
76
+ question: (prompt: string, callback: (answer: string) => void) => void;
77
+ close: () => void;
78
+ }
79
+
80
+ /**
81
+ * Options for promptAutoModeConfig
82
+ */
83
+ export interface PromptAutoModeOptions {
84
+ /** Readline interface (can be mocked for testing) */
85
+ readline: ReadlineInterface;
86
+ /** Default mode if user presses Enter */
87
+ defaultMode: Mode;
88
+ }
89
+
90
+ /**
91
+ * Result from promptAutoModeConfig
92
+ */
93
+ export interface AutoModeConfigResult {
94
+ enabled: boolean;
95
+ defaultMode?: Mode;
96
+ }
97
+
98
+ /**
99
+ * Get estimated savings percentage for a mode
100
+ */
101
+ export function getEstimatedSavings(mode: Mode): string {
102
+ return MODE_SAVINGS[mode];
103
+ }
104
+
105
+ /**
106
+ * Parse Auto Mode related CLI flags
107
+ */
108
+ export function parseAutoModeFlags(options: {
109
+ yes?: boolean;
110
+ noAutoMode?: boolean;
111
+ }): InitAutoModeOptions {
112
+ // --no-auto-mode takes priority over --yes
113
+ if (options.noAutoMode) {
114
+ return {
115
+ skipPrompt: true,
116
+ enabled: false,
117
+ };
118
+ }
119
+
120
+ if (options.yes) {
121
+ return {
122
+ skipPrompt: true,
123
+ enabled: true,
124
+ defaultMode: "good",
125
+ };
126
+ }
127
+
128
+ return {
129
+ skipPrompt: false,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Check if project already has Auto Mode config
135
+ */
136
+ export async function hasExistingAutoModeConfig(projectDir: string): Promise<boolean> {
137
+ const relentlessDir = join(projectDir, "relentless");
138
+
139
+ // Check config.json
140
+ const configJsonPath = join(relentlessDir, "config.json");
141
+ if (existsSync(configJsonPath)) {
142
+ try {
143
+ const content = await Bun.file(configJsonPath).text();
144
+ const config = JSON.parse(content);
145
+ if (config.autoMode !== undefined) {
146
+ return true;
147
+ }
148
+ } catch {
149
+ // Ignore parse errors
150
+ }
151
+ }
152
+
153
+ // Check relentless.config.yaml
154
+ const yamlPath = join(relentlessDir, "relentless.config.yaml");
155
+ if (existsSync(yamlPath)) {
156
+ try {
157
+ const content = await Bun.file(yamlPath).text();
158
+ if (content.includes("autoMode:")) {
159
+ return true;
160
+ }
161
+ } catch {
162
+ // Ignore read errors
163
+ }
164
+ }
165
+
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * Prompt user for Auto Mode configuration
171
+ */
172
+ export async function promptAutoModeConfig(
173
+ options: PromptAutoModeOptions
174
+ ): Promise<AutoModeConfigResult> {
175
+ const { readline, defaultMode } = options;
176
+
177
+ return new Promise((resolve) => {
178
+ // First question: Enable Auto Mode?
179
+ readline.question(
180
+ chalk.yellow("Enable Smart Auto Mode? (Saves 50-75% on costs) [Y/n]: "),
181
+ (enableAnswer) => {
182
+ const normalizedAnswer = enableAnswer.trim().toLowerCase();
183
+
184
+ // Empty or affirmative answers enable Auto Mode
185
+ if (
186
+ normalizedAnswer === "" ||
187
+ normalizedAnswer === "y" ||
188
+ normalizedAnswer === "yes"
189
+ ) {
190
+ // Second question: Select mode
191
+ readline.question(
192
+ chalk.yellow(`Default mode? [free/cheap/good/genius] (${defaultMode}): `),
193
+ (modeAnswer) => {
194
+ const trimmedMode = modeAnswer.trim().toLowerCase();
195
+ let selectedMode: Mode = defaultMode;
196
+
197
+ if (trimmedMode === "") {
198
+ selectedMode = defaultMode;
199
+ } else if (["free", "cheap", "good", "genius"].includes(trimmedMode)) {
200
+ selectedMode = trimmedMode as Mode;
201
+ } else {
202
+ // Invalid mode - use default
203
+ selectedMode = defaultMode;
204
+ }
205
+
206
+ resolve({
207
+ enabled: true,
208
+ defaultMode: selectedMode,
209
+ });
210
+ }
211
+ );
212
+ } else {
213
+ // User declined
214
+ resolve({
215
+ enabled: false,
216
+ });
217
+ }
218
+ }
219
+ );
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Generate YAML config content for Auto Mode
225
+ */
226
+ export function generateAutoModeYamlConfig(config: AutoModeConfigResult): string {
227
+ const lines: string[] = [];
228
+
229
+ lines.push("# Relentless Auto Mode Configuration");
230
+ lines.push("# Generated by relentless init");
231
+ lines.push("");
232
+ lines.push("autoMode:");
233
+ lines.push(` enabled: ${config.enabled}`);
234
+
235
+ if (config.enabled && config.defaultMode) {
236
+ lines.push(` defaultMode: ${config.defaultMode}`);
237
+ lines.push("");
238
+ lines.push(" # Harness fallback order (uncomment to customize)");
239
+ lines.push(" # fallbackOrder:");
240
+ lines.push(" # - claude");
241
+ lines.push(" # - amp");
242
+ lines.push(" # - opencode");
243
+ lines.push(" # - codex");
244
+ lines.push(" # - gemini");
245
+ lines.push(" # - droid");
246
+ lines.push("");
247
+ lines.push(" # Custom model mappings by complexity (uncomment to customize)");
248
+ lines.push(" # modeModels:");
249
+ lines.push(" # simple: haiku-4.5");
250
+ lines.push(" # medium: sonnet-4.5");
251
+ lines.push(" # complex: opus-4.5");
252
+ lines.push(" # expert: opus-4.5");
253
+ }
254
+
255
+ lines.push("");
256
+
257
+ return lines.join("\n");
258
+ }
28
259
 
29
260
  /**
30
261
  * Get the relentless root directory
@@ -47,14 +278,6 @@ function getRelentlessRoot(): string {
47
278
 
48
279
  const relentlessRoot = getRelentlessRoot();
49
280
 
50
- /**
51
- * Files to create in the relentless/ directory
52
- * These can be force-updated with -f flag
53
- */
54
- const RELENTLESS_FILES: Record<string, () => string> = {
55
- "config.json": () => JSON.stringify(DEFAULT_CONFIG, null, 2),
56
- };
57
-
58
281
  /**
59
282
  * Default progress.txt content for a new feature with YAML frontmatter
60
283
  */
@@ -81,7 +304,11 @@ patterns: []
81
304
  /**
82
305
  * Initialize Relentless in a project
83
306
  */
84
- export async function initProject(projectDir: string = process.cwd(), force: boolean = false): Promise<void> {
307
+ export async function initProject(
308
+ projectDir: string = process.cwd(),
309
+ force: boolean = false,
310
+ autoModeOptions: InitAutoModeOptions = {}
311
+ ): Promise<void> {
85
312
  console.log(chalk.bold.blue(`\n🚀 ${force ? "Reinstalling" : "Initializing"} Relentless\n`));
86
313
 
87
314
  // Check installed agents
@@ -112,17 +339,50 @@ export async function initProject(projectDir: string = process.cwd(), force: boo
112
339
  // Create relentless files (can be force-updated)
113
340
  console.log(chalk.dim("\nCreating relentless files..."));
114
341
 
115
- for (const [filename, contentFn] of Object.entries(RELENTLESS_FILES)) {
116
- const path = join(relentlessDir, filename);
117
-
118
- if (existsSync(path) && !force) {
119
- console.log(` ${chalk.yellow("⚠")} relentless/${filename} already exists, skipping`);
120
- continue;
342
+ // Generate config with Auto Mode settings if provided
343
+ const configPath = join(relentlessDir, "config.json");
344
+ const existingAutoMode = await hasExistingAutoModeConfig(projectDir);
345
+
346
+ if (existsSync(configPath) && !force) {
347
+ console.log(` ${chalk.yellow("⚠")} relentless/config.json already exists, skipping`);
348
+ } else {
349
+ // Build config with Auto Mode settings
350
+ const config = { ...DEFAULT_CONFIG };
351
+
352
+ // Apply Auto Mode options if provided
353
+ if (autoModeOptions.skipPrompt) {
354
+ config.autoMode = {
355
+ ...config.autoMode,
356
+ enabled: autoModeOptions.enabled ?? false,
357
+ };
358
+ if (autoModeOptions.enabled && autoModeOptions.defaultMode) {
359
+ config.autoMode.defaultMode = autoModeOptions.defaultMode;
360
+ }
361
+ } else if (!existingAutoMode) {
362
+ // Set default autoMode if no existing config
363
+ config.autoMode = {
364
+ ...config.autoMode,
365
+ enabled: autoModeOptions.enabled ?? false,
366
+ };
367
+ if (autoModeOptions.enabled && autoModeOptions.defaultMode) {
368
+ config.autoMode.defaultMode = autoModeOptions.defaultMode;
369
+ }
121
370
  }
122
371
 
123
- await Bun.write(path, contentFn());
124
- const action = existsSync(path) && force ? "updated" : "created";
125
- console.log(` ${chalk.green("✓")} relentless/${filename} ${force ? `(${action})` : ""}`);
372
+ await Bun.write(configPath, JSON.stringify(config, null, 2));
373
+ const action = force ? "updated" : "created";
374
+ console.log(` ${chalk.green("✓")} relentless/config.json (${action})`);
375
+ }
376
+
377
+ // Generate YAML config if requested
378
+ if (autoModeOptions.generateYaml && autoModeOptions.enabled !== undefined) {
379
+ const yamlPath = join(relentlessDir, "relentless.config.yaml");
380
+ const yamlContent = generateAutoModeYamlConfig({
381
+ enabled: autoModeOptions.enabled,
382
+ defaultMode: autoModeOptions.defaultMode,
383
+ });
384
+ await Bun.write(yamlPath, yamlContent);
385
+ console.log(` ${chalk.green("✓")} relentless/relentless.config.yaml (created)`);
126
386
  }
127
387
 
128
388
  // Note: constitution.md and prompt.md are NOT created here
@@ -171,6 +431,7 @@ export async function initProject(projectDir: string = process.cwd(), force: boo
171
431
  "specify",
172
432
  "plan",
173
433
  "tasks",
434
+ "convert",
174
435
  "checklist",
175
436
  "clarify",
176
437
  "analyze",
@@ -382,6 +643,7 @@ export async function initProject(projectDir: string = process.cwd(), force: boo
382
643
  "relentless.checklist.md",
383
644
  "relentless.clarify.md",
384
645
  "relentless.constitution.md",
646
+ "relentless.convert.md",
385
647
  "relentless.implement.md",
386
648
  "relentless.plan.md",
387
649
  "relentless.specify.md",
@@ -573,6 +835,8 @@ For full documentation, see: https://github.com/ArvorCo/Relentless
573
835
  console.log(chalk.dim("\n4. Convert to JSON and run:"));
574
836
  console.log(` ${chalk.cyan("relentless convert relentless/features/NNN-feature/tasks.md --feature NNN-feature")}`);
575
837
  console.log(` ${chalk.cyan("relentless run --feature NNN-feature --tui")}`);
838
+ console.log(chalk.dim("\nUpgrade note:"));
839
+ console.log(chalk.dim("If you are upgrading Relentless, re-run /relentless.constitution to refresh prompt.md with the latest instructions."));
576
840
  console.log("");
577
841
  }
578
842
 
@@ -662,11 +926,7 @@ export function listFeatures(projectDir: string): string[] {
662
926
  return [];
663
927
  }
664
928
 
665
- const entries = Bun.spawnSync(["ls", "-1", featuresDir]);
666
- const output = new TextDecoder().decode(entries.stdout);
667
-
668
- return output
669
- .split("\n")
929
+ return readdirSync(featuresDir)
670
930
  .map((s) => s.trim())
671
931
  .filter((s) => s && s !== ".gitkeep");
672
932
  }
package/src/prd/parser.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * Parses PRD markdown files and converts them to prd.json format
5
5
  */
6
6
 
7
- import { PRDSchema, type PRD, type UserStory } from "./types";
7
+ import { PRDSchema, type PRD, type UserStory, type ExecutionHistory, type EscalationAttempt } from "./types";
8
+ import type { Mode, HarnessName } from "../config/schema";
8
9
 
9
10
  /**
10
11
  * Check if a criterion line is valid (not a file path, divider, etc.)
@@ -59,6 +60,26 @@ export function parsePRDMarkdown(content: string): Partial<PRD> {
59
60
  continue;
60
61
  }
61
62
 
63
+ // Parse routing preference line
64
+ if (trimmed.match(/^(\*\*Routing Preference\*\*|Routing Preference):/i)) {
65
+ const raw = trimmed.replace(/^(\*\*Routing Preference\*\*|Routing Preference):/i, "").trim();
66
+ const lower = raw.toLowerCase();
67
+ const modeMatch = lower.match(/\b(free|cheap|good|genius)\b/);
68
+ const allowFreeMatch = lower.match(/allow\s+free:\s*(yes|no)/);
69
+ const harnessMatch = lower.match(/\b(claude|amp|opencode|codex|droid|gemini)\b/);
70
+ const modelMatch = raw.match(/\/([^\s]+)/);
71
+
72
+ prd.routingPreference = {
73
+ raw,
74
+ type: lower.includes("auto") ? "auto" : harnessMatch ? "harness" : undefined,
75
+ mode: modeMatch ? (modeMatch[1] as Mode) : undefined,
76
+ allowFree: allowFreeMatch ? allowFreeMatch[1] === "yes" : undefined,
77
+ harness: harnessMatch ? (harnessMatch[1] as HarnessName) : undefined,
78
+ model: modelMatch ? modelMatch[1].replace(/[,)]$/, "") : undefined,
79
+ };
80
+ continue;
81
+ }
82
+
62
83
  // Parse section headers (## Section)
63
84
  if (trimmed.startsWith("## ")) {
64
85
  currentSection = trimmed.replace("## ", "").toLowerCase();
@@ -258,6 +279,10 @@ export function createPRD(parsed: Partial<PRD>, featureName?: string): PRD {
258
279
  })),
259
280
  };
260
281
 
282
+ if (parsed.routingPreference) {
283
+ prd.routingPreference = parsed.routingPreference;
284
+ }
285
+
261
286
  // Validate
262
287
  return PRDSchema.parse(prd);
263
288
  }
@@ -419,3 +444,69 @@ export async function prioritizeStory(
419
444
  previousPriority,
420
445
  };
421
446
  }
447
+
448
+ /**
449
+ * Result of updating story execution history
450
+ */
451
+ export interface UpdateExecutionResult {
452
+ success: boolean;
453
+ error?: string;
454
+ }
455
+
456
+ /**
457
+ * Update a story's execution history after it completes
458
+ *
459
+ * @param prdPath - Path to the prd.json file
460
+ * @param storyId - The ID of the completed story
461
+ * @param executionData - Execution data to save
462
+ * @returns Result indicating success or failure
463
+ */
464
+ export async function updateStoryExecution(
465
+ prdPath: string,
466
+ storyId: string,
467
+ executionData: {
468
+ attempts: number;
469
+ escalations: EscalationAttempt[];
470
+ actualCost: number;
471
+ actualHarness: HarnessName;
472
+ actualModel: string;
473
+ inputTokens?: number;
474
+ outputTokens?: number;
475
+ }
476
+ ): Promise<UpdateExecutionResult> {
477
+ // Load current PRD
478
+ const prd = await loadPRD(prdPath);
479
+
480
+ // Find the story
481
+ const storyIndex = prd.userStories.findIndex((s) => s.id === storyId);
482
+ if (storyIndex === -1) {
483
+ return {
484
+ success: false,
485
+ error: `Story ${storyId} not found in PRD`,
486
+ };
487
+ }
488
+
489
+ // Build execution history
490
+ const executionHistory: ExecutionHistory = {
491
+ attempts: executionData.attempts,
492
+ escalations: executionData.escalations,
493
+ actualCost: executionData.actualCost,
494
+ actualHarness: executionData.actualHarness,
495
+ actualModel: executionData.actualModel,
496
+ inputTokens: executionData.inputTokens,
497
+ outputTokens: executionData.outputTokens,
498
+ };
499
+
500
+ // Update the story with execution history
501
+ prd.userStories[storyIndex] = {
502
+ ...prd.userStories[storyIndex],
503
+ execution: executionHistory,
504
+ };
505
+
506
+ // Save the updated PRD
507
+ await savePRD(prd, prdPath);
508
+
509
+ return {
510
+ success: true,
511
+ };
512
+ }
package/src/prd/types.ts CHANGED
@@ -5,6 +5,128 @@
5
5
  */
6
6
 
7
7
  import { z } from "zod";
8
+ import { ModeSchema, ComplexitySchema, HarnessNameSchema } from "../config/schema";
9
+
10
+ // ============================================================================
11
+ // Routing Metadata Schemas (US-026)
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Routing metadata schema for user stories.
16
+ *
17
+ * Contains classification and routing decisions made during the
18
+ * `/relentless.tasks` phase after complexity classification.
19
+ *
20
+ * @example
21
+ * {
22
+ * complexity: "medium",
23
+ * harness: "claude",
24
+ * model: "sonnet-4.5",
25
+ * mode: "good",
26
+ * estimatedCost: 0.15,
27
+ * classificationReasoning: "Keywords suggest medium complexity"
28
+ * }
29
+ */
30
+ export const RoutingMetadataSchema = z.object({
31
+ /** Task complexity level determined by the classifier */
32
+ complexity: ComplexitySchema,
33
+ /** Selected harness for executing this story */
34
+ harness: HarnessNameSchema,
35
+ /** Selected model within the harness */
36
+ model: z.string(),
37
+ /** Mode used for routing decision (free, cheap, good, genius) */
38
+ mode: ModeSchema,
39
+ /** Estimated cost in USD based on token estimation */
40
+ estimatedCost: z.number().nonnegative(),
41
+ /** Human-readable explanation of classification reasoning (optional) */
42
+ classificationReasoning: z.string().optional(),
43
+ });
44
+
45
+ export type RoutingMetadata = z.infer<typeof RoutingMetadataSchema>;
46
+
47
+ /**
48
+ * Escalation attempt result type.
49
+ *
50
+ * Tracks the result of each execution attempt.
51
+ */
52
+ export const EscalationResultSchema = z.enum(["success", "failure", "rate_limited"]);
53
+
54
+ export type EscalationResult = z.infer<typeof EscalationResultSchema>;
55
+
56
+ /**
57
+ * Escalation attempt schema.
58
+ *
59
+ * Records a single execution attempt with its outcome.
60
+ *
61
+ * @example
62
+ * {
63
+ * attempt: 1,
64
+ * harness: "claude",
65
+ * model: "haiku-4.5",
66
+ * result: "failure",
67
+ * error: "Task too complex",
68
+ * cost: 0.02,
69
+ * duration: 45000
70
+ * }
71
+ */
72
+ export const EscalationAttemptSchema = z.object({
73
+ /** Attempt number (1-indexed) */
74
+ attempt: z.number().int().min(1),
75
+ /** Harness used for this attempt */
76
+ harness: HarnessNameSchema,
77
+ /** Model used for this attempt */
78
+ model: z.string(),
79
+ /** Result of the attempt */
80
+ result: EscalationResultSchema,
81
+ /** Error message if the attempt failed (optional) */
82
+ error: z.string().optional(),
83
+ /** Actual cost for this attempt in USD */
84
+ cost: z.number().nonnegative(),
85
+ /** Duration of the attempt in milliseconds */
86
+ duration: z.number().nonnegative(),
87
+ });
88
+
89
+ export type EscalationAttempt = z.infer<typeof EscalationAttemptSchema>;
90
+
91
+ /**
92
+ * Execution history schema for user stories.
93
+ *
94
+ * Contains execution details after the story has been executed,
95
+ * including escalation attempts and actual costs.
96
+ *
97
+ * @example
98
+ * {
99
+ * attempts: 2,
100
+ * escalations: [...],
101
+ * actualCost: 0.17,
102
+ * actualHarness: "claude",
103
+ * actualModel: "sonnet-4.5",
104
+ * inputTokens: 5000,
105
+ * outputTokens: 3000
106
+ * }
107
+ */
108
+ export const ExecutionHistorySchema = z.object({
109
+ /** Total number of attempts made */
110
+ attempts: z.number().int().min(1),
111
+ /** Array of escalation attempts with details */
112
+ escalations: z.array(EscalationAttemptSchema),
113
+ /** Total actual cost across all attempts in USD */
114
+ actualCost: z.number().nonnegative(),
115
+ /** Final harness that successfully completed the story */
116
+ actualHarness: HarnessNameSchema,
117
+ /** Final model that successfully completed the story */
118
+ actualModel: z.string(),
119
+ /** Total input tokens used across all attempts (optional) */
120
+ inputTokens: z.number().nonnegative().optional(),
121
+ /** Total output tokens used across all attempts (optional) */
122
+ outputTokens: z.number().nonnegative().optional(),
123
+ });
124
+
125
+ export type ExecutionHistory = z.infer<typeof ExecutionHistorySchema>;
126
+
127
+ // ============================================================================
128
+ // User Story Schema
129
+ // ============================================================================
8
130
 
9
131
  /**
10
132
  * User story schema
@@ -22,6 +144,10 @@ export const UserStorySchema = z.object({
22
144
  phase: z.string().optional(), // Phase marker (e.g., "Setup", "Foundation", "Stories", "Polish")
23
145
  research: z.boolean().optional(), // Requires research phase before implementation
24
146
  skipped: z.boolean().optional(), // Whether the story was skipped by user command
147
+ // Routing metadata - populated during task planning phase (US-026)
148
+ routing: RoutingMetadataSchema.optional(),
149
+ // Execution history - populated after story execution (US-026)
150
+ execution: ExecutionHistorySchema.optional(),
25
151
  });
26
152
 
27
153
  export type UserStory = z.infer<typeof UserStorySchema>;
@@ -33,6 +159,16 @@ export const PRDSchema = z.object({
33
159
  project: z.string(),
34
160
  branchName: z.string(),
35
161
  description: z.string(),
162
+ routingPreference: z
163
+ .object({
164
+ type: z.enum(["auto", "harness"]).optional(),
165
+ mode: ModeSchema.optional(),
166
+ allowFree: z.boolean().optional(),
167
+ harness: HarnessNameSchema.optional(),
168
+ model: z.string().optional(),
169
+ raw: z.string().optional(),
170
+ })
171
+ .optional(),
36
172
  userStories: z.array(UserStorySchema),
37
173
  });
38
174