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/index.js ADDED
@@ -0,0 +1,1488 @@
1
+ #!/usr/bin/env node
2
+ // iriai-build — Socket Mode bridge between Slack and iriai planning pipeline.
3
+ //
4
+ // Routes Slack messages to signal files, watches signal files for agent responses,
5
+ // posts them back to Slack. Manages the full feature lifecycle from [FEATURE] post
6
+ // through planning pipeline to implementation kickoff.
7
+
8
+ import { SocketModeClient } from "@slack/socket-mode";
9
+ import { WebClient } from "@slack/web-api";
10
+ import chokidar from "chokidar";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { spawn, execSync } from "node:child_process";
14
+
15
+ // ─── Config ──────────────────────────────────────────────────────────────────
16
+
17
+ const SLACK_APP_TOKEN = process.env.SLACK_APP_TOKEN; // xapp-... (Socket Mode)
18
+ const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN; // xoxb-... (Web API)
19
+ const PLANNING_CHANNEL = process.env.SLACK_CHANNEL_ID; // #planning channel ID
20
+
21
+ if (!SLACK_APP_TOKEN || !SLACK_BOT_TOKEN || !PLANNING_CHANNEL) {
22
+ console.error(
23
+ "Missing required env vars: SLACK_APP_TOKEN, SLACK_BOT_TOKEN, SLACK_CHANNEL_ID"
24
+ );
25
+ process.exit(1);
26
+ }
27
+
28
+ const PLANNING_BASE =
29
+ process.env.PLANNING_SIGNAL_BASE ||
30
+ path.join(process.env.HOME, "src/iriai/.planning");
31
+ const IRIAI_TEAM_DIR =
32
+ process.env.IRIAI_TEAM_DIR ||
33
+ path.join(process.env.HOME, "src/iriai/iriai-team");
34
+ const SCRIPTS_DIR = path.join(IRIAI_TEAM_DIR, "scripts");
35
+ const IMPL_BASE =
36
+ process.env.IMPL_SIGNAL_BASE ||
37
+ path.join(process.env.HOME, "src/iriai/.implementation");
38
+ const STATE_FILE = path.join(PLANNING_BASE, "lead", ".bridge-state.json");
39
+
40
+ const ROLE_DIRS = {
41
+ pm: path.join(PLANNING_BASE, "pm"),
42
+ designer: path.join(PLANNING_BASE, "design"),
43
+ architect: path.join(PLANNING_BASE, "architect"),
44
+ "plan-compiler": path.join(PLANNING_BASE, "plan-compiler"),
45
+ lead: path.join(PLANNING_BASE, "lead"),
46
+ };
47
+
48
+ const ROLE_LABELS = {
49
+ pm: "PM",
50
+ designer: "Designer",
51
+ architect: "Architect",
52
+ "plan-compiler": "Plan Compiler",
53
+ lead: "Feature Lead",
54
+ };
55
+
56
+ // Pipeline order for planning roles
57
+ const PIPELINE_ORDER = ["pm", "designer", "architect", "plan-compiler"];
58
+
59
+ // ─── State ───────────────────────────────────────────────────────────────────
60
+
61
+ // thread_ts -> { feature_slug, active_role, signal_dir, plan_summary_ts, impl_channel }
62
+ let features = {};
63
+ let watchers = [];
64
+
65
+ // Track running role runner PIDs to prevent duplicate spawns
66
+ // role -> child process pid
67
+ const runningRoles = {};
68
+
69
+ // Track implementation signal watchers (separate from planning watchers)
70
+ let implWatchers = [];
71
+
72
+ // Track running impl processes: key -> { pid }
73
+ // Keys: "fl-<slug>", "team-<slug>-N", "role-<slug>-N-<role>", etc.
74
+ const implProcesses = {};
75
+
76
+ // ─── Clients ─────────────────────────────────────────────────────────────────
77
+
78
+ const socketClient = new SocketModeClient({ appToken: SLACK_APP_TOKEN });
79
+ const web = new WebClient(SLACK_BOT_TOKEN);
80
+
81
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
82
+
83
+ function slugify(text) {
84
+ return text
85
+ .toLowerCase()
86
+ .replace(/[^a-z0-9]+/g, "-")
87
+ .replace(/^-|-$/g, "")
88
+ .slice(0, 40);
89
+ }
90
+
91
+ function ensureDir(dir) {
92
+ fs.mkdirSync(dir, { recursive: true });
93
+ }
94
+
95
+ function saveState() {
96
+ ensureDir(path.dirname(STATE_FILE));
97
+ fs.writeFileSync(STATE_FILE, JSON.stringify(features, null, 2));
98
+ }
99
+
100
+ function loadState() {
101
+ try {
102
+ if (fs.existsSync(STATE_FILE)) {
103
+ features = JSON.parse(fs.readFileSync(STATE_FILE, "utf-8"));
104
+ console.log(
105
+ `Restored state: ${Object.keys(features).length} active features`
106
+ );
107
+ }
108
+ } catch {
109
+ console.warn("Could not load bridge state, starting fresh");
110
+ features = {};
111
+ }
112
+ }
113
+
114
+ function parseRole(text) {
115
+ const lower = text.toLowerCase();
116
+ if (lower.includes("@pm")) return "pm";
117
+ if (lower.includes("@designer")) return "designer";
118
+ if (lower.includes("@architect")) return "architect";
119
+ if (lower.includes("@compiler") || lower.includes("@plan-compiler")) return "plan-compiler";
120
+ if (lower.includes("@lead")) return "lead";
121
+ return null;
122
+ }
123
+
124
+ async function postToThread(channel, thread_ts, text) {
125
+ await web.chat.postMessage({ channel, thread_ts, text });
126
+ }
127
+
128
+ async function addReaction(channel, timestamp, reaction) {
129
+ try {
130
+ await web.reactions.add({ channel, name: reaction, timestamp });
131
+ } catch {
132
+ // Ignore — reaction may already exist or message may be gone
133
+ }
134
+ }
135
+
136
+ async function removeReaction(channel, timestamp, reaction) {
137
+ try {
138
+ await web.reactions.remove({ channel, name: reaction, timestamp });
139
+ } catch {
140
+ // Ignore — reaction may not exist
141
+ }
142
+ }
143
+
144
+ // Track the last user message ts per feature thread so we can remove :eyes: when agent responds
145
+ // thread_ts -> last_user_message_ts
146
+ const pendingUserMessages = {};
147
+
148
+ // Bot's own user ID — populated at startup to filter self-reactions
149
+ let botUserId = null;
150
+
151
+ function findArtifact(filename) {
152
+ const planDir = path.join(IRIAI_TEAM_DIR, "implementation-plans", "current");
153
+ try {
154
+ const files = fs.readdirSync(planDir);
155
+ const match = files.find((f) => f.includes(filename) && f.endsWith(".md"));
156
+ if (match) return path.join(planDir, match);
157
+ } catch {
158
+ // Directory or file doesn't exist yet
159
+ }
160
+ return null;
161
+ }
162
+
163
+ async function uploadArtifact(channel, thread_ts, filePath, title) {
164
+ try {
165
+ const content = fs.readFileSync(filePath, "utf-8");
166
+ await web.filesUploadV2({
167
+ channel_id: channel,
168
+ thread_ts,
169
+ content,
170
+ filename: path.basename(filePath),
171
+ title,
172
+ });
173
+ } catch (err) {
174
+ console.error(`Error uploading artifact ${title}:`, err.message);
175
+ // Fall back to posting as text if upload fails
176
+ const content = fs.readFileSync(filePath, "utf-8");
177
+ const truncated = content.length > 3000
178
+ ? content.slice(0, 3000) + "\n\n_(truncated — full document in repo)_"
179
+ : content;
180
+ await postToThread(channel, thread_ts, `*${title}:*\n\n${truncated}`);
181
+ }
182
+ }
183
+
184
+ // Known repo paths in the monorepo
185
+ const KNOWN_REPOS = [
186
+ "first-party-apps/directory/directory-backend",
187
+ "first-party-apps/directory/directory-frontend",
188
+ "first-party-apps/events/events-backend",
189
+ "first-party-apps/events/events-frontend",
190
+ "first-party-apps/subdomain-home/subdomain-home-frontend",
191
+ "first-party-apps/subdomain-home/subdomain-home-server",
192
+ "frontend-apps/iriai-app/iriai-app-bff",
193
+ "frontend-apps/iriai-app/iriai-app-frontend",
194
+ "packages/auth-python",
195
+ "packages/auth-react",
196
+ "platform/auth/auth-frontend",
197
+ "platform/auth/auth-service",
198
+ "platform/deploy-console/deploy-console-frontend",
199
+ "platform/deploy-console/deploy-console-service",
200
+ "platform/integration-engine/integration-engine-service",
201
+ ];
202
+
203
+ function detectReposFromPlan() {
204
+ const planPath = findArtifact("implementation-plan");
205
+ if (!planPath) return [];
206
+
207
+ const content = fs.readFileSync(planPath, "utf-8");
208
+ return KNOWN_REPOS.filter((repo) => content.includes(repo));
209
+ }
210
+
211
+ // ─── Signal File Watching ────────────────────────────────────────────────────
212
+
213
+ function watchAgentResponses() {
214
+ // Watch all role signal dirs for .agent-response files
215
+ const dirsToWatch = Object.values(ROLE_DIRS);
216
+
217
+ for (const dir of dirsToWatch) {
218
+ ensureDir(dir);
219
+ }
220
+
221
+ const watcher = chokidar.watch(
222
+ dirsToWatch.map((d) => path.join(d, ".agent-response")),
223
+ { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 500 } }
224
+ );
225
+
226
+ watcher.on("add", handleAgentResponse);
227
+ watcher.on("change", handleAgentResponse);
228
+ watchers.push(watcher);
229
+
230
+ // Also watch for .done files (phase completion)
231
+ const doneWatcher = chokidar.watch(
232
+ dirsToWatch.map((d) => path.join(d, ".done")),
233
+ { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 500 } }
234
+ );
235
+
236
+ doneWatcher.on("add", handleDoneSignal);
237
+ doneWatcher.on("change", handleDoneSignal);
238
+ watchers.push(doneWatcher);
239
+
240
+ // Watch for .question files (agent questions needing user input)
241
+ const questionWatcher = chokidar.watch(
242
+ dirsToWatch.map((d) => path.join(d, ".question")),
243
+ { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 500 } }
244
+ );
245
+
246
+ questionWatcher.on("add", handleQuestionSignal);
247
+ questionWatcher.on("change", handleQuestionSignal);
248
+ watchers.push(questionWatcher);
249
+
250
+ console.log("Watching signal dirs for .agent-response, .done, .question");
251
+ }
252
+
253
+ async function handleAgentResponse(filePath) {
254
+ try {
255
+ const content = fs.readFileSync(filePath, "utf-8").trim();
256
+ if (!content) return;
257
+
258
+ // Determine which role produced this response
259
+ const dir = path.dirname(filePath);
260
+ const role = Object.entries(ROLE_DIRS).find(
261
+ ([, d]) => d === dir
262
+ )?.[0];
263
+ if (!role) return;
264
+
265
+ const label = ROLE_LABELS[role] || role;
266
+
267
+ // Find the feature this role is currently working on
268
+ const feature = Object.entries(features).find(
269
+ ([, f]) => f.active_role === role
270
+ );
271
+
272
+ if (feature) {
273
+ const [thread_ts, featureState] = feature;
274
+ const channel = role === "lead" && featureState.impl_channel
275
+ ? featureState.impl_channel
276
+ : PLANNING_CHANNEL;
277
+
278
+ await postToThread(
279
+ channel,
280
+ thread_ts,
281
+ `*[${label}]* ${content}`
282
+ );
283
+
284
+ // Remove :eyes: from the user's message now that the agent has responded
285
+ const pendingTs = pendingUserMessages[thread_ts];
286
+ if (pendingTs) {
287
+ await removeReaction(channel, pendingTs, "eyes");
288
+ delete pendingUserMessages[thread_ts];
289
+ }
290
+ }
291
+
292
+ // Delete after posting
293
+ fs.unlinkSync(filePath);
294
+ } catch (err) {
295
+ console.error("Error handling agent response:", err.message);
296
+ }
297
+ }
298
+
299
+ async function handleQuestionSignal(filePath) {
300
+ try {
301
+ const content = fs.readFileSync(filePath, "utf-8").trim();
302
+ if (!content) return;
303
+
304
+ const dir = path.dirname(filePath);
305
+ const role = Object.entries(ROLE_DIRS).find(
306
+ ([, d]) => d === dir
307
+ )?.[0];
308
+ if (!role) return;
309
+
310
+ const label = ROLE_LABELS[role] || role;
311
+
312
+ // Find the feature this role is working on
313
+ const feature = Object.entries(features).find(
314
+ ([, f]) => f.active_role === role
315
+ );
316
+
317
+ if (feature) {
318
+ const [thread_ts, featureState] = feature;
319
+ const channel = role === "lead" && featureState.impl_channel
320
+ ? featureState.impl_channel
321
+ : PLANNING_CHANNEL;
322
+
323
+ // Post verbatim with full attribution: role, phase/task context
324
+ const phase = featureState.active_role;
325
+ const slug = featureState.feature_slug;
326
+ await postToThread(
327
+ channel,
328
+ thread_ts,
329
+ `*[${label} — ${slug} / ${phase} phase]*\n\n${content}`
330
+ );
331
+ }
332
+
333
+ fs.unlinkSync(filePath);
334
+ } catch (err) {
335
+ console.error("Error handling question signal:", err.message);
336
+ }
337
+ }
338
+
339
+ async function handleDoneSignal(filePath) {
340
+ try {
341
+ const dir = path.dirname(filePath);
342
+ const role = Object.entries(ROLE_DIRS).find(
343
+ ([, d]) => d === dir
344
+ )?.[0];
345
+ if (!role) return;
346
+
347
+ const label = ROLE_LABELS[role] || role;
348
+
349
+ // Find the feature where this role was active
350
+ const feature = Object.entries(features).find(
351
+ ([, f]) => f.active_role === role
352
+ );
353
+ if (!feature) return;
354
+
355
+ const [thread_ts, featureState] = feature;
356
+
357
+ // Read .output if it exists
358
+ const outputPath = path.join(dir, ".output");
359
+ let output = "";
360
+ if (fs.existsSync(outputPath)) {
361
+ output = fs.readFileSync(outputPath, "utf-8").trim();
362
+ }
363
+
364
+ // Post phase completion
365
+ await postToThread(
366
+ PLANNING_CHANNEL,
367
+ thread_ts,
368
+ `*[Pipeline]* ${label} phase complete.${output ? ` Output: ${output}` : ""}`
369
+ );
370
+
371
+ // Artifacts are uploaded together when planning completes (postPlanForApproval)
372
+
373
+ // Advance to next role in pipeline
374
+ const currentIndex = PIPELINE_ORDER.indexOf(role);
375
+
376
+ if (currentIndex >= 0 && currentIndex < PIPELINE_ORDER.length - 1) {
377
+ // Advance to next planning role
378
+ const nextRole = PIPELINE_ORDER[currentIndex + 1];
379
+ const nextLabel = ROLE_LABELS[nextRole];
380
+ featureState.active_role = nextRole;
381
+ saveState();
382
+
383
+ await postToThread(
384
+ PLANNING_CHANNEL,
385
+ thread_ts,
386
+ `*[Pipeline]* Starting ${nextLabel} phase...`
387
+ );
388
+
389
+ dispatchToRole(nextRole, featureState.feature_slug, thread_ts);
390
+ } else if (role === "plan-compiler") {
391
+ // Plan Compiler done — post plan for approval (last planning step)
392
+ await postPlanForApproval(thread_ts, featureState);
393
+ }
394
+
395
+ // Clean up done/output signals
396
+ fs.unlinkSync(filePath);
397
+ if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
398
+ } catch (err) {
399
+ console.error("Error handling done signal:", err.message);
400
+ }
401
+ }
402
+
403
+ // ─── Implementation Signal Handling ──────────────────────────────────────────
404
+ // Phases 1, 4, 5, 6, 7, 8: Full implementation lifecycle from Slack
405
+
406
+ function discoverImplSignalTree(slug) {
407
+ const featureDir = path.join(IMPL_BASE, "features", slug);
408
+ const tree = {
409
+ featureDir,
410
+ featureLead: null,
411
+ operator: null,
412
+ featureReview: {},
413
+ teams: {},
414
+ };
415
+
416
+ const flDir = path.join(featureDir, "feature-lead");
417
+ if (fs.existsSync(flDir)) tree.featureLead = flDir;
418
+
419
+ const opDir = path.join(featureDir, "operator");
420
+ if (fs.existsSync(opDir)) tree.operator = opDir;
421
+
422
+ const reviewDir = path.join(featureDir, "feature-review");
423
+ try {
424
+ for (const entry of fs.readdirSync(reviewDir)) {
425
+ const roleDir = path.join(reviewDir, entry);
426
+ if (fs.statSync(roleDir).isDirectory()) {
427
+ tree.featureReview[entry] = roleDir;
428
+ }
429
+ }
430
+ } catch { /* no review dir yet */ }
431
+
432
+ const teamsDir = path.join(featureDir, "teams");
433
+ try {
434
+ for (const team of fs.readdirSync(teamsDir).sort()) {
435
+ if (!team.startsWith("team-")) continue;
436
+ const teamDir = path.join(teamsDir, team);
437
+ if (!fs.statSync(teamDir).isDirectory()) continue;
438
+ const teamNum = team.replace("team-", "");
439
+ tree.teams[teamNum] = {
440
+ dir: teamDir,
441
+ orchestrator: null,
442
+ roles: {},
443
+ };
444
+
445
+ const orchDir = path.join(teamDir, "orchestrator");
446
+ if (fs.existsSync(orchDir)) tree.teams[teamNum].orchestrator = orchDir;
447
+
448
+ const rolesDir = path.join(teamDir, "roles");
449
+ try {
450
+ for (const role of fs.readdirSync(rolesDir)) {
451
+ const roleDir = path.join(rolesDir, role);
452
+ if (fs.statSync(roleDir).isDirectory()) {
453
+ tree.teams[teamNum].roles[role] = roleDir;
454
+ }
455
+ }
456
+ } catch { /* no roles dir */ }
457
+ }
458
+ } catch { /* no teams dir */ }
459
+
460
+ return tree;
461
+ }
462
+
463
+ function watchImplSignals(slug, thread_ts) {
464
+ const featureState = features[thread_ts];
465
+ if (!featureState) return;
466
+
467
+ const tree = discoverImplSignalTree(slug);
468
+ featureState.impl_signal_tree = tree;
469
+ saveState();
470
+
471
+ const flDir = tree.featureLead;
472
+ if (!flDir) {
473
+ console.warn(`No Feature Lead dir found for ${slug}`);
474
+ return;
475
+ }
476
+
477
+ // Watch Feature Lead's .agent-response → post to #impl channel
478
+ const responseWatcher = chokidar.watch(
479
+ path.join(flDir, ".agent-response"),
480
+ { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 500 } }
481
+ );
482
+ responseWatcher.on("add", (fp) => handleImplAgentResponse(fp, slug, thread_ts));
483
+ responseWatcher.on("change", (fp) => handleImplAgentResponse(fp, slug, thread_ts));
484
+ implWatchers.push(responseWatcher);
485
+
486
+ // Watch Operator's .agent-response → post to #impl channel
487
+ if (tree.operator) {
488
+ const opResponseWatcher = chokidar.watch(
489
+ path.join(tree.operator, ".agent-response"),
490
+ { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 500 } }
491
+ );
492
+ opResponseWatcher.on("add", (fp) => handleOperatorResponse(fp, slug, thread_ts));
493
+ opResponseWatcher.on("change", (fp) => handleOperatorResponse(fp, slug, thread_ts));
494
+ implWatchers.push(opResponseWatcher);
495
+ }
496
+
497
+ // Watch Feature Lead's .feature-complete
498
+ const completeWatcher = chokidar.watch(
499
+ path.join(flDir, ".feature-complete"),
500
+ { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 500 } }
501
+ );
502
+ completeWatcher.on("add", (fp) => handleFeatureComplete(fp, slug, thread_ts));
503
+ implWatchers.push(completeWatcher);
504
+
505
+ // Watch .needs-restart across all agent dirs for handover
506
+ const allDirs = [flDir];
507
+ if (tree.operator) allDirs.push(tree.operator);
508
+ for (const team of Object.values(tree.teams)) {
509
+ if (team.orchestrator) allDirs.push(team.orchestrator);
510
+ for (const roleDir of Object.values(team.roles)) {
511
+ allDirs.push(roleDir);
512
+ }
513
+ }
514
+ for (const reviewDir of Object.values(tree.featureReview)) {
515
+ allDirs.push(reviewDir);
516
+ }
517
+
518
+ const restartWatcher = chokidar.watch(
519
+ allDirs.map((d) => path.join(d, ".needs-restart")),
520
+ { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 500 } }
521
+ );
522
+ restartWatcher.on("add", (fp) => handleNeedsRestart(fp, slug, thread_ts));
523
+ implWatchers.push(restartWatcher);
524
+
525
+ console.log(`Watching impl signals for ${slug}: Feature Lead + ${allDirs.length} total dirs`);
526
+ }
527
+
528
+ // Phase 4: Route Feature Lead messages to #impl channel
529
+ async function handleImplAgentResponse(filePath, slug, thread_ts) {
530
+ try {
531
+ const content = fs.readFileSync(filePath, "utf-8").trim();
532
+ if (!content) return;
533
+
534
+ const featureState = features[thread_ts];
535
+ if (!featureState) return;
536
+
537
+ const channel = featureState.impl_channel || PLANNING_CHANNEL;
538
+
539
+ // Detect gate evidence pattern for approval flow
540
+ const isGateEvidence = /gate\s*(evidence|summary|review)/i.test(content) ||
541
+ (/approve|reject/i.test(content.slice(-300)) && content.length > 200);
542
+
543
+ const result = await web.chat.postMessage({
544
+ channel,
545
+ text: `*[Feature Lead]* ${content}`,
546
+ });
547
+
548
+ // If this looks like gate evidence, track for reaction-based approval
549
+ if (isGateEvidence) {
550
+ featureState.gate_evidence_ts = result.ts;
551
+ featureState.gate_evidence_channel = channel;
552
+ saveState();
553
+ // Add reaction hints for approval
554
+ await addReaction(channel, result.ts, "white_check_mark");
555
+ await addReaction(channel, result.ts, "x");
556
+ }
557
+
558
+ // Remove :eyes: from pending user message
559
+ const pendingTs = pendingUserMessages[thread_ts];
560
+ if (pendingTs) {
561
+ await removeReaction(channel, pendingTs, "eyes");
562
+ delete pendingUserMessages[thread_ts];
563
+ }
564
+
565
+ fs.unlinkSync(filePath);
566
+ } catch (err) {
567
+ console.error("Error handling impl agent response:", err.message);
568
+ }
569
+ }
570
+
571
+ // Operator responses → post to #impl channel with [Operator] prefix
572
+ async function handleOperatorResponse(filePath, slug, thread_ts) {
573
+ try {
574
+ const content = fs.readFileSync(filePath, "utf-8").trim();
575
+ if (!content) return;
576
+
577
+ const featureState = features[thread_ts];
578
+ if (!featureState) return;
579
+
580
+ const channel = featureState.impl_channel || PLANNING_CHANNEL;
581
+
582
+ await web.chat.postMessage({
583
+ channel,
584
+ text: `*[Operator]* ${content}`,
585
+ });
586
+
587
+ // Remove :eyes: from pending user message
588
+ const pendingTs = pendingUserMessages[thread_ts];
589
+ if (pendingTs) {
590
+ await removeReaction(channel, pendingTs, "eyes");
591
+ delete pendingUserMessages[thread_ts];
592
+ }
593
+
594
+ fs.unlinkSync(filePath);
595
+ } catch (err) {
596
+ console.error("Error handling operator response:", err.message);
597
+ }
598
+ }
599
+
600
+ // Phase 6: Gate approval via Slack reactions
601
+ async function handleGateApproval(thread_ts, featureState) {
602
+ const flDir = featureState.impl_signal_tree?.featureLead;
603
+ if (!flDir) return;
604
+
605
+ const channel = featureState.impl_channel || PLANNING_CHANNEL;
606
+ await postToThread(
607
+ channel,
608
+ null,
609
+ `*[Pipeline]* Gate approved! Feature Lead will advance teams to next phase.`
610
+ );
611
+
612
+ // Write approval to Feature Lead's .user-message
613
+ fs.writeFileSync(
614
+ path.join(flDir, ".user-message"),
615
+ "GATE APPROVED. Advance all teams to the next phase."
616
+ );
617
+
618
+ featureState.gate_evidence_ts = null;
619
+ saveState();
620
+ }
621
+
622
+ async function handleGateRejection(thread_ts, featureState, reason) {
623
+ const flDir = featureState.impl_signal_tree?.featureLead;
624
+ if (!flDir) return;
625
+
626
+ const channel = featureState.impl_channel || PLANNING_CHANNEL;
627
+ await postToThread(
628
+ channel,
629
+ null,
630
+ `*[Pipeline]* Gate rejected. Feature Lead will re-dispatch with feedback.`
631
+ );
632
+
633
+ // Write rejection to Feature Lead's .user-message
634
+ fs.writeFileSync(
635
+ path.join(flDir, ".user-message"),
636
+ `GATE REJECTED: ${reason || "Please revise and resubmit."}`
637
+ );
638
+
639
+ featureState.gate_evidence_ts = null;
640
+ saveState();
641
+ }
642
+
643
+ // Phase 7: Feature completion
644
+ async function handleFeatureComplete(filePath, slug, thread_ts) {
645
+ try {
646
+ const featureState = features[thread_ts];
647
+ if (!featureState) return;
648
+
649
+ const implChannel = featureState.impl_channel;
650
+
651
+ // Post summary to impl channel
652
+ if (implChannel) {
653
+ await web.chat.postMessage({
654
+ channel: implChannel,
655
+ text: `*[Pipeline]* Feature *${slug}* is complete! All gates passed. :tada:`,
656
+ });
657
+ }
658
+
659
+ // Post broadcast to #planning thread
660
+ await postToThread(
661
+ PLANNING_CHANNEL,
662
+ thread_ts,
663
+ `*[Pipeline]* Feature complete: *${slug}* :tada:\nAll gates approved and merged.`
664
+ );
665
+
666
+ // Also broadcast to channel
667
+ await web.chat.postMessage({
668
+ channel: PLANNING_CHANNEL,
669
+ thread_ts,
670
+ reply_broadcast: true,
671
+ text: `*Feature complete: ${slug}* :tada:`,
672
+ });
673
+
674
+ // Archive feature state
675
+ featureState.active_role = "complete";
676
+ featureState.completed_at = new Date().toISOString();
677
+ saveState();
678
+
679
+ // Kill all impl processes for this feature
680
+ killImplProcesses(slug);
681
+
682
+ console.log(`Feature ${slug} complete`);
683
+ } catch (err) {
684
+ console.error("Error handling feature complete:", err.message);
685
+ }
686
+ }
687
+
688
+ // Phase 8: Handover & context recovery
689
+ async function handleNeedsRestart(filePath, slug, thread_ts) {
690
+ try {
691
+ const dir = path.dirname(filePath);
692
+ const featureState = features[thread_ts];
693
+ if (!featureState) return;
694
+
695
+ // Determine which agent needs restart by matching dir to signal tree
696
+ const tree = featureState.impl_signal_tree;
697
+ if (!tree) return;
698
+
699
+ let agentKey = null;
700
+ let agentLabel = null;
701
+ let runnerScript = null;
702
+ let runnerArgs = [];
703
+ let runnerCwd = null;
704
+ let runnerSignalDir = dir;
705
+
706
+ if (tree.featureLead === dir) {
707
+ agentKey = `fl-${slug}`;
708
+ agentLabel = "Feature Lead";
709
+ runnerScript = path.join(SCRIPTS_DIR, "run-feature-lead.sh");
710
+ const numTeams = Object.keys(tree.teams).length || 2;
711
+ runnerArgs = [slug, String(numTeams), "dynamic", "--slack"];
712
+ runnerCwd = IRIAI_TEAM_DIR;
713
+ } else if (tree.operator === dir) {
714
+ agentKey = `op-${slug}`;
715
+ agentLabel = "Operator";
716
+ runnerScript = path.join(SCRIPTS_DIR, "run-operator.sh");
717
+ runnerArgs = [slug, "--slack"];
718
+ runnerCwd = IRIAI_TEAM_DIR;
719
+ } else {
720
+ for (const [teamNum, team] of Object.entries(tree.teams)) {
721
+ if (team.orchestrator === dir) {
722
+ agentKey = `team-${slug}-${teamNum}`;
723
+ agentLabel = `Team ${teamNum} Orchestrator`;
724
+ runnerScript = path.join(SCRIPTS_DIR, "run-team.sh");
725
+ runnerArgs = [teamNum, "dynamic", slug, "--slack"];
726
+ runnerCwd = IRIAI_TEAM_DIR;
727
+ break;
728
+ }
729
+ for (const [role, roleDir] of Object.entries(team.roles)) {
730
+ if (roleDir === dir) {
731
+ agentKey = `role-${slug}-${teamNum}-${role}`;
732
+ agentLabel = `Team ${teamNum} ${role}`;
733
+ runnerScript = path.join(SCRIPTS_DIR, "run-role.sh");
734
+ const worktreeDir = path.join(
735
+ process.env.HOME, "src/iriai/.features", slug
736
+ );
737
+ const cwd = fs.existsSync(worktreeDir) ? worktreeDir : IRIAI_TEAM_DIR;
738
+ runnerArgs = [role, "opus", "--signal-dir", roleDir, "--cwd", cwd, "--slack"];
739
+ runnerCwd = cwd;
740
+ break;
741
+ }
742
+ }
743
+ if (agentKey) break;
744
+ }
745
+ }
746
+
747
+ if (!agentKey || !runnerScript) {
748
+ console.warn(`Could not identify agent for .needs-restart at ${dir}`);
749
+ fs.unlinkSync(filePath);
750
+ return;
751
+ }
752
+
753
+ // Read handover context
754
+ const handoverPath = path.join(dir, ".handover");
755
+ let handoverContent = "";
756
+ if (fs.existsSync(handoverPath)) {
757
+ handoverContent = fs.readFileSync(handoverPath, "utf-8");
758
+ }
759
+
760
+ // Kill the old process
761
+ if (implProcesses[agentKey]) {
762
+ try { process.kill(-implProcesses[agentKey], "SIGTERM"); } catch { /* gone */ }
763
+ delete implProcesses[agentKey];
764
+ }
765
+
766
+ // Clean up signal files
767
+ fs.unlinkSync(filePath);
768
+ if (fs.existsSync(handoverPath)) fs.unlinkSync(handoverPath);
769
+
770
+ // Post to impl channel
771
+ const channel = featureState.impl_channel || PLANNING_CHANNEL;
772
+ await web.chat.postMessage({
773
+ channel,
774
+ text: `*[Pipeline]* ${agentLabel} restarting (context limit). Work preserved.`,
775
+ });
776
+
777
+ // Respawn the runner (the runner script handles handover context internally
778
+ // via .handover files, or the Feature Lead's FEATURE-STATUS.md)
779
+ spawnImplRunner(agentKey, runnerScript, runnerArgs, runnerCwd, runnerSignalDir, {
780
+ IMPL_SIGNAL_BASE: IMPL_BASE,
781
+ });
782
+
783
+ console.log(`Restarted ${agentLabel} after context handover`);
784
+ } catch (err) {
785
+ console.error("Error handling needs-restart:", err.message);
786
+ }
787
+ }
788
+
789
+ // Spawn an implementation runner process (detached — fire and forget)
790
+ function spawnImplRunner(key, script, args, cwd, signalDir, extraEnv) {
791
+ // Kill existing process if any
792
+ if (implProcesses[key]) {
793
+ try { process.kill(-implProcesses[key], "SIGTERM"); } catch { /* gone */ }
794
+ delete implProcesses[key];
795
+ }
796
+
797
+ const child = spawn("bash", [script, ...args], {
798
+ cwd,
799
+ env: { ...process.env, ...extraEnv },
800
+ detached: true,
801
+ stdio: "ignore",
802
+ });
803
+ child.unref();
804
+
805
+ implProcesses[key] = child.pid;
806
+ console.log(`Spawned ${key} (pid ${child.pid})`);
807
+ return child.pid;
808
+ }
809
+
810
+ // Kill all impl processes for a feature
811
+ function killImplProcesses(slug) {
812
+ for (const [key, pid] of Object.entries(implProcesses)) {
813
+ if (key.includes(slug)) {
814
+ try { process.kill(-pid, "SIGTERM"); } catch { /* gone */ }
815
+ delete implProcesses[key];
816
+ }
817
+ }
818
+ }
819
+
820
+ // Phase 5: Launch implementation — spawn all runners after launch-feature.sh
821
+ function launchImplRunners(slug, thread_ts) {
822
+ const tree = discoverImplSignalTree(slug);
823
+ const featureState = features[thread_ts];
824
+ if (!featureState) return;
825
+ featureState.impl_signal_tree = tree;
826
+ saveState();
827
+
828
+ const numTeams = Object.keys(tree.teams).length || 2;
829
+ const worktreeDir = path.join(process.env.HOME, "src/iriai/.features", slug);
830
+ const featureCwd = fs.existsSync(worktreeDir) ? worktreeDir : IRIAI_TEAM_DIR;
831
+
832
+ // Spawn Feature Lead
833
+ if (tree.featureLead) {
834
+ spawnImplRunner(
835
+ `fl-${slug}`,
836
+ path.join(SCRIPTS_DIR, "run-feature-lead.sh"),
837
+ [slug, String(numTeams), "dynamic", "--slack"],
838
+ IRIAI_TEAM_DIR,
839
+ tree.featureLead,
840
+ { IMPL_SIGNAL_BASE: IMPL_BASE }
841
+ );
842
+ }
843
+
844
+ // Spawn Operator (user-facing responder)
845
+ if (tree.operator) {
846
+ spawnImplRunner(
847
+ `op-${slug}`,
848
+ path.join(SCRIPTS_DIR, "run-operator.sh"),
849
+ [slug, "--slack"],
850
+ IRIAI_TEAM_DIR,
851
+ tree.operator,
852
+ { IMPL_SIGNAL_BASE: IMPL_BASE }
853
+ );
854
+ }
855
+
856
+ // Spawn team orchestrators and role runners
857
+ for (const [teamNum, team] of Object.entries(tree.teams)) {
858
+ if (team.orchestrator) {
859
+ spawnImplRunner(
860
+ `team-${slug}-${teamNum}`,
861
+ path.join(SCRIPTS_DIR, "run-team.sh"),
862
+ [teamNum, "dynamic", slug, "--slack"],
863
+ IRIAI_TEAM_DIR,
864
+ team.orchestrator,
865
+ { IMPL_SIGNAL_BASE: IMPL_BASE }
866
+ );
867
+ }
868
+
869
+ for (const [role, roleDir] of Object.entries(team.roles)) {
870
+ spawnImplRunner(
871
+ `role-${slug}-${teamNum}-${role}`,
872
+ path.join(SCRIPTS_DIR, "run-role.sh"),
873
+ [role, "opus", "--signal-dir", roleDir, "--cwd", featureCwd, "--slack"],
874
+ featureCwd,
875
+ roleDir,
876
+ { IMPL_SIGNAL_BASE: IMPL_BASE }
877
+ );
878
+ }
879
+ }
880
+
881
+ // Spawn feature-review runners (code-reviewer, integration-tester, security-auditor)
882
+ if (tree.featureReview) {
883
+ for (const [role, roleDir] of Object.entries(tree.featureReview)) {
884
+ spawnImplRunner(
885
+ `review-${slug}-${role}`,
886
+ path.join(SCRIPTS_DIR, "run-role.sh"),
887
+ [role, "opus", "--signal-dir", roleDir, "--cwd", featureCwd, "--slack"],
888
+ featureCwd,
889
+ roleDir,
890
+ { IMPL_SIGNAL_BASE: IMPL_BASE }
891
+ );
892
+ }
893
+ }
894
+
895
+ console.log(`Launched all impl runners for ${slug}: FL + operator + ${numTeams} teams + feature-review`);
896
+ }
897
+
898
+ // Process any existing impl signals on startup/recovery
899
+ async function processExistingImplSignals() {
900
+ for (const [thread_ts, featureState] of Object.entries(features)) {
901
+ if (featureState.active_role !== "impl") continue;
902
+
903
+ const slug = featureState.feature_slug;
904
+ console.log(`Recovering impl watchers for active feature: ${slug}`);
905
+
906
+ // Re-establish watchers
907
+ watchImplSignals(slug, thread_ts);
908
+
909
+ // Check for unprocessed signals
910
+ const tree = featureState.impl_signal_tree;
911
+ if (!tree?.featureLead) continue;
912
+
913
+ const flDir = tree.featureLead;
914
+ const responsePath = path.join(flDir, ".agent-response");
915
+ if (fs.existsSync(responsePath)) {
916
+ console.log(`Found unprocessed .agent-response for impl Feature Lead`);
917
+ await handleImplAgentResponse(responsePath, slug, thread_ts);
918
+ }
919
+
920
+ if (tree.operator) {
921
+ const opResponsePath = path.join(tree.operator, ".agent-response");
922
+ if (fs.existsSync(opResponsePath)) {
923
+ console.log(`Found unprocessed .agent-response for Operator`);
924
+ await handleOperatorResponse(opResponsePath, slug, thread_ts);
925
+ }
926
+ }
927
+
928
+ const completePath = path.join(flDir, ".feature-complete");
929
+ if (fs.existsSync(completePath)) {
930
+ console.log(`Found unprocessed .feature-complete for ${slug}`);
931
+ await handleFeatureComplete(completePath, slug, thread_ts);
932
+ }
933
+ }
934
+ }
935
+
936
+ // ─── Dispatching ─────────────────────────────────────────────────────────────
937
+
938
+ function dispatchToRole(role, featureSlug, thread_ts) {
939
+ const signalDir = ROLE_DIRS[role];
940
+ ensureDir(signalDir);
941
+
942
+ // Read existing task content if the planning lead already wrote one
943
+ // Otherwise create a task header for Slack mode
944
+ const taskHeader = [
945
+ "SLACK_MODE=true",
946
+ `FEATURE_SLUG=${featureSlug}`,
947
+ `SIGNAL_DIR=${signalDir}`,
948
+ `THREAD_TS=${thread_ts}`,
949
+ "---",
950
+ ].join("\n");
951
+
952
+ // Check if there's an existing .task from the planning lead
953
+ const taskPath = path.join(signalDir, ".task");
954
+ let existingTask = "";
955
+ if (fs.existsSync(taskPath)) {
956
+ existingTask = fs.readFileSync(taskPath, "utf-8");
957
+ }
958
+
959
+ // Write task with Slack mode header prepended
960
+ const taskContent = existingTask
961
+ ? `${taskHeader}\n${existingTask}`
962
+ : `${taskHeader}\nStart the ${ROLE_LABELS[role]} phase for feature: ${featureSlug}`;
963
+
964
+ fs.writeFileSync(taskPath, taskContent);
965
+
966
+ // Kill any existing runner for this role before spawning a new one
967
+ if (runningRoles[role]) {
968
+ console.log(`Killing previous ${role} runner (pid ${runningRoles[role]})`);
969
+ try { process.kill(-runningRoles[role], "SIGTERM"); } catch { /* gone */ }
970
+ delete runningRoles[role];
971
+ }
972
+
973
+ // Launch the role runner in Slack mode (detached)
974
+ const script = path.join(SCRIPTS_DIR, "run-planning-role.sh");
975
+ if (fs.existsSync(script)) {
976
+ const child = spawn("bash", [script, role, "--slack"], {
977
+ cwd: signalDir,
978
+ env: { ...process.env, PLANNING_SIGNAL_BASE: PLANNING_BASE },
979
+ detached: true,
980
+ stdio: "ignore",
981
+ });
982
+ child.unref();
983
+
984
+ runningRoles[role] = child.pid;
985
+ console.log(`Dispatched ${role} in Slack mode (pid ${child.pid})`);
986
+ }
987
+ }
988
+
989
+ // ─── Plan Approval ───────────────────────────────────────────────────────────
990
+
991
+ async function postPlanForApproval(thread_ts, featureState) {
992
+ const slug = featureState.feature_slug;
993
+ const branch = `feature/${slug}`;
994
+
995
+ // Upload all three artifacts as file attachments in the thread
996
+ const prdPath = findArtifact("prd");
997
+ const designPath = findArtifact("design-decisions");
998
+ const planPath = findArtifact("implementation-plan");
999
+
1000
+ if (prdPath) await uploadArtifact(PLANNING_CHANNEL, thread_ts, prdPath, "PRD");
1001
+ if (designPath) await uploadArtifact(PLANNING_CHANNEL, thread_ts, designPath, "Design Decisions");
1002
+ if (planPath) await uploadArtifact(PLANNING_CHANNEL, thread_ts, planPath, "Implementation Plan");
1003
+
1004
+ // Post the approval prompt (thread-only)
1005
+ const result = await web.chat.postMessage({
1006
+ channel: PLANNING_CHANNEL,
1007
+ thread_ts,
1008
+ text: `*[Pipeline]* Plan ready for approval.\n\nReact :white_check_mark: to approve or :x: to reject.\nOr reply with "approved" / "rejected: <reason>".`,
1009
+ });
1010
+
1011
+ featureState.plan_summary_ts = result.ts;
1012
+
1013
+ // Broadcast a single summary to the channel (Also send to channel)
1014
+ const artifactList = [prdPath, designPath, planPath]
1015
+ .filter(Boolean)
1016
+ .map((p) => path.basename(p))
1017
+ .join(", ");
1018
+
1019
+ await web.chat.postMessage({
1020
+ channel: PLANNING_CHANNEL,
1021
+ thread_ts,
1022
+ reply_broadcast: true,
1023
+ text: [
1024
+ `*Planning complete: ${slug}*`,
1025
+ ``,
1026
+ `Branch: \`${branch}\``,
1027
+ `Artifacts: ${artifactList || "none"}`,
1028
+ ``,
1029
+ `React :white_check_mark: to approve or :x: to reject (in thread).`,
1030
+ ].join("\n"),
1031
+ });
1032
+
1033
+ featureState.active_role = null; // Waiting for approval
1034
+ saveState();
1035
+ }
1036
+
1037
+ async function handleApproval(thread_ts, featureState) {
1038
+ const slug = featureState.feature_slug;
1039
+
1040
+ await postToThread(
1041
+ PLANNING_CHANNEL,
1042
+ thread_ts,
1043
+ `*[Pipeline]* Plan approved! Creating implementation channel and launching feature...`
1044
+ );
1045
+
1046
+ // Create #impl-<slug> channel
1047
+ try {
1048
+ const result = await web.conversations.create({
1049
+ name: `impl-${slug}`.slice(0, 80),
1050
+ });
1051
+ featureState.impl_channel = result.channel.id;
1052
+ saveState();
1053
+
1054
+ await web.chat.postMessage({
1055
+ channel: result.channel.id,
1056
+ text: `Implementation channel for feature: *${slug}*\nPlanning thread: https://slack.com/archives/${PLANNING_CHANNEL}/p${thread_ts.replace(".", "")}`,
1057
+ });
1058
+ } catch (err) {
1059
+ // Channel may already exist
1060
+ if (err.data?.error === "name_taken") {
1061
+ try {
1062
+ const list = await web.conversations.list({ types: "public_channel", limit: 200 });
1063
+ const existing = list.channels.find(
1064
+ (c) => c.name === `impl-${slug}`.slice(0, 80)
1065
+ );
1066
+ if (existing) featureState.impl_channel = existing.id;
1067
+ } catch {
1068
+ // Continue without impl channel
1069
+ }
1070
+ }
1071
+ console.warn("Could not create impl channel:", err.message);
1072
+ }
1073
+
1074
+ // Auto-detect repos from the implementation plan and ask for confirmation.
1075
+ const detectedRepos = detectReposFromPlan();
1076
+
1077
+ featureState.pending_repos = detectedRepos;
1078
+ featureState.awaiting_repo_confirm = true;
1079
+ saveState();
1080
+
1081
+ const repoList = detectedRepos.length > 0
1082
+ ? detectedRepos.map((r) => ` - \`${r}\``).join("\n")
1083
+ : " _(none detected — check implementation plan)_";
1084
+
1085
+ const implChannelLink = featureState.impl_channel
1086
+ ? `*Impl channel:* <#${featureState.impl_channel}>`
1087
+ : "";
1088
+
1089
+ await postToThread(
1090
+ PLANNING_CHANNEL,
1091
+ thread_ts,
1092
+ [
1093
+ `*[Pipeline]* Ready to launch implementation.`,
1094
+ ``,
1095
+ `*Feature:* ${slug}`,
1096
+ `*Branch:* \`feature/${slug}\``,
1097
+ implChannelLink,
1098
+ `*Repos affected:*`,
1099
+ repoList,
1100
+ ``,
1101
+ `Reply *"go"* to create branches and start implementation.`,
1102
+ ].filter(Boolean).join("\n")
1103
+ );
1104
+ }
1105
+
1106
+ async function handleRejection(thread_ts, featureState, reason) {
1107
+ await postToThread(
1108
+ PLANNING_CHANNEL,
1109
+ thread_ts,
1110
+ `*[Pipeline]* Plan rejected. Re-dispatching Architect with feedback...`
1111
+ );
1112
+
1113
+ featureState.active_role = "architect";
1114
+ saveState();
1115
+
1116
+ // Write rejection feedback as .user-message to architect
1117
+ const archDir = ROLE_DIRS.architect;
1118
+ ensureDir(archDir);
1119
+ fs.writeFileSync(
1120
+ path.join(archDir, ".user-message"),
1121
+ `REVISION REQUESTED: ${reason || "Plan rejected. Please revise."}`
1122
+ );
1123
+
1124
+ dispatchToRole("architect", featureState.feature_slug, thread_ts);
1125
+ }
1126
+
1127
+ // ─── Message Handling ────────────────────────────────────────────────────────
1128
+
1129
+ socketClient.on("message", async ({ event, ack }) => {
1130
+ await ack();
1131
+
1132
+ console.log(`[MSG] channel=${event.channel} user=${event.user} thread_ts=${event.thread_ts || "none"} text="${(event.text || "").slice(0, 80)}" subtype=${event.subtype || "none"}`);
1133
+
1134
+ // Ignore bot messages
1135
+ if (event.bot_id || event.subtype === "bot_message") return;
1136
+
1137
+ const text = (event.text || "").trim();
1138
+ const channel = event.channel;
1139
+ const thread_ts = event.thread_ts || event.ts;
1140
+ const isThreadReply = !!event.thread_ts;
1141
+
1142
+ // ── Feature detection: top-level [FEATURE] messages in #planning ──
1143
+ if (
1144
+ channel === PLANNING_CHANNEL &&
1145
+ !isThreadReply &&
1146
+ text.toUpperCase().startsWith("[FEATURE]")
1147
+ ) {
1148
+ const featureDesc = text.replace(/^\[FEATURE\]\s*/i, "").trim();
1149
+ const slug = slugify(featureDesc);
1150
+
1151
+ // Create thread
1152
+ const reply = await web.chat.postMessage({
1153
+ channel: PLANNING_CHANNEL,
1154
+ thread_ts: event.ts,
1155
+ text: `*[Pipeline]* Starting planning pipeline for: *${slug}*\nPhase 1: Product Manager interview`,
1156
+ });
1157
+
1158
+ features[event.ts] = {
1159
+ feature_slug: slug,
1160
+ active_role: "pm",
1161
+ signal_dir: ROLE_DIRS.pm,
1162
+ plan_summary_ts: null,
1163
+ impl_channel: null,
1164
+ };
1165
+ saveState();
1166
+
1167
+ // Dispatch PM
1168
+ dispatchToRole("pm", slug, event.ts);
1169
+ return;
1170
+ }
1171
+
1172
+ // ── Thread replies: route to active role ──
1173
+ if (isThreadReply && features[thread_ts]) {
1174
+ const featureState = features[thread_ts];
1175
+ const lower = text.toLowerCase();
1176
+
1177
+ // ── Repo confirmation after approval ──
1178
+ if (featureState.awaiting_repo_confirm) {
1179
+ if (lower !== "go" && lower !== "yes" && lower !== "y") {
1180
+ return; // Ignore non-confirmation messages
1181
+ }
1182
+
1183
+ const repos = featureState.pending_repos || [];
1184
+
1185
+ featureState.awaiting_repo_confirm = false;
1186
+ featureState.pending_repos = null;
1187
+ saveState();
1188
+
1189
+ // Run planning-complete.sh with the slug and repos
1190
+ try {
1191
+ if (repos.length > 0) {
1192
+ await postToThread(
1193
+ PLANNING_CHANNEL,
1194
+ thread_ts,
1195
+ `*[Pipeline]* Creating \`feature/${featureState.feature_slug}\` branches in ${repos.length} repo(s)...`
1196
+ );
1197
+
1198
+ const completeScript = path.join(SCRIPTS_DIR, "planning-complete.sh");
1199
+ if (fs.existsSync(completeScript)) {
1200
+ execSync(
1201
+ `bash "${completeScript}" "${featureState.feature_slug}" ${repos.map((r) => `"${r}"`).join(" ")}`,
1202
+ { cwd: IRIAI_TEAM_DIR, stdio: "pipe", timeout: 120000 }
1203
+ );
1204
+ }
1205
+ await postToThread(
1206
+ PLANNING_CHANNEL,
1207
+ thread_ts,
1208
+ `*[Pipeline]* Feature branches created. Launching implementation...`
1209
+ );
1210
+ } else {
1211
+ await postToThread(
1212
+ PLANNING_CHANNEL,
1213
+ thread_ts,
1214
+ `*[Pipeline]* No repos configured — skipping branch creation. Launching implementation...`
1215
+ );
1216
+ }
1217
+
1218
+ // Run launch-feature.sh synchronously (creates dirs, worktrees, exits in Slack mode)
1219
+ const launchScript = path.join(SCRIPTS_DIR, "launch-feature.sh");
1220
+ if (fs.existsSync(launchScript)) {
1221
+ try {
1222
+ execSync(
1223
+ `bash "${launchScript}" "${featureState.feature_slug}"`,
1224
+ {
1225
+ cwd: IRIAI_TEAM_DIR,
1226
+ stdio: "pipe",
1227
+ timeout: 300000,
1228
+ env: {
1229
+ ...process.env,
1230
+ PLANNING_SIGNAL_BASE: PLANNING_BASE,
1231
+ IMPL_SIGNAL_BASE: IMPL_BASE,
1232
+ SLACK_MODE: "true",
1233
+ FEATURE_SLUG: featureState.feature_slug,
1234
+ IMPL_CHANNEL: featureState.impl_channel || "",
1235
+ },
1236
+ }
1237
+ );
1238
+ } catch (launchErr) {
1239
+ console.error("launch-feature.sh failed:", launchErr.message);
1240
+ const stderr = launchErr.stderr ? launchErr.stderr.toString().slice(-500) : launchErr.message;
1241
+ await postToThread(
1242
+ PLANNING_CHANNEL,
1243
+ thread_ts,
1244
+ `*[Pipeline]* Error setting up implementation:\n\`\`\`${stderr}\`\`\``
1245
+ );
1246
+ featureState.awaiting_repo_confirm = true;
1247
+ saveState();
1248
+ return;
1249
+ }
1250
+
1251
+ // Discover the signal tree created by launch-feature.sh
1252
+ watchImplSignals(featureState.feature_slug, thread_ts);
1253
+
1254
+ // Spawn all runner processes (Feature Lead, orchestrators, roles)
1255
+ launchImplRunners(featureState.feature_slug, thread_ts);
1256
+
1257
+ const implLink = featureState.impl_channel
1258
+ ? ` Follow progress in <#${featureState.impl_channel}>.`
1259
+ : "";
1260
+ await postToThread(
1261
+ PLANNING_CHANNEL,
1262
+ thread_ts,
1263
+ `*[Pipeline]* Implementation launched for \`feature/${featureState.feature_slug}\`.${implLink}`
1264
+ );
1265
+ }
1266
+
1267
+ featureState.active_role = "impl";
1268
+ saveState();
1269
+ } catch (err) {
1270
+ const stderr = err.stderr ? err.stderr.toString().slice(-500) : err.message;
1271
+ await postToThread(
1272
+ PLANNING_CHANNEL,
1273
+ thread_ts,
1274
+ `*[Pipeline]* Error creating branches:\n\`\`\`${stderr}\`\`\`\nYou can retry by replying with the repo list again.`
1275
+ );
1276
+ featureState.awaiting_repo_confirm = true;
1277
+ saveState();
1278
+ }
1279
+ return;
1280
+ }
1281
+
1282
+ // Check for approval/rejection text
1283
+ if (
1284
+ featureState.plan_summary_ts &&
1285
+ featureState.active_role === null &&
1286
+ !featureState.awaiting_repo_confirm
1287
+ ) {
1288
+ if (
1289
+ lower === "approved" ||
1290
+ lower === "lgtm" ||
1291
+ lower.startsWith("approve")
1292
+ ) {
1293
+ await handleApproval(thread_ts, featureState);
1294
+ return;
1295
+ }
1296
+ if (
1297
+ lower.startsWith("reject") ||
1298
+ lower === "no" ||
1299
+ lower === "nope" ||
1300
+ lower.startsWith("redo") ||
1301
+ lower.startsWith("revise")
1302
+ ) {
1303
+ const reason = text.replace(/^(rejected?|no|nope|redo|revise):?\s*/i, "").trim();
1304
+ await handleRejection(thread_ts, featureState, reason || "Plan rejected");
1305
+ return;
1306
+ }
1307
+ }
1308
+
1309
+ // Route to specific role via @mention, or fall back to active role
1310
+ const mentionedRole = parseRole(text);
1311
+ const targetRole = mentionedRole || featureState.active_role;
1312
+
1313
+ if (targetRole && ROLE_DIRS[targetRole]) {
1314
+ const signalDir = ROLE_DIRS[targetRole];
1315
+ ensureDir(signalDir);
1316
+
1317
+ // Strip the @mention from the message before writing
1318
+ const cleanText = text
1319
+ .replace(/@(pm|designer|architect|plan-compiler|compiler|lead)\b/gi, "")
1320
+ .trim();
1321
+
1322
+ fs.writeFileSync(path.join(signalDir, ".user-message"), cleanText);
1323
+
1324
+ // Add :eyes: to show the agent is processing
1325
+ await addReaction(channel, event.ts, "eyes");
1326
+ pendingUserMessages[thread_ts] = event.ts;
1327
+ }
1328
+ return;
1329
+ }
1330
+
1331
+ // ── Messages in impl channels: route to Operator (with FL fallback) ──
1332
+ const implFeature = Object.entries(features).find(
1333
+ ([, f]) => f.impl_channel === channel
1334
+ );
1335
+ if (implFeature) {
1336
+ const [implThread, featureState] = implFeature;
1337
+
1338
+ const lower = text.toLowerCase();
1339
+
1340
+ // Check for gate approval/rejection text in impl channel
1341
+ if (featureState.gate_evidence_ts) {
1342
+ if (lower === "approved" || lower === "lgtm" || lower.startsWith("approve")) {
1343
+ await handleGateApproval(implThread, featureState);
1344
+ return;
1345
+ }
1346
+ if (lower.startsWith("reject")) {
1347
+ const reason = text.replace(/^rejected?:?\s*/i, "");
1348
+ await handleGateRejection(implThread, featureState, reason);
1349
+ return;
1350
+ }
1351
+ }
1352
+
1353
+ // Route to operator (fast responder) with FL fallback
1354
+ const targetDir = featureState.impl_signal_tree?.operator || featureState.impl_signal_tree?.featureLead || ROLE_DIRS.lead;
1355
+ ensureDir(targetDir);
1356
+ fs.writeFileSync(path.join(targetDir, ".user-message"), text);
1357
+
1358
+ // Add :eyes: to show the agent is processing
1359
+ await addReaction(channel, event.ts, "eyes");
1360
+ pendingUserMessages[implThread] = event.ts;
1361
+ }
1362
+ });
1363
+
1364
+ // ── Reaction handling for plan approval and gate approval ──
1365
+ socketClient.on("reaction_added", async ({ event, ack }) => {
1366
+ await ack();
1367
+
1368
+ const { reaction, item } = event;
1369
+ console.log(`[REACTION] ${reaction} on ${item.type} ts=${item.ts} channel=${item.channel} user=${event.user} botUserId=${botUserId}`);
1370
+
1371
+ // Ignore our own reactions (e.g., approval hint checkmarks we add to gate evidence)
1372
+ if (event.user === botUserId) {
1373
+ console.log(`[REACTION] Ignoring self-reaction from bot`);
1374
+ return;
1375
+ }
1376
+
1377
+ if (item.type !== "message") return;
1378
+
1379
+ const messageTs = item.ts;
1380
+
1381
+ // Check for plan approval reaction — match on the approval prompt message,
1382
+ // the thread parent message, or any message in the thread while awaiting approval.
1383
+ const planFeature = Object.entries(features).find(
1384
+ ([thread_ts, f]) =>
1385
+ f.plan_summary_ts &&
1386
+ f.active_role === null &&
1387
+ !f.awaiting_repo_confirm &&
1388
+ (f.plan_summary_ts === messageTs || thread_ts === messageTs)
1389
+ );
1390
+ if (planFeature) {
1391
+ const [featureThread, featureState] = planFeature;
1392
+ if (reaction === "white_check_mark" || reaction === "+1") {
1393
+ await handleApproval(featureThread, featureState);
1394
+ } else if (reaction === "x" || reaction === "-1") {
1395
+ await handleRejection(featureThread, featureState, "Rejected via reaction");
1396
+ }
1397
+ return;
1398
+ }
1399
+
1400
+ // Check for gate approval reaction (Phase 6)
1401
+ const gateFeature = Object.entries(features).find(
1402
+ ([, f]) => f.gate_evidence_ts === messageTs
1403
+ );
1404
+ if (gateFeature) {
1405
+ const [featureThread, featureState] = gateFeature;
1406
+ if (reaction === "white_check_mark" || reaction === "+1") {
1407
+ await handleGateApproval(featureThread, featureState);
1408
+ } else if (reaction === "x" || reaction === "-1") {
1409
+ await handleGateRejection(featureThread, featureState, "Rejected via reaction");
1410
+ }
1411
+ }
1412
+ });
1413
+
1414
+ // ─── Startup Recovery ────────────────────────────────────────────────────────
1415
+
1416
+ async function processExistingSignals() {
1417
+ // On restart, check for .done / .agent-response files that were written
1418
+ // while the bridge was down. chokidar ignoreInitial:true won't catch these.
1419
+ for (const [role, dir] of Object.entries(ROLE_DIRS)) {
1420
+ const donePath = path.join(dir, ".done");
1421
+ if (fs.existsSync(donePath)) {
1422
+ console.log(`Found unprocessed .done for ${role}, processing...`);
1423
+ await handleDoneSignal(donePath);
1424
+ }
1425
+
1426
+ const responsePath = path.join(dir, ".agent-response");
1427
+ if (fs.existsSync(responsePath)) {
1428
+ console.log(`Found unprocessed .agent-response for ${role}, processing...`);
1429
+ await handleAgentResponse(responsePath);
1430
+ }
1431
+
1432
+ const questionPath = path.join(dir, ".question");
1433
+ if (fs.existsSync(questionPath)) {
1434
+ console.log(`Found unprocessed .question for ${role}, processing...`);
1435
+ await handleQuestionSignal(questionPath);
1436
+ }
1437
+ }
1438
+ }
1439
+
1440
+ // ─── Startup ─────────────────────────────────────────────────────────────────
1441
+
1442
+ async function start() {
1443
+ console.log("Starting Iriai Slack Bridge...");
1444
+
1445
+ // Identify ourselves so we can filter our own reactions
1446
+ const authResult = await web.auth.test();
1447
+ botUserId = authResult.user_id;
1448
+ console.log(`Bot user ID: ${botUserId}`);
1449
+
1450
+ // Ensure signal dirs exist
1451
+ for (const dir of Object.values(ROLE_DIRS)) {
1452
+ ensureDir(dir);
1453
+ }
1454
+
1455
+ // Load persisted state for crash recovery
1456
+ loadState();
1457
+
1458
+ // Start file watchers
1459
+ watchAgentResponses();
1460
+
1461
+ // Connect Socket Mode
1462
+ await socketClient.start();
1463
+ console.log("Socket Mode connected. Watching #planning for [FEATURE] messages.");
1464
+
1465
+ // Wait for connection to stabilize before processing leftover signals
1466
+ await new Promise((resolve) => setTimeout(resolve, 3000));
1467
+
1468
+ // Check for unprocessed signals left over from before restart
1469
+ await processExistingSignals();
1470
+
1471
+ // Recover impl watchers for any active implementation features
1472
+ await processExistingImplSignals();
1473
+ }
1474
+
1475
+ // Handle Socket Mode disconnects gracefully
1476
+ socketClient.on("disconnect", () => {
1477
+ console.warn("Socket Mode disconnected. Will attempt reconnect...");
1478
+ });
1479
+
1480
+ // Catch unhandled rejections so the process doesn't crash on transient Slack errors
1481
+ process.on("unhandledRejection", (err) => {
1482
+ console.error("Unhandled rejection (non-fatal):", err.message || err);
1483
+ });
1484
+
1485
+ start().catch((err) => {
1486
+ console.error("Fatal:", err);
1487
+ process.exit(1);
1488
+ });