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,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:  → [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
|
+
}
|