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,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
|
+
}
|