iriai-build 0.3.0 → 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.0",
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("");
@@ -469,67 +476,43 @@ export class Orchestrator {
469
476
  this._notifyOperatorOfDecision(feature, planDecision);
470
477
  }
471
478
  } else if (role === "ux-designer") {
472
- // Compound design step: ux-designer done dispatch ui-designer (no phase-review yet)
473
- await this.adapter.postPipelineMessage(feature.id,
474
- `UX Designer complete. Starting UI Designer...`);
475
- queries.updateFeaturePlanningRole(feature.id, "ui-designer");
476
- this.dispatchPlanningRole(feature.id, "ui-designer");
477
- } else if (role === "ui-designer") {
478
- // Compound design step: ui-designer done → surface as "designer" phase-review
479
- // Both sub-roles are complete; present combined output for user approval
480
- await this._requestPhaseReview(feature, "designer", output);
481
- } else if (role === "architect") {
482
- // Architect validation: ensure structured plan directory before review gate.
483
- // The architect often drifts to writing a monolithic architecture doc
484
- // instead of the structured plan directory (plan.yaml + phases/ + task files).
485
- // Validate required artifacts and redispatch if incomplete.
479
+ // Validate UX designer output before proceeding to UI designer
486
480
  const planDir = path.join(feature.signal_dir, "plans");
487
- const validation = this._validateArchitectOutput(planDir);
488
-
489
- if (validation.pass) {
490
- await this._requestPhaseReview(feature, role, output);
481
+ const validation = this._validatePlanningOutput(role, planDir);
482
+ if (!validation.pass) {
483
+ await this.adapter.postPipelineMessage(feature.id,
484
+ `UX Designer output incomplete (missing: ${validation.missing.join(", ")}). Redispatching...`);
485
+ this._redispatchForMissingArtifacts(feature, role, validation);
491
486
  } else {
492
- // Redispatch architect with focused instructions
493
- console.log(`[orchestrator] Architect output incomplete for ${slug}: missing ${validation.missing.join(", ")}`);
487
+ // Compound design step: ux-designer done → dispatch ui-designer (no phase-review yet)
494
488
  await this.adapter.postPipelineMessage(feature.id,
495
- `Architect investigation complete redispatching for structured plan output (missing: ${validation.missing.join(", ")})`);
496
-
497
- const signalDir = path.join(feature.signal_dir, "planning", "architect");
498
- ensureDir(signalDir);
499
-
500
- // Write focused redispatch instructions as .user-message so the agent picks it up
501
- const redispatchMsg = [
502
- `STRUCTURED PLAN OUTPUT REQUIRED`,
503
- ``,
504
- `Your investigation phase is complete. You wrote a thorough context.md in $PLAN_DIR/.`,
505
- `Now you MUST produce the structured plan directory. Read your CLAUDE.md "Output Format" section carefully.`,
506
- ``,
507
- `WHAT EXISTS (do NOT redo):`,
508
- validation.found.map(f => ` ✓ ${f}`).join("\n"),
509
- ``,
510
- `WHAT IS MISSING (you must create these):`,
511
- validation.missing.map(f => ` ✗ ${f}`).join("\n"),
512
- ``,
513
- `INSTRUCTIONS:`,
514
- `1. Read $PLAN_DIR/context.md for your investigation notes — do NOT re-explore the entire codebase`,
515
- `2. Do targeted source code reads ONLY where you need specific file paths, function signatures, or line numbers for task files`,
516
- `3. Produce plan.yaml with phase DAG, role assignments, and journey refs`,
517
- `4. Create phases/ directory with structured task files (YAML frontmatter + detailed instructions)`,
518
- `5. Create journeys/ directory with test journeys (happy-path, failure, regression)`,
519
- `6. Every task file must cite real file paths and code evidence from the codebase`,
520
- `7. Signal .done + .output when complete`,
521
- ``,
522
- `Your context.md is the foundation. Build the structured plan ON TOP of it.`,
523
- ].join("\n");
524
-
525
- writeSignal(path.join(signalDir, SIGNAL.USER_MESSAGE), redispatchMsg);
526
- queries.updateFeaturePlanningRole(feature.id, "architect");
527
- // Fresh context — investigation session is done, architect needs full budget for structured output
528
- this.dispatchPlanningRole(feature.id, "architect", { continue: false });
489
+ `UX Designer complete. Starting UI Designer...`);
490
+ queries.updateFeaturePlanningRole(feature.id, "ui-designer");
491
+ this.dispatchPlanningRole(feature.id, "ui-designer");
492
+ }
493
+ } else if (role === "ui-designer") {
494
+ // Validate UI designer output before surfacing combined design review
495
+ const planDir = path.join(feature.signal_dir, "plans");
496
+ const validation = this._validatePlanningOutput(role, planDir);
497
+ if (!validation.pass) {
498
+ await this.adapter.postPipelineMessage(feature.id,
499
+ `UI Designer output incomplete (missing: ${validation.missing.join(", ")}). Redispatching...`);
500
+ this._redispatchForMissingArtifacts(feature, role, validation);
501
+ } else {
502
+ // Both sub-roles complete; present combined output for user approval
503
+ await this._requestPhaseReview(feature, "designer", output);
529
504
  }
530
505
  } else {
531
- // Non-final role summary + artifact upload + Block Kit review gate (all sequential)
532
- await this._requestPhaseReview(feature, role, output);
506
+ // All other roles (pm, architect, designer): validate then review gate
507
+ const planDir = path.join(feature.signal_dir, "plans");
508
+ const validation = this._validatePlanningOutput(role, planDir);
509
+ if (!validation.pass) {
510
+ await this.adapter.postPipelineMessage(feature.id,
511
+ `${PLANNING_ROLE_LABELS[role] || role} output incomplete (missing: ${validation.missing.join(", ")}). Redispatching...`);
512
+ this._redispatchForMissingArtifacts(feature, role, validation);
513
+ } else {
514
+ await this._requestPhaseReview(feature, role, output);
515
+ }
533
516
  }
534
517
 
535
518
  } finally {
@@ -539,69 +522,124 @@ export class Orchestrator {
539
522
  }
540
523
 
541
524
  /**
542
- * Validate that the architect produced the required structured plan directory.
543
- * Auto-normalizes architecture.mdcontext.md if needed.
544
- * Returns { pass: boolean, found: string[], missing: string[] }
525
+ * Artifact requirements per planning role.
526
+ * Each entry: { label, check(planDir) boolean }
545
527
  */
546
- _validateArchitectOutput(planDir) {
547
- const found = [];
548
- const missing = [];
528
+ static ROLE_ARTIFACTS = {
529
+ pm: [
530
+ { label: "PRD document (*-prd.md)", check: (d) => !!findArtifact("prd", d) },
531
+ ],
532
+ "ux-designer": [
533
+ { label: "design-decisions.md", check: (d) => !!findArtifact("design-decisions", d) },
534
+ ],
535
+ "ui-designer": [
536
+ { label: "design-decisions.md (with Visual Design Language)", check: (d) => {
537
+ const content = findArtifact("design-decisions", d);
538
+ if (!content) return false;
539
+ try {
540
+ const text = fs.readFileSync(content, "utf-8");
541
+ return text.includes("Visual Design Language") || text.includes("Color Palette");
542
+ } catch { return false; }
543
+ }},
544
+ { label: "mockup.html", check: (d) => fs.existsSync(path.join(d, "mockup.html")) },
545
+ ],
546
+ designer: [
547
+ { label: "design-decisions.md", check: (d) => !!findArtifact("design-decisions", d) },
548
+ { label: "mockup.html", check: (d) => fs.existsSync(path.join(d, "mockup.html")) },
549
+ ],
550
+ architect: [
551
+ { label: "context.md (investigation notes)", check: (d) => !!findArtifact("context", d) || !!findArtifact("architecture", d) },
552
+ { label: "plan.yaml (phase DAG)", check: (d) => fs.existsSync(path.join(d, "plan.yaml")) },
553
+ { label: "phases/ (task files)", check: (d) => {
554
+ try {
555
+ return fs.readdirSync(path.join(d, "phases"), { withFileTypes: true }).some(e => e.isDirectory());
556
+ } catch { return false; }
557
+ }},
558
+ { label: "journeys/ (test journeys)", check: (d) => {
559
+ try {
560
+ return fs.readdirSync(path.join(d, "journeys")).some(e => e.endsWith(".md"));
561
+ } catch { return false; }
562
+ }},
563
+ ],
564
+ };
549
565
 
550
- // Normalize: rename architecture.md → context.md if architect used wrong name
551
- const archFile = path.join(planDir, "architecture.md");
552
- const contextFile = path.join(planDir, "context.md");
553
- if (fs.existsSync(archFile) && !fs.existsSync(contextFile)) {
554
- try {
555
- fs.renameSync(archFile, contextFile);
556
- console.log(`[orchestrator] Renamed architecture.md context.md in ${planDir}`);
557
- } catch { /* ok, proceed with whatever exists */ }
566
+ /**
567
+ * Validate that a planning role produced its required artifacts.
568
+ * Runs auto-normalization (e.g. architecture.md → context.md) before checking.
569
+ * Returns { pass, found, missing }
570
+ */
571
+ _validatePlanningOutput(role, planDir) {
572
+ // Auto-normalize known filename drift
573
+ if (role === "architect") {
574
+ const archFile = path.join(planDir, "architecture.md");
575
+ const contextFile = path.join(planDir, "context.md");
576
+ if (fs.existsSync(archFile) && !fs.existsSync(contextFile)) {
577
+ try {
578
+ fs.renameSync(archFile, contextFile);
579
+ console.log(`[orchestrator] Renamed architecture.md → context.md in ${planDir}`);
580
+ } catch { /* ok */ }
581
+ }
558
582
  }
559
583
 
560
- // Check context.md (investigation notes)
561
- if (findArtifact("context", planDir) || findArtifact("architecture", planDir)) {
562
- found.push("context.md (investigation notes)");
563
- } else {
564
- missing.push("context.md (investigation notes)");
565
- }
584
+ const artifacts = Orchestrator.ROLE_ARTIFACTS[role];
585
+ if (!artifacts) return { pass: true, found: [], missing: [] };
566
586
 
567
- // Check plan.yaml (phase DAG metadata)
568
- if (fs.existsSync(path.join(planDir, "plan.yaml"))) {
569
- found.push("plan.yaml (phase DAG)");
570
- } else {
571
- missing.push("plan.yaml (phase DAG + metadata)");
587
+ const found = [];
588
+ const missing = [];
589
+ for (const art of artifacts) {
590
+ if (art.check(planDir)) {
591
+ found.push(art.label);
592
+ } else {
593
+ missing.push(art.label);
594
+ }
572
595
  }
573
596
 
574
- // Check phases/ directory with at least one phase subdirectory
575
- const phasesDir = path.join(planDir, "phases");
576
- let hasPhases = false;
577
- try {
578
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
579
- hasPhases = entries.some(e => e.isDirectory());
580
- } catch { /* doesn't exist */ }
581
- if (hasPhases) {
582
- found.push("phases/ (task files)");
583
- } else {
584
- missing.push("phases/ directory with task files");
585
- }
597
+ return { pass: missing.length === 0, found, missing };
598
+ }
586
599
 
587
- // Check journeys/ directory
588
- const journeysDir = path.join(planDir, "journeys");
589
- let hasJourneys = false;
590
- try {
591
- const entries = fs.readdirSync(journeysDir);
592
- hasJourneys = entries.some(e => e.endsWith(".md"));
593
- } catch { /* doesn't exist */ }
594
- if (hasJourneys) {
595
- found.push("journeys/ (test journeys)");
596
- } else {
597
- missing.push("journeys/ (test journeys)");
600
+ /**
601
+ * Redispatch a planning role that produced incomplete output.
602
+ * Writes focused instructions to .user-message and re-spawns with fresh context.
603
+ */
604
+ _redispatchForMissingArtifacts(feature, role, validation) {
605
+ const slug = feature.slug;
606
+ console.log(`[orchestrator] ${role} output incomplete for ${slug}: missing ${validation.missing.join(", ")}`);
607
+
608
+ const signalDir = path.join(feature.signal_dir, "planning", role);
609
+ ensureDir(signalDir);
610
+
611
+ const roleName = PLANNING_ROLE_LABELS[role] || role;
612
+ const lines = [
613
+ `INCOMPLETE OUTPUT — REDISPATCH`,
614
+ ``,
615
+ `You signaled .done but required artifacts are missing.`,
616
+ `Read your CLAUDE.md output format section and produce the missing items.`,
617
+ ``,
618
+ `WHAT EXISTS (do NOT redo):`,
619
+ ...validation.found.map(f => ` ✓ ${f}`),
620
+ ``,
621
+ `WHAT IS MISSING (you MUST create these):`,
622
+ ...validation.missing.map(f => ` ✗ ${f}`),
623
+ ``,
624
+ `Write the missing artifacts to $PLAN_DIR/, then signal .done + .output again.`,
625
+ ];
626
+
627
+ // Role-specific guidance
628
+ if (role === "architect") {
629
+ lines.push(
630
+ ``,
631
+ `ARCHITECT-SPECIFIC:`,
632
+ `1. Read $PLAN_DIR/context.md for your investigation notes — do NOT re-explore the entire codebase`,
633
+ `2. Do targeted source code reads ONLY where needed for specific file paths or function signatures`,
634
+ `3. Every task file must cite real file paths and code evidence from the codebase`,
635
+ );
598
636
  }
599
637
 
600
- return {
601
- pass: missing.length === 0,
602
- found,
603
- missing,
604
- };
638
+ writeSignal(path.join(signalDir, SIGNAL.USER_MESSAGE), lines.join("\n"));
639
+ queries.updateFeaturePlanningRole(feature.id, role);
640
+
641
+ // Fresh context for the structured output pass
642
+ this.dispatchPlanningRole(feature.id, role, { continue: false });
605
643
  }
606
644
 
607
645
  /**
@@ -1065,6 +1103,7 @@ export class Orchestrator {
1065
1103
  rolesDir: V3_ROLES_DIR,
1066
1104
  implBase: IMPL_BASE,
1067
1105
  planDir,
1106
+ budget: this.budget,
1068
1107
  });
1069
1108
 
1070
1109
  // Launch implementation agents
@@ -1074,7 +1113,7 @@ export class Orchestrator {
1074
1113
  queries.updateFeaturePhase(featureId, "impl");
1075
1114
  });
1076
1115
 
1077
- queries.insertEvent(featureId, "phase-transition", "bridge", "Implementation launched");
1116
+ queries.insertEvent(featureId, "phase-transition", "bridge", `Implementation launched (budget: ${this.budget.label})`);
1078
1117
  } catch (err) {
1079
1118
  console.error("[orchestrator] Implementation launch error:", err.message);
1080
1119
  const stderr = err.stderr ? err.stderr.toString().slice(-500) : err.message;
@@ -1146,6 +1185,7 @@ export class Orchestrator {
1146
1185
  rolesDir: V3_ROLES_DIR,
1147
1186
  implBase: IMPL_BASE,
1148
1187
  planDir,
1188
+ budget: this.budget,
1149
1189
  });
1150
1190
  } catch (launchErr) {
1151
1191
  console.error("[orchestrator] launchImpl failed:", launchErr.message);
@@ -1318,6 +1358,7 @@ export class Orchestrator {
1318
1358
  const feature = queries.getFeatureById(featureId);
1319
1359
  if (!feature) return;
1320
1360
 
1361
+ const budget = this.budget;
1321
1362
  const tree = this.discoverSignalTree(feature.slug);
1322
1363
  const numTeams = Object.keys(tree.teams).length || feature.num_teams;
1323
1364
  const worktreeDir = path.join(PROJECT_ROOT, ".features", feature.slug);
@@ -1337,10 +1378,11 @@ export class Orchestrator {
1337
1378
  roleName: "feature-lead",
1338
1379
  signalDir: tree.featureLead,
1339
1380
  cwd: featureCwd,
1340
- maxRetries: MAX_FL_RETRIES,
1381
+ model: getModel(budget, "feature-lead"),
1382
+ maxRetries: budget.retries.featureLead,
1341
1383
  });
1342
1384
  } else {
1343
- this._syncAgentRetries(flAgent, MAX_FL_RETRIES);
1385
+ this._syncAgentRetries(flAgent, budget.retries.featureLead);
1344
1386
  }
1345
1387
 
1346
1388
  // Use "refresh" mode if FEATURE-STATUS.md exists (recovery/restart scenario)
@@ -1364,10 +1406,11 @@ export class Orchestrator {
1364
1406
  roleName: "operator",
1365
1407
  signalDir: tree.operator,
1366
1408
  cwd: featureCwd,
1367
- maxRetries: MAX_OPERATOR_RETRIES,
1409
+ model: getModel(budget, "operator"),
1410
+ maxRetries: budget.retries.operator,
1368
1411
  });
1369
1412
  } else {
1370
- this._syncAgentRetries(opAgent, MAX_OPERATOR_RETRIES);
1413
+ this._syncAgentRetries(opAgent, budget.retries.operator);
1371
1414
  }
1372
1415
  // Operator is reactive — spawned by routeUserMessage
1373
1416
  queries.updateAgentStatus(opAgent.id, "idle");
@@ -1390,10 +1433,11 @@ export class Orchestrator {
1390
1433
  teamNum,
1391
1434
  signalDir: team.orchestrator,
1392
1435
  cwd: teamCwd,
1393
- maxRetries: MAX_ORCH_RETRIES,
1436
+ model: getModel(budget, "team-orchestrator"),
1437
+ maxRetries: budget.retries.orchestrator,
1394
1438
  });
1395
1439
  } else {
1396
- this._syncAgentRetries(orchAgent, MAX_ORCH_RETRIES);
1440
+ this._syncAgentRetries(orchAgent, budget.retries.orchestrator);
1397
1441
  }
1398
1442
  // Orchestrator waits for .task — no auto-start
1399
1443
  }
@@ -1411,10 +1455,11 @@ export class Orchestrator {
1411
1455
  teamNum,
1412
1456
  signalDir: roleDir,
1413
1457
  cwd: teamCwd,
1414
- maxRetries: MAX_ROLE_RETRIES,
1458
+ model: getModel(budget, "role-agent"),
1459
+ maxRetries: budget.retries.role,
1415
1460
  });
1416
1461
  } else {
1417
- this._syncAgentRetries(roleAgent, MAX_ROLE_RETRIES);
1462
+ this._syncAgentRetries(roleAgent, budget.retries.role);
1418
1463
  }
1419
1464
  // Role waits for .task — no auto-start
1420
1465
  }
@@ -1432,10 +1477,11 @@ export class Orchestrator {
1432
1477
  roleName: role,
1433
1478
  signalDir: reviewDir,
1434
1479
  cwd: featureCwd,
1435
- maxRetries: MAX_ROLE_RETRIES,
1480
+ model: getModel(budget, "review-agent"),
1481
+ maxRetries: budget.retries.role,
1436
1482
  });
1437
1483
  } else {
1438
- this._syncAgentRetries(reviewAgent, MAX_ROLE_RETRIES);
1484
+ this._syncAgentRetries(reviewAgent, budget.retries.role);
1439
1485
  }
1440
1486
  }
1441
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: