iriai-build 0.4.3 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iriai-build",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Iriai Build tool — AI agent orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -450,17 +450,18 @@ export class Orchestrator {
450
450
 
451
451
  // Start interactive plan review session (compiled tabbed HTML)
452
452
  let reviewUrl = null;
453
+ const planDecisionId = `plan-approval@${feature.slug}`;
453
454
  if (this.reviewSessions) {
454
455
  const compiledPath = compilePlanReviewHtml(planDir);
455
456
  if (compiledPath) {
456
457
  reviewUrl = await this.reviewSessions.startDocReview(
457
- "plan-approval", compiledPath, "Plan Review", { featureId: feature.id }
458
+ planDecisionId, compiledPath, "Plan Review", { featureId: feature.id }
458
459
  );
459
460
  }
460
461
  }
461
462
 
462
463
  const planDecision = {
463
- id: "plan-approval",
464
+ id: planDecisionId,
464
465
  title: "Plan ready for approval",
465
466
  context: "All planning phases complete. Review the artifacts.",
466
467
  options: [
@@ -888,22 +889,27 @@ export class Orchestrator {
888
889
  if (feature.phase === "plan-approval") {
889
890
  // Restore or start plan review session
890
891
  let reviewUrl = null;
892
+ const planDecisionId = `plan-approval@${feature.slug}`;
891
893
  if (this.reviewSessions) {
892
- reviewUrl = await this.reviewSessions.restoreSession("plan-approval");
894
+ reviewUrl = await this.reviewSessions.restoreSession(planDecisionId);
895
+ if (!reviewUrl) {
896
+ // Try legacy key for in-flight features with old sessions
897
+ reviewUrl = await this.reviewSessions.restoreSession("plan-approval");
898
+ }
893
899
  if (!reviewUrl) {
894
900
  // No prior session — compile and start fresh
895
901
  const planDir = path.join(feature.signal_dir, "plans");
896
902
  const compiledPath = compilePlanReviewHtml(planDir);
897
903
  if (compiledPath) {
898
904
  reviewUrl = await this.reviewSessions.startDocReview(
899
- "plan-approval", compiledPath, "Plan Review", { featureId }
905
+ planDecisionId, compiledPath, "Plan Review", { featureId }
900
906
  );
901
907
  }
902
908
  }
903
909
  }
904
910
 
905
911
  const decision = {
906
- id: "plan-approval",
912
+ id: planDecisionId,
907
913
  title: "Plan ready for approval",
908
914
  context: "All planning phases complete. Review the artifacts.",
909
915
  options: [
@@ -1559,6 +1565,7 @@ export class Orchestrator {
1559
1565
  dashboardLog: path.join(tree.featureDir, ".dashboard-log"),
1560
1566
  questionTeams: opts.questionTeams,
1561
1567
  crashedTeams: opts.crashedTeams,
1568
+ gateTeams: opts.gateTeams,
1562
1569
  recoveryContext: opts.recoveryContext,
1563
1570
  featureDir: tree.featureDir,
1564
1571
  teamWorktreeBase: path.join(PROJECT_ROOT, ".features", feature.slug, "teams"),
@@ -1604,12 +1611,23 @@ export class Orchestrator {
1604
1611
  const tree = this._signalTrees[feature.slug];
1605
1612
  if (!tree?.featureLead) return;
1606
1613
 
1614
+ // Read which teams were under review (written at gate-ready trigger time)
1615
+ const reviewTeamsFile = path.join(tree.featureDir, ".gate-review-teams");
1616
+ let reviewTeams;
1617
+ try {
1618
+ reviewTeams = JSON.parse(fs.readFileSync(reviewTeamsFile, "utf-8"));
1619
+ fs.unlinkSync(reviewTeamsFile);
1620
+ } catch {
1621
+ // Fallback: all teams (backward compat for in-flight features without the file)
1622
+ reviewTeams = Object.keys(tree.teams);
1623
+ }
1624
+
1607
1625
  writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE),
1608
- "GATE APPROVED. Advance all teams to the next phase.");
1626
+ `GATE APPROVED. Advance teams ${reviewTeams.join(", ")} to the next phase. Other teams are unaffected.`);
1609
1627
 
1610
1628
  queries.updateFeatureGateEvidenceTs(featureId, null);
1611
1629
  queries.updateFeatureGate(featureId, (feature.gate_number || 0) + 1);
1612
- queries.insertEvent(featureId, "gate-approved", "bridge", `Gate ${(feature.gate_number || 0) + 1} approved by user`);
1630
+ queries.insertEvent(featureId, "gate-approved", "bridge", `Gate ${(feature.gate_number || 0) + 1} approved (teams ${reviewTeams.join(", ")})`);
1613
1631
  }
1614
1632
 
1615
1633
  async handleGateRejection(featureId, reason) {
@@ -1618,20 +1636,153 @@ export class Orchestrator {
1618
1636
  const tree = this._signalTrees[feature.slug];
1619
1637
  if (!tree?.featureLead) return;
1620
1638
 
1639
+ // Read which teams were under review
1640
+ const reviewTeamsFile = path.join(tree.featureDir, ".gate-review-teams");
1641
+ let reviewTeams;
1642
+ try {
1643
+ reviewTeams = JSON.parse(fs.readFileSync(reviewTeamsFile, "utf-8"));
1644
+ fs.unlinkSync(reviewTeamsFile);
1645
+ } catch {
1646
+ reviewTeams = Object.keys(tree.teams);
1647
+ }
1648
+
1621
1649
  writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE),
1622
- `GATE REJECTED: ${reason || "Please revise and resubmit."}`);
1650
+ `GATE REJECTED (teams ${reviewTeams.join(", ")}): ${reason || "Please revise and resubmit."}`);
1623
1651
 
1624
1652
  queries.updateFeatureGateEvidenceTs(featureId, null);
1625
- queries.insertEvent(featureId, "gate-rejected", "bridge", `Gate rejected: ${reason}`);
1653
+ queries.insertEvent(featureId, "gate-rejected", "bridge", `Gate rejected (teams ${reviewTeams.join(", ")}): ${reason}`);
1654
+ }
1655
+
1656
+ // ─── Gate Team Dependency Resolution ─────────────────────────────────
1657
+
1658
+ /**
1659
+ * Derive which teams must sync for a gate, based on plan.yaml phase dependencies.
1660
+ * Teams whose current phases have cross-dependencies form a gate group.
1661
+ * Falls back to all teams if plan.yaml is unavailable.
1662
+ *
1663
+ * @param {string} slug - Feature slug
1664
+ * @param {string} triggeringTeam - Team number that signaled gate-ready
1665
+ * @returns {string[]} Array of team numbers in the gate group
1666
+ */
1667
+ _getGateTeams(slug, triggeringTeam) {
1668
+ const tree = this._signalTrees[slug];
1669
+ if (!tree) return [triggeringTeam];
1670
+
1671
+ const allTeamNums = Object.keys(tree.teams);
1672
+ if (allTeamNums.length <= 1) return allTeamNums.length ? allTeamNums : [triggeringTeam];
1673
+
1674
+ // Read plan.yaml for phase dependency graph
1675
+ const planYamlPath = tree.plansDir
1676
+ ? path.join(tree.plansDir, "plan.yaml")
1677
+ : path.join(tree.featureDir, "plans", "plan.yaml");
1678
+
1679
+ let phaseDeps;
1680
+ try {
1681
+ const content = fs.readFileSync(planYamlPath, "utf-8");
1682
+ phaseDeps = this._parsePhaseDependencies(content);
1683
+ } catch {
1684
+ // No plan.yaml — fall back to all teams (safe default, old behavior)
1685
+ console.log(`[orchestrator] No plan.yaml for ${slug}, falling back to all-teams gate check`);
1686
+ return allTeamNums;
1687
+ }
1688
+
1689
+ // Read each team's manifest to get their current phases
1690
+ const teamPhases = {};
1691
+ for (const tn of allTeamNums) {
1692
+ const manifestPath = path.join(tree.teams[tn].dir, "manifest.yaml");
1693
+ try {
1694
+ const manifest = fs.readFileSync(manifestPath, "utf-8");
1695
+ // Parse gate_phases (new field) or fall back to current_phase
1696
+ const gatePhasesMatch = manifest.match(/^gate_phases:\s*\[([^\]]*)\]/m);
1697
+ if (gatePhasesMatch) {
1698
+ teamPhases[tn] = gatePhasesMatch[1].split(",").map(s => s.trim().replace(/['"]/g, "")).filter(Boolean);
1699
+ } else {
1700
+ const currentPhaseMatch = manifest.match(/^current_phase:\s*(\S+)/m);
1701
+ teamPhases[tn] = currentPhaseMatch ? [currentPhaseMatch[1]] : [];
1702
+ }
1703
+ } catch {
1704
+ teamPhases[tn] = [];
1705
+ }
1706
+ }
1707
+
1708
+ // Build gate group: triggering team + any team with cross-phase dependencies
1709
+ const myPhases = new Set(teamPhases[triggeringTeam] || []);
1710
+ const gateTeams = new Set([triggeringTeam]);
1711
+
1712
+ for (const tn of allTeamNums) {
1713
+ if (tn === triggeringTeam) continue;
1714
+ const otherPhases = new Set(teamPhases[tn] || []);
1715
+
1716
+ // Check: do any of my phases depend on any of their phases?
1717
+ for (const myPhase of myPhases) {
1718
+ for (const dep of (phaseDeps[myPhase] || [])) {
1719
+ if (otherPhases.has(dep)) gateTeams.add(tn);
1720
+ }
1721
+ }
1722
+ // Check: do any of their phases depend on any of my phases?
1723
+ for (const otherPhase of otherPhases) {
1724
+ for (const dep of (phaseDeps[otherPhase] || [])) {
1725
+ if (myPhases.has(dep)) gateTeams.add(tn);
1726
+ }
1727
+ }
1728
+ }
1729
+
1730
+ return [...gateTeams];
1731
+ }
1732
+
1733
+ /**
1734
+ * Parse phase dependency graph from plan.yaml content.
1735
+ * Returns { phaseId: [dep1, dep2, ...] } map.
1736
+ * Uses simple regex parsing (no yaml library).
1737
+ * Only parses the `phases:` block — ignores journeys and other top-level keys.
1738
+ */
1739
+ _parsePhaseDependencies(content) {
1740
+ // Extract only the phases: block (stop at next top-level key or end of file)
1741
+ const phasesMatch = content.match(/^phases:\s*\n([\s\S]*?)(?=\n[a-zA-Z])/m)
1742
+ || content.match(/^phases:\s*\n([\s\S]*)$/m);
1743
+ if (!phasesMatch) return {};
1744
+ const phasesBlock = phasesMatch[1];
1745
+
1746
+ const deps = {};
1747
+ const phaseEntries = phasesBlock.split(/(?=^\s*-\s+id:\s)/m);
1748
+ for (const block of phaseEntries) {
1749
+ const idMatch = block.match(/^\s*-?\s*id:\s*(\S+)/m);
1750
+ if (!idMatch) continue;
1751
+ const phaseId = idMatch[1];
1752
+ const depsMatch = block.match(/depends_on:\s*\[([^\]]*)\]/);
1753
+ if (depsMatch && depsMatch[1].trim()) {
1754
+ deps[phaseId] = depsMatch[1].split(",").map(s => s.trim().replace(/['"]/g, "")).filter(Boolean);
1755
+ } else {
1756
+ deps[phaseId] = [];
1757
+ }
1758
+ }
1759
+ return deps;
1626
1760
  }
1627
1761
 
1628
1762
  // ─── Decision Click Routing (Block Kit Buttons) ──────────────────────
1629
1763
 
1630
1764
  async handleDecisionClick(decisionId, optionId, userId, channel, messageTs, feedback = "", route = "") {
1631
1765
  // Collect annotations from review session and enrich rejection feedback
1766
+ // Review session keys are scoped by feature: try scoped key first, then bare decisionId
1632
1767
  let enrichedFeedback = feedback;
1633
1768
  if (this.reviewSessions && optionId !== "approve") {
1634
- const annotations = await this.reviewSessions.collectFeedback(decisionId);
1769
+ let annotations = null;
1770
+ // For plan-approval and gate decisions, the session key includes @<slug>
1771
+ // Try to find the scoped session by looking up feature from channel
1772
+ if (decisionId === "plan-approval" || decisionId.startsWith("plan-approval@") || decisionId.startsWith("gate-")) {
1773
+ const features = queries.getActiveFeatures();
1774
+ const feature = features.find(f => {
1775
+ const fChannel = f.feature_channel || this.adapter?.planningChannel;
1776
+ return fChannel === channel;
1777
+ });
1778
+ if (feature) {
1779
+ const scopedKey = decisionId.includes("@") ? decisionId : `${decisionId}@${feature.slug}`;
1780
+ annotations = await this.reviewSessions.collectFeedback(scopedKey);
1781
+ }
1782
+ }
1783
+ if (!annotations) {
1784
+ annotations = await this.reviewSessions.collectFeedback(decisionId);
1785
+ }
1635
1786
  if (annotations && annotations.length > 0) {
1636
1787
  enrichedFeedback = formatAnnotationsAsFeedback(annotations, feedback);
1637
1788
  }
@@ -1675,15 +1826,22 @@ export class Orchestrator {
1675
1826
  return;
1676
1827
  }
1677
1828
 
1678
- // Plan approval: "plan-approval"
1679
- if (decisionId === "plan-approval") {
1680
- const features = queries.getActiveFeatures();
1681
- // Use channel to disambiguate when multiple features are in plan-approval
1682
- const feature = features.find(f => {
1683
- if (f.phase !== "plan-approval") return false;
1684
- const fChannel = f.feature_channel || this.adapter.planningChannel;
1685
- return fChannel === channel;
1686
- });
1829
+ // Plan approval: "plan-approval@<slug>" (or legacy bare "plan-approval")
1830
+ if (decisionId === "plan-approval" || decisionId.startsWith("plan-approval@")) {
1831
+ let feature;
1832
+ // New format: plan-approval@<slug> extract slug directly
1833
+ const slugMatch = decisionId.match(/^plan-approval@(.+)$/);
1834
+ if (slugMatch) {
1835
+ feature = queries.getFeatureBySlug(slugMatch[1]);
1836
+ } else {
1837
+ // Legacy fallback: bare "plan-approval" — use channel to disambiguate
1838
+ const features = queries.getActiveFeatures();
1839
+ feature = features.find(f => {
1840
+ if (f.phase !== "plan-approval") return false;
1841
+ const fChannel = f.feature_channel || this.adapter.planningChannel;
1842
+ return fChannel === channel;
1843
+ });
1844
+ }
1687
1845
  if (!feature) return;
1688
1846
 
1689
1847
  if (optionId === "approve") {
@@ -1708,7 +1866,15 @@ export class Orchestrator {
1708
1866
  await this.handleGateRejection(feature.id, enrichedFeedback || "Rejected via button");
1709
1867
  }
1710
1868
  await this._resolveDecisionMessage(feature.id, messageTs, decisionId, optionId, userId, feedback);
1711
- if (this.reviewSessions) await this.reviewSessions.stop(decisionId);
1869
+ // Review session key is scoped by feature slug — try scoped key, fall back to bare decisionId
1870
+ if (this.reviewSessions) {
1871
+ const scopedKey = `${decisionId}@${feature.slug}`;
1872
+ if (this.reviewSessions.hasSession(scopedKey)) {
1873
+ await this.reviewSessions.stop(scopedKey);
1874
+ } else {
1875
+ await this.reviewSessions.stop(decisionId);
1876
+ }
1877
+ }
1712
1878
  return;
1713
1879
  }
1714
1880
 
@@ -1818,7 +1984,7 @@ export class Orchestrator {
1818
1984
  // Plan approval
1819
1985
  if (feature.phase === "plan-approval") {
1820
1986
  return {
1821
- id: "plan-approval",
1987
+ id: `plan-approval@${feature.slug}`,
1822
1988
  options: [
1823
1989
  { id: "approve", label: "Approve Plan" },
1824
1990
  { id: "reject", label: "Reject Plan" },
@@ -1826,10 +1992,10 @@ export class Orchestrator {
1826
1992
  };
1827
1993
  }
1828
1994
 
1829
- // Gate review
1995
+ // Gate review — scoped by feature slug for review session lookup
1830
1996
  if (feature.gate_evidence_ts) {
1831
1997
  return {
1832
- id: `gate-${(feature.gate_number || 0) + 1}-review`,
1998
+ id: `gate-${(feature.gate_number || 0) + 1}-review@${feature.slug}`,
1833
1999
  options: [
1834
2000
  { id: "approve", label: "Approve" },
1835
2001
  { id: "reject", label: "Reject" },
@@ -2145,15 +2311,16 @@ export class Orchestrator {
2145
2311
  description: o.description || "",
2146
2312
  }));
2147
2313
 
2148
- // Start review session for gate decisions
2314
+ // Start review session for gate decisions — scope key by feature to avoid cross-feature collisions
2149
2315
  let reviewUrl = null;
2150
2316
  let qaUrl = null;
2317
+ const reviewSessionKey = decision.id.startsWith("gate-") ? `${decision.id}@${feature.slug}` : decision.id;
2151
2318
  if (this.reviewSessions && decision.type === "approval" && decision.id.startsWith("gate-")) {
2152
2319
  // Look for gate evidence HTML
2153
2320
  const evidencePath = path.join(feature.signal_dir, "feature-lead", ".gate-evidence.html");
2154
2321
  if (fs.existsSync(evidencePath)) {
2155
2322
  reviewUrl = await this.reviewSessions.startDocReview(
2156
- decision.id, evidencePath, `Gate Evidence — ${decision.title}`, { featureId: feature.id }
2323
+ reviewSessionKey, evidencePath, `Gate Evidence — ${decision.title}`, { featureId: feature.id }
2157
2324
  );
2158
2325
  }
2159
2326
 
@@ -2177,7 +2344,7 @@ export class Orchestrator {
2177
2344
  }
2178
2345
  }
2179
2346
  qaUrl = await this.reviewSessions.startQaSession(
2180
- decision.id, verification.url, { featureId: feature.id }
2347
+ reviewSessionKey, verification.url, { featureId: feature.id }
2181
2348
  );
2182
2349
  }
2183
2350
  }
@@ -2287,18 +2454,25 @@ export class Orchestrator {
2287
2454
  }
2288
2455
  });
2289
2456
 
2290
- // Gate-ready: check if ALL teams are ready
2457
+ // Gate-ready: check only co-dependent teams (derived from plan.yaml phase dependencies)
2291
2458
  this.fileIO.on("impl:gateReady", ({ slug, teamNum }) => {
2292
2459
  const tree = this._signalTrees[slug];
2293
2460
  if (!tree) return;
2294
2461
 
2295
- const allReady = Object.keys(tree.teams).every(tn =>
2296
- fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.GATE_READY))
2462
+ const gateTeams = this._getGateTeams(slug, String(teamNum));
2463
+ const gateReady = gateTeams.every(tn =>
2464
+ tree.teams[tn]?.orchestrator && fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.GATE_READY))
2297
2465
  );
2298
2466
 
2299
- if (allReady) {
2300
- console.log(`[orchestrator] All teams gate-ready for ${slug}`);
2301
- this._triggerFeatureLead(slug, "gate-ready");
2467
+ if (gateReady) {
2468
+ console.log(`[orchestrator] Gate teams [${gateTeams.join(", ")}] ready for ${slug}`);
2469
+ // Persist which teams are under review (read by handleGateApproval/Rejection).
2470
+ // NOTE: assumes FL serializes gate reviews — if two independent gate groups finish
2471
+ // concurrently, the second write overwrites the first. Safe because the FL processes
2472
+ // one gate review at a time per feature before advancing to the next.
2473
+ const reviewTeamsFile = path.join(tree.featureDir, ".gate-review-teams");
2474
+ writeSignal(reviewTeamsFile, JSON.stringify(gateTeams));
2475
+ this._triggerFeatureLead(slug, "gate-ready", { gateTeams });
2302
2476
  }
2303
2477
  });
2304
2478
 
@@ -2405,7 +2579,7 @@ export class Orchestrator {
2405
2579
  // If FL is already running, send trigger as a .user-message instead of killing it
2406
2580
  if (this.supervisor.isRunning(flKey)) {
2407
2581
  const msg = trigger === "gate-ready"
2408
- ? "TRIGGER: All teams have signaled gate-ready. Begin gate review."
2582
+ ? `TRIGGER: Teams ${(opts.gateTeams || ["all"]).join(", ")} have signaled gate-ready. Review ONLY these teams' work for this gate.`
2409
2583
  : trigger === "question"
2410
2584
  ? `TRIGGER: Teams ${(opts.questionTeams || []).join(", ")} have questions needing resolution.`
2411
2585
  : trigger === "crash"
@@ -2649,11 +2823,16 @@ export class Orchestrator {
2649
2823
  return;
2650
2824
  }
2651
2825
 
2826
+ // Re-read feature from DB — the file watcher's _handlePlanningDone may have
2827
+ // updated phase/role between the time the exit event was queued and now.
2828
+ const freshFeature = queries.getFeatureById(feature.id);
2829
+ if (!freshFeature) return;
2830
+
2652
2831
  // Check if the file watcher already processed .done (it deletes the file).
2653
2832
  // If review gate is posted or agent already marked done, don't retry.
2654
2833
  const meta = queries.getFeatureMetadata(feature.id);
2655
2834
  const agent = queries.getAgentById(agentId);
2656
- if (meta.awaiting_phase_review && meta.phase_review_role === roleName) {
2835
+ if (meta.awaiting_phase_review) {
2657
2836
  console.log(`[orchestrator] ${roleName} exit: review gate already posted, skipping retry`);
2658
2837
  queries.updateAgentStatus(agentId, "done");
2659
2838
  return;
@@ -2662,9 +2841,15 @@ export class Orchestrator {
2662
2841
  console.log(`[orchestrator] ${roleName} exit: already marked done, skipping retry`);
2663
2842
  return;
2664
2843
  }
2665
- // Also skip if phase has already advanced past this role
2666
- if (feature.active_planning_role && feature.active_planning_role !== roleName) {
2667
- console.log(`[orchestrator] ${roleName} exit: phase already advanced to ${feature.active_planning_role}, skipping retry`);
2844
+ // Skip if feature has left planning phase (e.g. plan-compiler PASS → plan-approval)
2845
+ if (freshFeature.phase !== "planning") {
2846
+ console.log(`[orchestrator] ${roleName} exit: feature phase is ${freshFeature.phase}, not planning — skipping retry`);
2847
+ queries.updateAgentStatus(agentId, "done");
2848
+ return;
2849
+ }
2850
+ // Skip if phase has already advanced past this role (null means role was cleared)
2851
+ if (freshFeature.active_planning_role !== roleName) {
2852
+ console.log(`[orchestrator] ${roleName} exit: active role is now ${freshFeature.active_planning_role || "none"}, skipping retry`);
2668
2853
  queries.updateAgentStatus(agentId, "done");
2669
2854
  return;
2670
2855
  }
@@ -3261,14 +3446,24 @@ modify a SPECIFIC section — never re-read them in full.\n`;
3261
3446
  const flAgent = queries.getAgentByKey(`fl-${slug}`);
3262
3447
  const flRunning = flAgent && (flAgent.status === "running" || this.supervisor.isRunning(`fl-${slug}`));
3263
3448
  if (!flRunning && !feature.gate_evidence_ts) {
3264
- const allReady = Object.keys(tree.teams).every(tn =>
3265
- tree.teams[tn].orchestrator && fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.GATE_READY))
3266
- );
3267
- if (allReady && Object.keys(tree.teams).length > 0) {
3268
- console.log(`[orchestrator] Stale scan: all teams gate-ready for ${slug}`);
3269
- this._triggerFeatureLead(slug, "gate-ready");
3270
- continue;
3449
+ // Check each team's gate group independently (not all teams at once)
3450
+ let gateTriggered = false;
3451
+ for (const tn of Object.keys(tree.teams)) {
3452
+ if (!tree.teams[tn].orchestrator || !fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.GATE_READY))) continue;
3453
+ const gateTeams = this._getGateTeams(slug, tn);
3454
+ const gateReady = gateTeams.every(gt =>
3455
+ tree.teams[gt]?.orchestrator && fs.existsSync(path.join(tree.teams[gt].orchestrator, SIGNAL.GATE_READY))
3456
+ );
3457
+ if (gateReady) {
3458
+ console.log(`[orchestrator] Stale scan: gate teams [${gateTeams.join(", ")}] ready for ${slug}`);
3459
+ const reviewTeamsFile = path.join(tree.featureDir, ".gate-review-teams");
3460
+ writeSignal(reviewTeamsFile, JSON.stringify(gateTeams));
3461
+ this._triggerFeatureLead(slug, "gate-ready", { gateTeams });
3462
+ gateTriggered = true;
3463
+ break;
3464
+ }
3271
3465
  }
3466
+ if (gateTriggered) continue;
3272
3467
  }
3273
3468
 
3274
3469
  // Check for stale questions
@@ -659,13 +659,16 @@ Then poll for feature-review/health-monitor/.done, read its .output, and act on
659
659
 
660
660
  function buildGateReviewInstructions(featureLeadDir, featureReviewDir, featureStatusFile, dashboardFile) {
661
661
  return `
662
- ## Gate Review (once all teams are gate-ready)
662
+ ## Gate Review (once gate teams are ready)
663
+
664
+ Gates are per-team-group: the trigger message tells you which teams to review.
665
+ Review ONLY those teams — other teams may be working independently on unrelated phases.
663
666
 
664
667
  Follow the Gate Evidence Document Protocol from your CLAUDE.md. The ordered steps below ensure
665
668
  adversarial cross-check happens LAST (after all review agents have completed):
666
669
 
667
- 1. **Read team evidence** — Read \`.gate-evidence.yaml\` from each team's orchestrator signal dir.
668
- If any team lacks \`.gate-evidence.yaml\`, REJECT the gate immediately.
670
+ 1. **Read team evidence** — Read \`.gate-evidence.yaml\` from each REVIEWED team's orchestrator signal dir.
671
+ If any reviewed team lacks \`.gate-evidence.yaml\`, REJECT the gate immediately.
669
672
  2. **Push team branches** and create PRs (team branch → integration branch) per repo using gh CLI
670
673
  3. **Determine which review agents to dispatch** for this gate:
671
674
  a. Read the current phase's \`phase.yaml\` and look for the \`review_roles\` field.
@@ -838,7 +841,15 @@ ${planReadInstruction}
838
841
  3. Identify the gate boundaries in the plan
839
842
  4. Partition the current gate's work across ${numTeams} teams
840
843
  4. Write GATE-CONTEXT.md for each team (at ${teamSignalBase}/team-N/GATE-CONTEXT.md)
841
- 5. Write team manifests if not present (at ${teamSignalBase}/team-N/manifest.yaml)
844
+ 5. Write team manifests (at ${teamSignalBase}/team-N/manifest.yaml) — MUST include \`gate_phases\` field:
845
+ \`\`\`yaml
846
+ current_phase: phase-1 # starting phase for this gate
847
+ current_gate: 1
848
+ gate_phases: [phase-1] # ALL phases assigned to this team for this gate
849
+ \`\`\`
850
+ The \`gate_phases\` field lists every phase this team will complete before signaling gate-ready.
851
+ Gates are per-team-group: teams working on independent phases (no shared dependencies in plan.yaml)
852
+ will be reviewed independently. Only teams whose phases share dependencies need to sync.
842
853
  6. Dispatch to each team orchestrator by writing .task files (at ${teamSignalBase}/team-N/orchestrator/.task)
843
854
  7. Update ${featureStatusFile} with gate 1 status
844
855
  8. Update ${dashboardFile}
@@ -916,7 +927,7 @@ ${slackComm}`;
916
927
  export function buildFeatureLeadTriggerPrompt({
917
928
  featureName, numTeams, trigger, teamSignalBase,
918
929
  featureLeadDir, featureReviewDir, dashboardLog,
919
- questionTeams, crashedTeams, recoveryContext,
930
+ questionTeams, crashedTeams, gateTeams, recoveryContext,
920
931
  featureDir, teamWorktreeBase,
921
932
  }) {
922
933
  const featureStatusFile = `${featureDir}/FEATURE-STATUS.md`;
@@ -949,7 +960,9 @@ ${base}`;
949
960
  switch (trigger) {
950
961
  case "gate-ready":
951
962
  base += `
952
- TRIGGER: All ${numTeams} teams have signaled .gate-ready.
963
+ TRIGGER: Teams ${(gateTeams || ["all"]).join(", ")} have signaled .gate-ready.
964
+ Review ONLY these teams' work for this gate — other teams may still be working independently.
965
+ Read each reviewed team's .gate-evidence.yaml and GATE-CONTEXT.md to scope the review.
953
966
  ${gateReview}`;
954
967
  break;
955
968
 
package/v3/recovery.js CHANGED
@@ -100,8 +100,10 @@ export class Recovery {
100
100
  };
101
101
  }
102
102
 
103
- // Emit stale signals
103
+ // Emit stale signals — only for the active planning role
104
+ const activeRole = feature.active_planning_role;
104
105
  for (const [role, dir] of Object.entries(planningTree)) {
106
+ if (role !== activeRole) continue;
105
107
  for (const [sig, eventType] of [
106
108
  [SIGNAL.DONE, "planning:done"],
107
109
  [SIGNAL.AGENT_RESPONSE, "planning:response"],
@@ -281,8 +283,12 @@ export class Recovery {
281
283
  };
282
284
  }
283
285
 
284
- // Emit stale signals
286
+ // Emit stale signals — only for the active planning role to avoid
287
+ // re-processing signals from already-completed roles (which would cause
288
+ // duplicate artifact uploads and decision re-posts).
289
+ const activeRole = feature.active_planning_role;
285
290
  for (const [role, dir] of Object.entries(planningTree)) {
291
+ if (role !== activeRole) continue;
286
292
  for (const [sig, eventType] of [
287
293
  [SIGNAL.DONE, "planning:done"],
288
294
  [SIGNAL.AGENT_RESPONSE, "planning:response"],
@@ -18,9 +18,10 @@ The user will reject weak evidence. Catch problems before they reach the user.
18
18
  ## Constraints
19
19
  - ONLY read/write signal files and status documents
20
20
  - Partition plan phases across teams based on domain boundaries
21
- - Monitor `.gate-ready` signals from all team orchestrators
22
- - At gate boundaries: dispatch gate-level QA (integration-tester, code-reviewer)
23
- - Compile the full gate evidence bundle before posting to the user
21
+ - Monitor `.gate-ready` signals from team orchestrators
22
+ - Gates are per-team-group: teams with independent phases are reviewed separately
23
+ - At gate boundaries: dispatch gate-level QA (integration-tester, code-reviewer) for the reviewed teams only
24
+ - Compile the gate evidence bundle for only the reviewed teams before posting to the user
24
25
  - Escalate questions you cannot answer with high confidence to the user via the bridge
25
26
 
26
27
  ## Dynamic Dispatch Model
@@ -97,7 +98,7 @@ You post to the feature's channel via the bridge. Rules:
97
98
  - The HTML file IS the message. No text summary needed.
98
99
  - Include `[evidence:<path to .gate-evidence.html>]` marker — HTML uploaded as attachment
99
100
  - Include `[DECISION]` block with approve/reject buttons
100
- - This is the ONE approval point per gate — no per-team approvals
101
+ - This is the ONE approval point per gate group the trigger tells you which teams are in scope
101
102
  - The HTML links to team gate HTMLs for drill-down
102
103
  ```
103
104
  [evidence:<path to .gate-evidence.html>]
@@ -166,10 +167,18 @@ The bridge posts this to the user. Wait for `.user-message`, then transcribe the
166
167
  1. Read `plan.yaml` — understand feature scope and phase ordering
167
168
  2. Partition phases across teams by domain (backend, frontend, etc.)
168
169
  3. Write `.task` files to each team orchestrator with phase references and cross-team context
169
- 4. Monitor gate completion across all teams
170
- 5. At gate boundaries: run gate-level QA, compile evidence, present to user
171
- 6. On approval: advance all teams to next gate
172
- 7. On feature completion: manage merge ordering across team branches
170
+ 4. Write team manifests with `gate_phases` listing all phases for the current gate
171
+ 5. Monitor gate completion gates are per-team-group based on phase dependencies
172
+ 6. At gate boundaries: run gate-level QA for the reviewed teams, compile evidence, present to user
173
+ 7. On approval: advance ONLY the reviewed teams to next gate (other teams unaffected)
174
+ 8. On feature completion: manage merge ordering across team branches
175
+
176
+ ### Per-Team-Group Gates
177
+ Teams whose current phases have NO cross-dependencies in `plan.yaml` are reviewed independently.
178
+ Example: if Team 1 works on phase-1 (depends_on: []) and Team 2 works on phase-5 (depends_on: []),
179
+ they form separate gate groups — Team 1's gate review doesn't wait for Team 2.
180
+ Only when a phase depends on another team's phase (e.g., phase-6 depends_on: [phase-4, phase-5])
181
+ do both teams need to sync for a shared gate.
173
182
 
174
183
  ## Output
175
184
  Write `FEATURE-STATUS.md` and `DASHBOARD.md` in your feature's signal directory with current state.
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate gate dispatch correctness for a feature.
4
+ *
5
+ * Reads plan.yaml + all team manifests and checks:
6
+ * 1. gate_phases / current_phase reference valid phase IDs
7
+ * 2. Phase dependencies are satisfiable (deps in prior gates or same-team same-gate)
8
+ * 3. Gate groups are consistent (teams with cross-deps should share a gate boundary)
9
+ * 4. GATE-CONTEXT.md phase references match manifest
10
+ *
11
+ * Usage: node v3/validate-gate-dispatch.js <feature-signal-dir>
12
+ * e.g. node v3/validate-gate-dispatch.js .implementation/features/desktop-companion-app-for-iriai-build
13
+ */
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ function parsePlanYaml(content) {
19
+ // Extract only the phases: block (stop at next top-level key or end of file)
20
+ const phasesMatch = content.match(/^phases:\s*\n([\s\S]*?)(?=\n[a-zA-Z])/m)
21
+ || content.match(/^phases:\s*\n([\s\S]*)$/m);
22
+ if (!phasesMatch) return [];
23
+ const phasesBlock = phasesMatch[1];
24
+
25
+ const phases = [];
26
+ const phaseEntries = phasesBlock.split(/(?=^\s*-\s+id:\s)/m);
27
+ for (const block of phaseEntries) {
28
+ const idMatch = block.match(/^\s*-?\s*id:\s*(\S+)/m);
29
+ if (!idMatch) continue;
30
+ const phaseId = idMatch[1];
31
+ const depsMatch = block.match(/depends_on:\s*\[([^\]]*)\]/);
32
+ const deps = depsMatch && depsMatch[1].trim()
33
+ ? depsMatch[1].split(",").map(s => s.trim().replace(/['"]/g, "")).filter(Boolean)
34
+ : [];
35
+ const titleMatch = block.match(/title:\s*"?([^"\n]+)"?/);
36
+ phases.push({ id: phaseId, depends_on: deps, title: titleMatch ? titleMatch[1].trim() : phaseId });
37
+ }
38
+ return phases;
39
+ }
40
+
41
+ function parseManifest(content) {
42
+ const result = {};
43
+ const teamMatch = content.match(/^team:\s*(\S+)/m);
44
+ if (teamMatch) result.team = teamMatch[1];
45
+ const phaseMatch = content.match(/^current_phase:\s*(\S+)/m);
46
+ if (phaseMatch) result.current_phase = phaseMatch[1];
47
+ const gateMatch = content.match(/^current_gate:\s*(\S+)/m);
48
+ if (gateMatch) result.current_gate = gateMatch[1];
49
+ const gatePhasesMatch = content.match(/^gate_phases:\s*\[([^\]]*)\]/m);
50
+ if (gatePhasesMatch) {
51
+ result.gate_phases = gatePhasesMatch[1].split(",").map(s => s.trim().replace(/['"]/g, "")).filter(Boolean);
52
+ }
53
+ return result;
54
+ }
55
+
56
+ function main() {
57
+ const featureDir = process.argv[2];
58
+ if (!featureDir || !fs.existsSync(featureDir)) {
59
+ console.error("Usage: node v3/validate-gate-dispatch.js <feature-signal-dir>");
60
+ process.exit(1);
61
+ }
62
+
63
+ const errors = [];
64
+ const warnings = [];
65
+
66
+ // 1. Read plan.yaml
67
+ const planPath = path.join(featureDir, "plans", "plan.yaml");
68
+ if (!fs.existsSync(planPath)) {
69
+ console.error(`ERROR: No plan.yaml at ${planPath}`);
70
+ process.exit(1);
71
+ }
72
+ const phases = parsePlanYaml(fs.readFileSync(planPath, "utf-8"));
73
+ const phaseIds = new Set(phases.map(p => p.id));
74
+ const phaseDeps = Object.fromEntries(phases.map(p => [p.id, p.depends_on]));
75
+
76
+ console.log(`Plan: ${phases.length} phases — ${phases.map(p => p.id).join(", ")}`);
77
+
78
+ // 2. Read team manifests
79
+ const teamsDir = path.join(featureDir, "teams");
80
+ if (!fs.existsSync(teamsDir)) {
81
+ console.error(`ERROR: No teams directory at ${teamsDir}`);
82
+ process.exit(1);
83
+ }
84
+
85
+ const teams = {};
86
+ for (const entry of fs.readdirSync(teamsDir, { withFileTypes: true })) {
87
+ if (!entry.isDirectory() || !entry.name.startsWith("team-")) continue;
88
+ const teamNum = entry.name.replace("team-", "");
89
+ const manifestPath = path.join(teamsDir, entry.name, "manifest.yaml");
90
+ if (!fs.existsSync(manifestPath)) {
91
+ errors.push(`Team ${teamNum}: No manifest.yaml`);
92
+ continue;
93
+ }
94
+ teams[teamNum] = parseManifest(fs.readFileSync(manifestPath, "utf-8"));
95
+ teams[teamNum].dir = path.join(teamsDir, entry.name);
96
+ }
97
+
98
+ console.log(`Teams: ${Object.keys(teams).length} — ${Object.keys(teams).map(t => `team-${t}`).join(", ")}`);
99
+
100
+ // 3. Validate each team's phases
101
+ for (const [tn, manifest] of Object.entries(teams)) {
102
+ const teamPhases = manifest.gate_phases || (manifest.current_phase ? [manifest.current_phase] : []);
103
+
104
+ if (!manifest.gate_phases) {
105
+ warnings.push(`Team ${tn}: No gate_phases in manifest — falling back to current_phase: ${manifest.current_phase || "MISSING"}`);
106
+ }
107
+
108
+ for (const phaseId of teamPhases) {
109
+ if (!phaseIds.has(phaseId)) {
110
+ errors.push(`Team ${tn}: Phase '${phaseId}' not found in plan.yaml`);
111
+ }
112
+ }
113
+
114
+ // Check dependencies are satisfiable
115
+ for (const phaseId of teamPhases) {
116
+ const deps = phaseDeps[phaseId] || [];
117
+ for (const dep of deps) {
118
+ // Dep should be in a prior gate (another team's completed work) or same team's gate_phases
119
+ const inSameGate = teamPhases.includes(dep);
120
+ const inOtherTeam = Object.entries(teams).some(([otherTn, otherManifest]) => {
121
+ if (otherTn === tn) return false;
122
+ const otherPhases = otherManifest.gate_phases || (otherManifest.current_phase ? [otherManifest.current_phase] : []);
123
+ return otherPhases.includes(dep);
124
+ });
125
+
126
+ if (inOtherTeam && !inSameGate) {
127
+ // Cross-team dependency — this is an expected sync point
128
+ const depTeam = Object.entries(teams).find(([otherTn, otherManifest]) => {
129
+ if (otherTn === tn) return false;
130
+ const otherPhases = otherManifest.gate_phases || (otherManifest.current_phase ? [otherManifest.current_phase] : []);
131
+ return otherPhases.includes(dep);
132
+ });
133
+ if (depTeam) {
134
+ console.log(` Team ${tn} phase ${phaseId} depends on Team ${depTeam[0]} phase ${dep} — cross-team gate sync required`);
135
+ }
136
+ } else if (!inSameGate && !inOtherTeam) {
137
+ // Dep is not in any team's current gate — should be from a prior completed gate
138
+ warnings.push(`Team ${tn}: Phase '${phaseId}' depends on '${dep}' which is not in any team's current gate (should be completed already)`);
139
+ }
140
+ }
141
+ }
142
+
143
+ // 4. Check GATE-CONTEXT.md consistency
144
+ const gateContextPath = path.join(manifest.dir, "GATE-CONTEXT.md");
145
+ if (fs.existsSync(gateContextPath)) {
146
+ const gateContext = fs.readFileSync(gateContextPath, "utf-8");
147
+ for (const phaseId of teamPhases) {
148
+ // Check that the phase is mentioned in GATE-CONTEXT
149
+ const phaseNum = phaseId.replace("phase-", "");
150
+ if (!gateContext.includes(`Phase ${phaseNum}`) && !gateContext.includes(phaseId)) {
151
+ warnings.push(`Team ${tn}: GATE-CONTEXT.md doesn't mention ${phaseId}`);
152
+ }
153
+ }
154
+ } else {
155
+ warnings.push(`Team ${tn}: No GATE-CONTEXT.md`);
156
+ }
157
+ }
158
+
159
+ // 5. Compute and display gate groups
160
+ console.log("\n--- Gate Groups (derived from plan.yaml dependencies) ---");
161
+ const allTeamNums = Object.keys(teams);
162
+ const processed = new Set();
163
+
164
+ for (const tn of allTeamNums) {
165
+ if (processed.has(tn)) continue;
166
+ const myPhases = new Set(teams[tn].gate_phases || (teams[tn].current_phase ? [teams[tn].current_phase] : []));
167
+ const group = new Set([tn]);
168
+
169
+ for (const otherTn of allTeamNums) {
170
+ if (otherTn === tn) continue;
171
+ const otherPhases = new Set(teams[otherTn].gate_phases || (teams[otherTn].current_phase ? [teams[otherTn].current_phase] : []));
172
+
173
+ let linked = false;
174
+ for (const p of myPhases) {
175
+ for (const dep of (phaseDeps[p] || [])) {
176
+ if (otherPhases.has(dep)) linked = true;
177
+ }
178
+ }
179
+ for (const p of otherPhases) {
180
+ for (const dep of (phaseDeps[p] || [])) {
181
+ if (myPhases.has(dep)) linked = true;
182
+ }
183
+ }
184
+ if (linked) group.add(otherTn);
185
+ }
186
+
187
+ for (const g of group) processed.add(g);
188
+ const groupPhases = [...group].flatMap(g => teams[g].gate_phases || (teams[g].current_phase ? [teams[g].current_phase] : []));
189
+ console.log(` Gate group [${[...group].join(", ")}]: ${groupPhases.join(", ")}`);
190
+ }
191
+
192
+ // 6. Print results
193
+ if (errors.length > 0) {
194
+ console.log("\n--- ERRORS ---");
195
+ for (const e of errors) console.log(` ✗ ${e}`);
196
+ }
197
+ if (warnings.length > 0) {
198
+ console.log("\n--- WARNINGS ---");
199
+ for (const w of warnings) console.log(` ⚠ ${w}`);
200
+ }
201
+ if (errors.length === 0 && warnings.length === 0) {
202
+ console.log("\n✓ All checks passed");
203
+ }
204
+
205
+ process.exit(errors.length > 0 ? 1 : 0);
206
+ }
207
+
208
+ main();