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
package/v3/recovery.js ADDED
@@ -0,0 +1,508 @@
1
+ // recovery.js — Startup recovery: orphan cleanup, stale signal processing.
2
+ // Runs once at bridge startup to restore state from SQLite + filesystem.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import * as queries from "./queries.js";
7
+ import { PLANNING_ROLES, PIPELINE_ORDER, SIGNAL } from "./constants.js";
8
+
9
+ export class Recovery {
10
+ constructor({ orchestrator, adapter }) {
11
+ this.orchestrator = orchestrator;
12
+ this.adapter = adapter;
13
+ }
14
+
15
+ /**
16
+ * Run selective recovery for a single feature.
17
+ * Used by CLI commands to resume a specific feature.
18
+ */
19
+ async runForFeature(featureId) {
20
+ const feature = queries.getFeatureById(featureId);
21
+ if (!feature) return;
22
+
23
+ console.log(`[recovery] Running selective recovery for feature: ${feature.slug} (${feature.phase})`);
24
+
25
+ if (feature.phase === "planning" && feature.active_planning_role) {
26
+ // Recover first (checks .done on disk), THEN set up watchers which emit stale signals
27
+ // (stale signal handlers may delete .done files via readSignal({ deleteAfter: true }))
28
+ await this._recoverPlanningFeature(feature);
29
+ await this._setupPlanningWatchersForFeature(feature);
30
+ } else if (feature.phase === "impl") {
31
+ await this._cleanDeadAgentsForFeature(featureId);
32
+ await this._recoverImplFeature(feature);
33
+ } else if (feature.phase === "plan-approval") {
34
+ const teamsDir = path.join(feature.signal_dir, "teams");
35
+ if (fs.existsSync(teamsDir) && fs.readdirSync(teamsDir).some(d => d.startsWith("team-"))) {
36
+ console.log(`[recovery] Feature ${feature.slug} phase is plan-approval but has impl infrastructure — fixing phase to impl`);
37
+ queries.updateFeaturePhase(feature.id, "impl");
38
+ await this._cleanDeadAgentsForFeature(featureId);
39
+ await this._recoverImplFeature({ ...feature, phase: "impl" });
40
+ } else {
41
+ console.log(`[recovery] Feature ${feature.slug} awaiting plan approval — re-notifying Operator`);
42
+ await this.orchestrator.repostPendingDecision(featureId);
43
+ }
44
+ }
45
+
46
+ // Re-present any pending decision that was lost across restart
47
+ // (skip phases already handled above to avoid double-notify)
48
+ if (feature.phase !== "plan-approval" && feature.phase !== "planning") {
49
+ const pendingDecision = queries.getPendingDecision(featureId);
50
+ if (pendingDecision) {
51
+ console.log(`[recovery] Re-presenting pending decision: ${pendingDecision.decision_id}`);
52
+ await this.orchestrator.repostPendingDecision(featureId);
53
+ }
54
+ }
55
+
56
+ // Recover any stuck relay entries for this feature
57
+ const stuckEntries = queries.getProcessingRelays(featureId);
58
+ for (const entry of stuckEntries) {
59
+ // Skip decision-needed entries — already rendered by postDecision()
60
+ if (entry.event_hint === "decision-needed") {
61
+ queries.updateRelayStatus(entry.id, "posted", { processedAt: new Date().toISOString() });
62
+ continue;
63
+ }
64
+ try {
65
+ await this.adapter.postAgentResponse(feature.id, entry.source_agent, entry.raw_content);
66
+ queries.updateRelayStatus(entry.id, "posted", { processedAt: new Date().toISOString() });
67
+ } catch (err) {
68
+ queries.updateRelayStatus(entry.id, "failed");
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Set up signal watchers and process stale planning signals for a single feature.
75
+ * Mirrors _processExistingPlanningSignals() but scoped to one feature.
76
+ */
77
+ async _setupPlanningWatchersForFeature(feature) {
78
+ const featureDir = feature.signal_dir;
79
+ const planningDir = path.join(featureDir, "planning");
80
+ if (!fs.existsSync(planningDir)) return;
81
+
82
+ const planningTree = {};
83
+ for (const role of PLANNING_ROLES) {
84
+ const roleDir = path.join(planningDir, role);
85
+ if (fs.existsSync(roleDir)) planningTree[role] = roleDir;
86
+ }
87
+
88
+ const operatorDir = fs.existsSync(path.join(featureDir, "operator")) ? path.join(featureDir, "operator") : null;
89
+ this.orchestrator.fileIO.watchFeaturePlanningSignals(feature.slug, planningTree, operatorDir);
90
+
91
+ if (!this.orchestrator._signalTrees[feature.slug]) {
92
+ this.orchestrator._signalTrees[feature.slug] = {
93
+ featureDir,
94
+ featureLead: null,
95
+ operator: operatorDir,
96
+ featureReview: {},
97
+ teams: {},
98
+ planning: planningTree,
99
+ plansDir: fs.existsSync(path.join(featureDir, "plans")) ? path.join(featureDir, "plans") : null,
100
+ };
101
+ }
102
+
103
+ // Emit stale signals
104
+ for (const [role, dir] of Object.entries(planningTree)) {
105
+ for (const [sig, eventType] of [
106
+ [SIGNAL.DONE, "planning:done"],
107
+ [SIGNAL.AGENT_RESPONSE, "planning:response"],
108
+ [SIGNAL.QUESTION, "planning:question"],
109
+ ]) {
110
+ const filePath = path.join(dir, sig);
111
+ if (fs.existsSync(filePath)) {
112
+ console.log(`[recovery] Found unprocessed ${sig} for ${feature.slug}/${role}`);
113
+ this.orchestrator.fileIO.emit(eventType, { slug: feature.slug, role, filePath });
114
+ }
115
+ }
116
+ }
117
+
118
+ if (operatorDir) {
119
+ const opResponse = path.join(operatorDir, SIGNAL.AGENT_RESPONSE);
120
+ if (fs.existsSync(opResponse)) {
121
+ console.log(`[recovery] Found stale operator .agent-response for ${feature.slug} (planning)`);
122
+ this.orchestrator.fileIO.emit("impl:operatorResponse", { slug: feature.slug, filePath: opResponse });
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Clean dead agents for a single feature (mirrors _cleanDeadAgents scoped to one feature).
129
+ */
130
+ async _cleanDeadAgentsForFeature(featureId) {
131
+ const runningAgents = queries.getRunningAgents(featureId);
132
+ for (const agent of runningAgents) {
133
+ if (agent.pid && !this.orchestrator.supervisor.isAlive(agent.pid)) {
134
+ console.log(`[recovery] Agent ${agent.agent_key} PID ${agent.pid} is dead — marking crashed`);
135
+ queries.updateAgentStatus(agent.id, "crashed");
136
+ queries.insertEvent(agent.feature_id, "agent-crashed", "bridge",
137
+ `${agent.agent_key} found dead on recovery (PID ${agent.pid})`);
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Run full recovery sequence on startup.
144
+ */
145
+ /**
146
+ * Sync CLI-created features to Slack.
147
+ * Features created in terminal mode have slug-based feature_channel values
148
+ * (not real Slack channel IDs). Detect these and create real Slack channels.
149
+ */
150
+ async _syncCliFeaturesToSlack() {
151
+ const activeFeatures = queries.getActiveFeatures();
152
+ for (const feature of activeFeatures) {
153
+ const ch = feature.feature_channel;
154
+ // Slack channel IDs start with C/G/D followed by alphanumerics
155
+ if (ch && !/^[CGD][A-Z0-9]+$/.test(ch)) {
156
+ console.log(`[recovery] Syncing CLI feature "${feature.slug}" to Slack (channel was: ${ch})`);
157
+ try {
158
+ const channelId = await this.adapter.createFeatureChannel(feature.id, feature.slug);
159
+ if (channelId) {
160
+ queries.updateFeatureChannel(feature.id, channelId);
161
+ console.log(`[recovery] Created Slack channel ${channelId} for ${feature.slug}`);
162
+
163
+ // Announce in #planning channel with link to impl channel
164
+ await this.adapter.web.chat.postMessage({
165
+ channel: this.adapter.planningChannel,
166
+ text: `[FEATURE][CLI SYNC] ${feature.slug} → <#${channelId}>`,
167
+ });
168
+
169
+ // Post status as first message in the feature channel
170
+ const phase = feature.phase || "planning";
171
+ const role = feature.active_planning_role;
172
+ const meta = JSON.parse(feature.metadata || "{}");
173
+
174
+ const lines = [`*Feature:* \`${feature.slug}\``, `*Phase:* ${phase}`];
175
+ if (role) lines.push(`*Active role:* ${role}`);
176
+ if (meta.awaiting_phase_review) lines.push(`*Status:* Awaiting ${meta.phase_review_role} phase review`);
177
+ else if (phase === "plan-approval") lines.push("*Status:* Awaiting plan approval");
178
+ else if (phase === "impl") lines.push("*Status:* Implementation in progress");
179
+ else if (role) lines.push(`*Status:* ${role} agent running`);
180
+
181
+ const planDir = path.join(feature.signal_dir, "plans");
182
+ if (fs.existsSync(planDir)) {
183
+ const files = fs.readdirSync(planDir).filter(f => f.endsWith(".md") || f.endsWith(".yaml") || f.endsWith(".html"));
184
+ if (files.length > 0) lines.push(`*Planning artifacts:* ${files.join(", ")}`);
185
+ }
186
+
187
+ lines.push("_Synced from CLI._");
188
+ await this.adapter.postMessage(feature.id, lines.join("\n"));
189
+ }
190
+ } catch (err) {
191
+ console.error(`[recovery] Failed to sync CLI feature ${feature.slug} to Slack:`, err.message);
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ async run() {
198
+ console.log("[recovery] Starting recovery...");
199
+
200
+ // 0. Sync CLI-created features to Slack (create channels for slug-based feature_channels)
201
+ if (this.adapter.createFeatureChannel && typeof this.adapter.createFeatureChannel === "function") {
202
+ await this._syncCliFeaturesToSlack();
203
+ }
204
+
205
+ // 1. Process stale planning signals
206
+ await this._processExistingPlanningSignals();
207
+
208
+ // 2. Recover active features (re-read after sync since channels may have changed)
209
+ const activeFeatures = queries.getActiveFeatures();
210
+ console.log(`[recovery] Found ${activeFeatures.length} active feature(s)`);
211
+
212
+ for (const feature of activeFeatures) {
213
+ try {
214
+ if (feature.phase === "planning" && feature.active_planning_role) {
215
+ await this._recoverPlanningFeature(feature);
216
+ } else if (feature.phase === "impl") {
217
+ await this._recoverImplFeature(feature);
218
+ } else if (feature.phase === "plan-approval") {
219
+ // Check if impl infrastructure exists — feature was approved but phase
220
+ // never transitioned (e.g. old "go" confirmation step, or crash mid-launch).
221
+ const teamsDir = path.join(feature.signal_dir, "teams");
222
+ if (fs.existsSync(teamsDir) && fs.readdirSync(teamsDir).some(d => d.startsWith("team-"))) {
223
+ console.log(`[recovery] Feature ${feature.slug} phase is plan-approval but has impl infrastructure — fixing phase to impl`);
224
+ queries.updateFeaturePhase(feature.id, "impl");
225
+ await this._recoverImplFeature({ ...feature, phase: "impl" });
226
+ } else {
227
+ console.log(`[recovery] Feature ${feature.slug} awaiting plan approval — re-notifying Operator`);
228
+ await this.orchestrator.repostPendingDecision(feature.id);
229
+ }
230
+ } else if (feature.phase === "launching") {
231
+ console.log(`[recovery] Feature ${feature.slug} was launching — may need manual intervention`);
232
+ }
233
+ } catch (err) {
234
+ console.error(`[recovery] Error recovering feature ${feature.slug} (id=${feature.id}):`, err.message);
235
+ }
236
+ }
237
+
238
+ // 3. Recover stale relay queue entries
239
+ await this._recoverRelayQueue();
240
+
241
+ // 4. Check for running agents with dead PIDs
242
+ await this._cleanDeadAgents();
243
+
244
+ console.log("[recovery] Recovery complete");
245
+ }
246
+
247
+ /**
248
+ * Process existing planning signals for active planning-phase features.
249
+ * Scans per-feature planning/ subdirs instead of global ROLE_DIRS.
250
+ */
251
+ async _processExistingPlanningSignals() {
252
+ const activeFeatures = queries.getActiveFeatures();
253
+ for (const feature of activeFeatures) {
254
+ if (feature.phase !== "planning" || !feature.active_planning_role) continue;
255
+
256
+ const featureDir = feature.signal_dir;
257
+ const planningDir = path.join(featureDir, "planning");
258
+ if (!fs.existsSync(planningDir)) continue;
259
+
260
+ // Build planning tree for this feature
261
+ const planningTree = {};
262
+ for (const role of PLANNING_ROLES) {
263
+ const roleDir = path.join(planningDir, role);
264
+ if (fs.existsSync(roleDir)) planningTree[role] = roleDir;
265
+ }
266
+
267
+ // Set up watchers for this feature's planning dirs + operator
268
+ const operatorDir = fs.existsSync(path.join(featureDir, "operator")) ? path.join(featureDir, "operator") : null;
269
+ this.orchestrator.fileIO.watchFeaturePlanningSignals(feature.slug, planningTree, operatorDir);
270
+
271
+ // Cache in signal trees
272
+ if (!this.orchestrator._signalTrees[feature.slug]) {
273
+ this.orchestrator._signalTrees[feature.slug] = {
274
+ featureDir,
275
+ featureLead: null,
276
+ operator: operatorDir,
277
+ featureReview: {},
278
+ teams: {},
279
+ planning: planningTree,
280
+ plansDir: fs.existsSync(path.join(featureDir, "plans")) ? path.join(featureDir, "plans") : null,
281
+ };
282
+ }
283
+
284
+ // Emit stale signals
285
+ for (const [role, dir] of Object.entries(planningTree)) {
286
+ for (const [sig, eventType] of [
287
+ [SIGNAL.DONE, "planning:done"],
288
+ [SIGNAL.AGENT_RESPONSE, "planning:response"],
289
+ [SIGNAL.QUESTION, "planning:question"],
290
+ ]) {
291
+ const filePath = path.join(dir, sig);
292
+ if (fs.existsSync(filePath)) {
293
+ console.log(`[recovery] Found unprocessed ${sig} for ${feature.slug}/${role}`);
294
+ this.orchestrator.fileIO.emit(eventType, { slug: feature.slug, role, filePath });
295
+ }
296
+ }
297
+ }
298
+
299
+ // Emit stale operator signals during planning
300
+ if (operatorDir) {
301
+ const opResponse = path.join(operatorDir, SIGNAL.AGENT_RESPONSE);
302
+ if (fs.existsSync(opResponse)) {
303
+ console.log(`[recovery] Found stale operator .agent-response for ${feature.slug} (planning)`);
304
+ this.orchestrator.fileIO.emit("impl:operatorResponse", { slug: feature.slug, filePath: opResponse });
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Recover a feature in planning phase.
312
+ */
313
+ async _recoverPlanningFeature(feature) {
314
+ const role = feature.active_planning_role;
315
+ if (!role || !PIPELINE_ORDER.includes(role)) return;
316
+
317
+ const featureDir = feature.signal_dir;
318
+ const roleDir = path.join(featureDir, "planning", role);
319
+
320
+ // If a .done file exists, defer to processExistingPlanningSignals
321
+ if (fs.existsSync(path.join(roleDir, SIGNAL.DONE))) {
322
+ console.log(`[recovery] Feature ${feature.slug}: stale ${role}.done found, deferring to signal processing`);
323
+ return;
324
+ }
325
+
326
+ // If already awaiting phase review, re-post the decision prompt (may have been lost)
327
+ const meta = JSON.parse(feature.metadata || "{}");
328
+ if (meta.awaiting_phase_review) {
329
+ console.log(`[recovery] Feature ${feature.slug}: awaiting phase review for ${meta.phase_review_role} — re-posting decision`);
330
+ await this.orchestrator.repostPendingPhaseReview(feature.id);
331
+ return;
332
+ }
333
+
334
+ // .handover exists → context was exhausted → fresh session with handover context
335
+ if (fs.existsSync(path.join(roleDir, SIGNAL.HANDOVER))) {
336
+ console.log(`[recovery] Feature ${feature.slug}: found .handover for ${role}, starting fresh with handover`);
337
+ this.orchestrator.dispatchPlanningRole(feature.id, role, { continue: false });
338
+ return;
339
+ }
340
+
341
+ // No .handover, no .done → crash or bridge died mid-session → --continue
342
+ console.log(`[recovery] Feature ${feature.slug}: resuming ${role} session with --continue`);
343
+ this.orchestrator.dispatchPlanningRole(feature.id, role, { continue: true });
344
+ }
345
+
346
+ /**
347
+ * Recover a feature in implementation phase.
348
+ */
349
+ async _recoverImplFeature(feature) {
350
+ console.log(`[recovery] Recovering impl feature: ${feature.slug}`);
351
+
352
+ // Discover signal tree
353
+ const tree = this.orchestrator.discoverSignalTree(feature.slug);
354
+
355
+ // Kill orphaned processes
356
+ this.orchestrator.supervisor.killOrphans(tree);
357
+
358
+ // Launch all agents (creates DB records, watches signals)
359
+ this.orchestrator.launchImplAgents(feature.id);
360
+
361
+ // Process stale signals that arrived while bridge was down
362
+ await this._processStaleImplSignals(feature, tree);
363
+ }
364
+
365
+ /**
366
+ * Process stale implementation signals.
367
+ */
368
+ async _processStaleImplSignals(feature, tree) {
369
+ const fileIO = this.orchestrator.fileIO;
370
+ const slug = feature.slug;
371
+
372
+ // Feature Lead signals
373
+ if (tree.featureLead) {
374
+ const flDir = tree.featureLead;
375
+ const staleChecks = [
376
+ [SIGNAL.AGENT_RESPONSE, "impl:response", { slug, agent: "feature-lead" }],
377
+ [SIGNAL.FEATURE_COMPLETE, "impl:featureComplete", { slug }],
378
+ [SIGNAL.PHASE_DONE, "impl:phaseDone", { slug }],
379
+ [SIGNAL.CONTEXT_REFRESH, "impl:contextRefresh", { slug }],
380
+ [SIGNAL.NEEDS_RESTART, "impl:needsRestart", { slug, dir: flDir }],
381
+ ];
382
+ for (const [sig, event, data] of staleChecks) {
383
+ const filePath = path.join(flDir, sig);
384
+ if (fs.existsSync(filePath)) {
385
+ console.log(`[recovery] Processing stale FL ${sig} for ${slug}`);
386
+ fileIO.emit(event, { ...data, filePath });
387
+ }
388
+ }
389
+ }
390
+
391
+ // Operator signals
392
+ if (tree.operator) {
393
+ const opDir = tree.operator;
394
+ const opResponse = path.join(opDir, SIGNAL.AGENT_RESPONSE);
395
+ if (fs.existsSync(opResponse)) {
396
+ console.log(`[recovery] Processing stale operator .agent-response for ${slug}`);
397
+ fileIO.emit("impl:operatorResponse", { slug, filePath: opResponse });
398
+ }
399
+ const opUserMsg = path.join(opDir, SIGNAL.USER_MESSAGE);
400
+ if (fs.existsSync(opUserMsg)) {
401
+ console.log(`[recovery] Processing stale operator .user-message for ${slug}`);
402
+ fileIO.emit("impl:userMessage", { slug, agent: "operator", filePath: opUserMsg });
403
+ }
404
+ }
405
+
406
+ // Team signals
407
+ for (const [teamNum, team] of Object.entries(tree.teams)) {
408
+ if (team.orchestrator) {
409
+ // Stale .task
410
+ const taskFile = path.join(team.orchestrator, SIGNAL.TASK);
411
+ if (fs.existsSync(taskFile)) {
412
+ console.log(`[recovery] Found existing .task for team-${teamNum} orchestrator`);
413
+ fileIO.emit("impl:orchTask", { slug, teamNum, filePath: taskFile });
414
+ }
415
+
416
+ // Stale .gate-ready
417
+ if (fs.existsSync(path.join(team.orchestrator, SIGNAL.GATE_READY))) {
418
+ console.log(`[recovery] Found stale .gate-ready for team-${teamNum}`);
419
+ fileIO.emit("impl:gateReady", { slug, teamNum, filePath: path.join(team.orchestrator, SIGNAL.GATE_READY) });
420
+ }
421
+
422
+ // Stale .question
423
+ if (fs.existsSync(path.join(team.orchestrator, SIGNAL.QUESTION))) {
424
+ console.log(`[recovery] Found stale .question for team-${teamNum}`);
425
+ fileIO.emit("impl:question", { slug, teamNum, filePath: path.join(team.orchestrator, SIGNAL.QUESTION) });
426
+ }
427
+
428
+ // Stale .crashed
429
+ if (fs.existsSync(path.join(team.orchestrator, SIGNAL.CRASHED))) {
430
+ console.log(`[recovery] Found stale .crashed for team-${teamNum}`);
431
+ fileIO.emit("impl:crashed", { slug, agent: `orch-${teamNum}`, teamNum, filePath: path.join(team.orchestrator, SIGNAL.CRASHED) });
432
+ }
433
+
434
+ // Stale .needs-restart
435
+ if (fs.existsSync(path.join(team.orchestrator, SIGNAL.NEEDS_RESTART))) {
436
+ console.log(`[recovery] Processing stale .needs-restart for team-${teamNum} orch`);
437
+ fileIO.emit("impl:needsRestart", { slug, dir: team.orchestrator, filePath: path.join(team.orchestrator, SIGNAL.NEEDS_RESTART) });
438
+ }
439
+ }
440
+
441
+ // Role agent stale signals
442
+ for (const [role, roleDir] of Object.entries(team.roles)) {
443
+ if (fs.existsSync(path.join(roleDir, SIGNAL.TASK))) {
444
+ console.log(`[recovery] Found existing .task for team-${teamNum} ${role}`);
445
+ fileIO.emit("impl:task", { slug, teamNum, role, filePath: path.join(roleDir, SIGNAL.TASK) });
446
+ }
447
+ if (fs.existsSync(path.join(roleDir, SIGNAL.DONE))) {
448
+ console.log(`[recovery] Found stale .done for team-${teamNum} ${role}`);
449
+ fileIO.emit("impl:done", { slug, teamNum, role, agent: `role-${teamNum}-${role}`, filePath: path.join(roleDir, SIGNAL.DONE) });
450
+ }
451
+ }
452
+ }
453
+
454
+ // Feature review stale signals
455
+ for (const [role, reviewDir] of Object.entries(tree.featureReview)) {
456
+ if (fs.existsSync(path.join(reviewDir, SIGNAL.DONE))) {
457
+ console.log(`[recovery] Processing stale review .done for ${slug}/${role}`);
458
+ fileIO.emit("impl:done", { slug, agent: `review-${role}`, role, filePath: path.join(reviewDir, SIGNAL.DONE) });
459
+ }
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Recover relay queue entries stuck in 'processing' state (bridge died mid-relay).
465
+ * Fallback: post raw content directly.
466
+ */
467
+ async _recoverRelayQueue() {
468
+ const stuckEntries = queries.getAllProcessingRelays();
469
+ if (!stuckEntries.length) return;
470
+
471
+ console.log(`[recovery] Found ${stuckEntries.length} stuck relay queue entries`);
472
+ for (const entry of stuckEntries) {
473
+ // Skip decision-needed entries — already rendered by postDecision()
474
+ if (entry.event_hint === "decision-needed") {
475
+ queries.updateRelayStatus(entry.id, "posted", { processedAt: new Date().toISOString() });
476
+ continue;
477
+ }
478
+ try {
479
+ const feature = queries.getFeatureById(entry.feature_id);
480
+ if (!feature) {
481
+ queries.updateRelayStatus(entry.id, "failed");
482
+ continue;
483
+ }
484
+ console.log(`[recovery] Fallback posting relay ${entry.id} (${entry.source_agent}/${entry.event_hint})`);
485
+ await this.adapter.postAgentResponse(feature.id, entry.source_agent, entry.raw_content);
486
+ queries.updateRelayStatus(entry.id, "posted", { processedAt: new Date().toISOString() });
487
+ } catch (err) {
488
+ console.error(`[recovery] Failed to recover relay ${entry.id}:`, err.message);
489
+ queries.updateRelayStatus(entry.id, "failed");
490
+ }
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Clean up agents that are marked 'running' in DB but whose PIDs are dead.
496
+ */
497
+ async _cleanDeadAgents() {
498
+ const runningAgents = queries.getAllRunningAgents();
499
+ for (const agent of runningAgents) {
500
+ if (agent.pid && !this.orchestrator.supervisor.isAlive(agent.pid)) {
501
+ console.log(`[recovery] Agent ${agent.agent_key} PID ${agent.pid} is dead — marking crashed`);
502
+ queries.updateAgentStatus(agent.id, "crashed");
503
+ queries.insertEvent(agent.feature_id, "agent-crashed", "bridge",
504
+ `${agent.agent_key} found dead on recovery (PID ${agent.pid})`);
505
+ }
506
+ }
507
+ }
508
+ }