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,215 @@
1
+ // signal-watcher.js — Centralized chokidar-based signal file watcher.
2
+ // Replaces the 5+ fragmented watchers in index.js.
3
+ // Single chokidar instance per signal domain, emits typed events.
4
+
5
+ import { EventEmitter } from "node:events";
6
+ import path from "node:path";
7
+ import fs from "node:fs";
8
+ import chokidar from "chokidar";
9
+ import { ROLE_DIRS, SIGNAL } from "./constants.js";
10
+ import { ensureDir } from "./slack-helpers.js";
11
+
12
+ const WATCHER_OPTS = {
13
+ ignoreInitial: true,
14
+ awaitWriteFinish: { stabilityThreshold: 500 },
15
+ };
16
+
17
+ export default class SignalWatcher extends EventEmitter {
18
+ constructor() {
19
+ super();
20
+ this._watchers = [];
21
+ this._featureWatchers = new Map(); // slug -> watcher[]
22
+ }
23
+
24
+ // ─── Planning Signals ────────────────────────────────────────────────────
25
+
26
+ watchPlanningSignals() {
27
+ const dirs = Object.values(ROLE_DIRS);
28
+ for (const d of dirs) ensureDir(d);
29
+
30
+ // Watch .agent-response
31
+ this._watch(
32
+ dirs.map((d) => path.join(d, SIGNAL.AGENT_RESPONSE)),
33
+ (fp) => {
34
+ const role = this._roleFromDir(path.dirname(fp));
35
+ if (role) this.emit("planning:response", { role, filePath: fp });
36
+ }
37
+ );
38
+
39
+ // NOTE: .done is intentionally NOT watched by chokidar here.
40
+ // PlanningRole state machine emits "done" on exit, and dispatchPlanningRole
41
+ // re-emits it as signalWatcher.emit("planning:done"). Watching it here too
42
+ // would cause double-posting. processExistingSignals() handles stale .done
43
+ // files on startup.
44
+
45
+ // Watch .question
46
+ this._watch(
47
+ dirs.map((d) => path.join(d, SIGNAL.QUESTION)),
48
+ (fp) => {
49
+ const role = this._roleFromDir(path.dirname(fp));
50
+ if (role) this.emit("planning:question", { role, filePath: fp });
51
+ }
52
+ );
53
+
54
+ console.log("[signal-watcher] Watching planning signals");
55
+ }
56
+
57
+ // ─── Implementation Feature Signals ──────────────────────────────────────
58
+
59
+ watchFeatureSignals(slug, tree) {
60
+ const featureWatchers = [];
61
+ const watch = (patterns, handler) => {
62
+ const w = this._createWatcher(patterns, handler);
63
+ featureWatchers.push(w);
64
+ };
65
+
66
+ // Feature Lead .agent-response
67
+ if (tree.featureLead) {
68
+ watch(
69
+ [path.join(tree.featureLead, SIGNAL.AGENT_RESPONSE)],
70
+ (fp) => this.emit("impl:response", { slug, agent: "feature-lead", filePath: fp })
71
+ );
72
+
73
+ // .feature-complete
74
+ watch(
75
+ [path.join(tree.featureLead, SIGNAL.FEATURE_COMPLETE)],
76
+ (fp) => this.emit("impl:featureComplete", { slug, filePath: fp })
77
+ );
78
+
79
+ // .phase-done
80
+ watch(
81
+ [path.join(tree.featureLead, SIGNAL.PHASE_DONE)],
82
+ (fp) => this.emit("impl:phaseDone", { slug, filePath: fp })
83
+ );
84
+
85
+ // .context-refresh
86
+ watch(
87
+ [path.join(tree.featureLead, SIGNAL.CONTEXT_REFRESH)],
88
+ (fp) => this.emit("impl:contextRefresh", { slug, filePath: fp })
89
+ );
90
+ }
91
+
92
+ // Operator .agent-response
93
+ if (tree.operator) {
94
+ watch(
95
+ [path.join(tree.operator, SIGNAL.AGENT_RESPONSE)],
96
+ (fp) => this.emit("impl:operatorResponse", { slug, filePath: fp })
97
+ );
98
+
99
+ // Operator .user-message (for triggering operator agent)
100
+ watch(
101
+ [path.join(tree.operator, SIGNAL.USER_MESSAGE)],
102
+ (fp) => this.emit("impl:userMessage", { slug, agent: "operator", filePath: fp })
103
+ );
104
+ }
105
+
106
+ // Collect all agent dirs for .needs-restart watching
107
+ const allDirs = [];
108
+ if (tree.featureLead) allDirs.push(tree.featureLead);
109
+ if (tree.operator) allDirs.push(tree.operator);
110
+
111
+ for (const [teamNum, team] of Object.entries(tree.teams)) {
112
+ if (team.orchestrator) {
113
+ allDirs.push(team.orchestrator);
114
+
115
+ // Watch .gate-ready per team
116
+ watch(
117
+ [path.join(team.orchestrator, SIGNAL.GATE_READY)],
118
+ (fp) => this.emit("impl:gateReady", { slug, teamNum, filePath: fp })
119
+ );
120
+
121
+ // Watch .crashed per team orchestrator
122
+ watch(
123
+ [path.join(team.orchestrator, SIGNAL.CRASHED)],
124
+ (fp) => this.emit("impl:crashed", { slug, agent: `team-${teamNum}-orch`, teamNum, filePath: fp })
125
+ );
126
+
127
+ // Watch .question per team orchestrator
128
+ watch(
129
+ [path.join(team.orchestrator, SIGNAL.QUESTION)],
130
+ (fp) => this.emit("impl:question", { slug, teamNum, filePath: fp })
131
+ );
132
+
133
+ // Watch .task for team orchestrator (dispatched by Feature Lead)
134
+ watch(
135
+ [path.join(team.orchestrator, SIGNAL.TASK)],
136
+ (fp) => this.emit("impl:orchTask", { slug, teamNum, filePath: fp })
137
+ );
138
+ }
139
+
140
+ for (const [role, roleDir] of Object.entries(team.roles)) {
141
+ allDirs.push(roleDir);
142
+
143
+ // Watch .task for role agents
144
+ watch(
145
+ [path.join(roleDir, SIGNAL.TASK)],
146
+ (fp) => this.emit("impl:task", { slug, teamNum, role, filePath: fp })
147
+ );
148
+
149
+ // Watch .done for role agents
150
+ watch(
151
+ [path.join(roleDir, SIGNAL.DONE)],
152
+ (fp) => this.emit("impl:done", { slug, teamNum, role, filePath: fp })
153
+ );
154
+ }
155
+ }
156
+
157
+ // Feature review roles
158
+ for (const [role, reviewDir] of Object.entries(tree.featureReview)) {
159
+ allDirs.push(reviewDir);
160
+
161
+ watch(
162
+ [path.join(reviewDir, SIGNAL.DONE)],
163
+ (fp) => this.emit("impl:done", { slug, agent: `review-${role}`, role, filePath: fp })
164
+ );
165
+ }
166
+
167
+ // Watch .needs-restart across ALL agent dirs
168
+ watch(
169
+ allDirs.map((d) => path.join(d, SIGNAL.NEEDS_RESTART)),
170
+ (fp) => this.emit("impl:needsRestart", { slug, dir: path.dirname(fp), filePath: fp })
171
+ );
172
+
173
+ this._featureWatchers.set(slug, featureWatchers);
174
+ console.log(`[signal-watcher] Watching impl signals for ${slug}: ${allDirs.length} dirs`);
175
+ }
176
+
177
+ unwatchFeature(slug) {
178
+ const watchers = this._featureWatchers.get(slug);
179
+ if (watchers) {
180
+ for (const w of watchers) w.close();
181
+ this._featureWatchers.delete(slug);
182
+ console.log(`[signal-watcher] Unwatched feature ${slug}`);
183
+ }
184
+ }
185
+
186
+ // ─── Cleanup ─────────────────────────────────────────────────────────────
187
+
188
+ async closeAll() {
189
+ for (const w of this._watchers) await w.close();
190
+ for (const [, watchers] of this._featureWatchers) {
191
+ for (const w of watchers) await w.close();
192
+ }
193
+ this._watchers = [];
194
+ this._featureWatchers.clear();
195
+ }
196
+
197
+ // ─── Internals ───────────────────────────────────────────────────────────
198
+
199
+ _watch(patterns, handler) {
200
+ const w = this._createWatcher(patterns, handler);
201
+ this._watchers.push(w);
202
+ return w;
203
+ }
204
+
205
+ _createWatcher(patterns, handler) {
206
+ const w = chokidar.watch(patterns, WATCHER_OPTS);
207
+ w.on("add", handler);
208
+ w.on("change", handler);
209
+ return w;
210
+ }
211
+
212
+ _roleFromDir(dir) {
213
+ return Object.entries(ROLE_DIRS).find(([, d]) => d === dir)?.[0] || null;
214
+ }
215
+ }
@@ -0,0 +1,224 @@
1
+ // slack-helpers.js — Pure helper functions for Slack API and file operations.
2
+ // Extracted from index.js lines 82-210.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { IRIAI_TEAM_DIR, KNOWN_REPOS } from "./constants.js";
7
+
8
+ export function slugify(text) {
9
+ return text
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/^-|-$/g, "")
13
+ .slice(0, 40);
14
+ }
15
+
16
+ export function ensureDir(dir) {
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ }
19
+
20
+ export async function postToThread(web, channel, thread_ts, text) {
21
+ await web.chat.postMessage({ channel, thread_ts, text: markdownToMrkdwn(text), mrkdwn: true });
22
+ }
23
+
24
+ export async function addReaction(web, channel, timestamp, reaction) {
25
+ try {
26
+ await web.reactions.add({ channel, name: reaction, timestamp });
27
+ } catch {
28
+ // Ignore — reaction may already exist or message may be gone
29
+ }
30
+ }
31
+
32
+ export async function removeReaction(web, channel, timestamp, reaction) {
33
+ try {
34
+ await web.reactions.remove({ channel, name: reaction, timestamp });
35
+ } catch {
36
+ // Ignore — reaction may not exist
37
+ }
38
+ }
39
+
40
+ export function findArtifact(filename) {
41
+ const planDir = path.join(IRIAI_TEAM_DIR, "implementation-plans", "current");
42
+ try {
43
+ const files = fs.readdirSync(planDir);
44
+ const match = files.find((f) => f.includes(filename) && f.endsWith(".md"));
45
+ if (match) return path.join(planDir, match);
46
+ } catch {
47
+ // Directory or file doesn't exist yet
48
+ }
49
+ return null;
50
+ }
51
+
52
+ export async function uploadArtifact(web, channel, thread_ts, filePath, title) {
53
+ try {
54
+ const content = fs.readFileSync(filePath, "utf-8");
55
+ await web.filesUploadV2({
56
+ channel_id: channel,
57
+ thread_ts,
58
+ content,
59
+ filename: path.basename(filePath),
60
+ title,
61
+ });
62
+ } catch (err) {
63
+ console.error(`Error uploading artifact ${title}:`, err.message);
64
+ const content = fs.readFileSync(filePath, "utf-8");
65
+ const truncated =
66
+ content.length > 3000
67
+ ? content.slice(0, 3000) + "\n\n_(truncated — full document in repo)_"
68
+ : content;
69
+ await postToThread(web, channel, thread_ts, `*${title}:*\n\n${truncated}`);
70
+ }
71
+ }
72
+
73
+ export function detectReposFromPlan() {
74
+ const planPath = findArtifact("implementation-plan");
75
+ if (!planPath) return [];
76
+ const content = fs.readFileSync(planPath, "utf-8");
77
+ return KNOWN_REPOS.filter((repo) => content.includes(repo));
78
+ }
79
+
80
+ export function parseRole(text) {
81
+ const lower = text.toLowerCase();
82
+ if (lower.includes("@pm")) return "pm";
83
+ if (lower.includes("@designer")) return "designer";
84
+ if (lower.includes("@architect")) return "architect";
85
+ if (lower.includes("@compiler") || lower.includes("@plan-compiler"))
86
+ return "plan-compiler";
87
+ if (lower.includes("@lead")) return "lead";
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Read a signal file, return content, and optionally delete it.
93
+ * Returns empty string if file doesn't exist.
94
+ */
95
+ export function readSignal(filePath, { deleteAfter = false } = {}) {
96
+ try {
97
+ const content = fs.readFileSync(filePath, "utf-8").trim();
98
+ if (deleteAfter) fs.unlinkSync(filePath);
99
+ return content;
100
+ } catch {
101
+ return "";
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Write content to a signal file (ensures parent dir exists).
107
+ */
108
+ export function writeSignal(filePath, content) {
109
+ ensureDir(path.dirname(filePath));
110
+ fs.writeFileSync(filePath, content);
111
+ }
112
+
113
+ /**
114
+ * Convert standard Markdown to Slack mrkdwn format.
115
+ * Handles: **bold**→*bold*, ## headers→*Header*, [text](url)→<url|text>,
116
+ * *italic*→_italic_, ![alt](path)→[img:path]. Preserves code blocks.
117
+ */
118
+ export function markdownToMrkdwn(text) {
119
+ if (!text) return text;
120
+
121
+ // Extract code blocks to protect them from conversion
122
+ const codeBlocks = [];
123
+ let result = text.replace(/```[\s\S]*?```/g, (match) => {
124
+ codeBlocks.push(match);
125
+ return `\x00CODE${codeBlocks.length - 1}\x00`;
126
+ });
127
+
128
+ // Extract inline code
129
+ const inlineCode = [];
130
+ result = result.replace(/`[^`]+`/g, (match) => {
131
+ inlineCode.push(match);
132
+ return `\x00INLINE${inlineCode.length - 1}\x00`;
133
+ });
134
+
135
+ // Headers → bold (Slack has no headers)
136
+ result = result.replace(/^#{1,6}\s+(.+)$/gm, "*$1*");
137
+
138
+ // Bold: **text** → *text*
139
+ result = result.replace(/\*\*(.+?)\*\*/g, "*$1*");
140
+
141
+ // Italic: _text_ stays as-is (works in both)
142
+ // Standalone *text* (not **) that isn't already converted → _text_
143
+ // This is tricky — after bold conversion, single * pairs are already bold in Slack.
144
+ // Skip this to avoid double-converting.
145
+
146
+ // Links: [text](url) → <url|text>
147
+ result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>");
148
+
149
+ // Image refs: ![alt](path) → [img:path]
150
+ result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "[img:$2]");
151
+
152
+ // Restore inline code
153
+ result = result.replace(/\x00INLINE(\d+)\x00/g, (_, i) => inlineCode[i]);
154
+
155
+ // Restore code blocks
156
+ result = result.replace(/\x00CODE(\d+)\x00/g, (_, i) => codeBlocks[i]);
157
+
158
+ return result;
159
+ }
160
+
161
+ /**
162
+ * Extract media markers from message text: [gif:<path>], [img:<path>], [video:<path>].
163
+ * Returns the cleaned text (markers removed) and an array of file paths.
164
+ */
165
+ export function parseGifMarkers(text) {
166
+ const gifPaths = [];
167
+ const cleaned = text.replace(/\[(gif|img|image|video|screenshot):([^\]]+)\]/gi, (match, type, filePath) => {
168
+ gifPaths.push(filePath.trim());
169
+ return "";
170
+ });
171
+ return { text: cleaned.trim(), gifPaths };
172
+ }
173
+
174
+ /**
175
+ * Check whether a file path is within the allowed directories for media uploads.
176
+ * Prevents path-traversal attacks that could exfiltrate arbitrary files.
177
+ */
178
+ function isMediaPathAllowed(filePath) {
179
+ const resolved = path.resolve(filePath);
180
+ const ALLOWED_DIRS = [
181
+ path.resolve('/Users/danielzhang/src/iriai'),
182
+ '/tmp',
183
+ '/private/tmp',
184
+ ];
185
+ return ALLOWED_DIRS.some(dir => resolved.startsWith(dir + path.sep) || resolved === dir);
186
+ }
187
+
188
+ /**
189
+ * Upload a binary file (GIF, PNG, JPG, WebM, MP4) as a Slack attachment.
190
+ * Reads the file as a Buffer and uses filesUploadV2.
191
+ * Silently skips if file doesn't exist or exceeds 20MB.
192
+ */
193
+ export async function uploadGifAttachment(web, channel, thread_ts, filePath, title) {
194
+ try {
195
+ if (!isMediaPathAllowed(filePath)) {
196
+ console.warn(`[uploadGifAttachment] Path not in allowed directories: ${filePath}`);
197
+ return false;
198
+ }
199
+
200
+ if (!fs.existsSync(filePath)) {
201
+ console.warn(`[bridge] Media file not found: ${filePath}`);
202
+ return false;
203
+ }
204
+
205
+ const stats = fs.statSync(filePath);
206
+ if (stats.size > 20 * 1024 * 1024) {
207
+ console.warn(`[bridge] Media too large (${(stats.size / 1024 / 1024).toFixed(1)}MB): ${filePath}`);
208
+ return false;
209
+ }
210
+
211
+ const fileBuffer = fs.readFileSync(filePath);
212
+ await web.filesUploadV2({
213
+ channel_id: channel,
214
+ thread_ts,
215
+ file: fileBuffer,
216
+ filename: path.basename(filePath),
217
+ title: title || path.basename(filePath),
218
+ });
219
+ return true;
220
+ } catch (err) {
221
+ console.error(`[bridge] Media upload failed for ${filePath}:`, err.message);
222
+ return false;
223
+ }
224
+ }