iriai-build 0.1.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.
Files changed (80) hide show
  1. package/bin/iriai-build.js +78 -0
  2. package/bridge-v3.js +98 -0
  3. package/cli/bootstrap.js +83 -0
  4. package/cli/commands/implementation.js +64 -0
  5. package/cli/commands/index.js +46 -0
  6. package/cli/commands/launch.js +153 -0
  7. package/cli/commands/plan.js +117 -0
  8. package/cli/commands/setup.js +80 -0
  9. package/cli/commands/slack.js +97 -0
  10. package/cli/commands/transfer.js +111 -0
  11. package/cli/config.js +92 -0
  12. package/cli/display.js +121 -0
  13. package/cli/terminal-input.js +666 -0
  14. package/cli/wait.js +82 -0
  15. package/index.js +1488 -0
  16. package/lib/agent-process.js +170 -0
  17. package/lib/bridge-state.js +126 -0
  18. package/lib/constants.js +137 -0
  19. package/lib/health-monitor.js +113 -0
  20. package/lib/prompt-builder.js +565 -0
  21. package/lib/signal-watcher.js +215 -0
  22. package/lib/slack-helpers.js +224 -0
  23. package/lib/state-machines/feature-lead.js +408 -0
  24. package/lib/state-machines/operator-agent.js +173 -0
  25. package/lib/state-machines/planning-role.js +161 -0
  26. package/lib/state-machines/role-agent.js +186 -0
  27. package/lib/state-machines/team-orchestrator.js +160 -0
  28. package/package.json +31 -0
  29. package/v3/.handover-html-evidence.md +35 -0
  30. package/v3/KICKOFF-HTML-EVIDENCE.md +98 -0
  31. package/v3/PLAN-HTML-EVIDENCE-HARDENING.md +603 -0
  32. package/v3/adapters/desktop-adapter.js +78 -0
  33. package/v3/adapters/interface.js +146 -0
  34. package/v3/adapters/slack-adapter.js +608 -0
  35. package/v3/adapters/slack-helpers.js +179 -0
  36. package/v3/adapters/terminal-adapter.js +249 -0
  37. package/v3/agent-supervisor.js +320 -0
  38. package/v3/artifact-portal.js +1184 -0
  39. package/v3/bridge.db +0 -0
  40. package/v3/constants.js +170 -0
  41. package/v3/db.js +76 -0
  42. package/v3/file-io.js +216 -0
  43. package/v3/helpers.js +174 -0
  44. package/v3/operator.js +364 -0
  45. package/v3/orchestrator.js +2886 -0
  46. package/v3/plan-compiler.js +440 -0
  47. package/v3/prompt-builder.js +849 -0
  48. package/v3/queries.js +461 -0
  49. package/v3/recovery.js +508 -0
  50. package/v3/review-sessions.js +360 -0
  51. package/v3/roles/accessibility-auditor/CLAUDE.md +50 -0
  52. package/v3/roles/analytics-engineer/CLAUDE.md +40 -0
  53. package/v3/roles/architect/CLAUDE.md +809 -0
  54. package/v3/roles/backend-implementer/CLAUDE.md +97 -0
  55. package/v3/roles/code-reviewer/CLAUDE.md +89 -0
  56. package/v3/roles/database-implementer/CLAUDE.md +97 -0
  57. package/v3/roles/deployer/CLAUDE.md +42 -0
  58. package/v3/roles/designer/CLAUDE.md +386 -0
  59. package/v3/roles/documentation/CLAUDE.md +40 -0
  60. package/v3/roles/feature-lead/CLAUDE.md +233 -0
  61. package/v3/roles/frontend-implementer/CLAUDE.md +97 -0
  62. package/v3/roles/implementer/CLAUDE.md +97 -0
  63. package/v3/roles/integration-tester/CLAUDE.md +174 -0
  64. package/v3/roles/observability-engineer/CLAUDE.md +40 -0
  65. package/v3/roles/operator/CLAUDE.md +322 -0
  66. package/v3/roles/orchestrator/CLAUDE.md +288 -0
  67. package/v3/roles/package-implementer/CLAUDE.md +47 -0
  68. package/v3/roles/performance-analyst/CLAUDE.md +49 -0
  69. package/v3/roles/plan-compiler/CLAUDE.md +163 -0
  70. package/v3/roles/planning-lead/CLAUDE.md +41 -0
  71. package/v3/roles/pm/CLAUDE.md +806 -0
  72. package/v3/roles/regression-tester/CLAUDE.md +135 -0
  73. package/v3/roles/release-manager/CLAUDE.md +43 -0
  74. package/v3/roles/security-auditor/CLAUDE.md +90 -0
  75. package/v3/roles/smoke-tester/CLAUDE.md +97 -0
  76. package/v3/roles/test-author/CLAUDE.md +42 -0
  77. package/v3/roles/verifier/CLAUDE.md +90 -0
  78. package/v3/schema.sql +134 -0
  79. package/v3/slack-adapter.js +510 -0
  80. package/v3/slack-helpers.js +346 -0
@@ -0,0 +1,2886 @@
1
+ // orchestrator.js — Feature lifecycle + agent dispatch state machines.
2
+ // The core brain of bridge v3. Replaces bridge-v2.js state machines + signal handlers.
3
+ // State lives in SQLite — no in-memory state machines.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { execSync, spawn as cpSpawn } from "node:child_process";
8
+ import * as db from "./db.js";
9
+ import * as queries from "./queries.js";
10
+ import { AgentSupervisor } from "./agent-supervisor.js";
11
+ import AgentProcess from "../lib/agent-process.js";
12
+ import { FileIO } from "./file-io.js";
13
+ import { invokeOperator, invokeOperatorRelay, parseOperatorResponse } from "./operator.js";
14
+ import {
15
+ buildPlanningRolePrompt, buildArtifactSummarizerPrompt, buildRolePrompt, buildOrchestratorPrompt,
16
+ buildFeatureLeadInitPrompt, buildFeatureLeadRefreshPrompt, buildFeatureLeadTriggerPrompt,
17
+ } from "./prompt-builder.js";
18
+ import {
19
+ IMPL_BASE, IRIAI_TEAM_DIR, SCRIPTS_DIR,
20
+ V3_ROLES_DIR, PLANNING_ROLES, PLANNING_ROLE_LABELS,
21
+ ROLE_LABELS, PIPELINE_ORDER, SIGNAL,
22
+ STALE_SCAN_INTERVAL_MS, OPERATOR_RELAY_TIMEOUT_MS,
23
+ MAX_PLANNING_RETRIES, MAX_FL_RETRIES, MAX_FL_INIT_RETRIES,
24
+ MAX_ROLE_RETRIES, MAX_ORCH_RETRIES, MAX_OPERATOR_RETRIES,
25
+ FL_CONTEXT_EXHAUST_MS, FEATURE_REVIEW_ROLES,
26
+ ARTIFACT_SUMMARY_THRESHOLD_KB, ARTIFACT_SUMMARY_FILE,
27
+ SUMMARIZER_TIMEOUT_MS, SUMMARIZER_MODEL,
28
+ } from "./constants.js";
29
+ import {
30
+ readSignal, writeSignal, ensureDir, detectReposFromPlan, scaffoldNewRepo, slugify,
31
+ findArtifact,
32
+ } from "./helpers.js";
33
+ import { formatAnnotationsAsFeedback } from "./review-sessions.js";
34
+ import { compilePlanReviewHtml } from "./plan-compiler.js";
35
+
36
+ export class Orchestrator {
37
+ constructor({ adapter, reviewSessions = null }) {
38
+ this.adapter = adapter;
39
+ this.reviewSessions = reviewSessions;
40
+ this.supervisor = new AgentSupervisor();
41
+ this.fileIO = new FileIO();
42
+
43
+ // Per-feature signal trees (discovered on launch)
44
+ this._signalTrees = {};
45
+
46
+ // Operator relay queue processing state (per feature)
47
+ this._relayProcessing = {};
48
+ // Pending relay promises: relayQueueId → { resolve, timer }
49
+ this._relayWaiters = {};
50
+ // Deferred decisions: featureId → { decision, featureId } — posted after Operator relay completes
51
+ this._deferredDecisions = {};
52
+
53
+ this._staleScanTimer = null;
54
+
55
+ // When true, handlePlanApproval will NOT auto-launch implementation.
56
+ // The CLI sets this so it can prompt "Continue to implementation?" first.
57
+ this.deferImplLaunch = false;
58
+
59
+ this._setupFileIOHandlers();
60
+ this._setupSupervisorHandlers();
61
+ this.supervisor.startHealthMonitor();
62
+ }
63
+
64
+ // ═══════════════════════════════════════════════════════════════════════════
65
+ // FEATURE INITIALIZATION (at [FEATURE] detection)
66
+ // ═══════════════════════════════════════════════════════════════════════════
67
+
68
+ /**
69
+ * Initialize a feature from [FEATURE] detection: create per-feature dirs,
70
+ * feature channel, operator record, and start planning pipeline.
71
+ */
72
+ async initializeFeature(slug, messageTs, userId) {
73
+ const featureDir = path.join(IMPL_BASE, "features", slug);
74
+
75
+ // 1. Create directories
76
+ const operatorDir = path.join(featureDir, "operator");
77
+ const plansDir = path.join(featureDir, "plans");
78
+ ensureDir(operatorDir);
79
+ ensureDir(plansDir);
80
+
81
+ const planningTree = {};
82
+ for (const role of PLANNING_ROLES) {
83
+ const roleDir = path.join(featureDir, "planning", role);
84
+ ensureDir(roleDir);
85
+ planningTree[role] = roleDir;
86
+ }
87
+
88
+ // 2. Symlink CLAUDE.md from v3/roles/ into planning + operator dirs
89
+ for (const role of PLANNING_ROLES) {
90
+ const src = path.join(V3_ROLES_DIR, role, "CLAUDE.md");
91
+ const dest = path.join(planningTree[role], "CLAUDE.md");
92
+ try { fs.unlinkSync(dest); } catch { /* ok */ }
93
+ fs.symlinkSync(src, dest);
94
+ }
95
+ const opSrc = path.join(V3_ROLES_DIR, "operator", "CLAUDE.md");
96
+ const opDest = path.join(operatorDir, "CLAUDE.md");
97
+ try { fs.unlinkSync(opDest); } catch { /* ok */ }
98
+ fs.symlinkSync(opSrc, opDest);
99
+
100
+ // 3. Create feature in SQLite
101
+ const feature = queries.createFeature({ slug, threadTs: messageTs, signalDir: featureDir });
102
+ queries.insertEvent(feature.id, "system", `user:${userId}`, `Feature requested: ${slug}`);
103
+
104
+ // 4. Create feature channel immediately
105
+ const channelId = await this.adapter.createFeatureChannel(feature.id, slug);
106
+ if (channelId) {
107
+ queries.updateFeatureChannel(feature.id, channelId);
108
+ }
109
+
110
+ // 5. Create Operator agent record (idle, ready for relay)
111
+ queries.createAgent({
112
+ featureId: feature.id,
113
+ agentType: "operator",
114
+ agentKey: `op-${slug}`,
115
+ roleName: "operator",
116
+ signalDir: operatorDir,
117
+ cwd: featureDir,
118
+ maxRetries: MAX_OPERATOR_RETRIES,
119
+ });
120
+
121
+ // 6. Post link in #planning thread
122
+ if (channelId) {
123
+ await this.adapter.postThreadMessage(feature.id,
124
+ `*[Pipeline]* Starting planning for *${slug}*. Implementation channel: <#${channelId}>`);
125
+ await this.adapter.postMessage(feature.id,
126
+ `*[Pipeline]* Planning pipeline started for feature: *${slug}*\nPhase 1: Product Manager interview`);
127
+ } else {
128
+ await this.adapter.postThreadMessage(feature.id,
129
+ `*[Pipeline]* Starting planning pipeline for: *${slug}*\nPhase 1: Product Manager interview`);
130
+ }
131
+
132
+ // 7. Cache signal tree with planning field
133
+ this._signalTrees[slug] = {
134
+ featureDir,
135
+ featureLead: null,
136
+ operator: operatorDir,
137
+ featureReview: {},
138
+ teams: {},
139
+ planning: planningTree,
140
+ plansDir,
141
+ };
142
+
143
+ // 8. Start watching per-feature planning + operator signals
144
+ this.fileIO.watchFeaturePlanningSignals(slug, planningTree, operatorDir);
145
+
146
+ // 9. Set active planning role and dispatch PM
147
+ queries.updateFeaturePlanningRole(feature.id, "pm");
148
+ this.dispatchPlanningRole(feature.id, "pm");
149
+
150
+ return feature;
151
+ }
152
+
153
+ // ═══════════════════════════════════════════════════════════════════════════
154
+ // PLANNING PIPELINE
155
+ // ═══════════════════════════════════════════════════════════════════════════
156
+
157
+ /**
158
+ * Dispatch a planning role agent (PM, Designer, Architect, Plan-Compiler).
159
+ */
160
+ dispatchPlanningRole(featureId, role, opts = {}) {
161
+ const feature = queries.getFeatureById(featureId);
162
+ if (!feature) return;
163
+
164
+ // Compute dirs from feature record instead of global ROLE_DIRS
165
+ const featureDir = feature.signal_dir;
166
+ const signalDir = path.join(featureDir, "planning", role);
167
+ const planDir = path.join(featureDir, "plans");
168
+ ensureDir(signalDir);
169
+ ensureDir(planDir);
170
+
171
+ // Use repos worktree dir as cwd if it exists, otherwise signal dir
172
+ const reposDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "repos");
173
+ const roleCwd = fs.existsSync(reposDir) ? reposDir : signalDir;
174
+
175
+ const agentKey = `${role}-${feature.slug}`;
176
+
177
+ // Kill existing if any
178
+ this.supervisor.kill(agentKey);
179
+
180
+ // Clean up stale signal files from previous run so the file watcher
181
+ // doesn't immediately fire _handlePlanningDone before the agent starts.
182
+ for (const sig of [SIGNAL.DONE, SIGNAL.OUTPUT]) {
183
+ try { fs.unlinkSync(path.join(signalDir, sig)); } catch { /* ok */ }
184
+ }
185
+
186
+ // Create or update agent record
187
+ let agent = queries.getAgentByKey(agentKey);
188
+ const hasSession = agent?.started_at != null;
189
+ if (!agent) {
190
+ agent = queries.createAgent({
191
+ featureId,
192
+ agentType: "planning-role",
193
+ agentKey,
194
+ roleName: role,
195
+ signalDir,
196
+ cwd: roleCwd,
197
+ maxRetries: MAX_PLANNING_RETRIES,
198
+ });
199
+ } else {
200
+ queries.updateAgentStatus(agent.id, "idle");
201
+ queries.resetAgentRetry(agent.id);
202
+ this._syncAgentRetries(agent, MAX_PLANNING_RETRIES);
203
+ }
204
+
205
+ // Build task from any existing .task file in the signal dir
206
+ let task = "";
207
+ const taskFile = path.join(signalDir, SIGNAL.TASK);
208
+ if (fs.existsSync(taskFile)) {
209
+ task = fs.readFileSync(taskFile, "utf-8").trim();
210
+ }
211
+
212
+ // Check for pending .user-message (e.g. revision feedback) and inline it
213
+ const userMsgFile = path.join(signalDir, SIGNAL.USER_MESSAGE);
214
+ let revisionContext = "";
215
+ if (fs.existsSync(userMsgFile)) {
216
+ const msg = fs.readFileSync(userMsgFile, "utf-8").trim();
217
+ if (msg) {
218
+ revisionContext = `\n\n--- REVISION FEEDBACK ---\n${msg}\n--- END FEEDBACK ---\n\nYou are being re-dispatched after a phase review rejection. Address the feedback above. Read your previous output in $PLAN_DIR/ and revise it.`;
219
+ }
220
+ // Don't delete — the agent's poll loop will also pick it up as a belt-and-suspenders
221
+ }
222
+
223
+ // Add feature header with PLAN_DIR, FEATURE_DIR, REPOS_DIR
224
+ const taskHeader = [
225
+ `SLACK_MODE=true`,
226
+ `FEATURE_SLUG=${feature.slug}`,
227
+ `SIGNAL_DIR=${signalDir}`,
228
+ `PLAN_DIR=${planDir}`,
229
+ `FEATURE_DIR=${featureDir}`,
230
+ `REPOS_DIR=${reposDir}`,
231
+ `THREAD_TS=${feature.thread_ts}`,
232
+ ].join("\n");
233
+ const fullTask = task ? `${taskHeader}\n\n${task}${revisionContext}` : `${taskHeader}${revisionContext}`;
234
+
235
+ const prompt = buildPlanningRolePrompt({ task: fullTask, signalDir, featureSlug: feature.slug });
236
+
237
+ queries.updateFeaturePlanningRole(featureId, role);
238
+ queries.insertEvent(featureId, "agent-started", "bridge", `Planning role ${role} dispatched`);
239
+
240
+ // Use --continue if agent has a prior session and caller didn't explicitly set continue
241
+ const useContinue = opts.continue !== undefined ? opts.continue : hasSession;
242
+ this.supervisor.spawn(agent.id, prompt, { continue: useContinue });
243
+ }
244
+
245
+ /**
246
+ * Handle planning role completion — upload artifact + post review gate (or handle plan-compiler).
247
+ */
248
+ async _handlePlanningDone(slug, role, filePath) {
249
+ const dir = path.dirname(filePath);
250
+ const label = ROLE_LABELS[role] || role;
251
+
252
+ const feature = queries.getFeatureBySlug(slug);
253
+ if (!feature) return;
254
+
255
+ // Dedup guard: both file watcher (planning:done) and agent exit handler
256
+ // (_handlePlanningRoleExit) can call this for the same .done file.
257
+ // We use a synchronous in-memory lock + metadata check to prevent concurrent processing.
258
+ const dedupKey = `planningDone:${feature.id}:${role}`;
259
+ if (this._planningDoneLocks?.[dedupKey]) return;
260
+ const meta = queries.getFeatureMetadata(feature.id);
261
+ if (meta.awaiting_phase_review && meta.phase_review_role === role) return;
262
+ if (role === "plan-compiler" && feature.phase === "plan-approval") return;
263
+
264
+ // Acquire lock before any async work
265
+ if (!this._planningDoneLocks) this._planningDoneLocks = {};
266
+ this._planningDoneLocks[dedupKey] = true;
267
+
268
+ try {
269
+ // Read .output
270
+ const outputPath = path.join(dir, SIGNAL.OUTPUT);
271
+ let output = "";
272
+ if (fs.existsSync(outputPath)) {
273
+ output = fs.readFileSync(outputPath, "utf-8").trim();
274
+ }
275
+
276
+ queries.insertEvent(feature.id, "phase-transition", "bridge", `${label} complete`, { output });
277
+
278
+ // Clean up .done/.output after reading — output is already in memory.
279
+ try { fs.unlinkSync(filePath); } catch { /* ok */ }
280
+ if (fs.existsSync(outputPath)) try { fs.unlinkSync(outputPath); } catch { /* ok */ }
281
+
282
+ if (role === "plan-compiler") {
283
+ // Check PASS/FAIL
284
+ const isPassing = output && (output.startsWith("PASS") || output.startsWith("PASS_WITH_WARNINGS"));
285
+ if (!isPassing) {
286
+ // FAIL → re-dispatch architect (no user gate)
287
+ await this.adapter.postPipelineMessage(feature.id,
288
+ `Plan validation failed. Re-dispatching Architect.\n\n${output}`);
289
+ queries.updateFeaturePlanningRole(feature.id, "architect");
290
+ const archDir = path.join(feature.signal_dir, "planning", "architect");
291
+ ensureDir(archDir);
292
+ writeSignal(path.join(archDir, SIGNAL.USER_MESSAGE),
293
+ `REVISION REQUESTED: Plan compiler validation failed.\n\n${output}`);
294
+ this.dispatchPlanningRole(feature.id, "architect");
295
+ } else {
296
+ // PASS → final plan approval (uploads + buttons)
297
+ db.transaction(() => {
298
+ queries.updateFeaturePhase(feature.id, "plan-approval");
299
+ queries.updateFeaturePlanningRole(feature.id, null);
300
+ });
301
+ const planDir = path.join(feature.signal_dir, "plans");
302
+
303
+ // Upload artifacts (without decision buttons — those are deferred)
304
+ for (const art of [
305
+ { name: "PRD", pattern: "prd" },
306
+ { name: "Design Decisions", pattern: "design-decisions" },
307
+ { name: "Implementation Plan", pattern: "implementation-plan" },
308
+ ]) {
309
+ const artPath = findArtifact(art.pattern, planDir);
310
+ if (artPath) await this.adapter.uploadArtifact(feature.id, artPath, art.name);
311
+ }
312
+
313
+ // Start interactive plan review session (compiled tabbed HTML)
314
+ let reviewUrl = null;
315
+ if (this.reviewSessions) {
316
+ const compiledPath = compilePlanReviewHtml(planDir);
317
+ if (compiledPath) {
318
+ reviewUrl = await this.reviewSessions.startDocReview(
319
+ "plan-approval", compiledPath, "Plan Review", { featureId: feature.id }
320
+ );
321
+ }
322
+ }
323
+
324
+ const planDecision = {
325
+ id: "plan-approval",
326
+ title: "Plan ready for approval",
327
+ context: "All planning phases complete. Review the artifacts.",
328
+ options: [
329
+ { id: "approve", label: "Approve Plan", style: "primary" },
330
+ { id: "reject", label: "Reject Plan", style: "danger" },
331
+ ],
332
+ reviewUrl,
333
+
334
+ };
335
+
336
+ // Defer decision until after Operator relay completes
337
+ this._deferredDecisions[feature.id] = { decision: planDecision, featureId: feature.id };
338
+ this._notifyOperatorOfDecision(feature, planDecision);
339
+ }
340
+ } else {
341
+ // Non-final role → summary + artifact upload + Block Kit review gate (all sequential)
342
+ await this._requestPhaseReview(feature, role, output);
343
+ }
344
+
345
+ } finally {
346
+ // Release the dedup lock
347
+ delete this._planningDoneLocks[dedupKey];
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Post phase summary, upload artifact, then post Block Kit approve/reject buttons.
353
+ * Everything is sequential to guarantee correct message ordering.
354
+ */
355
+ async _requestPhaseReview(feature, role, output) {
356
+ const planDir = path.join(feature.signal_dir, "plans");
357
+ const label = PLANNING_ROLE_LABELS[role] || role;
358
+
359
+ // Artifact definitions — used for review session targets and fallback uploads
360
+ const ARTIFACT_MAP = {
361
+ pm: [{ name: "PRD", pattern: "prd" }],
362
+ designer: [
363
+ { name: "Design Decisions", pattern: "design-decisions" },
364
+ { name: "UI Mockup", file: "mockup.html" },
365
+ ],
366
+ architect: [
367
+ { name: "Implementation Plan (plan.yaml)", file: "plan.yaml" },
368
+ { name: "Architecture Context", pattern: "context" },
369
+ ],
370
+ };
371
+
372
+ const artifacts = ARTIFACT_MAP[role] || [];
373
+
374
+ // Only upload raw artifacts if review sessions are NOT available
375
+ // (review sessions replace file uploads with interactive doc review URLs)
376
+ if (!this.reviewSessions) {
377
+ for (const artifact of artifacts) {
378
+ const artifactPath = artifact.file
379
+ ? (fs.existsSync(path.join(planDir, artifact.file)) ? path.join(planDir, artifact.file) : null)
380
+ : findArtifact(artifact.pattern, planDir);
381
+ if (artifactPath) {
382
+ await this.adapter.uploadArtifact(feature.id, artifactPath, artifact.name);
383
+ }
384
+ }
385
+ }
386
+ const artifact = artifacts[0]; // primary artifact for button text
387
+
388
+ const nextRole = PIPELINE_ORDER[PIPELINE_ORDER.indexOf(role) + 1];
389
+ const nextLabel = PLANNING_ROLE_LABELS[nextRole] || nextRole;
390
+ const decisionId = `phase-review-${feature.slug}-${role}`;
391
+
392
+ // Set awaiting_phase_review BEFORE postDecision so handleDecisionClick
393
+ // can find the feature when the user responds.
394
+ const meta = queries.getFeatureMetadata(feature.id);
395
+ meta.awaiting_phase_review = true;
396
+ meta.phase_review_role = role;
397
+ meta.phase_review_ts = decisionId;
398
+ queries.updateFeatureMetadata(feature.id, meta);
399
+
400
+ // For designer phase: detect mockup.html, serve it, inject URL into design-decisions.md
401
+ if (role === "designer" && this.reviewSessions) {
402
+ await this._ensureMockupSession(planDir, `${decisionId}-mockup`, feature.id);
403
+ }
404
+
405
+ // Start interactive review session for the primary artifact
406
+ let reviewUrl = null;
407
+ if (this.reviewSessions) {
408
+ const primaryArtifact = artifacts[0];
409
+ const primaryPath = primaryArtifact?.file
410
+ ? (fs.existsSync(path.join(planDir, primaryArtifact.file)) ? path.join(planDir, primaryArtifact.file) : null)
411
+ : findArtifact(primaryArtifact?.pattern, planDir);
412
+ if (primaryPath) {
413
+ reviewUrl = await this.reviewSessions.startDocReview(
414
+ decisionId, primaryPath, primaryArtifact.name, { featureId: feature.id }
415
+ );
416
+ }
417
+ }
418
+
419
+ const decision = {
420
+ id: decisionId,
421
+ title: `${label} phase complete — review the ${artifact?.name || "output"} above`,
422
+ context: `Approve to proceed to ${nextLabel}, or reject to request revisions.`,
423
+ options: [
424
+ { id: "approve", label: "Approve", style: "primary" },
425
+ { id: "reject", label: "Request Revisions", style: "danger" },
426
+ ],
427
+ reviewUrl,
428
+
429
+ };
430
+
431
+ // Defer the decision post until after the Operator relay completes,
432
+ // so the Operator's "PRD complete" message appears first.
433
+ this._deferredDecisions[feature.id] = { decision, featureId: feature.id };
434
+
435
+ // Notify the Operator — when the relay finishes, _postDeferredDecision() fires.
436
+ this._notifyOperatorOfDecision(feature, decision, output);
437
+ }
438
+
439
+ /**
440
+ * Notify the Operator about a pipeline decision via relay.
441
+ * Gives the Operator awareness and context regardless of adapter mode.
442
+ */
443
+ _notifyOperatorOfDecision(feature, decision, agentOutput) {
444
+ const optionsList = decision.options
445
+ .map(o => `${o.label} (id: ${o.id})`)
446
+ .join(", ");
447
+
448
+ const summary = agentOutput
449
+ ? `Agent output summary: ${agentOutput.slice(0, 500)}`
450
+ : "";
451
+
452
+ const reviewLine = decision.reviewUrl
453
+ ? `Review URL: ${decision.reviewUrl} (user can annotate feedback in browser)`
454
+ : "";
455
+ const qaLine = decision.qaUrl
456
+ ? `QA Test URL: ${decision.qaUrl} (user can test the live app)`
457
+ : "";
458
+
459
+ const content = [
460
+ `Pipeline event: ${decision.title}`,
461
+ decision.context || "",
462
+ `Decision ID: ${decision.id}`,
463
+ `Options: ${optionsList}`,
464
+ reviewLine,
465
+ qaLine,
466
+ summary,
467
+ ].filter(Boolean).join("\n");
468
+
469
+ this._enqueueForOperatorRelay(feature, "Pipeline", "decision-needed", content);
470
+ }
471
+
472
+ /**
473
+ * Re-post a pending phase review decision on resume.
474
+ * Called when a feature is in awaiting_phase_review state but the decision
475
+ * prompt was lost (e.g. terminal session died).
476
+ */
477
+ async repostPendingPhaseReview(featureId) {
478
+ const feature = queries.getFeatureById(featureId);
479
+ if (!feature) return;
480
+ const meta = queries.getFeatureMetadata(featureId);
481
+ if (!meta.awaiting_phase_review || !meta.phase_review_role) return;
482
+
483
+ const role = meta.phase_review_role;
484
+ const label = PLANNING_ROLE_LABELS[role] || role;
485
+ const nextRole = PIPELINE_ORDER[PIPELINE_ORDER.indexOf(role) + 1];
486
+ const nextLabel = nextRole ? (PLANNING_ROLE_LABELS[nextRole] || nextRole) : "Plan Approval";
487
+ const decisionId = `phase-review-${feature.slug}-${role}`;
488
+
489
+ // Upload artifacts again so the user can review
490
+ const planDir = path.join(feature.signal_dir, "plans");
491
+ const ARTIFACT_MAP = {
492
+ pm: [{ name: "PRD", pattern: "prd" }],
493
+ designer: [
494
+ { name: "Design Decisions", pattern: "design-decisions" },
495
+ { name: "UI Mockup", file: "mockup.html" },
496
+ ],
497
+ architect: [
498
+ { name: "Implementation Plan (plan.yaml)", file: "plan.yaml" },
499
+ { name: "Architecture Context", pattern: "context" },
500
+ ],
501
+ };
502
+ const artifacts = ARTIFACT_MAP[role] || [];
503
+
504
+ // Restore or start mockup session for designer phase
505
+ if (role === "designer" && this.reviewSessions) {
506
+ await this._ensureMockupSession(planDir, `${decisionId}-mockup`, feature.id);
507
+ }
508
+
509
+ // Restore or start review session for the primary artifact
510
+ let reviewUrl = null;
511
+ if (this.reviewSessions) {
512
+ reviewUrl = await this.reviewSessions.restoreSession(decisionId);
513
+ if (!reviewUrl) {
514
+ // No saved session — start a fresh one
515
+ const primaryArtifact = artifacts[0];
516
+ const primaryPath = primaryArtifact?.file
517
+ ? (fs.existsSync(path.join(planDir, primaryArtifact.file)) ? path.join(planDir, primaryArtifact.file) : null)
518
+ : findArtifact(primaryArtifact?.pattern, planDir);
519
+ if (primaryPath) {
520
+ reviewUrl = await this.reviewSessions.startDocReview(
521
+ decisionId, primaryPath, primaryArtifact.name, { featureId: feature.id }
522
+ );
523
+ }
524
+ }
525
+ }
526
+
527
+ // Only upload raw artifacts if no review session (fallback for non-review mode)
528
+ if (!reviewUrl) {
529
+ for (const artifact of artifacts) {
530
+ const artifactPath = artifact.file
531
+ ? (fs.existsSync(path.join(planDir, artifact.file)) ? path.join(planDir, artifact.file) : null)
532
+ : findArtifact(artifact.pattern, planDir);
533
+ if (artifactPath) {
534
+ await this.adapter.uploadArtifact(feature.id, artifactPath, artifact.name);
535
+ }
536
+ }
537
+ }
538
+
539
+ const decision = {
540
+ id: decisionId,
541
+ title: `${label} phase complete — review the ${artifacts[0]?.name || "output"} above`,
542
+ context: `Approve to proceed to ${nextLabel}, or reject to request revisions.`,
543
+ options: [
544
+ { id: "approve", label: "Approve", style: "primary" },
545
+ { id: "reject", label: "Request Revisions", style: "danger" },
546
+ ],
547
+ reviewUrl,
548
+
549
+ };
550
+
551
+ // Post decision directly — no deferred/Operator relay for reposts
552
+ await this.adapter.postDecision(feature.id, decision);
553
+ }
554
+
555
+ /**
556
+ * Re-present any pending decision on resume (restart/reconnect).
557
+ * Looks up the pending decision from DB and re-notifies the Operator.
558
+ */
559
+ async repostPendingDecision(featureId) {
560
+ const feature = queries.getFeatureById(featureId);
561
+ if (!feature) return;
562
+
563
+ // Check for awaiting_phase_review first — has its own richer repost logic
564
+ const meta = queries.getFeatureMetadata(featureId);
565
+ if (meta.awaiting_phase_review) {
566
+ return this.repostPendingPhaseReview(featureId);
567
+ }
568
+
569
+ // Check for plan-approval phase
570
+ if (feature.phase === "plan-approval") {
571
+ // Restore or start plan review session
572
+ let reviewUrl = null;
573
+ if (this.reviewSessions) {
574
+ reviewUrl = await this.reviewSessions.restoreSession("plan-approval");
575
+ if (!reviewUrl) {
576
+ // No prior session — compile and start fresh
577
+ const planDir = path.join(feature.signal_dir, "plans");
578
+ const compiledPath = compilePlanReviewHtml(planDir);
579
+ if (compiledPath) {
580
+ reviewUrl = await this.reviewSessions.startDocReview(
581
+ "plan-approval", compiledPath, "Plan Review", { featureId }
582
+ );
583
+ }
584
+ }
585
+ }
586
+
587
+ const decision = {
588
+ id: "plan-approval",
589
+ title: "Plan ready for approval",
590
+ context: "All planning phases complete. Review the artifacts.",
591
+ options: [
592
+ { id: "approve", label: "Approve Plan", style: "primary" },
593
+ { id: "reject", label: "Reject Plan", style: "danger" },
594
+ ],
595
+ reviewUrl,
596
+
597
+ };
598
+ await this.adapter.postDecision(feature.id, decision);
599
+ this._notifyOperatorOfDecision(feature, decision);
600
+ return;
601
+ }
602
+
603
+ // Generic: re-notify for any pending decision in DB
604
+ const pendingDecision = queries.getPendingDecision(featureId);
605
+ if (pendingDecision) {
606
+ const options = JSON.parse(pendingDecision.options || "[]");
607
+ const decision = {
608
+ id: pendingDecision.decision_id,
609
+ title: pendingDecision.title || pendingDecision.decision_id,
610
+ context: pendingDecision.context_text || "",
611
+ options,
612
+ };
613
+ this._notifyOperatorOfDecision(feature, decision);
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Handle phase review approval — advance to next planning role.
619
+ */
620
+ async handlePhaseReviewApproval(featureId) {
621
+ const feature = queries.getFeatureById(featureId);
622
+ if (!feature) return;
623
+
624
+ const meta = queries.getFeatureMetadata(featureId);
625
+ const role = meta.phase_review_role;
626
+ if (!role) return;
627
+
628
+ const label = PLANNING_ROLE_LABELS[role] || role;
629
+
630
+ // Clear review state
631
+ meta.awaiting_phase_review = false;
632
+ meta.phase_review_role = null;
633
+ meta.phase_review_ts = null;
634
+ queries.updateFeatureMetadata(featureId, meta);
635
+
636
+ const currentIndex = PIPELINE_ORDER.indexOf(role);
637
+ if (currentIndex >= 0 && currentIndex < PIPELINE_ORDER.length - 1) {
638
+ const nextRole = PIPELINE_ORDER[currentIndex + 1];
639
+ const nextLabel = PLANNING_ROLE_LABELS[nextRole] || nextRole;
640
+
641
+ await this.adapter.postPipelineMessage(feature.id,
642
+ `${label} approved. Starting ${nextLabel}...`);
643
+
644
+ db.transaction(() => {
645
+ queries.updateFeaturePlanningRole(feature.id, nextRole);
646
+ });
647
+
648
+ this.dispatchPlanningRole(feature.id, nextRole);
649
+ }
650
+
651
+ queries.insertEvent(featureId, "phase-transition", "bridge", `${role} phase approved`);
652
+ }
653
+
654
+ /**
655
+ * Handle phase review rejection — re-dispatch same role with feedback.
656
+ */
657
+ async handlePhaseReviewRejection(featureId, reason) {
658
+ const feature = queries.getFeatureById(featureId);
659
+ if (!feature) return;
660
+
661
+ const meta = queries.getFeatureMetadata(featureId);
662
+ const role = meta.phase_review_role;
663
+ if (!role) return;
664
+
665
+ const label = PLANNING_ROLE_LABELS[role] || role;
666
+
667
+ // Clear review state
668
+ meta.awaiting_phase_review = false;
669
+ meta.phase_review_role = null;
670
+ meta.phase_review_ts = null;
671
+ queries.updateFeatureMetadata(featureId, meta);
672
+
673
+ await this.adapter.postPipelineMessage(feature.id,
674
+ `Revision requested for ${label}: "${reason || "Please revise."}". Re-dispatching...`);
675
+
676
+ const roleDir = path.join(feature.signal_dir, "planning", role);
677
+ ensureDir(roleDir);
678
+
679
+ // Clean up stale .done/.output so file watcher doesn't immediately re-trigger
680
+ for (const sig of [SIGNAL.DONE, SIGNAL.OUTPUT]) {
681
+ try { fs.unlinkSync(path.join(roleDir, sig)); } catch { /* ok */ }
682
+ }
683
+
684
+ // Check if the agent has a prior session to continue
685
+ const agentKey = `${role}-${feature.slug}`;
686
+ const agent = queries.getAgentByKey(agentKey);
687
+
688
+ if (agent?.started_at != null) {
689
+ // Agent has prior session — use --continue with just the revision feedback
690
+ // No need to rebuild full prompt; the agent has full prior context
691
+ const revisionPrompt = `REVISION REQUESTED by the user:\n\n${reason || "Please revise your output."}\n\nIMPORTANT PROTOCOL:\n- If the feedback is unclear or you need clarification, write your question to .agent-response and then poll for .user-message (while [ ! -f .user-message ]; do sleep 5; done). Do NOT write .done until you have received the user's answer and completed your revision.\n- Do NOT write .done if you are asking a question. You must wait for the response first.\n- Only write .done after the revision is fully complete.`;
692
+
693
+ this.supervisor.kill(agentKey);
694
+ queries.updateAgentStatus(agent.id, "idle");
695
+ queries.resetAgentRetry(agent.id);
696
+ this.supervisor.spawn(agent.id, revisionPrompt, { continue: true });
697
+ } else {
698
+ // No prior session — fall back to full re-dispatch with revision context
699
+ writeSignal(path.join(roleDir, SIGNAL.USER_MESSAGE),
700
+ `REVISION REQUESTED: ${reason || "Please revise your output."}\n\nIMPORTANT PROTOCOL:\n- If the feedback is unclear or you need clarification, write your question to .agent-response and then poll for .user-message (while [ ! -f .user-message ]; do sleep 5; done). Do NOT write .done until you have received the user's answer and completed your revision.\n- Do NOT write .done if you are asking a question. You must wait for the response first.\n- Only write .done after the revision is fully complete.`);
701
+ this.dispatchPlanningRole(featureId, role, { continue: false });
702
+ }
703
+
704
+ queries.insertEvent(featureId, "phase-transition", "bridge", `${role} phase rejected: ${reason}`);
705
+ }
706
+
707
+ /**
708
+ * Handle plan approval — create branches and launch implementation directly.
709
+ */
710
+ async handlePlanApproval(featureId) {
711
+ const feature = queries.getFeatureById(featureId);
712
+ if (!feature) return;
713
+
714
+ if (this.deferImplLaunch) {
715
+ // CLI mode: don't auto-launch impl. CLI will prompt "continue to impl?" and
716
+ // call startImplementation() separately.
717
+ queries.insertEvent(featureId, "phase-transition", "bridge", "Plan approved (impl deferred to CLI prompt)");
718
+ return;
719
+ }
720
+
721
+ // Normal mode (Slack): create branches + launch implementation immediately
722
+ await this.startImplementation(featureId);
723
+ }
724
+
725
+ /**
726
+ * Start implementation for a feature: create branches, run launch script, spawn agents.
727
+ * Extracted from handlePlanApproval() so the CLI can call it independently after
728
+ * prompting "Continue to implementation?"
729
+ */
730
+ async startImplementation(featureId) {
731
+ const feature = queries.getFeatureById(featureId);
732
+ if (!feature) return;
733
+
734
+ const planDir = path.join(feature.signal_dir, "plans");
735
+ const detectedRepos = detectReposFromPlan(planDir, feature.slug);
736
+ const meta = queries.getFeatureMetadata(featureId);
737
+ const newRepos = meta.new_repos || [];
738
+
739
+ try {
740
+ // Create feature branches
741
+ if (detectedRepos.length > 0 || newRepos.length > 0) {
742
+ const completeScript = path.join(SCRIPTS_DIR, "planning-complete.sh");
743
+ if (fs.existsSync(completeScript)) {
744
+ const newFlags = newRepos.map(nr => `--new "${nr.localPath}" "${nr.githubName}"`);
745
+ const newRepoPaths = new Set(newRepos.map(r => r.localPath));
746
+ const existingArgs = detectedRepos.filter(r => !newRepoPaths.has(r)).map(r => `"${r}"`);
747
+ const args = [`"${feature.slug}"`, ...newFlags, ...existingArgs].join(" ");
748
+
749
+ execSync(`bash "${completeScript}" ${args}`, {
750
+ cwd: IRIAI_TEAM_DIR, stdio: "pipe", timeout: 120000,
751
+ env: { ...process.env, PLAN_DIR: planDir, CONFIRM: "y" },
752
+ });
753
+ }
754
+ }
755
+
756
+ // Run launch script if it exists
757
+ const launchScript = path.join(SCRIPTS_DIR, "launch-feature.sh");
758
+ if (fs.existsSync(launchScript)) {
759
+ execSync(`bash "${launchScript}" "${feature.slug}"`, {
760
+ cwd: IRIAI_TEAM_DIR, stdio: "pipe", timeout: 300000,
761
+ env: {
762
+ ...process.env,
763
+ IMPL_SIGNAL_BASE: IMPL_BASE,
764
+ SLACK_MODE: "true",
765
+ FEATURE_SLUG: feature.slug,
766
+ IMPL_CHANNEL: feature.feature_channel || "",
767
+ PLAN_DIR: planDir,
768
+ ROLES_DIR: V3_ROLES_DIR,
769
+ },
770
+ });
771
+ }
772
+
773
+ // Launch implementation agents
774
+ this.launchImplAgents(featureId);
775
+
776
+ db.transaction(() => {
777
+ queries.updateFeaturePhase(featureId, "impl");
778
+ });
779
+
780
+ queries.insertEvent(featureId, "phase-transition", "bridge", "Implementation launched");
781
+ } catch (err) {
782
+ console.error("[orchestrator] Implementation launch error:", err.message);
783
+ const stderr = err.stderr ? err.stderr.toString().slice(-500) : err.message;
784
+ await this.adapter.postPipelineMessage(feature.id,
785
+ `Error launching implementation:\n\`\`\`${stderr}\`\`\``);
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Handle plan rejection — re-dispatch architect.
791
+ */
792
+ async handlePlanRejection(featureId, reason) {
793
+ const feature = queries.getFeatureById(featureId);
794
+ if (!feature) return;
795
+
796
+ await this.adapter.postPipelineMessage(feature.id,
797
+ `Plan rejected. Re-dispatching Architect with feedback...`);
798
+
799
+ db.transaction(() => {
800
+ queries.updateFeaturePhase(featureId, "planning");
801
+ queries.updateFeaturePlanningRole(featureId, "architect");
802
+ });
803
+
804
+ const archDir = path.join(feature.signal_dir, "planning", "architect");
805
+ ensureDir(archDir);
806
+ writeSignal(path.join(archDir, SIGNAL.USER_MESSAGE),
807
+ `REVISION REQUESTED: ${reason || "Plan rejected. Please revise."}`);
808
+
809
+ this.dispatchPlanningRole(featureId, "architect");
810
+ queries.insertEvent(featureId, "phase-transition", "bridge", `Plan rejected: ${reason}`);
811
+ }
812
+
813
+ /**
814
+ * Handle repo confirmation ("go") — create branches and launch implementation.
815
+ */
816
+ async handleRepoConfirmation(featureId) {
817
+ const feature = queries.getFeatureById(featureId);
818
+ if (!feature) return;
819
+
820
+ const meta = queries.getFeatureMetadata(featureId);
821
+ const repos = meta.pending_repos || [];
822
+ const newRepos = meta.new_repos || [];
823
+ meta.awaiting_repo_confirm = false;
824
+ meta.pending_repos = null;
825
+ queries.updateFeatureMetadata(featureId, meta);
826
+
827
+ const planDir = path.join(feature.signal_dir, "plans");
828
+ try {
829
+ if (repos.length > 0 || newRepos.length > 0) {
830
+ const totalCount = repos.length + newRepos.filter(nr => !repos.includes(nr.localPath)).length;
831
+ await this.adapter.postThreadMessage(feature.id,
832
+ `*[Pipeline]* Creating \`feature/${feature.slug}\` branches in ${totalCount} repo(s)...`);
833
+
834
+ const completeScript = path.join(SCRIPTS_DIR, "planning-complete.sh");
835
+ if (fs.existsSync(completeScript)) {
836
+ // Build --new flags for new repos
837
+ const newFlags = newRepos.map(nr => `--new "${nr.localPath}" "${nr.githubName}"`);
838
+ // Existing repos (exclude new repo paths that will be handled by --new)
839
+ const newRepoPaths = new Set(newRepos.map(r => r.localPath));
840
+ const existingArgs = repos.filter(r => !newRepoPaths.has(r)).map(r => `"${r}"`);
841
+
842
+ const args = [
843
+ `"${feature.slug}"`,
844
+ ...newFlags,
845
+ ...existingArgs,
846
+ ].join(" ");
847
+
848
+ execSync(
849
+ `bash "${completeScript}" ${args}`,
850
+ {
851
+ cwd: IRIAI_TEAM_DIR, stdio: "pipe", timeout: 120000,
852
+ env: { ...process.env, PLAN_DIR: planDir, CONFIRM: "y" },
853
+ }
854
+ );
855
+ }
856
+ await this.adapter.postThreadMessage(feature.id,
857
+ `*[Pipeline]* Feature branches created. Launching implementation...`);
858
+ } else {
859
+ await this.adapter.postThreadMessage(feature.id,
860
+ `*[Pipeline]* No repos configured — skipping branch creation. Launching implementation...`);
861
+ }
862
+
863
+ const launchScript = path.join(SCRIPTS_DIR, "launch-feature.sh");
864
+ if (fs.existsSync(launchScript)) {
865
+ try {
866
+ execSync(`bash "${launchScript}" "${feature.slug}"`, {
867
+ cwd: IRIAI_TEAM_DIR, stdio: "pipe", timeout: 300000,
868
+ env: {
869
+ ...process.env,
870
+ IMPL_SIGNAL_BASE: IMPL_BASE,
871
+ SLACK_MODE: "true",
872
+ FEATURE_SLUG: feature.slug,
873
+ IMPL_CHANNEL: feature.feature_channel || "",
874
+ PLAN_DIR: planDir,
875
+ ROLES_DIR: V3_ROLES_DIR,
876
+ },
877
+ });
878
+ } catch (launchErr) {
879
+ console.error("[orchestrator] launch-feature.sh failed:", launchErr.message);
880
+ const stderr = launchErr.stderr ? launchErr.stderr.toString().slice(-500) : launchErr.message;
881
+ await this.adapter.postThreadMessage(feature.id,
882
+ `*[Pipeline]* Error setting up implementation:\n\`\`\`${stderr}\`\`\``);
883
+ meta.awaiting_repo_confirm = true;
884
+ queries.updateFeatureMetadata(featureId, meta);
885
+ return;
886
+ }
887
+ }
888
+
889
+ // Launch implementation agents
890
+ this.launchImplAgents(featureId);
891
+
892
+ db.transaction(() => {
893
+ queries.updateFeaturePhase(featureId, "impl");
894
+ });
895
+
896
+ queries.insertEvent(featureId, "phase-transition", "bridge", "Implementation launched");
897
+ } catch (err) {
898
+ const stderr = err.stderr ? err.stderr.toString().slice(-500) : err.message;
899
+ await this.adapter.postThreadMessage(feature.id,
900
+ `*[Pipeline]* Error creating branches:\n\`\`\`${stderr}\`\`\`\nRetry by replying "go" again.`);
901
+ meta.awaiting_repo_confirm = true;
902
+ queries.updateFeatureMetadata(featureId, meta);
903
+ }
904
+ }
905
+
906
+ // ═══════════════════════════════════════════════════════════════════════════
907
+ // IMPLEMENTATION PIPELINE
908
+ // ═══════════════════════════════════════════════════════════════════════════
909
+
910
+ /**
911
+ * Discover the signal tree for a feature slug.
912
+ */
913
+ discoverSignalTree(slug) {
914
+ const featureDir = path.join(IMPL_BASE, "features", slug);
915
+ const tree = {
916
+ featureDir,
917
+ featureLead: null,
918
+ operator: null,
919
+ featureReview: {},
920
+ teams: {},
921
+ planning: {},
922
+ plansDir: null,
923
+ };
924
+
925
+ const flDir = path.join(featureDir, "feature-lead");
926
+ if (fs.existsSync(flDir)) tree.featureLead = flDir;
927
+
928
+ const opDir = path.join(featureDir, "operator");
929
+ if (fs.existsSync(opDir)) tree.operator = opDir;
930
+
931
+ const reviewDir = path.join(featureDir, "feature-review");
932
+ if (fs.existsSync(reviewDir)) {
933
+ try {
934
+ for (const entry of fs.readdirSync(reviewDir, { withFileTypes: true })) {
935
+ if (entry.isDirectory()) {
936
+ tree.featureReview[entry.name] = path.join(reviewDir, entry.name);
937
+ }
938
+ }
939
+ } catch { /* ok */ }
940
+ }
941
+
942
+ const teamsDir = path.join(featureDir, "teams");
943
+ if (fs.existsSync(teamsDir)) {
944
+ try {
945
+ for (const entry of fs.readdirSync(teamsDir, { withFileTypes: true })) {
946
+ if (!entry.isDirectory() || !entry.name.startsWith("team-")) continue;
947
+ const teamNum = entry.name.replace("team-", "");
948
+ const teamDir = path.join(teamsDir, entry.name);
949
+ const orchDir = path.join(teamDir, "orchestrator");
950
+ const roles = {};
951
+ const rolesDir = path.join(teamDir, "roles");
952
+ if (fs.existsSync(rolesDir)) {
953
+ for (const roleEntry of fs.readdirSync(rolesDir, { withFileTypes: true })) {
954
+ if (roleEntry.isDirectory()) {
955
+ roles[roleEntry.name] = path.join(rolesDir, roleEntry.name);
956
+ }
957
+ }
958
+ }
959
+ tree.teams[teamNum] = {
960
+ dir: teamDir,
961
+ orchestrator: fs.existsSync(orchDir) ? orchDir : null,
962
+ roles,
963
+ };
964
+ }
965
+ } catch { /* ok */ }
966
+ }
967
+
968
+ // Planning dirs
969
+ const planningDir = path.join(featureDir, "planning");
970
+ if (fs.existsSync(planningDir)) {
971
+ for (const role of PLANNING_ROLES) {
972
+ const roleDir = path.join(planningDir, role);
973
+ if (fs.existsSync(roleDir)) tree.planning[role] = roleDir;
974
+ }
975
+ }
976
+
977
+ const plansDir = path.join(featureDir, "plans");
978
+ if (fs.existsSync(plansDir)) tree.plansDir = plansDir;
979
+
980
+ this._signalTrees[slug] = tree;
981
+ return tree;
982
+ }
983
+
984
+ /**
985
+ * Create per-team worktrees from the feature-level repos.
986
+ * For each repo at .features/<slug>/repos/<basename>/ (on feature/<slug> branch),
987
+ * creates .features/<slug>/teams/<teamNum>/repos/<basename>/ on feature/<slug>/team-<teamNum> branch.
988
+ */
989
+ _createTeamWorktrees(feature, numTeams) {
990
+ const featuresBase = path.join(process.env.HOME, "src/iriai/.features", feature.slug);
991
+ const reposDir = path.join(featuresBase, "repos");
992
+ if (!fs.existsSync(reposDir)) return;
993
+
994
+ const repos = fs.readdirSync(reposDir).filter(name => {
995
+ const repoPath = path.join(reposDir, name);
996
+ // Worktrees have a .git file (not directory), regular repos have .git directory
997
+ return fs.statSync(repoPath).isDirectory() && fs.existsSync(path.join(repoPath, ".git"));
998
+ });
999
+
1000
+ if (repos.length === 0) return;
1001
+
1002
+ const featureBranch = `feature/${feature.slug}`;
1003
+
1004
+ for (let teamNum = 1; teamNum <= numTeams; teamNum++) {
1005
+ const teamReposDir = path.join(featuresBase, "teams", String(teamNum), "repos");
1006
+ ensureDir(teamReposDir);
1007
+
1008
+ for (const repoName of repos) {
1009
+ const featureRepoPath = path.join(reposDir, repoName);
1010
+ const teamWorktreeDest = path.join(teamReposDir, repoName);
1011
+ const teamBranch = `${featureBranch}/team-${teamNum}`;
1012
+
1013
+ // Skip if team worktree already exists
1014
+ if (fs.existsSync(teamWorktreeDest)) continue;
1015
+
1016
+ try {
1017
+ // Create team branch from feature branch if it doesn't exist
1018
+ try {
1019
+ execSync(`git -C "${featureRepoPath}" branch "${teamBranch}" "${featureBranch}" 2>/dev/null || true`, { stdio: "pipe" });
1020
+ } catch { /* branch may already exist */ }
1021
+
1022
+ // Create worktree for this team
1023
+ execSync(
1024
+ `git -C "${featureRepoPath}" worktree add "${teamWorktreeDest}" "${teamBranch}"`,
1025
+ { stdio: "pipe", timeout: 30000 }
1026
+ );
1027
+ console.log(`[orchestrator] Created team worktree: ${repoName} → team-${teamNum} (${teamBranch})`);
1028
+ } catch (err) {
1029
+ // Try with -b flag as fallback
1030
+ try {
1031
+ execSync(
1032
+ `git -C "${featureRepoPath}" worktree add "${teamWorktreeDest}" -b "${teamBranch}" 2>/dev/null || git -C "${featureRepoPath}" worktree add "${teamWorktreeDest}" "${teamBranch}"`,
1033
+ { stdio: "pipe", timeout: 30000 }
1034
+ );
1035
+ console.log(`[orchestrator] Created team worktree (fallback): ${repoName} → team-${teamNum}`);
1036
+ } catch (err2) {
1037
+ console.error(`[orchestrator] Failed to create team worktree for ${repoName} team-${teamNum}:`, err2.message);
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ /**
1045
+ * Launch all implementation agents for a feature.
1046
+ */
1047
+ launchImplAgents(featureId) {
1048
+ const feature = queries.getFeatureById(featureId);
1049
+ if (!feature) return;
1050
+
1051
+ const tree = this.discoverSignalTree(feature.slug);
1052
+ const numTeams = Object.keys(tree.teams).length || feature.num_teams;
1053
+ const worktreeDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug);
1054
+ const featureCwd = fs.existsSync(worktreeDir) ? worktreeDir : IRIAI_TEAM_DIR;
1055
+
1056
+ // Create per-team worktrees so each team works on its own branch
1057
+ this._createTeamWorktrees(feature, numTeams);
1058
+
1059
+ // Feature Lead
1060
+ if (tree.featureLead) {
1061
+ let flAgent = queries.getAgentByKey(`fl-${feature.slug}`);
1062
+ if (!flAgent) {
1063
+ flAgent = queries.createAgent({
1064
+ featureId,
1065
+ agentType: "feature-lead",
1066
+ agentKey: `fl-${feature.slug}`,
1067
+ roleName: "feature-lead",
1068
+ signalDir: tree.featureLead,
1069
+ cwd: featureCwd,
1070
+ maxRetries: MAX_FL_RETRIES,
1071
+ });
1072
+ } else {
1073
+ this._syncAgentRetries(flAgent, MAX_FL_RETRIES);
1074
+ }
1075
+
1076
+ // Use "refresh" mode if FEATURE-STATUS.md exists (recovery/restart scenario)
1077
+ const featureStatusPath = path.join(tree.featureDir, "FEATURE-STATUS.md");
1078
+ const flMode = fs.existsSync(featureStatusPath) ? "refresh" : "init";
1079
+ // On recovery: if FL has prior session and no .handover, use --continue
1080
+ const flHasHandover = fs.existsSync(path.join(tree.featureLead, SIGNAL.HANDOVER));
1081
+ const flHasSession = flAgent.started_at != null;
1082
+ const flContinue = flHasSession && !flHasHandover && flMode === "refresh";
1083
+ this._spawnFeatureLead(flAgent.id, feature, tree, flMode, { continue: flContinue });
1084
+ }
1085
+
1086
+ // Operator
1087
+ if (tree.operator) {
1088
+ let opAgent = queries.getAgentByKey(`op-${feature.slug}`);
1089
+ if (!opAgent) {
1090
+ opAgent = queries.createAgent({
1091
+ featureId,
1092
+ agentType: "operator",
1093
+ agentKey: `op-${feature.slug}`,
1094
+ roleName: "operator",
1095
+ signalDir: tree.operator,
1096
+ cwd: featureCwd,
1097
+ maxRetries: MAX_OPERATOR_RETRIES,
1098
+ });
1099
+ } else {
1100
+ this._syncAgentRetries(opAgent, MAX_OPERATOR_RETRIES);
1101
+ }
1102
+ // Operator is reactive — spawned by routeUserMessage
1103
+ queries.updateAgentStatus(opAgent.id, "idle");
1104
+ }
1105
+
1106
+ // Team Orchestrators
1107
+ for (const [teamNum, team] of Object.entries(tree.teams)) {
1108
+ // Per-team CWD: use team-specific worktree if it exists, else fall back to feature-level
1109
+ const teamWorktreeDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams", teamNum, "repos");
1110
+ const teamCwd = fs.existsSync(teamWorktreeDir) ? path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams", teamNum) : featureCwd;
1111
+
1112
+ if (team.orchestrator) {
1113
+ const orchKey = `orch-${feature.slug}-${teamNum}`;
1114
+ let orchAgent = queries.getAgentByKey(orchKey);
1115
+ if (!orchAgent) {
1116
+ orchAgent = queries.createAgent({
1117
+ featureId,
1118
+ agentType: "team-orchestrator",
1119
+ agentKey: orchKey,
1120
+ teamNum,
1121
+ signalDir: team.orchestrator,
1122
+ cwd: teamCwd,
1123
+ maxRetries: MAX_ORCH_RETRIES,
1124
+ });
1125
+ } else {
1126
+ this._syncAgentRetries(orchAgent, MAX_ORCH_RETRIES);
1127
+ }
1128
+ // Orchestrator waits for .task — no auto-start
1129
+ }
1130
+
1131
+ // Role Agents
1132
+ for (const [role, roleDir] of Object.entries(team.roles)) {
1133
+ const roleKey = `role-${feature.slug}-${teamNum}-${role}`;
1134
+ let roleAgent = queries.getAgentByKey(roleKey);
1135
+ if (!roleAgent) {
1136
+ roleAgent = queries.createAgent({
1137
+ featureId,
1138
+ agentType: "role-agent",
1139
+ agentKey: roleKey,
1140
+ roleName: role,
1141
+ teamNum,
1142
+ signalDir: roleDir,
1143
+ cwd: teamCwd,
1144
+ maxRetries: MAX_ROLE_RETRIES,
1145
+ });
1146
+ } else {
1147
+ this._syncAgentRetries(roleAgent, MAX_ROLE_RETRIES);
1148
+ }
1149
+ // Role waits for .task — no auto-start
1150
+ }
1151
+ }
1152
+
1153
+ // Feature Review Roles
1154
+ for (const [role, reviewDir] of Object.entries(tree.featureReview)) {
1155
+ const reviewKey = `review-${feature.slug}-${role}`;
1156
+ let reviewAgent = queries.getAgentByKey(reviewKey);
1157
+ if (!reviewAgent) {
1158
+ reviewAgent = queries.createAgent({
1159
+ featureId,
1160
+ agentType: "review-agent",
1161
+ agentKey: reviewKey,
1162
+ roleName: role,
1163
+ signalDir: reviewDir,
1164
+ cwd: featureCwd,
1165
+ maxRetries: MAX_ROLE_RETRIES,
1166
+ });
1167
+ } else {
1168
+ this._syncAgentRetries(reviewAgent, MAX_ROLE_RETRIES);
1169
+ }
1170
+ }
1171
+
1172
+ // Watch signals
1173
+ this.fileIO.watchFeatureSignals(feature.slug, tree);
1174
+
1175
+ console.log(`[orchestrator] Launched impl agents for ${feature.slug}: FL + operator + ${numTeams} teams`);
1176
+ }
1177
+
1178
+ /**
1179
+ * Sync max_retries from current constants to existing DB agent records.
1180
+ */
1181
+ _syncAgentRetries(agent, expectedMaxRetries) {
1182
+ if (agent.max_retries !== expectedMaxRetries) {
1183
+ queries.updateAgentMaxRetries(agent.id, expectedMaxRetries);
1184
+ }
1185
+ // Reset crashed agents so they can be retried
1186
+ if (agent.status === "crashed") {
1187
+ queries.updateAgentStatus(agent.id, "idle");
1188
+ queries.resetAgentRetry(agent.id);
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * Spawn (or re-spawn) the Feature Lead agent.
1194
+ */
1195
+ _spawnFeatureLead(agentId, feature, tree, mode, opts = {}) {
1196
+ const numTeams = Object.keys(tree.teams).length || feature.num_teams;
1197
+ const teamSignalBase = path.join(tree.featureDir, "teams");
1198
+ const featureReviewDir = path.join(tree.featureDir, "feature-review");
1199
+
1200
+ // Resolve plan read instruction
1201
+ const planReadInstruction = this._resolvePlanInstruction(tree.featureLead, tree.featureDir);
1202
+
1203
+ let prompt;
1204
+ switch (mode) {
1205
+ case "init":
1206
+ prompt = buildFeatureLeadInitPrompt({
1207
+ featureName: feature.slug,
1208
+ numTeams,
1209
+ teamType: "dynamic",
1210
+ teamSignalBase,
1211
+ planReadInstruction,
1212
+ featureLeadDir: tree.featureLead,
1213
+ featureReviewDir,
1214
+ dashboardLog: path.join(tree.featureDir, ".dashboard-log"),
1215
+ featureDir: tree.featureDir,
1216
+ teamWorktreeBase: path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams"),
1217
+ });
1218
+ break;
1219
+
1220
+ case "refresh":
1221
+ prompt = buildFeatureLeadRefreshPrompt({
1222
+ featureName: feature.slug,
1223
+ numTeams,
1224
+ teamSignalBase,
1225
+ planReadInstruction,
1226
+ featureLeadDir: tree.featureLead,
1227
+ featureReviewDir,
1228
+ dashboardLog: path.join(tree.featureDir, ".dashboard-log"),
1229
+ gateEvidenceTs: feature.gate_evidence_ts,
1230
+ featureDir: tree.featureDir,
1231
+ teamWorktreeBase: path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams"),
1232
+ });
1233
+ break;
1234
+
1235
+ case "trigger":
1236
+ prompt = buildFeatureLeadTriggerPrompt({
1237
+ featureName: feature.slug,
1238
+ numTeams,
1239
+ trigger: opts.trigger,
1240
+ teamSignalBase,
1241
+ featureLeadDir: tree.featureLead,
1242
+ featureReviewDir,
1243
+ dashboardLog: path.join(tree.featureDir, ".dashboard-log"),
1244
+ questionTeams: opts.questionTeams,
1245
+ crashedTeams: opts.crashedTeams,
1246
+ recoveryContext: opts.recoveryContext,
1247
+ featureDir: tree.featureDir,
1248
+ teamWorktreeBase: path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams"),
1249
+ });
1250
+ break;
1251
+ }
1252
+
1253
+ // Prepend handover content if available
1254
+ if (opts.handoverContent) {
1255
+ prompt = `HANDOVER CONTEXT FROM PREVIOUS SESSION:\n${opts.handoverContent}\n\n---\n\n${prompt}`;
1256
+ }
1257
+
1258
+ // Use --continue if FL has prior session and this isn't a fresh start (handover/refresh)
1259
+ // Handover = context exhausted, must start fresh. Trigger = can continue prior conversation.
1260
+ const useContinue = opts.continue !== undefined
1261
+ ? opts.continue
1262
+ : (!opts.handoverContent && mode === "trigger");
1263
+ this.supervisor.spawn(agentId, prompt, { continue: useContinue });
1264
+ queries.insertEvent(feature.id, "agent-started", "bridge", `Feature Lead spawned (${mode})`);
1265
+ }
1266
+
1267
+ _resolvePlanInstruction(featureLeadDir, featureDir) {
1268
+ // Check for .feature-proposal with custom plan paths
1269
+ const proposalPath = path.join(featureLeadDir, ".feature-proposal");
1270
+ if (fs.existsSync(proposalPath)) {
1271
+ const content = fs.readFileSync(proposalPath, "utf-8");
1272
+ const planMatch = content.match(/plan_path:\s*(.+)/);
1273
+ if (planMatch) return `Read the implementation plan at: ${planMatch[1].trim()}`;
1274
+ }
1275
+ // Check per-feature plans dir first
1276
+ const perFeaturePlansDir = path.join(featureDir, "plans");
1277
+ if (fs.existsSync(perFeaturePlansDir)) {
1278
+ return `Read the implementation plan in ${perFeaturePlansDir}/`;
1279
+ }
1280
+ return `Read the implementation plan in ~/src/iriai/iriai-team/implementation-plans/current/`;
1281
+ }
1282
+
1283
+ // ─── Gate Handling ──────────────────────────────────────────────────────
1284
+
1285
+ async handleGateApproval(featureId) {
1286
+ const feature = queries.getFeatureById(featureId);
1287
+ if (!feature) return;
1288
+ const tree = this._signalTrees[feature.slug];
1289
+ if (!tree?.featureLead) return;
1290
+
1291
+ writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE),
1292
+ "GATE APPROVED. Advance all teams to the next phase.");
1293
+
1294
+ queries.updateFeatureGateEvidenceTs(featureId, null);
1295
+ queries.updateFeatureGate(featureId, (feature.gate_number || 0) + 1);
1296
+ queries.insertEvent(featureId, "gate-approved", "bridge", `Gate ${(feature.gate_number || 0) + 1} approved by user`);
1297
+ }
1298
+
1299
+ async handleGateRejection(featureId, reason) {
1300
+ const feature = queries.getFeatureById(featureId);
1301
+ if (!feature) return;
1302
+ const tree = this._signalTrees[feature.slug];
1303
+ if (!tree?.featureLead) return;
1304
+
1305
+ writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE),
1306
+ `GATE REJECTED: ${reason || "Please revise and resubmit."}`);
1307
+
1308
+ queries.updateFeatureGateEvidenceTs(featureId, null);
1309
+ queries.insertEvent(featureId, "gate-rejected", "bridge", `Gate rejected: ${reason}`);
1310
+ }
1311
+
1312
+ // ─── Decision Click Routing (Block Kit Buttons) ──────────────────────
1313
+
1314
+ async handleDecisionClick(decisionId, optionId, userId, channel, messageTs, feedback = "") {
1315
+ // Collect annotations from review session and enrich rejection feedback
1316
+ let enrichedFeedback = feedback;
1317
+ if (this.reviewSessions && optionId !== "approve") {
1318
+ const annotations = await this.reviewSessions.collectFeedback(decisionId);
1319
+ if (annotations && annotations.length > 0) {
1320
+ enrichedFeedback = formatAnnotationsAsFeedback(annotations, feedback);
1321
+ }
1322
+ }
1323
+
1324
+ // Phase review: "phase-review-<slug>-<role>" (or legacy "phase-review-<role>")
1325
+ if (decisionId.startsWith("phase-review-")) {
1326
+ const suffix = decisionId.replace("phase-review-", "");
1327
+ const features = queries.getActiveFeatures();
1328
+
1329
+ // New format: "phase-review-<slug>-<role>" — find feature by slug embedded in the ID
1330
+ let feature = features.find(f => {
1331
+ const m = queries.getFeatureMetadata(f.id);
1332
+ if (!m.awaiting_phase_review) return false;
1333
+ return suffix === `${f.slug}-${m.phase_review_role}`;
1334
+ });
1335
+
1336
+ // Legacy fallback: "phase-review-<role>" — disambiguate by channel
1337
+ if (!feature) {
1338
+ const role = suffix;
1339
+ feature = features.find(f => {
1340
+ const m = queries.getFeatureMetadata(f.id);
1341
+ if (!m.awaiting_phase_review || m.phase_review_role !== role) return false;
1342
+ const fChannel = f.feature_channel || this.adapter.planningChannel;
1343
+ return fChannel === channel;
1344
+ });
1345
+ }
1346
+ if (!feature) return;
1347
+
1348
+ if (optionId === "approve") {
1349
+ await this.handlePhaseReviewApproval(feature.id);
1350
+ } else {
1351
+ await this.handlePhaseReviewRejection(feature.id, enrichedFeedback || "Rejected via button");
1352
+ }
1353
+ await this._resolveDecisionMessage(feature.id, messageTs, decisionId, optionId, userId, feedback);
1354
+ if (this.reviewSessions) {
1355
+ await this.reviewSessions.stop(decisionId);
1356
+ // Also stop mockup session if this was a designer review
1357
+ await this.reviewSessions.stop(`${decisionId}-mockup`);
1358
+ }
1359
+ return;
1360
+ }
1361
+
1362
+ // Plan approval: "plan-approval"
1363
+ if (decisionId === "plan-approval") {
1364
+ const features = queries.getActiveFeatures();
1365
+ // Use channel to disambiguate when multiple features are in plan-approval
1366
+ const feature = features.find(f => {
1367
+ if (f.phase !== "plan-approval") return false;
1368
+ const fChannel = f.feature_channel || this.adapter.planningChannel;
1369
+ return fChannel === channel;
1370
+ });
1371
+ if (!feature) return;
1372
+
1373
+ if (optionId === "approve") {
1374
+ await this.handlePlanApproval(feature.id);
1375
+ } else {
1376
+ await this.handlePlanRejection(feature.id, enrichedFeedback || "Rejected via button");
1377
+ }
1378
+ await this._resolveDecisionMessage(feature.id, messageTs, decisionId, optionId, userId, feedback);
1379
+ if (this.reviewSessions) await this.reviewSessions.stop(decisionId);
1380
+ return;
1381
+ }
1382
+
1383
+ // Gate approval: "gate-*"
1384
+ if (decisionId.startsWith("gate-")) {
1385
+ const features = queries.getActiveFeatures();
1386
+ const feature = features.find(f => f.feature_channel === channel && f.gate_evidence_ts);
1387
+ if (!feature) return;
1388
+
1389
+ if (optionId === "approve") {
1390
+ await this.handleGateApproval(feature.id);
1391
+ } else {
1392
+ await this.handleGateRejection(feature.id, enrichedFeedback || "Rejected via button");
1393
+ }
1394
+ await this._resolveDecisionMessage(feature.id, messageTs, decisionId, optionId, userId, feedback);
1395
+ if (this.reviewSessions) await this.reviewSessions.stop(decisionId);
1396
+ return;
1397
+ }
1398
+
1399
+ // Custom [DECISION] — resolve in DB, notify agent
1400
+ // Use slack_channel stored on the decision to match the correct feature
1401
+ const decision = queries.getDecisionByChannel(channel, decisionId);
1402
+ if (decision) {
1403
+ queries.resolveDecision(decision.feature_id, decisionId, { selectedOption: optionId, resolvedBy: userId });
1404
+ await this._resolveDecisionMessage(decision.feature_id, messageTs, decisionId, optionId, userId, feedback);
1405
+ // Write decision result to operator for relay to active agent
1406
+ const feature = queries.getFeatureById(decision.feature_id);
1407
+ if (feature) {
1408
+ const tree = this._signalTrees[feature.slug];
1409
+ const targetDir = tree?.operator || tree?.featureLead;
1410
+ if (targetDir) {
1411
+ const options = JSON.parse(decision.options || "[]");
1412
+ const option = options.find(o => o.id === optionId);
1413
+ const feedbackSuffix = feedback ? `\nFeedback: ${feedback}` : "";
1414
+ writeSignal(path.join(targetDir, SIGNAL.USER_MESSAGE),
1415
+ `Decision "${decision.title}": User selected "${option?.label || optionId}"${feedbackSuffix}`);
1416
+ }
1417
+ }
1418
+ return;
1419
+ }
1420
+ }
1421
+
1422
+ async _resolveDecisionMessage(featureId, messageTs, decisionId, selectedOptionId, userId, feedback = "") {
1423
+ if (!featureId) return;
1424
+
1425
+ // Resolve the human-readable label for the selected option.
1426
+ // First check DB (custom [DECISION] blocks are stored there), then fall back to defaults.
1427
+ let selectedLabel = selectedOptionId;
1428
+ const dbDecision = queries.getDecision(featureId, decisionId);
1429
+ if (dbDecision) {
1430
+ const options = JSON.parse(dbDecision.options || "[]");
1431
+ const match = options.find(o => o.id === selectedOptionId);
1432
+ if (match) selectedLabel = match.label;
1433
+ } else {
1434
+ // Built-in decisions (phase-review, plan-approval, gate) aren't in the DB
1435
+ const FALLBACK_LABELS = { approve: "Approve", reject: "Reject" };
1436
+ if (FALLBACK_LABELS[selectedOptionId]) {
1437
+ selectedLabel = FALLBACK_LABELS[selectedOptionId];
1438
+ }
1439
+ }
1440
+
1441
+ try {
1442
+ await this.adapter.resolveDecisionMessage(
1443
+ featureId, messageTs, decisionId, selectedOptionId, selectedLabel, userId, feedback
1444
+ );
1445
+ } catch (err) {
1446
+ console.error("[orchestrator] Failed to update decision message:", err.message);
1447
+ }
1448
+ }
1449
+
1450
+ // ─── User Message Routing ─────────────────────────────────────────────
1451
+
1452
+ async routeUserMessage(featureId, text) {
1453
+ const feature = queries.getFeatureById(featureId);
1454
+ if (!feature) return;
1455
+
1456
+ let enrichedText = text;
1457
+
1458
+ // If the user's message matches a pending decision option and there are
1459
+ // doc review annotations, enrich the message so the Operator has them.
1460
+ const trimmed = text.trim().toLowerCase();
1461
+ const pendingDecision = this._findPendingDecisionForFeature(feature);
1462
+ if (pendingDecision && this.reviewSessions) {
1463
+ const matchedOption = pendingDecision.options.find(
1464
+ o => o.id.toLowerCase() === trimmed
1465
+ );
1466
+ if (matchedOption) {
1467
+ const annotations = await this.reviewSessions.collectFeedback(pendingDecision.id);
1468
+ if (annotations && annotations.length > 0) {
1469
+ const formatted = formatAnnotationsAsFeedback(annotations, null);
1470
+ enrichedText = `${text}\n\n${formatted}`;
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ const tree = this._signalTrees[feature.slug];
1476
+
1477
+ // Route to operator, with FL fallback
1478
+ const targetDir = tree?.operator || tree?.featureLead;
1479
+ if (!targetDir) return;
1480
+ ensureDir(targetDir);
1481
+ writeSignal(path.join(targetDir, SIGNAL.USER_MESSAGE), enrichedText);
1482
+ }
1483
+
1484
+ /**
1485
+ * Find the active pending decision for a feature (phase review, plan approval, or gate).
1486
+ * Returns { id, options } or null.
1487
+ */
1488
+ _findPendingDecisionForFeature(feature) {
1489
+ const meta = queries.getFeatureMetadata(feature.id);
1490
+
1491
+ // Phase review
1492
+ if (meta.awaiting_phase_review && meta.phase_review_role) {
1493
+ return {
1494
+ id: `phase-review-${feature.slug}-${meta.phase_review_role}`,
1495
+ options: [
1496
+ { id: "approve", label: "Approve" },
1497
+ { id: "reject", label: "Request Revisions" },
1498
+ ],
1499
+ };
1500
+ }
1501
+
1502
+ // Plan approval
1503
+ if (feature.phase === "plan-approval") {
1504
+ return {
1505
+ id: "plan-approval",
1506
+ options: [
1507
+ { id: "approve", label: "Approve Plan" },
1508
+ { id: "reject", label: "Reject Plan" },
1509
+ ],
1510
+ };
1511
+ }
1512
+
1513
+ // Gate review
1514
+ if (feature.gate_evidence_ts) {
1515
+ return {
1516
+ id: `gate-${(feature.gate_number || 0) + 1}-review`,
1517
+ options: [
1518
+ { id: "approve", label: "Approve" },
1519
+ { id: "reject", label: "Reject" },
1520
+ ],
1521
+ };
1522
+ }
1523
+
1524
+ // Custom decision from DB
1525
+ const dbDecision = queries.getPendingDecision(feature.id);
1526
+ if (dbDecision) {
1527
+ return {
1528
+ id: dbDecision.decision_id,
1529
+ options: JSON.parse(dbDecision.options || "[]"),
1530
+ };
1531
+ }
1532
+
1533
+ return null;
1534
+ }
1535
+
1536
+ /**
1537
+ * Read the verification config from plan.yaml for a feature.
1538
+ * Returns { type, command, url } or null if not present.
1539
+ */
1540
+ _readVerificationConfig(feature) {
1541
+ try {
1542
+ const planYamlPath = path.join(feature.signal_dir, "plans", "plan.yaml");
1543
+ if (!fs.existsSync(planYamlPath)) return null;
1544
+ const content = fs.readFileSync(planYamlPath, "utf-8");
1545
+
1546
+ // Extract the verification block (simple YAML subset — no nested objects)
1547
+ const verMatch = content.match(/^verification:\s*\n((?:\s+\S.*\n?)*)/m);
1548
+ if (!verMatch) return null;
1549
+
1550
+ const block = verMatch[1];
1551
+ const type = block.match(/type:\s*(.+)/)?.[1]?.trim().replace(/["']/g, "") || null;
1552
+ const command = block.match(/command:\s*["']?(.+?)["']?\s*$/m)?.[1]?.trim() || null;
1553
+ const url = block.match(/url:\s*["']?(.+?)["']?\s*$/m)?.[1]?.trim() || null;
1554
+
1555
+ if (!type) return null;
1556
+ return { type, command, url };
1557
+ } catch {
1558
+ return null;
1559
+ }
1560
+ }
1561
+
1562
+ // ─── Worktree Management ────────────────────────────────────────────
1563
+
1564
+ /**
1565
+ * Handle .needs-repos signal from Operator — create git worktrees for requested repos.
1566
+ * Each repo path (from DIRECTORY_MAP) gets a worktree at .features/<slug>/repos/<basename>/
1567
+ */
1568
+ async _handleNeedsRepos(feature, content) {
1569
+ const lines = content.split("\n").map(l => l.trim()).filter(Boolean);
1570
+ if (lines.length === 0) return;
1571
+
1572
+ const reposBase = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "repos");
1573
+ ensureDir(reposBase);
1574
+
1575
+ const featureBranch = `feature/${feature.slug}`;
1576
+ const projectRoot = path.join(process.env.HOME, "src/iriai");
1577
+ const created = [];
1578
+ const scaffolded = [];
1579
+ const skipped = [];
1580
+ const failed = [];
1581
+
1582
+ // Separate new repos (+prefix) from existing repos
1583
+ const existingRepoPaths = [];
1584
+ const newRepoSpecs = [];
1585
+ for (const line of lines) {
1586
+ if (line.startsWith("+")) {
1587
+ // Format: +<localPath>:<githubName>[:<template>]
1588
+ const parts = line.slice(1).split(":");
1589
+ if (parts.length >= 2) {
1590
+ newRepoSpecs.push({
1591
+ localPath: parts[0],
1592
+ githubName: parts[1],
1593
+ template: parts[2] || null,
1594
+ });
1595
+ }
1596
+ } else {
1597
+ existingRepoPaths.push(line);
1598
+ }
1599
+ }
1600
+
1601
+ // Handle new repos: scaffold locally, then proceed to worktree like existing
1602
+ const meta = queries.getFeatureMetadata(feature.id);
1603
+ const existingNewRepos = meta.new_repos || [];
1604
+ const existingPaths = new Set(existingNewRepos.map(r => r.localPath));
1605
+
1606
+ for (const spec of newRepoSpecs) {
1607
+ const repoName = path.basename(spec.localPath);
1608
+ const worktreeDest = path.join(reposBase, repoName);
1609
+
1610
+ // Dedup: skip if already scaffolded in a prior .needs-repos call
1611
+ if (fs.existsSync(worktreeDest)) {
1612
+ skipped.push(repoName);
1613
+ continue;
1614
+ }
1615
+
1616
+ // Scaffold the repo locally (creates dir, template, git init)
1617
+ const ok = scaffoldNewRepo(spec.localPath, spec.githubName, spec.template);
1618
+ if (!ok) {
1619
+ failed.push(repoName);
1620
+ continue;
1621
+ }
1622
+ scaffolded.push(repoName);
1623
+
1624
+ // Store metadata for later use in handleRepoConfirmation
1625
+ if (!existingPaths.has(spec.localPath)) {
1626
+ existingNewRepos.push(spec);
1627
+ existingPaths.add(spec.localPath);
1628
+ }
1629
+
1630
+ // Now treat as an existing repo for worktree creation
1631
+ existingRepoPaths.push(spec.localPath);
1632
+ }
1633
+
1634
+ // Persist new_repos metadata
1635
+ if (newRepoSpecs.length > 0) {
1636
+ meta.new_repos = existingNewRepos;
1637
+ queries.updateFeatureMetadata(feature.id, meta);
1638
+ }
1639
+
1640
+ // Handle existing repos (+ newly scaffolded ones): create worktrees
1641
+ for (const repoPath of existingRepoPaths) {
1642
+ const repoAbsPath = path.join(projectRoot, repoPath);
1643
+ const repoName = path.basename(repoPath);
1644
+ const worktreeDest = path.join(reposBase, repoName);
1645
+
1646
+ // Skip if worktree already exists
1647
+ if (fs.existsSync(worktreeDest)) {
1648
+ if (!skipped.includes(repoName)) skipped.push(repoName);
1649
+ continue;
1650
+ }
1651
+
1652
+ // Validate the source repo exists
1653
+ if (!fs.existsSync(path.join(repoAbsPath, ".git"))) {
1654
+ console.warn(`[orchestrator] Repo not found: ${repoPath}`);
1655
+ if (!failed.includes(repoName)) failed.push(repoName);
1656
+ continue;
1657
+ }
1658
+
1659
+ try {
1660
+ // Create feature branch if it doesn't exist
1661
+ try {
1662
+ execSync(`git -C "${repoAbsPath}" branch "${featureBranch}" 2>/dev/null || true`, { stdio: "pipe" });
1663
+ } catch { /* branch may already exist */ }
1664
+
1665
+ // Create worktree
1666
+ execSync(
1667
+ `git -C "${repoAbsPath}" worktree add "${worktreeDest}" "${featureBranch}"`,
1668
+ { stdio: "pipe", timeout: 30000 }
1669
+ );
1670
+ created.push(repoName);
1671
+ } catch (err) {
1672
+ // Try checking out existing branch
1673
+ try {
1674
+ execSync(
1675
+ `git -C "${repoAbsPath}" worktree add "${worktreeDest}" -b "${featureBranch}" 2>/dev/null || git -C "${repoAbsPath}" worktree add "${worktreeDest}" "${featureBranch}"`,
1676
+ { stdio: "pipe", timeout: 30000 }
1677
+ );
1678
+ created.push(repoName);
1679
+ } catch (err2) {
1680
+ console.error(`[orchestrator] Failed to create worktree for ${repoPath}:`, err2.message);
1681
+ if (!failed.includes(repoName)) failed.push(repoName);
1682
+ }
1683
+ }
1684
+ }
1685
+
1686
+ // Update signal tree with repos dir
1687
+ const tree = this._signalTrees[feature.slug];
1688
+ if (tree) {
1689
+ tree.reposDir = reposBase;
1690
+ }
1691
+
1692
+ // Post confirmation to feature channel
1693
+ const parts = [];
1694
+ if (scaffolded.length > 0) parts.push(`Scaffolded new: ${scaffolded.map(r => `\`${r}\``).join(", ")}`);
1695
+ if (created.length > 0) parts.push(`Created worktrees: ${created.map(r => `\`${r}\``).join(", ")}`);
1696
+ if (skipped.length > 0) parts.push(`Already exist: ${skipped.map(r => `\`${r}\``).join(", ")}`);
1697
+ if (failed.length > 0) parts.push(`Failed: ${failed.map(r => `\`${r}\``).join(", ")}`);
1698
+
1699
+ await this.adapter.postPipelineMessage(feature.id,
1700
+ `Repos pulled in for planning.\n${parts.join("\n")}\nWorktrees at: \`.features/${feature.slug}/repos/\``);
1701
+
1702
+ queries.insertEvent(feature.id, "system", "bridge",
1703
+ `Repos pulled: ${[...scaffolded, ...created].join(", ")}${failed.length ? ` | failed: ${failed.join(", ")}` : ""}`);
1704
+
1705
+ console.log(`[orchestrator] ${feature.slug}: scaffolded=${scaffolded.length} created=${created.length} skipped=${skipped.length} failed=${failed.length}`);
1706
+ }
1707
+
1708
+ // ─── Feature Completion ──────────────────────────────────────────────
1709
+
1710
+ async _handleFeatureComplete(slug) {
1711
+ const feature = queries.getFeatureBySlug(slug);
1712
+ if (!feature) return;
1713
+
1714
+ await this.adapter.postFeatureComplete(feature.id);
1715
+
1716
+ db.transaction(() => {
1717
+ queries.updateFeaturePhase(feature.id, "complete");
1718
+ });
1719
+
1720
+ this.supervisor.killFeature(feature.id);
1721
+ this.fileIO.unwatchFeature(slug);
1722
+ queries.insertEvent(feature.id, "feature-complete", "bridge", "All gates passed");
1723
+ console.log(`[orchestrator] Feature ${slug} complete`);
1724
+ }
1725
+
1726
+ // ═══════════════════════════════════════════════════════════════════════════
1727
+ // FILE I/O SIGNAL HANDLERS
1728
+ // ═══════════════════════════════════════════════════════════════════════════
1729
+
1730
+ _setupFileIOHandlers() {
1731
+ // Planning signals — now per-feature with { slug, role, filePath }
1732
+ this.fileIO.on("planning:response", async ({ slug, role, filePath }) => {
1733
+ try {
1734
+ const content = readSignal(filePath, { deleteAfter: true });
1735
+ if (!content) return;
1736
+
1737
+ const label = ROLE_LABELS[role] || role;
1738
+ const feature = queries.getFeatureBySlug(slug);
1739
+ if (!feature) return;
1740
+
1741
+ // Route through operator relay instead of posting directly
1742
+ await this._enqueueForOperatorRelay(feature, label, "planning-response", content);
1743
+ queries.insertEvent(feature.id, "agent-response", `agent:${role}`, content);
1744
+ } catch (err) {
1745
+ console.error("[orchestrator] Planning response error:", err.message);
1746
+ }
1747
+ });
1748
+
1749
+ this.fileIO.on("planning:question", async ({ slug, role, filePath }) => {
1750
+ try {
1751
+ const content = readSignal(filePath, { deleteAfter: true });
1752
+ if (!content) return;
1753
+
1754
+ const label = ROLE_LABELS[role] || role;
1755
+ const feature = queries.getFeatureBySlug(slug);
1756
+ if (!feature) return;
1757
+
1758
+ // Route through operator relay instead of posting directly
1759
+ const questionMsg = `*${feature.slug} / ${feature.active_planning_role} phase*\n\n${content}`;
1760
+ await this._enqueueForOperatorRelay(feature, label, "planning-question", questionMsg);
1761
+ queries.insertEvent(feature.id, "question", `agent:${role}`, content);
1762
+ } catch (err) {
1763
+ console.error("[orchestrator] Planning question error:", err.message);
1764
+ }
1765
+ });
1766
+
1767
+ this.fileIO.on("planning:done", async ({ slug, role, filePath }) => {
1768
+ try {
1769
+ await this._handlePlanningDone(slug, role, filePath);
1770
+ } catch (err) {
1771
+ console.error("[orchestrator] Planning done error:", err.message);
1772
+ }
1773
+ });
1774
+
1775
+ // Implementation signals — FL .agent-response routed through Operator relay
1776
+ this.fileIO.on("impl:response", async ({ slug, agent, filePath }) => {
1777
+ try {
1778
+ const content = readSignal(filePath, { deleteAfter: true });
1779
+ if (!content) return;
1780
+ const feature = queries.getFeatureBySlug(slug);
1781
+ if (!feature) return;
1782
+ await this._enqueueForOperatorRelay(feature, "Feature Lead", "fl-response", content);
1783
+ } catch (err) {
1784
+ console.error("[orchestrator] Impl response error:", err.message);
1785
+ }
1786
+ });
1787
+
1788
+ // impl:operatorResponse — SOLE path to user for impl-phase agent output
1789
+ this.fileIO.on("impl:operatorResponse", async ({ slug, filePath }) => {
1790
+ try {
1791
+ const content = readSignal(filePath, { deleteAfter: true });
1792
+ if (!content) return;
1793
+ const feature = queries.getFeatureBySlug(slug);
1794
+ if (!feature) return;
1795
+
1796
+ // Parse structured blocks from Operator output
1797
+ const parsed = parseOperatorResponse(content);
1798
+
1799
+ // Post the text content (with [DECISION] blocks stripped via parsed.plainText)
1800
+ const textContent = parsed.plainText || content;
1801
+ await this.adapter.postAgentResponse(feature.id, "Operator", textContent);
1802
+
1803
+ // Handle [DECISION] blocks — render as separate decision prompts.
1804
+ // Skip any that duplicate a deferred decision (already queued by _requestPhaseReview).
1805
+ for (const decision of parsed.decisions) {
1806
+ if (this._deferredDecisions[feature.id]) {
1807
+ const deferred = this._deferredDecisions[feature.id].decision;
1808
+ if (deferred.id === decision.id) continue;
1809
+ }
1810
+ const options = decision.options.map(o => ({
1811
+ id: o.id,
1812
+ label: o.label,
1813
+ style: o.style || "default",
1814
+ description: o.description || "",
1815
+ }));
1816
+
1817
+ // Start review session for gate decisions
1818
+ let reviewUrl = null;
1819
+ let qaUrl = null;
1820
+ if (this.reviewSessions && decision.type === "approval" && decision.id.startsWith("gate-")) {
1821
+ // Look for gate evidence HTML
1822
+ const evidencePath = path.join(feature.signal_dir, "feature-lead", ".gate-evidence.html");
1823
+ if (fs.existsSync(evidencePath)) {
1824
+ reviewUrl = await this.reviewSessions.startDocReview(
1825
+ decision.id, evidencePath, `Gate Evidence — ${decision.title}`, { featureId: feature.id }
1826
+ );
1827
+ }
1828
+
1829
+ // Read verification config from plan.yaml — start QA session if local-server
1830
+ const verification = this._readVerificationConfig(feature);
1831
+ if (verification && verification.type === "local-server" && verification.url) {
1832
+ if (verification.command) {
1833
+ // Start the dev server in the background
1834
+ try {
1835
+ const featureReposDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "repos");
1836
+ const proc = cpSpawn("sh", ["-c", verification.command], {
1837
+ cwd: featureReposDir,
1838
+ stdio: "ignore",
1839
+ detached: true,
1840
+ });
1841
+ proc.unref();
1842
+ // Brief wait for server startup
1843
+ await new Promise(r => setTimeout(r, 3000));
1844
+ } catch (err) {
1845
+ console.error(`[orchestrator] Failed to start dev server for gate review:`, err.message);
1846
+ }
1847
+ }
1848
+ qaUrl = await this.reviewSessions.startQaSession(
1849
+ decision.id, verification.url, { featureId: feature.id }
1850
+ );
1851
+ }
1852
+ }
1853
+
1854
+ const decResult = await this.adapter.postDecision(feature.id, {
1855
+ id: decision.id,
1856
+ title: decision.title,
1857
+ context: decision.context,
1858
+ type: decision.type || "approval",
1859
+ options,
1860
+ reviewUrl,
1861
+ qaUrl,
1862
+
1863
+ });
1864
+
1865
+ // Store in DB
1866
+ queries.createDecision({
1867
+ featureId: feature.id,
1868
+ decisionId: decision.id,
1869
+ decisionType: decision.type || "approval",
1870
+ title: decision.title,
1871
+ contextText: decision.context || null,
1872
+ options,
1873
+ });
1874
+
1875
+ // Store adapter-specific ref. Channel resolved from feature record.
1876
+ const decChannel = feature.feature_channel || this.adapter.planningChannel;
1877
+ queries.updateDecisionSlack(feature.id, decision.id, {
1878
+ slackTs: decResult?.ref || null,
1879
+ slackChannel: decChannel || null,
1880
+ permalink: null,
1881
+ });
1882
+
1883
+ // Gate decisions: set gate_evidence_ts
1884
+ if (decision.type === "approval" && decision.id.startsWith("gate-")) {
1885
+ queries.updateFeatureGateEvidenceTs(feature.id, decResult?.ref || null);
1886
+ }
1887
+ }
1888
+
1889
+ // Handle [RESOLVE_DECISION] blocks — Operator resolved a decision conversationally
1890
+ for (const resolution of parsed.resolutions) {
1891
+ const channel = feature.feature_channel || feature.slug;
1892
+ const messageTs = `operator-${Date.now()}`;
1893
+ try {
1894
+ await this.handleDecisionClick(
1895
+ resolution.id, resolution.option, "operator",
1896
+ channel, messageTs, resolution.feedback || ""
1897
+ );
1898
+ } catch (err) {
1899
+ console.error(`[orchestrator] Decision resolution error (${resolution.id}):`, err.message);
1900
+ }
1901
+ }
1902
+
1903
+ // Handle [ROUTE:agent_key] blocks
1904
+ for (const route of parsed.routes) {
1905
+ const targetAgent = queries.getAgentByKey(route.agentKey);
1906
+ if (targetAgent) {
1907
+ ensureDir(targetAgent.signal_dir);
1908
+ writeSignal(path.join(targetAgent.signal_dir, SIGNAL.USER_MESSAGE), route.content);
1909
+ }
1910
+ }
1911
+
1912
+ // Resolve any pending relay waiter
1913
+ this._resolveRelayWaiter(feature.id);
1914
+ } catch (err) {
1915
+ console.error("[orchestrator] Operator response error:", err.message);
1916
+ }
1917
+ });
1918
+
1919
+ this.fileIO.on("impl:featureComplete", async ({ slug }) => {
1920
+ await this._handleFeatureComplete(slug);
1921
+ });
1922
+
1923
+ this.fileIO.on("impl:phaseDone", ({ slug, filePath }) => {
1924
+ // FL phase done — read signal and clean up
1925
+ try { fs.unlinkSync(filePath); } catch { /* ok */ }
1926
+ // The FL exit handler will spawn the next phase
1927
+ });
1928
+
1929
+ this.fileIO.on("impl:contextRefresh", ({ slug, filePath }) => {
1930
+ // FL context refresh — handled by supervisor exit handler
1931
+ try { fs.unlinkSync(filePath); } catch { /* ok */ }
1932
+ });
1933
+
1934
+ this.fileIO.on("impl:needsRestart", async ({ slug, dir, filePath }) => {
1935
+ try {
1936
+ const feature = queries.getFeatureBySlug(slug);
1937
+ if (!feature) return;
1938
+ // Find agent by signal dir
1939
+ const agents = queries.getAgentsByFeature(feature.id);
1940
+ const agent = agents.find(a => a.signal_dir === dir);
1941
+ const agentLabel = agent?.role_name || "Agent";
1942
+
1943
+ await this.adapter.postPipelineMessage(feature.id,
1944
+ `${agentLabel} restarting (context limit). Work preserved.`);
1945
+ queries.insertEvent(feature.id, "system", "bridge", `${agentLabel} needs restart`);
1946
+
1947
+ // For planning roles: kill the agent so exit handler triggers retry with handover
1948
+ if (agent && agent.agent_type === "planning-role") {
1949
+ try { fs.unlinkSync(filePath); } catch { /* ok */ }
1950
+ this.supervisor.kill(agent.agent_key);
1951
+ // The exit handler (_handlePlanningRoleExit) will detect no .done and call
1952
+ // scheduleRetry, which rebuilds the prompt with .handover file reference.
1953
+ }
1954
+ } catch (err) {
1955
+ console.error("[orchestrator] Needs restart error:", err.message);
1956
+ }
1957
+ });
1958
+
1959
+ // Gate-ready: check if ALL teams are ready
1960
+ this.fileIO.on("impl:gateReady", ({ slug, teamNum }) => {
1961
+ const tree = this._signalTrees[slug];
1962
+ if (!tree) return;
1963
+
1964
+ const allReady = Object.keys(tree.teams).every(tn =>
1965
+ fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.GATE_READY))
1966
+ );
1967
+
1968
+ if (allReady) {
1969
+ console.log(`[orchestrator] All teams gate-ready for ${slug}`);
1970
+ this._triggerFeatureLead(slug, "gate-ready");
1971
+ }
1972
+ });
1973
+
1974
+ this.fileIO.on("impl:question", ({ slug, teamNum }) => {
1975
+ const tree = this._signalTrees[slug];
1976
+ if (!tree) return;
1977
+
1978
+ const questionTeams = Object.keys(tree.teams).filter(tn =>
1979
+ fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.QUESTION))
1980
+ );
1981
+ if (questionTeams.length > 0) {
1982
+ this._triggerFeatureLead(slug, "question", { questionTeams });
1983
+ }
1984
+ });
1985
+
1986
+ this.fileIO.on("impl:crashed", ({ slug, teamNum }) => {
1987
+ const tree = this._signalTrees[slug];
1988
+ if (!tree) return;
1989
+
1990
+ const crashedTeams = Object.keys(tree.teams).filter(tn =>
1991
+ fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.CRASHED))
1992
+ );
1993
+ if (crashedTeams.length > 0) {
1994
+ this._triggerFeatureLead(slug, "crash", { crashedTeams });
1995
+ }
1996
+ });
1997
+
1998
+ // Wire .task signals to orchestrators and role agents
1999
+ this.fileIO.on("impl:orchTask", ({ slug, teamNum }) => {
2000
+ const feature = queries.getFeatureBySlug(slug);
2001
+ if (!feature) return;
2002
+ const orchKey = `orch-${slug}-${teamNum}`;
2003
+ const agent = queries.getAgentByKey(orchKey);
2004
+ if (!agent) return;
2005
+ this._handleOrchestratorTask(agent.id, feature, teamNum);
2006
+ });
2007
+
2008
+ this.fileIO.on("impl:task", ({ slug, teamNum, role }) => {
2009
+ const feature = queries.getFeatureBySlug(slug);
2010
+ if (!feature) return;
2011
+ const roleKey = `role-${slug}-${teamNum}-${role}`;
2012
+ const agent = queries.getAgentByKey(roleKey);
2013
+ if (!agent) return;
2014
+ this._handleRoleTask(agent.id, feature);
2015
+ });
2016
+
2017
+ // Operator user message
2018
+ this.fileIO.on("impl:userMessage", ({ slug, agent: agentName }) => {
2019
+ if (agentName !== "operator") return;
2020
+ const feature = queries.getFeatureBySlug(slug);
2021
+ if (!feature) return;
2022
+ this._handleOperatorMessage(feature);
2023
+ });
2024
+
2025
+ // Review agent done — route through Operator relay
2026
+ this.fileIO.on("impl:done", async ({ slug, agent, role, filePath }) => {
2027
+ if (!agent || !agent.startsWith("review-")) return;
2028
+ try {
2029
+ const feature = queries.getFeatureBySlug(slug);
2030
+ if (!feature) return;
2031
+
2032
+ const outputPath = path.join(path.dirname(filePath), SIGNAL.OUTPUT);
2033
+ const rawOutput = readSignal(outputPath);
2034
+ if (!rawOutput) {
2035
+ await this.adapter.postMessage(feature.id, `*[${role}]* Review complete (no output).`);
2036
+ return;
2037
+ }
2038
+
2039
+ queries.insertEvent(feature.id, "agent-response", `agent:${role}`, rawOutput);
2040
+ await this._enqueueForOperatorRelay(feature, role, "review-completion", rawOutput);
2041
+ } catch (err) {
2042
+ console.error(`[orchestrator] Review done error for ${role}:`, err.message);
2043
+ }
2044
+ });
2045
+
2046
+ // Operator .needs-repos — create worktrees for requested repos
2047
+ this.fileIO.on("impl:needsRepos", async ({ slug, filePath }) => {
2048
+ try {
2049
+ const content = readSignal(filePath, { deleteAfter: true });
2050
+ if (!content) return;
2051
+ const feature = queries.getFeatureBySlug(slug);
2052
+ if (!feature) return;
2053
+
2054
+ await this._handleNeedsRepos(feature, content);
2055
+ } catch (err) {
2056
+ console.error("[orchestrator] Needs repos error:", err.message);
2057
+ }
2058
+ });
2059
+ }
2060
+
2061
+ _triggerFeatureLead(slug, trigger, opts = {}) {
2062
+ const feature = queries.getFeatureBySlug(slug);
2063
+ if (!feature) return;
2064
+ const flKey = `fl-${slug}`;
2065
+ const flAgent = queries.getAgentByKey(flKey);
2066
+ if (!flAgent) return;
2067
+
2068
+ const tree = this._signalTrees[slug];
2069
+ if (!tree) return;
2070
+
2071
+ // If FL is already running, send trigger as a .user-message instead of killing it
2072
+ if (this.supervisor.isRunning(flKey)) {
2073
+ const msg = trigger === "gate-ready"
2074
+ ? "TRIGGER: All teams have signaled gate-ready. Begin gate review."
2075
+ : trigger === "question"
2076
+ ? `TRIGGER: Teams ${(opts.questionTeams || []).join(", ")} have questions needing resolution.`
2077
+ : trigger === "crash"
2078
+ ? `TRIGGER: Teams ${(opts.crashedTeams || []).join(", ")} have crashed orchestrators. Handle recovery.`
2079
+ : `TRIGGER: ${trigger}`;
2080
+ writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE), msg);
2081
+ return;
2082
+ }
2083
+
2084
+ // FL not running — spawn in trigger mode
2085
+ this._spawnFeatureLead(flAgent.id, feature, tree, "trigger", {
2086
+ trigger,
2087
+ ...opts,
2088
+ });
2089
+ }
2090
+
2091
+ _handleOrchestratorTask(agentId, feature, teamNum) {
2092
+ const agent = queries.getAgentById(agentId);
2093
+ if (!agent) return;
2094
+ const tree = this._signalTrees[feature.slug];
2095
+ if (!tree) return;
2096
+ const team = tree.teams[teamNum];
2097
+ if (!team) return;
2098
+
2099
+ // Read and consume .task
2100
+ const taskFile = path.join(team.orchestrator, SIGNAL.TASK);
2101
+ const task = readSignal(taskFile, { deleteAfter: true });
2102
+ if (!task) return;
2103
+
2104
+ // Clean previous signals
2105
+ for (const sig of [SIGNAL.DONE, SIGNAL.GATE_READY, SIGNAL.CRASHED, SIGNAL.GATE_APPROVED, SIGNAL.QUESTION, SIGNAL.ANSWER]) {
2106
+ try { fs.unlinkSync(path.join(team.orchestrator, sig)); } catch { /* ok */ }
2107
+ }
2108
+
2109
+ // Resolve team-specific repos directory for branch isolation
2110
+ const teamReposDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams", String(teamNum), "repos");
2111
+ const prompt = buildOrchestratorPrompt({
2112
+ teamDir: team.dir,
2113
+ orchDir: team.orchestrator,
2114
+ task,
2115
+ teamReposDir: fs.existsSync(teamReposDir) ? teamReposDir : null,
2116
+ });
2117
+
2118
+ this.supervisor.spawn(agentId, prompt);
2119
+ queries.insertEvent(feature.id, "agent-started", "bridge", `Team ${teamNum} orchestrator dispatched`);
2120
+ }
2121
+
2122
+ _handleRoleTask(agentId, feature) {
2123
+ const agent = queries.getAgentById(agentId);
2124
+ if (!agent) return;
2125
+
2126
+ // Atomic rename: .task → .active-task
2127
+ const taskFile = path.join(agent.signal_dir, SIGNAL.TASK);
2128
+ const activeFile = path.join(agent.signal_dir, SIGNAL.ACTIVE_TASK);
2129
+ let task;
2130
+ try {
2131
+ fs.renameSync(taskFile, activeFile);
2132
+ task = fs.readFileSync(activeFile, "utf-8").trim();
2133
+ } catch {
2134
+ task = readSignal(taskFile, { deleteAfter: true });
2135
+ }
2136
+ if (!task) return;
2137
+
2138
+ // Clean previous signals
2139
+ for (const sig of [SIGNAL.DONE, SIGNAL.CRASHED]) {
2140
+ try { fs.unlinkSync(path.join(agent.signal_dir, sig)); } catch { /* ok */ }
2141
+ }
2142
+
2143
+ const prompt = buildRolePrompt({
2144
+ role: agent.role_name,
2145
+ signalDir: agent.signal_dir,
2146
+ task,
2147
+ });
2148
+
2149
+ this.supervisor.spawn(agentId, prompt);
2150
+ queries.insertEvent(feature.id, "agent-started", "bridge", `Role ${agent.role_name} (team ${agent.team_num}) dispatched`);
2151
+ }
2152
+
2153
+ async _handleOperatorMessage(feature) {
2154
+ const opKey = `op-${feature.slug}`;
2155
+ const opAgent = queries.getAgentByKey(opKey);
2156
+ if (!opAgent) return;
2157
+
2158
+ const tree = this._signalTrees[feature.slug];
2159
+ if (!tree?.operator) return;
2160
+
2161
+ // Read user message
2162
+ const msgPath = path.join(tree.operator, SIGNAL.USER_MESSAGE);
2163
+ const userMessage = readSignal(msgPath, { deleteAfter: true });
2164
+ if (!userMessage) return;
2165
+
2166
+ // Invoke ephemeral operator with planning-phase context
2167
+ const planDir = tree.plansDir || path.join(tree.featureDir, "plans");
2168
+ try {
2169
+ await invokeOperator({
2170
+ feature,
2171
+ operatorDir: tree.operator,
2172
+ flDir: tree.featureLead,
2173
+ featureDir: tree.featureDir,
2174
+ userMessage,
2175
+ supervisor: this.supervisor,
2176
+ agentId: opAgent.id,
2177
+ planDir,
2178
+ activePlanningRole: feature.active_planning_role,
2179
+ });
2180
+ } catch (err) {
2181
+ console.error("[orchestrator] Operator invocation error:", err.message);
2182
+ // Fallback: write raw message to active agent
2183
+ if (tree.featureLead) {
2184
+ writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE), userMessage);
2185
+ } else if (feature.active_planning_role && tree.planning?.[feature.active_planning_role]) {
2186
+ writeSignal(path.join(tree.planning[feature.active_planning_role], SIGNAL.USER_MESSAGE), userMessage);
2187
+ }
2188
+ }
2189
+ }
2190
+
2191
+ /**
2192
+ * Handle operator exit — retry on failure (e.g. "Prompt is too long").
2193
+ * On success (exit 0), reset to idle.
2194
+ */
2195
+ _handleOperatorExit(agentId, feature, exitCode, elapsed) {
2196
+ if (exitCode === 0) {
2197
+ queries.updateAgentStatus(agentId, "idle");
2198
+ queries.resetAgentRetry(agentId);
2199
+ return;
2200
+ }
2201
+
2202
+ // Non-zero exit — schedule retry via supervisor (respects max_retries)
2203
+ const tree = this._signalTrees[feature.slug];
2204
+ if (!tree) {
2205
+ queries.updateAgentStatus(agentId, "crashed");
2206
+ return;
2207
+ }
2208
+
2209
+ console.log(`[orchestrator] Operator exited with code ${exitCode} after ${elapsed}ms — scheduling retry`);
2210
+
2211
+ const retried = this.supervisor.scheduleRetry(agentId, () => {
2212
+ // On retry, assembleHistory will re-summarize with a tighter budget
2213
+ return { prompt: "Re-read your CLAUDE.md and check for pending relay queue items or user messages. Write a status update to .agent-response if there's nothing to relay.", continue: false };
2214
+ });
2215
+
2216
+ if (!retried) {
2217
+ queries.updateAgentStatus(agentId, "idle");
2218
+ console.error(`[orchestrator] Operator for ${feature.slug} exhausted retries — resetting to idle`);
2219
+ }
2220
+ }
2221
+
2222
+ // ═══════════════════════════════════════════════════════════════════════════
2223
+ // SUPERVISOR EVENT HANDLERS
2224
+ // ═══════════════════════════════════════════════════════════════════════════
2225
+
2226
+ _setupSupervisorHandlers() {
2227
+ this.supervisor.on("agent:exit", (info) => {
2228
+ this._handleAgentExit(info);
2229
+ });
2230
+
2231
+ this.supervisor.on("agent:crashed", async (info) => {
2232
+ const feature = queries.getFeatureById(info.featureId);
2233
+ if (!feature) return;
2234
+ await this.adapter.postPipelineMessage(feature.id,
2235
+ `Agent ${info.agentKey} crashed after ${info.retryCount} retries.`);
2236
+ queries.insertEvent(info.featureId, "agent-crashed", "bridge",
2237
+ `${info.agentKey} crashed after ${info.retryCount} retries`);
2238
+ });
2239
+ }
2240
+
2241
+ _handleAgentExit({ agentId, agentKey, agentType, featureId, exitCode, elapsed, signalDir, roleName, teamNum }) {
2242
+ const feature = queries.getFeatureById(featureId);
2243
+ if (!feature) return;
2244
+
2245
+ switch (agentType) {
2246
+ case "planning-role":
2247
+ this._handlePlanningRoleExit(agentId, feature, signalDir, roleName);
2248
+ break;
2249
+ case "feature-lead":
2250
+ this._handleFeatureLeadExit(agentId, feature, signalDir, elapsed);
2251
+ break;
2252
+ case "team-orchestrator":
2253
+ this._handleOrchestratorExit(agentId, feature, signalDir, teamNum, elapsed);
2254
+ break;
2255
+ case "role-agent":
2256
+ case "review-agent":
2257
+ this._handleRoleExit(agentId, feature, signalDir, roleName, elapsed);
2258
+ break;
2259
+ case "operator":
2260
+ this._handleOperatorExit(agentId, feature, exitCode, elapsed);
2261
+ break;
2262
+ }
2263
+ }
2264
+
2265
+ _handlePlanningRoleExit(agentId, feature, signalDir, roleName) {
2266
+ const doneFile = path.join(signalDir, SIGNAL.DONE);
2267
+ if (fs.existsSync(doneFile)) {
2268
+ queries.updateAgentStatus(agentId, "done");
2269
+ this._handlePlanningDone(feature.slug, roleName, doneFile);
2270
+ return;
2271
+ }
2272
+
2273
+ // Check if the file watcher already processed .done (it deletes the file).
2274
+ // If review gate is posted or agent already marked done, don't retry.
2275
+ const meta = queries.getFeatureMetadata(feature.id);
2276
+ const agent = queries.getAgentById(agentId);
2277
+ if (meta.awaiting_phase_review && meta.phase_review_role === roleName) {
2278
+ console.log(`[orchestrator] ${roleName} exit: review gate already posted, skipping retry`);
2279
+ queries.updateAgentStatus(agentId, "done");
2280
+ return;
2281
+ }
2282
+ if (agent && agent.status === "done") {
2283
+ console.log(`[orchestrator] ${roleName} exit: already marked done, skipping retry`);
2284
+ return;
2285
+ }
2286
+ // Also skip if phase has already advanced past this role
2287
+ if (feature.active_planning_role && feature.active_planning_role !== roleName) {
2288
+ console.log(`[orchestrator] ${roleName} exit: phase already advanced to ${feature.active_planning_role}, skipping retry`);
2289
+ queries.updateAgentStatus(agentId, "done");
2290
+ return;
2291
+ }
2292
+
2293
+ {
2294
+ // Retry with file-based handover reference (never embed content inline)
2295
+ const featureDir = feature.signal_dir;
2296
+ const planDir = path.join(featureDir, "plans");
2297
+ const reposDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "repos");
2298
+
2299
+ // Check if plan artifacts are large enough to warrant summarization
2300
+ const artifactStats = this._getPlanArtifactStats(planDir);
2301
+ const needsSummary = artifactStats.totalKB > ARTIFACT_SUMMARY_THRESHOLD_KB;
2302
+
2303
+ if (needsSummary) {
2304
+ // Run summarizer first, then retry the planning role on completion
2305
+ console.log(`[orchestrator] ${roleName}: plan artifacts are ${artifactStats.totalKB} KB — running summarizer before retry`);
2306
+ this._runArtifactSummarizer(feature, planDir, signalDir, artifactStats, () => {
2307
+ this._schedulePlanningRetry(agentId, feature, signalDir, planDir, featureDir, reposDir, roleName);
2308
+ });
2309
+ } else {
2310
+ this._schedulePlanningRetry(agentId, feature, signalDir, planDir, featureDir, reposDir, roleName);
2311
+ }
2312
+ }
2313
+ }
2314
+
2315
+ /**
2316
+ * Ensure a mockup review session exists and inject its URL into design-decisions.md.
2317
+ * Used by both _requestPhaseReview (fresh) and repostPendingPhaseReview (recovery).
2318
+ */
2319
+ async _ensureMockupSession(planDir, mockupDecisionId, featureId) {
2320
+ const mockupPath = path.join(planDir, "mockup.html");
2321
+ if (!fs.existsSync(mockupPath)) return;
2322
+
2323
+ // Try restore first, then start fresh
2324
+ let mockupUrl = await this.reviewSessions.restoreSession(mockupDecisionId);
2325
+ if (!mockupUrl) {
2326
+ mockupUrl = await this.reviewSessions.startMockupReview(
2327
+ mockupDecisionId, mockupPath, { featureId }
2328
+ );
2329
+ }
2330
+ if (!mockupUrl) return;
2331
+
2332
+ // Inject mockup URL into design-decisions.md (strip stale ones first)
2333
+ const designDocPath = findArtifact("design-decisions", planDir);
2334
+ if (!designDocPath) return;
2335
+
2336
+ let content = fs.readFileSync(designDocPath, "utf-8");
2337
+ content = content.replace(/\n+---\n+## Interactive Mockup\n+\*\*\[View UI Mockup in Browser\]\(http[^)]*\)\*\*\n+[^\n]*\n*/g, "");
2338
+
2339
+ const section = `\n\n---\n\n## Interactive Mockup\n\n**[View UI Mockup in Browser](${mockupUrl})**\n\nOpen the link above to see the HTML/CSS mockup with annotation tools.\n`;
2340
+ const overviewEnd = content.indexOf("\n---", content.indexOf("## Overview"));
2341
+ if (overviewEnd > 0) {
2342
+ fs.writeFileSync(designDocPath, content.slice(0, overviewEnd) + section + content.slice(overviewEnd), "utf-8");
2343
+ } else {
2344
+ const firstHeadingEnd = content.indexOf("\n", content.indexOf("# "));
2345
+ if (firstHeadingEnd > 0) {
2346
+ fs.writeFileSync(designDocPath, content.slice(0, firstHeadingEnd + 1) + section + content.slice(firstHeadingEnd + 1), "utf-8");
2347
+ } else {
2348
+ fs.writeFileSync(designDocPath, section + "\n" + content, "utf-8");
2349
+ }
2350
+ }
2351
+ }
2352
+
2353
+ /**
2354
+ * Schedule a planning role retry (extracted for use with/without summarizer).
2355
+ */
2356
+ _schedulePlanningRetry(agentId, feature, signalDir, planDir, featureDir, reposDir, roleName) {
2357
+ const summaryPath = path.join(planDir, ARTIFACT_SUMMARY_FILE);
2358
+ const hasSummary = fs.existsSync(summaryPath);
2359
+
2360
+ const retried = this.supervisor.scheduleRetry(agentId, (agent) => {
2361
+ const hasHandover = fs.existsSync(path.join(signalDir, SIGNAL.HANDOVER));
2362
+
2363
+ const taskHeader = [
2364
+ `SLACK_MODE=true`,
2365
+ `FEATURE_SLUG=${feature.slug}`,
2366
+ `SIGNAL_DIR=${signalDir}`,
2367
+ `PLAN_DIR=${planDir}`,
2368
+ `FEATURE_DIR=${featureDir}`,
2369
+ `REPOS_DIR=${reposDir}`,
2370
+ ].join("\n");
2371
+
2372
+ let handoverRef;
2373
+ if (hasHandover) {
2374
+ handoverRef = `\nCONTINUATION: Read ${signalDir}/.handover for your previous session's context. Resume from where it left off.\n`;
2375
+ } else {
2376
+ handoverRef = `\nRETRY ${agent.retry_count}: Previous session crashed. Check git status/diff for completed work. Do NOT redo completed work.\n`;
2377
+ }
2378
+
2379
+ // If summarizer produced a summary, tell the agent to use it instead of re-reading full artifacts
2380
+ if (hasSummary) {
2381
+ handoverRef += `
2382
+ ARTIFACT SUMMARY AVAILABLE: A compressed summary of your plan artifacts exists at:
2383
+ ${summaryPath}
2384
+
2385
+ Read this summary FIRST to restore context efficiently. It preserves component hierarchies,
2386
+ state tables, test IDs, and flow names while compressing prose and visual specs.
2387
+ Only read full artifact files (design-decisions.md, PRD, mockup.html) when you need to
2388
+ modify a SPECIFIC section — never re-read them in full.\n`;
2389
+ }
2390
+
2391
+ const prompt = buildPlanningRolePrompt({
2392
+ task: taskHeader + handoverRef,
2393
+ signalDir,
2394
+ featureSlug: feature.slug,
2395
+ });
2396
+
2397
+ return { prompt, continue: !hasHandover };
2398
+ });
2399
+ if (!retried) {
2400
+ queries.insertEvent(feature.id, "agent-crashed", "bridge", `Planning role ${roleName} crashed`);
2401
+ }
2402
+ }
2403
+
2404
+ /**
2405
+ * Get size stats for plan directory artifacts.
2406
+ */
2407
+ _getPlanArtifactStats(planDir) {
2408
+ const result = { totalKB: 0, artifacts: [] };
2409
+ if (!fs.existsSync(planDir)) return result;
2410
+
2411
+ const ARTIFACT_EXTENSIONS = new Set([".md", ".html", ".json", ".yaml", ".yml"]);
2412
+ try {
2413
+ for (const name of fs.readdirSync(planDir)) {
2414
+ // Skip the summary file itself and hidden files
2415
+ if (name === ARTIFACT_SUMMARY_FILE || name.startsWith(".")) continue;
2416
+ const ext = path.extname(name).toLowerCase();
2417
+ if (!ARTIFACT_EXTENSIONS.has(ext)) continue;
2418
+
2419
+ const filePath = path.join(planDir, name);
2420
+ const stat = fs.statSync(filePath);
2421
+ if (!stat.isFile()) continue;
2422
+
2423
+ const sizeKB = Math.round(stat.size / 1024);
2424
+ // Estimate line count for large files to avoid reading them into memory
2425
+ let lines;
2426
+ if (stat.size > 100 * 1024) {
2427
+ lines = Math.round(stat.size / 80); // ~80 bytes/line estimate
2428
+ } else {
2429
+ lines = fs.readFileSync(filePath, "utf-8").split("\n").length;
2430
+ }
2431
+ result.artifacts.push({ name, sizeKB, lines, path: filePath });
2432
+ result.totalKB += sizeKB;
2433
+ }
2434
+ } catch { /* ok — dir might be gone */ }
2435
+
2436
+ return result;
2437
+ }
2438
+
2439
+ /**
2440
+ * Spawn a fast summarizer agent (sonnet) to compress plan artifacts before restart.
2441
+ * Calls onComplete when done (whether success or failure — we retry either way).
2442
+ */
2443
+ _runArtifactSummarizer(feature, planDir, roleSignalDir, artifactStats, onComplete) {
2444
+ const summarizerDir = path.join(roleSignalDir, ".summarizer");
2445
+ ensureDir(summarizerDir);
2446
+
2447
+ const outputPath = path.join(planDir, ARTIFACT_SUMMARY_FILE);
2448
+
2449
+ const prompt = buildArtifactSummarizerPrompt({
2450
+ planDir,
2451
+ outputPath,
2452
+ artifacts: artifactStats.artifacts,
2453
+ });
2454
+
2455
+ // Spawn directly via AgentProcess (no DB record needed — this is ephemeral)
2456
+ const proc = new AgentProcess({
2457
+ key: `summarizer-${feature.slug}`,
2458
+ cwd: planDir,
2459
+ extraEnv: {},
2460
+ signalDir: summarizerDir,
2461
+ });
2462
+
2463
+ proc.spawnClaude(prompt, { model: SUMMARIZER_MODEL });
2464
+ queries.insertEvent(feature.id, "summarizer-started", "bridge",
2465
+ `Artifact summarizer dispatched (${artifactStats.totalKB} KB across ${artifactStats.artifacts.length} files)`);
2466
+
2467
+ // Timeout fallback — don't let a stuck summarizer block the retry forever
2468
+ const timer = setTimeout(() => {
2469
+ console.log(`[orchestrator] Summarizer timed out for ${feature.slug} — proceeding with retry`);
2470
+ proc.kill();
2471
+ onComplete();
2472
+ }, SUMMARIZER_TIMEOUT_MS);
2473
+
2474
+ proc.on("exit", ({ exitCode }) => {
2475
+ clearTimeout(timer);
2476
+
2477
+ if (exitCode === 0 && fs.existsSync(outputPath)) {
2478
+ const summarySize = Math.round(fs.statSync(outputPath).size / 1024);
2479
+ console.log(`[orchestrator] Summarizer complete for ${feature.slug}: ${summarySize} KB summary (from ${artifactStats.totalKB} KB artifacts)`);
2480
+ queries.insertEvent(feature.id, "summarizer-done", "bridge",
2481
+ `Artifact summary written: ${summarySize} KB (compressed from ${artifactStats.totalKB} KB)`);
2482
+ } else {
2483
+ console.log(`[orchestrator] Summarizer failed for ${feature.slug} (exit ${exitCode}) — proceeding without summary`);
2484
+ }
2485
+
2486
+ // Clean up ephemeral summarizer dir
2487
+ try { fs.rmSync(summarizerDir, { recursive: true, force: true }); } catch { /* ok */ }
2488
+
2489
+ onComplete();
2490
+ });
2491
+ }
2492
+
2493
+ _handleFeatureLeadExit(agentId, feature, signalDir, elapsed) {
2494
+ const tree = this._signalTrees[feature.slug];
2495
+ if (!tree) return;
2496
+
2497
+ // Check signals in priority order
2498
+ const completeFile = path.join(signalDir, SIGNAL.FEATURE_COMPLETE);
2499
+ if (fs.existsSync(completeFile)) {
2500
+ queries.updateAgentStatus(agentId, "done");
2501
+ this._handleFeatureComplete(feature.slug);
2502
+ return;
2503
+ }
2504
+
2505
+ const refreshFile = path.join(signalDir, SIGNAL.CONTEXT_REFRESH);
2506
+ if (fs.existsSync(refreshFile)) {
2507
+ try { fs.unlinkSync(refreshFile); } catch { /* ok */ }
2508
+ // Read handover if available
2509
+ const handoverFile = path.join(signalDir, SIGNAL.HANDOVER);
2510
+ const handoverContent = readSignal(handoverFile, { deleteAfter: true });
2511
+ queries.resetAgentRetry(agentId);
2512
+ this._spawnFeatureLead(agentId, feature, tree, "refresh", { handoverContent });
2513
+ return;
2514
+ }
2515
+
2516
+ const phaseDoneFile = path.join(signalDir, SIGNAL.PHASE_DONE);
2517
+ if (fs.existsSync(phaseDoneFile)) {
2518
+ try { fs.unlinkSync(phaseDoneFile); } catch { /* ok */ }
2519
+ // Phase complete — spawn refresh for next phase
2520
+ queries.resetAgentRetry(agentId);
2521
+ this._spawnFeatureLead(agentId, feature, tree, "refresh");
2522
+ return;
2523
+ }
2524
+
2525
+ const needsRestartFile = path.join(signalDir, SIGNAL.NEEDS_RESTART);
2526
+ if (fs.existsSync(needsRestartFile)) {
2527
+ try { fs.unlinkSync(needsRestartFile); } catch { /* ok */ }
2528
+ const handoverContent = readSignal(path.join(signalDir, SIGNAL.HANDOVER), { deleteAfter: true });
2529
+ queries.resetAgentRetry(agentId);
2530
+ this._spawnFeatureLead(agentId, feature, tree, "refresh", { handoverContent });
2531
+ return;
2532
+ }
2533
+
2534
+ // Guard: file watcher may have already processed signals and deleted them.
2535
+ // If agent is already marked done, don't retry.
2536
+ const agentState = queries.getAgentById(agentId);
2537
+ if (agentState && agentState.status === "done") {
2538
+ console.log(`[orchestrator] FL exit: already marked done, skipping retry`);
2539
+ return;
2540
+ }
2541
+
2542
+ // Auto-refresh if ran long enough (context exhaustion)
2543
+ if (elapsed >= FL_CONTEXT_EXHAUST_MS) {
2544
+ queries.resetAgentRetry(agentId);
2545
+ this._spawnFeatureLead(agentId, feature, tree, "refresh");
2546
+ return;
2547
+ }
2548
+
2549
+ // Otherwise crash — try retry with --continue (conversation is intact, just process died)
2550
+ const retried = this.supervisor.scheduleRetry(agentId, () => {
2551
+ const prompt = buildFeatureLeadRefreshPrompt({
2552
+ featureName: feature.slug,
2553
+ numTeams: Object.keys(tree.teams).length || feature.num_teams,
2554
+ teamSignalBase: path.join(tree.featureDir, "teams"),
2555
+ planReadInstruction: this._resolvePlanInstruction(tree.featureLead, tree.featureDir),
2556
+ featureLeadDir: tree.featureLead,
2557
+ featureReviewDir: path.join(tree.featureDir, "feature-review"),
2558
+ dashboardLog: path.join(tree.featureDir, ".dashboard-log"),
2559
+ gateEvidenceTs: feature.gate_evidence_ts,
2560
+ featureDir: tree.featureDir,
2561
+ });
2562
+ return { prompt, continue: true };
2563
+ });
2564
+
2565
+ if (!retried) {
2566
+ // Max retries exhausted — stop and notify
2567
+ queries.updateAgentStatus(agentId, "idle");
2568
+ this.adapter.postPipelineMessage(feature.id,
2569
+ `Feature Lead exhausted all retries. Waiting for operator or user intervention. Use "restart FL" to retry.`).catch(() => {});
2570
+ queries.insertEvent(feature.id, "agent-crashed", "bridge", "Feature Lead exhausted max retries, stopped.");
2571
+ }
2572
+ }
2573
+
2574
+ _handleOrchestratorExit(agentId, feature, signalDir, teamNum, elapsed) {
2575
+ const gateReadyFile = path.join(signalDir, SIGNAL.GATE_READY);
2576
+ const doneFile = path.join(signalDir, SIGNAL.DONE);
2577
+
2578
+ if (fs.existsSync(gateReadyFile) || fs.existsSync(doneFile)) {
2579
+ queries.updateAgentStatus(agentId, "done");
2580
+ return;
2581
+ }
2582
+
2583
+ const needsRestartFile = path.join(signalDir, SIGNAL.NEEDS_RESTART);
2584
+ if (fs.existsSync(needsRestartFile)) {
2585
+ try { fs.unlinkSync(needsRestartFile); } catch { /* ok */ }
2586
+ // Don't count as crash
2587
+ queries.updateAgentStatus(agentId, "idle");
2588
+ return;
2589
+ }
2590
+
2591
+ // Guard: file watcher may have already processed signals and deleted them.
2592
+ const agentState = queries.getAgentById(agentId);
2593
+ if (agentState && agentState.status === "done") {
2594
+ console.log(`[orchestrator] Orch-${teamNum} exit: already marked done, skipping retry`);
2595
+ return;
2596
+ }
2597
+
2598
+ // Retry
2599
+ const tree = this._signalTrees[feature.slug];
2600
+ const team = tree?.teams[teamNum];
2601
+ if (!team) return;
2602
+
2603
+ const teamReposDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams", String(teamNum), "repos");
2604
+ this.supervisor.scheduleRetry(agentId, () => {
2605
+ const prompt = buildOrchestratorPrompt({
2606
+ teamDir: team.dir,
2607
+ orchDir: team.orchestrator,
2608
+ task: "Resume from where you left off. Read HANDOVER.md and STEP-SUMMARY.md for progress.",
2609
+ recoveryContext: { retryCount: queries.getAgentById(agentId).retry_count },
2610
+ teamReposDir: fs.existsSync(teamReposDir) ? teamReposDir : null,
2611
+ });
2612
+ // Crash recovery — use --continue to resume the conversation
2613
+ return { prompt, continue: true };
2614
+ });
2615
+ }
2616
+
2617
+ _handleRoleExit(agentId, feature, signalDir, roleName, elapsed) {
2618
+ const doneFile = path.join(signalDir, SIGNAL.DONE);
2619
+ if (fs.existsSync(doneFile)) {
2620
+ queries.updateAgentStatus(agentId, "done");
2621
+ return;
2622
+ }
2623
+
2624
+ // Guard: file watcher may have already processed .done and deleted it.
2625
+ const agentState = queries.getAgentById(agentId);
2626
+ if (agentState && agentState.status === "done") {
2627
+ console.log(`[orchestrator] Role ${roleName} exit: already marked done, skipping retry`);
2628
+ return;
2629
+ }
2630
+
2631
+ const needsRestartFile = path.join(signalDir, SIGNAL.NEEDS_RESTART);
2632
+ if (fs.existsSync(needsRestartFile)) {
2633
+ try { fs.unlinkSync(needsRestartFile); } catch { /* ok */ }
2634
+ const handoverContent = readSignal(path.join(signalDir, SIGNAL.HANDOVER), { deleteAfter: true });
2635
+ queries.resetAgentRetry(agentId);
2636
+ // Re-spawn with handover
2637
+ const prompt = buildRolePrompt({
2638
+ role: roleName,
2639
+ signalDir,
2640
+ task: "Resume from handover context.",
2641
+ recoveryContext: handoverContent ? { type: "handover", content: handoverContent } : null,
2642
+ });
2643
+ this.supervisor.spawn(agentId, prompt);
2644
+ return;
2645
+ }
2646
+
2647
+ // Retry with crash recovery context — use --continue to resume conversation
2648
+ this.supervisor.scheduleRetry(agentId, (agent) => {
2649
+ const prompt = buildRolePrompt({
2650
+ role: roleName,
2651
+ signalDir,
2652
+ task: "Resume from where you left off.",
2653
+ recoveryContext: { type: "crash", retryCount: agent.retry_count },
2654
+ });
2655
+ return { prompt, continue: true };
2656
+ });
2657
+ }
2658
+
2659
+ // ═══════════════════════════════════════════════════════════════════════════
2660
+ // OPERATOR RELAY QUEUE
2661
+ // ═══════════════════════════════════════════════════════════════════════════
2662
+
2663
+ /**
2664
+ * Enqueue agent output for formatting by the Operator before posting to the user.
2665
+ */
2666
+ async _enqueueForOperatorRelay(feature, sourceAgent, eventHint, rawContent) {
2667
+ queries.insertRelayEntry({
2668
+ featureId: feature.id,
2669
+ sourceAgent,
2670
+ eventHint,
2671
+ rawContent,
2672
+ });
2673
+
2674
+ // Kick the serial queue processor
2675
+ this._processRelayQueue(feature.id);
2676
+ }
2677
+
2678
+ /**
2679
+ * Serial queue processor per feature. Only one relay processes at a time.
2680
+ */
2681
+ async _processRelayQueue(featureId) {
2682
+ if (this._relayProcessing[featureId]) return;
2683
+ this._relayProcessing[featureId] = true;
2684
+
2685
+ try {
2686
+ let entry;
2687
+ while ((entry = queries.getNextPendingRelay(featureId))) {
2688
+ await this._invokeOperatorRelayAndWait(featureId, entry);
2689
+ }
2690
+ } catch (err) {
2691
+ console.error("[orchestrator] Relay queue error:", err.message);
2692
+ } finally {
2693
+ delete this._relayProcessing[featureId];
2694
+ }
2695
+ }
2696
+
2697
+ /**
2698
+ * Invoke Operator for a single relay entry. Returns a promise that resolves when
2699
+ * impl:operatorResponse fires (marking posted) or on timeout (fallback direct post).
2700
+ */
2701
+ async _invokeOperatorRelayAndWait(featureId, queueEntry) {
2702
+ const feature = queries.getFeatureById(featureId);
2703
+ if (!feature) return;
2704
+
2705
+ const tree = this._signalTrees[feature.slug];
2706
+ if (!tree?.operator) {
2707
+ // No operator dir — fallback to direct post
2708
+ await this._fallbackDirectPost(feature, queueEntry.source_agent, queueEntry.raw_content, queueEntry.id, queueEntry.event_hint);
2709
+ return;
2710
+ }
2711
+
2712
+ const opKey = `op-${feature.slug}`;
2713
+ const opAgent = queries.getAgentByKey(opKey);
2714
+ if (!opAgent) {
2715
+ await this._fallbackDirectPost(feature, queueEntry.source_agent, queueEntry.raw_content, queueEntry.id, queueEntry.event_hint);
2716
+ return;
2717
+ }
2718
+
2719
+ // Mark as processing
2720
+ queries.updateRelayStatus(queueEntry.id, "processing");
2721
+
2722
+ // Kill any existing operator session (shared key)
2723
+ this.supervisor.kill(opKey);
2724
+
2725
+ try {
2726
+ await new Promise((resolve, reject) => {
2727
+ // Store the waiter so impl:operatorResponse can resolve it
2728
+ this._relayWaiters[featureId] = { resolve, queueId: queueEntry.id };
2729
+
2730
+ // Set timeout for fallback
2731
+ const timer = setTimeout(() => {
2732
+ console.warn(`[orchestrator] Operator relay timed out for queue ${queueEntry.id}`);
2733
+ delete this._relayWaiters[featureId];
2734
+ this._fallbackDirectPost(feature, queueEntry.source_agent, queueEntry.raw_content, queueEntry.id, queueEntry.event_hint)
2735
+ .then(resolve)
2736
+ .catch(reject);
2737
+ }, OPERATOR_RELAY_TIMEOUT_MS);
2738
+
2739
+ this._relayWaiters[featureId].timer = timer;
2740
+
2741
+ // Spawn the Operator in relay mode (--continue if it has a prior session)
2742
+ const opHasSession = opAgent.started_at != null;
2743
+ invokeOperatorRelay({
2744
+ feature,
2745
+ queueEntry,
2746
+ supervisor: this.supervisor,
2747
+ agentId: opAgent.id,
2748
+ operatorDir: tree.operator,
2749
+ featureDir: tree.featureDir,
2750
+ continue: opHasSession,
2751
+ }).catch(err => {
2752
+ console.error("[orchestrator] Operator relay spawn error:", err.message);
2753
+ clearTimeout(timer);
2754
+ delete this._relayWaiters[featureId];
2755
+ this._fallbackDirectPost(feature, queueEntry.source_agent, queueEntry.raw_content, queueEntry.id, queueEntry.event_hint)
2756
+ .then(resolve)
2757
+ .catch(reject);
2758
+ });
2759
+ });
2760
+ } catch (err) {
2761
+ console.error("[orchestrator] Relay wait error:", err.message);
2762
+ }
2763
+ }
2764
+
2765
+ /**
2766
+ * Resolve a pending relay waiter (called when impl:operatorResponse fires).
2767
+ */
2768
+ _resolveRelayWaiter(featureId) {
2769
+ const waiter = this._relayWaiters[featureId];
2770
+ if (!waiter) return;
2771
+
2772
+ clearTimeout(waiter.timer);
2773
+ // Mark queue entry as posted
2774
+ queries.updateRelayStatus(waiter.queueId, "posted", { processedAt: new Date().toISOString() });
2775
+ delete this._relayWaiters[featureId];
2776
+ waiter.resolve();
2777
+
2778
+ // Post any deferred decision now that the Operator message is visible
2779
+ this._postDeferredDecision(featureId);
2780
+ }
2781
+
2782
+ /**
2783
+ * Post a deferred decision (stored by _requestPhaseReview) after the Operator
2784
+ * relay completes, so the decision buttons appear after the Operator's message.
2785
+ */
2786
+ async _postDeferredDecision(featureId) {
2787
+ const deferred = this._deferredDecisions[featureId];
2788
+ if (!deferred) return;
2789
+ delete this._deferredDecisions[featureId];
2790
+
2791
+ try {
2792
+ await this.adapter.postDecision(deferred.featureId, deferred.decision);
2793
+ } catch (err) {
2794
+ console.error("[orchestrator] Failed to post deferred decision:", err.message);
2795
+ }
2796
+ }
2797
+
2798
+ /**
2799
+ * Fallback: post raw agent content directly when Operator fails or times out.
2800
+ * @param {string} eventHint - optional hint from the relay entry
2801
+ */
2802
+ async _fallbackDirectPost(feature, sourceAgent, rawContent, queueId, eventHint) {
2803
+ // For decision-needed relays: skip text fallback but post the deferred decision
2804
+ if (eventHint === "decision-needed") {
2805
+ console.log(`[orchestrator] Operator relay timed out for decision-needed (queue ${queueId}) — posting decision directly`);
2806
+ queries.updateRelayStatus(queueId, "posted", { processedAt: new Date().toISOString() });
2807
+ await this._postDeferredDecision(feature.id);
2808
+ return;
2809
+ }
2810
+
2811
+ console.warn(`[orchestrator] Fallback direct post for ${sourceAgent} (queue ${queueId})`);
2812
+ try {
2813
+ await this.adapter.postAgentResponse(feature.id, sourceAgent, rawContent);
2814
+ queries.updateRelayStatus(queueId, "posted", { processedAt: new Date().toISOString() });
2815
+ } catch (err) {
2816
+ console.error("[orchestrator] Fallback post error:", err.message);
2817
+ queries.updateRelayStatus(queueId, "failed");
2818
+ queries.incrementRelayRetry(queueId);
2819
+ }
2820
+ }
2821
+
2822
+ // ═══════════════════════════════════════════════════════════════════════════
2823
+ // STALE SIGNAL SAFETY NET
2824
+ // ═══════════════════════════════════════════════════════════════════════════
2825
+
2826
+ startStaleScan() {
2827
+ this._staleScanTimer = setInterval(() => this._scanForStaleSignals(), STALE_SCAN_INTERVAL_MS);
2828
+ this._staleScanTimer.unref();
2829
+ }
2830
+
2831
+ _scanForStaleSignals() {
2832
+ for (const [slug, tree] of Object.entries(this._signalTrees)) {
2833
+ const feature = queries.getFeatureBySlug(slug);
2834
+ if (!feature || feature.phase === "complete" || feature.phase === "failed") continue;
2835
+
2836
+ // Check for stale gate-ready — skip if FL already running or gate evidence already posted
2837
+ const flAgent = queries.getAgentByKey(`fl-${slug}`);
2838
+ const flRunning = flAgent && (flAgent.status === "running" || this.supervisor.isRunning(`fl-${slug}`));
2839
+ if (!flRunning && !feature.gate_evidence_ts) {
2840
+ const allReady = Object.keys(tree.teams).every(tn =>
2841
+ tree.teams[tn].orchestrator && fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.GATE_READY))
2842
+ );
2843
+ if (allReady && Object.keys(tree.teams).length > 0) {
2844
+ console.log(`[orchestrator] Stale scan: all teams gate-ready for ${slug}`);
2845
+ this._triggerFeatureLead(slug, "gate-ready");
2846
+ continue;
2847
+ }
2848
+ }
2849
+
2850
+ // Check for stale questions
2851
+ const questionTeams = Object.keys(tree.teams).filter(tn =>
2852
+ tree.teams[tn].orchestrator && fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.QUESTION))
2853
+ );
2854
+ if (questionTeams.length > 0) {
2855
+ console.log(`[orchestrator] Stale scan: questions pending for ${slug}`);
2856
+ this._triggerFeatureLead(slug, "question", { questionTeams });
2857
+ continue;
2858
+ }
2859
+
2860
+ // Check for stale crashes
2861
+ const crashedTeams = Object.keys(tree.teams).filter(tn =>
2862
+ tree.teams[tn].orchestrator && fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.CRASHED))
2863
+ );
2864
+ if (crashedTeams.length > 0) {
2865
+ console.log(`[orchestrator] Stale scan: crashes pending for ${slug}`);
2866
+ this._triggerFeatureLead(slug, "crash", { crashedTeams });
2867
+ }
2868
+ }
2869
+ }
2870
+
2871
+ // ═══════════════════════════════════════════════════════════════════════════
2872
+ // SHUTDOWN
2873
+ // ═══════════════════════════════════════════════════════════════════════════
2874
+
2875
+ async shutdown() {
2876
+ if (this._staleScanTimer) {
2877
+ clearInterval(this._staleScanTimer);
2878
+ this._staleScanTimer = null;
2879
+ }
2880
+ if (this.reviewSessions) {
2881
+ await this.reviewSessions.stopAll();
2882
+ }
2883
+ await this.supervisor.shutdown();
2884
+ await this.fileIO.closeAll();
2885
+ }
2886
+ }