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
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
|
+
});
|