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,346 @@
1
+ // slack-helpers.js — Ported pure helper functions from lib/slack-helpers.js.
2
+ // Slugify, mrkdwn conversion, media upload, signal file I/O.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { execSync } from "node:child_process";
7
+ import { IRIAI_TEAM_DIR, KNOWN_REPOS, TEMPLATES_DIR, AVAILABLE_TEMPLATES } from "./constants.js";
8
+
9
+ export function slugify(text) {
10
+ return text
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, "-")
13
+ .replace(/^-|-$/g, "")
14
+ .slice(0, 40);
15
+ }
16
+
17
+ export function ensureDir(dir) {
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ }
20
+
21
+ /**
22
+ * Read a signal file, return content, and optionally delete it.
23
+ */
24
+ export function readSignal(filePath, { deleteAfter = false } = {}) {
25
+ try {
26
+ const content = fs.readFileSync(filePath, "utf-8").trim();
27
+ if (deleteAfter) fs.unlinkSync(filePath);
28
+ return content;
29
+ } catch {
30
+ return "";
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Write content to a signal file (ensures parent dir exists).
36
+ */
37
+ export function writeSignal(filePath, content) {
38
+ ensureDir(path.dirname(filePath));
39
+ fs.writeFileSync(filePath, content);
40
+ }
41
+
42
+ /**
43
+ * Convert standard Markdown to Slack mrkdwn format.
44
+ */
45
+ export function markdownToMrkdwn(text) {
46
+ if (!text) return text;
47
+
48
+ const codeBlocks = [];
49
+ let result = text.replace(/```[\s\S]*?```/g, (match) => {
50
+ codeBlocks.push(match);
51
+ return `\x00CODE${codeBlocks.length - 1}\x00`;
52
+ });
53
+
54
+ const inlineCode = [];
55
+ result = result.replace(/`[^`]+`/g, (match) => {
56
+ inlineCode.push(match);
57
+ return `\x00INLINE${inlineCode.length - 1}\x00`;
58
+ });
59
+
60
+ // Headers → bold
61
+ result = result.replace(/^#{1,6}\s+(.+)$/gm, "*$1*");
62
+
63
+ // Bold: **text** → *text*
64
+ result = result.replace(/\*\*(.+?)\*\*/g, "*$1*");
65
+
66
+ // Links: [text](url) → <url|text>
67
+ result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>");
68
+
69
+ // Image refs: ![alt](path) → [img:path]
70
+ result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "[img:$2]");
71
+
72
+ // Restore inline code
73
+ result = result.replace(/\x00INLINE(\d+)\x00/g, (_, i) => inlineCode[i]);
74
+
75
+ // Restore code blocks
76
+ result = result.replace(/\x00CODE(\d+)\x00/g, (_, i) => codeBlocks[i]);
77
+
78
+ return result;
79
+ }
80
+
81
+ /**
82
+ * Extract media markers from message text.
83
+ */
84
+ export function parseGifMarkers(text) {
85
+ const gifPaths = [];
86
+ const evidencePaths = [];
87
+ const cleaned = text.replace(/\[(gif|img|image|video|screenshot|evidence):([^\]]+)\]/gi, (match, type, filePath) => {
88
+ if (type.toLowerCase() === "evidence") {
89
+ evidencePaths.push(filePath.trim());
90
+ } else {
91
+ gifPaths.push(filePath.trim());
92
+ }
93
+ return "";
94
+ });
95
+ return { text: cleaned.trim(), gifPaths, evidencePaths };
96
+ }
97
+
98
+ function isMediaPathAllowed(filePath) {
99
+ const resolved = path.resolve(filePath);
100
+ const ALLOWED_DIRS = [
101
+ path.resolve("/Users/danielzhang/src/iriai"),
102
+ "/tmp",
103
+ "/private/tmp",
104
+ ];
105
+ return ALLOWED_DIRS.some(dir => resolved.startsWith(dir + path.sep) || resolved === dir);
106
+ }
107
+
108
+ /**
109
+ * Upload a binary file as a Slack attachment.
110
+ */
111
+ export async function uploadGifAttachment(web, channel, thread_ts, filePath, title) {
112
+ try {
113
+ if (!isMediaPathAllowed(filePath)) {
114
+ console.warn(`[uploadGifAttachment] Path not in allowed directories: ${filePath}`);
115
+ return false;
116
+ }
117
+
118
+ if (!fs.existsSync(filePath)) {
119
+ console.warn(`[bridge] Media file not found: ${filePath}`);
120
+ return false;
121
+ }
122
+
123
+ const stats = fs.statSync(filePath);
124
+ if (stats.size > 20 * 1024 * 1024) {
125
+ console.warn(`[bridge] Media too large (${(stats.size / 1024 / 1024).toFixed(1)}MB): ${filePath}`);
126
+ return false;
127
+ }
128
+
129
+ const fileBuffer = fs.readFileSync(filePath);
130
+ await web.filesUploadV2({
131
+ channel_id: channel,
132
+ thread_ts,
133
+ file: fileBuffer,
134
+ filename: path.basename(filePath),
135
+ title: title || path.basename(filePath),
136
+ });
137
+ return true;
138
+ } catch (err) {
139
+ console.error(`[bridge] Media upload failed for ${filePath}:`, err.message);
140
+ return false;
141
+ }
142
+ }
143
+
144
+ export function findArtifact(filename, planDir) {
145
+ const dir = planDir || path.join(IRIAI_TEAM_DIR, "implementation-plans", "current");
146
+ try {
147
+ const files = fs.readdirSync(dir);
148
+ const match = files.find((f) => f.includes(filename) && f.endsWith(".md"));
149
+ if (match) return path.join(dir, match);
150
+ } catch {
151
+ // Directory doesn't exist yet
152
+ }
153
+ return null;
154
+ }
155
+
156
+ export async function uploadArtifact(web, channel, thread_ts, filePath, title) {
157
+ try {
158
+ const content = fs.readFileSync(filePath, "utf-8");
159
+ await web.filesUploadV2({
160
+ channel_id: channel,
161
+ thread_ts,
162
+ content,
163
+ filename: path.basename(filePath),
164
+ title,
165
+ });
166
+ } catch (err) {
167
+ console.error(`Error uploading artifact ${title}:`, err.message);
168
+ const content = fs.readFileSync(filePath, "utf-8");
169
+ const truncated =
170
+ content.length > 3000
171
+ ? content.slice(0, 3000) + "\n\n_(truncated — full document in repo)_"
172
+ : content;
173
+ await postToThread(web, channel, thread_ts, `*${title}:*\n\n${truncated}`);
174
+ }
175
+ }
176
+
177
+ export function detectReposFromPlan(planDir, featureSlug) {
178
+ const planPath = findArtifact("implementation-plan", planDir);
179
+ const detected = [];
180
+
181
+ // 1. Text-scan plan artifact against KNOWN_REPOS
182
+ if (planPath) {
183
+ const content = fs.readFileSync(planPath, "utf-8");
184
+ for (const repo of KNOWN_REPOS) {
185
+ if (content.includes(repo)) detected.push(repo);
186
+ }
187
+ }
188
+
189
+ // 2. Scan worktree dir for repos already pulled in (catches new repos)
190
+ if (featureSlug) {
191
+ const reposDir = path.join(process.env.HOME, "src/iriai/.features", featureSlug, "repos");
192
+ if (fs.existsSync(reposDir)) {
193
+ try {
194
+ const entries = fs.readdirSync(reposDir, { withFileTypes: true });
195
+ for (const entry of entries) {
196
+ if (!entry.isDirectory()) continue;
197
+ const wtPath = path.join(reposDir, entry.name);
198
+ if (!fs.existsSync(path.join(wtPath, ".git"))) continue;
199
+ // Try to resolve the source repo path from the worktree's remote
200
+ try {
201
+ const remote = execSync(`git -C "${wtPath}" remote get-url origin`, { stdio: "pipe" }).toString().trim();
202
+ // Remote is a local path like /Users/.../src/iriai/first-party-apps/test/test-backend
203
+ const projectRoot = path.join(process.env.HOME, "src/iriai");
204
+ if (remote.startsWith(projectRoot)) {
205
+ const relPath = remote.slice(projectRoot.length + 1);
206
+ if (!detected.includes(relPath)) detected.push(relPath);
207
+ }
208
+ } catch {
209
+ // No remote — might be a locally-scaffolded new repo; skip
210
+ }
211
+ }
212
+ } catch { /* reposDir read failed */ }
213
+ }
214
+ }
215
+
216
+ return detected;
217
+ }
218
+
219
+ /**
220
+ * Scaffold a new repo locally: create directory, apply template or bare scaffold,
221
+ * initialize git. Does NOT create GitHub repo (that happens post-approval).
222
+ * Returns true on success, false on failure.
223
+ */
224
+ export function scaffoldNewRepo(localPath, githubName, template) {
225
+ const projectRoot = path.join(process.env.HOME, "src/iriai");
226
+ const repoAbsPath = path.join(projectRoot, localPath);
227
+ const repoBasename = path.basename(localPath);
228
+
229
+ // Idempotent: if already exists with .git, skip
230
+ if (fs.existsSync(path.join(repoAbsPath, ".git"))) {
231
+ console.log(`[scaffoldNewRepo] ${localPath} already exists with .git, skipping`);
232
+ return true;
233
+ }
234
+
235
+ try {
236
+ fs.mkdirSync(repoAbsPath, { recursive: true });
237
+
238
+ if (template && AVAILABLE_TEMPLATES.includes(template)) {
239
+ const templateDir = path.join(TEMPLATES_DIR, template);
240
+ if (fs.existsSync(templateDir)) {
241
+ execSync(`rsync -a --exclude '.git' "${templateDir}/" "${repoAbsPath}/"`, { stdio: "pipe" });
242
+ console.log(`[scaffoldNewRepo] Applied template '${template}' to ${localPath}`);
243
+ } else {
244
+ console.warn(`[scaffoldNewRepo] Template dir not found: ${templateDir}, using bare scaffold`);
245
+ _writeBareScaffold(repoAbsPath, repoBasename);
246
+ }
247
+ } else {
248
+ _writeBareScaffold(repoAbsPath, repoBasename);
249
+ }
250
+
251
+ // Initialize git
252
+ execSync(`git -C "${repoAbsPath}" init -b main`, { stdio: "pipe" });
253
+ execSync(`git -C "${repoAbsPath}" add -A`, { stdio: "pipe" });
254
+ execSync(`git -C "${repoAbsPath}" commit -m "chore: scaffold ${repoBasename}"`, { stdio: "pipe" });
255
+
256
+ console.log(`[scaffoldNewRepo] Scaffolded ${localPath} (template: ${template || "bare"})`);
257
+ return true;
258
+ } catch (err) {
259
+ console.error(`[scaffoldNewRepo] Failed to scaffold ${localPath}:`, err.message);
260
+ return false;
261
+ }
262
+ }
263
+
264
+ function _writeBareScaffold(dir, name) {
265
+ fs.writeFileSync(path.join(dir, "README.md"), `# ${name}\n\nPart of the iriai platform.\n`);
266
+ fs.writeFileSync(path.join(dir, ".gitignore"), [
267
+ "node_modules/", "__pycache__/", "*.pyc", ".venv/", "venv/",
268
+ ".env", ".env.local", ".idea/", ".vscode/", "*.swp",
269
+ ".DS_Store", "dist/", "build/", "*.egg-info/",
270
+ ].join("\n") + "\n");
271
+ }
272
+
273
+ export function parseRole(text) {
274
+ const lower = text.toLowerCase();
275
+ if (lower.includes("@pm")) return "pm";
276
+ if (lower.includes("@designer")) return "designer";
277
+ if (lower.includes("@architect")) return "architect";
278
+ if (lower.includes("@compiler") || lower.includes("@plan-compiler"))
279
+ return "plan-compiler";
280
+ if (lower.includes("@lead")) return "lead";
281
+ return null;
282
+ }
283
+
284
+ /**
285
+ * Post a message to a Slack thread with mrkdwn conversion.
286
+ */
287
+ export async function postToThread(web, channel, thread_ts, text) {
288
+ await web.chat.postMessage({ channel, thread_ts, text: markdownToMrkdwn(text), mrkdwn: true });
289
+ }
290
+
291
+ /**
292
+ * Build Slack Block Kit blocks for a decision with action buttons.
293
+ */
294
+ export function buildDecisionBlocks(decisionId, title, context, options) {
295
+ const blocks = [];
296
+
297
+ blocks.push({
298
+ type: "section",
299
+ text: { type: "mrkdwn", text: `*${title}*${context ? `\n${context}` : ""}` },
300
+ });
301
+
302
+ blocks.push({
303
+ type: "actions",
304
+ block_id: `decision_${decisionId}`,
305
+ elements: options.map(opt => ({
306
+ type: "button",
307
+ text: { type: "plain_text", text: opt.label, emoji: true },
308
+ value: opt.id,
309
+ action_id: `decision_${decisionId}_${opt.id}`,
310
+ ...(opt.style === "primary" ? { style: "primary" } : {}),
311
+ ...(opt.style === "danger" ? { style: "danger" } : {}),
312
+ })),
313
+ });
314
+
315
+ return blocks;
316
+ }
317
+
318
+ /**
319
+ * Build resolved-state blocks to replace buttons after a decision is made.
320
+ */
321
+ export function buildResolvedBlocks(title, selectedLabel, resolvedBy, feedback = "") {
322
+ const feedbackLine = feedback ? `\n> ${feedback.replace(/\n/g, "\n> ")}` : "";
323
+ return [{
324
+ type: "section",
325
+ text: {
326
+ type: "mrkdwn",
327
+ text: `*${title}*\n~Resolved~: *${selectedLabel}*${resolvedBy ? ` by <@${resolvedBy}>` : ""}${feedbackLine}`,
328
+ },
329
+ }];
330
+ }
331
+
332
+ export async function addReaction(web, channel, timestamp, reaction) {
333
+ try {
334
+ await web.reactions.add({ channel, name: reaction, timestamp });
335
+ } catch {
336
+ // Ignore — reaction may already exist
337
+ }
338
+ }
339
+
340
+ export async function removeReaction(web, channel, timestamp, reaction) {
341
+ try {
342
+ await web.reactions.remove({ channel, name: reaction, timestamp });
343
+ } catch {
344
+ // Ignore
345
+ }
346
+ }