substrate-ai 0.1.32 → 0.2.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/README.md +33 -63
- package/dist/cli/index.js +4586 -18398
- package/dist/cli/templates/claude-md-substrate-section.md +3 -3
- package/dist/decisions-BBLMsN_c.js +336 -0
- package/dist/decisions-WIsicZiG.js +3 -0
- package/dist/errors-BPqtzQ4U.js +111 -0
- package/dist/event-bus-J-bw-pkp.js +1549 -0
- package/dist/experimenter-BSu2ie3J.js +484 -0
- package/dist/git-utils-BtI5eNoN.js +365 -0
- package/dist/index.d.ts +89 -137
- package/dist/index.js +5 -305
- package/dist/logger-C6n1g8uP.js +119 -0
- package/dist/metrics-BSg8VIHd.js +184 -0
- package/dist/run-CRmhkcwN.js +7 -0
- package/dist/run-DlOWhkIF.js +10725 -0
- package/dist/{upgrade-j7tWzbZ0.js → upgrade-BjYVeC6G.js} +2 -3
- package/dist/{upgrade-4j5rZskl.js → upgrade-rV26kdh3.js} +2 -2
- package/dist/version-manager-impl-9N_519Ey.js +3 -0
- package/dist/{version-manager-impl-mBbvaQL2.js → version-manager-impl-BpVx2DkY.js} +108 -9
- package/package.json +1 -1
- package/packs/bmad/prompts/ux-step-3-journeys.md +3 -3
- package/dist/app-CY3MaJtP.js +0 -6342
- package/dist/cli/templates/parallel.yaml +0 -72
- package/dist/cli/templates/research-then-implement.yaml +0 -103
- package/dist/cli/templates/review-cycle.yaml +0 -91
- package/dist/cli/templates/sequential.yaml +0 -68
- package/dist/config-schema-C9tTMcm1.js +0 -102
- package/dist/version-manager-impl-CJLdocS1.js +0 -4
|
@@ -0,0 +1,1549 @@
|
|
|
1
|
+
import { exec } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import * as readline from "node:readline";
|
|
4
|
+
import { EventEmitter } from "node:events";
|
|
5
|
+
|
|
6
|
+
//#region src/adapters/claude-adapter.ts
|
|
7
|
+
const execAsync$2 = promisify(exec);
|
|
8
|
+
/** Default model used when none is specified */
|
|
9
|
+
const DEFAULT_MODEL$1 = "claude-sonnet-4-6";
|
|
10
|
+
/** Approximate characters per token for estimation */
|
|
11
|
+
const CHARS_PER_TOKEN$2 = 3;
|
|
12
|
+
/** Estimated output token multiplier relative to input */
|
|
13
|
+
const OUTPUT_RATIO$2 = .5;
|
|
14
|
+
/** Strip markdown code fences from LLM output (e.g. ```json ... ```) */
|
|
15
|
+
function stripCodeFences$2(raw) {
|
|
16
|
+
return raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Adapter for the Claude Code CLI agent.
|
|
20
|
+
*
|
|
21
|
+
* Capabilities: JSON output, streaming, both billing modes, plan generation.
|
|
22
|
+
* Health check: runs `claude --version` to verify install.
|
|
23
|
+
* Billing detection: detects subscription vs API via version output or env.
|
|
24
|
+
*/
|
|
25
|
+
var ClaudeCodeAdapter = class {
|
|
26
|
+
id = "claude-code";
|
|
27
|
+
displayName = "Claude Code";
|
|
28
|
+
adapterVersion = "1.0.0";
|
|
29
|
+
/**
|
|
30
|
+
* Verify the `claude` binary is installed and responsive.
|
|
31
|
+
* Detects subscription vs API billing mode.
|
|
32
|
+
*/
|
|
33
|
+
async healthCheck() {
|
|
34
|
+
try {
|
|
35
|
+
const { stdout } = await execAsync$2("claude --version", { timeout: 1e4 });
|
|
36
|
+
const output = stdout.trim();
|
|
37
|
+
const detectedBillingModes = this._detectBillingModes(output);
|
|
38
|
+
let cliPath;
|
|
39
|
+
try {
|
|
40
|
+
const whichResult = await execAsync$2("which claude", { timeout: 5e3 });
|
|
41
|
+
cliPath = whichResult.stdout.trim();
|
|
42
|
+
} catch {}
|
|
43
|
+
return {
|
|
44
|
+
healthy: true,
|
|
45
|
+
version: output,
|
|
46
|
+
...cliPath !== void 0 ? { cliPath } : {},
|
|
47
|
+
detectedBillingModes,
|
|
48
|
+
supportsHeadless: true
|
|
49
|
+
};
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
52
|
+
return {
|
|
53
|
+
healthy: false,
|
|
54
|
+
error: `Claude CLI not available: ${message}`,
|
|
55
|
+
supportsHeadless: false
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Build spawn command for a coding task.
|
|
61
|
+
* Uses: `claude -p <prompt> --model <model> --dangerously-skip-permissions --system-prompt <minimal>`
|
|
62
|
+
*/
|
|
63
|
+
buildCommand(prompt, options) {
|
|
64
|
+
const model = options.model ?? DEFAULT_MODEL$1;
|
|
65
|
+
const systemPrompt = "You are an autonomous coding agent executing a single pipeline task. Ignore all session startup context, memory notes, and \"Next Up\" indicators. Follow the instructions in the user message exactly. Emit ONLY the YAML output specified in the Output Contract — no other text.";
|
|
66
|
+
const args = [
|
|
67
|
+
"-p",
|
|
68
|
+
prompt,
|
|
69
|
+
"--model",
|
|
70
|
+
model,
|
|
71
|
+
"--dangerously-skip-permissions",
|
|
72
|
+
"--system-prompt",
|
|
73
|
+
systemPrompt
|
|
74
|
+
];
|
|
75
|
+
if (options.maxTurns !== void 0) args.push("--max-turns", String(options.maxTurns));
|
|
76
|
+
if (options.additionalFlags && options.additionalFlags.length > 0) args.push(...options.additionalFlags);
|
|
77
|
+
const envEntries = {};
|
|
78
|
+
const unsetKeys = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"];
|
|
79
|
+
if (options.billingMode === "api" && options.apiKey) envEntries.ANTHROPIC_API_KEY = options.apiKey;
|
|
80
|
+
else unsetKeys.push("ANTHROPIC_API_KEY");
|
|
81
|
+
return {
|
|
82
|
+
binary: "claude",
|
|
83
|
+
args,
|
|
84
|
+
env: envEntries,
|
|
85
|
+
unsetEnvKeys: unsetKeys,
|
|
86
|
+
cwd: options.worktreePath
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Build spawn command for plan generation.
|
|
91
|
+
* Appends a structured planning directive to the prompt.
|
|
92
|
+
*/
|
|
93
|
+
buildPlanningCommand(request, options) {
|
|
94
|
+
const model = options.model ?? DEFAULT_MODEL$1;
|
|
95
|
+
const planningPrompt = this._buildPlanningPrompt(request);
|
|
96
|
+
const args = [
|
|
97
|
+
"-p",
|
|
98
|
+
planningPrompt,
|
|
99
|
+
"--model",
|
|
100
|
+
model
|
|
101
|
+
];
|
|
102
|
+
if (options.additionalFlags && options.additionalFlags.length > 0) args.push(...options.additionalFlags);
|
|
103
|
+
const envEntries = {};
|
|
104
|
+
const planUnsetKeys = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"];
|
|
105
|
+
if (options.billingMode === "api" && options.apiKey) envEntries.ANTHROPIC_API_KEY = options.apiKey;
|
|
106
|
+
else planUnsetKeys.push("ANTHROPIC_API_KEY");
|
|
107
|
+
return {
|
|
108
|
+
binary: "claude",
|
|
109
|
+
args,
|
|
110
|
+
env: envEntries,
|
|
111
|
+
unsetEnvKeys: planUnsetKeys,
|
|
112
|
+
cwd: options.worktreePath
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Parse Claude CLI JSON stdout output into TaskResult.
|
|
117
|
+
*/
|
|
118
|
+
parseOutput(stdout, stderr, exitCode) {
|
|
119
|
+
if (exitCode !== 0) return {
|
|
120
|
+
success: false,
|
|
121
|
+
output: stdout,
|
|
122
|
+
error: stderr || `Process exited with code ${String(exitCode)}`,
|
|
123
|
+
exitCode
|
|
124
|
+
};
|
|
125
|
+
if (stdout.trim() === "") return {
|
|
126
|
+
success: true,
|
|
127
|
+
output: "",
|
|
128
|
+
exitCode
|
|
129
|
+
};
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(stdout.trim());
|
|
132
|
+
const success = parsed.status === "completed" || parsed.status === void 0;
|
|
133
|
+
const rawTokens = parsed.metadata?.tokensUsed;
|
|
134
|
+
const tokensUsed = rawTokens !== void 0 ? {
|
|
135
|
+
input: rawTokens.input ?? 0,
|
|
136
|
+
output: rawTokens.output ?? 0,
|
|
137
|
+
total: (rawTokens.input ?? 0) + (rawTokens.output ?? 0)
|
|
138
|
+
} : void 0;
|
|
139
|
+
const executionTime = parsed.metadata?.executionTime;
|
|
140
|
+
return {
|
|
141
|
+
success: success && !parsed.error,
|
|
142
|
+
output: parsed.output ?? stdout,
|
|
143
|
+
...parsed.error ? { error: parsed.error } : {},
|
|
144
|
+
exitCode,
|
|
145
|
+
metadata: {
|
|
146
|
+
...executionTime !== void 0 ? { executionTime } : {},
|
|
147
|
+
...tokensUsed !== void 0 ? { tokensUsed } : {}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
} catch {
|
|
151
|
+
return {
|
|
152
|
+
success: true,
|
|
153
|
+
output: stdout,
|
|
154
|
+
exitCode
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Parse plan generation output from Claude.
|
|
160
|
+
*/
|
|
161
|
+
parsePlanOutput(stdout, stderr, exitCode) {
|
|
162
|
+
if (exitCode !== 0) return {
|
|
163
|
+
success: false,
|
|
164
|
+
tasks: [],
|
|
165
|
+
error: stderr || `Process exited with code ${String(exitCode)}`,
|
|
166
|
+
rawOutput: stdout
|
|
167
|
+
};
|
|
168
|
+
if (stdout.trim() === "") return {
|
|
169
|
+
success: false,
|
|
170
|
+
tasks: [],
|
|
171
|
+
error: "Empty output from plan generation",
|
|
172
|
+
rawOutput: stdout
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(stripCodeFences$2(stdout));
|
|
176
|
+
if (!Array.isArray(parsed.tasks)) return {
|
|
177
|
+
success: false,
|
|
178
|
+
tasks: [],
|
|
179
|
+
error: "Plan output missing tasks array",
|
|
180
|
+
rawOutput: stdout
|
|
181
|
+
};
|
|
182
|
+
const tasks = parsed.tasks.map((t) => ({
|
|
183
|
+
title: t.title ?? "Untitled task",
|
|
184
|
+
description: t.description ?? "",
|
|
185
|
+
...t.complexity !== void 0 ? { complexity: t.complexity } : {},
|
|
186
|
+
...t.dependencies !== void 0 ? { dependencies: t.dependencies } : {}
|
|
187
|
+
}));
|
|
188
|
+
return {
|
|
189
|
+
success: true,
|
|
190
|
+
tasks,
|
|
191
|
+
rawOutput: stdout
|
|
192
|
+
};
|
|
193
|
+
} catch {
|
|
194
|
+
return {
|
|
195
|
+
success: false,
|
|
196
|
+
tasks: [],
|
|
197
|
+
error: "Failed to parse plan output as JSON",
|
|
198
|
+
rawOutput: stdout
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Estimate token count using character-based heuristic.
|
|
204
|
+
* Approximation: 1 token ≈ 3 characters for English text.
|
|
205
|
+
*/
|
|
206
|
+
estimateTokens(prompt) {
|
|
207
|
+
const input = Math.ceil(prompt.length / CHARS_PER_TOKEN$2);
|
|
208
|
+
const output = Math.ceil(input * OUTPUT_RATIO$2);
|
|
209
|
+
return {
|
|
210
|
+
input,
|
|
211
|
+
output,
|
|
212
|
+
total: input + output
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Return Claude Code's capabilities.
|
|
217
|
+
*/
|
|
218
|
+
getCapabilities() {
|
|
219
|
+
return {
|
|
220
|
+
supportsJsonOutput: true,
|
|
221
|
+
supportsStreaming: true,
|
|
222
|
+
supportsSubscriptionBilling: true,
|
|
223
|
+
supportsApiBilling: true,
|
|
224
|
+
supportsPlanGeneration: true,
|
|
225
|
+
maxContextTokens: 2e5,
|
|
226
|
+
supportedTaskTypes: [
|
|
227
|
+
"code",
|
|
228
|
+
"refactor",
|
|
229
|
+
"test",
|
|
230
|
+
"review",
|
|
231
|
+
"debug",
|
|
232
|
+
"document",
|
|
233
|
+
"analyze"
|
|
234
|
+
],
|
|
235
|
+
supportedLanguages: ["*"]
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
_detectBillingModes(_versionOutput) {
|
|
239
|
+
const explicit = process.env.ADT_BILLING_MODE;
|
|
240
|
+
if (explicit === "subscription" || explicit === "api" || explicit === "free") return [explicit];
|
|
241
|
+
const modes = ["subscription"];
|
|
242
|
+
if (process.env.ANTHROPIC_API_KEY) modes.push("api");
|
|
243
|
+
return modes;
|
|
244
|
+
}
|
|
245
|
+
_buildPlanningPrompt(request) {
|
|
246
|
+
const maxTasks = request.maxTasks ?? 10;
|
|
247
|
+
const contextSection = request.context ? `\n\nAdditional context:\n${request.context}` : "";
|
|
248
|
+
return `Generate a detailed task plan for the following goal:\n${request.goal}${contextSection}\n\nOutput a JSON object with a "tasks" array. Each task should have: "title" (string), "description" (string), "complexity" (1-10 integer), "dependencies" (array of task titles this depends on). Produce at most ${String(maxTasks)} tasks. Output ONLY raw valid JSON — no markdown, no code fences, no explanation. Start your response with { and end with }.`;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/adapters/codex-adapter.ts
|
|
254
|
+
const execAsync$1 = promisify(exec);
|
|
255
|
+
/** Approximate characters per token for estimation */
|
|
256
|
+
const CHARS_PER_TOKEN$1 = 3;
|
|
257
|
+
/** Estimated output token multiplier relative to input */
|
|
258
|
+
const OUTPUT_RATIO$1 = .5;
|
|
259
|
+
/** Strip markdown code fences from LLM output (e.g. ```json ... ```) */
|
|
260
|
+
function stripCodeFences$1(raw) {
|
|
261
|
+
return raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
|
|
262
|
+
}
|
|
263
|
+
/** Codex default billing modes — subscription via `codex login`, or API key */
|
|
264
|
+
const CODEX_BILLING_MODES = ["subscription", "api"];
|
|
265
|
+
/**
|
|
266
|
+
* Adapter for the OpenAI Codex CLI agent.
|
|
267
|
+
*
|
|
268
|
+
* Codex CLI uses stdin for the prompt and outputs JSON when --json flag is used.
|
|
269
|
+
* Codex supports subscription billing (via `codex login`) and API key billing.
|
|
270
|
+
*/
|
|
271
|
+
var CodexCLIAdapter = class {
|
|
272
|
+
id = "codex";
|
|
273
|
+
displayName = "Codex CLI";
|
|
274
|
+
adapterVersion = "1.0.0";
|
|
275
|
+
/**
|
|
276
|
+
* Verify the `codex` binary is installed and responsive.
|
|
277
|
+
*/
|
|
278
|
+
async healthCheck() {
|
|
279
|
+
try {
|
|
280
|
+
const { stdout } = await execAsync$1("codex --version", { timeout: 1e4 });
|
|
281
|
+
const output = stdout.trim();
|
|
282
|
+
let cliPath;
|
|
283
|
+
try {
|
|
284
|
+
const whichResult = await execAsync$1("which codex", { timeout: 5e3 });
|
|
285
|
+
cliPath = whichResult.stdout.trim();
|
|
286
|
+
} catch {}
|
|
287
|
+
return {
|
|
288
|
+
healthy: true,
|
|
289
|
+
version: output,
|
|
290
|
+
...cliPath !== void 0 ? { cliPath } : {},
|
|
291
|
+
detectedBillingModes: CODEX_BILLING_MODES,
|
|
292
|
+
supportsHeadless: true
|
|
293
|
+
};
|
|
294
|
+
} catch (err) {
|
|
295
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
296
|
+
return {
|
|
297
|
+
healthy: false,
|
|
298
|
+
error: `Codex CLI not available: ${message}`,
|
|
299
|
+
supportsHeadless: false
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Build spawn command for a coding task.
|
|
305
|
+
* Uses: `codex exec --json` with prompt delivered via stdin.
|
|
306
|
+
*/
|
|
307
|
+
buildCommand(prompt, options) {
|
|
308
|
+
const args = ["exec", "--json"];
|
|
309
|
+
if (options.additionalFlags && options.additionalFlags.length > 0) args.push(...options.additionalFlags);
|
|
310
|
+
const envEntries = {};
|
|
311
|
+
if (options.apiKey) envEntries.OPENAI_API_KEY = options.apiKey;
|
|
312
|
+
const hasEnv = Object.keys(envEntries).length > 0;
|
|
313
|
+
return {
|
|
314
|
+
binary: "codex",
|
|
315
|
+
args,
|
|
316
|
+
...hasEnv ? { env: envEntries } : {},
|
|
317
|
+
cwd: options.worktreePath,
|
|
318
|
+
stdin: prompt
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Build spawn command for plan generation.
|
|
323
|
+
* Uses codex exec with a JSON plan generation prompt via stdin.
|
|
324
|
+
*/
|
|
325
|
+
buildPlanningCommand(request, options) {
|
|
326
|
+
const planningPrompt = this._buildPlanningPrompt(request);
|
|
327
|
+
const args = [
|
|
328
|
+
"exec",
|
|
329
|
+
planningPrompt,
|
|
330
|
+
"--sandbox",
|
|
331
|
+
"read-only"
|
|
332
|
+
];
|
|
333
|
+
if (options.additionalFlags && options.additionalFlags.length > 0) args.push(...options.additionalFlags);
|
|
334
|
+
const envEntries = {};
|
|
335
|
+
if (options.apiKey) envEntries.OPENAI_API_KEY = options.apiKey;
|
|
336
|
+
const hasEnv = Object.keys(envEntries).length > 0;
|
|
337
|
+
return {
|
|
338
|
+
binary: "codex",
|
|
339
|
+
args,
|
|
340
|
+
...hasEnv ? { env: envEntries } : {},
|
|
341
|
+
cwd: options.worktreePath
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Parse Codex CLI JSON output into a TaskResult.
|
|
346
|
+
*/
|
|
347
|
+
parseOutput(stdout, stderr, exitCode) {
|
|
348
|
+
if (exitCode !== 0) return {
|
|
349
|
+
success: false,
|
|
350
|
+
output: stdout,
|
|
351
|
+
error: stderr || `Process exited with code ${String(exitCode)}`,
|
|
352
|
+
exitCode
|
|
353
|
+
};
|
|
354
|
+
if (stdout.trim() === "") return {
|
|
355
|
+
success: true,
|
|
356
|
+
output: "",
|
|
357
|
+
exitCode
|
|
358
|
+
};
|
|
359
|
+
try {
|
|
360
|
+
const parsed = JSON.parse(stdout.trim());
|
|
361
|
+
const success = parsed.status === "success" || parsed.status === "completed" || parsed.status === void 0 && !parsed.error;
|
|
362
|
+
const inputTokens = parsed.tokens?.input ?? 0;
|
|
363
|
+
const outputTokens = parsed.tokens?.output ?? 0;
|
|
364
|
+
const totalTokens = parsed.tokens?.total ?? inputTokens + outputTokens;
|
|
365
|
+
const hasTokens = inputTokens > 0 || outputTokens > 0;
|
|
366
|
+
const tokensUsed = hasTokens ? {
|
|
367
|
+
input: inputTokens,
|
|
368
|
+
output: outputTokens,
|
|
369
|
+
total: totalTokens
|
|
370
|
+
} : void 0;
|
|
371
|
+
const executionTime = parsed.executionTime;
|
|
372
|
+
return {
|
|
373
|
+
success: success && !parsed.error,
|
|
374
|
+
output: parsed.output ?? parsed.result ?? stdout,
|
|
375
|
+
...parsed.error ? { error: parsed.error } : {},
|
|
376
|
+
exitCode,
|
|
377
|
+
metadata: {
|
|
378
|
+
...executionTime !== void 0 ? { executionTime } : {},
|
|
379
|
+
...tokensUsed !== void 0 ? { tokensUsed } : {}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
} catch {
|
|
383
|
+
return {
|
|
384
|
+
success: true,
|
|
385
|
+
output: stdout,
|
|
386
|
+
exitCode
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Parse Codex plan generation output.
|
|
392
|
+
*/
|
|
393
|
+
parsePlanOutput(stdout, stderr, exitCode) {
|
|
394
|
+
if (exitCode !== 0) return {
|
|
395
|
+
success: false,
|
|
396
|
+
tasks: [],
|
|
397
|
+
error: stderr || `Process exited with code ${String(exitCode)}`,
|
|
398
|
+
rawOutput: stdout
|
|
399
|
+
};
|
|
400
|
+
if (stdout.trim() === "") return {
|
|
401
|
+
success: false,
|
|
402
|
+
tasks: [],
|
|
403
|
+
error: "Empty output from plan generation",
|
|
404
|
+
rawOutput: stdout
|
|
405
|
+
};
|
|
406
|
+
try {
|
|
407
|
+
const parsed = JSON.parse(stripCodeFences$1(stdout));
|
|
408
|
+
if (parsed.tasks === void 0 && parsed.plan === void 0) return {
|
|
409
|
+
success: false,
|
|
410
|
+
tasks: [],
|
|
411
|
+
error: "Plan output missing tasks array",
|
|
412
|
+
rawOutput: stdout
|
|
413
|
+
};
|
|
414
|
+
const rawTasks = parsed.tasks ?? parsed.plan ?? [];
|
|
415
|
+
if (!Array.isArray(rawTasks)) return {
|
|
416
|
+
success: false,
|
|
417
|
+
tasks: [],
|
|
418
|
+
error: "Plan output missing tasks array",
|
|
419
|
+
rawOutput: stdout
|
|
420
|
+
};
|
|
421
|
+
const tasks = rawTasks.map((t) => {
|
|
422
|
+
const deps = "dependencies" in t ? t.dependencies : t.deps;
|
|
423
|
+
return {
|
|
424
|
+
title: t.title ?? "Untitled task",
|
|
425
|
+
description: t.description ?? "",
|
|
426
|
+
...t.complexity !== void 0 ? { complexity: t.complexity } : {},
|
|
427
|
+
...deps !== void 0 ? { dependencies: deps } : {}
|
|
428
|
+
};
|
|
429
|
+
});
|
|
430
|
+
return {
|
|
431
|
+
success: true,
|
|
432
|
+
tasks,
|
|
433
|
+
rawOutput: stdout
|
|
434
|
+
};
|
|
435
|
+
} catch {
|
|
436
|
+
return {
|
|
437
|
+
success: false,
|
|
438
|
+
tasks: [],
|
|
439
|
+
error: "Failed to parse Codex plan output as JSON",
|
|
440
|
+
rawOutput: stdout
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Estimate token count using character-based heuristic.
|
|
446
|
+
*/
|
|
447
|
+
estimateTokens(prompt) {
|
|
448
|
+
const input = Math.ceil(prompt.length / CHARS_PER_TOKEN$1);
|
|
449
|
+
const output = Math.ceil(input * OUTPUT_RATIO$1);
|
|
450
|
+
return {
|
|
451
|
+
input,
|
|
452
|
+
output,
|
|
453
|
+
total: input + output
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Return Codex CLI capabilities.
|
|
458
|
+
*/
|
|
459
|
+
getCapabilities() {
|
|
460
|
+
return {
|
|
461
|
+
supportsJsonOutput: true,
|
|
462
|
+
supportsStreaming: false,
|
|
463
|
+
supportsSubscriptionBilling: true,
|
|
464
|
+
supportsApiBilling: true,
|
|
465
|
+
supportsPlanGeneration: true,
|
|
466
|
+
maxContextTokens: 128e3,
|
|
467
|
+
supportedTaskTypes: [
|
|
468
|
+
"code",
|
|
469
|
+
"refactor",
|
|
470
|
+
"test",
|
|
471
|
+
"debug",
|
|
472
|
+
"analyze"
|
|
473
|
+
],
|
|
474
|
+
supportedLanguages: ["*"]
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
_buildPlanningPrompt(request) {
|
|
478
|
+
const maxTasks = request.maxTasks ?? 10;
|
|
479
|
+
const contextSection = request.context ? `\n\nAdditional context:\n${request.context}` : "";
|
|
480
|
+
return `Generate a detailed task plan for the following goal:\n${request.goal}${contextSection}\n\nOutput a JSON object with a "tasks" array. Each task should have: "title" (string), "description" (string), "complexity" (1-10 integer), "dependencies" (array of task titles this depends on). Produce at most ${String(maxTasks)} tasks. Output ONLY raw valid JSON — no markdown, no code fences, no explanation. Start your response with { and end with }.`;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/adapters/gemini-adapter.ts
|
|
486
|
+
const execAsync = promisify(exec);
|
|
487
|
+
/** Default model used when none is specified */
|
|
488
|
+
const DEFAULT_MODEL = "gemini-2.0-flash";
|
|
489
|
+
/** Approximate characters per token for estimation */
|
|
490
|
+
const CHARS_PER_TOKEN = 3;
|
|
491
|
+
/** Estimated output token multiplier relative to input */
|
|
492
|
+
const OUTPUT_RATIO = .5;
|
|
493
|
+
/**
|
|
494
|
+
* Strip markdown code fences from LLM output.
|
|
495
|
+
* LLMs often wrap JSON in ```json ... ``` despite being told not to.
|
|
496
|
+
*/
|
|
497
|
+
function stripCodeFences(raw) {
|
|
498
|
+
const stripped = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "");
|
|
499
|
+
return stripped.trim();
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Adapter for the Google Gemini CLI agent.
|
|
503
|
+
*
|
|
504
|
+
* Gemini CLI follows similar patterns to Claude Code: prompt via `-p` flag,
|
|
505
|
+
* JSON output via `--output-format json`, and model via `--model`.
|
|
506
|
+
*/
|
|
507
|
+
var GeminiCLIAdapter = class {
|
|
508
|
+
id = "gemini";
|
|
509
|
+
displayName = "Gemini CLI";
|
|
510
|
+
adapterVersion = "1.0.0";
|
|
511
|
+
/**
|
|
512
|
+
* Verify the `gemini` binary is installed and responsive.
|
|
513
|
+
* Detects subscription vs API billing mode.
|
|
514
|
+
*/
|
|
515
|
+
async healthCheck() {
|
|
516
|
+
try {
|
|
517
|
+
const { stdout } = await execAsync("gemini --version", { timeout: 1e4 });
|
|
518
|
+
const output = stdout.trim();
|
|
519
|
+
const detectedBillingModes = this._detectBillingModes(output);
|
|
520
|
+
let cliPath;
|
|
521
|
+
try {
|
|
522
|
+
const whichResult = await execAsync("which gemini", { timeout: 5e3 });
|
|
523
|
+
cliPath = whichResult.stdout.trim();
|
|
524
|
+
} catch {}
|
|
525
|
+
return {
|
|
526
|
+
healthy: true,
|
|
527
|
+
version: output,
|
|
528
|
+
...cliPath !== void 0 ? { cliPath } : {},
|
|
529
|
+
detectedBillingModes,
|
|
530
|
+
supportsHeadless: true
|
|
531
|
+
};
|
|
532
|
+
} catch (err) {
|
|
533
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
534
|
+
return {
|
|
535
|
+
healthy: false,
|
|
536
|
+
error: `Gemini CLI not available: ${message}`,
|
|
537
|
+
supportsHeadless: false
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Build spawn command for a coding task.
|
|
543
|
+
* Uses: `gemini -p <prompt> --output-format json --model <model>`
|
|
544
|
+
*/
|
|
545
|
+
buildCommand(prompt, options) {
|
|
546
|
+
const model = options.model ?? DEFAULT_MODEL;
|
|
547
|
+
const args = [
|
|
548
|
+
"-p",
|
|
549
|
+
prompt,
|
|
550
|
+
"--output-format",
|
|
551
|
+
"json",
|
|
552
|
+
"--model",
|
|
553
|
+
model
|
|
554
|
+
];
|
|
555
|
+
if (options.additionalFlags && options.additionalFlags.length > 0) args.push(...options.additionalFlags);
|
|
556
|
+
const envEntries = {};
|
|
557
|
+
if (options.billingMode === "api" && options.apiKey) envEntries.GEMINI_API_KEY = options.apiKey;
|
|
558
|
+
const hasEnv = Object.keys(envEntries).length > 0;
|
|
559
|
+
return {
|
|
560
|
+
binary: "gemini",
|
|
561
|
+
args,
|
|
562
|
+
...hasEnv ? { env: envEntries } : {},
|
|
563
|
+
cwd: options.worktreePath
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Build spawn command for plan generation.
|
|
568
|
+
*/
|
|
569
|
+
buildPlanningCommand(request, options) {
|
|
570
|
+
const model = options.model ?? DEFAULT_MODEL;
|
|
571
|
+
const planningPrompt = this._buildPlanningPrompt(request);
|
|
572
|
+
const args = [
|
|
573
|
+
planningPrompt,
|
|
574
|
+
"--model",
|
|
575
|
+
model
|
|
576
|
+
];
|
|
577
|
+
if (options.additionalFlags && options.additionalFlags.length > 0) args.push(...options.additionalFlags);
|
|
578
|
+
const envEntries = {};
|
|
579
|
+
if (options.billingMode === "api" && options.apiKey) envEntries.GEMINI_API_KEY = options.apiKey;
|
|
580
|
+
const hasEnv = Object.keys(envEntries).length > 0;
|
|
581
|
+
return {
|
|
582
|
+
binary: "gemini",
|
|
583
|
+
args,
|
|
584
|
+
...hasEnv ? { env: envEntries } : {},
|
|
585
|
+
cwd: options.worktreePath
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Parse Gemini CLI JSON output into a TaskResult.
|
|
590
|
+
*/
|
|
591
|
+
parseOutput(stdout, stderr, exitCode) {
|
|
592
|
+
if (exitCode !== 0) return {
|
|
593
|
+
success: false,
|
|
594
|
+
output: stdout,
|
|
595
|
+
error: stderr || `Process exited with code ${String(exitCode)}`,
|
|
596
|
+
exitCode
|
|
597
|
+
};
|
|
598
|
+
if (stdout.trim() === "") return {
|
|
599
|
+
success: true,
|
|
600
|
+
output: "",
|
|
601
|
+
exitCode
|
|
602
|
+
};
|
|
603
|
+
try {
|
|
604
|
+
const parsed = JSON.parse(stdout.trim());
|
|
605
|
+
const success = parsed.status === "completed" || parsed.status === void 0;
|
|
606
|
+
const usageMeta = parsed.metadata?.usageMetadata;
|
|
607
|
+
const inputTokens = parsed.metadata?.tokensUsed?.input ?? usageMeta?.promptTokenCount ?? 0;
|
|
608
|
+
const outputTokens = parsed.metadata?.tokensUsed?.output ?? usageMeta?.candidatesTokenCount ?? 0;
|
|
609
|
+
const hasTokens = inputTokens > 0 || outputTokens > 0;
|
|
610
|
+
const tokensUsed = hasTokens ? {
|
|
611
|
+
input: inputTokens,
|
|
612
|
+
output: outputTokens,
|
|
613
|
+
total: inputTokens + outputTokens
|
|
614
|
+
} : void 0;
|
|
615
|
+
const executionTime = parsed.metadata?.executionTime;
|
|
616
|
+
return {
|
|
617
|
+
success: success && !parsed.error,
|
|
618
|
+
output: parsed.output ?? parsed.response ?? stdout,
|
|
619
|
+
...parsed.error ? { error: parsed.error } : {},
|
|
620
|
+
exitCode,
|
|
621
|
+
metadata: {
|
|
622
|
+
...executionTime !== void 0 ? { executionTime } : {},
|
|
623
|
+
...tokensUsed !== void 0 ? { tokensUsed } : {}
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
} catch {
|
|
627
|
+
return {
|
|
628
|
+
success: true,
|
|
629
|
+
output: stdout,
|
|
630
|
+
exitCode
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Parse Gemini plan generation output.
|
|
636
|
+
*/
|
|
637
|
+
parsePlanOutput(stdout, stderr, exitCode) {
|
|
638
|
+
if (exitCode !== 0) return {
|
|
639
|
+
success: false,
|
|
640
|
+
tasks: [],
|
|
641
|
+
error: stderr || `Process exited with code ${String(exitCode)}`,
|
|
642
|
+
rawOutput: stdout
|
|
643
|
+
};
|
|
644
|
+
if (stdout.trim() === "") return {
|
|
645
|
+
success: false,
|
|
646
|
+
tasks: [],
|
|
647
|
+
error: "Empty output from plan generation",
|
|
648
|
+
rawOutput: stdout
|
|
649
|
+
};
|
|
650
|
+
try {
|
|
651
|
+
const parsed = JSON.parse(stripCodeFences(stdout));
|
|
652
|
+
if (!Array.isArray(parsed.tasks)) return {
|
|
653
|
+
success: false,
|
|
654
|
+
tasks: [],
|
|
655
|
+
error: "Plan output missing tasks array",
|
|
656
|
+
rawOutput: stdout
|
|
657
|
+
};
|
|
658
|
+
const tasks = parsed.tasks.map((t) => ({
|
|
659
|
+
title: t.title ?? "Untitled task",
|
|
660
|
+
description: t.description ?? "",
|
|
661
|
+
...t.complexity !== void 0 ? { complexity: t.complexity } : {},
|
|
662
|
+
...t.dependencies !== void 0 ? { dependencies: t.dependencies } : {}
|
|
663
|
+
}));
|
|
664
|
+
return {
|
|
665
|
+
success: true,
|
|
666
|
+
tasks,
|
|
667
|
+
rawOutput: stdout
|
|
668
|
+
};
|
|
669
|
+
} catch {
|
|
670
|
+
return {
|
|
671
|
+
success: false,
|
|
672
|
+
tasks: [],
|
|
673
|
+
error: "Failed to parse Gemini plan output as JSON",
|
|
674
|
+
rawOutput: stdout
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Estimate token count using character-based heuristic.
|
|
680
|
+
*/
|
|
681
|
+
estimateTokens(prompt) {
|
|
682
|
+
const input = Math.ceil(prompt.length / CHARS_PER_TOKEN);
|
|
683
|
+
const output = Math.ceil(input * OUTPUT_RATIO);
|
|
684
|
+
return {
|
|
685
|
+
input,
|
|
686
|
+
output,
|
|
687
|
+
total: input + output
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Return Gemini CLI capabilities.
|
|
692
|
+
*/
|
|
693
|
+
getCapabilities() {
|
|
694
|
+
return {
|
|
695
|
+
supportsJsonOutput: true,
|
|
696
|
+
supportsStreaming: true,
|
|
697
|
+
supportsSubscriptionBilling: true,
|
|
698
|
+
supportsApiBilling: true,
|
|
699
|
+
supportsPlanGeneration: true,
|
|
700
|
+
maxContextTokens: 1e6,
|
|
701
|
+
supportedTaskTypes: [
|
|
702
|
+
"code",
|
|
703
|
+
"refactor",
|
|
704
|
+
"test",
|
|
705
|
+
"review",
|
|
706
|
+
"debug",
|
|
707
|
+
"document",
|
|
708
|
+
"analyze"
|
|
709
|
+
],
|
|
710
|
+
supportedLanguages: ["*"]
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
_detectBillingModes(versionOutput) {
|
|
714
|
+
const explicit = process.env.ADT_BILLING_MODE;
|
|
715
|
+
if (explicit === "subscription" || explicit === "api" || explicit === "free") return [explicit];
|
|
716
|
+
const modes = [];
|
|
717
|
+
if (process.env.GEMINI_API_KEY) modes.push("api");
|
|
718
|
+
if (versionOutput.toLowerCase().includes("subscription")) modes.push("subscription");
|
|
719
|
+
if (modes.length === 0) return ["subscription", "api"];
|
|
720
|
+
return modes;
|
|
721
|
+
}
|
|
722
|
+
_buildPlanningPrompt(request) {
|
|
723
|
+
const maxTasks = request.maxTasks ?? 10;
|
|
724
|
+
const contextSection = request.context ? `\n\nAdditional context:\n${request.context}` : "";
|
|
725
|
+
return `Generate a detailed task plan for the following goal:\n${request.goal}${contextSection}\n\nOutput a JSON object with a "tasks" array. Each task should have: "title" (string), "description" (string), "complexity" (1-10 integer), "dependencies" (array of task titles this depends on). Produce at most ${String(maxTasks)} tasks. Output ONLY raw valid JSON — no markdown, no code fences, no explanation. Start your response with { and end with }.`;
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
//#endregion
|
|
730
|
+
//#region src/adapters/adapter-registry.ts
|
|
731
|
+
/**
|
|
732
|
+
* AdapterRegistry manages the lifecycle of WorkerAdapter instances.
|
|
733
|
+
*
|
|
734
|
+
* Usage:
|
|
735
|
+
* ```typescript
|
|
736
|
+
* const registry = new AdapterRegistry()
|
|
737
|
+
* const report = await registry.discoverAndRegister()
|
|
738
|
+
* const claude = registry.get('claude-code')
|
|
739
|
+
* ```
|
|
740
|
+
*/
|
|
741
|
+
var AdapterRegistry = class {
|
|
742
|
+
_adapters = new Map();
|
|
743
|
+
/**
|
|
744
|
+
* Register an adapter by its id.
|
|
745
|
+
* Overwrites any existing adapter with the same id.
|
|
746
|
+
*/
|
|
747
|
+
register(adapter) {
|
|
748
|
+
this._adapters.set(adapter.id, adapter);
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Retrieve a registered adapter by id.
|
|
752
|
+
* @returns The adapter, or undefined if not registered
|
|
753
|
+
*/
|
|
754
|
+
get(id) {
|
|
755
|
+
return this._adapters.get(id);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Return all registered adapters as an array.
|
|
759
|
+
*/
|
|
760
|
+
getAll() {
|
|
761
|
+
return Array.from(this._adapters.values());
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Return all registered adapters that support plan generation.
|
|
765
|
+
*/
|
|
766
|
+
getPlanningCapable() {
|
|
767
|
+
return this.getAll().filter((adapter) => adapter.getCapabilities().supportsPlanGeneration);
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Instantiate all built-in adapters, run health checks sequentially,
|
|
771
|
+
* and register those that pass.
|
|
772
|
+
*
|
|
773
|
+
* Failed adapters are included in the report but do NOT prevent startup.
|
|
774
|
+
*
|
|
775
|
+
* @returns Discovery report with per-adapter results
|
|
776
|
+
*/
|
|
777
|
+
async discoverAndRegister() {
|
|
778
|
+
const builtInAdapters = [
|
|
779
|
+
new ClaudeCodeAdapter(),
|
|
780
|
+
new CodexCLIAdapter(),
|
|
781
|
+
new GeminiCLIAdapter()
|
|
782
|
+
];
|
|
783
|
+
const results = [];
|
|
784
|
+
let registeredCount = 0;
|
|
785
|
+
let failedCount = 0;
|
|
786
|
+
for (const adapter of builtInAdapters) {
|
|
787
|
+
let healthResult;
|
|
788
|
+
try {
|
|
789
|
+
healthResult = await adapter.healthCheck();
|
|
790
|
+
} catch (err) {
|
|
791
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
792
|
+
healthResult = {
|
|
793
|
+
healthy: false,
|
|
794
|
+
error: `Unexpected error during health check: ${message}`,
|
|
795
|
+
supportsHeadless: false
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
const registered = healthResult.healthy;
|
|
799
|
+
if (registered) {
|
|
800
|
+
this.register(adapter);
|
|
801
|
+
registeredCount++;
|
|
802
|
+
} else failedCount++;
|
|
803
|
+
results.push({
|
|
804
|
+
adapterId: adapter.id,
|
|
805
|
+
displayName: adapter.displayName,
|
|
806
|
+
healthResult,
|
|
807
|
+
registered
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
registeredCount,
|
|
812
|
+
failedCount,
|
|
813
|
+
results
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
//#endregion
|
|
819
|
+
//#region src/tui/ansi.ts
|
|
820
|
+
/**
|
|
821
|
+
* ANSI escape code helpers for TUI rendering.
|
|
822
|
+
*
|
|
823
|
+
* Provides cursor control, color, and screen manipulation utilities.
|
|
824
|
+
*/
|
|
825
|
+
const ANSI = {
|
|
826
|
+
RESET: "\x1B[0m",
|
|
827
|
+
BOLD: "\x1B[1m",
|
|
828
|
+
DIM: "\x1B[2m",
|
|
829
|
+
BLACK: "\x1B[30m",
|
|
830
|
+
RED: "\x1B[31m",
|
|
831
|
+
GREEN: "\x1B[32m",
|
|
832
|
+
YELLOW: "\x1B[33m",
|
|
833
|
+
BLUE: "\x1B[34m",
|
|
834
|
+
MAGENTA: "\x1B[35m",
|
|
835
|
+
CYAN: "\x1B[36m",
|
|
836
|
+
WHITE: "\x1B[37m",
|
|
837
|
+
BRIGHT_BLACK: "\x1B[90m",
|
|
838
|
+
BRIGHT_RED: "\x1B[91m",
|
|
839
|
+
BRIGHT_GREEN: "\x1B[92m",
|
|
840
|
+
BRIGHT_YELLOW: "\x1B[93m",
|
|
841
|
+
BRIGHT_BLUE: "\x1B[94m",
|
|
842
|
+
BRIGHT_MAGENTA: "\x1B[95m",
|
|
843
|
+
BRIGHT_CYAN: "\x1B[96m",
|
|
844
|
+
BRIGHT_WHITE: "\x1B[97m",
|
|
845
|
+
BG_BLACK: "\x1B[40m",
|
|
846
|
+
BG_WHITE: "\x1B[47m",
|
|
847
|
+
BG_BLUE: "\x1B[44m",
|
|
848
|
+
BG_BRIGHT_BLACK: "\x1B[100m",
|
|
849
|
+
HIDE_CURSOR: "\x1B[?25l",
|
|
850
|
+
SHOW_CURSOR: "\x1B[?25h",
|
|
851
|
+
CLEAR_SCREEN: "\x1B[2J",
|
|
852
|
+
HOME: "\x1B[H",
|
|
853
|
+
CLEAR_LINE: "\x1B[2K",
|
|
854
|
+
ERASE_DOWN: "\x1B[J",
|
|
855
|
+
ALT_SCREEN_ENTER: "\x1B[?1049h",
|
|
856
|
+
ALT_SCREEN_EXIT: "\x1B[?1049l"
|
|
857
|
+
};
|
|
858
|
+
/** Check if color output is supported. */
|
|
859
|
+
function supportsColor(isTTY) {
|
|
860
|
+
if (process.env.NO_COLOR !== void 0) return false;
|
|
861
|
+
return isTTY;
|
|
862
|
+
}
|
|
863
|
+
/** Wrap text with an ANSI color code (only if color is enabled). */
|
|
864
|
+
function colorize(text, code, useColor) {
|
|
865
|
+
if (!useColor) return text;
|
|
866
|
+
return `${code}${text}${ANSI.RESET}`;
|
|
867
|
+
}
|
|
868
|
+
/** Bold text. */
|
|
869
|
+
function bold(text, useColor) {
|
|
870
|
+
if (!useColor) return text;
|
|
871
|
+
return `${ANSI.BOLD}${text}${ANSI.RESET}`;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Get current terminal dimensions.
|
|
875
|
+
* Returns { cols, rows } with fallback defaults if unavailable.
|
|
876
|
+
*/
|
|
877
|
+
function getTerminalSize() {
|
|
878
|
+
const cols = process.stdout.columns ?? 80;
|
|
879
|
+
const rows = process.stdout.rows ?? 24;
|
|
880
|
+
return {
|
|
881
|
+
cols,
|
|
882
|
+
rows
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Truncate a string to fit within maxWidth characters.
|
|
887
|
+
* Adds ellipsis if truncated.
|
|
888
|
+
*/
|
|
889
|
+
function truncate(text, maxWidth) {
|
|
890
|
+
if (maxWidth <= 0) return "";
|
|
891
|
+
if (text.length <= maxWidth) return text;
|
|
892
|
+
if (maxWidth <= 3) return text.slice(0, maxWidth);
|
|
893
|
+
return text.slice(0, maxWidth - 3) + "...";
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Pad or truncate a string to exactly `width` characters.
|
|
897
|
+
*/
|
|
898
|
+
function padOrTruncate(text, width, padChar = " ") {
|
|
899
|
+
if (text.length > width) return truncate(text, width);
|
|
900
|
+
return text.padEnd(width, padChar);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
//#endregion
|
|
904
|
+
//#region src/tui/story-panel.ts
|
|
905
|
+
const COL_KEY_WIDTH = 12;
|
|
906
|
+
const COL_PHASE_WIDTH = 8;
|
|
907
|
+
const COL_STATUS_WIDTH = 30;
|
|
908
|
+
/**
|
|
909
|
+
* Get ANSI color code for a story status.
|
|
910
|
+
*/
|
|
911
|
+
function statusColor(status) {
|
|
912
|
+
switch (status) {
|
|
913
|
+
case "pending": return ANSI.BRIGHT_BLACK;
|
|
914
|
+
case "in_progress": return ANSI.YELLOW;
|
|
915
|
+
case "succeeded": return ANSI.GREEN;
|
|
916
|
+
case "failed":
|
|
917
|
+
case "escalated": return ANSI.RED;
|
|
918
|
+
default: return ANSI.RESET;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Get status indicator symbol for a story status.
|
|
923
|
+
*/
|
|
924
|
+
function statusSymbol(status) {
|
|
925
|
+
switch (status) {
|
|
926
|
+
case "pending": return "○";
|
|
927
|
+
case "in_progress": return "◉";
|
|
928
|
+
case "succeeded": return "✓";
|
|
929
|
+
case "failed":
|
|
930
|
+
case "escalated": return "✗";
|
|
931
|
+
default: return "·";
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Render the story panel header row.
|
|
936
|
+
*/
|
|
937
|
+
function renderStoryPanelHeader(useColor) {
|
|
938
|
+
const key = padOrTruncate("STORY", COL_KEY_WIDTH);
|
|
939
|
+
const phase = padOrTruncate("PHASE", COL_PHASE_WIDTH);
|
|
940
|
+
const status = "STATUS";
|
|
941
|
+
const header = ` ${key} ${phase} ${status}`;
|
|
942
|
+
return bold(colorize(header, ANSI.BRIGHT_WHITE, useColor), useColor);
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Render a single story row.
|
|
946
|
+
*/
|
|
947
|
+
function renderStoryRow(story, isSelected, useColor, width) {
|
|
948
|
+
const symbol = statusSymbol(story.status);
|
|
949
|
+
const color = statusColor(story.status);
|
|
950
|
+
const keyCol = padOrTruncate(story.key, COL_KEY_WIDTH);
|
|
951
|
+
const phaseCol = padOrTruncate(story.phase, COL_PHASE_WIDTH);
|
|
952
|
+
const statusCol = padOrTruncate(story.statusLabel, COL_STATUS_WIDTH);
|
|
953
|
+
const maxWidth = Math.max(width - 2, 10);
|
|
954
|
+
let row = `${symbol} ${keyCol} ${phaseCol} ${statusCol}`;
|
|
955
|
+
row = row.slice(0, maxWidth);
|
|
956
|
+
if (useColor) row = `${color}${row}${ANSI.RESET}`;
|
|
957
|
+
if (isSelected && useColor) row = `${ANSI.BG_BRIGHT_BLACK}${row}${ANSI.RESET}`;
|
|
958
|
+
else if (isSelected) row = `> ${row.slice(2)}`;
|
|
959
|
+
return row;
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Render the complete story panel.
|
|
963
|
+
*
|
|
964
|
+
* Returns an array of lines to be written to the terminal.
|
|
965
|
+
*/
|
|
966
|
+
function renderStoryPanel(options) {
|
|
967
|
+
const { stories, selectedIndex, useColor, width } = options;
|
|
968
|
+
const lines = [];
|
|
969
|
+
lines.push(bold(colorize(" Story Status", ANSI.CYAN, useColor), useColor));
|
|
970
|
+
lines.push(renderStoryPanelHeader(useColor));
|
|
971
|
+
lines.push(" " + "─".repeat(Math.max(width - 4, 20)));
|
|
972
|
+
if (stories.length === 0) lines.push(colorize(" (no stories)", ANSI.BRIGHT_BLACK, useColor));
|
|
973
|
+
else for (let i = 0; i < stories.length; i++) {
|
|
974
|
+
const story = stories[i];
|
|
975
|
+
if (story !== void 0) lines.push(renderStoryRow(story, i === selectedIndex, useColor, width));
|
|
976
|
+
}
|
|
977
|
+
return lines;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
//#endregion
|
|
981
|
+
//#region src/tui/log-panel.ts
|
|
982
|
+
/**
|
|
983
|
+
* Format a log entry timestamp to a short HH:MM:SS format.
|
|
984
|
+
*/
|
|
985
|
+
function formatTimestamp(ts) {
|
|
986
|
+
try {
|
|
987
|
+
const date = new Date(ts);
|
|
988
|
+
const hh = date.getHours().toString().padStart(2, "0");
|
|
989
|
+
const mm = date.getMinutes().toString().padStart(2, "0");
|
|
990
|
+
const ss = date.getSeconds().toString().padStart(2, "0");
|
|
991
|
+
return `${hh}:${mm}:${ss}`;
|
|
992
|
+
} catch {
|
|
993
|
+
return ts.slice(11, 19);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Render a single log entry line.
|
|
998
|
+
*/
|
|
999
|
+
function renderLogEntry(entry, useColor, width) {
|
|
1000
|
+
const ts = formatTimestamp(entry.ts);
|
|
1001
|
+
const prefix = `[${ts}] [${entry.key}] `;
|
|
1002
|
+
const maxMsgWidth = Math.max(width - prefix.length - 2, 10);
|
|
1003
|
+
const msg = truncate(entry.msg, maxMsgWidth);
|
|
1004
|
+
const line = `${prefix}${msg}`;
|
|
1005
|
+
if (useColor) {
|
|
1006
|
+
if (entry.level === "warn") return colorize(line, ANSI.YELLOW, useColor);
|
|
1007
|
+
const coloredPrefix = colorize(`[${ts}] `, ANSI.BRIGHT_BLACK, useColor) + colorize(`[${entry.key}] `, ANSI.CYAN, useColor);
|
|
1008
|
+
return `${coloredPrefix}${msg}`;
|
|
1009
|
+
}
|
|
1010
|
+
return line;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Render the complete log panel.
|
|
1014
|
+
*
|
|
1015
|
+
* Auto-scrolls to show the most recent entries (last `maxLines` entries).
|
|
1016
|
+
* Returns an array of lines to be written to the terminal.
|
|
1017
|
+
*/
|
|
1018
|
+
function renderLogPanel(options) {
|
|
1019
|
+
const { entries, maxLines, useColor, width, filterKey } = options;
|
|
1020
|
+
const lines = [];
|
|
1021
|
+
const title = filterKey !== void 0 ? ` Logs for ${filterKey}` : " Live Logs";
|
|
1022
|
+
lines.push(bold(colorize(title, ANSI.CYAN, useColor), useColor));
|
|
1023
|
+
lines.push(" " + "─".repeat(Math.max(width - 4, 20)));
|
|
1024
|
+
const filtered = filterKey !== void 0 ? entries.filter((e) => e.key === filterKey) : entries;
|
|
1025
|
+
if (filtered.length === 0) {
|
|
1026
|
+
lines.push(colorize(" (no log entries)", ANSI.BRIGHT_BLACK, useColor));
|
|
1027
|
+
return lines;
|
|
1028
|
+
}
|
|
1029
|
+
const visibleEntries = filtered.slice(-maxLines);
|
|
1030
|
+
for (const entry of visibleEntries) lines.push(" " + renderLogEntry(entry, useColor, width - 2));
|
|
1031
|
+
return lines;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
//#endregion
|
|
1035
|
+
//#region src/tui/detail-view.ts
|
|
1036
|
+
/**
|
|
1037
|
+
* Render the full detail view for a story.
|
|
1038
|
+
*
|
|
1039
|
+
* Returns an array of lines to be written to the terminal.
|
|
1040
|
+
*/
|
|
1041
|
+
function renderDetailView(options) {
|
|
1042
|
+
const { story, allLogs, maxLogLines, useColor, width, height } = options;
|
|
1043
|
+
const lines = [];
|
|
1044
|
+
const titleBar = ` Story Detail: ${story.key}`;
|
|
1045
|
+
lines.push(bold(colorize(titleBar, ANSI.BRIGHT_WHITE, useColor), useColor));
|
|
1046
|
+
lines.push(" " + "═".repeat(Math.max(width - 4, 20)));
|
|
1047
|
+
lines.push("");
|
|
1048
|
+
const phaseLabel = padOrTruncate("Phase:", 12);
|
|
1049
|
+
const statusLabel = padOrTruncate("Status:", 12);
|
|
1050
|
+
const cyclesLabel = padOrTruncate("Review Cycles:", 12);
|
|
1051
|
+
lines.push(` ${bold(phaseLabel, useColor)} ${colorize(story.phase, ANSI.CYAN, useColor)}`);
|
|
1052
|
+
lines.push(` ${bold(statusLabel, useColor)} ${colorize(story.statusLabel, ANSI.WHITE, useColor)}`);
|
|
1053
|
+
lines.push(` ${bold(cyclesLabel, useColor)} ${story.reviewCycles}`);
|
|
1054
|
+
if (story.escalationReason !== void 0) lines.push(` ${bold(padOrTruncate("Escalated:", 12), useColor)} ${colorize(story.escalationReason, ANSI.RED, useColor)}`);
|
|
1055
|
+
lines.push("");
|
|
1056
|
+
lines.push(" " + "─".repeat(Math.max(width - 4, 20)));
|
|
1057
|
+
const availableLogLines = Math.max(height - lines.length - 4, 3);
|
|
1058
|
+
const logLines = renderLogPanel({
|
|
1059
|
+
entries: allLogs,
|
|
1060
|
+
maxLines: Math.min(maxLogLines, availableLogLines),
|
|
1061
|
+
useColor,
|
|
1062
|
+
width,
|
|
1063
|
+
filterKey: story.key
|
|
1064
|
+
});
|
|
1065
|
+
lines.push(...logLines);
|
|
1066
|
+
lines.push("");
|
|
1067
|
+
lines.push(colorize(" [Esc] Back to overview", ANSI.BRIGHT_BLACK, useColor));
|
|
1068
|
+
return lines;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
//#endregion
|
|
1072
|
+
//#region src/tui/help-overlay.ts
|
|
1073
|
+
/** All keyboard bindings shown in the help overlay. */
|
|
1074
|
+
const KEY_BINDINGS = [
|
|
1075
|
+
{
|
|
1076
|
+
key: "↑ / ↓",
|
|
1077
|
+
description: "Navigate between stories"
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
key: "Enter",
|
|
1081
|
+
description: "Drill into selected story detail view"
|
|
1082
|
+
},
|
|
1083
|
+
{
|
|
1084
|
+
key: "Esc",
|
|
1085
|
+
description: "Return to overview from detail view"
|
|
1086
|
+
},
|
|
1087
|
+
{
|
|
1088
|
+
key: "q",
|
|
1089
|
+
description: "Quit TUI (pipeline completes in background)"
|
|
1090
|
+
},
|
|
1091
|
+
{
|
|
1092
|
+
key: "?",
|
|
1093
|
+
description: "Show/hide this help overlay"
|
|
1094
|
+
}
|
|
1095
|
+
];
|
|
1096
|
+
/**
|
|
1097
|
+
* Render the help overlay.
|
|
1098
|
+
*
|
|
1099
|
+
* Returns an array of lines to be written to the terminal.
|
|
1100
|
+
*/
|
|
1101
|
+
function renderHelpOverlay(options) {
|
|
1102
|
+
const { useColor, width } = options;
|
|
1103
|
+
const lines = [];
|
|
1104
|
+
const boxWidth = Math.min(56, Math.max(width - 4, 30));
|
|
1105
|
+
const horizontalBorder = "─".repeat(boxWidth - 2);
|
|
1106
|
+
lines.push(colorize(` ┌${horizontalBorder}┐`, ANSI.CYAN, useColor));
|
|
1107
|
+
lines.push(colorize(` │${padOrTruncate(" Keyboard Shortcuts", boxWidth - 2)}│`, ANSI.CYAN, useColor));
|
|
1108
|
+
lines.push(colorize(` ├${horizontalBorder}┤`, ANSI.CYAN, useColor));
|
|
1109
|
+
for (const binding of KEY_BINDINGS) {
|
|
1110
|
+
const keyPart = bold(padOrTruncate(binding.key, 12), useColor);
|
|
1111
|
+
const descPart = padOrTruncate(binding.description, boxWidth - 16);
|
|
1112
|
+
lines.push(useColor ? `${ANSI.CYAN} │${ANSI.RESET} ${keyPart} ${descPart}${ANSI.CYAN}│${ANSI.RESET}` : ` │ ${keyPart} ${descPart}│`);
|
|
1113
|
+
}
|
|
1114
|
+
lines.push(colorize(` └${horizontalBorder}┘`, ANSI.CYAN, useColor));
|
|
1115
|
+
lines.push(colorize(" Press ? to close", ANSI.BRIGHT_BLACK, useColor));
|
|
1116
|
+
return lines;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
//#endregion
|
|
1120
|
+
//#region src/tui/app.ts
|
|
1121
|
+
/** Minimum terminal width for TUI to render properly. */
|
|
1122
|
+
const MIN_COLS = 80;
|
|
1123
|
+
/** Minimum terminal height for TUI to render properly. */
|
|
1124
|
+
const MIN_ROWS = 24;
|
|
1125
|
+
/** Maximum log entries to keep in memory. */
|
|
1126
|
+
const MAX_LOG_ENTRIES = 500;
|
|
1127
|
+
function mapPhaseToLabel(phase) {
|
|
1128
|
+
switch (phase) {
|
|
1129
|
+
case "create-story": return "create";
|
|
1130
|
+
case "dev-story": return "dev";
|
|
1131
|
+
case "code-review": return "review";
|
|
1132
|
+
case "fix": return "fix";
|
|
1133
|
+
default: return "wait";
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
function mapPhaseToStatus(phase, eventStatus) {
|
|
1137
|
+
if (eventStatus === "failed") return "failed";
|
|
1138
|
+
if (eventStatus === "in_progress") return "in_progress";
|
|
1139
|
+
if (eventStatus === "complete") {
|
|
1140
|
+
if (phase === "done") return "succeeded";
|
|
1141
|
+
return "in_progress";
|
|
1142
|
+
}
|
|
1143
|
+
return "pending";
|
|
1144
|
+
}
|
|
1145
|
+
function makeStatusLabel(phase, eventStatus, verdict) {
|
|
1146
|
+
if (eventStatus === "failed") return "failed";
|
|
1147
|
+
if (eventStatus === "in_progress") switch (phase) {
|
|
1148
|
+
case "create": return "creating story...";
|
|
1149
|
+
case "dev": return "implementing...";
|
|
1150
|
+
case "review": return "reviewing...";
|
|
1151
|
+
case "fix": return "fixing issues...";
|
|
1152
|
+
default: return "in progress...";
|
|
1153
|
+
}
|
|
1154
|
+
if (eventStatus === "complete") switch (phase) {
|
|
1155
|
+
case "create": return "story created";
|
|
1156
|
+
case "dev": return "implemented";
|
|
1157
|
+
case "review": return verdict !== void 0 ? `reviewed (${verdict})` : "reviewed";
|
|
1158
|
+
case "fix": return "fixes applied";
|
|
1159
|
+
default: return "complete";
|
|
1160
|
+
}
|
|
1161
|
+
return "queued";
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Factory that creates a TUI application instance.
|
|
1165
|
+
*
|
|
1166
|
+
* @param output - Writable stream for rendering (typically process.stdout)
|
|
1167
|
+
* @param input - Readable stream for keyboard input (typically process.stdin)
|
|
1168
|
+
*/
|
|
1169
|
+
function createTuiApp(output, input) {
|
|
1170
|
+
const isTTY = output.isTTY === true;
|
|
1171
|
+
const useColor = supportsColor(isTTY);
|
|
1172
|
+
const state = {
|
|
1173
|
+
headerLine: "",
|
|
1174
|
+
storyOrder: [],
|
|
1175
|
+
stories: new Map(),
|
|
1176
|
+
logs: [],
|
|
1177
|
+
selectedIndex: 0,
|
|
1178
|
+
view: "overview",
|
|
1179
|
+
pipelineComplete: false
|
|
1180
|
+
};
|
|
1181
|
+
let exitResolve;
|
|
1182
|
+
const exitPromise = new Promise((resolve) => {
|
|
1183
|
+
exitResolve = resolve;
|
|
1184
|
+
});
|
|
1185
|
+
let rl;
|
|
1186
|
+
function write(text) {
|
|
1187
|
+
try {
|
|
1188
|
+
output.write(text);
|
|
1189
|
+
} catch {}
|
|
1190
|
+
}
|
|
1191
|
+
function checkTerminalSize() {
|
|
1192
|
+
const { cols, rows } = getTerminalSize();
|
|
1193
|
+
if (cols < MIN_COLS || rows < MIN_ROWS) {
|
|
1194
|
+
write(ANSI.CLEAR_SCREEN + ANSI.HOME);
|
|
1195
|
+
write(colorize(`Terminal too small: ${cols}x${rows} (minimum ${MIN_COLS}x${MIN_ROWS})\n`, ANSI.YELLOW, useColor));
|
|
1196
|
+
write("Please resize your terminal.\n");
|
|
1197
|
+
return false;
|
|
1198
|
+
}
|
|
1199
|
+
return true;
|
|
1200
|
+
}
|
|
1201
|
+
function render() {
|
|
1202
|
+
const { cols, rows } = getTerminalSize();
|
|
1203
|
+
if (cols < MIN_COLS || rows < MIN_ROWS) {
|
|
1204
|
+
checkTerminalSize();
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
write(ANSI.CLEAR_SCREEN + ANSI.HOME);
|
|
1208
|
+
const lines = [];
|
|
1209
|
+
if (state.view === "help") renderHelpView(lines, cols, rows);
|
|
1210
|
+
else if (state.view === "detail") renderDetailViewLayout(lines, cols, rows);
|
|
1211
|
+
else renderOverviewLayout(lines, cols, rows);
|
|
1212
|
+
for (const line of lines) write(line + "\n");
|
|
1213
|
+
}
|
|
1214
|
+
function renderOverviewLayout(lines, cols, rows) {
|
|
1215
|
+
lines.push(bold(colorize(" substrate run --tui", ANSI.BRIGHT_WHITE, useColor), useColor));
|
|
1216
|
+
if (state.headerLine) lines.push(colorize(` ${state.headerLine}`, ANSI.BRIGHT_BLACK, useColor));
|
|
1217
|
+
lines.push("");
|
|
1218
|
+
const storyPanelHeight = Math.max(Math.floor(rows * .4), 6);
|
|
1219
|
+
const storyPanelLines = renderStoryPanel({
|
|
1220
|
+
stories: Array.from(state.storyOrder).map((k) => state.stories.get(k)).filter((s) => s !== void 0),
|
|
1221
|
+
selectedIndex: state.selectedIndex,
|
|
1222
|
+
useColor,
|
|
1223
|
+
width: cols
|
|
1224
|
+
});
|
|
1225
|
+
lines.push(...storyPanelLines.slice(0, storyPanelHeight));
|
|
1226
|
+
lines.push("");
|
|
1227
|
+
lines.push(" " + "─".repeat(Math.max(cols - 4, 20)));
|
|
1228
|
+
lines.push("");
|
|
1229
|
+
const usedLines = lines.length + 3;
|
|
1230
|
+
const logPanelHeight = Math.max(rows - usedLines, 3);
|
|
1231
|
+
const logLines = renderLogPanel({
|
|
1232
|
+
entries: state.logs,
|
|
1233
|
+
maxLines: logPanelHeight,
|
|
1234
|
+
useColor,
|
|
1235
|
+
width: cols
|
|
1236
|
+
});
|
|
1237
|
+
lines.push(...logLines);
|
|
1238
|
+
lines.push("");
|
|
1239
|
+
const footerParts = [
|
|
1240
|
+
colorize("[↑↓] Navigate", ANSI.BRIGHT_BLACK, useColor),
|
|
1241
|
+
colorize("[Enter] Details", ANSI.BRIGHT_BLACK, useColor),
|
|
1242
|
+
colorize("[q] Quit", ANSI.BRIGHT_BLACK, useColor),
|
|
1243
|
+
colorize("[?] Help", ANSI.BRIGHT_BLACK, useColor)
|
|
1244
|
+
];
|
|
1245
|
+
if (state.pipelineComplete) lines.push(colorize(" Pipeline complete. Press q to exit.", ANSI.GREEN, useColor));
|
|
1246
|
+
lines.push(" " + footerParts.join(" "));
|
|
1247
|
+
}
|
|
1248
|
+
function renderDetailViewLayout(lines, cols, rows) {
|
|
1249
|
+
const selectedKey = state.storyOrder[state.selectedIndex];
|
|
1250
|
+
const story = selectedKey !== void 0 ? state.stories.get(selectedKey) : void 0;
|
|
1251
|
+
if (story === void 0) {
|
|
1252
|
+
lines.push(" No story selected. Press Esc to go back.");
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
const detailLines = renderDetailView({
|
|
1256
|
+
story,
|
|
1257
|
+
allLogs: state.logs,
|
|
1258
|
+
maxLogLines: Math.max(rows - 10, 5),
|
|
1259
|
+
useColor,
|
|
1260
|
+
width: cols,
|
|
1261
|
+
height: rows
|
|
1262
|
+
});
|
|
1263
|
+
lines.push(...detailLines);
|
|
1264
|
+
}
|
|
1265
|
+
function renderHelpView(lines, cols, _rows) {
|
|
1266
|
+
lines.push(bold(colorize(" substrate run --tui", ANSI.BRIGHT_WHITE, useColor), useColor));
|
|
1267
|
+
lines.push("");
|
|
1268
|
+
const helpLines = renderHelpOverlay({
|
|
1269
|
+
useColor,
|
|
1270
|
+
width: cols
|
|
1271
|
+
});
|
|
1272
|
+
lines.push(...helpLines);
|
|
1273
|
+
}
|
|
1274
|
+
function setupKeyboard() {
|
|
1275
|
+
if (input.isTTY === true) try {
|
|
1276
|
+
input.setRawMode(true);
|
|
1277
|
+
} catch {}
|
|
1278
|
+
rl = readline.createInterface({
|
|
1279
|
+
input,
|
|
1280
|
+
terminal: false
|
|
1281
|
+
});
|
|
1282
|
+
readline.emitKeypressEvents(input);
|
|
1283
|
+
const stdin = input;
|
|
1284
|
+
const onKeypress = (chunk, key) => {
|
|
1285
|
+
if (key === void 0) return;
|
|
1286
|
+
handleKeypress(key);
|
|
1287
|
+
};
|
|
1288
|
+
stdin.on("keypress", onKeypress);
|
|
1289
|
+
rl.on("close", () => {
|
|
1290
|
+
exit();
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
function handleKeypress(key) {
|
|
1294
|
+
if (key.ctrl === true && key.name === "c") {
|
|
1295
|
+
exit();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
switch (key.name) {
|
|
1299
|
+
case "q":
|
|
1300
|
+
exit();
|
|
1301
|
+
return;
|
|
1302
|
+
case "up":
|
|
1303
|
+
if (state.view === "overview") {
|
|
1304
|
+
state.selectedIndex = Math.max(0, state.selectedIndex - 1);
|
|
1305
|
+
render();
|
|
1306
|
+
}
|
|
1307
|
+
break;
|
|
1308
|
+
case "down":
|
|
1309
|
+
if (state.view === "overview") {
|
|
1310
|
+
state.selectedIndex = Math.min(Math.max(0, state.storyOrder.length - 1), state.selectedIndex + 1);
|
|
1311
|
+
render();
|
|
1312
|
+
}
|
|
1313
|
+
break;
|
|
1314
|
+
case "return":
|
|
1315
|
+
case "enter":
|
|
1316
|
+
if (state.view === "overview" && state.storyOrder.length > 0) {
|
|
1317
|
+
state.view = "detail";
|
|
1318
|
+
render();
|
|
1319
|
+
}
|
|
1320
|
+
break;
|
|
1321
|
+
case "escape":
|
|
1322
|
+
if (state.view === "detail" || state.view === "help") {
|
|
1323
|
+
state.view = "overview";
|
|
1324
|
+
render();
|
|
1325
|
+
}
|
|
1326
|
+
break;
|
|
1327
|
+
case "?":
|
|
1328
|
+
state.view = state.view === "help" ? "overview" : "help";
|
|
1329
|
+
render();
|
|
1330
|
+
break;
|
|
1331
|
+
default:
|
|
1332
|
+
if (key.sequence === "?") {
|
|
1333
|
+
state.view = state.view === "help" ? "overview" : "help";
|
|
1334
|
+
render();
|
|
1335
|
+
}
|
|
1336
|
+
break;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
function onResize() {
|
|
1340
|
+
render();
|
|
1341
|
+
}
|
|
1342
|
+
function init() {
|
|
1343
|
+
write(ANSI.ALT_SCREEN_ENTER);
|
|
1344
|
+
write(ANSI.HIDE_CURSOR);
|
|
1345
|
+
setupKeyboard();
|
|
1346
|
+
const resizeEmitter = typeof output.on === "function" ? output : process.stdout;
|
|
1347
|
+
resizeEmitter.on("resize", onResize);
|
|
1348
|
+
render();
|
|
1349
|
+
}
|
|
1350
|
+
function exit() {
|
|
1351
|
+
cleanup();
|
|
1352
|
+
if (exitResolve !== void 0) {
|
|
1353
|
+
exitResolve();
|
|
1354
|
+
exitResolve = void 0;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
function cleanup() {
|
|
1358
|
+
write(ANSI.SHOW_CURSOR);
|
|
1359
|
+
write(ANSI.ALT_SCREEN_EXIT);
|
|
1360
|
+
const resizeEmitter = typeof output.on === "function" ? output : process.stdout;
|
|
1361
|
+
resizeEmitter.off("resize", onResize);
|
|
1362
|
+
if (rl !== void 0) {
|
|
1363
|
+
try {
|
|
1364
|
+
rl.close();
|
|
1365
|
+
} catch {}
|
|
1366
|
+
rl = void 0;
|
|
1367
|
+
}
|
|
1368
|
+
if (input.isTTY === true) try {
|
|
1369
|
+
input.setRawMode(false);
|
|
1370
|
+
} catch {}
|
|
1371
|
+
}
|
|
1372
|
+
function handleEvent(event) {
|
|
1373
|
+
switch (event.type) {
|
|
1374
|
+
case "pipeline:start":
|
|
1375
|
+
state.headerLine = `${event.stories.length} stories, concurrency ${event.concurrency}, run ${event.run_id.slice(0, 8)}...`;
|
|
1376
|
+
for (const key of event.stories) {
|
|
1377
|
+
state.storyOrder.push(key);
|
|
1378
|
+
state.stories.set(key, {
|
|
1379
|
+
key,
|
|
1380
|
+
phase: "wait",
|
|
1381
|
+
status: "pending",
|
|
1382
|
+
statusLabel: "queued",
|
|
1383
|
+
reviewCycles: 0
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
break;
|
|
1387
|
+
case "story:phase": {
|
|
1388
|
+
let story = state.stories.get(event.key);
|
|
1389
|
+
if (story === void 0) {
|
|
1390
|
+
state.storyOrder.push(event.key);
|
|
1391
|
+
story = {
|
|
1392
|
+
key: event.key,
|
|
1393
|
+
phase: "wait",
|
|
1394
|
+
status: "pending",
|
|
1395
|
+
statusLabel: "queued",
|
|
1396
|
+
reviewCycles: 0
|
|
1397
|
+
};
|
|
1398
|
+
state.stories.set(event.key, story);
|
|
1399
|
+
}
|
|
1400
|
+
const phaseLabel = mapPhaseToLabel(event.phase);
|
|
1401
|
+
story.phase = phaseLabel;
|
|
1402
|
+
story.status = mapPhaseToStatus(phaseLabel, event.status);
|
|
1403
|
+
story.statusLabel = makeStatusLabel(phaseLabel, event.status, event.verdict);
|
|
1404
|
+
break;
|
|
1405
|
+
}
|
|
1406
|
+
case "story:done": {
|
|
1407
|
+
let story = state.stories.get(event.key);
|
|
1408
|
+
if (story === void 0) {
|
|
1409
|
+
state.storyOrder.push(event.key);
|
|
1410
|
+
story = {
|
|
1411
|
+
key: event.key,
|
|
1412
|
+
phase: "done",
|
|
1413
|
+
status: event.result === "success" ? "succeeded" : "failed",
|
|
1414
|
+
statusLabel: event.result === "success" ? "SHIP_IT" : "FAILED",
|
|
1415
|
+
reviewCycles: event.review_cycles
|
|
1416
|
+
};
|
|
1417
|
+
state.stories.set(event.key, story);
|
|
1418
|
+
} else {
|
|
1419
|
+
story.phase = event.result === "success" ? "done" : "failed";
|
|
1420
|
+
story.status = event.result === "success" ? "succeeded" : "failed";
|
|
1421
|
+
const cycleWord = event.review_cycles === 1 ? "cycle" : "cycles";
|
|
1422
|
+
story.statusLabel = event.result === "success" ? `SHIP_IT (${event.review_cycles} ${cycleWord})` : "FAILED";
|
|
1423
|
+
story.reviewCycles = event.review_cycles;
|
|
1424
|
+
}
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
case "story:escalation": {
|
|
1428
|
+
let story = state.stories.get(event.key);
|
|
1429
|
+
if (story === void 0) {
|
|
1430
|
+
state.storyOrder.push(event.key);
|
|
1431
|
+
story = {
|
|
1432
|
+
key: event.key,
|
|
1433
|
+
phase: "escalated",
|
|
1434
|
+
status: "escalated",
|
|
1435
|
+
statusLabel: `ESCALATED — ${event.reason}`,
|
|
1436
|
+
reviewCycles: event.cycles,
|
|
1437
|
+
escalationReason: event.reason
|
|
1438
|
+
};
|
|
1439
|
+
state.stories.set(event.key, story);
|
|
1440
|
+
} else {
|
|
1441
|
+
story.phase = "escalated";
|
|
1442
|
+
story.status = "escalated";
|
|
1443
|
+
story.statusLabel = `ESCALATED — ${event.reason}`;
|
|
1444
|
+
story.reviewCycles = event.cycles;
|
|
1445
|
+
story.escalationReason = event.reason;
|
|
1446
|
+
}
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
case "story:warn": {
|
|
1450
|
+
const logEntry = {
|
|
1451
|
+
ts: event.ts,
|
|
1452
|
+
key: event.key,
|
|
1453
|
+
msg: `[WARN] ${event.msg}`,
|
|
1454
|
+
level: "warn"
|
|
1455
|
+
};
|
|
1456
|
+
state.logs.push(logEntry);
|
|
1457
|
+
if (state.logs.length > MAX_LOG_ENTRIES) state.logs.splice(0, state.logs.length - MAX_LOG_ENTRIES);
|
|
1458
|
+
break;
|
|
1459
|
+
}
|
|
1460
|
+
case "story:log": {
|
|
1461
|
+
const logEntry = {
|
|
1462
|
+
ts: event.ts,
|
|
1463
|
+
key: event.key,
|
|
1464
|
+
msg: event.msg,
|
|
1465
|
+
level: "log"
|
|
1466
|
+
};
|
|
1467
|
+
state.logs.push(logEntry);
|
|
1468
|
+
if (state.logs.length > MAX_LOG_ENTRIES) state.logs.splice(0, state.logs.length - MAX_LOG_ENTRIES);
|
|
1469
|
+
break;
|
|
1470
|
+
}
|
|
1471
|
+
case "pipeline:complete":
|
|
1472
|
+
state.pipelineComplete = true;
|
|
1473
|
+
state.completionStats = {
|
|
1474
|
+
succeeded: event.succeeded,
|
|
1475
|
+
failed: event.failed,
|
|
1476
|
+
escalated: event.escalated
|
|
1477
|
+
};
|
|
1478
|
+
setTimeout(() => {
|
|
1479
|
+
if (exitResolve !== void 0) render();
|
|
1480
|
+
}, 500);
|
|
1481
|
+
break;
|
|
1482
|
+
default: break;
|
|
1483
|
+
}
|
|
1484
|
+
render();
|
|
1485
|
+
}
|
|
1486
|
+
init();
|
|
1487
|
+
return {
|
|
1488
|
+
handleEvent,
|
|
1489
|
+
cleanup,
|
|
1490
|
+
waitForExit: () => exitPromise
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Check whether the TUI can run in the current environment.
|
|
1495
|
+
*
|
|
1496
|
+
* Returns true if stdout is a TTY, false otherwise.
|
|
1497
|
+
* If false, the caller should print a warning and use default output.
|
|
1498
|
+
*/
|
|
1499
|
+
function isTuiCapable() {
|
|
1500
|
+
return process.stdout.isTTY === true;
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Print the non-TTY fallback warning message.
|
|
1504
|
+
*/
|
|
1505
|
+
function printNonTtyWarning() {
|
|
1506
|
+
process.stderr.write("TUI requires an interactive terminal. Falling back to default output.\n");
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
//#endregion
|
|
1510
|
+
//#region src/core/event-bus.ts
|
|
1511
|
+
/**
|
|
1512
|
+
* Concrete implementation of TypedEventBus backed by Node.js EventEmitter.
|
|
1513
|
+
*
|
|
1514
|
+
* @example
|
|
1515
|
+
* const bus = new TypedEventBusImpl()
|
|
1516
|
+
* bus.on('task:complete', ({ taskId, result }) => {
|
|
1517
|
+
* console.log(`Task ${taskId} finished`)
|
|
1518
|
+
* })
|
|
1519
|
+
* bus.emit('task:complete', { taskId: 'abc', result: { exitCode: 0 } })
|
|
1520
|
+
*/
|
|
1521
|
+
var TypedEventBusImpl = class {
|
|
1522
|
+
_emitter;
|
|
1523
|
+
constructor() {
|
|
1524
|
+
this._emitter = new EventEmitter();
|
|
1525
|
+
this._emitter.setMaxListeners(100);
|
|
1526
|
+
}
|
|
1527
|
+
emit(event, payload) {
|
|
1528
|
+
this._emitter.emit(event, payload);
|
|
1529
|
+
}
|
|
1530
|
+
on(event, handler) {
|
|
1531
|
+
this._emitter.on(event, handler);
|
|
1532
|
+
}
|
|
1533
|
+
off(event, handler) {
|
|
1534
|
+
this._emitter.off(event, handler);
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
/**
|
|
1538
|
+
* Create a new TypedEventBus instance.
|
|
1539
|
+
*
|
|
1540
|
+
* @example
|
|
1541
|
+
* const bus = createEventBus()
|
|
1542
|
+
*/
|
|
1543
|
+
function createEventBus() {
|
|
1544
|
+
return new TypedEventBusImpl();
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
//#endregion
|
|
1548
|
+
export { AdapterRegistry, ClaudeCodeAdapter, CodexCLIAdapter, GeminiCLIAdapter, createEventBus, createTuiApp, isTuiCapable, printNonTtyWarning };
|
|
1549
|
+
//# sourceMappingURL=event-bus-J-bw-pkp.js.map
|