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