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,608 @@
1
+ // slack-adapter.js — SlackAdapter implementing InterfaceAdapter.
2
+ // Socket Mode + WebClient. All Slack I/O routed through the contract.
3
+
4
+ import { SocketModeClient } from "@slack/socket-mode";
5
+ import { WebClient } from "@slack/web-api";
6
+ import path from "node:path";
7
+ import * as queries from "../queries.js";
8
+ import { SIGNAL } from "../constants.js";
9
+ import { InterfaceAdapter } from "./interface.js";
10
+ import {
11
+ slugify, ensureDir, writeSignal, parseRole, parseGifMarkers, findArtifact,
12
+ } from "../helpers.js";
13
+ import {
14
+ markdownToMrkdwn, uploadGifAttachment, uploadArtifact as slackUploadArtifact,
15
+ postToThread, addReaction, removeReaction,
16
+ buildDecisionBlocks, buildResolvedBlocks,
17
+ } from "./slack-helpers.js";
18
+
19
+ export class SlackAdapter extends InterfaceAdapter {
20
+ constructor({ appToken, botToken, planningChannel }) {
21
+ super();
22
+ this.planningChannel = planningChannel;
23
+ this.web = new WebClient(botToken);
24
+ this.socket = new SocketModeClient({ appToken });
25
+ this.botUserId = null;
26
+ this._orchestrator = null;
27
+
28
+ // Track pending user messages for :eyes: cleanup
29
+ this._pendingUserMessages = {};
30
+ }
31
+
32
+ setOrchestrator(orchestrator) {
33
+ this._orchestrator = orchestrator;
34
+ }
35
+
36
+ async connect() {
37
+ const auth = await this.web.auth.test();
38
+ this.botUserId = auth.user_id;
39
+
40
+ this._setupMessageHandler();
41
+ this._setupReactionHandler();
42
+ this._setupInteractiveHandler();
43
+
44
+ this.socket.on("disconnect", () => {
45
+ console.warn("[slack] Socket Mode disconnected. Will attempt reconnect...");
46
+ });
47
+
48
+ this.socket.on("slack_event", ({ type }) => {
49
+ console.log(`[slack] Raw event: type=${type}`);
50
+ });
51
+
52
+ await this.socket.start();
53
+ }
54
+
55
+ // ─── InterfaceAdapter Implementation ──────────────────────────────────────
56
+
57
+ async createFeatureChannel(featureId, slug) {
58
+ try {
59
+ const result = await this.web.conversations.create({
60
+ name: `impl-${slug}`.slice(0, 80),
61
+ });
62
+ return result.channel.id;
63
+ } catch (err) {
64
+ if (err.data?.error === "name_taken") {
65
+ try {
66
+ const list = await this.web.conversations.list({ types: "public_channel", limit: 200 });
67
+ const existing = list.channels.find(c => c.name === `impl-${slug}`.slice(0, 80));
68
+ if (existing) return existing.id;
69
+ } catch { /* ok */ }
70
+ }
71
+ console.warn("[slack-adapter] Could not create feature channel:", err.message);
72
+ return null;
73
+ }
74
+ }
75
+
76
+ async postMessage(featureId, text) {
77
+ const channel = this._resolveChannel(featureId);
78
+ const result = await this.web.chat.postMessage({
79
+ channel,
80
+ text: markdownToMrkdwn(text),
81
+ mrkdwn: true,
82
+ });
83
+ return { ref: result.ts };
84
+ }
85
+
86
+ async postThreadMessage(featureId, text) {
87
+ const feature = queries.getFeatureById(featureId);
88
+ if (!feature) return;
89
+ await postToThread(this.web, this.planningChannel, feature.thread_ts, text);
90
+ }
91
+
92
+ async postPipelineMessage(featureId, text) {
93
+ const feature = queries.getFeatureById(featureId);
94
+ if (!feature) return;
95
+ const channel = this._resolveChannel(featureId);
96
+ const result = await this.web.chat.postMessage({
97
+ channel,
98
+ text: markdownToMrkdwn(`*[Pipeline]* ${text}`),
99
+ mrkdwn: true,
100
+ });
101
+ queries.insertEvent(featureId, "system", "bridge", text, {}, result.ts);
102
+ return { ref: result.ts };
103
+ }
104
+
105
+ async postAgentResponse(featureId, agentLabel, rawContent) {
106
+ const feature = queries.getFeatureById(featureId);
107
+ if (!feature) return;
108
+ const channel = this._resolveChannel(featureId);
109
+
110
+ const { text: cleanedContent, gifPaths, evidencePaths } = parseGifMarkers(rawContent);
111
+
112
+ const result = await this.web.chat.postMessage({
113
+ channel,
114
+ text: markdownToMrkdwn(`*[${agentLabel}]* ${cleanedContent}`),
115
+ mrkdwn: true,
116
+ });
117
+
118
+ // Upload media (GIFs/screenshots)
119
+ for (const gifPath of gifPaths) {
120
+ const resolvedPath = path.isAbsolute(gifPath) ? gifPath : path.resolve(gifPath);
121
+ const ext = path.extname(gifPath);
122
+ const label = path.basename(gifPath, ext).replace(/[-_]/g, " ");
123
+ await uploadGifAttachment(this.web, channel, result.ts, resolvedPath, label);
124
+ }
125
+
126
+ // Upload evidence documents
127
+ for (const evidencePath of evidencePaths) {
128
+ const resolvedPath = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(evidencePath);
129
+ await uploadGifAttachment(this.web, channel, result.ts, resolvedPath, "Gate Evidence Document");
130
+ }
131
+
132
+ // Record event + slack post for dedup
133
+ const eventId = queries.insertEvent(featureId, "agent-response", `agent:${agentLabel}`, rawContent, {}, result.ts);
134
+ queries.recordSlackPost(eventId, featureId, channel, result.ts);
135
+
136
+ // Clear pending eyes reaction
137
+ this._clearPendingReaction(feature.thread_ts, channel);
138
+
139
+ return { ref: result.ts };
140
+ }
141
+
142
+ async uploadArtifact(featureId, filePath, title) {
143
+ const channel = this._resolveChannel(featureId);
144
+ const feature = queries.getFeatureById(featureId);
145
+ const threadTs = feature?.thread_ts || null;
146
+ await slackUploadArtifact(this.web, channel, threadTs, filePath, title);
147
+ }
148
+
149
+ async postDecision(featureId, decision) {
150
+ const channel = this._resolveChannel(featureId);
151
+
152
+ const blocks = buildDecisionBlocks(
153
+ decision.id,
154
+ decision.title,
155
+ decision.context,
156
+ decision.options,
157
+ );
158
+
159
+ // Insert review URL block before the action buttons (last block)
160
+ if (decision.reviewUrl || decision.qaUrl) {
161
+ const links = [];
162
+ if (decision.reviewUrl) links.push(`📎 *<${decision.reviewUrl}|Doc Review — open to annotate>*`);
163
+ if (decision.qaUrl) links.push(`🧪 *<${decision.qaUrl}|QA Test — open to test live app>*`);
164
+ const reviewBlock = {
165
+ type: "section",
166
+ text: { type: "mrkdwn", text: links.join("\n") + "\n_Annotate in browser, then approve or reject below._" },
167
+ };
168
+ // Insert before the last block (action buttons)
169
+ blocks.splice(blocks.length - 1, 0, reviewBlock);
170
+ }
171
+
172
+ const result = await this.web.chat.postMessage({
173
+ channel,
174
+ text: decision.title,
175
+ blocks,
176
+ });
177
+
178
+ return { ref: result.ts };
179
+ }
180
+
181
+ async resolveDecisionMessage(featureId, messageRef, decisionId, selectedOption, selectedLabel, resolvedBy, feedback) {
182
+ const channel = this._resolveChannel(featureId);
183
+ try {
184
+ const result = await this.web.conversations.history({
185
+ channel, latest: messageRef, inclusive: true, limit: 1,
186
+ });
187
+ const msg = result.messages?.[0];
188
+ if (!msg) return;
189
+
190
+ // Get title from the first section block
191
+ const titleText = msg.blocks?.[0]?.text?.text || decisionId;
192
+
193
+ await this.web.chat.update({
194
+ channel,
195
+ ts: messageRef,
196
+ blocks: buildResolvedBlocks(titleText, selectedLabel, resolvedBy, feedback),
197
+ text: `Decision resolved: ${selectedLabel}`,
198
+ });
199
+ } catch (err) {
200
+ console.error("[slack-adapter] Failed to update decision message:", err.message);
201
+ }
202
+ }
203
+
204
+ async postPlanForApproval(featureId, planDir) {
205
+ const feature = queries.getFeatureById(featureId);
206
+ if (!feature) return;
207
+
208
+ const channel = this._resolveChannel(featureId);
209
+
210
+ const prdPath = findArtifact("prd", planDir);
211
+ const designPath = findArtifact("design-decisions", planDir);
212
+ const planPath = findArtifact("implementation-plan", planDir);
213
+
214
+ // Upload artifacts FIRST
215
+ if (prdPath) await slackUploadArtifact(this.web, channel, feature.thread_ts, prdPath, "PRD");
216
+ if (designPath) await slackUploadArtifact(this.web, channel, feature.thread_ts, designPath, "Design Decisions");
217
+ if (planPath) await slackUploadArtifact(this.web, channel, feature.thread_ts, planPath, "Implementation Plan");
218
+
219
+ // Post approval with Block Kit buttons AFTER uploads
220
+ const blocks = buildDecisionBlocks(
221
+ "plan-approval",
222
+ "Plan ready for approval",
223
+ "All planning phases complete. Review the artifacts above.",
224
+ [
225
+ { id: "approve", label: "Approve Plan", style: "primary" },
226
+ { id: "reject", label: "Reject Plan", style: "danger" },
227
+ ]
228
+ );
229
+
230
+ const result = await this.web.chat.postMessage({
231
+ channel,
232
+ text: "Plan ready for approval.",
233
+ blocks,
234
+ });
235
+
236
+ queries.updateFeaturePlanSummaryTs(featureId, result.ts);
237
+
238
+ const branch = `feature/${feature.slug}`;
239
+ const artifactList = [prdPath, designPath, planPath]
240
+ .filter(Boolean)
241
+ .map((p) => path.basename(p))
242
+ .join(", ");
243
+
244
+ // Also post to planning thread
245
+ await this.web.chat.postMessage({
246
+ channel: this.planningChannel,
247
+ thread_ts: feature.thread_ts,
248
+ reply_broadcast: true,
249
+ text: `*Planning complete: ${feature.slug}*\n\nBranch: \`${branch}\`\nArtifacts: ${artifactList || "none"}\n\nApprove/reject in <#${feature.feature_channel || channel}>.`,
250
+ mrkdwn: true,
251
+ });
252
+
253
+ return { ref: result.ts };
254
+ }
255
+
256
+ async postFeatureComplete(featureId) {
257
+ const feature = queries.getFeatureById(featureId);
258
+ if (!feature) return;
259
+
260
+ if (feature.feature_channel) {
261
+ await this.postMessage(featureId,
262
+ `*[Pipeline]* Feature *${feature.slug}* is complete! All gates passed. :tada:`);
263
+ }
264
+
265
+ await postToThread(this.web, this.planningChannel, feature.thread_ts,
266
+ `*[Pipeline]* Feature complete: *${feature.slug}* :tada:\nAll gates approved and merged.`);
267
+
268
+ await this.web.chat.postMessage({
269
+ channel: this.planningChannel,
270
+ thread_ts: feature.thread_ts,
271
+ reply_broadcast: true,
272
+ text: `*Feature complete: ${feature.slug}* :tada:`,
273
+ mrkdwn: true,
274
+ });
275
+ }
276
+
277
+ async markProcessing(featureId, messageRef) {
278
+ const channel = this._resolveChannel(featureId);
279
+ await addReaction(this.web, channel, messageRef, "eyes");
280
+ }
281
+
282
+ async clearProcessing(featureId, messageRef) {
283
+ const channel = this._resolveChannel(featureId);
284
+ await removeReaction(this.web, channel, messageRef, "eyes");
285
+ }
286
+
287
+ // ─── Internal Helpers ──────────────────────────────────────────────────
288
+
289
+ _resolveChannel(featureId) {
290
+ const feature = queries.getFeatureById(featureId);
291
+ return feature?.feature_channel || this.planningChannel;
292
+ }
293
+
294
+ _clearPendingReaction(threadTs, channel) {
295
+ const pendingTs = this._pendingUserMessages[threadTs];
296
+ if (pendingTs) {
297
+ removeReaction(this.web, channel, pendingTs, "eyes").catch(() => {});
298
+ delete this._pendingUserMessages[threadTs];
299
+ }
300
+ }
301
+
302
+ // ─── Inbound Message Handling ────────────────────────────────────────────
303
+
304
+ _setupMessageHandler() {
305
+ this.socket.on("message", async ({ event, ack }) => {
306
+ await ack();
307
+
308
+ if (event.bot_id || event.subtype === "bot_message") return;
309
+
310
+ const text = (event.text || "").trim();
311
+ const channel = event.channel;
312
+ const thread_ts = event.thread_ts || event.ts;
313
+ const isThreadReply = !!event.thread_ts;
314
+ const userId = event.user;
315
+
316
+ console.log(`[slack] Message: channel=${channel} thread=${isThreadReply} user=${userId} text="${text.slice(0, 60)}"`);
317
+
318
+ try {
319
+ // [FEATURE] detection in planning channel
320
+ if (channel === this.planningChannel && !isThreadReply && text.toUpperCase().startsWith("[FEATURE]")) {
321
+ await this._handleFeatureDetection(text, event.ts, userId);
322
+ return;
323
+ }
324
+
325
+ // Thread replies in planning channel
326
+ if (isThreadReply && channel === this.planningChannel) {
327
+ const feature = queries.getFeatureByThreadTs(thread_ts);
328
+ if (feature) {
329
+ await this._handlePlanningThreadReply(feature, text, event, userId);
330
+ return;
331
+ }
332
+ }
333
+
334
+ // Messages in impl channels
335
+ const features = queries.getActiveFeatures();
336
+ const implFeature = features.find(f => f.feature_channel === channel);
337
+ if (implFeature) {
338
+ await this._handleImplChannelMessage(implFeature, text, event, userId);
339
+ }
340
+ } catch (err) {
341
+ console.error("[slack] Message handler error:", err.message);
342
+ }
343
+ });
344
+ }
345
+
346
+ async _handleFeatureDetection(text, messageTs, userId) {
347
+ const featureDesc = text.replace(/^\[FEATURE\]\s*/i, "").trim();
348
+ const slug = slugify(featureDesc);
349
+
350
+ if (this._orchestrator) {
351
+ await this._orchestrator.initializeFeature(slug, messageTs, userId);
352
+ }
353
+ }
354
+
355
+ async _handlePlanningThreadReply(feature, text, event, userId) {
356
+ const lower = text.toLowerCase();
357
+
358
+ // Record user message
359
+ queries.insertEvent(feature.id, "user-message", `user:${userId}`, text);
360
+
361
+ // Phase review approval/rejection (text fallback for Block Kit buttons)
362
+ const meta = queries.getFeatureMetadata(feature.id);
363
+ if (meta.awaiting_phase_review) {
364
+ if (lower === "approved" || lower === "lgtm" || lower.startsWith("approve")) {
365
+ if (this._orchestrator) await this._orchestrator.handlePhaseReviewApproval(feature.id);
366
+ return;
367
+ }
368
+ if (lower.startsWith("reject") || lower === "no" || lower === "nope" ||
369
+ lower.startsWith("redo") || lower.startsWith("revise")) {
370
+ const reason = text.replace(/^(rejected?|no|nope|redo|revise):?\s*/i, "").trim();
371
+ if (this._orchestrator) await this._orchestrator.handlePhaseReviewRejection(feature.id, reason || "Revisions requested");
372
+ return;
373
+ }
374
+ }
375
+
376
+ // Plan approval/rejection
377
+ if (feature.plan_summary_ts && feature.phase === "plan-approval") {
378
+ if (lower === "approved" || lower === "lgtm" || lower.startsWith("approve")) {
379
+ if (this._orchestrator) await this._orchestrator.handlePlanApproval(feature.id);
380
+ return;
381
+ }
382
+ if (lower.startsWith("reject") || lower === "no" || lower === "nope" ||
383
+ lower.startsWith("redo") || lower.startsWith("revise")) {
384
+ const reason = text.replace(/^(rejected?|no|nope|redo|revise):?\s*/i, "").trim();
385
+ if (this._orchestrator) await this._orchestrator.handlePlanRejection(feature.id, reason || "Plan rejected");
386
+ return;
387
+ }
388
+ }
389
+
390
+ // Route to feature channel via operator (if channel exists and feature is in planning)
391
+ if (feature.feature_channel && this._orchestrator) {
392
+ this._orchestrator.routeUserMessage(feature.id, text);
393
+ await addReaction(this.web, event.channel, event.ts, "eyes");
394
+ this._pendingUserMessages[feature.thread_ts] = event.ts;
395
+ return;
396
+ }
397
+
398
+ // Fallback: route directly to active planning role signal dir
399
+ const mentionedRole = parseRole(text);
400
+ const targetRole = mentionedRole || feature.active_planning_role;
401
+
402
+ if (targetRole) {
403
+ const featureDir = feature.signal_dir;
404
+ const signalDir = path.join(featureDir, "planning", targetRole);
405
+ ensureDir(signalDir);
406
+ const cleanText = text.replace(/@(pm|designer|architect|plan-compiler|compiler|lead)\b/gi, "").trim();
407
+ writeSignal(path.join(signalDir, SIGNAL.USER_MESSAGE), cleanText);
408
+ await addReaction(this.web, event.channel, event.ts, "eyes");
409
+ this._pendingUserMessages[feature.thread_ts] = event.ts;
410
+ }
411
+ }
412
+
413
+ async _handleImplChannelMessage(feature, text, event, userId) {
414
+ const lower = text.toLowerCase();
415
+
416
+ // Record user message
417
+ queries.insertEvent(feature.id, "user-message", `user:${userId}`, text);
418
+
419
+ // Phase review approval/rejection (text fallback for Block Kit buttons)
420
+ const meta = queries.getFeatureMetadata(feature.id);
421
+ if (meta.awaiting_phase_review) {
422
+ if (lower === "approved" || lower === "lgtm" || lower.startsWith("approve")) {
423
+ if (this._orchestrator) await this._orchestrator.handlePhaseReviewApproval(feature.id);
424
+ return;
425
+ }
426
+ if (lower.startsWith("reject") || lower === "no" || lower === "nope" ||
427
+ lower.startsWith("redo") || lower.startsWith("revise")) {
428
+ const reason = text.replace(/^(rejected?|no|nope|redo|revise):?\s*/i, "").trim();
429
+ if (this._orchestrator) await this._orchestrator.handlePhaseReviewRejection(feature.id, reason || "Revisions requested");
430
+ return;
431
+ }
432
+ }
433
+
434
+ // Plan approval/rejection (feature channel now exists during planning)
435
+ if (feature.plan_summary_ts && feature.phase === "plan-approval") {
436
+ if (lower === "approved" || lower === "lgtm" || lower.startsWith("approve")) {
437
+ if (this._orchestrator) await this._orchestrator.handlePlanApproval(feature.id);
438
+ return;
439
+ }
440
+ if (lower.startsWith("reject") || lower === "no" || lower === "nope" ||
441
+ lower.startsWith("redo") || lower.startsWith("revise")) {
442
+ const reason = text.replace(/^(rejected?|no|nope|redo|revise):?\s*/i, "").trim();
443
+ if (this._orchestrator) await this._orchestrator.handlePlanRejection(feature.id, reason || "Plan rejected");
444
+ return;
445
+ }
446
+ }
447
+
448
+ // Gate approval/rejection
449
+ if (feature.gate_evidence_ts) {
450
+ if (lower === "approved" || lower === "lgtm" || lower.startsWith("approve")) {
451
+ if (this._orchestrator) await this._orchestrator.handleGateApproval(feature.id);
452
+ return;
453
+ }
454
+ if (lower.startsWith("reject")) {
455
+ const reason = text.replace(/^rejected?:?\s*/i, "");
456
+ if (this._orchestrator) await this._orchestrator.handleGateRejection(feature.id, reason);
457
+ return;
458
+ }
459
+ }
460
+
461
+ // Route to operator (or FL fallback)
462
+ if (this._orchestrator) {
463
+ this._orchestrator.routeUserMessage(feature.id, text);
464
+ }
465
+
466
+ await addReaction(this.web, event.channel, event.ts, "eyes");
467
+ this._pendingUserMessages[feature.thread_ts] = event.ts;
468
+ }
469
+
470
+ // ─── Interactive (Block Kit Button Clicks) ─────────────────────────────
471
+
472
+ _setupInteractiveHandler() {
473
+ this.socket.on("interactive", async ({ body, ack }) => {
474
+ await ack();
475
+
476
+ // Handle modal submissions (feedback for rejections)
477
+ if (body.type === "view_submission") {
478
+ const meta = JSON.parse(body.view?.private_metadata || "{}");
479
+ const feedback = body.view?.state?.values?.feedback_block?.feedback_input?.value || "";
480
+ const { decisionId, optionId, userId: origUser, channel, messageTs } = meta;
481
+
482
+ console.log(`[slack] Modal submit: decision=${decisionId} feedback="${feedback}"`);
483
+
484
+ if (!this._orchestrator) return;
485
+ try {
486
+ await this._orchestrator.handleDecisionClick(decisionId, optionId, origUser, channel, messageTs, feedback);
487
+ } catch (err) {
488
+ console.error("[slack] Modal submission error:", err.message);
489
+ }
490
+ return;
491
+ }
492
+
493
+ if (body.type !== "block_actions") return;
494
+ const action = body.actions?.[0];
495
+ if (!action) return;
496
+
497
+ const actionId = action.action_id; // "decision_<decisionId>_<optionId>"
498
+ const userId = body.user?.id;
499
+ const messageTs = body.message?.ts;
500
+ const channel = body.channel?.id;
501
+ const triggerId = body.trigger_id;
502
+
503
+ // Parse action_id
504
+ const parts = actionId.split("_");
505
+ if (parts[0] !== "decision" || parts.length < 3) return;
506
+ const decisionId = parts.slice(1, -1).join("_"); // handles hyphens in ID
507
+ const optionId = parts[parts.length - 1];
508
+
509
+ console.log(`[slack] Button click: decision=${decisionId} option=${optionId} user=${userId}`);
510
+
511
+ if (!this._orchestrator) return;
512
+
513
+ try {
514
+ // For reject/revision actions, open a modal to collect feedback
515
+ if (optionId === "reject" && triggerId) {
516
+ await this._openFeedbackModal(triggerId, decisionId, optionId, userId, channel, messageTs);
517
+ } else {
518
+ await this._orchestrator.handleDecisionClick(decisionId, optionId, userId, channel, messageTs);
519
+ }
520
+ } catch (err) {
521
+ console.error("[slack] Interactive handler error:", err.message);
522
+ }
523
+ });
524
+ }
525
+
526
+ async _openFeedbackModal(triggerId, decisionId, optionId, userId, channel, messageTs) {
527
+ const titleMap = {
528
+ "plan-approval": "Reject Plan",
529
+ };
530
+ if (decisionId.startsWith("phase-review-")) {
531
+ const role = decisionId.replace("phase-review-", "");
532
+ titleMap[decisionId] = `Revise ${role.charAt(0).toUpperCase() + role.slice(1)}`;
533
+ }
534
+ if (decisionId.startsWith("gate-")) {
535
+ titleMap[decisionId] = "Reject Gate";
536
+ }
537
+
538
+ const title = titleMap[decisionId] || "Provide Feedback";
539
+
540
+ await this.web.views.open({
541
+ trigger_id: triggerId,
542
+ view: {
543
+ type: "modal",
544
+ callback_id: "decision_feedback",
545
+ private_metadata: JSON.stringify({ decisionId, optionId, userId, channel, messageTs }),
546
+ title: { type: "plain_text", text: title.slice(0, 24) },
547
+ submit: { type: "plain_text", text: "Submit" },
548
+ close: { type: "plain_text", text: "Cancel" },
549
+ blocks: [
550
+ {
551
+ type: "input",
552
+ block_id: "feedback_block",
553
+ label: { type: "plain_text", text: "What needs to change?" },
554
+ element: {
555
+ type: "plain_text_input",
556
+ action_id: "feedback_input",
557
+ multiline: true,
558
+ placeholder: { type: "plain_text", text: "Describe what you'd like revised..." },
559
+ },
560
+ },
561
+ ],
562
+ },
563
+ });
564
+ }
565
+
566
+ // ─── Reaction Handling ───────────────────────────────────────────────────
567
+
568
+ _setupReactionHandler() {
569
+ this.socket.on("reaction_added", async ({ event, ack }) => {
570
+ await ack();
571
+
572
+ const { reaction, item } = event;
573
+ if (event.user === this.botUserId) return;
574
+ if (item.type !== "message") return;
575
+
576
+ const messageTs = item.ts;
577
+
578
+ try {
579
+ // Plan approval via reaction
580
+ const features = queries.getActiveFeatures();
581
+ const planFeature = features.find(f =>
582
+ f.plan_summary_ts && f.phase === "plan-approval" &&
583
+ (f.plan_summary_ts === messageTs || f.thread_ts === messageTs)
584
+ );
585
+ if (planFeature && this._orchestrator) {
586
+ if (reaction === "white_check_mark" || reaction === "+1") {
587
+ await this._orchestrator.handlePlanApproval(planFeature.id);
588
+ } else if (reaction === "x" || reaction === "-1") {
589
+ await this._orchestrator.handlePlanRejection(planFeature.id, "Rejected via reaction");
590
+ }
591
+ return;
592
+ }
593
+
594
+ // Gate approval via reaction
595
+ const gateFeature = features.find(f => f.gate_evidence_ts === messageTs);
596
+ if (gateFeature && this._orchestrator) {
597
+ if (reaction === "white_check_mark" || reaction === "+1") {
598
+ await this._orchestrator.handleGateApproval(gateFeature.id);
599
+ } else if (reaction === "x" || reaction === "-1") {
600
+ await this._orchestrator.handleGateRejection(gateFeature.id, "Rejected via reaction");
601
+ }
602
+ }
603
+ } catch (err) {
604
+ console.error("[slack] Reaction handler error:", err.message);
605
+ }
606
+ });
607
+ }
608
+ }