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 +1 -1
- package/v3/orchestrator.js +239 -44
- package/v3/prompt-builder.js +19 -6
- package/v3/recovery.js +8 -2
- package/v3/roles/feature-lead/CLAUDE.md +17 -8
- package/v3/validate-gate-dispatch.js +208 -0
package/package.json
CHANGED
package/v3/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
458
|
+
planDecisionId, compiledPath, "Plan Review", { featureId: feature.id }
|
|
458
459
|
);
|
|
459
460
|
}
|
|
460
461
|
}
|
|
461
462
|
|
|
462
463
|
const planDecision = {
|
|
463
|
-
id:
|
|
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(
|
|
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
|
-
|
|
905
|
+
planDecisionId, compiledPath, "Plan Review", { featureId }
|
|
900
906
|
);
|
|
901
907
|
}
|
|
902
908
|
}
|
|
903
909
|
}
|
|
904
910
|
|
|
905
911
|
const decision = {
|
|
906
|
-
id:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1681
|
-
//
|
|
1682
|
-
const
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2296
|
-
|
|
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 (
|
|
2300
|
-
console.log(`[orchestrator]
|
|
2301
|
-
|
|
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
|
-
?
|
|
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
|
|
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
|
-
//
|
|
2666
|
-
if (
|
|
2667
|
-
console.log(`[orchestrator] ${roleName} exit: phase
|
|
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
|
-
|
|
3265
|
-
|
|
3266
|
-
)
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
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
|
package/v3/prompt-builder.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
|
22
|
-
-
|
|
23
|
-
-
|
|
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 —
|
|
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.
|
|
170
|
-
5.
|
|
171
|
-
6.
|
|
172
|
-
7. On
|
|
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();
|