agentweaver 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/dist/index.js ADDED
@@ -0,0 +1,1218 @@
1
+ #!/usr/bin/env node
2
+ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { appendFile, readFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+ import { spawn, spawnSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+ import { READY_TO_MERGE_FILE, REVIEW_FILE_RE, REVIEW_REPLY_FILE_RE, artifactFile, designFile, planArtifacts, planFile, qaFile, requireArtifacts, taskSummaryFile, } from "./artifacts.js";
10
+ import { TaskRunnerError } from "./errors.js";
11
+ import { buildJiraApiUrl, buildJiraBrowseUrl, extractIssueKey, fetchJiraIssue, requireJiraTaskFile } from "./jira.js";
12
+ import { AUTO_REVIEW_FIX_EXTRA_PROMPT, IMPLEMENT_PROMPT_TEMPLATE, PLAN_PROMPT_TEMPLATE, REVIEW_FIX_PROMPT_TEMPLATE, REVIEW_PROMPT_TEMPLATE, REVIEW_REPLY_PROMPT_TEMPLATE, REVIEW_REPLY_SUMMARY_PROMPT_TEMPLATE, REVIEW_SUMMARY_PROMPT_TEMPLATE, TASK_SUMMARY_PROMPT_TEMPLATE, TEST_FIX_PROMPT_TEMPLATE, TEST_LINTER_FIX_PROMPT_TEMPLATE, formatPrompt, formatTemplate, } from "./prompts.js";
13
+ import { InteractiveUi } from "./interactive-ui.js";
14
+ import { bye, dim, formatDone, getOutputAdapter, printError, printInfo, printPanel, printPrompt, printSummary } from "./tui.js";
15
+ const COMMANDS = [
16
+ "plan",
17
+ "implement",
18
+ "review",
19
+ "review-fix",
20
+ "test",
21
+ "test-fix",
22
+ "test-linter-fix",
23
+ "auto",
24
+ "auto-status",
25
+ "auto-reset",
26
+ ];
27
+ const DEFAULT_CODEX_MODEL = "gpt-5.4";
28
+ const DEFAULT_CLAUDE_REVIEW_MODEL = "opus";
29
+ const DEFAULT_CLAUDE_SUMMARY_MODEL = "haiku";
30
+ const HISTORY_FILE = path.join(os.homedir(), ".codex", "memories", "agentweaver-history");
31
+ const AUTO_STATE_SCHEMA_VERSION = 1;
32
+ const AUTO_MAX_REVIEW_ITERATIONS = 3;
33
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
34
+ const PACKAGE_ROOT = path.resolve(MODULE_DIR, "..");
35
+ function usage() {
36
+ return `Usage:
37
+ agentweaver <jira-browse-url|jira-issue-key>
38
+ agentweaver --force <jira-browse-url|jira-issue-key>
39
+ agentweaver plan [--dry] [--verbose] [--prompt <text>] <jira-browse-url|jira-issue-key>
40
+ agentweaver implement [--dry] [--verbose] [--prompt <text>] <jira-browse-url|jira-issue-key>
41
+ agentweaver review [--dry] [--verbose] [--prompt <text>] <jira-browse-url|jira-issue-key>
42
+ agentweaver review-fix [--dry] [--verbose] [--prompt <text>] <jira-browse-url|jira-issue-key>
43
+ agentweaver test [--dry] [--verbose] <jira-browse-url|jira-issue-key>
44
+ agentweaver test-fix [--dry] [--verbose] [--prompt <text>] <jira-browse-url|jira-issue-key>
45
+ agentweaver test-linter-fix [--dry] [--verbose] [--prompt <text>] <jira-browse-url|jira-issue-key>
46
+ agentweaver auto [--dry] [--verbose] [--prompt <text>] <jira-browse-url|jira-issue-key>
47
+ agentweaver auto [--dry] [--verbose] [--prompt <text>] --from <phase> <jira-browse-url|jira-issue-key>
48
+ agentweaver auto --help-phases
49
+ agentweaver auto-status <jira-browse-url|jira-issue-key>
50
+ agentweaver auto-reset <jira-browse-url|jira-issue-key>
51
+
52
+ Interactive Mode:
53
+ When started with only a Jira task, the script opens an interactive shell.
54
+ Available slash commands: /plan, /implement, /review, /review-fix, /test, /test-fix, /test-linter-fix, /auto, /auto-status, /auto-reset, /help, /exit
55
+
56
+ Flags:
57
+ --force In interactive mode, force refresh Jira task and task summary
58
+ --dry Fetch Jira task, but print docker/codex/claude commands instead of executing them
59
+ --verbose Show live stdout/stderr of launched commands
60
+ --prompt Extra prompt text appended to the base prompt
61
+
62
+ Required environment variables:
63
+ JIRA_API_KEY Jira API key used in Authorization: Bearer <token> for plan
64
+
65
+ Optional environment variables:
66
+ JIRA_BASE_URL
67
+ AGENTWEAVER_HOME
68
+ DOCKER_COMPOSE_BIN
69
+ CODEX_BIN
70
+ CODEX_MODEL
71
+ CLAUDE_BIN
72
+ CLAUDE_REVIEW_MODEL
73
+ CLAUDE_SUMMARY_MODEL`;
74
+ }
75
+ function nowIso8601() {
76
+ return new Date().toISOString();
77
+ }
78
+ function normalizeAutoPhaseId(phaseId) {
79
+ return phaseId.trim().toLowerCase().replaceAll("-", "_");
80
+ }
81
+ function buildAutoSteps(maxReviewIterations = AUTO_MAX_REVIEW_ITERATIONS) {
82
+ const steps = [
83
+ { id: "plan", command: "plan", status: "pending" },
84
+ { id: "implement", command: "implement", status: "pending" },
85
+ { id: "test_after_implement", command: "test", status: "pending" },
86
+ ];
87
+ for (let iteration = 1; iteration <= maxReviewIterations; iteration += 1) {
88
+ steps.push({ id: `review_${iteration}`, command: "review", status: "pending", reviewIteration: iteration }, { id: `review_fix_${iteration}`, command: "review-fix", status: "pending", reviewIteration: iteration }, {
89
+ id: `test_after_review_fix_${iteration}`,
90
+ command: "test",
91
+ status: "pending",
92
+ reviewIteration: iteration,
93
+ });
94
+ }
95
+ return steps;
96
+ }
97
+ function autoPhaseIds(maxReviewIterations = AUTO_MAX_REVIEW_ITERATIONS) {
98
+ return buildAutoSteps(maxReviewIterations).map((step) => step.id);
99
+ }
100
+ function validateAutoPhaseId(phaseId) {
101
+ const normalized = normalizeAutoPhaseId(phaseId);
102
+ if (!autoPhaseIds().includes(normalized)) {
103
+ throw new TaskRunnerError(`Unknown auto phase: ${phaseId}\nUse 'agentweaver auto --help-phases' or '/help auto' to list valid phases.`);
104
+ }
105
+ return normalized;
106
+ }
107
+ function autoStateFile(taskKey) {
108
+ return path.join(process.cwd(), `.agentweaver-state-${taskKey}.json`);
109
+ }
110
+ function createAutoPipelineState(config) {
111
+ return {
112
+ schemaVersion: AUTO_STATE_SCHEMA_VERSION,
113
+ issueKey: config.taskKey,
114
+ jiraRef: config.jiraRef,
115
+ status: "pending",
116
+ currentStep: null,
117
+ maxReviewIterations: AUTO_MAX_REVIEW_ITERATIONS,
118
+ updatedAt: nowIso8601(),
119
+ steps: buildAutoSteps(),
120
+ };
121
+ }
122
+ function loadAutoPipelineState(config) {
123
+ const filePath = autoStateFile(config.taskKey);
124
+ if (!existsSync(filePath)) {
125
+ return null;
126
+ }
127
+ let raw;
128
+ try {
129
+ raw = JSON.parse(readFileSync(filePath, "utf8"));
130
+ }
131
+ catch (error) {
132
+ throw new TaskRunnerError(`Failed to parse auto state file ${filePath}: ${error.message}`);
133
+ }
134
+ if (!raw || typeof raw !== "object") {
135
+ throw new TaskRunnerError(`Invalid auto state file format: ${filePath}`);
136
+ }
137
+ const state = raw;
138
+ if (state.schemaVersion !== AUTO_STATE_SCHEMA_VERSION) {
139
+ throw new TaskRunnerError(`Unsupported auto state schema in ${filePath}: ${state.schemaVersion}`);
140
+ }
141
+ return state;
142
+ }
143
+ function saveAutoPipelineState(state) {
144
+ state.updatedAt = nowIso8601();
145
+ writeFileSync(autoStateFile(state.issueKey), `${JSON.stringify(state, null, 2)}\n`, "utf8");
146
+ }
147
+ function resetAutoPipelineState(config) {
148
+ const filePath = autoStateFile(config.taskKey);
149
+ if (!existsSync(filePath)) {
150
+ return false;
151
+ }
152
+ rmSync(filePath);
153
+ return true;
154
+ }
155
+ function nextAutoStep(state) {
156
+ return state.steps.find((step) => ["running", "failed", "pending"].includes(step.status)) ?? null;
157
+ }
158
+ function markAutoStepSkipped(step, note) {
159
+ step.status = "skipped";
160
+ step.note = note;
161
+ step.finishedAt = nowIso8601();
162
+ }
163
+ function skipAutoStepsAfterReadyToMerge(state, currentStepId) {
164
+ let seenCurrent = false;
165
+ for (const step of state.steps) {
166
+ if (!seenCurrent) {
167
+ seenCurrent = step.id === currentStepId;
168
+ continue;
169
+ }
170
+ if (step.status === "pending") {
171
+ markAutoStepSkipped(step, "ready-to-merge detected");
172
+ }
173
+ }
174
+ }
175
+ function printAutoState(state) {
176
+ const lines = [
177
+ `Issue: ${state.issueKey}`,
178
+ `Status: ${state.status}`,
179
+ `Current step: ${state.currentStep ?? "-"}`,
180
+ `Updated: ${state.updatedAt}`,
181
+ ];
182
+ if (state.lastError) {
183
+ lines.push(`Last error: ${state.lastError.step ?? "-"} (exit ${state.lastError.returnCode ?? "-"}, ${state.lastError.message ?? "-"})`);
184
+ }
185
+ lines.push("");
186
+ for (const step of state.steps) {
187
+ lines.push(`[${step.status}] ${step.id}${step.note ? ` (${step.note})` : ""}`);
188
+ }
189
+ printPanel("Auto Status", lines.join("\n"), "cyan");
190
+ }
191
+ function printAutoPhasesHelp() {
192
+ const phaseLines = ["Available auto phases:", "", "plan", "implement", "test_after_implement"];
193
+ for (let iteration = 1; iteration <= AUTO_MAX_REVIEW_ITERATIONS; iteration += 1) {
194
+ phaseLines.push(`review_${iteration}`, `review_fix_${iteration}`, `test_after_review_fix_${iteration}`);
195
+ }
196
+ phaseLines.push("", "You can resume auto from a phase with:", "agentweaver auto --from <phase> <jira>", "or in interactive mode:", "/auto --from <phase>");
197
+ printPanel("Auto Phases", phaseLines.join("\n"), "magenta");
198
+ }
199
+ function loadEnvFile(envFilePath) {
200
+ if (!existsSync(envFilePath)) {
201
+ return;
202
+ }
203
+ const lines = readFileSync(envFilePath, "utf8").split(/\r?\n/);
204
+ for (const rawLine of lines) {
205
+ let line = rawLine.trim();
206
+ if (!line || line.startsWith("#")) {
207
+ continue;
208
+ }
209
+ if (line.startsWith("export ")) {
210
+ line = line.slice(7).trim();
211
+ }
212
+ const separatorIndex = line.indexOf("=");
213
+ if (separatorIndex < 0) {
214
+ continue;
215
+ }
216
+ const key = line.slice(0, separatorIndex).trim();
217
+ if (!key || process.env[key] !== undefined) {
218
+ continue;
219
+ }
220
+ let value = line.slice(separatorIndex + 1).trim();
221
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
222
+ value = value.slice(1, -1);
223
+ }
224
+ process.env[key] = value;
225
+ }
226
+ }
227
+ function agentweaverHome() {
228
+ const configured = process.env.AGENTWEAVER_HOME?.trim();
229
+ if (configured) {
230
+ return path.resolve(configured);
231
+ }
232
+ return PACKAGE_ROOT;
233
+ }
234
+ function defaultDockerComposeFile() {
235
+ return path.join(agentweaverHome(), "docker-compose.yml");
236
+ }
237
+ function defaultCodexHomeDir() {
238
+ return path.join(agentweaverHome(), ".codex-home");
239
+ }
240
+ function ensureRuntimeBindPath(targetPath, isDir) {
241
+ mkdirSync(path.dirname(targetPath), { recursive: true });
242
+ if (isDir) {
243
+ mkdirSync(targetPath, { recursive: true });
244
+ }
245
+ else if (!existsSync(targetPath)) {
246
+ writeFileSync(targetPath, "", "utf8");
247
+ }
248
+ return targetPath;
249
+ }
250
+ function defaultHostSshDir() {
251
+ const candidate = path.join(os.homedir(), ".ssh");
252
+ if (existsSync(candidate)) {
253
+ return candidate;
254
+ }
255
+ return ensureRuntimeBindPath(path.join(agentweaverHome(), ".runtime", "ssh"), true);
256
+ }
257
+ function defaultHostGitconfig() {
258
+ const candidate = path.join(os.homedir(), ".gitconfig");
259
+ if (existsSync(candidate)) {
260
+ return candidate;
261
+ }
262
+ return ensureRuntimeBindPath(path.join(agentweaverHome(), ".runtime", "gitconfig"), false);
263
+ }
264
+ function dockerRuntimeEnv() {
265
+ const env = { ...process.env };
266
+ env.AGENTWEAVER_HOME ??= agentweaverHome();
267
+ env.PROJECT_DIR ??= process.cwd();
268
+ env.CODEX_HOME_DIR ??= ensureRuntimeBindPath(defaultCodexHomeDir(), true);
269
+ env.HOST_SSH_DIR ??= defaultHostSshDir();
270
+ env.HOST_GITCONFIG ??= defaultHostGitconfig();
271
+ env.LOCAL_UID ??= typeof process.getuid === "function" ? String(process.getuid()) : "1000";
272
+ env.LOCAL_GID ??= typeof process.getgid === "function" ? String(process.getgid()) : "1000";
273
+ return env;
274
+ }
275
+ function commandExists(commandName) {
276
+ const result = spawnSync("bash", ["-lc", `command -v ${shellQuote(commandName)}`], { stdio: "ignore" });
277
+ return result.status === 0;
278
+ }
279
+ function shellQuote(value) {
280
+ return `'${value.replaceAll("'", `'\\''`)}'`;
281
+ }
282
+ function resolveCmd(commandName, envVarName) {
283
+ const configuredPath = process.env[envVarName];
284
+ if (configuredPath) {
285
+ accessSync(configuredPath, constants.X_OK);
286
+ return configuredPath;
287
+ }
288
+ const result = spawnSync("bash", ["-lc", `command -v ${shellQuote(commandName)}`], { encoding: "utf8" });
289
+ if (result.status === 0 && result.stdout.trim()) {
290
+ return result.stdout.trim().split(/\r?\n/)[0] ?? commandName;
291
+ }
292
+ throw new TaskRunnerError(`Missing required command: ${commandName}`);
293
+ }
294
+ function requireDockerCompose() {
295
+ if (!commandExists("docker")) {
296
+ throw new TaskRunnerError("Missing required command: docker");
297
+ }
298
+ const result = spawnSync("docker", ["compose", "version"], { stdio: "ignore" });
299
+ if (result.status !== 0) {
300
+ throw new TaskRunnerError("Missing required docker compose plugin");
301
+ }
302
+ }
303
+ function resolveDockerComposeCmd() {
304
+ const configured = process.env.DOCKER_COMPOSE_BIN?.trim() ?? "";
305
+ if (configured) {
306
+ const parts = splitArgs(configured);
307
+ if (parts.length === 0) {
308
+ throw new TaskRunnerError("DOCKER_COMPOSE_BIN is set but empty.");
309
+ }
310
+ const executable = parts[0] ?? "";
311
+ try {
312
+ if (path.isAbsolute(executable)) {
313
+ accessSync(executable, constants.X_OK);
314
+ return parts;
315
+ }
316
+ }
317
+ catch {
318
+ throw new TaskRunnerError(`Configured docker compose command is not executable: ${configured}`);
319
+ }
320
+ if (commandExists(executable)) {
321
+ return parts;
322
+ }
323
+ throw new TaskRunnerError(`Configured docker compose command is not executable: ${configured}`);
324
+ }
325
+ if (commandExists("docker-compose")) {
326
+ return ["docker-compose"];
327
+ }
328
+ requireDockerCompose();
329
+ return ["docker", "compose"];
330
+ }
331
+ function nextReviewIterationForTask(taskKey) {
332
+ let maxIndex = 0;
333
+ for (const entry of readdirSync(process.cwd(), { withFileTypes: true })) {
334
+ if (!entry.isFile()) {
335
+ continue;
336
+ }
337
+ const match = REVIEW_FILE_RE.exec(entry.name) ?? REVIEW_REPLY_FILE_RE.exec(entry.name);
338
+ if (match && match[1] === taskKey) {
339
+ maxIndex = Math.max(maxIndex, Number.parseInt(match[2] ?? "0", 10));
340
+ }
341
+ }
342
+ return maxIndex + 1;
343
+ }
344
+ function latestReviewReplyIteration(taskKey) {
345
+ let maxIndex = null;
346
+ for (const entry of readdirSync(process.cwd(), { withFileTypes: true })) {
347
+ if (!entry.isFile()) {
348
+ continue;
349
+ }
350
+ const match = REVIEW_REPLY_FILE_RE.exec(entry.name);
351
+ if (match && match[1] === taskKey) {
352
+ const current = Number.parseInt(match[2] ?? "0", 10);
353
+ maxIndex = maxIndex === null ? current : Math.max(maxIndex, current);
354
+ }
355
+ }
356
+ return maxIndex;
357
+ }
358
+ function formatCommand(argv, env) {
359
+ const envParts = Object.entries(env ?? {})
360
+ .filter(([key, value]) => value !== undefined && process.env[key] !== value)
361
+ .map(([key, value]) => `${key}=${shellQuote(value ?? "")}`);
362
+ const command = argv.map(shellQuote).join(" ");
363
+ return envParts.length > 0 ? `${envParts.join(" ")} ${command}` : command;
364
+ }
365
+ function formatDuration(ms) {
366
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
367
+ const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
368
+ const seconds = String(totalSeconds % 60).padStart(2, "0");
369
+ return `${minutes}:${seconds}`;
370
+ }
371
+ async function runCommand(argv, options = {}) {
372
+ const { env, dryRun = false, verbose = false, label, printFailureOutput = true } = options;
373
+ const outputAdapter = getOutputAdapter();
374
+ if (dryRun) {
375
+ outputAdapter.writeStdout(`${formatCommand(argv, env)}\n`);
376
+ return "";
377
+ }
378
+ if (verbose && outputAdapter.supportsPassthrough) {
379
+ await new Promise((resolve, reject) => {
380
+ const child = spawn(argv[0] ?? "", argv.slice(1), {
381
+ stdio: "inherit",
382
+ env,
383
+ });
384
+ child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(String(code ?? 1)))));
385
+ child.on("error", reject);
386
+ }).catch((error) => {
387
+ const code = Number.parseInt(error.message, 10);
388
+ throw Object.assign(new Error(`Command failed with exit code ${Number.isNaN(code) ? 1 : code}`), {
389
+ returnCode: Number.isNaN(code) ? 1 : code,
390
+ output: "",
391
+ });
392
+ });
393
+ return "";
394
+ }
395
+ const startedAt = Date.now();
396
+ const statusLabel = label ?? path.basename(argv[0] ?? argv.join(" "));
397
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
398
+ let frameIndex = 0;
399
+ let output = "";
400
+ const child = spawn(argv[0] ?? "", argv.slice(1), {
401
+ env,
402
+ stdio: ["ignore", "pipe", "pipe"],
403
+ });
404
+ child.stdout?.on("data", (chunk) => {
405
+ const text = String(chunk);
406
+ output += text;
407
+ if (!outputAdapter.supportsTransientStatus || verbose) {
408
+ outputAdapter.writeStdout(text);
409
+ }
410
+ });
411
+ child.stderr?.on("data", (chunk) => {
412
+ const text = String(chunk);
413
+ output += text;
414
+ if (!outputAdapter.supportsTransientStatus || verbose) {
415
+ outputAdapter.writeStderr(text);
416
+ }
417
+ });
418
+ if (!outputAdapter.supportsTransientStatus) {
419
+ outputAdapter.writeStdout(`Running ${statusLabel}\n`);
420
+ }
421
+ const timer = outputAdapter.supportsTransientStatus
422
+ ? setInterval(() => {
423
+ const elapsed = formatDuration(Date.now() - startedAt);
424
+ process.stdout.write(`\r${frames[frameIndex]} ${statusLabel} ${dim(elapsed)}`);
425
+ frameIndex = (frameIndex + 1) % frames.length;
426
+ }, 200)
427
+ : null;
428
+ try {
429
+ const exitCode = await new Promise((resolve, reject) => {
430
+ child.on("error", reject);
431
+ child.on("exit", (code) => resolve(code ?? 1));
432
+ });
433
+ if (timer) {
434
+ clearInterval(timer);
435
+ process.stdout.write(`\r${" ".repeat(80)}\r${formatDone(formatDuration(Date.now() - startedAt))}\n`);
436
+ }
437
+ else {
438
+ outputAdapter.writeStdout(`Done ${formatDuration(Date.now() - startedAt)}\n`);
439
+ }
440
+ if (exitCode !== 0) {
441
+ if (output && printFailureOutput) {
442
+ if (outputAdapter.supportsTransientStatus) {
443
+ process.stderr.write(output);
444
+ if (!output.endsWith("\n")) {
445
+ process.stderr.write("\n");
446
+ }
447
+ }
448
+ }
449
+ throw Object.assign(new Error(`Command failed with exit code ${exitCode}`), {
450
+ returnCode: exitCode,
451
+ output,
452
+ });
453
+ }
454
+ return output;
455
+ }
456
+ finally {
457
+ if (timer) {
458
+ clearInterval(timer);
459
+ }
460
+ }
461
+ }
462
+ function buildConfig(command, jiraRef, options = {}) {
463
+ const jiraIssueKey = extractIssueKey(jiraRef);
464
+ return {
465
+ command,
466
+ jiraRef,
467
+ reviewFixPoints: options.reviewFixPoints ?? null,
468
+ extraPrompt: options.extraPrompt ?? null,
469
+ autoFromPhase: options.autoFromPhase ? validateAutoPhaseId(options.autoFromPhase) : null,
470
+ dryRun: options.dryRun ?? false,
471
+ verbose: options.verbose ?? false,
472
+ dockerComposeFile: defaultDockerComposeFile(),
473
+ dockerComposeCmd: [],
474
+ codexCmd: process.env.CODEX_BIN ?? "codex",
475
+ claudeCmd: process.env.CLAUDE_BIN ?? "claude",
476
+ jiraIssueKey,
477
+ taskKey: jiraIssueKey,
478
+ jiraBrowseUrl: buildJiraBrowseUrl(jiraRef),
479
+ jiraApiUrl: buildJiraApiUrl(jiraRef),
480
+ jiraTaskFile: `./${jiraIssueKey}.json`,
481
+ };
482
+ }
483
+ function checkPrerequisites(config) {
484
+ let codexCmd = config.codexCmd;
485
+ let claudeCmd = config.claudeCmd;
486
+ let dockerComposeCmd = config.dockerComposeCmd;
487
+ if (config.command === "plan" || config.command === "review") {
488
+ codexCmd = resolveCmd("codex", "CODEX_BIN");
489
+ }
490
+ if (config.command === "review") {
491
+ claudeCmd = resolveCmd("claude", "CLAUDE_BIN");
492
+ }
493
+ if (["implement", "review-fix", "test", "test-fix", "test-linter-fix"].includes(config.command)) {
494
+ dockerComposeCmd = resolveDockerComposeCmd();
495
+ if (!existsSync(config.dockerComposeFile)) {
496
+ throw new TaskRunnerError(`docker-compose file not found: ${config.dockerComposeFile}`);
497
+ }
498
+ }
499
+ return { codexCmd, claudeCmd, dockerComposeCmd };
500
+ }
501
+ function buildPhaseConfig(baseConfig, command) {
502
+ return { ...baseConfig, command };
503
+ }
504
+ function appendPromptText(basePrompt, suffix) {
505
+ if (!basePrompt?.trim()) {
506
+ return suffix;
507
+ }
508
+ return `${basePrompt.trim()}\n${suffix}`;
509
+ }
510
+ function configForAutoStep(baseConfig, step) {
511
+ if (step.command === "review-fix") {
512
+ return {
513
+ ...buildPhaseConfig(baseConfig, step.command),
514
+ extraPrompt: appendPromptText(baseConfig.extraPrompt, AUTO_REVIEW_FIX_EXTRA_PROMPT),
515
+ };
516
+ }
517
+ return buildPhaseConfig(baseConfig, step.command);
518
+ }
519
+ function rewindAutoPipelineState(state, phaseId) {
520
+ const targetPhaseId = validateAutoPhaseId(phaseId);
521
+ let phaseSeen = false;
522
+ for (const step of state.steps) {
523
+ if (step.id === targetPhaseId) {
524
+ phaseSeen = true;
525
+ }
526
+ if (phaseSeen) {
527
+ step.status = "pending";
528
+ step.startedAt = null;
529
+ step.finishedAt = null;
530
+ step.returnCode = null;
531
+ step.note = null;
532
+ }
533
+ else {
534
+ step.status = "done";
535
+ step.returnCode = 0;
536
+ step.finishedAt ??= nowIso8601();
537
+ }
538
+ }
539
+ state.status = "pending";
540
+ state.currentStep = null;
541
+ state.lastError = null;
542
+ }
543
+ async function runCodexInDocker(config, dockerComposeCmd, prompt, labelText) {
544
+ const dockerEnv = dockerRuntimeEnv();
545
+ dockerEnv.CODEX_PROMPT = prompt;
546
+ dockerEnv.CODEX_EXEC_FLAGS = `--model ${codexModel()} --dangerously-bypass-approvals-and-sandbox`;
547
+ printInfo(labelText);
548
+ printPrompt("Codex", prompt);
549
+ await runCommand([...dockerComposeCmd, "-f", config.dockerComposeFile, "run", "--rm", "codex-exec"], {
550
+ env: dockerEnv,
551
+ dryRun: config.dryRun,
552
+ verbose: config.verbose,
553
+ label: `codex:${codexModel()}`,
554
+ });
555
+ }
556
+ async function runVerifyBuildInDocker(config, dockerComposeCmd, labelText) {
557
+ printInfo(labelText);
558
+ try {
559
+ await runCommand([...dockerComposeCmd, "-f", config.dockerComposeFile, "run", "--rm", "verify-build"], {
560
+ env: dockerRuntimeEnv(),
561
+ dryRun: config.dryRun,
562
+ verbose: false,
563
+ label: "verify-build",
564
+ printFailureOutput: false,
565
+ });
566
+ }
567
+ catch (error) {
568
+ const returnCode = Number(error.returnCode ?? 1);
569
+ printError(`Build verification failed with exit code ${returnCode}`);
570
+ if (!config.dryRun) {
571
+ printSummary("Build Failure Summary", await summarizeBuildFailure(String(error.output ?? "")));
572
+ }
573
+ throw error;
574
+ }
575
+ }
576
+ function codexModel() {
577
+ return process.env.CODEX_MODEL?.trim() || DEFAULT_CODEX_MODEL;
578
+ }
579
+ function claudeReviewModel() {
580
+ return process.env.CLAUDE_REVIEW_MODEL?.trim() || DEFAULT_CLAUDE_REVIEW_MODEL;
581
+ }
582
+ function claudeSummaryModel() {
583
+ return process.env.CLAUDE_SUMMARY_MODEL?.trim() || DEFAULT_CLAUDE_SUMMARY_MODEL;
584
+ }
585
+ function truncateText(text, maxChars = 12000) {
586
+ return text.length <= maxChars ? text.trim() : text.trim().slice(-maxChars);
587
+ }
588
+ function fallbackBuildFailureSummary(output) {
589
+ const lines = output
590
+ .split(/\r?\n/)
591
+ .map((line) => line.trim())
592
+ .filter(Boolean);
593
+ const tail = lines.length > 0 ? lines.slice(-8) : ["No build output captured."];
594
+ return `Не удалось получить summary через Claude.\n\nПоследние строки лога:\n${tail.join("\n")}`;
595
+ }
596
+ async function summarizeBuildFailure(output) {
597
+ if (!output.trim()) {
598
+ return "Build verification failed, but no output was captured.";
599
+ }
600
+ let claudeCmd;
601
+ try {
602
+ claudeCmd = resolveCmd("claude", "CLAUDE_BIN");
603
+ }
604
+ catch {
605
+ return fallbackBuildFailureSummary(output);
606
+ }
607
+ const prompt = "Ниже лог упавшей build verification.\n" +
608
+ "Сделай краткое резюме на русском языке, без воды.\n" +
609
+ "Нужно обязательно выделить:\n" +
610
+ "1. Где именно упало.\n" +
611
+ "2. Главную причину падения.\n" +
612
+ "3. Что нужно исправить дальше, если это очевидно.\n" +
613
+ "Ответ дай максимум 5 короткими пунктами.\n\n" +
614
+ `Лог:\n${truncateText(output)}`;
615
+ printInfo(`Summarizing build failure with Claude (${claudeSummaryModel()})`);
616
+ try {
617
+ const summary = await runCommand([claudeCmd, "--model", claudeSummaryModel(), "-p", prompt], {
618
+ env: { ...process.env },
619
+ dryRun: false,
620
+ verbose: false,
621
+ label: `claude:${claudeSummaryModel()}`,
622
+ });
623
+ return summary.trim() || fallbackBuildFailureSummary(output);
624
+ }
625
+ catch {
626
+ return fallbackBuildFailureSummary(output);
627
+ }
628
+ }
629
+ async function runClaudeSummary(claudeCmd, outputFile, prompt, verbose = false) {
630
+ printInfo(`Preparing summary in ${outputFile}`);
631
+ printPrompt("Claude", prompt);
632
+ await runCommand([claudeCmd, "--model", claudeSummaryModel(), "-p", "--allowedTools=Read,Write,Edit", prompt], {
633
+ env: { ...process.env },
634
+ dryRun: false,
635
+ verbose,
636
+ label: `claude:${claudeSummaryModel()}`,
637
+ });
638
+ requireArtifacts([outputFile], `Claude summary did not produce ${outputFile}.`);
639
+ return readFileSync(outputFile, "utf8").trim();
640
+ }
641
+ async function summarizeTask(jiraRef) {
642
+ const config = buildConfig("plan", jiraRef);
643
+ const claudeCmd = resolveCmd("claude", "CLAUDE_BIN");
644
+ await fetchJiraIssue(config.jiraApiUrl, config.jiraTaskFile);
645
+ const summaryPrompt = formatTemplate(TASK_SUMMARY_PROMPT_TEMPLATE, {
646
+ jira_task_file: config.jiraTaskFile,
647
+ task_summary_file: taskSummaryFile(config.taskKey),
648
+ });
649
+ const summaryText = await runClaudeSummary(claudeCmd, taskSummaryFile(config.taskKey), summaryPrompt);
650
+ return { issueKey: config.jiraIssueKey, summaryText };
651
+ }
652
+ function resolveTaskIdentity(jiraRef) {
653
+ const config = buildConfig("plan", jiraRef);
654
+ const summaryPath = taskSummaryFile(config.taskKey);
655
+ const summaryText = existsSync(summaryPath) ? readFileSync(summaryPath, "utf8").trim() : "";
656
+ return { issueKey: config.jiraIssueKey, summaryText };
657
+ }
658
+ async function executeCommand(config, runFollowupVerify = true) {
659
+ if (config.command === "auto") {
660
+ await runAutoPipeline(config);
661
+ return false;
662
+ }
663
+ if (config.command === "auto-status") {
664
+ const state = loadAutoPipelineState(config);
665
+ if (!state) {
666
+ printPanel("Auto Status", `No auto state file found for ${config.taskKey}.`, "yellow");
667
+ return false;
668
+ }
669
+ printAutoState(state);
670
+ return false;
671
+ }
672
+ if (config.command === "auto-reset") {
673
+ const removed = resetAutoPipelineState(config);
674
+ printPanel("Auto Reset", removed ? `State file ${autoStateFile(config.taskKey)} removed.` : "No auto state file found.", "yellow");
675
+ return false;
676
+ }
677
+ const { codexCmd, claudeCmd, dockerComposeCmd } = checkPrerequisites(config);
678
+ process.env.JIRA_BROWSE_URL = config.jiraBrowseUrl;
679
+ process.env.JIRA_API_URL = config.jiraApiUrl;
680
+ process.env.JIRA_TASK_FILE = config.jiraTaskFile;
681
+ const planPrompt = formatPrompt(formatTemplate(PLAN_PROMPT_TEMPLATE, {
682
+ jira_task_file: config.jiraTaskFile,
683
+ design_file: designFile(config.taskKey),
684
+ plan_file: planFile(config.taskKey),
685
+ qa_file: qaFile(config.taskKey),
686
+ }), config.extraPrompt);
687
+ const implementPrompt = formatPrompt(formatTemplate(IMPLEMENT_PROMPT_TEMPLATE, {
688
+ design_file: designFile(config.taskKey),
689
+ plan_file: planFile(config.taskKey),
690
+ }), config.extraPrompt);
691
+ if (config.command === "plan") {
692
+ if (config.verbose) {
693
+ process.stdout.write(`Fetching Jira issue from browse URL: ${config.jiraBrowseUrl}\n`);
694
+ process.stdout.write(`Resolved Jira API URL: ${config.jiraApiUrl}\n`);
695
+ process.stdout.write(`Saving Jira issue JSON to: ${config.jiraTaskFile}\n`);
696
+ }
697
+ await fetchJiraIssue(config.jiraApiUrl, config.jiraTaskFile);
698
+ printInfo("Running Codex planning mode");
699
+ printPrompt("Codex", planPrompt);
700
+ await runCommand([codexCmd, "exec", "--model", codexModel(), "--full-auto", planPrompt], {
701
+ env: { ...process.env },
702
+ dryRun: config.dryRun,
703
+ verbose: config.verbose,
704
+ label: `codex:${codexModel()}`,
705
+ });
706
+ requireArtifacts(planArtifacts(config.taskKey), "Plan mode did not produce the required artifacts.");
707
+ return false;
708
+ }
709
+ if (config.command === "implement") {
710
+ requireJiraTaskFile(config.jiraTaskFile);
711
+ requireArtifacts(planArtifacts(config.taskKey), "Implement mode requires plan artifacts from the planning phase.");
712
+ await runCodexInDocker(config, dockerComposeCmd, implementPrompt, "Running Codex implementation mode in isolated Docker");
713
+ if (runFollowupVerify) {
714
+ await runVerifyBuildInDocker(config, dockerComposeCmd, "Running build verification in isolated Docker");
715
+ }
716
+ return false;
717
+ }
718
+ if (config.command === "review") {
719
+ requireJiraTaskFile(config.jiraTaskFile);
720
+ requireArtifacts(planArtifacts(config.taskKey), "Review mode requires plan artifacts from the planning phase.");
721
+ const iteration = nextReviewIterationForTask(config.taskKey);
722
+ const reviewFile = artifactFile("review", config.taskKey, iteration);
723
+ const reviewReplyFile = artifactFile("review-reply", config.taskKey, iteration);
724
+ const reviewSummaryFile = artifactFile("review-summary", config.taskKey, iteration);
725
+ const reviewReplySummaryFile = artifactFile("review-reply-summary", config.taskKey, iteration);
726
+ const claudePrompt = formatPrompt(formatTemplate(REVIEW_PROMPT_TEMPLATE, {
727
+ jira_task_file: config.jiraTaskFile,
728
+ design_file: designFile(config.taskKey),
729
+ plan_file: planFile(config.taskKey),
730
+ review_file: reviewFile,
731
+ }), config.extraPrompt);
732
+ const codexReplyPrompt = formatPrompt(formatTemplate(REVIEW_REPLY_PROMPT_TEMPLATE, {
733
+ review_file: reviewFile,
734
+ jira_task_file: config.jiraTaskFile,
735
+ design_file: designFile(config.taskKey),
736
+ plan_file: planFile(config.taskKey),
737
+ review_reply_file: reviewReplyFile,
738
+ }), config.extraPrompt);
739
+ printInfo(`Running Claude review mode (iteration ${iteration})`);
740
+ printPrompt("Claude", claudePrompt);
741
+ await runCommand([
742
+ claudeCmd,
743
+ "--model",
744
+ claudeReviewModel(),
745
+ "-p",
746
+ "--allowedTools=Read,Write,Edit",
747
+ "--output-format",
748
+ "stream-json",
749
+ "--verbose",
750
+ "--include-partial-messages",
751
+ claudePrompt,
752
+ ], {
753
+ env: { ...process.env },
754
+ dryRun: config.dryRun,
755
+ verbose: config.verbose,
756
+ label: `claude:${claudeReviewModel()}`,
757
+ });
758
+ if (!config.dryRun) {
759
+ requireArtifacts([reviewFile], "Claude review did not produce the required review artifact.");
760
+ const reviewSummaryText = await runClaudeSummary(claudeCmd, reviewSummaryFile, formatTemplate(REVIEW_SUMMARY_PROMPT_TEMPLATE, {
761
+ review_file: reviewFile,
762
+ review_summary_file: reviewSummaryFile,
763
+ }), config.verbose);
764
+ printSummary("Claude Comments", reviewSummaryText);
765
+ }
766
+ printInfo(`Running Codex review reply mode (iteration ${iteration})`);
767
+ printPrompt("Codex", codexReplyPrompt);
768
+ await runCommand([codexCmd, "exec", "--model", codexModel(), "--full-auto", codexReplyPrompt], {
769
+ env: { ...process.env },
770
+ dryRun: config.dryRun,
771
+ verbose: config.verbose,
772
+ label: `codex:${codexModel()}`,
773
+ });
774
+ let readyToMerge = false;
775
+ if (!config.dryRun) {
776
+ requireArtifacts([reviewReplyFile], "Codex review reply did not produce the required review-reply artifact.");
777
+ const reviewReplySummaryText = await runClaudeSummary(claudeCmd, reviewReplySummaryFile, formatTemplate(REVIEW_REPLY_SUMMARY_PROMPT_TEMPLATE, {
778
+ review_reply_file: reviewReplyFile,
779
+ review_reply_summary_file: reviewReplySummaryFile,
780
+ }), config.verbose);
781
+ printSummary("Codex Reply Summary", reviewReplySummaryText);
782
+ if (existsSync(READY_TO_MERGE_FILE)) {
783
+ printPanel("Ready To Merge", "Изменения готовы к merge\nФайл ready-to-merge.md создан.", "green");
784
+ readyToMerge = true;
785
+ }
786
+ }
787
+ return readyToMerge;
788
+ }
789
+ if (config.command === "review-fix") {
790
+ requireJiraTaskFile(config.jiraTaskFile);
791
+ requireArtifacts(planArtifacts(config.taskKey), "Review-fix mode requires plan artifacts from the planning phase.");
792
+ const latestIteration = latestReviewReplyIteration(config.taskKey);
793
+ if (latestIteration === null) {
794
+ throw new TaskRunnerError(`Review-fix mode requires at least one review-reply-${config.taskKey}-N.md artifact.`);
795
+ }
796
+ const reviewReplyFile = artifactFile("review-reply", config.taskKey, latestIteration);
797
+ const reviewFixFile = artifactFile("review-fix", config.taskKey, latestIteration);
798
+ const reviewFixPrompt = formatPrompt(formatTemplate(REVIEW_FIX_PROMPT_TEMPLATE, {
799
+ review_reply_file: reviewReplyFile,
800
+ items: config.reviewFixPoints ?? "",
801
+ review_fix_file: reviewFixFile,
802
+ }), config.extraPrompt);
803
+ await runCodexInDocker(config, dockerComposeCmd, reviewFixPrompt, `Running Codex review-fix mode in isolated Docker (iteration ${latestIteration})`);
804
+ if (!config.dryRun) {
805
+ requireArtifacts([reviewFixFile], "Review-fix mode did not produce the required review-fix artifact.");
806
+ }
807
+ if (runFollowupVerify) {
808
+ await runVerifyBuildInDocker(config, dockerComposeCmd, "Running build verification in isolated Docker");
809
+ }
810
+ return false;
811
+ }
812
+ if (config.command === "test") {
813
+ requireJiraTaskFile(config.jiraTaskFile);
814
+ requireArtifacts(planArtifacts(config.taskKey), "Test mode requires plan artifacts from the planning phase.");
815
+ await runVerifyBuildInDocker(config, dockerComposeCmd, "Running build verification in isolated Docker");
816
+ return false;
817
+ }
818
+ if (config.command === "test-fix" || config.command === "test-linter-fix") {
819
+ requireJiraTaskFile(config.jiraTaskFile);
820
+ requireArtifacts(planArtifacts(config.taskKey), `${config.command} mode requires plan artifacts from the planning phase.`);
821
+ const prompt = formatPrompt(config.command === "test-fix" ? TEST_FIX_PROMPT_TEMPLATE : TEST_LINTER_FIX_PROMPT_TEMPLATE, config.extraPrompt);
822
+ await runCodexInDocker(config, dockerComposeCmd, prompt, `Running Codex ${config.command} mode in isolated Docker`);
823
+ return false;
824
+ }
825
+ throw new TaskRunnerError(`Unsupported command: ${config.command}`);
826
+ }
827
+ async function runAutoPipelineDryRun(config) {
828
+ printInfo("Dry-run auto pipeline: plan -> implement -> test -> review/review-fix/test");
829
+ await executeCommand(buildPhaseConfig(config, "plan"));
830
+ await executeCommand(buildPhaseConfig(config, "implement"), false);
831
+ await executeCommand(buildPhaseConfig(config, "test"));
832
+ for (let iteration = 1; iteration <= AUTO_MAX_REVIEW_ITERATIONS; iteration += 1) {
833
+ printInfo(`Dry-run auto review iteration ${iteration}/${AUTO_MAX_REVIEW_ITERATIONS}`);
834
+ await executeCommand(buildPhaseConfig(config, "review"));
835
+ await executeCommand({
836
+ ...buildPhaseConfig(config, "review-fix"),
837
+ extraPrompt: appendPromptText(config.extraPrompt, AUTO_REVIEW_FIX_EXTRA_PROMPT),
838
+ }, false);
839
+ await executeCommand(buildPhaseConfig(config, "test"));
840
+ }
841
+ }
842
+ async function runAutoPipeline(config) {
843
+ if (config.dryRun) {
844
+ await runAutoPipelineDryRun(config);
845
+ return;
846
+ }
847
+ let state = loadAutoPipelineState(config) ?? createAutoPipelineState(config);
848
+ if (config.autoFromPhase) {
849
+ rewindAutoPipelineState(state, config.autoFromPhase);
850
+ printPanel("Auto Resume", `Auto pipeline will continue from phase: ${config.autoFromPhase}`, "yellow");
851
+ saveAutoPipelineState(state);
852
+ }
853
+ else if (!existsSync(autoStateFile(config.taskKey))) {
854
+ saveAutoPipelineState(state);
855
+ }
856
+ printInfo("Running auto pipeline with persisted state");
857
+ while (true) {
858
+ const step = nextAutoStep(state);
859
+ if (!step) {
860
+ if (state.steps.some((candidate) => candidate.status === "failed")) {
861
+ state.status = "blocked";
862
+ }
863
+ else if (state.steps.some((candidate) => candidate.status === "skipped")) {
864
+ state.status = "completed";
865
+ }
866
+ else {
867
+ state.status = "max-iterations-reached";
868
+ }
869
+ state.currentStep = null;
870
+ saveAutoPipelineState(state);
871
+ if (state.status === "completed") {
872
+ printPanel("Auto", "Auto pipeline finished", "green");
873
+ }
874
+ else {
875
+ printInfo(`Auto pipeline finished with status: ${state.status}`);
876
+ }
877
+ return;
878
+ }
879
+ state.status = "running";
880
+ state.currentStep = step.id;
881
+ step.status = "running";
882
+ step.startedAt = nowIso8601();
883
+ step.finishedAt = null;
884
+ step.returnCode = null;
885
+ step.note = null;
886
+ state.lastError = null;
887
+ saveAutoPipelineState(state);
888
+ try {
889
+ printInfo(`Running auto step: ${step.id}`);
890
+ const readyToMerge = await executeCommand(configForAutoStep(config, step), !["implement", "review-fix"].includes(step.command));
891
+ step.status = "done";
892
+ step.finishedAt = nowIso8601();
893
+ step.returnCode = 0;
894
+ if (step.command === "review" && readyToMerge) {
895
+ skipAutoStepsAfterReadyToMerge(state, step.id);
896
+ state.status = "completed";
897
+ state.currentStep = null;
898
+ saveAutoPipelineState(state);
899
+ printPanel("Auto", "Auto pipeline finished", "green");
900
+ return;
901
+ }
902
+ }
903
+ catch (error) {
904
+ const returnCode = Number(error.returnCode ?? 1);
905
+ step.status = "failed";
906
+ step.finishedAt = nowIso8601();
907
+ step.returnCode = returnCode;
908
+ state.status = "blocked";
909
+ state.currentStep = step.id;
910
+ state.lastError = {
911
+ step: step.id,
912
+ returnCode,
913
+ message: "command failed",
914
+ };
915
+ saveAutoPipelineState(state);
916
+ throw error;
917
+ }
918
+ saveAutoPipelineState(state);
919
+ }
920
+ }
921
+ function splitArgs(input) {
922
+ const result = [];
923
+ let current = "";
924
+ let quote = null;
925
+ for (let index = 0; index < input.length; index += 1) {
926
+ const char = input[index] ?? "";
927
+ if (quote) {
928
+ if (char === quote) {
929
+ quote = null;
930
+ }
931
+ else {
932
+ current += char;
933
+ }
934
+ continue;
935
+ }
936
+ if (char === "'" || char === '"') {
937
+ quote = char;
938
+ continue;
939
+ }
940
+ if (/\s/.test(char)) {
941
+ if (current) {
942
+ result.push(current);
943
+ current = "";
944
+ }
945
+ continue;
946
+ }
947
+ current += char;
948
+ }
949
+ if (quote) {
950
+ throw new TaskRunnerError("Cannot parse command: unterminated quote");
951
+ }
952
+ if (current) {
953
+ result.push(current);
954
+ }
955
+ return result;
956
+ }
957
+ function parseCliArgs(argv) {
958
+ if (argv.includes("--help") || argv.includes("-h")) {
959
+ process.stdout.write(`${usage()}\n`);
960
+ process.exit(0);
961
+ }
962
+ if (argv.length === 0) {
963
+ process.stderr.write(`${usage()}\n`);
964
+ process.exit(1);
965
+ }
966
+ const command = argv[0];
967
+ if (!COMMANDS.includes(command)) {
968
+ process.stderr.write(`${usage()}\n`);
969
+ process.exit(1);
970
+ }
971
+ let dry = false;
972
+ let verbose = false;
973
+ let prompt;
974
+ let autoFromPhase;
975
+ let helpPhases = false;
976
+ let jiraRef;
977
+ for (let index = 1; index < argv.length; index += 1) {
978
+ const token = argv[index] ?? "";
979
+ if (token === "--dry") {
980
+ dry = true;
981
+ continue;
982
+ }
983
+ if (token === "--verbose") {
984
+ verbose = true;
985
+ continue;
986
+ }
987
+ if (token === "--help-phases") {
988
+ helpPhases = true;
989
+ continue;
990
+ }
991
+ if (token === "--prompt") {
992
+ prompt = argv[index + 1];
993
+ index += 1;
994
+ continue;
995
+ }
996
+ if (token === "--from") {
997
+ autoFromPhase = argv[index + 1];
998
+ index += 1;
999
+ continue;
1000
+ }
1001
+ jiraRef = token;
1002
+ }
1003
+ if (command === "auto" && helpPhases) {
1004
+ printAutoPhasesHelp();
1005
+ process.exit(0);
1006
+ }
1007
+ if (!jiraRef) {
1008
+ process.stderr.write(`${usage()}\n`);
1009
+ process.exit(1);
1010
+ }
1011
+ return {
1012
+ command: command,
1013
+ jiraRef,
1014
+ dry,
1015
+ verbose,
1016
+ helpPhases,
1017
+ ...(prompt !== undefined ? { prompt } : {}),
1018
+ ...(autoFromPhase !== undefined ? { autoFromPhase } : {}),
1019
+ };
1020
+ }
1021
+ function buildConfigFromArgs(args) {
1022
+ return buildConfig(args.command, args.jiraRef, {
1023
+ ...(args.prompt !== undefined ? { extraPrompt: args.prompt } : {}),
1024
+ ...(args.autoFromPhase !== undefined ? { autoFromPhase: args.autoFromPhase } : {}),
1025
+ dryRun: args.dry,
1026
+ verbose: args.verbose,
1027
+ });
1028
+ }
1029
+ function interactiveHelp() {
1030
+ printPanel("Interactive Commands", [
1031
+ "/plan [extra prompt]",
1032
+ "/implement [extra prompt]",
1033
+ "/review [extra prompt]",
1034
+ "/review-fix [extra prompt]",
1035
+ "/test",
1036
+ "/test-fix [extra prompt]",
1037
+ "/test-linter-fix [extra prompt]",
1038
+ "/auto [extra prompt]",
1039
+ "/auto --from <phase> [extra prompt]",
1040
+ "/auto-status",
1041
+ "/auto-reset",
1042
+ "/help auto",
1043
+ "/help",
1044
+ "/exit",
1045
+ ].join("\n"), "magenta");
1046
+ }
1047
+ function parseInteractiveCommand(line, jiraRef) {
1048
+ const parts = splitArgs(line);
1049
+ if (parts.length === 0) {
1050
+ return null;
1051
+ }
1052
+ const command = parts[0] ?? "";
1053
+ if (!command.startsWith("/")) {
1054
+ throw new TaskRunnerError("Interactive mode expects slash commands. Use /help.");
1055
+ }
1056
+ const commandName = command.slice(1);
1057
+ if (commandName === "help") {
1058
+ if (parts[1] === "auto" || parts[1] === "phases") {
1059
+ printAutoPhasesHelp();
1060
+ return null;
1061
+ }
1062
+ interactiveHelp();
1063
+ return null;
1064
+ }
1065
+ if (commandName === "exit" || commandName === "quit") {
1066
+ throw new EOFError();
1067
+ }
1068
+ if (!COMMANDS.includes(commandName)) {
1069
+ throw new TaskRunnerError(`Unknown command: ${command}`);
1070
+ }
1071
+ if (commandName === "auto") {
1072
+ let autoFromPhase;
1073
+ let extraParts = parts.slice(1);
1074
+ if (extraParts[0] === "--from") {
1075
+ if (!extraParts[1]) {
1076
+ throw new TaskRunnerError("auto --from requires a phase name. Use /help auto.");
1077
+ }
1078
+ autoFromPhase = extraParts[1];
1079
+ extraParts = extraParts.slice(2);
1080
+ }
1081
+ return buildConfig("auto", jiraRef, {
1082
+ extraPrompt: extraParts.join(" ") || null,
1083
+ ...(autoFromPhase !== undefined ? { autoFromPhase } : {}),
1084
+ });
1085
+ }
1086
+ return buildConfig(commandName, jiraRef, {
1087
+ extraPrompt: parts.slice(1).join(" ") || null,
1088
+ });
1089
+ }
1090
+ class EOFError extends Error {
1091
+ }
1092
+ async function ensureHistoryFile() {
1093
+ mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
1094
+ if (!existsSync(HISTORY_FILE)) {
1095
+ writeFileSync(HISTORY_FILE, "", "utf8");
1096
+ }
1097
+ }
1098
+ async function runInteractive(jiraRef, forceRefresh = false) {
1099
+ const config = buildConfig("plan", jiraRef);
1100
+ const jiraTaskPath = config.jiraTaskFile;
1101
+ const taskIdentity = forceRefresh || !existsSync(jiraTaskPath) ? await summarizeTask(jiraRef) : resolveTaskIdentity(jiraRef);
1102
+ await ensureHistoryFile();
1103
+ const historyLines = (await readFile(HISTORY_FILE, "utf8"))
1104
+ .split(/\r?\n/)
1105
+ .filter(Boolean)
1106
+ .slice(-200);
1107
+ let exiting = false;
1108
+ const commandList = [
1109
+ "/plan",
1110
+ "/implement",
1111
+ "/review",
1112
+ "/review-fix",
1113
+ "/test",
1114
+ "/test-fix",
1115
+ "/test-linter-fix",
1116
+ "/auto",
1117
+ "/auto-status",
1118
+ "/auto-reset",
1119
+ "/help",
1120
+ "/exit",
1121
+ ];
1122
+ const ui = new InteractiveUi({
1123
+ issueKey: taskIdentity.issueKey,
1124
+ summaryText: taskIdentity.summaryText || "Task summary is not available yet. Run /plan or refresh Jira data.",
1125
+ cwd: process.cwd(),
1126
+ commands: commandList,
1127
+ onSubmit: async (line) => {
1128
+ try {
1129
+ await appendFile(HISTORY_FILE, `${line.trim()}\n`, "utf8");
1130
+ const command = parseInteractiveCommand(line, jiraRef);
1131
+ if (!command) {
1132
+ return;
1133
+ }
1134
+ ui.setBusy(true, command.command);
1135
+ await executeCommand(command);
1136
+ }
1137
+ catch (error) {
1138
+ if (error instanceof EOFError) {
1139
+ exiting = true;
1140
+ return;
1141
+ }
1142
+ if (error instanceof TaskRunnerError) {
1143
+ printError(error.message);
1144
+ return;
1145
+ }
1146
+ const returnCode = Number(error.returnCode);
1147
+ if (!Number.isNaN(returnCode)) {
1148
+ printError(`Command failed with exit code ${returnCode}`);
1149
+ return;
1150
+ }
1151
+ throw error;
1152
+ }
1153
+ finally {
1154
+ ui.setBusy(false);
1155
+ }
1156
+ },
1157
+ onExit: () => {
1158
+ exiting = true;
1159
+ },
1160
+ }, historyLines);
1161
+ ui.mount();
1162
+ printInfo(`Interactive mode for ${taskIdentity.issueKey}`);
1163
+ if (taskIdentity.summaryText) {
1164
+ printInfo("Task summary loaded.");
1165
+ }
1166
+ else {
1167
+ printInfo("Task summary is not available yet.");
1168
+ }
1169
+ printInfo("Use /help to see commands.");
1170
+ return await new Promise((resolve, reject) => {
1171
+ const interval = setInterval(() => {
1172
+ if (!exiting) {
1173
+ return;
1174
+ }
1175
+ clearInterval(interval);
1176
+ try {
1177
+ ui.destroy();
1178
+ bye();
1179
+ resolve(0);
1180
+ }
1181
+ catch (error) {
1182
+ reject(error);
1183
+ }
1184
+ }, 100);
1185
+ });
1186
+ }
1187
+ export async function main(argv = process.argv.slice(2)) {
1188
+ loadEnvFile(path.join(process.cwd(), ".env"));
1189
+ let forceRefresh = false;
1190
+ const args = [...argv];
1191
+ if (args[0] === "--force") {
1192
+ forceRefresh = true;
1193
+ args.shift();
1194
+ }
1195
+ try {
1196
+ if (args.length === 1 && !args[0]?.startsWith("-") && !COMMANDS.includes(args[0])) {
1197
+ return await runInteractive(args[0] ?? "", forceRefresh);
1198
+ }
1199
+ const parsedArgs = parseCliArgs(args);
1200
+ await executeCommand(buildConfigFromArgs(parsedArgs));
1201
+ return 0;
1202
+ }
1203
+ catch (error) {
1204
+ if (error instanceof TaskRunnerError) {
1205
+ printError(error.message);
1206
+ return 1;
1207
+ }
1208
+ const returnCode = Number(error.returnCode);
1209
+ if (!Number.isNaN(returnCode)) {
1210
+ printError(`Command failed with exit code ${returnCode}`);
1211
+ return returnCode || 1;
1212
+ }
1213
+ throw error;
1214
+ }
1215
+ }
1216
+ void main().then((code) => {
1217
+ process.exitCode = code;
1218
+ });