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.
- package/bin/iriai-build.js +78 -0
- package/bridge-v3.js +98 -0
- package/cli/bootstrap.js +83 -0
- package/cli/commands/implementation.js +64 -0
- package/cli/commands/index.js +46 -0
- package/cli/commands/launch.js +153 -0
- package/cli/commands/plan.js +117 -0
- package/cli/commands/setup.js +80 -0
- package/cli/commands/slack.js +97 -0
- package/cli/commands/transfer.js +111 -0
- package/cli/config.js +92 -0
- package/cli/display.js +121 -0
- package/cli/terminal-input.js +666 -0
- package/cli/wait.js +82 -0
- package/index.js +1488 -0
- package/lib/agent-process.js +170 -0
- package/lib/bridge-state.js +126 -0
- package/lib/constants.js +137 -0
- package/lib/health-monitor.js +113 -0
- package/lib/prompt-builder.js +565 -0
- package/lib/signal-watcher.js +215 -0
- package/lib/slack-helpers.js +224 -0
- package/lib/state-machines/feature-lead.js +408 -0
- package/lib/state-machines/operator-agent.js +173 -0
- package/lib/state-machines/planning-role.js +161 -0
- package/lib/state-machines/role-agent.js +186 -0
- package/lib/state-machines/team-orchestrator.js +160 -0
- package/package.json +31 -0
- package/v3/.handover-html-evidence.md +35 -0
- package/v3/KICKOFF-HTML-EVIDENCE.md +98 -0
- package/v3/PLAN-HTML-EVIDENCE-HARDENING.md +603 -0
- package/v3/adapters/desktop-adapter.js +78 -0
- package/v3/adapters/interface.js +146 -0
- package/v3/adapters/slack-adapter.js +608 -0
- package/v3/adapters/slack-helpers.js +179 -0
- package/v3/adapters/terminal-adapter.js +249 -0
- package/v3/agent-supervisor.js +320 -0
- package/v3/artifact-portal.js +1184 -0
- package/v3/bridge.db +0 -0
- package/v3/constants.js +170 -0
- package/v3/db.js +76 -0
- package/v3/file-io.js +216 -0
- package/v3/helpers.js +174 -0
- package/v3/operator.js +364 -0
- package/v3/orchestrator.js +2886 -0
- package/v3/plan-compiler.js +440 -0
- package/v3/prompt-builder.js +849 -0
- package/v3/queries.js +461 -0
- package/v3/recovery.js +508 -0
- package/v3/review-sessions.js +360 -0
- package/v3/roles/accessibility-auditor/CLAUDE.md +50 -0
- package/v3/roles/analytics-engineer/CLAUDE.md +40 -0
- package/v3/roles/architect/CLAUDE.md +809 -0
- package/v3/roles/backend-implementer/CLAUDE.md +97 -0
- package/v3/roles/code-reviewer/CLAUDE.md +89 -0
- package/v3/roles/database-implementer/CLAUDE.md +97 -0
- package/v3/roles/deployer/CLAUDE.md +42 -0
- package/v3/roles/designer/CLAUDE.md +386 -0
- package/v3/roles/documentation/CLAUDE.md +40 -0
- package/v3/roles/feature-lead/CLAUDE.md +233 -0
- package/v3/roles/frontend-implementer/CLAUDE.md +97 -0
- package/v3/roles/implementer/CLAUDE.md +97 -0
- package/v3/roles/integration-tester/CLAUDE.md +174 -0
- package/v3/roles/observability-engineer/CLAUDE.md +40 -0
- package/v3/roles/operator/CLAUDE.md +322 -0
- package/v3/roles/orchestrator/CLAUDE.md +288 -0
- package/v3/roles/package-implementer/CLAUDE.md +47 -0
- package/v3/roles/performance-analyst/CLAUDE.md +49 -0
- package/v3/roles/plan-compiler/CLAUDE.md +163 -0
- package/v3/roles/planning-lead/CLAUDE.md +41 -0
- package/v3/roles/pm/CLAUDE.md +806 -0
- package/v3/roles/regression-tester/CLAUDE.md +135 -0
- package/v3/roles/release-manager/CLAUDE.md +43 -0
- package/v3/roles/security-auditor/CLAUDE.md +90 -0
- package/v3/roles/smoke-tester/CLAUDE.md +97 -0
- package/v3/roles/test-author/CLAUDE.md +42 -0
- package/v3/roles/verifier/CLAUDE.md +90 -0
- package/v3/schema.sql +134 -0
- package/v3/slack-adapter.js +510 -0
- 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
|
+
}
|