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,666 @@
1
+ // terminal-input.js — Full-screen TUI with alternate screen buffer, scroll regions,
2
+ // and raw stdin input. Output scrolls in the top region. A defined input area
3
+ // with status sits at the bottom, vertically centered for tall terminals.
4
+
5
+ import readline from "node:readline";
6
+ import { select, input, confirm } from "@inquirer/prompts";
7
+ import * as queries from "../v3/queries.js";
8
+ import { setOutputFn } from "./display.js";
9
+
10
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
11
+
12
+ const C = {
13
+ reset: "\x1b[0m",
14
+ bold: "\x1b[1m",
15
+ dim: "\x1b[2m",
16
+ cyan: "\x1b[36m",
17
+ green: "\x1b[32m",
18
+ yellow: "\x1b[33m",
19
+ white: "\x1b[37m",
20
+ clearLine: "\x1b[2K",
21
+ };
22
+
23
+ const ROLE_LABELS = {
24
+ pm: "PM", designer: "Designer", architect: "Architect",
25
+ "plan-compiler": "Plan Compiler", "feature-lead": "FL",
26
+ };
27
+
28
+ // Footer height: top-border, status, blank, input, bottom-border, padding
29
+ const FOOTER_LINES = 6;
30
+
31
+ export class TerminalInput {
32
+ constructor({ orchestrator }) {
33
+ this.orchestrator = orchestrator;
34
+ this._activeFeatureId = null;
35
+ this._activeChannel = null;
36
+ this._inputLoopRunning = false;
37
+ this._statusText = "";
38
+ this._statusTimer = null;
39
+ this._spinnerFrame = 0;
40
+ this._agentState = "idle";
41
+
42
+ // TUI state
43
+ this._inTUI = false;
44
+ this._rows = 0;
45
+ this._cols = 0;
46
+ this._scrollBottom = 0; // last row of scroll region
47
+ this._footerTop = 0; // first row of footer area
48
+ this._inputBuffer = "";
49
+ this._cursorPos = 0;
50
+ this._inputResolve = null;
51
+ this._keypressHandler = null;
52
+ this._resizeHandler = null;
53
+ }
54
+
55
+ setActiveFeature(featureId, channel) {
56
+ this._activeFeatureId = featureId;
57
+ this._activeChannel = channel;
58
+ }
59
+
60
+ // ─── TUI Screen Management ──────────────────────────────────────
61
+
62
+ _enterTUI() {
63
+ if (this._inTUI) return;
64
+ this._inTUI = true;
65
+
66
+ // Use the normal screen buffer (not alternate) so terminal scrollback works.
67
+ // Push enough newlines to make room for the footer at the bottom.
68
+ const rows = process.stdout.rows || 24;
69
+ process.stdout.write("\n".repeat(rows));
70
+ process.stdout.write(`\x1b[${rows};1H`); // move to bottom
71
+
72
+ this._setupLayout();
73
+
74
+ // Print welcome header in scroll region
75
+ const feature = this._activeFeatureId
76
+ ? queries.getFeatureById(this._activeFeatureId)
77
+ : null;
78
+ const slug = feature?.slug || "unknown";
79
+ const w = Math.min(this._cols, 60);
80
+ this._printToScrollRegion("");
81
+ this._printToScrollRegion(
82
+ ` ${C.bold}${C.cyan}iriai-build${C.reset} ${C.dim}v3${C.reset}`
83
+ );
84
+ this._printToScrollRegion(
85
+ ` ${C.dim}${"─".repeat(w)}${C.reset}`
86
+ );
87
+ this._printToScrollRegion(
88
+ ` ${C.bold}Feature:${C.reset} ${C.cyan}${slug}${C.reset}`
89
+ );
90
+ this._printToScrollRegion(
91
+ ` ${C.dim}Planning pipeline: PM → Designer → Architect → Plan Approval${C.reset}`
92
+ );
93
+ this._printToScrollRegion(
94
+ ` ${C.dim}Type a message to talk to the Operator. Ctrl+C to quit.${C.reset}`
95
+ );
96
+ this._printToScrollRegion(
97
+ ` ${C.dim}${"─".repeat(w)}${C.reset}`
98
+ );
99
+ this._printToScrollRegion("");
100
+
101
+ // Handle terminal resize
102
+ this._resizeHandler = () => {
103
+ this._setupLayout();
104
+ this._drawFooter();
105
+ this._drawInputLine();
106
+ };
107
+ process.stdout.on("resize", this._resizeHandler);
108
+
109
+ // Route display.js output through TUI
110
+ setOutputFn((text) => this.printAboveLine(text));
111
+
112
+ // Set up raw stdin input
113
+ this._setupRawInput();
114
+
115
+ // Show cursor at input line
116
+ process.stdout.write("\x1b[?25h");
117
+ this._drawInputLine();
118
+ }
119
+
120
+ _exitTUI() {
121
+ if (!this._inTUI) return;
122
+ this._inTUI = false;
123
+
124
+ // Tear down raw input
125
+ this._teardownRawInput();
126
+
127
+ // Remove resize handler
128
+ if (this._resizeHandler) {
129
+ process.stdout.removeListener("resize", this._resizeHandler);
130
+ this._resizeHandler = null;
131
+ }
132
+
133
+ // Reset scroll region, move cursor below footer, show cursor
134
+ process.stdout.write("\x1b[r");
135
+ const rows = process.stdout.rows || 24;
136
+ process.stdout.write(`\x1b[${rows};1H\n`);
137
+ process.stdout.write("\x1b[?25h");
138
+
139
+ // Reset display output to console.log
140
+ setOutputFn(null);
141
+ }
142
+
143
+ /**
144
+ * Clean up TUI on final shutdown. Resets scroll region and cursor.
145
+ */
146
+ exitScreen() {
147
+ if (this._inTUI) this._exitTUI();
148
+ process.stdout.write("\x1b[?25h");
149
+ process.stdout.write("\x1b[r");
150
+ }
151
+
152
+ _setupLayout() {
153
+ this._rows = process.stdout.rows || 24;
154
+ this._cols = process.stdout.columns || 80;
155
+
156
+ // Output region uses all rows above the footer
157
+ const outputRows = this._rows - FOOTER_LINES;
158
+
159
+ // Footer starts right after output region
160
+ this._footerTop = outputRows + 1;
161
+ this._scrollBottom = outputRows;
162
+
163
+ // Set scroll region (rows 1 through scrollBottom)
164
+ process.stdout.write(`\x1b[1;${this._scrollBottom}r`);
165
+
166
+ // Draw footer
167
+ this._drawFooter();
168
+ }
169
+
170
+ // ─── Footer (bordered input area) ─────────────────────────────
171
+
172
+ _drawFooter() {
173
+ if (!this._inTUI) return;
174
+
175
+ const w = this._cols;
176
+ const sepRow = this._footerTop; // horizontal rule
177
+ const statusRow = this._footerTop + 1; // status line
178
+ const blankRow = this._footerTop + 2; // spacing
179
+ const inputRow = this._footerTop + 3; // input prompt
180
+ const padRow = this._footerTop + 4; // bottom padding
181
+
182
+ const rule = "─".repeat(w);
183
+
184
+ // Save cursor, draw footer, restore
185
+ process.stdout.write("\x1b7");
186
+
187
+ // Separator rule
188
+ process.stdout.write(`\x1b[${sepRow};1H${C.clearLine}${C.dim}${rule}${C.reset}`);
189
+
190
+ // Status line
191
+ const statusContent = this._buildStatusLine();
192
+ process.stdout.write(`\x1b[${statusRow};1H${C.clearLine} ${statusContent}`);
193
+
194
+ // Blank spacing
195
+ process.stdout.write(`\x1b[${blankRow};1H${C.clearLine}`);
196
+
197
+ // Input line (drawn separately for cursor positioning)
198
+ process.stdout.write(`\x1b[${inputRow};1H${C.clearLine}`);
199
+
200
+ // Bottom padding
201
+ process.stdout.write(`\x1b[${padRow};1H${C.clearLine}`);
202
+
203
+ // Clear any rows below the footer
204
+ for (let r = padRow + 1; r <= this._rows; r++) {
205
+ process.stdout.write(`\x1b[${r};1H${C.clearLine}`);
206
+ }
207
+
208
+ process.stdout.write("\x1b8");
209
+ }
210
+
211
+ _drawInputLine() {
212
+ if (!this._inTUI) return;
213
+
214
+ const inputRow = this._footerTop + 3;
215
+ const prompt = `${C.bold}>${C.reset} `;
216
+
217
+ process.stdout.write(`\x1b[${inputRow};1H${C.clearLine} ${prompt}${this._inputBuffer}`);
218
+
219
+ // Position cursor: " > " = 3 visible chars before input, 1-indexed
220
+ const cursorCol = 4 + this._cursorPos;
221
+ process.stdout.write(`\x1b[${inputRow};${cursorCol}H`);
222
+ }
223
+
224
+ // ─── Status ─────────────────────────────────────────────────────
225
+
226
+ _buildStatusLine() {
227
+ if (this._agentState === "running") {
228
+ const frame = SPINNER_FRAMES[this._spinnerFrame % SPINNER_FRAMES.length];
229
+ return `${C.cyan}${frame}${C.reset} ${C.dim}${this._statusText}${C.reset}`;
230
+ } else if (this._agentState === "decision") {
231
+ return `${C.yellow}${C.bold}●${C.reset} ${C.yellow}${this._statusText}${C.reset}`;
232
+ }
233
+ return `${C.dim}○ ${this._statusText || "Idle"}${C.reset}`;
234
+ }
235
+
236
+ _updateStatus() {
237
+ if (!this._activeFeatureId) {
238
+ this._agentState = "idle";
239
+ this._statusText = "No active feature";
240
+ return;
241
+ }
242
+
243
+ const feature = queries.getFeatureById(this._activeFeatureId);
244
+ if (!feature) {
245
+ this._agentState = "idle";
246
+ this._statusText = "Feature not found";
247
+ return;
248
+ }
249
+
250
+ const running = queries.getRunningAgents(this._activeFeatureId);
251
+ const meta = queries.getFeatureMetadata(this._activeFeatureId);
252
+
253
+ // Phase context
254
+ let phaseInfo = feature.phase;
255
+ if (feature.phase === "planning" && feature.active_planning_role) {
256
+ phaseInfo = `${ROLE_LABELS[feature.active_planning_role] || feature.active_planning_role} phase`;
257
+ } else if (feature.phase === "plan-approval") {
258
+ phaseInfo = "Plan approval";
259
+ } else if (feature.phase === "impl") {
260
+ phaseInfo = `Implementation · gate ${feature.gate_number || 0}`;
261
+ }
262
+
263
+ // Determine state
264
+ if (meta.awaiting_phase_review) {
265
+ this._agentState = "decision";
266
+ const reviewRole = ROLE_LABELS[meta.phase_review_role] || meta.phase_review_role;
267
+ this._statusText = `Awaiting your decision · ${reviewRole} review`;
268
+ } else if (feature.phase === "plan-approval") {
269
+ this._agentState = "decision";
270
+ this._statusText = "Awaiting your decision · Plan approval";
271
+ } else if (running.length > 0) {
272
+ this._agentState = "running";
273
+ const agentNames = running
274
+ .filter(a => a.agent_type !== "operator")
275
+ .map(a => ROLE_LABELS[a.role_name] || a.role_name || a.agent_type);
276
+ const unique = [...new Set(agentNames)];
277
+ const agentInfo = unique.length > 0 ? unique.join(", ") : "agents";
278
+ this._statusText = `${running.length} agent${running.length > 1 ? "s" : ""} working · ${phaseInfo} · ${agentInfo}`;
279
+ } else {
280
+ this._agentState = "idle";
281
+ this._statusText = `Idle · ${phaseInfo}`;
282
+ }
283
+ }
284
+
285
+ _refreshStatus() {
286
+ if (!this._inTUI) return;
287
+ this._spinnerFrame++;
288
+ this._updateStatus();
289
+ this._drawFooter();
290
+ this._drawInputLine();
291
+ }
292
+
293
+ // ─── Output ─────────────────────────────────────────────────────
294
+
295
+ /**
296
+ * Print a line into the scroll region (above the footer).
297
+ * Works by moving to the bottom of the scroll region, issuing a linefeed
298
+ * to scroll up, then writing the text. The footer is outside the scroll
299
+ * region so it stays fixed.
300
+ */
301
+ _printToScrollRegion(text) {
302
+ const lines = text.split("\n");
303
+ for (const line of lines) {
304
+ process.stdout.write(
305
+ `\x1b[${this._scrollBottom};1H` + // move to bottom of scroll region
306
+ "\n" + // scroll region up
307
+ `\x1b[${this._scrollBottom};1H` + // ensure we're at bottom row
308
+ `${C.clearLine}${line}` // clear and write
309
+ );
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Print text above the footer. Safe to call from adapter or display.js.
315
+ */
316
+ printAboveLine(text) {
317
+ if (!this._inTUI) {
318
+ console.log(text);
319
+ return;
320
+ }
321
+
322
+ // Save cursor (at input line), print in scroll region, restore cursor
323
+ process.stdout.write("\x1b7");
324
+ this._printToScrollRegion(text);
325
+ process.stdout.write("\x1b8");
326
+ // Redraw input line to keep cursor position correct
327
+ this._drawInputLine();
328
+ }
329
+
330
+ // ─── Raw Input ──────────────────────────────────────────────────
331
+
332
+ _setupRawInput() {
333
+ if (!process.stdin.isTTY) return;
334
+
335
+ // Set up keypress event parsing (idempotent guard)
336
+ if (!process.stdin.__keypressEventsEmitted) {
337
+ readline.emitKeypressEvents(process.stdin);
338
+ process.stdin.__keypressEventsEmitted = true;
339
+ }
340
+
341
+ process.stdin.setRawMode(true);
342
+ process.stdin.resume();
343
+
344
+ this._keypressHandler = (str, key) => this._handleKeypress(str, key);
345
+ process.stdin.on("keypress", this._keypressHandler);
346
+ }
347
+
348
+ _teardownRawInput() {
349
+ if (this._keypressHandler) {
350
+ process.stdin.removeListener("keypress", this._keypressHandler);
351
+ this._keypressHandler = null;
352
+ }
353
+ if (process.stdin.isTTY && process.stdin.rawMode) {
354
+ process.stdin.setRawMode(false);
355
+ }
356
+ process.stdin.pause();
357
+ }
358
+
359
+ _handleKeypress(str, key) {
360
+ if (!key && str) {
361
+ this._insertChar(str);
362
+ return;
363
+ }
364
+ if (!key) return;
365
+
366
+ // Ctrl+C → SIGINT
367
+ if (key.ctrl && key.name === "c") {
368
+ process.kill(process.pid, "SIGINT");
369
+ return;
370
+ }
371
+
372
+ // Ctrl+D → close input
373
+ if (key.ctrl && key.name === "d") {
374
+ if (this._inputResolve) {
375
+ const resolve = this._inputResolve;
376
+ this._inputResolve = null;
377
+ resolve(null);
378
+ }
379
+ return;
380
+ }
381
+
382
+ // Enter → submit line
383
+ if (key.name === "return") {
384
+ const text = this._inputBuffer;
385
+ this._inputBuffer = "";
386
+ this._cursorPos = 0;
387
+ this._drawInputLine();
388
+ if (this._inputResolve) {
389
+ const resolve = this._inputResolve;
390
+ this._inputResolve = null;
391
+ resolve(text);
392
+ }
393
+ return;
394
+ }
395
+
396
+ // Backspace
397
+ if (key.name === "backspace") {
398
+ if (this._cursorPos > 0) {
399
+ this._inputBuffer =
400
+ this._inputBuffer.slice(0, this._cursorPos - 1) +
401
+ this._inputBuffer.slice(this._cursorPos);
402
+ this._cursorPos--;
403
+ this._drawInputLine();
404
+ }
405
+ return;
406
+ }
407
+
408
+ // Delete
409
+ if (key.name === "delete") {
410
+ if (this._cursorPos < this._inputBuffer.length) {
411
+ this._inputBuffer =
412
+ this._inputBuffer.slice(0, this._cursorPos) +
413
+ this._inputBuffer.slice(this._cursorPos + 1);
414
+ this._drawInputLine();
415
+ }
416
+ return;
417
+ }
418
+
419
+ // Arrow keys
420
+ if (key.name === "left" && this._cursorPos > 0) {
421
+ this._cursorPos--;
422
+ this._drawInputLine();
423
+ return;
424
+ }
425
+ if (key.name === "right" && this._cursorPos < this._inputBuffer.length) {
426
+ this._cursorPos++;
427
+ this._drawInputLine();
428
+ return;
429
+ }
430
+
431
+ // Home / Ctrl+A
432
+ if (key.name === "home" || (key.ctrl && key.name === "a")) {
433
+ this._cursorPos = 0;
434
+ this._drawInputLine();
435
+ return;
436
+ }
437
+
438
+ // End / Ctrl+E
439
+ if (key.name === "end" || (key.ctrl && key.name === "e")) {
440
+ this._cursorPos = this._inputBuffer.length;
441
+ this._drawInputLine();
442
+ return;
443
+ }
444
+
445
+ // Ctrl+U → clear line
446
+ if (key.ctrl && key.name === "u") {
447
+ this._inputBuffer = "";
448
+ this._cursorPos = 0;
449
+ this._drawInputLine();
450
+ return;
451
+ }
452
+
453
+ // Ctrl+K → kill to end of line
454
+ if (key.ctrl && key.name === "k") {
455
+ this._inputBuffer = this._inputBuffer.slice(0, this._cursorPos);
456
+ this._drawInputLine();
457
+ return;
458
+ }
459
+
460
+ // Ctrl+W → delete word backward
461
+ if (key.ctrl && key.name === "w") {
462
+ const before = this._inputBuffer.slice(0, this._cursorPos);
463
+ const after = this._inputBuffer.slice(this._cursorPos);
464
+ const trimmed = before.replace(/\S+\s*$/, "");
465
+ this._cursorPos = trimmed.length;
466
+ this._inputBuffer = trimmed + after;
467
+ this._drawInputLine();
468
+ return;
469
+ }
470
+
471
+ // Regular printable character
472
+ if (str && !key.ctrl && !key.meta) {
473
+ this._insertChar(str);
474
+ }
475
+ }
476
+
477
+ _insertChar(ch) {
478
+ this._inputBuffer =
479
+ this._inputBuffer.slice(0, this._cursorPos) +
480
+ ch +
481
+ this._inputBuffer.slice(this._cursorPos);
482
+ this._cursorPos += ch.length;
483
+ this._drawInputLine();
484
+ }
485
+
486
+ // ─── Wait for input ─────────────────────────────────────────────
487
+
488
+ _waitForLine() {
489
+ return new Promise((resolve) => {
490
+ this._inputResolve = resolve;
491
+ });
492
+ }
493
+
494
+ // ─── Pre-loop prompts (before TUI is active) ───────────────────
495
+
496
+ async promptFeatureDescription() {
497
+ const desc = await input({ message: "Describe the feature:" });
498
+ return desc.trim();
499
+ }
500
+
501
+ async promptConfirm(message) {
502
+ return confirm({ message });
503
+ }
504
+
505
+ async promptContinueToImpl() {
506
+ this.stopInputLoop();
507
+ const choices = [
508
+ { name: "Continue to implementation", value: "continue" },
509
+ { name: "Exit (resume later with `iriai-build implementation`)", value: "exit" },
510
+ ];
511
+ const selected = await select({
512
+ message: "Plan approved! What would you like to do?",
513
+ choices,
514
+ });
515
+ return selected === "continue";
516
+ }
517
+
518
+ // ─── Input loop ─────────────────────────────────────────────────
519
+
520
+ startInputLoop() {
521
+ if (this._inputLoopRunning) return;
522
+ this._inputLoopRunning = true;
523
+
524
+ // Enter TUI mode
525
+ this._enterTUI();
526
+
527
+ // Poll agent status every 250ms
528
+ this._statusTimer = setInterval(() => this._refreshStatus(), 250);
529
+
530
+ this._runInputLoop().catch((err) => {
531
+ if (err?.name === "ExitPromptError") return;
532
+ console.error("[terminal-input] Input loop crashed:", err);
533
+ });
534
+ }
535
+
536
+ stopInputLoop() {
537
+ this._inputLoopRunning = false;
538
+ if (this._statusTimer) {
539
+ clearInterval(this._statusTimer);
540
+ this._statusTimer = null;
541
+ }
542
+ // Resolve any pending input
543
+ if (this._inputResolve) {
544
+ const resolve = this._inputResolve;
545
+ this._inputResolve = null;
546
+ resolve(null);
547
+ }
548
+ // Exit TUI mode (but stay on alternate screen)
549
+ this._exitTUI();
550
+ }
551
+
552
+ async _handleTransferToSlack() {
553
+ try {
554
+ const { getSlackConfig, isSlackConfigured } = await import("./config.js");
555
+ if (!isSlackConfigured()) {
556
+ this.printAboveLine(`${C.yellow}${C.bold}[system]${C.reset} Slack not configured. Run \`iriai-build setup\` first.`);
557
+ return;
558
+ }
559
+
560
+ const slack = getSlackConfig();
561
+ const { SlackAdapter } = await import("../v3/adapters/slack-adapter.js");
562
+ const { Recovery } = await import("../v3/recovery.js");
563
+
564
+ this.printAboveLine(`${C.dim}[system]${C.reset} Connecting to Slack...`);
565
+ const slackAdapter = new SlackAdapter({
566
+ appToken: slack.appToken,
567
+ botToken: slack.botToken,
568
+ planningChannel: slack.channelId,
569
+ });
570
+ await slackAdapter.connect();
571
+ this.printAboveLine(`${C.green}${C.bold}[system]${C.reset} Slack connected (${slackAdapter.botUserId})`);
572
+
573
+ // Exit TUI — switch to plain console output for Slack bridge mode
574
+ this.stopInputLoop();
575
+ this.exitScreen();
576
+
577
+ // Swap adapter on the orchestrator
578
+ this.orchestrator.adapter = slackAdapter;
579
+ slackAdapter.setOrchestrator(this.orchestrator);
580
+
581
+ // Prevent macOS from sleeping while the bridge is running
582
+ const { spawn: cpSpawn } = await import("node:child_process");
583
+ const caffeinate = cpSpawn("caffeinate", ["-dims"], { stdio: "ignore", detached: true });
584
+ caffeinate.unref();
585
+
586
+ console.log("\n\x1b[1m\x1b[36miriai-build\x1b[0m — Transferred to Slack bridge mode\n");
587
+
588
+ // Sync all CLI features to Slack
589
+ const activeFeatures = queries.getActiveFeatures();
590
+ for (const feature of activeFeatures) {
591
+ const ch = feature.feature_channel;
592
+ if (ch && !/^[CGD][A-Z0-9]+$/.test(ch)) {
593
+ try {
594
+ const channelId = await slackAdapter.createFeatureChannel(feature.id, feature.slug);
595
+ if (channelId) {
596
+ queries.updateFeatureChannel(feature.id, channelId);
597
+ await slackAdapter.web.chat.postMessage({
598
+ channel: slack.channelId,
599
+ text: `[FEATURE][CLI SYNC] ${feature.slug} → <#${channelId}>`,
600
+ });
601
+ const phase = feature.phase || "planning";
602
+ const meta = queries.getFeatureMetadata(feature.id);
603
+ const lines = [`*Feature:* \`${feature.slug}\``, `*Phase:* ${phase}`];
604
+ if (meta.awaiting_phase_review) lines.push(`*Status:* Awaiting ${meta.phase_review_role} phase review`);
605
+ lines.push("_Transferred from CLI._");
606
+ await slackAdapter.postMessage(feature.id, lines.join("\n"));
607
+ console.log(` Synced: ${feature.slug} → #impl-${feature.slug}`);
608
+ }
609
+ } catch (err) {
610
+ console.error(` Failed to sync ${feature.slug}: ${err.message}`);
611
+ }
612
+ }
613
+ }
614
+
615
+ // Run recovery to re-post pending decisions via Slack
616
+ console.log("Running recovery...");
617
+ const recovery = new Recovery({ orchestrator: this.orchestrator, adapter: slackAdapter });
618
+ await recovery.run();
619
+
620
+ console.log("\n\x1b[32m\x1b[1mSlack bridge running.\x1b[0m Press Ctrl+C to stop.\n");
621
+
622
+ // Keep process alive — Slack socket handles events, Ctrl+C exits
623
+ const shutdown = async (signal) => {
624
+ console.log(`\n[bridge] ${signal} — shutting down...`);
625
+ caffeinate.kill();
626
+ if (this.orchestrator.reviewSessions) await this.orchestrator.reviewSessions.stopAll();
627
+ await this.orchestrator.shutdown();
628
+ process.exit(0);
629
+ };
630
+ process.on("SIGINT", () => shutdown("SIGINT"));
631
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
632
+
633
+ } catch (err) {
634
+ this.printAboveLine(`${C.yellow}${C.bold}[system]${C.reset} Transfer failed: ${err.message}`);
635
+ }
636
+ }
637
+
638
+ async _runInputLoop() {
639
+ while (this._inputLoopRunning) {
640
+ if (!this._activeFeatureId) {
641
+ await new Promise((r) => setTimeout(r, 300));
642
+ continue;
643
+ }
644
+
645
+ const text = await this._waitForLine();
646
+ if (text === null) break; // Ctrl+D
647
+
648
+ if (text.trim()) {
649
+ const trimmed = text.trim();
650
+
651
+ // Inline commands
652
+ if (trimmed === "/transfer-to-slack") {
653
+ this.printAboveLine(`${C.cyan}${C.bold}[system]${C.reset} Transferring to Slack...`);
654
+ await this._handleTransferToSlack();
655
+ continue;
656
+ }
657
+
658
+ // Echo user input in the display area
659
+ this.printAboveLine(
660
+ `${C.green}${C.bold}[you]${C.reset} ${trimmed}`
661
+ );
662
+ this.orchestrator.routeUserMessage(this._activeFeatureId, trimmed);
663
+ }
664
+ }
665
+ }
666
+ }