iriai-build 0.3.1 → 0.4.0

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/bin/cli.js CHANGED
@@ -38,23 +38,26 @@ program
38
38
  program
39
39
  .command("plan [description]")
40
40
  .description("Run planning pipeline. After plan approved, prompts to continue to implementation.")
41
- .action(async (description) => {
42
- await withReadyCheck(() => planCommand(description));
41
+ .option("--budget <tier>", "Usage budget tier: unrestricted, econ, or budget")
42
+ .action(async (description, opts) => {
43
+ await withReadyCheck(() => planCommand(description, opts));
43
44
  });
44
45
 
45
46
  program
46
47
  .command("implementation")
47
48
  .alias("impl")
48
49
  .description("Select from planned/in-progress features and run implementation.")
49
- .action(async () => {
50
- await withReadyCheck(() => implementationCommand());
50
+ .option("--budget <tier>", "Usage budget tier: unrestricted, econ, or budget")
51
+ .action(async (opts) => {
52
+ await withReadyCheck(() => implementationCommand(opts));
51
53
  });
52
54
 
53
55
  program
54
56
  .command("launch [description]")
55
57
  .description("Full flow: planning -> implementation -> complete. Without arg: select or create.")
56
- .action(async (description) => {
57
- await withReadyCheck(() => launchCommand(description));
58
+ .option("--budget <tier>", "Usage budget tier: unrestricted, econ, or budget")
59
+ .action(async (description, opts) => {
60
+ await withReadyCheck(() => launchCommand(description, opts));
58
61
  });
59
62
 
60
63
  program
package/cli/bootstrap.js CHANGED
@@ -11,17 +11,21 @@ import * as queries from "../v3/queries.js";
11
11
  import { DB_PATH, PORTAL_PORT } from "../v3/constants.js";
12
12
  import { ArtifactPortal } from "../v3/artifact-portal.js";
13
13
  import { slugify } from "../v3/helpers.js";
14
+ import { resolveBudget } from "../v3/budget.js";
14
15
 
15
16
  /**
16
17
  * Bootstrap the engine for CLI use.
17
- * Returns { orchestrator, adapter, recovery, input, shutdown }.
18
+ * @param {object} [opts]
19
+ * @param {string} [opts.budgetOverride] - CLI flag override for budget tier
20
+ * Returns { orchestrator, adapter, recovery, input, shutdown, budget }.
18
21
  */
19
- export function bootstrap() {
22
+ export function bootstrap({ budgetOverride } = {}) {
20
23
  db.open(DB_PATH);
21
24
 
25
+ const budget = resolveBudget(budgetOverride);
22
26
  const adapter = new TerminalAdapter();
23
27
  const reviewSessions = new ReviewSessionManager();
24
- const orchestrator = new Orchestrator({ adapter, reviewSessions });
28
+ const orchestrator = new Orchestrator({ adapter, reviewSessions, budget });
25
29
  const input = new TerminalInput({ orchestrator });
26
30
  adapter.setInputHandler(input);
27
31
  const recovery = new Recovery({ orchestrator, adapter });
@@ -61,7 +65,7 @@ export function bootstrap() {
61
65
  });
62
66
  }
63
67
 
64
- return { orchestrator, adapter, recovery, input, shutdown };
68
+ return { orchestrator, adapter, recovery, input, shutdown, budget };
65
69
  }
66
70
 
67
71
  /**
@@ -7,9 +7,10 @@ import { bootstrap } from "../bootstrap.js";
7
7
  import { banner, formatFeatureChoice, successMsg, systemMsg, errorMsg } from "../display.js";
8
8
  import { waitForCompletion } from "../wait.js";
9
9
 
10
- export async function implementationCommand() {
10
+ export async function implementationCommand(opts = {}) {
11
11
  banner();
12
- const { orchestrator, adapter, recovery, input, shutdown } = bootstrap();
12
+ const { orchestrator, adapter, recovery, input, shutdown, budget } = bootstrap({ budgetOverride: opts.budget });
13
+ systemMsg(`Budget: ${budget.label} — ${budget.description}`);
13
14
 
14
15
  const features = queries.getFeaturesForImplCommand();
15
16
  if (features.length === 0) {
@@ -8,9 +8,10 @@ import { bootstrap, initNewFeature } from "../bootstrap.js";
8
8
  import { banner, formatFeatureChoice, successMsg, systemMsg, errorMsg } from "../display.js";
9
9
  import { waitForPlanDecision, waitForCompletion } from "../wait.js";
10
10
 
11
- export async function launchCommand(description) {
11
+ export async function launchCommand(description, opts = {}) {
12
12
  banner();
13
- const { orchestrator, adapter, recovery, input, shutdown } = bootstrap();
13
+ const { orchestrator, adapter, recovery, input, shutdown, budget } = bootstrap({ budgetOverride: opts.budget });
14
+ systemMsg(`Budget: ${budget.label} — ${budget.description}`);
14
15
 
15
16
  let featureId;
16
17
  let isNew = false;
@@ -7,9 +7,10 @@ import { bootstrap, initNewFeature } from "../bootstrap.js";
7
7
  import { banner, formatFeatureChoice, successMsg, systemMsg, errorMsg } from "../display.js";
8
8
  import { waitForPlanDecision, waitForCompletion } from "../wait.js";
9
9
 
10
- export async function planCommand(description) {
10
+ export async function planCommand(description, opts = {}) {
11
11
  banner();
12
- const { orchestrator, adapter, recovery, input, shutdown } = bootstrap();
12
+ const { orchestrator, adapter, recovery, input, shutdown, budget } = bootstrap({ budgetOverride: opts.budget });
13
+ systemMsg(`Budget: ${budget.label} — ${budget.description}`);
13
14
 
14
15
  // Defer impl launch so we can prompt "Continue to implementation?" first
15
16
  orchestrator.deferImplLaunch = true;
@@ -1,9 +1,10 @@
1
1
  // setup.js — `iriai-build setup` command.
2
2
  // Interactive config for Slack tokens and tool paths.
3
3
 
4
- import { input, confirm } from "@inquirer/prompts";
4
+ import { input, confirm, select } from "@inquirer/prompts";
5
5
  import * as config from "../config.js";
6
6
  import { banner, systemMsg, successMsg, errorMsg } from "../display.js";
7
+ import { BUDGET_TIERS } from "../../v3/budget.js";
7
8
 
8
9
  function mask(value) {
9
10
  if (!value) return null;
@@ -47,6 +48,20 @@ export async function setupCommand() {
47
48
  message: `Slack Planning Channel ID (C...):${hint(current.slack_channel_id)}`,
48
49
  });
49
50
 
51
+ // Budget tier
52
+ console.log("\n\x1b[1mUsage Budget\x1b[0m");
53
+ console.log("\x1b[2mControls model selection, team count, and review depth during implementation.\x1b[0m");
54
+ console.log("\x1b[2mPlanning is always full-quality regardless of tier.\x1b[0m\n");
55
+
56
+ const budgetTier = await select({
57
+ message: `Usage budget:${hint(current.budget_tier || "unrestricted")}`,
58
+ choices: Object.entries(BUDGET_TIERS).map(([key, tier]) => ({
59
+ name: `${tier.label} — ${tier.description}`,
60
+ value: key,
61
+ })),
62
+ default: current.budget_tier || "unrestricted",
63
+ });
64
+
50
65
  // Tool paths
51
66
  console.log("\n\x1b[1mTool Paths\x1b[0m");
52
67
  console.log("\x1b[2mOptional — Enter to keep defaults.\x1b[0m\n");
@@ -67,6 +82,7 @@ export async function setupCommand() {
67
82
  if (channelId) updates.slack_channel_id = channelId;
68
83
  if (claudeBin) updates.claude_bin = claudeBin;
69
84
  if (qaFeedbackBin) updates.qa_feedback_bin = qaFeedbackBin;
85
+ updates.budget_tier = budgetTier;
70
86
 
71
87
  config.save(updates);
72
88
 
package/cli/config.js CHANGED
@@ -92,6 +92,14 @@ export function getProjectRoot() {
92
92
  return get("project_root", "PROJECT_ROOT") || process.cwd();
93
93
  }
94
94
 
95
+ /**
96
+ * Get the budget tier. Env var IRIAI_BUDGET overrides config file.
97
+ * Defaults to null (will be prompted on first run).
98
+ */
99
+ export function getBudgetTier() {
100
+ return get("budget_tier", "IRIAI_BUDGET") || null;
101
+ }
102
+
95
103
  /**
96
104
  * Get the config file path (for display purposes).
97
105
  */
package/cli/first-run.js CHANGED
@@ -5,9 +5,10 @@
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { execSync } from "node:child_process";
8
- import { input } from "@inquirer/prompts";
8
+ import { input, select } from "@inquirer/prompts";
9
9
  import * as config from "./config.js";
10
10
  import { banner, systemMsg, successMsg, errorMsg, separator } from "./display.js";
11
+ import { BUDGET_TIERS } from "../v3/budget.js";
11
12
 
12
13
  const C = {
13
14
  reset: "\x1b[0m",
@@ -65,10 +66,27 @@ export async function onboard() {
65
66
  default: detectedClaude || "claude",
66
67
  });
67
68
 
69
+ // 3. Budget tier
70
+ console.log("");
71
+ console.log(`${C.bold}Usage Budget${C.reset}`);
72
+ console.log(`${C.dim}Controls model selection, team count, and review depth during implementation.${C.reset}`);
73
+ console.log(`${C.dim}Planning is always full-quality regardless of tier. Change anytime via ${C.bold}iriai-build setup${C.reset}${C.dim}.${C.reset}`);
74
+ console.log("");
75
+
76
+ const budgetTier = await select({
77
+ message: "Select your usage budget:",
78
+ choices: Object.entries(BUDGET_TIERS).map(([key, tier]) => ({
79
+ name: `${tier.label} — ${tier.description}`,
80
+ value: key,
81
+ })),
82
+ default: "unrestricted",
83
+ });
84
+
68
85
  // Save minimal config
69
86
  config.save({
70
87
  project_root: resolvedRoot,
71
88
  claude_bin: claudeBin || detectedClaude || "claude",
89
+ budget_tier: budgetTier,
72
90
  });
73
91
 
74
92
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iriai-build",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Iriai Build tool — AI agent orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,11 +13,13 @@ import {
13
13
  ROLE_HARD_TIMEOUT_MS, ORCH_SOFT_TIMEOUT_MS, FL_SOFT_TIMEOUT_MS,
14
14
  RSS_CEILING_KB, STUCK_THRESHOLD_MS, HEALTH_CHECK_INTERVAL_MS,
15
15
  } from "./constants.js";
16
+ import { resolveBudget } from "./budget.js";
16
17
  import { readSignal, ensureDir } from "./helpers.js";
17
18
 
18
19
  export class AgentSupervisor extends EventEmitter {
19
- constructor() {
20
+ constructor(budget) {
20
21
  super();
22
+ this.budget = budget || resolveBudget();
21
23
  // agentKey -> { process: AgentProcess, retryTimer, healthRegistered }
22
24
  this._processes = {};
23
25
  this._healthTimer = null;
@@ -254,7 +256,8 @@ export class AgentSupervisor extends EventEmitter {
254
256
  const agent = queries.getAgentByKey(key);
255
257
  if (!agent) continue;
256
258
 
257
- // Hard timeout based on agent type
259
+ // Hard timeout based on agent type (budget-aware for role and FL)
260
+ const budgetTimeouts = this.budget.timeouts;
258
261
  let hardTimeout;
259
262
  switch (agent.agent_type) {
260
263
  case "planning-role":
@@ -262,16 +265,16 @@ export class AgentSupervisor extends EventEmitter {
262
265
  break;
263
266
  case "role-agent":
264
267
  case "review-agent":
265
- hardTimeout = ROLE_HARD_TIMEOUT_MS;
268
+ hardTimeout = budgetTimeouts.roleHardMs;
266
269
  break;
267
270
  case "team-orchestrator":
268
271
  hardTimeout = ORCH_SOFT_TIMEOUT_MS;
269
272
  break;
270
273
  case "feature-lead":
271
- hardTimeout = FL_SOFT_TIMEOUT_MS;
274
+ hardTimeout = budgetTimeouts.flSoftMs;
272
275
  break;
273
276
  default:
274
- hardTimeout = ROLE_HARD_TIMEOUT_MS;
277
+ hardTimeout = budgetTimeouts.roleHardMs;
275
278
  }
276
279
 
277
280
  if (hardTimeout && elapsed > hardTimeout) {
package/v3/budget.js ADDED
@@ -0,0 +1,110 @@
1
+ // budget.js — Budget tier presets for iriai-build.
2
+ // Controls model selection, team limits, role sets, review agents, timeouts, and retries.
3
+ // Planning phase is identical across all tiers.
4
+
5
+ import * as config from "../cli/config.js";
6
+
7
+ export const BUDGET_TIERS = {
8
+ unrestricted: {
9
+ label: "Unrestricted",
10
+ description: "Full power — Opus everywhere, up to 5 teams, all review agents",
11
+ models: {
12
+ "feature-lead": "opus",
13
+ "team-orchestrator": "opus",
14
+ "role-agent": "opus",
15
+ "review-agent": "opus",
16
+ operator: "haiku",
17
+ },
18
+ maxTeams: 5,
19
+ reviewRoles: ["integration-tester", "code-reviewer", "security-auditor", "regression-tester", "verifier"],
20
+ timeouts: {
21
+ roleHardMs: 75 * 60 * 1000,
22
+ flSoftMs: 45 * 60 * 1000,
23
+ },
24
+ retries: {
25
+ role: 3,
26
+ orchestrator: 3,
27
+ featureLead: 5,
28
+ featureLeadInit: 3,
29
+ planning: 5,
30
+ operator: 3,
31
+ },
32
+ },
33
+
34
+ econ: {
35
+ label: "Econ",
36
+ description: "Balanced — Sonnet for most agents, 1 team, code reviewer + verifier",
37
+ models: {
38
+ "feature-lead": "opus",
39
+ "team-orchestrator": "sonnet",
40
+ "role-agent": "sonnet",
41
+ "review-agent": "sonnet",
42
+ operator: "haiku",
43
+ },
44
+ maxTeams: 1,
45
+ reviewRoles: ["code-reviewer", "verifier"],
46
+ timeouts: {
47
+ roleHardMs: 30 * 60 * 1000,
48
+ flSoftMs: 20 * 60 * 1000,
49
+ },
50
+ retries: {
51
+ role: 2,
52
+ orchestrator: 2,
53
+ featureLead: 3,
54
+ featureLeadInit: 2,
55
+ planning: 5,
56
+ operator: 3,
57
+ },
58
+ },
59
+
60
+ budget: {
61
+ label: "Budget",
62
+ description: "Minimal spend — Haiku/Sonnet, 1 team, verifier only",
63
+ models: {
64
+ "feature-lead": "sonnet",
65
+ "team-orchestrator": "sonnet",
66
+ "role-agent": "haiku",
67
+ "review-agent": "haiku",
68
+ operator: "haiku",
69
+ },
70
+ maxTeams: 1,
71
+ reviewRoles: ["verifier"],
72
+ timeouts: {
73
+ roleHardMs: 15 * 60 * 1000,
74
+ flSoftMs: 10 * 60 * 1000,
75
+ },
76
+ retries: {
77
+ role: 1,
78
+ orchestrator: 1,
79
+ featureLead: 2,
80
+ featureLeadInit: 1,
81
+ planning: 5,
82
+ operator: 3,
83
+ },
84
+ },
85
+ };
86
+
87
+ /**
88
+ * Resolve the active budget tier. Priority: explicit override > config > default (unrestricted).
89
+ * @param {string} [override] - CLI flag override (e.g., --budget econ)
90
+ * @returns {object} The budget preset object
91
+ */
92
+ export function resolveBudget(override) {
93
+ const tierName = override || config.get("budget_tier") || "unrestricted";
94
+ const tier = BUDGET_TIERS[tierName];
95
+ if (!tier) {
96
+ console.warn(`[budget] Unknown tier "${tierName}", falling back to unrestricted`);
97
+ return { ...BUDGET_TIERS.unrestricted, name: "unrestricted" };
98
+ }
99
+ return { ...tier, name: tierName };
100
+ }
101
+
102
+ /**
103
+ * Get the model for a given agent type from the budget.
104
+ * @param {object} budget - Resolved budget preset
105
+ * @param {string} agentType - One of: feature-lead, team-orchestrator, role-agent, review-agent, operator
106
+ * @returns {string} Model name (opus, sonnet, haiku)
107
+ */
108
+ export function getModel(budget, agentType) {
109
+ return budget.models[agentType] || "opus";
110
+ }
package/v3/constants.js CHANGED
@@ -163,10 +163,12 @@ export const AVAILABLE_TEMPLATES = [];
163
163
  export const PORTAL_PORT = process.env.PORTAL_PORT ? parseInt(process.env.PORTAL_PORT) : 8900;
164
164
 
165
165
  // ─── Feature Review Roles ────────────────────────────────────────────────────
166
+ // Default (unrestricted) review roles. Budget-aware code should use budget.reviewRoles instead.
166
167
 
167
168
  export const FEATURE_REVIEW_ROLES = [
168
169
  "integration-tester",
169
170
  "code-reviewer",
170
171
  "security-auditor",
171
- "health-monitor",
172
+ "regression-tester",
173
+ "verifier",
172
174
  ];
package/v3/launch-impl.js CHANGED
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { execSync } from "node:child_process";
7
7
  import { IMPL_BASE, PROJECT_ROOT, V3_ROLES_DIR } from "./constants.js";
8
+ import { resolveBudget } from "./budget.js";
8
9
 
9
10
  const LEADERSHIP_ROLES = new Set([
10
11
  "orchestrator", "feature-lead", "planning-lead", "pm", "designer", "architect", "operator",
@@ -76,25 +77,43 @@ function discoverDispatchRoles(rolesDir) {
76
77
  *
77
78
  * @param {string} featureSlug - Kebab-case feature name
78
79
  * @param {object} opts
79
- * @param {number} [opts.numTeams=2] - Number of teams
80
+ * @param {number} [opts.numTeams] - Number of teams (capped by budget.maxTeams)
80
81
  * @param {string[]} [opts.roles] - Explicit roles (auto-discovered if not set)
81
82
  * @param {string} [opts.rolesDir] - Roles directory (defaults to V3_ROLES_DIR)
82
83
  * @param {string} [opts.implBase] - Implementation signal base directory
83
84
  * @param {string} [opts.planDir] - Plan directory to check for plan.yaml
85
+ * @param {object} [opts.budget] - Resolved budget preset (from resolveBudget())
84
86
  */
85
87
  export function launchImpl(featureSlug, {
86
- numTeams = 2,
88
+ numTeams,
87
89
  roles,
88
90
  rolesDir = V3_ROLES_DIR,
89
91
  implBase = IMPL_BASE,
90
92
  planDir,
93
+ budget: budgetOverride,
91
94
  } = {}) {
95
+ const budget = budgetOverride || resolveBudget();
92
96
  const dispatchRoles = roles || discoverDispatchRoles(rolesDir);
93
97
  const featureDir = path.join(implBase, "features", featureSlug);
94
98
 
99
+ // Determine team count: explicit param > plan.yaml num_teams > budget max.
100
+ // Plan compiler should set num_teams based on actual plan structure (not to fill a max).
101
+ // Budget maxTeams is always the hard cap.
102
+ let planTeams = numTeams;
103
+ if (!planTeams && planDir) {
104
+ try {
105
+ const planYaml = fs.readFileSync(path.join(planDir, "plan.yaml"), "utf-8");
106
+ const match = planYaml.match(/^num_teams:\s*(\d+)/m);
107
+ if (match) planTeams = parseInt(match[1], 10);
108
+ } catch { /* no plan.yaml or no num_teams field */ }
109
+ }
110
+ const effectiveTeams = Math.min(planTeams || budget.maxTeams, budget.maxTeams);
111
+
95
112
  console.log(`\nLaunching feature: ${featureSlug}`);
96
- console.log(` Teams: ${numTeams}`);
113
+ console.log(` Budget: ${budget.label}`);
114
+ console.log(` Teams: ${effectiveTeams} (max: ${budget.maxTeams})`);
97
115
  console.log(` Roles per team: ${dispatchRoles.length}`);
116
+ console.log(` Review roles: ${budget.reviewRoles.join(", ")}`);
98
117
  console.log(` Signal base: ${featureDir}`);
99
118
 
100
119
  // Warn if no plan.yaml
@@ -117,15 +136,15 @@ export function launchImpl(featureSlug, {
117
136
  symlinkRole(rolesDir, "operator", opDir);
118
137
  }
119
138
 
120
- // Feature-level review roles
121
- for (const reviewRole of ["code-reviewer", "integration-tester", "security-auditor"]) {
139
+ // Feature-level review roles (budget-aware)
140
+ for (const reviewRole of budget.reviewRoles) {
122
141
  const reviewDir = path.join(featureDir, "feature-review", reviewRole);
123
142
  fs.mkdirSync(reviewDir, { recursive: true });
124
143
  symlinkRole(rolesDir, reviewRole, reviewDir);
125
144
  }
126
145
 
127
146
  // Per-team directories
128
- for (let i = 1; i <= numTeams; i++) {
147
+ for (let i = 1; i <= effectiveTeams; i++) {
129
148
  const teamDir = path.join(featureDir, "teams", `team-${i}`);
130
149
  const orchDir = path.join(teamDir, "orchestrator");
131
150
 
@@ -190,11 +209,13 @@ export function launchImpl(featureSlug, {
190
209
  }
191
210
  fs.writeFileSync(path.join(flMcpDir, "settings.local.json"), JSON.stringify({ mcpServers: flServers }, null, 2) + "\n");
192
211
 
193
- // Feature-level review roles
194
- configureMcp("integration-tester", path.join(featureDir, "feature-review", "integration-tester"));
212
+ // Feature-level review roles (only configure MCP for roles in budget)
213
+ if (budget.reviewRoles.includes("integration-tester")) {
214
+ configureMcp("integration-tester", path.join(featureDir, "feature-review", "integration-tester"));
215
+ }
195
216
 
196
217
  // Per-team roles
197
- for (let i = 1; i <= numTeams; i++) {
218
+ for (let i = 1; i <= effectiveTeams; i++) {
198
219
  const teamDir = path.join(featureDir, "teams", `team-${i}`);
199
220
  configureMcp("orchestrator", path.join(teamDir, "orchestrator"));
200
221
  for (const role of dispatchRoles) {
@@ -18,6 +18,7 @@ import {
18
18
  } from "./prompt-builder.js";
19
19
  import { planningComplete } from "./planning-complete.js";
20
20
  import { launchImpl } from "./launch-impl.js";
21
+ import { resolveBudget, getModel } from "./budget.js";
21
22
  import {
22
23
  IMPL_BASE, IRIAI_TEAM_DIR, PROJECT_ROOT,
23
24
  V3_ROLES_DIR, PLANNING_ROLES, PLANNING_ROLE_LABELS,
@@ -38,10 +39,11 @@ import { formatAnnotationsAsFeedback } from "./review-sessions.js";
38
39
  import { compilePlanReviewHtml } from "./plan-compiler.js";
39
40
 
40
41
  export class Orchestrator {
41
- constructor({ adapter, reviewSessions = null }) {
42
+ constructor({ adapter, reviewSessions = null, budget = null }) {
42
43
  this.adapter = adapter;
43
44
  this.reviewSessions = reviewSessions;
44
- this.supervisor = new AgentSupervisor();
45
+ this.budget = budget || resolveBudget();
46
+ this.supervisor = new AgentSupervisor(this.budget);
45
47
  this.fileIO = new FileIO();
46
48
 
47
49
  // Per-feature signal trees (discovered on launch)
@@ -348,6 +350,11 @@ export class Orchestrator {
348
350
  `THREAD_TS=${feature.thread_ts}`,
349
351
  ];
350
352
 
353
+ // Inject available review roles for architect and plan-compiler
354
+ if (role === "architect" || role === "plan-compiler") {
355
+ taskHeaderLines.push(`AVAILABLE_REVIEW_ROLES=${this.budget.reviewRoles.join(",")}`);
356
+ }
357
+
351
358
  // Role-specific dispatch instructions (interview enforcement)
352
359
  if ((role === "architect" || role === "pm" || role === "designer" || role === "ux-designer") && !revisionContext) {
353
360
  taskHeaderLines.push("");
@@ -1096,6 +1103,7 @@ export class Orchestrator {
1096
1103
  rolesDir: V3_ROLES_DIR,
1097
1104
  implBase: IMPL_BASE,
1098
1105
  planDir,
1106
+ budget: this.budget,
1099
1107
  });
1100
1108
 
1101
1109
  // Launch implementation agents
@@ -1105,7 +1113,7 @@ export class Orchestrator {
1105
1113
  queries.updateFeaturePhase(featureId, "impl");
1106
1114
  });
1107
1115
 
1108
- queries.insertEvent(featureId, "phase-transition", "bridge", "Implementation launched");
1116
+ queries.insertEvent(featureId, "phase-transition", "bridge", `Implementation launched (budget: ${this.budget.label})`);
1109
1117
  } catch (err) {
1110
1118
  console.error("[orchestrator] Implementation launch error:", err.message);
1111
1119
  const stderr = err.stderr ? err.stderr.toString().slice(-500) : err.message;
@@ -1177,6 +1185,7 @@ export class Orchestrator {
1177
1185
  rolesDir: V3_ROLES_DIR,
1178
1186
  implBase: IMPL_BASE,
1179
1187
  planDir,
1188
+ budget: this.budget,
1180
1189
  });
1181
1190
  } catch (launchErr) {
1182
1191
  console.error("[orchestrator] launchImpl failed:", launchErr.message);
@@ -1349,6 +1358,7 @@ export class Orchestrator {
1349
1358
  const feature = queries.getFeatureById(featureId);
1350
1359
  if (!feature) return;
1351
1360
 
1361
+ const budget = this.budget;
1352
1362
  const tree = this.discoverSignalTree(feature.slug);
1353
1363
  const numTeams = Object.keys(tree.teams).length || feature.num_teams;
1354
1364
  const worktreeDir = path.join(PROJECT_ROOT, ".features", feature.slug);
@@ -1368,10 +1378,11 @@ export class Orchestrator {
1368
1378
  roleName: "feature-lead",
1369
1379
  signalDir: tree.featureLead,
1370
1380
  cwd: featureCwd,
1371
- maxRetries: MAX_FL_RETRIES,
1381
+ model: getModel(budget, "feature-lead"),
1382
+ maxRetries: budget.retries.featureLead,
1372
1383
  });
1373
1384
  } else {
1374
- this._syncAgentRetries(flAgent, MAX_FL_RETRIES);
1385
+ this._syncAgentRetries(flAgent, budget.retries.featureLead);
1375
1386
  }
1376
1387
 
1377
1388
  // Use "refresh" mode if FEATURE-STATUS.md exists (recovery/restart scenario)
@@ -1395,10 +1406,11 @@ export class Orchestrator {
1395
1406
  roleName: "operator",
1396
1407
  signalDir: tree.operator,
1397
1408
  cwd: featureCwd,
1398
- maxRetries: MAX_OPERATOR_RETRIES,
1409
+ model: getModel(budget, "operator"),
1410
+ maxRetries: budget.retries.operator,
1399
1411
  });
1400
1412
  } else {
1401
- this._syncAgentRetries(opAgent, MAX_OPERATOR_RETRIES);
1413
+ this._syncAgentRetries(opAgent, budget.retries.operator);
1402
1414
  }
1403
1415
  // Operator is reactive — spawned by routeUserMessage
1404
1416
  queries.updateAgentStatus(opAgent.id, "idle");
@@ -1421,10 +1433,11 @@ export class Orchestrator {
1421
1433
  teamNum,
1422
1434
  signalDir: team.orchestrator,
1423
1435
  cwd: teamCwd,
1424
- maxRetries: MAX_ORCH_RETRIES,
1436
+ model: getModel(budget, "team-orchestrator"),
1437
+ maxRetries: budget.retries.orchestrator,
1425
1438
  });
1426
1439
  } else {
1427
- this._syncAgentRetries(orchAgent, MAX_ORCH_RETRIES);
1440
+ this._syncAgentRetries(orchAgent, budget.retries.orchestrator);
1428
1441
  }
1429
1442
  // Orchestrator waits for .task — no auto-start
1430
1443
  }
@@ -1442,10 +1455,11 @@ export class Orchestrator {
1442
1455
  teamNum,
1443
1456
  signalDir: roleDir,
1444
1457
  cwd: teamCwd,
1445
- maxRetries: MAX_ROLE_RETRIES,
1458
+ model: getModel(budget, "role-agent"),
1459
+ maxRetries: budget.retries.role,
1446
1460
  });
1447
1461
  } else {
1448
- this._syncAgentRetries(roleAgent, MAX_ROLE_RETRIES);
1462
+ this._syncAgentRetries(roleAgent, budget.retries.role);
1449
1463
  }
1450
1464
  // Role waits for .task — no auto-start
1451
1465
  }
@@ -1463,10 +1477,11 @@ export class Orchestrator {
1463
1477
  roleName: role,
1464
1478
  signalDir: reviewDir,
1465
1479
  cwd: featureCwd,
1466
- maxRetries: MAX_ROLE_RETRIES,
1480
+ model: getModel(budget, "review-agent"),
1481
+ maxRetries: budget.retries.role,
1467
1482
  });
1468
1483
  } else {
1469
- this._syncAgentRetries(reviewAgent, MAX_ROLE_RETRIES);
1484
+ this._syncAgentRetries(reviewAgent, budget.retries.role);
1470
1485
  }
1471
1486
  }
1472
1487
 
@@ -667,37 +667,42 @@ adversarial cross-check happens LAST (after all review agents have completed):
667
667
  1. **Read team evidence** — Read \`.gate-evidence.yaml\` from each team's orchestrator signal dir.
668
668
  If any team lacks \`.gate-evidence.yaml\`, REJECT the gate immediately.
669
669
  2. **Push team branches** and create PRs (team branch → integration branch) per repo using gh CLI
670
- 3. **Dispatch feature-level review agents** by writing .task files:
670
+ 3. **Determine which review agents to dispatch** for this gate:
671
+ a. Read the current phase's \`phase.yaml\` and look for the \`review_roles\` field.
672
+ b. If \`review_roles\` is present, dispatch ONLY the listed roles.
673
+ c. If \`review_roles\` is absent, discover available review roles by listing the directories under \`${featureReviewDir}/\`:
674
+ \`\`\`bash
675
+ ls ${featureReviewDir}/
676
+ \`\`\`
677
+ Dispatch all discovered review roles.
678
+ d. Only dispatch to roles that have a directory — skip any that don't exist.
679
+ 4. **Dispatch review agents** by writing .task files to each role:
671
680
  \`\`\`bash
672
681
  PREVIEW_ENV=""
673
682
  if [ -f "${featureLeadDir}/preview-env.json" ]; then
674
683
  PREVIEW_ENV="Preview environment: ${featureLeadDir}/preview-env.json"
675
684
  fi
676
- cat > ${featureReviewDir}/integration-tester/.task << TASK_EOF
677
- <your review task here>
685
+ # For each review role from step 3:
686
+ cat > ${featureReviewDir}/<role-name>/.task << TASK_EOF
687
+ <your review task here — include gate context, PRs, journeys to verify>
678
688
  \$PREVIEW_ENV
679
689
  TASK_EOF
680
- cat > ${featureReviewDir}/code-reviewer/.task << 'TASK_EOF'
681
- <your review task here>
682
- TASK_EOF
683
- cat > ${featureReviewDir}/security-auditor/.task << 'TASK_EOF'
684
- <your review task here>
685
- TASK_EOF
686
690
  \`\`\`
687
- 4. **Wait for all review agents** to complete (.done files) — poll every 75s:
691
+ 5. **Wait for all dispatched review agents** to complete (.done files) — poll every 75s:
688
692
  \`\`\`bash
689
- while [ ! -f ${featureReviewDir}/integration-tester/.done ] || \\
690
- [ ! -f ${featureReviewDir}/code-reviewer/.done ] || \\
691
- [ ! -f ${featureReviewDir}/security-auditor/.done ]; do
692
- sleep 75
693
+ # Check .done for each role you dispatched in step 4
694
+ ALL_DONE=true
695
+ for role in <list of dispatched roles>; do
696
+ [ ! -f "${featureReviewDir}/$role/.done" ] && ALL_DONE=false
693
697
  done
698
+ # Repeat until ALL_DONE is true
694
699
  \`\`\`
695
- 5. **Adversarial cross-check** (FINAL step before user sees it):
700
+ 6. **Adversarial cross-check** (FINAL step before user sees it):
696
701
  - Cross-check evidence across ALL teams for inconsistencies
697
702
  - Call \`get_screenshots\` for critical journeys and independently verify orchestrator claims
698
703
  - Review feature-level review agent outputs
699
704
  - If discrepancies found → REJECT gate, do NOT escalate to user
700
- 6. **Merge evidence** — Combine all team YAMLs + feature-level review outputs into:
705
+ 7. **Merge evidence** — Combine all team YAMLs + feature-level review outputs into:
701
706
  \`\`\`bash
702
707
  cat > ${featureLeadDir}/.gate-evidence.yaml << 'EVIDENCE_EOF'
703
708
  <merged evidence YAML>
@@ -706,14 +711,14 @@ adversarial cross-check happens LAST (after all review agents have completed):
706
711
  Include: \`coverage_matrix\`, \`deviations\`, \`self_reported_risks\` (aggregated from all teams)
707
712
  Include: \`reviewer_comments\` with your FL assessment
708
713
  Include: \`cross_team_surface\` (APIs, contracts, shared state)
709
- 7. **Compile feature gate HTML** — Call \`compile_gate_evidence\` MCP tool:
714
+ 8. **Compile feature gate HTML** — Call \`compile_gate_evidence\` MCP tool:
710
715
  - \`evidence_yaml_path\`: ${featureLeadDir}/.gate-evidence.yaml
711
716
  - \`output_html_path\`: ${featureLeadDir}/.gate-evidence.html
712
717
  - \`doc_type\`: \`"feature"\`
713
718
  - \`team_html_paths\`: list of \`{ team_num, html_path }\` for each team's gate HTML
714
- - If tool returns ERROR → re-dispatch affected verification role → retry from step 4
719
+ - If tool returns ERROR → re-dispatch affected verification role → retry from step 5
715
720
  - Do NOT proceed until \`compile_gate_evidence\` succeeds
716
- 8. **Post feature gate HTML** to .agent-response — the HTML IS the message:
721
+ 9. **Post feature gate HTML** to .agent-response — the HTML IS the message:
717
722
  - \`[evidence:${featureLeadDir}/.gate-evidence.html]\` marker
718
723
  - \`[DECISION]\` block with approve/reject buttons
719
724
  - No text summary needed — the HTML contains everything
@@ -272,6 +272,7 @@ Conforms to `phase.schema.md`. Contains:
272
272
  - Phase identity (id, title, objective, risk)
273
273
  - Task DAG — ordered list of tasks with dependency references
274
274
  - Role assignments — which role executes which tasks
275
+ - Review roles — which feature-level review agents run at this phase's gate
275
276
  - Phase-level acceptance criteria (user_criteria with action/observe pairs)
276
277
  - Phase-scoped journey references
277
278
  - Regression test references
@@ -311,6 +312,15 @@ role_assignments:
311
312
  verifier: ["1.v"]
312
313
  test-author: ["1.t"]
313
314
 
315
+ # Which feature-level review agents run at this phase's gate.
316
+ # ONLY use roles from $AVAILABLE_REVIEW_ROLES (set in your task header).
317
+ # Choose based on what changed: security-auditor for auth/data changes,
318
+ # integration-tester for API/UI flows, code-reviewer for structural quality, etc.
319
+ # If omitted, all available review roles are dispatched (wasteful — always specify).
320
+ review_roles:
321
+ - code-reviewer
322
+ - integration-tester
323
+
314
324
  acceptance:
315
325
  user_criteria:
316
326
  - action: "POST /api/bot-collaborator/invite with body {repo_owner, repo_name}"
@@ -57,8 +57,8 @@ You post to the feature's channel via the bridge. Rules:
57
57
 
58
58
  1. **Read team evidence** — Read `.gate-evidence.yaml` from each team's orchestrator signal dir
59
59
  2. **Validate evidence exists** — If any team lacks `.gate-evidence.yaml`, REJECT the gate immediately (write feedback to team orchestrator, do not escalate to user)
60
- 3. **Dispatch feature-level review agents** (integration-tester, code-reviewer, security-auditor) these run against the merged codebase and produce their own `.output` files
61
- 4. **Wait for review agents to complete** — read their `.output` files
60
+ 3. **Dispatch feature-level review agents** — read the current phase's `phase.yaml` for a `review_roles` field. If present, dispatch ONLY those roles. If absent, dispatch all review roles that have directories under `feature-review/`. These run against the merged codebase and produce their own `.output` files.
61
+ 4. **Wait for dispatched review agents to complete** — read their `.output` files
62
62
  4b. **Review gaps across all levels.** Read `gaps` from:
63
63
  - Each team orchestrator's `.gate-evidence.yaml` (team-level QA gaps)
64
64
  - Each team's compiled `.gate-evidence.html` (review visually)
@@ -58,6 +58,16 @@ You are a fresh-context validator. The Architect just produced a structured plan
58
58
  - [ ] No orphaned tasks (every task is reachable from the root of the DAG)
59
59
  - [ ] Parallelizable tasks have no false dependencies (tasks that could run in parallel shouldn't depend on each other unless truly necessary)
60
60
 
61
+ ### 2b. Review Roles Validation
62
+
63
+ - [ ] Every `phase.yaml` has a `review_roles` list (warn if missing — FL will fall back to dispatching all available review roles, which is wasteful)
64
+ - [ ] Every role in `review_roles` is from `$AVAILABLE_REVIEW_ROLES` (set in your task header) — reject any role not in that list
65
+ - [ ] Review role selection makes sense for the phase:
66
+ - Phases touching auth, secrets, or data access should include `security-auditor`
67
+ - Phases with API or UI changes should include `integration-tester`
68
+ - `code-reviewer` and `verifier` are reasonable defaults for any phase
69
+ - `regression-tester` should be included when the phase modifies existing behavior
70
+
61
71
  ### 3. Codebase Cross-Check
62
72
 
63
73
  For every task's `scope.modify` and `scope.read` paths:
@@ -204,6 +214,21 @@ budget:
204
214
 
205
215
  If the user delegates, use sensible defaults and document them.
206
216
 
217
+ ### Step 6c: Team Count (`num_teams` in plan.yaml)
218
+
219
+ Add a `num_teams` field to `plan.yaml` that reflects the **actual parallelism needed by the plan** — NOT just filling to a maximum. Consider:
220
+
221
+ - How many independent domain boundaries exist (e.g., backend vs frontend vs infrastructure)
222
+ - Whether phases can actually run in parallel or are sequential
223
+ - A single-service feature with 2 phases probably needs 1 team, not 5
224
+
225
+ The orchestrator will cap `num_teams` to the user's budget tier maximum, so err toward what the plan actually needs. If only 2 independent workstreams exist, set `num_teams: 2` even if the budget allows 5.
226
+
227
+ ```yaml
228
+ # In plan.yaml:
229
+ num_teams: 2 # Based on 2 independent workstreams: backend API + frontend UI
230
+ ```
231
+
207
232
  ### Step 7: Write Final Report
208
233
 
209
234
  Write your findings to `.output` with this structure: