agentweaver 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +30 -5
  2. package/dist/artifacts.js +24 -2
  3. package/dist/executors/claude-executor.js +46 -0
  4. package/dist/executors/claude-summary-executor.js +31 -0
  5. package/dist/executors/codex-docker-executor.js +27 -0
  6. package/dist/executors/codex-local-executor.js +25 -0
  7. package/dist/executors/command-check-executor.js +14 -0
  8. package/dist/executors/configs/claude-config.js +12 -0
  9. package/dist/executors/configs/claude-summary-config.js +8 -0
  10. package/dist/executors/configs/codex-docker-config.js +10 -0
  11. package/dist/executors/configs/codex-local-config.js +8 -0
  12. package/dist/executors/configs/jira-fetch-config.js +4 -0
  13. package/dist/executors/configs/process-config.js +3 -0
  14. package/dist/executors/configs/verify-build-config.js +7 -0
  15. package/dist/executors/jira-fetch-executor.js +11 -0
  16. package/dist/executors/process-executor.js +21 -0
  17. package/dist/executors/types.js +1 -0
  18. package/dist/executors/verify-build-executor.js +22 -0
  19. package/dist/index.js +456 -699
  20. package/dist/interactive-ui.js +536 -182
  21. package/dist/jira.js +3 -1
  22. package/dist/pipeline/auto-flow.js +9 -0
  23. package/dist/pipeline/build-failure-summary.js +6 -0
  24. package/dist/pipeline/checks.js +15 -0
  25. package/dist/pipeline/context.js +19 -0
  26. package/dist/pipeline/declarative-flow-runner.js +246 -0
  27. package/dist/pipeline/declarative-flows.js +24 -0
  28. package/dist/pipeline/flow-runner.js +13 -0
  29. package/dist/pipeline/flow-specs/auto.json +471 -0
  30. package/dist/pipeline/flow-specs/implement.json +47 -0
  31. package/dist/pipeline/flow-specs/plan.json +88 -0
  32. package/dist/pipeline/flow-specs/preflight.json +174 -0
  33. package/dist/pipeline/flow-specs/review-fix.json +76 -0
  34. package/dist/pipeline/flow-specs/review.json +233 -0
  35. package/dist/pipeline/flow-specs/test-fix.json +24 -0
  36. package/dist/pipeline/flow-specs/test-linter-fix.json +24 -0
  37. package/dist/pipeline/flow-specs/test.json +19 -0
  38. package/dist/pipeline/flow-types.js +1 -0
  39. package/dist/pipeline/flows/implement-flow.js +47 -0
  40. package/dist/pipeline/flows/plan-flow.js +42 -0
  41. package/dist/pipeline/flows/preflight-flow.js +19 -0
  42. package/dist/pipeline/flows/review-fix-flow.js +62 -0
  43. package/dist/pipeline/flows/review-flow.js +124 -0
  44. package/dist/pipeline/flows/test-fix-flow.js +12 -0
  45. package/dist/pipeline/flows/test-flow.js +32 -0
  46. package/dist/pipeline/node-registry.js +71 -0
  47. package/dist/pipeline/node-runner.js +20 -0
  48. package/dist/pipeline/nodes/build-failure-summary-node.js +71 -0
  49. package/dist/pipeline/nodes/claude-prompt-node.js +54 -0
  50. package/dist/pipeline/nodes/claude-summary-node.js +38 -0
  51. package/dist/pipeline/nodes/codex-docker-prompt-node.js +32 -0
  52. package/dist/pipeline/nodes/codex-local-prompt-node.js +32 -0
  53. package/dist/pipeline/nodes/command-check-node.js +10 -0
  54. package/dist/pipeline/nodes/file-check-node.js +15 -0
  55. package/dist/pipeline/nodes/implement-codex-node.js +16 -0
  56. package/dist/pipeline/nodes/jira-fetch-node.js +25 -0
  57. package/dist/pipeline/nodes/plan-codex-node.js +32 -0
  58. package/dist/pipeline/nodes/review-claude-node.js +38 -0
  59. package/dist/pipeline/nodes/review-reply-codex-node.js +40 -0
  60. package/dist/pipeline/nodes/summary-file-load-node.js +16 -0
  61. package/dist/pipeline/nodes/task-summary-node.js +42 -0
  62. package/dist/pipeline/nodes/verify-build-node.js +14 -0
  63. package/dist/pipeline/prompt-registry.js +22 -0
  64. package/dist/pipeline/prompt-runtime.js +18 -0
  65. package/dist/pipeline/registry.js +23 -0
  66. package/dist/pipeline/spec-compiler.js +200 -0
  67. package/dist/pipeline/spec-loader.js +14 -0
  68. package/dist/pipeline/spec-types.js +1 -0
  69. package/dist/pipeline/spec-validator.js +290 -0
  70. package/dist/pipeline/types.js +10 -0
  71. package/dist/pipeline/value-resolver.js +199 -0
  72. package/dist/prompts.js +1 -3
  73. package/dist/runtime/command-resolution.js +139 -0
  74. package/dist/runtime/docker-runtime.js +51 -0
  75. package/dist/runtime/process-runner.js +112 -0
  76. package/dist/tui.js +73 -0
  77. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -1,17 +1,22 @@
1
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";
2
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
3
  import path from "node:path";
6
4
  import process from "node:process";
7
- import { spawn, spawnSync } from "node:child_process";
8
5
  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";
6
+ import { REVIEW_FILE_RE, REVIEW_REPLY_FILE_RE, autoStateFile, ensureTaskWorkspaceDir, jiraTaskFile, planArtifacts, readyToMergeFile, requireArtifacts, taskWorkspaceDir, taskSummaryFile, } from "./artifacts.js";
10
7
  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";
8
+ import { buildJiraApiUrl, buildJiraBrowseUrl, extractIssueKey, requireJiraTaskFile } from "./jira.js";
9
+ import { summarizeBuildFailure as summarizeBuildFailureViaPipeline } from "./pipeline/build-failure-summary.js";
10
+ import { createPipelineContext } from "./pipeline/context.js";
11
+ import { loadAutoFlow } from "./pipeline/auto-flow.js";
12
+ import { loadDeclarativeFlow } from "./pipeline/declarative-flows.js";
13
+ import { findPhaseById, runExpandedPhase } from "./pipeline/declarative-flow-runner.js";
14
+ import { runPreflightFlow } from "./pipeline/flows/preflight-flow.js";
15
+ import { resolveCmd, resolveDockerComposeCmd } from "./runtime/command-resolution.js";
16
+ import { defaultDockerComposeFile, dockerRuntimeEnv } from "./runtime/docker-runtime.js";
17
+ import { runCommand } from "./runtime/process-runner.js";
13
18
  import { InteractiveUi } from "./interactive-ui.js";
14
- import { bye, dim, formatDone, getOutputAdapter, printError, printInfo, printPanel, printPrompt, printSummary } from "./tui.js";
19
+ import { bye, printError, printInfo, printPanel, printSummary, setFlowExecutionState } from "./tui.js";
15
20
  const COMMANDS = [
16
21
  "plan",
17
22
  "implement",
@@ -24,14 +29,15 @@ const COMMANDS = [
24
29
  "auto-status",
25
30
  "auto-reset",
26
31
  ];
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;
32
+ const AUTO_STATE_SCHEMA_VERSION = 2;
33
33
  const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
34
34
  const PACKAGE_ROOT = path.resolve(MODULE_DIR, "..");
35
+ const runtimeServices = {
36
+ resolveCmd,
37
+ resolveDockerComposeCmd,
38
+ dockerRuntimeEnv: () => dockerRuntimeEnv(PACKAGE_ROOT),
39
+ runCommand,
40
+ };
35
41
  function usage() {
36
42
  return `Usage:
37
43
  agentweaver <jira-browse-url|jira-issue-key>
@@ -50,10 +56,11 @@ function usage() {
50
56
  agentweaver auto-reset <jira-browse-url|jira-issue-key>
51
57
 
52
58
  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
59
+ When started with only a Jira task, the script opens an interactive UI.
60
+ Use Up/Down to select a flow, Enter to confirm launch, h for help, q to exit.
55
61
 
56
62
  Flags:
63
+ --version Show package version
57
64
  --force In interactive mode, force refresh Jira task and task summary
58
65
  --dry Fetch Jira task, but print docker/codex/claude commands instead of executing them
59
66
  --verbose Show live stdout/stderr of launched commands
@@ -69,8 +76,15 @@ Optional environment variables:
69
76
  CODEX_BIN
70
77
  CODEX_MODEL
71
78
  CLAUDE_BIN
72
- CLAUDE_REVIEW_MODEL
73
- CLAUDE_SUMMARY_MODEL`;
79
+ CLAUDE_MODEL`;
80
+ }
81
+ function packageVersion() {
82
+ const packageJsonPath = path.join(PACKAGE_ROOT, "package.json");
83
+ const raw = JSON.parse(readFileSync(packageJsonPath, "utf8"));
84
+ if (typeof raw.version !== "string" || !raw.version.trim()) {
85
+ throw new TaskRunnerError(`Package version is missing in ${packageJsonPath}`);
86
+ }
87
+ return raw.version;
74
88
  }
75
89
  function nowIso8601() {
76
90
  return new Date().toISOString();
@@ -78,24 +92,14 @@ function nowIso8601() {
78
92
  function normalizeAutoPhaseId(phaseId) {
79
93
  return phaseId.trim().toLowerCase().replaceAll("-", "_");
80
94
  }
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;
95
+ function buildAutoSteps() {
96
+ return loadAutoFlow().phases.map((phase) => ({
97
+ id: phase.id,
98
+ status: "pending",
99
+ }));
96
100
  }
97
- function autoPhaseIds(maxReviewIterations = AUTO_MAX_REVIEW_ITERATIONS) {
98
- return buildAutoSteps(maxReviewIterations).map((step) => step.id);
101
+ function autoPhaseIds() {
102
+ return buildAutoSteps().map((step) => step.id);
99
103
  }
100
104
  function validateAutoPhaseId(phaseId) {
101
105
  const normalized = normalizeAutoPhaseId(phaseId);
@@ -104,19 +108,46 @@ function validateAutoPhaseId(phaseId) {
104
108
  }
105
109
  return normalized;
106
110
  }
107
- function autoStateFile(taskKey) {
108
- return path.join(process.cwd(), `.agentweaver-state-${taskKey}.json`);
109
- }
110
111
  function createAutoPipelineState(config) {
112
+ const autoFlow = loadAutoFlow();
113
+ const maxReviewIterations = autoFlow.phases.filter((phase) => /^review_\d+$/.test(phase.id)).length;
111
114
  return {
112
115
  schemaVersion: AUTO_STATE_SCHEMA_VERSION,
113
116
  issueKey: config.taskKey,
114
117
  jiraRef: config.jiraRef,
115
118
  status: "pending",
116
119
  currentStep: null,
117
- maxReviewIterations: AUTO_MAX_REVIEW_ITERATIONS,
120
+ maxReviewIterations,
118
121
  updatedAt: nowIso8601(),
119
122
  steps: buildAutoSteps(),
123
+ executionState: {
124
+ flowKind: autoFlow.kind,
125
+ flowVersion: autoFlow.version,
126
+ terminated: false,
127
+ phases: [],
128
+ },
129
+ };
130
+ }
131
+ function stripExecutionStatePayload(executionState) {
132
+ return {
133
+ flowKind: executionState.flowKind,
134
+ flowVersion: executionState.flowVersion,
135
+ terminated: executionState.terminated,
136
+ ...(executionState.terminationReason ? { terminationReason: executionState.terminationReason } : {}),
137
+ phases: executionState.phases.map((phase) => ({
138
+ id: phase.id,
139
+ status: phase.status,
140
+ repeatVars: { ...phase.repeatVars },
141
+ ...(phase.startedAt ? { startedAt: phase.startedAt } : {}),
142
+ ...(phase.finishedAt ? { finishedAt: phase.finishedAt } : {}),
143
+ steps: phase.steps.map((step) => ({
144
+ id: step.id,
145
+ status: step.status,
146
+ ...(step.startedAt ? { startedAt: step.startedAt } : {}),
147
+ ...(step.finishedAt ? { finishedAt: step.finishedAt } : {}),
148
+ ...(step.stopFlow !== undefined ? { stopFlow: step.stopFlow } : {}),
149
+ })),
150
+ })),
120
151
  };
121
152
  }
122
153
  function loadAutoPipelineState(config) {
@@ -138,11 +169,29 @@ function loadAutoPipelineState(config) {
138
169
  if (state.schemaVersion !== AUTO_STATE_SCHEMA_VERSION) {
139
170
  throw new TaskRunnerError(`Unsupported auto state schema in ${filePath}: ${state.schemaVersion}`);
140
171
  }
172
+ if (!state.executionState) {
173
+ const autoFlow = loadAutoFlow();
174
+ state.executionState = {
175
+ flowKind: autoFlow.kind,
176
+ flowVersion: autoFlow.version,
177
+ terminated: false,
178
+ phases: [],
179
+ };
180
+ }
181
+ syncAutoStepsFromExecutionState(state);
141
182
  return state;
142
183
  }
143
184
  function saveAutoPipelineState(state) {
144
185
  state.updatedAt = nowIso8601();
145
- writeFileSync(autoStateFile(state.issueKey), `${JSON.stringify(state, null, 2)}\n`, "utf8");
186
+ ensureTaskWorkspaceDir(state.issueKey);
187
+ writeFileSync(autoStateFile(state.issueKey), `${JSON.stringify({
188
+ ...state,
189
+ executionState: stripExecutionStatePayload(state.executionState),
190
+ }, null, 2)}\n`, "utf8");
191
+ }
192
+ function syncAndSaveAutoPipelineState(state) {
193
+ syncAutoStepsFromExecutionState(state);
194
+ saveAutoPipelineState(state);
146
195
  }
147
196
  function resetAutoPipelineState(config) {
148
197
  const filePath = autoStateFile(config.taskKey);
@@ -155,28 +204,42 @@ function resetAutoPipelineState(config) {
155
204
  function nextAutoStep(state) {
156
205
  return state.steps.find((step) => ["running", "failed", "pending"].includes(step.status)) ?? null;
157
206
  }
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");
207
+ function findCurrentExecutionStep(state) {
208
+ for (const phase of state.executionState.phases) {
209
+ const runningStep = phase.steps.find((step) => step.status === "running");
210
+ if (runningStep) {
211
+ return `${phase.id}:${runningStep.id}`;
172
212
  }
173
213
  }
214
+ return null;
215
+ }
216
+ function deriveAutoPipelineStatus(state) {
217
+ if (state.lastError || state.steps.some((candidate) => candidate.status === "failed")) {
218
+ return "blocked";
219
+ }
220
+ if (state.executionState.terminated) {
221
+ return "completed";
222
+ }
223
+ if (state.steps.some((candidate) => candidate.status === "running")) {
224
+ return "running";
225
+ }
226
+ if (state.steps.some((candidate) => candidate.status === "pending")) {
227
+ return "pending";
228
+ }
229
+ if (state.steps.some((candidate) => candidate.status === "skipped")) {
230
+ return "completed";
231
+ }
232
+ if (state.steps.every((candidate) => candidate.status === "done")) {
233
+ return "completed";
234
+ }
235
+ return state.status;
174
236
  }
175
237
  function printAutoState(state) {
238
+ const currentStep = findCurrentExecutionStep(state) ?? state.currentStep ?? "-";
176
239
  const lines = [
177
240
  `Issue: ${state.issueKey}`,
178
- `Status: ${state.status}`,
179
- `Current step: ${state.currentStep ?? "-"}`,
241
+ `Status: ${deriveAutoPipelineStatus(state)}`,
242
+ `Current step: ${currentStep}`,
180
243
  `Updated: ${state.updatedAt}`,
181
244
  ];
182
245
  if (state.lastError) {
@@ -185,14 +248,42 @@ function printAutoState(state) {
185
248
  lines.push("");
186
249
  for (const step of state.steps) {
187
250
  lines.push(`[${step.status}] ${step.id}${step.note ? ` (${step.note})` : ""}`);
251
+ const phaseState = state.executionState.phases.find((candidate) => candidate.id === step.id);
252
+ for (const childStep of phaseState?.steps ?? []) {
253
+ lines.push(` - [${childStep.status}] ${childStep.id}`);
254
+ }
255
+ }
256
+ if (state.executionState.terminated) {
257
+ lines.push("", `Execution terminated: ${state.executionState.terminationReason ?? "yes"}`);
188
258
  }
189
259
  printPanel("Auto Status", lines.join("\n"), "cyan");
190
260
  }
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}`);
261
+ function syncAutoStepsFromExecutionState(state) {
262
+ for (const step of state.steps) {
263
+ const phaseState = state.executionState.phases.find((candidate) => candidate.id === step.id);
264
+ if (!phaseState) {
265
+ continue;
266
+ }
267
+ step.status = phaseState.status;
268
+ step.startedAt = phaseState.startedAt ?? null;
269
+ step.finishedAt = phaseState.finishedAt ?? null;
270
+ step.note = null;
271
+ if (phaseState.status === "skipped") {
272
+ step.note = "condition not met";
273
+ step.returnCode ??= 0;
274
+ }
275
+ else if (phaseState.status === "done") {
276
+ step.returnCode ??= 0;
277
+ if (state.executionState.terminated && state.executionState.terminationReason?.startsWith(`Stopped by ${step.id}:`)) {
278
+ step.note = "stop condition met";
279
+ }
280
+ }
195
281
  }
282
+ state.currentStep = findCurrentExecutionStep(state);
283
+ state.status = deriveAutoPipelineStatus(state);
284
+ }
285
+ function printAutoPhasesHelp() {
286
+ const phaseLines = ["Available auto phases:", "", ...autoPhaseIds()];
196
287
  phaseLines.push("", "You can resume auto from a phase with:", "agentweaver auto --from <phase> <jira>", "or in interactive mode:", "/auto --from <phase>");
197
288
  printPanel("Auto Phases", phaseLines.join("\n"), "magenta");
198
289
  }
@@ -224,113 +315,13 @@ function loadEnvFile(envFilePath) {
224
315
  process.env[key] = value;
225
316
  }
226
317
  }
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
318
  function nextReviewIterationForTask(taskKey) {
332
319
  let maxIndex = 0;
333
- for (const entry of readdirSync(process.cwd(), { withFileTypes: true })) {
320
+ const workspaceDir = taskWorkspaceDir(taskKey);
321
+ if (!existsSync(workspaceDir)) {
322
+ return 1;
323
+ }
324
+ for (const entry of readdirSync(workspaceDir, { withFileTypes: true })) {
334
325
  if (!entry.isFile()) {
335
326
  continue;
336
327
  }
@@ -343,7 +334,11 @@ function nextReviewIterationForTask(taskKey) {
343
334
  }
344
335
  function latestReviewReplyIteration(taskKey) {
345
336
  let maxIndex = null;
346
- for (const entry of readdirSync(process.cwd(), { withFileTypes: true })) {
337
+ const workspaceDir = taskWorkspaceDir(taskKey);
338
+ if (!existsSync(workspaceDir)) {
339
+ return null;
340
+ }
341
+ for (const entry of readdirSync(workspaceDir, { withFileTypes: true })) {
347
342
  if (!entry.isFile()) {
348
343
  continue;
349
344
  }
@@ -355,112 +350,9 @@ function latestReviewReplyIteration(taskKey) {
355
350
  }
356
351
  return maxIndex;
357
352
  }
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
353
  function buildConfig(command, jiraRef, options = {}) {
463
354
  const jiraIssueKey = extractIssueKey(jiraRef);
355
+ ensureTaskWorkspaceDir(jiraIssueKey);
464
356
  return {
465
357
  command,
466
358
  jiraRef,
@@ -469,52 +361,158 @@ function buildConfig(command, jiraRef, options = {}) {
469
361
  autoFromPhase: options.autoFromPhase ? validateAutoPhaseId(options.autoFromPhase) : null,
470
362
  dryRun: options.dryRun ?? false,
471
363
  verbose: options.verbose ?? false,
472
- dockerComposeFile: defaultDockerComposeFile(),
473
- dockerComposeCmd: [],
474
- codexCmd: process.env.CODEX_BIN ?? "codex",
475
- claudeCmd: process.env.CLAUDE_BIN ?? "claude",
364
+ dockerComposeFile: defaultDockerComposeFile(PACKAGE_ROOT),
476
365
  jiraIssueKey,
477
366
  taskKey: jiraIssueKey,
478
367
  jiraBrowseUrl: buildJiraBrowseUrl(jiraRef),
479
368
  jiraApiUrl: buildJiraApiUrl(jiraRef),
480
- jiraTaskFile: `./${jiraIssueKey}.json`,
369
+ jiraTaskFile: jiraTaskFile(jiraIssueKey),
481
370
  };
482
371
  }
483
372
  function checkPrerequisites(config) {
484
- let codexCmd = config.codexCmd;
485
- let claudeCmd = config.claudeCmd;
486
- let dockerComposeCmd = config.dockerComposeCmd;
487
373
  if (config.command === "plan" || config.command === "review") {
488
- codexCmd = resolveCmd("codex", "CODEX_BIN");
374
+ resolveCmd("codex", "CODEX_BIN");
489
375
  }
490
376
  if (config.command === "review") {
491
- claudeCmd = resolveCmd("claude", "CLAUDE_BIN");
377
+ resolveCmd("claude", "CLAUDE_BIN");
492
378
  }
493
- if (["implement", "review-fix", "test", "test-fix", "test-linter-fix"].includes(config.command)) {
494
- dockerComposeCmd = resolveDockerComposeCmd();
379
+ if (["implement", "review-fix", "test"].includes(config.command)) {
380
+ resolveDockerComposeCmd();
495
381
  if (!existsSync(config.dockerComposeFile)) {
496
382
  throw new TaskRunnerError(`docker-compose file not found: ${config.dockerComposeFile}`);
497
383
  }
498
384
  }
499
- return { codexCmd, claudeCmd, dockerComposeCmd };
500
385
  }
501
- function buildPhaseConfig(baseConfig, command) {
502
- return { ...baseConfig, command };
386
+ function checkAutoPrerequisites(config) {
387
+ resolveCmd("codex", "CODEX_BIN");
388
+ resolveCmd("claude", "CLAUDE_BIN");
389
+ resolveDockerComposeCmd();
390
+ if (!existsSync(config.dockerComposeFile)) {
391
+ throw new TaskRunnerError(`docker-compose file not found: ${config.dockerComposeFile}`);
392
+ }
393
+ }
394
+ function autoFlowParams(config) {
395
+ return {
396
+ jiraApiUrl: config.jiraApiUrl,
397
+ taskKey: config.taskKey,
398
+ dockerComposeFile: config.dockerComposeFile,
399
+ extraPrompt: config.extraPrompt,
400
+ reviewFixPoints: config.reviewFixPoints,
401
+ };
402
+ }
403
+ function declarativeFlowDefinition(id, label, fileName) {
404
+ const flow = loadDeclarativeFlow(fileName);
405
+ return {
406
+ id,
407
+ label,
408
+ phases: flow.phases.map((phase) => ({
409
+ id: phase.id,
410
+ repeatVars: Object.fromEntries(Object.entries(phase.repeatVars).map(([key, value]) => [key, value])),
411
+ steps: phase.steps.map((step) => ({
412
+ id: step.id,
413
+ })),
414
+ })),
415
+ };
416
+ }
417
+ function autoFlowDefinition() {
418
+ const flow = loadAutoFlow();
419
+ return {
420
+ id: "auto",
421
+ label: "auto",
422
+ phases: flow.phases.map((phase) => ({
423
+ id: phase.id,
424
+ repeatVars: Object.fromEntries(Object.entries(phase.repeatVars).map(([key, value]) => [key, value])),
425
+ steps: phase.steps.map((step) => ({
426
+ id: step.id,
427
+ })),
428
+ })),
429
+ };
430
+ }
431
+ function interactiveFlowDefinitions() {
432
+ return [
433
+ autoFlowDefinition(),
434
+ declarativeFlowDefinition("plan", "plan", "plan.json"),
435
+ declarativeFlowDefinition("implement", "implement", "implement.json"),
436
+ declarativeFlowDefinition("review", "review", "review.json"),
437
+ declarativeFlowDefinition("review-fix", "review-fix", "review-fix.json"),
438
+ declarativeFlowDefinition("test", "test", "test.json"),
439
+ declarativeFlowDefinition("test-fix", "test-fix", "test-fix.json"),
440
+ declarativeFlowDefinition("test-linter-fix", "test-linter-fix", "test-linter-fix.json"),
441
+ ];
442
+ }
443
+ function publishFlowState(flowId, executionState) {
444
+ setFlowExecutionState(flowId, stripExecutionStatePayload(executionState));
503
445
  }
504
- function appendPromptText(basePrompt, suffix) {
505
- if (!basePrompt?.trim()) {
506
- return suffix;
446
+ async function runDeclarativeFlowBySpecFile(fileName, config, flowParams) {
447
+ const context = createPipelineContext({
448
+ issueKey: config.taskKey,
449
+ jiraRef: config.jiraRef,
450
+ dryRun: config.dryRun,
451
+ verbose: config.verbose,
452
+ runtime: runtimeServices,
453
+ });
454
+ const flow = loadDeclarativeFlow(fileName);
455
+ const executionState = {
456
+ flowKind: flow.kind,
457
+ flowVersion: flow.version,
458
+ terminated: false,
459
+ phases: [],
460
+ };
461
+ publishFlowState(config.command, executionState);
462
+ for (const phase of flow.phases) {
463
+ await runExpandedPhase(phase, context, flowParams, flow.constants, {
464
+ executionState,
465
+ flowKind: flow.kind,
466
+ flowVersion: flow.version,
467
+ onStateChange: async (state) => {
468
+ publishFlowState(config.command, state);
469
+ },
470
+ });
507
471
  }
508
- return `${basePrompt.trim()}\n${suffix}`;
509
472
  }
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
- };
473
+ async function runAutoPhaseViaSpec(config, phaseId, executionState, state) {
474
+ const context = createPipelineContext({
475
+ issueKey: config.taskKey,
476
+ jiraRef: config.jiraRef,
477
+ dryRun: config.dryRun,
478
+ verbose: config.verbose,
479
+ runtime: runtimeServices,
480
+ });
481
+ const autoFlow = loadAutoFlow();
482
+ const phase = findPhaseById(autoFlow.phases, phaseId);
483
+ publishFlowState("auto", executionState);
484
+ try {
485
+ const result = await runExpandedPhase(phase, context, autoFlowParams(config), autoFlow.constants, {
486
+ executionState,
487
+ flowKind: autoFlow.kind,
488
+ flowVersion: autoFlow.version,
489
+ onStateChange: async (state) => {
490
+ publishFlowState("auto", state);
491
+ },
492
+ onStepStart: async (_phase, step) => {
493
+ if (!state) {
494
+ return;
495
+ }
496
+ state.currentStep = `${phaseId}:${step.id}`;
497
+ saveAutoPipelineState(state);
498
+ },
499
+ });
500
+ if (state) {
501
+ state.executionState = result.executionState;
502
+ syncAndSaveAutoPipelineState(state);
503
+ }
504
+ return result.status === "skipped" ? "skipped" : "done";
505
+ }
506
+ catch (error) {
507
+ if (!config.dryRun) {
508
+ const output = String(error.output ?? "");
509
+ if (output.trim()) {
510
+ printError("Build verification failed");
511
+ printSummary("Build Failure Summary", await summarizeBuildFailure(output));
512
+ }
513
+ }
514
+ throw error;
516
515
  }
517
- return buildPhaseConfig(baseConfig, step.command);
518
516
  }
519
517
  function rewindAutoPipelineState(state, phaseId) {
520
518
  const targetPhaseId = validateAutoPhaseId(phaseId);
@@ -539,121 +537,21 @@ function rewindAutoPipelineState(state, phaseId) {
539
537
  state.status = "pending";
540
538
  state.currentStep = null;
541
539
  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
- });
540
+ const targetIndex = state.executionState.phases.findIndex((phase) => phase.id === targetPhaseId);
541
+ if (targetIndex >= 0) {
542
+ state.executionState.phases = state.executionState.phases.slice(0, targetIndex);
566
543
  }
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")}`;
544
+ state.executionState.terminated = false;
545
+ delete state.executionState.terminationReason;
595
546
  }
596
547
  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 },
548
+ return summarizeBuildFailureViaPipeline(createPipelineContext({
549
+ issueKey: "build-failure-summary",
550
+ jiraRef: "build-failure-summary",
634
551
  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 };
552
+ verbose: false,
553
+ runtime: runtimeServices,
554
+ }), output);
657
555
  }
658
556
  async function executeCommand(config, runFollowupVerify = true) {
659
557
  if (config.command === "auto") {
@@ -674,169 +572,127 @@ async function executeCommand(config, runFollowupVerify = true) {
674
572
  printPanel("Auto Reset", removed ? `State file ${autoStateFile(config.taskKey)} removed.` : "No auto state file found.", "yellow");
675
573
  return false;
676
574
  }
677
- const { codexCmd, claudeCmd, dockerComposeCmd } = checkPrerequisites(config);
575
+ checkPrerequisites(config);
678
576
  process.env.JIRA_BROWSE_URL = config.jiraBrowseUrl;
679
577
  process.env.JIRA_API_URL = config.jiraApiUrl;
680
578
  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
579
  if (config.command === "plan") {
692
580
  if (config.verbose) {
693
581
  process.stdout.write(`Fetching Jira issue from browse URL: ${config.jiraBrowseUrl}\n`);
694
582
  process.stdout.write(`Resolved Jira API URL: ${config.jiraApiUrl}\n`);
695
583
  process.stdout.write(`Saving Jira issue JSON to: ${config.jiraTaskFile}\n`);
696
584
  }
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()}`,
585
+ await runDeclarativeFlowBySpecFile("plan.json", config, {
586
+ jiraApiUrl: config.jiraApiUrl,
587
+ taskKey: config.taskKey,
588
+ extraPrompt: config.extraPrompt,
705
589
  });
706
- requireArtifacts(planArtifacts(config.taskKey), "Plan mode did not produce the required artifacts.");
707
590
  return false;
708
591
  }
709
592
  if (config.command === "implement") {
710
593
  requireJiraTaskFile(config.jiraTaskFile);
711
594
  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");
595
+ try {
596
+ await runDeclarativeFlowBySpecFile("implement.json", config, {
597
+ taskKey: config.taskKey,
598
+ dockerComposeFile: config.dockerComposeFile,
599
+ extraPrompt: config.extraPrompt,
600
+ runFollowupVerify,
601
+ });
602
+ }
603
+ catch (error) {
604
+ if (!config.dryRun) {
605
+ const output = String(error.output ?? "");
606
+ if (output.trim()) {
607
+ printError("Build verification failed");
608
+ printSummary("Build Failure Summary", await summarizeBuildFailure(output));
609
+ }
610
+ }
611
+ throw error;
715
612
  }
716
613
  return false;
717
614
  }
718
615
  if (config.command === "review") {
719
616
  requireJiraTaskFile(config.jiraTaskFile);
720
- requireArtifacts(planArtifacts(config.taskKey), "Review mode requires plan artifacts from the planning phase.");
721
617
  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()}`,
618
+ await runDeclarativeFlowBySpecFile("review.json", config, {
619
+ taskKey: config.taskKey,
620
+ iteration,
621
+ extraPrompt: config.extraPrompt,
773
622
  });
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;
623
+ return !config.dryRun && existsSync(readyToMergeFile(config.taskKey));
788
624
  }
789
625
  if (config.command === "review-fix") {
790
626
  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.");
627
+ try {
628
+ await runDeclarativeFlowBySpecFile("review-fix.json", config, {
629
+ taskKey: config.taskKey,
630
+ dockerComposeFile: config.dockerComposeFile,
631
+ latestIteration: latestReviewReplyIteration(config.taskKey),
632
+ runFollowupVerify,
633
+ extraPrompt: config.extraPrompt,
634
+ reviewFixPoints: config.reviewFixPoints,
635
+ });
806
636
  }
807
- if (runFollowupVerify) {
808
- await runVerifyBuildInDocker(config, dockerComposeCmd, "Running build verification in isolated Docker");
637
+ catch (error) {
638
+ if (!config.dryRun) {
639
+ const output = String(error.output ?? "");
640
+ if (output.trim()) {
641
+ printError("Build verification failed");
642
+ printSummary("Build Failure Summary", await summarizeBuildFailure(output));
643
+ }
644
+ }
645
+ throw error;
809
646
  }
810
647
  return false;
811
648
  }
812
649
  if (config.command === "test") {
813
650
  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");
651
+ try {
652
+ await runDeclarativeFlowBySpecFile("test.json", config, {
653
+ taskKey: config.taskKey,
654
+ dockerComposeFile: config.dockerComposeFile,
655
+ });
656
+ }
657
+ catch (error) {
658
+ if (!config.dryRun) {
659
+ const output = String(error.output ?? "");
660
+ if (output.trim()) {
661
+ printError("Build verification failed");
662
+ printSummary("Build Failure Summary", await summarizeBuildFailure(output));
663
+ }
664
+ }
665
+ throw error;
666
+ }
816
667
  return false;
817
668
  }
818
669
  if (config.command === "test-fix" || config.command === "test-linter-fix") {
819
670
  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`);
671
+ await runDeclarativeFlowBySpecFile(config.command === "test-fix" ? "test-fix.json" : "test-linter-fix.json", config, {
672
+ taskKey: config.taskKey,
673
+ extraPrompt: config.extraPrompt,
674
+ });
823
675
  return false;
824
676
  }
825
677
  throw new TaskRunnerError(`Unsupported command: ${config.command}`);
826
678
  }
827
679
  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"));
680
+ checkAutoPrerequisites(config);
681
+ printInfo("Dry-run auto pipeline from declarative spec");
682
+ const autoFlow = loadAutoFlow();
683
+ const executionState = {
684
+ flowKind: autoFlow.kind,
685
+ flowVersion: autoFlow.version,
686
+ terminated: false,
687
+ phases: [],
688
+ };
689
+ publishFlowState("auto", executionState);
690
+ for (const phase of autoFlow.phases) {
691
+ printInfo(`Dry-run auto phase: ${phase.id}`);
692
+ await runAutoPhaseViaSpec(config, phase.id, executionState);
693
+ if (executionState.terminated) {
694
+ break;
695
+ }
840
696
  }
841
697
  }
842
698
  async function runAutoPipeline(config) {
@@ -844,6 +700,10 @@ async function runAutoPipeline(config) {
844
700
  await runAutoPipelineDryRun(config);
845
701
  return;
846
702
  }
703
+ checkAutoPrerequisites(config);
704
+ process.env.JIRA_BROWSE_URL = config.jiraBrowseUrl;
705
+ process.env.JIRA_API_URL = config.jiraApiUrl;
706
+ process.env.JIRA_TASK_FILE = config.jiraTaskFile;
847
707
  let state = loadAutoPipelineState(config) ?? createAutoPipelineState(config);
848
708
  if (config.autoFromPhase) {
849
709
  rewindAutoPipelineState(state, config.autoFromPhase);
@@ -857,17 +717,7 @@ async function runAutoPipeline(config) {
857
717
  while (true) {
858
718
  const step = nextAutoStep(state);
859
719
  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);
720
+ syncAndSaveAutoPipelineState(state);
871
721
  if (state.status === "completed") {
872
722
  printPanel("Auto", "Auto pipeline finished", "green");
873
723
  }
@@ -887,18 +737,14 @@ async function runAutoPipeline(config) {
887
737
  saveAutoPipelineState(state);
888
738
  try {
889
739
  printInfo(`Running auto step: ${step.id}`);
890
- const readyToMerge = await executeCommand(configForAutoStep(config, step), !["implement", "review-fix"].includes(step.command));
891
- step.status = "done";
740
+ const status = await runAutoPhaseViaSpec(config, step.id, state.executionState, state);
741
+ step.status = status;
892
742
  step.finishedAt = nowIso8601();
893
743
  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;
744
+ if (status === "skipped") {
745
+ step.note = "condition not met";
901
746
  }
747
+ syncAndSaveAutoPipelineState(state);
902
748
  }
903
749
  catch (error) {
904
750
  const returnCode = Number(error.returnCode ?? 1);
@@ -915,46 +761,18 @@ async function runAutoPipeline(config) {
915
761
  saveAutoPipelineState(state);
916
762
  throw error;
917
763
  }
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;
764
+ if (state.executionState.terminated) {
765
+ syncAndSaveAutoPipelineState(state);
766
+ printPanel("Auto", "Auto pipeline finished", "green");
767
+ return;
946
768
  }
947
- current += char;
948
769
  }
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
770
  }
957
771
  function parseCliArgs(argv) {
772
+ if (argv.includes("--version") || argv.includes("-v")) {
773
+ process.stdout.write(`${packageVersion()}\n`);
774
+ process.exit(0);
775
+ }
958
776
  if (argv.includes("--help") || argv.includes("-h")) {
959
777
  process.stdout.write(`${usage()}\n`);
960
778
  process.exit(0);
@@ -1026,147 +844,86 @@ function buildConfigFromArgs(args) {
1026
844
  verbose: args.verbose,
1027
845
  });
1028
846
  }
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
847
  async function runInteractive(jiraRef, forceRefresh = false) {
1099
848
  const config = buildConfig("plan", jiraRef);
1100
849
  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
850
  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
851
  const ui = new InteractiveUi({
1123
- issueKey: taskIdentity.issueKey,
1124
- summaryText: taskIdentity.summaryText || "Task summary is not available yet. Run /plan or refresh Jira data.",
852
+ issueKey: config.jiraIssueKey,
853
+ summaryText: "Starting interactive session...",
1125
854
  cwd: process.cwd(),
1126
- commands: commandList,
1127
- onSubmit: async (line) => {
855
+ flows: interactiveFlowDefinitions(),
856
+ onRun: async (flowId) => {
1128
857
  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);
858
+ const command = buildConfig(flowId, jiraRef);
1135
859
  await executeCommand(command);
1136
860
  }
1137
861
  catch (error) {
1138
- if (error instanceof EOFError) {
1139
- exiting = true;
1140
- return;
1141
- }
1142
862
  if (error instanceof TaskRunnerError) {
863
+ ui.setFlowFailed(flowId);
1143
864
  printError(error.message);
1144
865
  return;
1145
866
  }
1146
867
  const returnCode = Number(error.returnCode);
1147
868
  if (!Number.isNaN(returnCode)) {
869
+ ui.setFlowFailed(flowId);
1148
870
  printError(`Command failed with exit code ${returnCode}`);
1149
871
  return;
1150
872
  }
1151
873
  throw error;
1152
874
  }
1153
- finally {
1154
- ui.setBusy(false);
1155
- }
1156
875
  },
1157
876
  onExit: () => {
1158
877
  exiting = true;
1159
878
  },
1160
- }, historyLines);
879
+ });
1161
880
  ui.mount();
1162
- printInfo(`Interactive mode for ${taskIdentity.issueKey}`);
1163
- if (taskIdentity.summaryText) {
1164
- printInfo("Task summary loaded.");
881
+ printInfo(`Interactive mode for ${config.jiraIssueKey}`);
882
+ printInfo("Use h to see help.");
883
+ try {
884
+ ui.setBusy(true, "preflight");
885
+ const preflightState = await runPreflightFlow(createPipelineContext({
886
+ issueKey: config.taskKey,
887
+ jiraRef: config.jiraRef,
888
+ dryRun: false,
889
+ verbose: config.verbose,
890
+ runtime: runtimeServices,
891
+ setSummary: (markdown) => {
892
+ ui.setSummary(markdown);
893
+ },
894
+ }), {
895
+ jiraApiUrl: config.jiraApiUrl,
896
+ jiraTaskFile: config.jiraTaskFile,
897
+ taskKey: config.taskKey,
898
+ forceRefresh,
899
+ });
900
+ const preflightPhase = preflightState.phases.find((phase) => phase.id === "preflight");
901
+ if (preflightPhase) {
902
+ ui.appendLog("[preflight] completed");
903
+ for (const step of preflightPhase.steps) {
904
+ ui.appendLog(`[preflight] ${step.id}: ${step.status}`);
905
+ }
906
+ }
907
+ if (!existsSync(jiraTaskPath)) {
908
+ throw new TaskRunnerError(`Preflight finished without Jira task file: ${jiraTaskPath}\n` +
909
+ "Jira fetch did not complete successfully. Check JIRA_API_KEY and Jira connectivity.");
910
+ }
911
+ if (!existsSync(taskSummaryFile(config.taskKey))) {
912
+ ui.appendLog("[preflight] task summary file was not created");
913
+ ui.setSummary("Task summary is not available yet. Select and run `plan` or refresh Jira data.");
914
+ }
1165
915
  }
1166
- else {
1167
- printInfo("Task summary is not available yet.");
916
+ catch (error) {
917
+ if (error instanceof TaskRunnerError) {
918
+ printError(error.message);
919
+ }
920
+ else {
921
+ throw error;
922
+ }
923
+ }
924
+ finally {
925
+ ui.setBusy(false);
1168
926
  }
1169
- printInfo("Use /help to see commands.");
1170
927
  return await new Promise((resolve, reject) => {
1171
928
  const interval = setInterval(() => {
1172
929
  if (!exiting) {