From cc3931f873215ae906a0a8f44f7977011a026436 Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Fri, 14 Aug 2026 12:54:28 +0700 Subject: [PATCH] feat(code): add extensible pipeline step architecture --- AGENTS.md | 1 + docs/code/configuration.md | 71 +- packages/code/CLAUDE.md | 53 + packages/code/README.md | 24 + packages/code/package.json | 5 + packages/code/src/index.ts | 978 ++---------------- packages/code/src/lib/auto-review-loop.ts | 8 +- packages/code/src/lib/clarity-check.ts | 392 +++++++ packages/code/src/lib/errors.ts | 15 + packages/code/src/lib/pipeline/config.ts | 106 ++ packages/code/src/lib/pipeline/index.ts | 52 + packages/code/src/lib/pipeline/pipeline.ts | 138 +++ packages/code/src/lib/pipeline/registry.ts | 77 ++ .../lib/pipeline/steps/auto-review-step.ts | 130 +++ .../src/lib/pipeline/steps/clarity-step.ts | 56 + .../src/lib/pipeline/steps/commit-step.ts | 85 ++ .../src/lib/pipeline/steps/finalize-step.ts | 190 ++++ .../src/lib/pipeline/steps/hook-helpers.ts | 215 ++++ .../src/lib/pipeline/steps/implement-step.ts | 344 ++++++ .../src/lib/pipeline/steps/plan-detection.ts | 102 ++ .../src/lib/pipeline/steps/verify-step.ts | 219 ++++ packages/code/src/lib/pipeline/types.ts | 151 +++ packages/code/src/lib/project-settings.ts | 149 +++ packages/code/src/types/settings.ts | 31 + packages/code/tests/pipeline-config.test.ts | 82 ++ packages/code/tests/pipeline-plugins.test.ts | 141 +++ packages/code/tests/pipeline-runner.test.ts | 264 +++++ packages/code/tests/verify-step.test.ts | 194 ++++ 28 files changed, 3379 insertions(+), 894 deletions(-) create mode 100644 packages/code/src/lib/clarity-check.ts create mode 100644 packages/code/src/lib/errors.ts create mode 100644 packages/code/src/lib/pipeline/config.ts create mode 100644 packages/code/src/lib/pipeline/index.ts create mode 100644 packages/code/src/lib/pipeline/pipeline.ts create mode 100644 packages/code/src/lib/pipeline/registry.ts create mode 100644 packages/code/src/lib/pipeline/steps/auto-review-step.ts create mode 100644 packages/code/src/lib/pipeline/steps/clarity-step.ts create mode 100644 packages/code/src/lib/pipeline/steps/commit-step.ts create mode 100644 packages/code/src/lib/pipeline/steps/finalize-step.ts create mode 100644 packages/code/src/lib/pipeline/steps/hook-helpers.ts create mode 100644 packages/code/src/lib/pipeline/steps/implement-step.ts create mode 100644 packages/code/src/lib/pipeline/steps/plan-detection.ts create mode 100644 packages/code/src/lib/pipeline/steps/verify-step.ts create mode 100644 packages/code/src/lib/pipeline/types.ts create mode 100644 packages/code/src/lib/project-settings.ts create mode 100644 packages/code/tests/pipeline-config.test.ts create mode 100644 packages/code/tests/pipeline-plugins.test.ts create mode 100644 packages/code/tests/pipeline-runner.test.ts create mode 100644 packages/code/tests/verify-step.test.ts diff --git a/AGENTS.md b/AGENTS.md index 59d5e80..4543c3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ bun run --filter @getdevintern/pm build ### `@getdevintern/code` - Entry: `src/index.ts` +- Public plugin API: `@getdevintern/code/pipeline` (implemented under `src/lib/pipeline/`); `src/**/*` is included in the published package for this subpath export - Tests: `bun test` (Bun native test runner in `tests/`) - Build: `bun run build.ts`: bundles with `Bun.build`, then replaces shebang from `node` to `bun` in `dist/index.js` - Run locally: `bun start TASK-123` diff --git a/docs/code/configuration.md b/docs/code/configuration.md index 56d3f60..74ee351 100644 --- a/docs/code/configuration.md +++ b/docs/code/configuration.md @@ -4,7 +4,7 @@ sidebarLabel: "Configuration" description: "Environment variables, settings.json, tracker credentials, and agent harness options for @devintern/code." section: "Code" order: 2 -dateModified: 2026-08-08 +dateModified: 2026-08-12 --- # @devintern/code Configuration @@ -209,6 +209,75 @@ The active tracker is read from the `TASK_TRACKER` environment variable (default } ``` +## Pipeline Customization + +The task workflow is built from pluggable pipeline steps. By default @devintern/code runs: implement, commit, auto-review (when `--auto-review` is set), and finalize (push, comment, PR, status transition). You can reorder steps, add extra checks, or plug in your own steps via the `pipeline` section of `.devintern-code/settings.json`. + +```json +{ + "pipeline": { + "steps": [ + { "use": "implement" }, + { "use": "commit" }, + { "use": "verify", "onFail": "loopback", "minSeverity": "high", "maxIterations": 3 }, + { "use": "auto-review" }, + { "use": "finalize" } + ] + } +} +``` + +**Built-in steps:** `clarity`, `implement`, `commit`, `auto-review`, `verify`, `finalize`. + +### The verify step + +`verify` is an agent-backed requirements checker: it reads the task and the committed diff, asks the agent for a structured verdict, and acts on the result. It is not part of the default pipeline; add it when you want an extra gate. Options: + +- `prompt`: custom verification instructions (inline text or a path to a prompt file) +- `onFail`: `"loopback"` (default, feed findings back to the implement step and re-verify), `"halt"` (stop and mark the task incomplete), or `"warn"` (record a warning and continue) +- `minSeverity`: findings at or above this priority fail the verdict (default `"high"`) +- `maxIterations`: bound for the loopback cycle (default `3`) + +You can add several `verify` entries with different prompts, for example one for functional requirements and one for security review. + +### Custom step plugins + +For logic that config alone cannot express, write a step module that default-exports a step definition and list it under `pipeline.plugins`. Entries are file paths (resolved against your project root) or npm package names; no rebuild of @devintern/code is required. + +```json +{ + "pipeline": { + "plugins": ["./.devintern-code/steps/my-lint.ts"], + "steps": [ + { "use": "implement" }, + { "use": "commit" }, + { "use": "my-lint", "threshold": 0.9 }, + { "use": "finalize" } + ] + } +} +``` + +```ts +// .devintern-code/steps/my-lint.ts +import type { StepDefinition } from "@getdevintern/code/pipeline"; + +const definition: StepDefinition = { + name: "my-lint", + create: (config) => ({ + name: "my-lint", + async run(ctx) { + // run checks against ctx.workingDir ... + return { status: "continue" }; + }, + }), +}; + +export default definition; +``` + +Steps return one of four statuses: `continue`, `warn` (record a warning and continue), `halt` (stop; by default the task is reverted to To Do with an incomplete-implementation comment), or `loopback` (jump back to an earlier step with structured findings). Throw `StepExecutionError` for transient failures you want retried. + ## Verbose API Logging To enable detailed API call logging for debugging, set the `DEVINTERN_VERBOSE` environment variable: diff --git a/packages/code/CLAUDE.md b/packages/code/CLAUDE.md index 3d8a5f9..5a80456 100644 --- a/packages/code/CLAUDE.md +++ b/packages/code/CLAUDE.md @@ -8,6 +8,27 @@ This file provides guidance to Claude Code when working with this repository. ## Architecture +### Core Components + +- **[src/index.ts](src/index.ts)** - Main entry, CLI parsing, orchestrates workflow: fetch → format → git → claude → commit → PR +- **[src/lib/task-tracker-client.ts](src/lib/task-tracker-client.ts)** - Interface for all task tracker clients (JIRA, Linear, Trello, etc.) +- **[src/lib/task-tracker-manager.ts](src/lib/task-tracker-manager.ts)** - Factory that resolves the concrete tracker from the `TASK_TRACKER` environment variable (defaults to JIRA) +- **[src/lib/trackers/jira/jira-task-tracker-client.ts](src/lib/trackers/jira/jira-task-tracker-client.ts)** - JIRA implementation of `TaskTrackerClient`; delegates HTTP to `JiraClient` and issue parsing to `@devintern/task-trackers` +- **[src/lib/trackers/jira/jira-formatter.ts](src/lib/trackers/jira/jira-formatter.ts)** - JIRA-specific ADF comment formatting for @devintern/code automation +- **[src/lib/task-formatter.ts](src/lib/task-formatter.ts)** - Formats task tracker data (ADF/HTML → Markdown) for LLM prompts +- **[src/lib/utils.ts](src/lib/utils.ts)** - Git operations, file handling utilities +- **[src/lib/github-reviews.ts](src/lib/github-reviews.ts)** - GitHub API client for PR reviews +- **[src/lib/review-formatter.ts](src/lib/review-formatter.ts)** - Formats PR review feedback for Claude +- **[src/lib/address-review.ts](src/lib/address-review.ts)** - Handles PR review responses +- **[src/lib/auto-review-loop.ts](src/lib/auto-review-loop.ts)** - Automatic PR self-review and improvement loop; exports the `runAgentPrompt` / `parseReviewFeedback` / `filterByPriority` / `getPRDiff` primitives reused by pipeline steps +- **[src/lib/pipeline/](src/lib/pipeline/)** - Extensible task pipeline (types, registry, runner, config, built-in steps); public plugin API via the `@getdevintern/code/pipeline` subpath export +- **[src/lib/project-settings.ts](src/lib/project-settings.ts)** - settings.json loading + per-project status resolution (extracted from index.ts so steps avoid an import cycle) +- **[src/lib/clarity-check.ts](src/lib/clarity-check.ts)** - Pre-implementation feasibility assessment (`runClarityCheck`) +- **[src/lib/errors.ts](src/lib/errors.ts)** - `UsageLimitError` (aborts a batch; re-thrown by the pipeline runner, never retried) +- **[src/webhook-server.ts](src/webhook-server.ts)** - Webhook server for automated PR review handling +- **[src/types/](src/types/)** - TypeScript interfaces + - `task-tracker.ts` - Platform-agnostic domain types (`Task`, `Comment`, `FormattedTaskDetails`, etc.) + - `jira.ts` - JIRA-specific type aliases (re-exports generic types for backward compatibility) ### Key Workflows **JIRA Task Processing:** @@ -22,6 +43,38 @@ This file provides guidance to Claude Code when working with this repository. 1. Webhook receives review → 2. Check bot mention → 3. Queue review → 4. Switch worktree to PR branch → 5. Fetch comments → 6. Run Claude → 7. Commit fixes → 8. Push & reply +### Pipeline & Steps + +Task execution (everything after the `processSingleTask` preamble: fetch → clarity check → branch → In-Progress transition) runs through an ordered pipeline of steps sharing one mutable `TaskContext` (`src/lib/pipeline/`). + +**Default pipeline** (used when `settings.pipeline` is absent; reproduces the classic flow): + +``` +implement → commit → auto-review → finalize +``` + +- `implement` — runs the agent (`runImplementation`); consumes `ctx.loopbackFeedback` / `ctx.pendingPromptOverride` as prompt overrides +- `commit` — commit with git-hook auto-fix retries; detects plan-only output and loops back to `implement` once with a "now implement the plan" prompt +- `auto-review` — self-gates on `--auto-review`; validates pre-push hook, runs `runAutoReviewLoop({ skipPush: true })`, re-validates +- `finalize` — hook validation (if not already done) → push → tracker comment → PR creation → status transition +- `clarity` and `verify` are registered but **not** in the default list. The preamble clarity check in `processSingleTask` still runs before branch creation; the `clarity` step exists for custom pipelines. `verify` is the opt-in requirements checker. + +**Commit ordering matters:** `commit` must run before `auto-review`/`verify` because both diff `origin/...HEAD`; uncommitted changes would be invisible. + +**Failure model (two channels):** + +- Execution errors (subprocess crash, unparseable verdict JSON) — steps **throw** `StepExecutionError`; the runner retries the step (default 1 retry) then halts. +- Verdict failures (requirements genuinely unmet) — steps **return** `status: "loopback"` with `ReviewFeedback`; the runner jumps back to `loopbackTo` (default `implement`), bounded by `maxLoopbacks`, then halts. +- `Halt` with `haltKind: "incomplete"` (default) triggers the `onHalt` callback (incomplete-implementation comment + revert to To Do); `haltKind: "stop"` stops quietly (e.g. unfixable pre-push hook). +- `UsageLimitError` is always re-thrown so a batch aborts (never retried). + +**User extensibility (two tiers), via `settings.pipeline`:** + +1. Declarative: `pipeline.steps: [{ "use": "verify", "onFail": "loopback", "minSeverity": "high", "maxIterations": 3 }, ...]` — any number of `verify` instances with different `prompt`/`onFail`/`minSeverity`. +2. Code plugins: `pipeline.plugins: ["./.devintern-code/steps/my-step.ts", "@org/pkg"]` — each module default-exports a `StepDefinition`; loaded via dynamic `import()` before step resolution, registered in the same registry as built-ins (name collisions error out). Typed API surface: `@getdevintern/code/pipeline` (exports live at `src/lib/pipeline/index.ts`; `src/**` ships in the npm tarball for this reason). + +`runAgentHarness` in `src/index.ts` remains as a thin back-compat shim: it builds the `TaskContext`, loads plugins, resolves the pipeline (default when unconfigured), and runs it — preserving the old contract (resolves for normal/incomplete/max-turns so batches continue; rejects on timeout, non-zero exit, and `UsageLimitError`). + ### Configuration **Environment Variables (.devintern-code/.env):** diff --git a/packages/code/README.md b/packages/code/README.md index d63aafe..3c4eaaf 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -48,6 +48,30 @@ Full docs: **[devintern.com/docs/code](https://devintern.com/docs/code/quick-sta Source monorepo: [getdevintern/devintern](https://github.com/getdevintern/devintern) +## Extensible pipeline + +The task workflow is an ordered pipeline of pluggable steps (default: `implement` → `commit` → `auto-review` → `finalize`). Customize it in `.devintern-code/settings.json`: + +```json +{ + "pipeline": { + "plugins": ["./.devintern-code/steps/my-step.ts"], + "steps": [ + { "use": "implement" }, + { "use": "commit" }, + { "use": "verify", "onFail": "loopback", "minSeverity": "high" }, + { "use": "my-step" }, + { "use": "finalize" } + ] + } +} +``` + +- **Declarative (no code):** add the built-in `verify` step — an agent-backed requirements checker that feeds findings back to the implementer (`onFail: "loopback"`), halts, or warns. Multiple instances with different prompts are supported. +- **Code plugins:** a plugin module default-exports a `StepDefinition` (typed API from `@getdevintern/code/pipeline`) and is loaded at startup from a file path or npm package name — no rebuild required. + +See the [Configuration guide](https://devintern.com/docs/code/configuration) for details. + ## License [FSL-1.1-Apache-2.0](./LICENSE.md). Interactive use free forever; unattended automation requires a license — see [pricing](https://devintern.com/pricing/). diff --git a/packages/code/package.json b/packages/code/package.json index 689ef10..3fbfc50 100644 --- a/packages/code/package.json +++ b/packages/code/package.json @@ -4,6 +4,10 @@ "description": "Turn tracker tickets into pull requests with any coding agent. Self-hosted, BYOK. Free interactive use.", "type": "module", "main": "dist/index.js", + "exports": { + ".": "./dist/index.js", + "./pipeline": "./src/lib/pipeline/index.ts" + }, "scripts": { "start": "bun run src/index.ts", "dev": "bun run src/index.ts", @@ -87,6 +91,7 @@ }, "files": [ "dist/**/*", + "src/**/*", "README.md", "LICENSE.md", ".env.example" diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 77b9f74..5b31e5a 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -27,7 +27,6 @@ import { buildPromptArgs, detectIncompleteImplementation, detectMaxTurnsReached, - detectOpenQuestions, detectSandboxProviders, detectUsageLimit, isConstrainedMode, @@ -67,14 +66,14 @@ import { parseGitHubIssueReference } from "./lib/trackers/github/github-task-tra import { parseLinearIssueReference } from "./lib/trackers/linear/linear-task-tracker-client"; import { LockManager } from "./lib/lock-manager"; import { PRManager } from "./lib/pr-client"; -import { RunStore, beginRun, endRun, recordRunPr, recordRunStage } from "./lib/run-recorder"; +import { RunStore, beginRun, endRun, recordRunStage } from "./lib/run-recorder"; import { clearRetryState, getRetryState, recordIncompleteAttempt } from "./lib/retry-state"; import { shouldSkipRetry } from "./lib/retry-gate"; -import { parseGitHubPrUrl, recordAgentPrFromUrl } from "./lib/worker-state"; import { Utils } from "./lib/utils"; -import { isCommitAlreadyComplete, runAgentHarnessToFixGitHook } from "./lib/git-hook-fixer"; -import { runAutoReviewLoop } from "./lib/auto-review-loop"; import { isAutomatedEnvironment } from "./lib/env-detector"; +import { UsageLimitError } from "./lib/errors"; +import { loadPlugins, Pipeline, resolvePipelineSteps } from "./lib/pipeline"; +import type { TaskContext } from "./lib/pipeline"; import type { BaseProjectConfig, ProjectSettings, TrackerSection } from "./types/settings"; // Version is injected at build time via --define flag, or read from package.json in dev @@ -1516,18 +1515,6 @@ function validateEnvironment(): void { } } -/** - * Thrown when the agent hits an account-wide usage/rate limit. Since every - * remaining task in a batch would fail identically, callers abort the batch - * rather than retrying immediately. - */ -class UsageLimitError extends Error { - constructor(public readonly resetHint?: string) { - super(`Agent usage limit reached${resetHint ? ` (resets ${resetHint})` : ""}`); - this.name = "UsageLimitError"; - } -} - /** * Run the full implementation workflow for one JIRA task key. * @@ -3305,6 +3292,49 @@ Now implement the solution. Write the actual code.`; * @param autoReviewIterations - Max auto-review iterations * @param isPlanRetry - Whether this run follows a plan-only retry */ +/** Handle a pipeline halt for an incomplete implementation. */ +async function handleIncompleteHalt(ctx: TaskContext): Promise { + const { tracker, taskKey, task } = ctx; + + if (tracker && !ctx.skipComments && taskKey && ctx.implementationOutput.trim()) { + try { + await tracker.postIncompleteImplementationComment( + taskKey, + ctx.implementationOutput, + ctx.taskSummary, + ); + if (task) { + recordIncompleteAttempt( + taskKey, + process.env.TASK_TRACKER || "jira", + tracker.extractDescriptionText(task), + ); + } + } catch (commentError) { + console.warn(`⚠️ Failed to post incomplete implementation comment: ${commentError}`); + } + } + + if (tracker && !ctx.skipComments && taskKey && ctx.projectSettings) { + const projectKey = resolveProjectKey(taskKey, task); + const todoStatus = getTodoStatusForProject(projectKey, ctx.projectSettings); + if (todoStatus?.trim()) { + try { + console.log( + `\n🔄 Moving ${taskKey} back to '${todoStatus}' due to incomplete implementation...`, + ); + await tracker.transitionStatus(taskKey, todoStatus.trim()); + console.log(`✅ Task moved to '${todoStatus}'`); + } catch (statusError) { + console.warn( + `⚠️ Failed to transition task to '${todoStatus}': ${(statusError as Error).message}`, + ); + } + } + } +} + +/** Run the configured task pipeline for a formatted task. */ async function runAgentHarness( taskFile: string, harness: AgentHarness, @@ -3325,881 +3355,51 @@ async function runAgentHarness( autoReviewIterations = 5, isPlanRetry = false, ): Promise { - // Wait out any in-progress CLI auto-update swap before spawning, so a - // transient `spawn ENOENT` doesn't abort the run. - const resolvedPath = await resolveExecutablePathWithRetry(executablePath, { - displayName: harness.displayName, - }); - - return new Promise((resolve, reject) => { - (async () => { - // Check if task file exists - if (!existsSync(taskFile)) { - reject(new Error(`Task file not found: ${taskFile}`)); - return; - } - - // Load project settings - const projectSettings = loadProjectSettings(); - - // Read the task content - const taskContent = readFileSync(taskFile, "utf8"); - - const timeoutMinutes = parseInt(process.env.AGENT_HARNESS_TIMEOUT_MINUTES || "60", 10); - - const agentArgs = harness.buildArgs({ - maxTurns, - skipPermissions: true, - workingDir: process.cwd(), - }); - console.log(`🚀 Launching ${harness.displayName}...`); - console.log(` Command: ${executablePath} ${agentArgs.join(" ")} --verbose`); - console.log(` Input: ${taskFile}`); - console.log(` Timeout: ${timeoutMinutes} minutes`); - console.log( - ` Output: All ${harness.displayName} output will be displayed below in real-time`, - ); - console.log("\n" + "=".repeat(60)); - - // Capture stderr to detect max turns error and stdout for JIRA comment - let stderrOutput = ""; - let stdoutOutput = ""; - let timedOut = false; - - // Spawn agent process with enhanced permissions and max turns - const { child: codeAgent, cleanup: sandboxCleanup } = await spawnAgent({ - resolvedPath, - args: [...agentArgs, ...buildPromptArgs(harness, taskContent)], - spawnOptions: { stdio: ["ignore", "pipe", "pipe"] }, - sandbox: await getSandbox(harness.name), - }); - - const timeout = setTimeout( - () => { - timedOut = true; - console.error( - `\n⏰ ${harness.displayName} process timed out after ${timeoutMinutes} minutes, killing...`, - ); - reapTree(codeAgent, "SIGTERM"); - setTimeout(() => { - if (!codeAgent.killed) { - reapTree(codeAgent, "SIGKILL"); - } - sandboxCleanup().catch(() => {}); - }, 10_000); - }, - timeoutMinutes * 60 * 1000, - ); - - // Capture and display stdout output - if (codeAgent.stdout) { - codeAgent.stdout.on("data", (data: Buffer) => { - const output = data.toString(); - stdoutOutput += output; - process.stdout.write(output); - }); - } - - // Capture stderr output for error detection while ensuring it's visible to user - if (codeAgent.stderr) { - codeAgent.stderr.on("data", (data: Buffer) => { - const output = data.toString(); - stderrOutput += output; - process.stderr.write(output); - }); - } - - // Handle errors - codeAgent.on("error", (error: NodeJS.ErrnoException) => { - clearTimeout(timeout); - if (error.code === "ENOENT") { - reject( - new Error( - `${harness.displayName} CLI not found at: ${executablePath}\nPlease install ${harness.displayName} or specify the correct path with --agent-path`, - ), - ); - } else { - reject(new Error(`Failed to run ${harness.displayName}: ${error.message}`)); - } - }); - - // Handle process exit - codeAgent.on("close", async (code: number | null) => { - clearTimeout(timeout); - sandboxCleanup().catch(() => {}); - console.log("\n" + "=".repeat(60)); - - if (timedOut) { - console.log(`⏰ ${harness.displayName} timed out after ${timeoutMinutes} minutes`); - reject(new Error(`${harness.displayName} timed out after ${timeoutMinutes} minutes`)); - return; - } - - // A usage/rate limit is account-global — abort the batch rather than - // treating this task as a normal failure (every other task would fail too). - const usage = detectUsageLimit(stdoutOutput, stderrOutput); - if (usage.limited) { - console.log( - `\n⏳ ${harness.displayName} hit a usage limit${ - usage.resetsAt ? ` (resets ${usage.resetsAt})` : "" - }`, - ); - reject(new UsageLimitError(usage.resetsAt)); - return; - } - - const maxTurnsReached = detectMaxTurnsReached(stdoutOutput, stderrOutput); - - if (maxTurnsReached) { - console.log("⚠️ Agent reached maximum turns limit without completing the task"); - console.log(" The task may be too complex or require more turns to complete"); - console.log( - " Consider breaking it into smaller tasks or increasing the max-turns limit", - ); - - // Save incomplete implementation for analysis - if (taskKey && stdoutOutput.trim()) { - try { - const baseOutputDir = resolveOutputDir(); - const taskDir = join(baseOutputDir, taskKey.toLowerCase()); - const summaryFile = join(taskDir, "implementation-summary-incomplete.md"); - - writeFileSync(summaryFile, stdoutOutput, "utf8"); - console.log(`\n💾 Saved incomplete implementation to: ${summaryFile}`); - - // Post incomplete implementation comment (no duplicate check here - // since we already skip tasks with existing incomplete comments) - if (tracker && !skipComments && task) { - try { - await tracker.postIncompleteImplementationComment( - taskKey, - stdoutOutput, - taskSummary, - ); - recordIncompleteAttempt( - taskKey, - process.env.TASK_TRACKER || "jira", - tracker.extractDescriptionText(task), - ); - } catch (commentError) { - console.warn( - `⚠️ Failed to post incomplete implementation comment to JIRA: ${commentError}`, - ); - } - } - - // Transition back to "To Do" status if configured - if (tracker && !skipComments && taskKey && projectSettings) { - const projectKey = resolveProjectKey(taskKey, task); - const todoStatus = getTodoStatusForProject(projectKey, projectSettings); - if (todoStatus && todoStatus.trim()) { - try { - console.log( - `\n🔄 Moving ${taskKey} back to '${todoStatus}' due to max turns reached...`, - ); - await tracker.transitionStatus(taskKey, todoStatus.trim()); - console.log(`✅ Task moved to '${todoStatus}'`); - } catch (statusError) { - console.warn( - `⚠️ Failed to transition task to '${todoStatus}': ${ - (statusError as Error).message - }`, - ); - } - } - } - } catch (saveError) { - console.warn(`⚠️ Failed to save implementation summary: ${saveError}`); - } - } - - console.log("\n⏭️ Skipping commit and moving to next task (if any)..."); - - // Resolve instead of reject to allow batch processing to continue - resolve(); - return; - } - - if (code === 0) { - // Even if exit code is 0, check if Agent actually completed meaningful work. - // Only inspect stdout: stderr often contains transient "Error:" lines from - // recovered tool failures (especially with Cursor CLI). - const { incomplete: seemsIncomplete, reasons: incompleteReasons } = - detectIncompleteImplementation(stdoutOutput); - - // Save implementation summary to task directory (even if incomplete for analysis) - if (taskKey && stdoutOutput.trim()) { - try { - const baseOutputDir = resolveOutputDir(); - const taskDir = join(baseOutputDir, taskKey.toLowerCase()); - const summaryFile = join( - taskDir, - seemsIncomplete - ? "implementation-summary-incomplete.md" - : "implementation-summary.md", - ); - - writeFileSync(summaryFile, stdoutOutput, "utf8"); - console.log(`\n💾 Saved implementation summary to: ${summaryFile}`); - } catch (saveError) { - console.warn(`⚠️ Failed to save implementation summary: ${saveError}`); - } - } - - if (seemsIncomplete) { - console.log("⚠️ Agent execution completed but appears to be incomplete or failed"); - console.log(` Reasons: ${incompleteReasons.join("; ")}`); - console.log(" Check the output above for specific issues"); - console.log("\n⏭️ Skipping commit and moving to next task (if any)..."); - - // Post incomplete implementation comment (no duplicate check here - // since we already skip tasks with existing incomplete comments) - if (tracker && !skipComments && taskKey && stdoutOutput.trim() && task) { - try { - await tracker.postIncompleteImplementationComment( - taskKey, - stdoutOutput, - taskSummary, - ); - recordIncompleteAttempt( - taskKey, - process.env.TASK_TRACKER || "jira", - tracker.extractDescriptionText(task), - ); - } catch (commentError) { - console.warn( - `⚠️ Failed to post incomplete implementation comment to JIRA: ${commentError}`, - ); - } - } - - // Transition back to "To Do" status if configured - if (tracker && !skipComments && taskKey && projectSettings) { - const projectKey = resolveProjectKey(taskKey, task); - const todoStatus = getTodoStatusForProject(projectKey, projectSettings); - if (todoStatus && todoStatus.trim()) { - try { - console.log( - `\n🔄 Moving ${taskKey} back to '${todoStatus}' due to incomplete implementation...`, - ); - await tracker.transitionStatus(taskKey, todoStatus.trim()); - console.log(`✅ Task moved to '${todoStatus}'`); - } catch (statusError) { - console.warn( - `⚠️ Failed to transition task to '${todoStatus}': ${ - (statusError as Error).message - }`, - ); - } - } - } - - // Don't commit or continue processing when implementation is incomplete - // Just resolve to allow batch processing to continue - resolve(); - return; - } else { - console.log("✅ Agent execution completed successfully"); - } - - // Agent finished by asking the user questions instead of implementing. - // Committing here would ship an answer nobody gave, so surface the - // questions and stop before the git/PR flow. - const openQuestions = detectOpenQuestions(stdoutOutput); - if (openQuestions.awaitingInput) { - console.log("\n⏸️ Agent is asking questions and needs your input before proceeding:"); - for (const question of openQuestions.questions) { - console.log(` • ${question}`); - } - - if (tracker && !skipComments && taskKey) { - try { - const questionList = openQuestions.questions.map((q) => `- ${q}`).join("\n"); - await tracker.postComment(taskKey, { - format: "markdown", - body: - `🤖 The agent needs input before it can implement this task:\n\n${questionList}\n\n` + - `Answer in the task description or a comment, then re-run devintern.`, - }); - console.log("💬 Posted the questions as a comment on the task"); - } catch (commentError) { - console.warn(`⚠️ Failed to post questions comment: ${commentError}`); - } - } - - console.log("\n⏭️ Skipping commit and PR until the questions are answered..."); - resolve(); - return; - } - - // --- Shared helpers for hook validation, push, and PR creation --- - const validatePrePushHook = async (phase: string) => { - let attempt = 0; - while (attempt <= hookRetries) { - attempt++; - const hookResult = await Utils.runPrePushHookLocally({ - verbose: options.verbose, - }); - if (hookResult.success) { - if (attempt === 1) { - console.log(`✅ ${hookResult.message}`); - } else { - console.log(`✅ Pre-push hook passed after ${attempt} attempt(s)`); - } - return { success: true, result: hookResult }; - } - if (hookResult.hookError && attempt <= hookRetries) { - console.log( - `\n⚠️ Pre-push hook failed during ${phase} (attempt ${attempt}/${hookRetries + 1})`, - ); - const fixed = await runAgentHarnessToFixGitHook( - "push", - harness, - executablePath, - maxTurns, - ); - logHookErrorToFile( - taskKey ?? "unknown", - "push-local-validation", - attempt, - hookResult.hookError, - fixed, - ); - if (fixed) { - console.log( - `\n🔄 Retrying local hook validation after ${harness.displayName} fixed the issues...`, - ); - continue; - } else { - console.log("\n❌ Could not fix pre-push hook errors automatically"); - return { success: false, result: hookResult }; - } - } else { - if (attempt > hookRetries) { - console.log(`\n❌ Max retries (${hookRetries}) exceeded for pre-push hook fixes`); - } - console.log(`⚠️ ${hookResult.message}`); - return { success: false, result: hookResult }; - } - } - return { - success: false, - result: { message: "Max retries exceeded" }, - }; - }; - - const pushWithHookRetry = async () => { - console.log("\n📤 Pushing branch to remote..."); - let attempt = 0; - while (attempt <= hookRetries) { - attempt++; - const pushResult = await Utils.pushCurrentBranch({ - verbose: options.verbose, - }); - if (pushResult.success) { - console.log(`✅ ${pushResult.message}`); - return { success: true, result: pushResult }; - } - if (pushResult.hookError && attempt <= hookRetries) { - console.log( - `\n⚠️ Git pre-push hook failed during push (attempt ${attempt}/${hookRetries + 1})`, - ); - const fixed = await runAgentHarnessToFixGitHook( - "push", - harness, - executablePath, - maxTurns, - ); - logHookErrorToFile( - taskKey ?? "unknown", - "push", - attempt, - pushResult.hookError, - fixed, - ); - if (fixed) { - console.log( - `\n🔄 Retrying push after ${harness.displayName} fixed and amended the commit...`, - ); - continue; - } else { - console.log("\n❌ Could not fix git pre-push hook errors automatically"); - return { success: false, result: pushResult }; - } - } else { - if (attempt > hookRetries) { - console.log(`\n❌ Max retries (${hookRetries}) exceeded for git hook fixes`); - } - console.log(`⚠️ ${pushResult.message}`); - return { success: false, result: pushResult }; - } - } - return { - success: false, - result: { message: "Max retries exceeded" }, - }; - }; - - const createPrAndTransition = async ( - implementationOutput: string, - autoReviewRan = false, - ) => { - console.log("\n🔀 Creating pull request..."); - try { - const prManager = new PRManager(); - const branchForPr = await Utils.getCurrentBranch(); - - if (!branchForPr) { - console.log("⚠️ Could not determine current branch for PR creation"); - return; - } - if (await Utils.isProtectedBranch(branchForPr)) { - console.error(`\n❌ Cannot create PR from protected branch '${branchForPr}'`); - console.error(" This indicates a bug - feature branch was not created properly."); - return; - } - - // Ensure the PR target branch actually exists on the remote. A wrong or - // missing target (e.g. `--pr-target-branch main` on a `master` repo) makes - // GitHub reject the PR with "Validation Failed", leaving a pushed branch - // and no PR. Fall back to the repo's real default branch in that case. - let effectivePrTargetBranch = prTargetBranch; - if (!(await Utils.remoteBranchExists(prTargetBranch, { verbose: options.verbose }))) { - const defaultBranch = await Utils.getMainBranchName(); - if (defaultBranch !== prTargetBranch) { - console.log( - `⚠️ Target branch '${prTargetBranch}' not found on remote, falling back to '${defaultBranch}'`, - ); - effectivePrTargetBranch = defaultBranch; - } - } - - const prResult = await prManager.createPullRequest( - task, - branchForPr, - effectivePrTargetBranch, - implementationOutput, - ); - - if (prResult.success) { - console.log(`✅ Pull request created: ${prResult.url}`); - - // Register the PR so worker review-polling watches it automatically. - if (prResult.url) { - recordAgentPrFromUrl(prResult.url, branchForPr, taskKey); - recordRunPr({ ...parseGitHubPrUrl(prResult.url), url: prResult.url }); - } - - if (taskKey && tracker && !skipComments) { - const projectKey = resolveProjectKey(taskKey, task); - const prStatus = getPrStatusForProject(projectKey, projectSettings); - if (prStatus && prStatus.trim()) { - try { - console.log("\n🔄 Transitioning JIRA status after PR creation..."); - await tracker.transitionStatus(taskKey, prStatus.trim()); - } catch (statusError) { - console.warn( - `⚠️ Failed to transition JIRA status: ${(statusError as Error).message}`, - ); - console.log(" PR was created successfully, but status transition failed"); - } - } - } else if (skipComments) { - console.log("\n⏭️ Skipping task tracker status transition (--skip-comments)"); - } - - if (autoReviewRan) { - console.log( - "\n✅ Auto-review was completed before push (see summary file for details)", - ); - } - } else { - console.log(`⚠️ PR creation failed: ${prResult.message}`); - } - } catch (prError) { - console.log(`⚠️ PR creation failed: ${(prError as Error).message}`); - } - }; - // --- End shared helpers --- - - // Commit changes if git is enabled and we have task details - if (enableGit && taskKey && taskSummary) { - console.log("\n📝 Committing changes..."); - - // Try committing with retry logic for git hook failures - const handleCommitWithRetry = async () => { - let attempt = 0; - - while (attempt <= hookRetries) { - attempt++; - const commitResult = await Utils.commitChanges(taskKey, taskSummary, { - verbose: options.verbose, - author: gitAuthor, - }); - - if (commitResult.success) { - console.log(`✅ ${commitResult.message}`); - return { success: true, result: commitResult }; - } - - // Check if this is a git hook error that we can try to fix - if (commitResult.hookError && attempt <= hookRetries) { - console.log(`\n⚠️ Git hook failed (attempt ${attempt}/${hookRetries + 1})`); - - // Try to fix the hook error with agent - const fixed = await runAgentHarnessToFixGitHook( - "commit", - harness, - executablePath, - maxTurns, - ); - - // Log the hook error to file - logHookErrorToFile(taskKey, "commit", attempt, commitResult.hookError, fixed); - - if (fixed) { - if (await isCommitAlreadyComplete()) { - console.log("✅ Commit already completed during hook fix"); - return { - success: true, - result: { - message: `Successfully committed changes for ${taskKey} (via hook fix)`, - }, - }; - } - - console.log("\n🔄 Retrying commit after Agent fixed the issues..."); - continue; - } else { - console.log("\n❌ Could not fix git hook errors automatically"); - return { success: false, result: commitResult }; - } - } else { - // Not a hook error or out of retries - if (attempt > hookRetries) { - console.log(`\n❌ Max retries (${hookRetries}) exceeded for git hook fixes`); - } - console.log(`⚠️ ${commitResult.message}`); - return { success: false, result: commitResult }; - } - } - - return { - success: false, - result: { message: "Max retries exceeded" }, - }; - }; - - handleCommitWithRetry() - .then(async ({ success, result }) => { - if (!success) { - // Check if this is a "plan only" scenario - Agent created a plan but didn't implement - const noChangesToCommit = result.message === "No changes to commit"; - const planPath = noChangesToCommit ? detectPlanOnlyBehavior(stdoutOutput) : null; - - if (noChangesToCommit && planPath && !isPlanRetry) { - // Agent only created a plan - run it again with instructions to implement - console.log( - "\n🔄 Agent created a plan but didn't implement it. Re-running to execute the plan...", - ); - - if (planPath !== "PLAN_DETECTED_NO_PATH") { - console.log(` Plan file detected: ${planPath}`); - } - - // Create a new prompt to implement the plan - const implementationPrompt = createPlanImplementationPrompt( - planPath, - taskContent, - ); - - // Spawn agent again with the implementation prompt. Re-resolve - // the CLI path here (rather than reusing the first spawn's) — a - // long agent run may straddle an auto-update, so wait out any - // swap in progress before this second spawn. - const retryArgs = harness.buildArgs({ - maxTurns, - skipPermissions: true, - workingDir: process.cwd(), - }); - const retryResolvedPath = await resolveExecutablePathWithRetry(executablePath, { - displayName: harness.displayName, - }); - const { child: retryProcess, cleanup: retrySandboxCleanup } = await spawnAgent({ - resolvedPath: retryResolvedPath, - args: [...retryArgs, ...buildPromptArgs(harness, implementationPrompt)], - spawnOptions: { stdio: ["ignore", "pipe", "pipe"] }, - sandbox: await getSandbox(harness.name), - }); - - let retryStdoutOutput = ""; - let retryStderrOutput = ""; - - if (retryProcess.stdout) { - retryProcess.stdout.on("data", (data: Buffer) => { - const output = data.toString(); - retryStdoutOutput += output; - process.stdout.write(output); - }); - } - - if (retryProcess.stderr) { - retryProcess.stderr.on("data", (data: Buffer) => { - const output = data.toString(); - retryStderrOutput += output; - process.stderr.write(output); - }); - } - - retryProcess.on("close", async (retryCode: number | null) => { - retrySandboxCleanup().catch(() => {}); - console.log("\n" + "=".repeat(60)); - - if (retryCode === 0) { - console.log("✅ Plan implementation completed"); - - // Save updated implementation summary - if (taskKey && retryStdoutOutput.trim()) { - try { - const summaryFile = join( - dirname(taskFile), - "implementation-summary.md", - ); - writeFileSync( - summaryFile, - `# Plan Implementation Output\n\n${retryStdoutOutput}`, - "utf8", - ); - console.log(`\n💾 Updated implementation summary: ${summaryFile}`); - } catch (saveError) { - console.warn(`⚠️ Failed to save implementation summary: ${saveError}`); - } - } - - // Try to commit the changes from plan implementation - console.log("\n📝 Committing plan implementation changes..."); - const retryCommitResult = await Utils.commitChanges(taskKey, taskSummary, { - verbose: options.verbose, - author: gitAuthor, - }); - - if (retryCommitResult.success) { - console.log(`✅ ${retryCommitResult.message}`); - - // Continue with PR creation if requested - if (createPr && task) { - // Validate pre-push hook locally BEFORE pushing - console.log( - "\n🔍 Validating pre-push hook locally (before pushing)...", - ); - const planHookValidation = await validatePrePushHook( - "plan implementation validation", - ); - if (!planHookValidation.success) { - console.log( - " Cannot proceed without passing pre-push hook validation", - ); - resolve(); - return; - } - - const planPushOutcome = await pushWithHookRetry(); - - if (planPushOutcome.success) { - if (tracker && !skipComments && retryStdoutOutput.trim()) { - try { - await postImplementationComment( - tracker, - taskKey, - retryStdoutOutput, - taskSummary, - ); - } catch (commentError) { - console.warn( - `⚠️ Failed to post implementation comment: ${commentError}`, - ); - } - } - - await createPrAndTransition(retryStdoutOutput); - } - } - } else { - console.log(`⚠️ ${retryCommitResult.message}`); - console.log( - 'You can commit changes manually with: git add . && git commit -m "feat: implement task"', - ); - } - } else { - console.log("⚠️ Plan implementation failed"); - } - - resolve(); - }); - - retryProcess.on("error", (error: Error) => { - retrySandboxCleanup().catch(() => {}); - console.error(`❌ Failed to re-run Agent: ${error.message}`); - resolve(); - }); - - return; - } - - console.log( - 'You can commit changes manually with: git add . && git commit -m "feat: implement task"', - ); - resolve(); - return; - } - - // Create pull request if requested - if (createPr && task) { - // Step 1: Validate pre-push hook locally BEFORE any push - console.log("\n🔍 Validating pre-push hook locally (before pushing)..."); - const initialHookValidation = await validatePrePushHook("initial validation"); - - if (!initialHookValidation.success) { - console.log(" Cannot proceed without passing pre-push hook validation"); - resolve(); - return; - } + if (!existsSync(taskFile)) { + throw new Error(`Task file not found: ${taskFile}`); + } - // Step 2: Run auto-review with skipPush if enabled - const currentBranch = await Utils.getCurrentBranch(); - let autoReviewRan = false; - - if (autoReview && currentBranch) { - try { - console.log("\n🔄 Running auto-review loop (without pushing)..."); - - const baseOutputDir = resolveOutputDir(); - const taskDir = taskKey - ? join(baseOutputDir, taskKey.toLowerCase()) - : join(baseOutputDir, `auto-review-${Date.now()}`); - - const autoReviewResult = await runAutoReviewLoop({ - repository: "local/repo", - prNumber: 0, - prBranch: currentBranch, - baseBranch: prTargetBranch, - harness, - executablePath, - maxIterations: autoReviewIterations, - minPriority: "medium", - workingDir: process.cwd(), - outputDir: taskDir, - skipPush: true, - }); - - const summaryPath = join(taskDir, "auto-review-summary.json"); - writeFileSync(summaryPath, JSON.stringify(autoReviewResult, null, 2)); - console.log(`\n📄 Auto-review summary saved to: ${summaryPath}`); - - recordRunStage("auto_review", { - status: autoReviewResult.success ? "succeeded" : "failed", - summary: `${autoReviewResult.iterations} iteration(s), ${ - autoReviewResult.success ? "approved" : "incomplete" - }`, - detail: { - iterations: autoReviewResult.iterations, - success: autoReviewResult.success, - finalFeedback: autoReviewResult.finalFeedback, - }, - }); - - autoReviewRan = true; - - // Step 3: After auto-review, validate hooks again - console.log( - "\n🔍 Re-validating pre-push hook after auto-review improvements...", - ); - const postAutoReviewValidation = await validatePrePushHook( - "post auto-review validation", - ); - - if (!postAutoReviewValidation.success) { - console.log( - " Cannot proceed - auto-review changes failed pre-push hook validation", - ); - resolve(); - return; - } - } catch (autoReviewError) { - recordRunStage("auto_review", { - status: "failed", - summary: `loop errored: ${(autoReviewError as Error).message}`, - }); - console.warn( - `\n⚠️ Auto-review loop failed: ${(autoReviewError as Error).message}`, - ); - console.log(" Continuing with push and PR creation..."); - } - } + const settings = projectSettings ?? loadProjectSettings(); + const taskContent = readFileSync(taskFile, "utf8"); + const taskDir = taskKey ? join(resolveOutputDir(), taskKey.toLowerCase()) : dirname(taskFile); + const ctx: TaskContext = { + taskKey, + taskSummary, + task, + taskFile, + taskContent, + taskDir, + workingDir: process.cwd(), + harness, + executablePath, + maxTurns, + tracker, + projectSettings: settings, + prTargetBranch, + hookRetries, + gitAuthor, + enableGit, + createPr, + skipComments, + autoReview, + autoReviewIterations, + skipClarityCheck: options.skipClarityCheck, + verbose: Boolean(options.verbose), + implementationOutput: "", + isPlanRetry, + commitSucceeded: false, + autoReviewRan: false, + hookValidated: false, + results: [], + warnings: [], + }; - // Step 4: Push with hook retry - const pushOutcome = await pushWithHookRetry(); - - if (pushOutcome.success) { - if (taskKey && tracker && stdoutOutput.trim() && !skipComments) { - try { - console.log("\n💬 Posting implementation summary to task tracker..."); - await postImplementationComment( - tracker, - taskKey, - stdoutOutput, - taskSummary, - ); - } catch (commentError) { - console.warn( - `⚠️ Failed to post implementation comment to task tracker: ${commentError}`, - ); - console.log(" Push succeeded, but task tracker comment failed"); - } - } else if (skipComments && taskKey) { - console.log("\n⏭️ Skipping task tracker comment posting (--skip-comments)"); - } - - await createPrAndTransition(stdoutOutput, autoReviewRan); - } else { - console.log(" Cannot create PR without pushing branch to remote"); - } - } else { - // No PR requested, but commit succeeded - post to task tracker here - if (taskKey && tracker && stdoutOutput.trim() && !skipComments) { - try { - console.log("\n💬 Posting implementation summary to task tracker..."); - await postImplementationComment(tracker, taskKey, stdoutOutput, taskSummary); - } catch (commentError) { - console.warn( - `⚠️ Failed to post implementation comment to task tracker: ${commentError}`, - ); - console.log(" Commit succeeded, but task tracker comment failed"); - } - } else if (skipComments && taskKey) { - console.log("\n⏭️ Skipping task tracker comment posting (--skip-comments)"); - } - } - resolve(); - }) - .catch((commitError) => { - console.log(`⚠️ Failed to commit changes: ${commitError.message}`); - console.log( - 'You can commit changes manually with: git add . && git commit -m "feat: implement task"', - ); - resolve(); // Still resolve since Agent succeeded - }); - } else { - resolve(); - } - } else { - console.log(`❌ Agent exited with non-zero code ${code}`); - console.log(" No JIRA comment will be posted due to execution failure"); - reject(new Error(`Agent exited with code ${code}`)); - } - }); - })().catch(reject); + const pipelineConfig = settings?.pipeline; + await loadPlugins(pipelineConfig?.plugins, process.cwd()); + const pipeline = new Pipeline(resolvePipelineSteps(pipelineConfig?.steps), { + onHalt: handleIncompleteHalt, }); + await pipeline.run(ctx); } // Handle uncaught errors diff --git a/packages/code/src/lib/auto-review-loop.ts b/packages/code/src/lib/auto-review-loop.ts index a716ec2..21ee756 100644 --- a/packages/code/src/lib/auto-review-loop.ts +++ b/packages/code/src/lib/auto-review-loop.ts @@ -159,7 +159,7 @@ ${commitInstruction} * @returns Unified diff from merge-base to HEAD * @throws When the remote base branch cannot be resolved or diff generation fails */ -function getPRDiff(baseBranch: string, workingDir: string): string { +export function getPRDiff(baseBranch: string, workingDir: string): string { // Strip origin/ prefix if present for fetch command const branchName = baseBranch.replace(/^origin\//, ""); const remoteBase = `origin/${branchName}`; @@ -279,7 +279,7 @@ function getPRDiff(baseBranch: string, workingDir: string): string { * @returns Validated {@link ReviewFeedback} object * @throws When no JSON is found or the structure is invalid */ -function parseReviewFeedback(agentOutput: string): ReviewFeedback { +export function parseReviewFeedback(agentOutput: string): ReviewFeedback { // Extract JSON from potential markdown code blocks const jsonMatch = agentOutput.match(/```json\s*([\s\S]*?)\s*```/) || agentOutput.match(/\{[\s\S]*\}/); @@ -318,7 +318,7 @@ function parseReviewFeedback(agentOutput: string): ReviewFeedback { * @returns Agent stdout on success * @throws When the agent times out or exits with a non-zero code */ -async function runAgentPrompt( +export async function runAgentPrompt( prompt: string, workingDir: string, harness: AgentHarness, @@ -407,7 +407,7 @@ async function runAgentPrompt( * @param minPriority - Minimum priority to include * @returns Items meeting the priority threshold */ -function filterByPriority( +export function filterByPriority( items: ReviewFeedbackItem[], minPriority: ReviewPriority, ): ReviewFeedbackItem[] { diff --git a/packages/code/src/lib/clarity-check.ts b/packages/code/src/lib/clarity-check.ts new file mode 100644 index 0000000..d1365bd --- /dev/null +++ b/packages/code/src/lib/clarity-check.ts @@ -0,0 +1,392 @@ +/** + * Task clarity / feasibility assessment. + * + * Extracted from src/index.ts so the pipeline `clarity` step can consume it + * without importing the CLI entrypoint (which would create an import cycle). + */ + +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { + buildPromptArgs, + detectMaxTurnsReached, + reapTree, + resolveExecutablePathWithRetry, + spawnAgent, +} from "@devintern/agent-harness"; +import type { AgentHarness } from "@devintern/agent-harness"; +import { getSandbox } from "./sandbox"; +import type { TaskTrackerClient } from "./task-tracker-client"; + +/** Structured result of the clarity / feasibility assessment. */ +export interface ClarityAssessment { + isImplementable: boolean; + clarityScore: number; + issues: Array<{ + category: string; + description: string; + severity: "critical" | "major" | "minor"; + }>; + recommendations: string[]; + summary: string; +} + +/** + * Run the pre-implementation clarity/feasibility assessment for a task. + * + * @param clarityFile - Path to the formatted clarity assessment prompt + * @param harness - Agent harness configuration + * @param executablePath - Agent CLI executable path + * @param taskKey - Task tracker issue key + * @param tracker - Task tracker client for posting assessment comments + * @param skipComments - Skip posting tracker comments + * @returns Parsed assessment, or `null` when the response could not be parsed + */ +export async function runClarityCheck( + clarityFile: string, + harness: AgentHarness, + executablePath: string, + taskKey: string, + tracker: TaskTrackerClient | undefined, + skipComments = false, +): Promise { + // Wait out any in-progress CLI auto-update swap before spawning, so a + // transient `spawn ENOENT` doesn't abort the clarity check. + const resolvedPath = await resolveExecutablePathWithRetry(executablePath, { + displayName: harness.displayName, + }); + + return new Promise((resolve, reject) => { + (async () => { + // Check if clarity file exists + if (!existsSync(clarityFile)) { + reject(new Error(`Clarity assessment file not found: ${clarityFile}`)); + return; + } + + // Read the clarity assessment content + const clarityContent = readFileSync(clarityFile, "utf8"); + + const timeoutMinutes = parseInt(process.env.AGENT_HARNESS_TIMEOUT_MINUTES || "60", 10); + + const clarityArgs = harness.buildArgs({ + maxTurns: 10, + skipPermissions: true, + workingDir: process.cwd(), + }); + console.log(`🔍 Running feasibility assessment with ${harness.displayName}...`); + console.log(` Command: ${executablePath} ${clarityArgs.join(" ")}`); + console.log(` Input: ${clarityFile}`); + + let stdoutOutput = ""; + let stderrOutput = ""; + let timedOut = false; + + // Spawn agent process for clarity check + const { child: clarityAgent, cleanup: sandboxCleanup } = await spawnAgent({ + resolvedPath, + args: [...clarityArgs, ...buildPromptArgs(harness, clarityContent)], + spawnOptions: { stdio: ["ignore", "pipe", "pipe"] }, + sandbox: await getSandbox(harness.name), + }); + + const timeout = setTimeout( + () => { + timedOut = true; + console.error( + `\n⏰ ${harness.displayName} process timed out after ${timeoutMinutes} minutes, killing...`, + ); + reapTree(clarityAgent, "SIGTERM"); + setTimeout(() => { + if (!clarityAgent.killed) { + reapTree(clarityAgent, "SIGKILL"); + } + }, 10_000); + }, + timeoutMinutes * 60 * 1000, + ); + + // Capture stdout for parsing JSON response + if (clarityAgent.stdout) { + clarityAgent.stdout.on("data", (data: Buffer) => { + stdoutOutput += data.toString(); + }); + } + + // Capture stderr for error handling + if (clarityAgent.stderr) { + clarityAgent.stderr.on("data", (data: Buffer) => { + stderrOutput += data.toString(); + }); + } + + // Handle errors + clarityAgent.on("error", (error: NodeJS.ErrnoException) => { + clearTimeout(timeout); + sandboxCleanup().catch(() => {}); + if (error.code === "ENOENT") { + reject( + new Error( + `${harness.displayName} CLI not found at: ${executablePath}\nPlease install ${harness.displayName} or specify the correct path with --agent-path`, + ), + ); + } else { + reject(new Error(`Failed to run ${harness.displayName} clarity check: ${error.message}`)); + } + }); + + // Handle process exit + clarityAgent.on("close", async (code: number | null) => { + clearTimeout(timeout); + sandboxCleanup().catch(() => {}); + if (timedOut) { + reject( + new Error( + `${harness.displayName} clarity check timed out after ${timeoutMinutes} minutes`, + ), + ); + return; + } + if (code === 0) { + try { + // Parse the JSON response from agent + const assessment = parseClarityResponse(stdoutOutput); + + // Save assessment results to task directory for debugging + try { + const baseOutputDir = process.env.DEVINTERN_OUTPUT_DIR || "/tmp/devintern-tasks"; + const taskDir = join(baseOutputDir, taskKey.toLowerCase()); + const assessmentResultFile = join(taskDir, "feasibility-assessment.md"); + + // Format assessment as readable markdown + let assessmentContent = `# Feasibility Assessment Results\n\n`; + assessmentContent += `**Status**: ${assessment.isImplementable ? "✅ Implementable" : "❌ Not Implementable"}\n`; + assessmentContent += `**Clarity Score**: ${assessment.clarityScore}/10\n\n`; + assessmentContent += `## Summary\n\n${assessment.summary}\n\n`; + + if (assessment.issues.length > 0) { + assessmentContent += `## Issues\n\n`; + assessment.issues.forEach((issue) => { + const severityIcon = + issue.severity === "critical" ? "🔴" : issue.severity === "major" ? "🟡" : "🔵"; + assessmentContent += `### ${severityIcon} ${issue.category} (${issue.severity})\n\n`; + assessmentContent += `${issue.description}\n\n`; + }); + } + + if (assessment.recommendations.length > 0) { + assessmentContent += `## Recommendations\n\n`; + assessment.recommendations.forEach((rec, index) => { + assessmentContent += `${index + 1}. ${rec}\n`; + }); + assessmentContent += `\n`; + } + + // Also save raw JSON for programmatic access + assessmentContent += `## Raw JSON\n\n\`\`\`json\n${JSON.stringify(assessment, null, 2)}\n\`\`\`\n`; + + writeFileSync(assessmentResultFile, assessmentContent, "utf8"); + console.log(`\n💾 Saved feasibility assessment to: ${assessmentResultFile}`); + } catch (saveError) { + console.warn(`⚠️ Failed to save feasibility assessment: ${saveError}`); + } + + if (assessment.isImplementable) { + console.log("\n✅ Task feasibility assessment passed"); + console.log(`📊 Clarity Score: ${assessment.clarityScore}/10 (threshold: 4/10)`); + console.log(`📝 Summary: ${assessment.summary}`); + if (assessment.clarityScore < 7) { + console.log("💡 Note: Some details may need to be inferred from existing codebase"); + } + + // Post successful assessment to task tracker as well for feedback + if (tracker && !skipComments) { + console.log("\n💬 Posting feasibility assessment to task tracker..."); + await postClarityComment(tracker, taskKey, assessment); + } else { + console.log("\n⏭️ Skipping feasibility assessment comment (--skip-comments)"); + } + } else { + console.log("\n❌ Task feasibility assessment failed"); + console.log(`📊 Clarity Score: ${assessment.clarityScore}/10 (threshold: 4/10)`); + console.log(`📝 Summary: ${assessment.summary}`); + + if (assessment.issues.length > 0) { + console.log("\n🚨 Critical issues identified:"); + assessment.issues.forEach((issue) => { + const severityIcon = + issue.severity === "critical" ? "🔴" : issue.severity === "major" ? "🟡" : "🔵"; + console.log(` ${severityIcon} ${issue.category}: ${issue.description}`); + }); + } + + if (assessment.recommendations.length > 0) { + console.log("\n💡 Recommendations:"); + assessment.recommendations.forEach((rec, index) => { + console.log(` ${index + 1}. ${rec}`); + }); + } + + // Post comment to task tracker with clarity issues + if (tracker && !skipComments) { + await postClarityComment(tracker, taskKey, assessment); + } else { + console.log("\n⏭️ Skipping failed assessment comment (--skip-comments)"); + } + + console.log("\n🛑 Stopping execution - fundamental requirements unclear"); + console.log(" Please address the critical issues and run again"); + console.log(" Or use --skip-clarity-check to bypass this assessment"); + } + + resolve(assessment); + } catch (parseError) { + console.warn("Failed to parse clarity assessment response:", parseError); + console.log("Raw Agent output:", stdoutOutput); + + // Save failed assessment output for debugging + try { + const baseOutputDir = process.env.DEVINTERN_OUTPUT_DIR || "/tmp/devintern-tasks"; + const taskDir = join(baseOutputDir, taskKey.toLowerCase()); + const failedAssessmentFile = join(taskDir, "feasibility-assessment-failed.txt"); + + writeFileSync(failedAssessmentFile, stdoutOutput, "utf8"); + console.log(`\n💾 Saved failed assessment output to: ${failedAssessmentFile}`); + } catch (saveError) { + console.warn(`⚠️ Failed to save assessment output: ${saveError}`); + } + + // Check if Agent reached max turns or had other issues + if (detectMaxTurnsReached(stdoutOutput, stderrOutput)) { + console.log("\n⚠️ Clarity assessment reached maximum conversation turns"); + console.log(" This may indicate task complexity or insufficient details"); + if (!skipComments) { + console.log( + " Will attempt to proceed with implementation but posting failure to task tracker...\n", + ); + + // Post assessment failure to task tracker + try { + if (tracker) + await postAssessmentFailure(tracker, taskKey, "max-turns", stdoutOutput); + } catch (trackerError) { + console.warn("Failed to post assessment failure to task tracker:", trackerError); + } + } else { + console.log( + " Will attempt to proceed with implementation (skipping tracker comment)...\n", + ); + } + } else { + console.log("\n⚠️ Could not parse clarity assessment response"); + if (!skipComments) { + console.log( + " Will attempt to proceed with implementation but posting failure to task tracker...\n", + ); + + // Post assessment failure to task tracker + try { + if (tracker) + await postAssessmentFailure(tracker, taskKey, "parse-error", stdoutOutput); + } catch (trackerError) { + console.warn("Failed to post assessment failure to task tracker:", trackerError); + } + } else { + console.log( + " Will attempt to proceed with implementation (skipping tracker comment)...\n", + ); + } + } + + resolve(null); // Continue with implementation if parsing fails + } + } else { + reject(new Error(`Agent clarity check exited with code ${code}`)); + } + }); + })().catch(reject); + }); +} + +/** + * Parse JSON clarity assessment output from the agent. + * + * @param output - Raw agent stdout + * @throws When JSON is missing or required fields are invalid + */ +export function parseClarityResponse(output: string): ClarityAssessment { + // Extract JSON from Agent's response + const jsonMatch = output.match(/```json\s*([\s\S]*?)\s*```/); + if (!jsonMatch) { + // Provide more specific error based on output content + if (detectMaxTurnsReached(output, "")) { + throw new Error("warn: Agent reached max turns - no JSON assessment available"); + } + if (output.trim().length === 0) { + throw new Error("warn: Empty response from Agent"); + } + throw new Error("warn: No JSON found in Agent response"); + } + + try { + const assessment = JSON.parse(jsonMatch[1]); + + // Validate required fields + if ( + typeof assessment.isImplementable !== "boolean" || + typeof assessment.clarityScore !== "number" || + !Array.isArray(assessment.issues) || + !Array.isArray(assessment.recommendations) || + typeof assessment.summary !== "string" + ) { + throw new Error("warn: Invalid assessment structure - missing required fields"); + } + + return assessment; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`warn: Malformed JSON in Agent response: ${error.message}`); + } + throw new Error(`warn: Failed to parse assessment: ${error}`); + } +} + +/** + * Post a task tracker comment when clarity assessment fails (max turns or parse error). + * + * @param tracker - Task tracker client + * @param taskKey - Task tracker issue key + * @param failureType - Failure reason category + * @param rawOutput - Raw agent output + */ +export async function postAssessmentFailure( + tracker: TaskTrackerClient, + taskKey: string, + failureType: "max-turns" | "parse-error", + rawOutput: string, +): Promise { + try { + await tracker.postAssessmentFailure(taskKey, failureType, rawOutput); + } catch (error) { + console.warn("Failed to post assessment failure:", error); + } +} + +/** + * Post a clarity assessment comment to the task tracker when the check passes thresholds. + * + * @param tracker - Task tracker client + * @param taskKey - Task tracker issue key + * @param assessment - Parsed clarity assessment + */ +export async function postClarityComment( + tracker: TaskTrackerClient, + taskKey: string, + assessment: ClarityAssessment, +): Promise { + try { + await tracker.postClarityComment(taskKey, assessment); + } catch (error) { + console.warn("Failed to post clarity comment:", error); + } +} diff --git a/packages/code/src/lib/errors.ts b/packages/code/src/lib/errors.ts new file mode 100644 index 0000000..74995ec --- /dev/null +++ b/packages/code/src/lib/errors.ts @@ -0,0 +1,15 @@ +/** + * Shared error types for @devintern/code. + */ + +/** + * Thrown when the agent hits an account-wide usage/rate limit. Since every + * remaining task in a batch would fail identically, callers abort the batch + * rather than retrying immediately. + */ +export class UsageLimitError extends Error { + constructor(public readonly resetHint?: string) { + super(`Agent usage limit reached${resetHint ? ` (resets ${resetHint})` : ""}`); + this.name = "UsageLimitError"; + } +} diff --git a/packages/code/src/lib/pipeline/config.ts b/packages/code/src/lib/pipeline/config.ts new file mode 100644 index 0000000..b4fe2cb --- /dev/null +++ b/packages/code/src/lib/pipeline/config.ts @@ -0,0 +1,106 @@ +/** + * Pipeline configuration: resolve `settings.pipeline.steps` entries into + * step instances and load user step plugins (`pipeline.plugins`). + */ + +import { isAbsolute, resolve } from "path"; +import type { PipelineStepConfig } from "../../types/settings"; +import { getStep, listSteps, registerStep } from "./registry"; +import type { PipelineStep, StepDefinition } from "./types"; + +const loadedPluginSpecifiers = new Set(); + +/** + * The default pipeline, reproducing the classic flow: + * implement -> commit -> auto-review -> push/comment/PR (finalize). + * + * The clarity check is not listed here: it runs in the task preamble (before + * branch creation and the In-Progress transition), exactly as before. + * `auto-review` gates itself on the `--auto-review` flag, so this list stays + * static while behavior stays flag-driven. + */ +export const DEFAULT_PIPELINE: PipelineStepConfig[] = [ + { use: "implement" }, + { use: "commit" }, + { use: "auto-review" }, + { use: "finalize" }, +]; + +/** + * Resolve pipeline step configs into step instances. + * + * @param stepConfigs - `settings.pipeline.steps`; falls back to {@link DEFAULT_PIPELINE} + * @throws When an entry is malformed or references an unknown step + */ +export function resolvePipelineSteps(stepConfigs?: PipelineStepConfig[]): PipelineStep[] { + const configs = stepConfigs && stepConfigs.length > 0 ? stepConfigs : DEFAULT_PIPELINE; + + return configs.map((entry) => { + if (!entry || typeof entry.use !== "string" || entry.use.length === 0) { + throw new Error( + `Invalid pipeline step entry ${JSON.stringify(entry)} - each entry must be an object with a 'use' field naming a registered step`, + ); + } + const definition = getStep(entry.use); + if (!definition) { + const available = listSteps() + .map((step) => step.name) + .join(", "); + throw new Error( + `Unknown pipeline step '${entry.use}'. Available steps: ${available}. ` + + `Custom steps must be registered via 'pipeline.plugins' before they can be used.`, + ); + } + const { use: _use, ...config } = entry; + return definition.create(config); + }); +} + +/** + * Load user pipeline plugins: dynamic-import each module and register its + * default-exported {@link StepDefinition}. + * + * Entries are either file paths (resolved against `projectRoot`) or npm + * package names (resolved from the project's node_modules at runtime). + * + * @throws With a clear message on load failure, missing/invalid default + * export, or a name collision with an existing step. + */ +export async function loadPlugins( + plugins: string[] | undefined, + projectRoot: string, +): Promise { + for (const entry of plugins ?? []) { + const isPath = entry.startsWith(".") || isAbsolute(entry); + const specifier = isPath ? resolve(projectRoot, entry) : entry; + if (loadedPluginSpecifiers.has(specifier)) { + continue; + } + + let mod: Record; + try { + mod = (await import(specifier)) as Record; + } catch (importError) { + throw new Error( + `Failed to load pipeline plugin '${entry}': ${(importError as Error).message}`, + ); + } + + const definition = mod.default as StepDefinition | undefined; + if ( + !definition || + typeof definition !== "object" || + typeof definition.name !== "string" || + definition.name.length === 0 || + typeof definition.create !== "function" + ) { + throw new Error( + `Pipeline plugin '${entry}' must default-export a StepDefinition ({ name, create }). ` + + `${mod.default === undefined ? "The module has no default export." : `Got: ${typeof mod.default}`}`, + ); + } + + registerStep(definition); + loadedPluginSpecifiers.add(specifier); + } +} diff --git a/packages/code/src/lib/pipeline/index.ts b/packages/code/src/lib/pipeline/index.ts new file mode 100644 index 0000000..3b87276 --- /dev/null +++ b/packages/code/src/lib/pipeline/index.ts @@ -0,0 +1,52 @@ +/** + * Public pipeline API for `@devintern/code` plugin authors, exposed via the + * `@devintern/code/pipeline` subpath export. + * + * A plugin module default-exports a {@link StepDefinition} and is referenced + * in `.devintern-code/settings.json`: + * + * ```json + * { + * "pipeline": { + * "plugins": ["./.devintern-code/steps/my-step.ts"], + * "steps": [ + * { "use": "implement" }, + * { "use": "commit" }, + * { "use": "my-step", "threshold": 0.9 }, + * { "use": "finalize" } + * ] + * } + * } + * ``` + */ + +export { + StepExecutionError, + StepStatus, + type HaltKind, + type PipelineStep, + type StepDefinition, + type StepResult, + type TaskContext, +} from "./types"; +export { Pipeline, type PipelineOptions } from "./pipeline"; +export { getStep, listSteps, registerStep } from "./registry"; +export { DEFAULT_PIPELINE, loadPlugins, resolvePipelineSteps } from "./config"; +export { + buildLoopbackPrompt, + ImplementStep, + runImplementation, + type ImplementationRunResult, +} from "./steps/implement-step"; +export { VerifyStep, type VerifyStepConfig, type VerifyStepDeps } from "./steps/verify-step"; + +// Agent-invocation + verdict-parse primitives for custom agent-backed steps. +export { + filterByPriority, + getPRDiff, + parseReviewFeedback, + runAgentPrompt, +} from "../auto-review-loop"; +export type { ReviewFeedback, ReviewFeedbackItem, ReviewPriority } from "../../types/auto-review"; +export type { PipelineConfig, PipelineStepConfig } from "../../types/settings"; +export { UsageLimitError } from "../errors"; diff --git a/packages/code/src/lib/pipeline/pipeline.ts b/packages/code/src/lib/pipeline/pipeline.ts new file mode 100644 index 0000000..a0298ab --- /dev/null +++ b/packages/code/src/lib/pipeline/pipeline.ts @@ -0,0 +1,138 @@ +/** + * Pipeline runner: executes steps in order, owning retry counting (for + * execution errors), loopback bounding (for verdict failures), and Halt + * handling. Limits live in one place - steps stay simple. + */ + +import { UsageLimitError } from "../errors"; +import { StepExecutionError, StepStatus } from "./types"; +import type { PipelineStep, StepResult, TaskContext } from "./types"; + +export interface PipelineOptions { + /** + * Called when a step halts with `haltKind: "incomplete"` (or a loopback / + * retry budget is exhausted). Typically posts the incomplete-implementation + * comment and reverts the ticket to To Do. + */ + onHalt?: (ctx: TaskContext, result: StepResult) => Promise; + /** Retries per step after the first {@link StepExecutionError} (default 1). */ + maxStepRetries?: number; + /** Loopback bound per step when the step result doesn't specify one (default 3). */ + defaultMaxLoopbacks?: number; +} + +export class Pipeline { + constructor( + private readonly steps: PipelineStep[], + private readonly options: PipelineOptions = {}, + ) {} + + /** + * Run all steps against the shared context. + * + * Throws when a step throws anything other than {@link StepExecutionError} + * (e.g. {@link UsageLimitError}, which must abort a batch, or agent + * timeouts / non-zero exits). Halts resolve normally so batch processing + * can continue with the next task. + */ + async run(ctx: TaskContext): Promise { + const maxRetries = this.options.maxStepRetries ?? 1; + const defaultMaxLoopbacks = this.options.defaultMaxLoopbacks ?? 3; + const retries = new Map(); + const loopbacks = new Map(); + + let i = 0; + while (i < this.steps.length) { + const step = this.steps[i]; + let result: StepResult; + + try { + result = await step.run(ctx); + } catch (error) { + if (error instanceof UsageLimitError) { + // Account-global limit: never retry, abort the whole batch. + throw error; + } + if (error instanceof StepExecutionError) { + const attempts = (retries.get(i) ?? 0) + 1; + retries.set(i, attempts); + if (attempts <= maxRetries) { + console.log( + `\n🔁 Step '${step.name}' failed (${error.message}); retrying (attempt ${attempts + 1})...`, + ); + continue; + } + result = { + status: StepStatus.Halt, + haltKind: "incomplete", + reason: `Step '${step.name}' failed after ${attempts + 1} attempt(s): ${error.message}`, + }; + } else { + throw error; + } + } + + ctx.results.push(result); + + switch (result.status) { + case StepStatus.Continue: + i++; + break; + + case StepStatus.WarnContinue: + if (result.reason) { + ctx.warnings.push(`${step.name}: ${result.reason}`); + } + i++; + break; + + case StepStatus.Loopback: { + const targetName = result.loopbackTo ?? "implement"; + const targetIndex = this.steps.findIndex((s) => s.name === targetName); + if (targetIndex === -1) { + await this.halt(ctx, { + status: StepStatus.Halt, + haltKind: "incomplete", + reason: `Step '${step.name}' requested loopback to unknown step '${targetName}'`, + }); + return; + } + const count = (loopbacks.get(i) ?? 0) + 1; + const bound = result.maxLoopbacks ?? defaultMaxLoopbacks; + if (count > bound) { + console.log( + `\n🛑 Step '${step.name}' exhausted its loopback budget (${bound} iteration(s))`, + ); + await this.halt(ctx, { + status: StepStatus.Halt, + haltKind: "incomplete", + reason: + result.reason ?? + `Step '${step.name}' still failing after ${bound} loopback iteration(s)`, + }); + return; + } + loopbacks.set(i, count); + if (result.loopbackFeedback) { + ctx.loopbackFeedback = result.loopbackFeedback; + } + console.log( + `\n🔄 Step '${step.name}' looping back to '${targetName}' (iteration ${count}/${bound})...`, + ); + i = targetIndex; + break; + } + + case StepStatus.Halt: + await this.halt(ctx, result); + return; + } + } + } + + private async halt(ctx: TaskContext, result: StepResult): Promise { + if ((result.haltKind ?? "incomplete") === "incomplete" && this.options.onHalt) { + await this.options.onHalt(ctx, result); + } + } +} diff --git a/packages/code/src/lib/pipeline/registry.ts b/packages/code/src/lib/pipeline/registry.ts new file mode 100644 index 0000000..cb5e68d --- /dev/null +++ b/packages/code/src/lib/pipeline/registry.ts @@ -0,0 +1,77 @@ +/** + * Pipeline step registry. New steps can be registered at runtime (via + * `pipeline.plugins` in settings.json or `registerStep()` from + * `@devintern/code/pipeline`) or by editing this file. + * + * Mirrors the harness registry in `@devintern/agent-harness`. + */ + +import type { StepDefinition } from "./types"; +import { autoReviewStepDefinition } from "./steps/auto-review-step"; +import { clarityStepDefinition } from "./steps/clarity-step"; +import { commitStepDefinition } from "./steps/commit-step"; +import { finalizeStepDefinition } from "./steps/finalize-step"; +import { implementStepDefinition } from "./steps/implement-step"; +import { verifyStepDefinition } from "./steps/verify-step"; + +const registry = new Map(); + +const BUILT_IN_STEPS: StepDefinition[] = [ + clarityStepDefinition, + implementStepDefinition, + commitStepDefinition, + autoReviewStepDefinition, + verifyStepDefinition, + finalizeStepDefinition, +]; + +/** + * Register a step definition for lookup by {@link getStep}. + * + * @param definition - Step definition; keyed by {@link StepDefinition.name}. + * @throws When a different definition is already registered under the name. + */ +export function registerStep(definition: StepDefinition): void { + const existing = registry.get(definition.name); + if (existing && existing !== definition) { + throw new Error( + `A pipeline step named '${definition.name}' is already registered. ` + + `Step names must be unique - rename your custom step.`, + ); + } + registry.set(definition.name, definition); +} + +/** + * Look up a registered step definition by its machine-readable name. + * + * @param name - Step identifier (e.g. `"implement"`). + * @returns The step definition, or `undefined` if not registered. + */ +export function getStep(name: string): StepDefinition | undefined { + return registry.get(name); +} + +/** + * Return every step definition currently registered in the global registry. + * + * @returns A snapshot of all registered step definitions. + */ +export function listSteps(): StepDefinition[] { + return Array.from(registry.values()); +} + +/** Test-only: reset the registry back to the built-in steps. */ +export function __resetStepsForTests(): void { + registry.clear(); + registerBuiltInSteps(); +} + +function registerBuiltInSteps(): void { + for (const definition of BUILT_IN_STEPS) { + registry.set(definition.name, definition); + } +} + +// Register built-in steps ----------------------------------------------------- +registerBuiltInSteps(); diff --git a/packages/code/src/lib/pipeline/steps/auto-review-step.ts b/packages/code/src/lib/pipeline/steps/auto-review-step.ts new file mode 100644 index 0000000..a51ae3a --- /dev/null +++ b/packages/code/src/lib/pipeline/steps/auto-review-step.ts @@ -0,0 +1,130 @@ +/** + * The `auto-review` step: iterative self-review of the committed changes + * before pushing (wraps `runAutoReviewLoop` with `skipPush: true`). + * + * Also performs the pre-push hook validations exactly where today's flow + * does: an initial validation before the review loop and a re-validation + * after it (review fixes may have broken hooks). + */ + +import { writeFileSync } from "fs"; +import { join } from "path"; +import { runAutoReviewLoop } from "../../auto-review-loop"; +import { recordRunStage } from "../../run-recorder"; +import { Utils } from "../../utils"; +import { validatePrePushHook } from "./hook-helpers"; +import type { ReviewPriority } from "../../../types/auto-review"; +import { StepStatus } from "../types"; +import type { PipelineStep, StepDefinition, StepResult, TaskContext } from "../types"; + +export interface AutoReviewStepConfig { + /** Max review iterations (defaults to `ctx.autoReviewIterations`). */ + maxIterations?: number; + /** Minimum priority to address (default "medium"). */ + minSeverity?: ReviewPriority; +} + +export class AutoReviewStep implements PipelineStep { + readonly name = "auto-review"; + + constructor(private readonly config: AutoReviewStepConfig = {}) {} + + async run(ctx: TaskContext): Promise { + if (!ctx.autoReview) { + return { status: StepStatus.Continue, data: { skipped: true } }; + } + // Auto-review only runs on committed changes headed for a PR (matches + // the previous inline flow, which lived inside the create-PR branch). + if (!(ctx.enableGit && ctx.taskKey && ctx.taskSummary && ctx.commitSucceeded)) { + return { status: StepStatus.Continue, data: { skipped: true } }; + } + if (!(ctx.createPr && ctx.task)) { + return { status: StepStatus.Continue, data: { skipped: true } }; + } + + // Step 1: Validate pre-push hook locally BEFORE any push + console.log("\n🔍 Validating pre-push hook locally (before pushing)..."); + const initialHookValidation = await validatePrePushHook(ctx, "initial validation"); + + if (!initialHookValidation.success) { + console.log(" Cannot proceed without passing pre-push hook validation"); + return { + status: StepStatus.Halt, + haltKind: "stop", + reason: "Pre-push hook validation failed before auto-review", + }; + } + ctx.hookValidated = true; + + const currentBranch = await Utils.getCurrentBranch(); + if (!currentBranch) { + return { status: StepStatus.Continue, data: { skipped: true } }; + } + + try { + console.log("\n🔄 Running auto-review loop (without pushing)..."); + + const autoReviewResult = await runAutoReviewLoop({ + repository: "local/repo", + prNumber: 0, + prBranch: currentBranch, + baseBranch: ctx.prTargetBranch, + harness: ctx.harness, + executablePath: ctx.executablePath, + maxIterations: this.config.maxIterations ?? ctx.autoReviewIterations, + minPriority: this.config.minSeverity ?? "medium", + workingDir: ctx.workingDir, + outputDir: ctx.taskDir, + skipPush: true, + }); + + const summaryPath = join(ctx.taskDir, "auto-review-summary.json"); + writeFileSync(summaryPath, JSON.stringify(autoReviewResult, null, 2)); + console.log(`\n📄 Auto-review summary saved to: ${summaryPath}`); + + recordRunStage("auto_review", { + status: autoReviewResult.success ? "succeeded" : "failed", + summary: `${autoReviewResult.iterations} iteration(s), ${ + autoReviewResult.success ? "approved" : "incomplete" + }`, + detail: { + iterations: autoReviewResult.iterations, + success: autoReviewResult.success, + finalFeedback: autoReviewResult.finalFeedback, + }, + }); + + ctx.autoReviewRan = true; + + // Step 2: After auto-review, validate hooks again + console.log("\n🔍 Re-validating pre-push hook after auto-review improvements..."); + const postAutoReviewValidation = await validatePrePushHook( + ctx, + "post auto-review validation", + ); + + if (!postAutoReviewValidation.success) { + console.log(" Cannot proceed - auto-review changes failed pre-push hook validation"); + return { + status: StepStatus.Halt, + haltKind: "stop", + reason: "Auto-review changes failed pre-push hook validation", + }; + } + } catch (autoReviewError) { + recordRunStage("auto_review", { + status: "failed", + summary: `loop errored: ${(autoReviewError as Error).message}`, + }); + console.warn(`\n⚠️ Auto-review loop failed: ${(autoReviewError as Error).message}`); + console.log(" Continuing with push and PR creation..."); + } + + return { status: StepStatus.Continue }; + } +} + +export const autoReviewStepDefinition: StepDefinition = { + name: "auto-review", + create: (config) => new AutoReviewStep(config as AutoReviewStepConfig), +}; diff --git a/packages/code/src/lib/pipeline/steps/clarity-step.ts b/packages/code/src/lib/pipeline/steps/clarity-step.ts new file mode 100644 index 0000000..d482c5a --- /dev/null +++ b/packages/code/src/lib/pipeline/steps/clarity-step.ts @@ -0,0 +1,56 @@ +/** + * The `clarity` step: opt-in pipeline wrapper around the feasibility + * assessment (`runClarityCheck`). + * + * Note: the built-in preamble clarity check in `processSingleTask` runs + * BEFORE branch creation and the In-Progress transition; this step runs at + * its configured position in the pipeline (after those side effects). It is + * not part of the default pipeline - add it via `pipeline.steps` when you + * want a clarity gate inside a custom pipeline. + */ + +import { runClarityCheck } from "../../clarity-check"; +import { StepStatus } from "../types"; +import type { PipelineStep, StepDefinition, StepResult, TaskContext } from "../types"; + +export class ClarityStep implements PipelineStep { + readonly name = "clarity"; + + async run(ctx: TaskContext): Promise { + if (ctx.skipClarityCheck || !ctx.taskKey) { + return { status: StepStatus.Continue, data: { skipped: true } }; + } + + try { + const assessment = await runClarityCheck( + ctx.taskFile, + ctx.harness, + ctx.executablePath, + ctx.taskKey, + ctx.tracker, + ctx.skipComments, + ); + + if (assessment && !assessment.isImplementable) { + return { + status: StepStatus.Halt, + haltKind: "stop", + reason: `Task failed clarity assessment: ${assessment.summary}`, + }; + } + return { status: StepStatus.Continue }; + } catch (clarityError) { + // Match the preamble behavior: a failed clarity check is a warning, + // implementation proceeds. + return { + status: StepStatus.WarnContinue, + reason: `Feasibility check failed, continuing with implementation: ${clarityError}`, + }; + } + } +} + +export const clarityStepDefinition: StepDefinition = { + name: "clarity", + create: () => new ClarityStep(), +}; diff --git a/packages/code/src/lib/pipeline/steps/commit-step.ts b/packages/code/src/lib/pipeline/steps/commit-step.ts new file mode 100644 index 0000000..1c619ae --- /dev/null +++ b/packages/code/src/lib/pipeline/steps/commit-step.ts @@ -0,0 +1,85 @@ +/** + * The `commit` step: commits implementation changes (with git-hook auto-fix + * retries) right after `implement`, so later review/verify steps see the + * changes in HEAD when diffing against the base branch. + * + * Also detects plan-only agent behavior ("No changes to commit" plus plan + * language in the output) and loops back to `implement` once with a prompt + * asking the agent to actually execute its plan. + */ + +import { handleCommitWithRetry } from "./hook-helpers"; +import { createPlanImplementationPrompt, detectPlanOnlyBehavior } from "./plan-detection"; +import { StepStatus } from "../types"; +import type { PipelineStep, StepDefinition, StepResult, TaskContext } from "../types"; + +export class CommitStep implements PipelineStep { + readonly name = "commit"; + + async run(ctx: TaskContext): Promise { + if (!(ctx.enableGit && ctx.taskKey && ctx.taskSummary)) { + ctx.commitSucceeded = false; + return { status: StepStatus.Continue, data: { skipped: true } }; + } + + console.log("\n📝 Committing changes..."); + + let outcome: Awaited>; + try { + outcome = await handleCommitWithRetry(ctx, ctx.taskKey, ctx.taskSummary); + } catch (commitError) { + console.log(`⚠️ Failed to commit changes: ${(commitError as Error).message}`); + console.log( + 'You can commit changes manually with: git add . && git commit -m "feat: implement task"', + ); + ctx.commitSucceeded = false; + return { + status: StepStatus.WarnContinue, + reason: `Commit failed: ${(commitError as Error).message}`, + }; + } + + if (outcome.success) { + ctx.commitSucceeded = true; + return { status: StepStatus.Continue }; + } + + // Check if this is a "plan only" scenario - Agent created a plan but didn't implement + const noChangesToCommit = outcome.result.message === "No changes to commit"; + const planPath = noChangesToCommit ? detectPlanOnlyBehavior(ctx.implementationOutput) : null; + + if (noChangesToCommit && planPath && !ctx.isPlanRetry) { + // Agent only created a plan - run it again with instructions to implement + console.log( + "\n🔄 Agent created a plan but didn't implement it. Re-running to execute the plan...", + ); + + if (planPath !== "PLAN_DETECTED_NO_PATH") { + console.log(` Plan file detected: ${planPath}`); + } + + ctx.isPlanRetry = true; + ctx.pendingPromptOverride = createPlanImplementationPrompt(planPath, ctx.taskContent); + return { + status: StepStatus.Loopback, + loopbackTo: "implement", + maxLoopbacks: 1, + reason: "Agent produced a plan without implementing it", + }; + } + + console.log( + 'You can commit changes manually with: git add . && git commit -m "feat: implement task"', + ); + ctx.commitSucceeded = false; + return { + status: StepStatus.WarnContinue, + reason: `Commit failed: ${outcome.result.message}`, + }; + } +} + +export const commitStepDefinition: StepDefinition = { + name: "commit", + create: () => new CommitStep(), +}; diff --git a/packages/code/src/lib/pipeline/steps/finalize-step.ts b/packages/code/src/lib/pipeline/steps/finalize-step.ts new file mode 100644 index 0000000..19b76a4 --- /dev/null +++ b/packages/code/src/lib/pipeline/steps/finalize-step.ts @@ -0,0 +1,190 @@ +/** + * The `finalize` step: pre-push hook validation (when not already validated + * by auto-review), push with hook retry, implementation-summary comment, PR + * creation, and post-PR status transition. Wraps the existing helpers from + * the old `runAgentHarness` close handler; behavior is unchanged. + */ + +import { PRManager } from "../../pr-client"; +import { getPrStatusForProject, resolveProjectKey } from "../../project-settings"; +import { recordRunPr } from "../../run-recorder"; +import type { TaskTrackerClient } from "../../task-tracker-client"; +import { Utils } from "../../utils"; +import { parseGitHubPrUrl, recordAgentPrFromUrl } from "../../worker-state"; +import { pushWithHookRetry, validatePrePushHook } from "./hook-helpers"; +import { StepStatus } from "../types"; +import type { PipelineStep, StepDefinition, StepResult, TaskContext } from "../types"; + +/** + * Post a successful implementation summary comment to the task tracker. + */ +async function postImplementationComment( + tracker: TaskTrackerClient, + taskKey: string, + agentOutput: string, + taskSummary?: string, +): Promise { + try { + await tracker.postImplementationComment(taskKey, agentOutput, taskSummary); + console.log(`✅ Implementation summary posted to ${taskKey}`); + } catch (error) { + throw new Error(`Failed to post implementation comment: ${error}`); + } +} + +/** Create the PR and transition the ticket to its post-PR status. */ +async function createPrAndTransition( + ctx: TaskContext, + implementationOutput: string, + autoReviewRan = false, +): Promise { + console.log("\n🔀 Creating pull request..."); + try { + const prManager = new PRManager(); + const branchForPr = await Utils.getCurrentBranch(); + + if (!branchForPr) { + console.log("⚠️ Could not determine current branch for PR creation"); + return; + } + if (await Utils.isProtectedBranch(branchForPr)) { + console.error(`\n❌ Cannot create PR from protected branch '${branchForPr}'`); + console.error(" This indicates a bug - feature branch was not created properly."); + return; + } + + // Ensure the PR target branch actually exists on the remote. A wrong or + // missing target (e.g. `--pr-target-branch main` on a `master` repo) makes + // GitHub reject the PR with "Validation Failed", leaving a pushed branch + // and no PR. Fall back to the repo's real default branch in that case. + let effectivePrTargetBranch = ctx.prTargetBranch; + if (!(await Utils.remoteBranchExists(ctx.prTargetBranch, { verbose: ctx.verbose }))) { + const defaultBranch = await Utils.getMainBranchName(); + if (defaultBranch !== ctx.prTargetBranch) { + console.log( + `⚠️ Target branch '${ctx.prTargetBranch}' not found on remote, falling back to '${defaultBranch}'`, + ); + effectivePrTargetBranch = defaultBranch; + } + } + + const prResult = await prManager.createPullRequest( + ctx.task, + branchForPr, + effectivePrTargetBranch, + implementationOutput, + ); + + if (prResult.success) { + console.log(`✅ Pull request created: ${prResult.url}`); + + if (prResult.url) { + recordAgentPrFromUrl(prResult.url, branchForPr, ctx.taskKey); + recordRunPr({ ...parseGitHubPrUrl(prResult.url), url: prResult.url }); + } + + if (ctx.taskKey && ctx.tracker && !ctx.skipComments) { + const projectKey = resolveProjectKey(ctx.taskKey, ctx.task); + const prStatus = getPrStatusForProject(projectKey, ctx.projectSettings); + if (prStatus && prStatus.trim()) { + try { + console.log("\n🔄 Transitioning JIRA status after PR creation..."); + await ctx.tracker.transitionStatus(ctx.taskKey, prStatus.trim()); + } catch (statusError) { + console.warn(`⚠️ Failed to transition JIRA status: ${(statusError as Error).message}`); + console.log(" PR was created successfully, but status transition failed"); + } + } + } else if (ctx.skipComments) { + console.log("\n⏭️ Skipping task tracker status transition (--skip-comments)"); + } + + if (autoReviewRan) { + console.log("\n✅ Auto-review was completed before push (see summary file for details)"); + } + } else { + console.log(`⚠️ PR creation failed: ${prResult.message}`); + } + } catch (prError) { + console.log(`⚠️ PR creation failed: ${(prError as Error).message}`); + } +} + +export class FinalizeStep implements PipelineStep { + readonly name = "finalize"; + + async run(ctx: TaskContext): Promise { + if (!(ctx.enableGit && ctx.taskKey && ctx.taskSummary)) { + return { status: StepStatus.Continue, data: { skipped: true } }; + } + if (!ctx.commitSucceeded) { + // Commit step already reported the failure; nothing to push or PR. + return { status: StepStatus.Continue, data: { skipped: true } }; + } + + const output = ctx.implementationOutput; + + if (ctx.createPr && ctx.task) { + // Validate pre-push hook locally BEFORE pushing (unless the + // auto-review step already validated in this run). + if (!ctx.hookValidated) { + console.log("\n🔍 Validating pre-push hook locally (before pushing)..."); + const initialHookValidation = await validatePrePushHook(ctx, "initial validation"); + + if (!initialHookValidation.success) { + console.log(" Cannot proceed without passing pre-push hook validation"); + return { + status: StepStatus.Halt, + haltKind: "stop", + reason: "Pre-push hook validation failed", + }; + } + ctx.hookValidated = true; + } + + const pushOutcome = await pushWithHookRetry(ctx); + + if (pushOutcome.success) { + if (ctx.taskKey && ctx.tracker && output.trim() && !ctx.skipComments) { + try { + console.log("\n💬 Posting implementation summary to task tracker..."); + await postImplementationComment(ctx.tracker, ctx.taskKey, output, ctx.taskSummary); + } catch (commentError) { + console.warn( + `⚠️ Failed to post implementation comment to task tracker: ${commentError}`, + ); + console.log(" Push succeeded, but task tracker comment failed"); + } + } else if (ctx.skipComments && ctx.taskKey) { + console.log("\n⏭️ Skipping task tracker comment posting (--skip-comments)"); + } + + await createPrAndTransition(ctx, output, ctx.autoReviewRan); + } else { + console.log(" Cannot create PR without pushing branch to remote"); + } + } else { + // No PR requested, but commit succeeded - post to task tracker here + if (ctx.taskKey && ctx.tracker && output.trim() && !ctx.skipComments) { + try { + console.log("\n💬 Posting implementation summary to task tracker..."); + await postImplementationComment(ctx.tracker, ctx.taskKey, output, ctx.taskSummary); + } catch (commentError) { + console.warn( + `⚠️ Failed to post implementation comment to task tracker: ${commentError}`, + ); + console.log(" Commit succeeded, but task tracker comment failed"); + } + } else if (ctx.skipComments && ctx.taskKey) { + console.log("\n⏭️ Skipping task tracker comment posting (--skip-comments)"); + } + } + + return { status: StepStatus.Continue }; + } +} + +export const finalizeStepDefinition: StepDefinition = { + name: "finalize", + create: () => new FinalizeStep(), +}; diff --git a/packages/code/src/lib/pipeline/steps/hook-helpers.ts b/packages/code/src/lib/pipeline/steps/hook-helpers.ts new file mode 100644 index 0000000..1343269 --- /dev/null +++ b/packages/code/src/lib/pipeline/steps/hook-helpers.ts @@ -0,0 +1,215 @@ +/** + * Git-hook-aware commit / validate / push helpers with agent-assisted retry. + * + * Extracted from the `runAgentHarness` close handler in src/index.ts; the + * closures became module functions taking the shared {@link TaskContext}. + * Behavior (messages, retry bounds, outcomes) is unchanged. + */ + +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { isCommitAlreadyComplete, runAgentHarnessToFixGitHook } from "../../git-hook-fixer"; +import { Utils } from "../../utils"; +import type { TaskContext } from "../types"; + +interface HookOutcome { + success: boolean; + result: { message: string; hookError?: string }; +} + +/** Append a git hook failure record to the task's hook error log. */ +export function logHookErrorToFile( + taskKey: string, + hookType: string, + attempt: number, + error: string, + fixed: boolean, +): void { + try { + const baseOutputDir = process.env.DEVINTERN_OUTPUT_DIR || "/tmp/devintern-tasks"; + const taskDir = join(baseOutputDir, taskKey.toLowerCase()); + const hookErrorFile = join(taskDir, "git-hook-errors.log"); + + const timestamp = new Date().toISOString(); + const status = fixed ? "FIXED" : "FAILED"; + const logEntry = ` +${"=".repeat(80)} +Timestamp: ${timestamp} +Hook Type: ${hookType} +Attempt: ${attempt} +Status: ${status} +Error: +${error} +${"=".repeat(80)} +`; + + // Append to log file + const existingContent = existsSync(hookErrorFile) + ? readFileSync(hookErrorFile, "utf8") + : "# Git Hook Errors Log\n\n"; + + writeFileSync(hookErrorFile, existingContent + logEntry, "utf8"); + console.log(`💾 Hook error logged to: ${hookErrorFile}`); + } catch (saveError) { + console.warn(`⚠️ Failed to save hook error to file: ${saveError}`); + } +} + +/** Validate the pre-push hook locally, letting the agent fix failures. */ +export async function validatePrePushHook(ctx: TaskContext, phase: string): Promise { + const { hookRetries, harness, executablePath, maxTurns } = ctx; + let attempt = 0; + while (attempt <= hookRetries) { + attempt++; + const hookResult = await Utils.runPrePushHookLocally({ + verbose: ctx.verbose, + }); + if (hookResult.success) { + if (attempt === 1) { + console.log(`✅ ${hookResult.message}`); + } else { + console.log(`✅ Pre-push hook passed after ${attempt} attempt(s)`); + } + return { success: true, result: hookResult }; + } + if (hookResult.hookError && attempt <= hookRetries) { + console.log( + `\n⚠️ Pre-push hook failed during ${phase} (attempt ${attempt}/${hookRetries + 1})`, + ); + const fixed = await runAgentHarnessToFixGitHook("push", harness, executablePath, maxTurns); + logHookErrorToFile( + ctx.taskKey ?? "unknown", + "push-local-validation", + attempt, + hookResult.hookError, + fixed, + ); + if (fixed) { + console.log( + `\n🔄 Retrying local hook validation after ${harness.displayName} fixed the issues...`, + ); + continue; + } else { + console.log("\n❌ Could not fix pre-push hook errors automatically"); + return { success: false, result: hookResult }; + } + } else { + if (attempt > hookRetries) { + console.log(`\n❌ Max retries (${hookRetries}) exceeded for pre-push hook fixes`); + } + console.log(`⚠️ ${hookResult.message}`); + return { success: false, result: hookResult }; + } + } + return { + success: false, + result: { message: "Max retries exceeded" }, + }; +} + +/** Push the current branch, letting the agent fix pre-push hook failures. */ +export async function pushWithHookRetry(ctx: TaskContext): Promise { + const { hookRetries, harness, executablePath, maxTurns } = ctx; + console.log("\n📤 Pushing branch to remote..."); + let attempt = 0; + while (attempt <= hookRetries) { + attempt++; + const pushResult = await Utils.pushCurrentBranch({ + verbose: ctx.verbose, + }); + if (pushResult.success) { + console.log(`✅ ${pushResult.message}`); + return { success: true, result: pushResult }; + } + if (pushResult.hookError && attempt <= hookRetries) { + console.log( + `\n⚠️ Git pre-push hook failed during push (attempt ${attempt}/${hookRetries + 1})`, + ); + const fixed = await runAgentHarnessToFixGitHook("push", harness, executablePath, maxTurns); + logHookErrorToFile(ctx.taskKey ?? "unknown", "push", attempt, pushResult.hookError, fixed); + if (fixed) { + console.log( + `\n🔄 Retrying push after ${harness.displayName} fixed and amended the commit...`, + ); + continue; + } else { + console.log("\n❌ Could not fix git pre-push hook errors automatically"); + return { success: false, result: pushResult }; + } + } else { + if (attempt > hookRetries) { + console.log(`\n❌ Max retries (${hookRetries}) exceeded for git hook fixes`); + } + console.log(`⚠️ ${pushResult.message}`); + return { success: false, result: pushResult }; + } + } + return { + success: false, + result: { message: "Max retries exceeded" }, + }; +} + +/** Commit staged changes, letting the agent fix commit hook failures. */ +export async function handleCommitWithRetry( + ctx: TaskContext, + taskKey: string, + taskSummary: string, +): Promise { + const { hookRetries, harness, executablePath, maxTurns } = ctx; + let attempt = 0; + + while (attempt <= hookRetries) { + attempt++; + const commitResult = await Utils.commitChanges(taskKey, taskSummary, { + verbose: ctx.verbose, + author: ctx.gitAuthor, + }); + + if (commitResult.success) { + console.log(`✅ ${commitResult.message}`); + return { success: true, result: commitResult }; + } + + // Check if this is a git hook error that we can try to fix + if (commitResult.hookError && attempt <= hookRetries) { + console.log(`\n⚠️ Git hook failed (attempt ${attempt}/${hookRetries + 1})`); + + // Try to fix the hook error with agent + const fixed = await runAgentHarnessToFixGitHook("commit", harness, executablePath, maxTurns); + + // Log the hook error to file + logHookErrorToFile(taskKey, "commit", attempt, commitResult.hookError, fixed); + + if (fixed) { + if (await isCommitAlreadyComplete()) { + console.log("✅ Commit already completed during hook fix"); + return { + success: true, + result: { + message: `Successfully committed changes for ${taskKey} (via hook fix)`, + }, + }; + } + + console.log("\n🔄 Retrying commit after Agent fixed the issues..."); + continue; + } else { + console.log("\n❌ Could not fix git hook errors automatically"); + return { success: false, result: commitResult }; + } + } else { + // Not a hook error or out of retries + if (attempt > hookRetries) { + console.log(`\n❌ Max retries (${hookRetries}) exceeded for git hook fixes`); + } + console.log(`⚠️ ${commitResult.message}`); + return { success: false, result: commitResult }; + } + } + + return { + success: false, + result: { message: "Max retries exceeded" }, + }; +} diff --git a/packages/code/src/lib/pipeline/steps/implement-step.ts b/packages/code/src/lib/pipeline/steps/implement-step.ts new file mode 100644 index 0000000..2200199 --- /dev/null +++ b/packages/code/src/lib/pipeline/steps/implement-step.ts @@ -0,0 +1,344 @@ +/** + * The `implement` step: runs the main agent harness implementation session. + * + * `runImplementation` is the primitive extracted from `runAgentHarness`'s + * subprocess close handler in src/index.ts. It owns ONLY: spawn + capture + + * timeout, usage-limit / max-turns / incomplete detection, and summary-file + * saving. Commit / push / PR live in later steps so a verify loopback can + * re-run implementation without re-committing mid-verification. + */ + +import { existsSync, writeFileSync } from "fs"; +import { join } from "path"; +import { + buildPromptArgs, + detectIncompleteImplementation, + detectMaxTurnsReached, + detectOpenQuestions, + detectUsageLimit, + reapTree, + resolveExecutablePathWithRetry, + spawnAgent, +} from "@devintern/agent-harness"; +import { UsageLimitError } from "../../errors"; +import { getSandbox } from "../../sandbox"; +import type { ReviewFeedback } from "../../../types/auto-review"; +import { StepStatus } from "../types"; +import type { PipelineStep, StepDefinition, StepResult, TaskContext } from "../types"; + +/** Result of one agent implementation run. */ +export interface ImplementationRunResult { + /** Full agent stdout. */ + stdout: string; + /** Whether the run produced a (seemingly) complete implementation. */ + outcome: "complete" | "incomplete" | "awaiting-input"; + /** Reasons when `outcome === "incomplete"`. */ + incompleteReasons?: string[]; + /** Questions the agent needs answered before it can continue. */ + openQuestions?: string[]; +} + +/** + * Run one agent implementation session for the task. + * + * @param ctx - Shared task context (harness, task file, output dirs) + * @param promptOverride - When set, sent to the agent instead of the task + * file contents (used for plan-only retries and verify loopbacks) + * @throws {UsageLimitError} When the agent hit an account-wide usage limit + * @throws {Error} On timeout, spawn failure, or non-zero agent exit + */ +export async function runImplementation( + ctx: TaskContext, + promptOverride?: string, +): Promise { + const { harness, executablePath, maxTurns } = ctx; + + // Wait out any in-progress CLI auto-update swap before spawning, so a + // transient `spawn ENOENT` doesn't abort the run. + const resolvedPath = await resolveExecutablePathWithRetry(executablePath, { + displayName: harness.displayName, + }); + + if (!existsSync(ctx.taskFile)) { + throw new Error(`Task file not found: ${ctx.taskFile}`); + } + + const promptContent = promptOverride ?? ctx.taskContent; + + const timeoutMinutes = parseInt(process.env.AGENT_HARNESS_TIMEOUT_MINUTES || "60", 10); + + const agentArgs = harness.buildArgs({ + maxTurns, + skipPermissions: true, + workingDir: ctx.workingDir, + }); + console.log(`🚀 Launching ${harness.displayName}...`); + console.log(` Command: ${executablePath} ${agentArgs.join(" ")} --verbose`); + console.log(` Input: ${ctx.taskFile}`); + console.log(` Timeout: ${timeoutMinutes} minutes`); + console.log(` Output: All ${harness.displayName} output will be displayed below in real-time`); + console.log("\n" + "=".repeat(60)); + + let stderrOutput = ""; + let stdoutOutput = ""; + let timedOut = false; + + const { child: codeAgent, cleanup: sandboxCleanup } = await spawnAgent({ + resolvedPath, + args: [...agentArgs, ...buildPromptArgs(harness, promptContent)], + spawnOptions: { stdio: ["ignore", "pipe", "pipe"] }, + sandbox: await getSandbox(harness.name), + }); + + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => { + timedOut = true; + console.error( + `\n⏰ ${harness.displayName} process timed out after ${timeoutMinutes} minutes, killing...`, + ); + reapTree(codeAgent, "SIGTERM"); + setTimeout(() => { + if (!codeAgent.killed) { + reapTree(codeAgent, "SIGKILL"); + } + }, 10_000); + }, + timeoutMinutes * 60 * 1000, + ); + + // Capture and display stdout output + if (codeAgent.stdout) { + codeAgent.stdout.on("data", (data: Buffer) => { + const output = data.toString(); + stdoutOutput += output; + process.stdout.write(output); + }); + } + + // Capture stderr output for error detection while ensuring it's visible to user + if (codeAgent.stderr) { + codeAgent.stderr.on("data", (data: Buffer) => { + const output = data.toString(); + stderrOutput += output; + process.stderr.write(output); + }); + } + + // Handle errors + codeAgent.on("error", (error: NodeJS.ErrnoException) => { + clearTimeout(timeout); + sandboxCleanup().catch(() => {}); + if (error.code === "ENOENT") { + reject( + new Error( + `${harness.displayName} CLI not found at: ${executablePath}\nPlease install ${harness.displayName} or specify the correct path with --agent-path`, + ), + ); + } else { + reject(new Error(`Failed to run ${harness.displayName}: ${error.message}`)); + } + }); + + // Handle process exit + codeAgent.on("close", (code: number | null) => { + clearTimeout(timeout); + sandboxCleanup().catch(() => {}); + console.log("\n" + "=".repeat(60)); + + if (timedOut) { + console.log(`⏰ ${harness.displayName} timed out after ${timeoutMinutes} minutes`); + reject(new Error(`${harness.displayName} timed out after ${timeoutMinutes} minutes`)); + return; + } + + // A usage/rate limit is account-global - abort the batch rather than + // treating this task as a normal failure (every other task would fail too). + const usage = detectUsageLimit(stdoutOutput, stderrOutput); + if (usage.limited) { + console.log( + `\n⏳ ${harness.displayName} hit a usage limit${ + usage.resetsAt ? ` (resets ${usage.resetsAt})` : "" + }`, + ); + reject(new UsageLimitError(usage.resetsAt)); + return; + } + + const maxTurnsReached = detectMaxTurnsReached(stdoutOutput, stderrOutput); + + if (maxTurnsReached) { + console.log("⚠️ Agent reached maximum turns limit without completing the task"); + console.log(" The task may be too complex or require more turns to complete"); + console.log(" Consider breaking it into smaller tasks or increasing the max-turns limit"); + + saveImplementationSummary(ctx, stdoutOutput, true); + + console.log("\n⏭️ Skipping commit and moving to next task (if any)..."); + + resolve({ + stdout: stdoutOutput, + outcome: "incomplete", + incompleteReasons: ["Agent reached maximum turns limit without completing the task"], + }); + return; + } + + if (code === 0) { + // Even if exit code is 0, check if Agent actually completed meaningful work. + // Only inspect stdout: stderr often contains transient "Error:" lines from + // recovered tool failures (especially with Cursor CLI). + const { incomplete: seemsIncomplete, reasons: incompleteReasons } = + detectIncompleteImplementation(stdoutOutput); + + // Save implementation summary to task directory (even if incomplete for analysis) + saveImplementationSummary(ctx, stdoutOutput, seemsIncomplete); + + if (seemsIncomplete) { + console.log("⚠️ Agent execution completed but appears to be incomplete or failed"); + console.log(` Reasons: ${incompleteReasons.join("; ")}`); + console.log(" Check the output above for specific issues"); + console.log("\n⏭️ Skipping commit and moving to next task (if any)..."); + + resolve({ + stdout: stdoutOutput, + outcome: "incomplete", + incompleteReasons, + }); + return; + } + + const openQuestions = detectOpenQuestions(stdoutOutput); + if (openQuestions.awaitingInput) { + console.log("\n⏸️ Agent is asking questions and needs your input before proceeding:"); + for (const question of openQuestions.questions) { + console.log(` • ${question}`); + } + console.log("\n⏭️ Skipping commit and PR until the questions are answered..."); + resolve({ + stdout: stdoutOutput, + outcome: "awaiting-input", + openQuestions: openQuestions.questions, + }); + return; + } + + console.log("✅ Agent execution completed successfully"); + resolve({ stdout: stdoutOutput, outcome: "complete" }); + } else { + console.log(`❌ Agent exited with non-zero code ${code}`); + console.log(" No JIRA comment will be posted due to execution failure"); + reject(new Error(`Agent exited with code ${code}`)); + } + }); + }); +} + +/** Persist the implementation summary (complete or incomplete) for analysis. */ +function saveImplementationSummary(ctx: TaskContext, stdout: string, incomplete: boolean): void { + if (!ctx.taskKey || !stdout.trim()) { + return; + } + try { + const summaryFile = join( + ctx.taskDir, + incomplete ? "implementation-summary-incomplete.md" : "implementation-summary.md", + ); + writeFileSync(summaryFile, stdout, "utf8"); + console.log( + incomplete + ? `\n💾 Saved incomplete implementation to: ${summaryFile}` + : `\n💾 Saved implementation summary to: ${summaryFile}`, + ); + } catch (saveError) { + console.warn(`⚠️ Failed to save implementation summary: ${saveError}`); + } +} + +/** Build the prompt for a re-implementation run driven by verify findings. */ +export function buildLoopbackPrompt(taskContent: string, feedback: ReviewFeedback): string { + const itemsList = feedback.items + .map( + (item, idx) => + `${idx + 1}. **[${item.priority.toUpperCase()}] ${item.category}** ${item.file ? `in \`${item.file}\`` : ""}${item.line ? ` (line ${item.line})` : ""} + - Issue: ${item.issue} + - Suggestion: ${item.suggestion}`, + ) + .join("\n\n"); + + return `Your previous implementation of this task did not satisfy the requirements. A verification pass found the following problems: + +## Verification Summary +${feedback.summary} + +## Findings to Address +${itemsList} + +## Instructions +1. Address each finding above - make the implementation actually satisfy the task requirements +2. Make focused changes; do not start over unless necessary +3. Do NOT commit or push - this is handled automatically + +For reference, here is the original task: +--- +${taskContent} +--- + +Now fix the implementation.`; +} + +/** Pipeline step wrapping {@link runImplementation}. */ +export class ImplementStep implements PipelineStep { + readonly name = "implement"; + + async run(ctx: TaskContext): Promise { + let promptOverride: string | undefined; + if (ctx.pendingPromptOverride) { + promptOverride = ctx.pendingPromptOverride; + ctx.pendingPromptOverride = undefined; + } else if (ctx.loopbackFeedback) { + promptOverride = buildLoopbackPrompt(ctx.taskContent, ctx.loopbackFeedback); + ctx.loopbackFeedback = undefined; + } + + const result = await runImplementation(ctx, promptOverride); + ctx.implementationOutput = result.stdout; + + if (result.outcome === "incomplete") { + return { + status: StepStatus.Halt, + haltKind: "incomplete", + reason: result.incompleteReasons?.join("; ") ?? "Implementation appears incomplete", + }; + } + if (result.outcome === "awaiting-input") { + if (ctx.tracker && !ctx.skipComments && ctx.taskKey) { + try { + const questionList = (result.openQuestions ?? []) + .map((question) => `- ${question}`) + .join("\n"); + await ctx.tracker.postComment(ctx.taskKey, { + format: "markdown", + body: + `🤖 The agent needs input before it can implement this task:\n\n${questionList}\n\n` + + "Answer in the task description or a comment, then re-run devintern.", + }); + console.log("💬 Posted the questions as a comment on the task"); + } catch (commentError) { + console.warn(`⚠️ Failed to post questions comment: ${commentError}`); + } + } + return { + status: StepStatus.Halt, + haltKind: "stop", + reason: "Agent is awaiting user input", + }; + } + return { status: StepStatus.Continue }; + } +} + +export const implementStepDefinition: StepDefinition = { + name: "implement", + create: () => new ImplementStep(), +}; diff --git a/packages/code/src/lib/pipeline/steps/plan-detection.ts b/packages/code/src/lib/pipeline/steps/plan-detection.ts new file mode 100644 index 0000000..8e2aa2f --- /dev/null +++ b/packages/code/src/lib/pipeline/steps/plan-detection.ts @@ -0,0 +1,102 @@ +/** + * Detection of plan-only agent behavior and the follow-up prompt asking the + * agent to actually implement its plan. Extracted from src/index.ts. + */ + +/** + * Detect plan-only agent behavior and extract a plan file path if present. + * + * @param agentOutput - Raw agent stdout + * @returns Plan file path, or `null` when implementation appears complete + */ +export function detectPlanOnlyBehavior(agentOutput: string): string | null { + // Check for common plan creation patterns (specific phrases first) + const planCreationPatterns = [ + /I'?ve created (a|an|the) (comprehensive )?(implementation )?plan/i, + /created a plan for/i, + /plan has been created/i, + /implementation plan is (now )?ready/i, + /The plan is (now )?ready/i, + /plan is ready for (your )?review/i, + /Here'?s a summary:?\s*\n+##.*plan/i, + /drafted a plan/i, + /wrote out a plan/i, + /plan (file )?(is )?(available|saved)/i, + /##.*plan.*summary/i, + ]; + + const hasPlanCreationLanguage = planCreationPatterns.some((pattern) => pattern.test(agentOutput)); + + // Fallback: if "plan" appears with context suggesting plan-only behavior + // (since this function is only called when there are no changes to commit) + const hasPlanFallback = + !hasPlanCreationLanguage && + /\bplan\b/i.test(agentOutput) && + /summary|review|ready|created|implementation|approach|steps|changes (required|needed)/i.test( + agentOutput, + ); + + if (!hasPlanCreationLanguage && !hasPlanFallback) { + return null; + } + + // Try to extract the plan file path + // Common patterns: + // - "available at `/path/to/plan.md`" + // - "available at /path/to/plan.md" + // - "saved to: /path/to/plan.md" + // - ~/.claude/plans/something.md + const pathPatterns = [ + /(?:available at|saved to:?)\s*[`"]?((?:\/[^\s`"]+|~\/\.claude\/plans\/[^\s`"]+)\.md)[`"]?/i, + /[`"]((?:\/home\/[^\s`"]+|~)\/\.claude\/plans\/[^\s`"]+\.md)[`"]/, + /(\/home\/[^\s]+\/\.claude\/plans\/[^\s]+\.md)/, + ]; + + for (const pattern of pathPatterns) { + const match = agentOutput.match(pattern); + if (match && match[1]) { + let planPath = match[1]; + // Expand ~ to home directory + if (planPath.startsWith("~")) { + const homeDir = process.env.HOME || "/tmp"; + planPath = planPath.replace("~", homeDir); + } + return planPath; + } + } + + // If we detected plan creation language but couldn't extract the path, + // return a sentinel value to indicate plan-only behavior + return "PLAN_DETECTED_NO_PATH"; +} + +/** + * Build a follow-up prompt asking the agent to implement an existing plan file. + * + * @param planPath - Plan markdown path, or sentinel when path unknown + * @param originalTaskContent - Original formatted task prompt for context + */ +export function createPlanImplementationPrompt( + planPath: string | null, + originalTaskContent: string, +): string { + const planInstructions = + planPath && planPath !== "PLAN_DETECTED_NO_PATH" + ? `You previously created an implementation plan at: ${planPath} + +Please read this plan file and implement it NOW. Do not create another plan - actually write the code and make the changes described in the plan.` + : `You previously created an implementation plan but did not implement it. + +Please implement the task NOW. Do not just plan or describe what needs to be done - actually write the code and make the changes.`; + + return `${planInstructions} + +IMPORTANT: You MUST actually implement the changes, not just plan them. Create/modify files as needed. Do not exit until actual code changes have been made. + +For reference, here is the original task: +--- +${originalTaskContent} +--- + +Now implement the solution. Write the actual code.`; +} diff --git a/packages/code/src/lib/pipeline/steps/verify-step.ts b/packages/code/src/lib/pipeline/steps/verify-step.ts new file mode 100644 index 0000000..1602f54 --- /dev/null +++ b/packages/code/src/lib/pipeline/steps/verify-step.ts @@ -0,0 +1,219 @@ +/** + * The `verify` step: an agent-backed functional-requirements verifier and + * the primary declarative extension point of the pipeline. + * + * Shape: build prompt (task + committed diff) -> run agent -> parse a + * structured JSON verdict (the `ReviewFeedback` contract) -> decide. + * + * Failure model (two channels): + * - Execution errors (agent crashed, diff unavailable, unparseable JSON) + * throw {@link StepExecutionError}; the runner retries the step. + * - Verdict failures (requirements genuinely not met) return `onFail`: + * `"loopback"` (default) re-runs `implement` with the findings (bounded by + * `maxIterations`), `"halt"` stops and marks the task incomplete, + * `"warn"` records a warning and continues. + * + * Users can register any number of verify instances in `pipeline.steps` with + * different `prompt` / `onFail` / `minSeverity` settings - no code required. + */ + +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { isAbsolute, join, resolve } from "path"; +import { + filterByPriority, + getPRDiff, + parseReviewFeedback, + runAgentPrompt, +} from "../../auto-review-loop"; +import type { ReviewFeedback, ReviewPriority } from "../../../types/auto-review"; +import { StepExecutionError, StepStatus } from "../types"; +import type { PipelineStep, StepDefinition, StepResult, TaskContext } from "../types"; + +export interface VerifyStepConfig { + /** + * Custom verification instructions: either an inline string or a path to a + * prompt file (relative paths resolve against the working directory). The + * task content, diff, and JSON verdict format are always appended. + */ + prompt?: string; + /** What to do on a failed verdict (default "loopback"). */ + onFail?: "loopback" | "halt" | "warn"; + /** Loopback bound when `onFail` is "loopback" (default 3). */ + maxIterations?: number; + /** Findings at or above this priority fail the verdict (default "high"). */ + minSeverity?: ReviewPriority; +} + +/** Injectable dependencies (for tests - no subprocess required). */ +export interface VerifyStepDeps { + runAgentPrompt: typeof runAgentPrompt; + getPRDiff: typeof getPRDiff; +} + +const DEFAULT_INSTRUCTIONS = `You are verifying whether an implementation satisfies the functional requirements of a task. Analyze the task description and the code diff, then judge whether the requirements are actually met. + +Focus on: +1. **Functional completeness**: Does the diff implement everything the task asks for? +2. **Correctness**: Does the implementation do what the requirements describe (not just compile)? +3. **Acceptance criteria**: Are all stated acceptance criteria satisfied? +4. **Regressions**: Does the change appear to break existing behavior the task did not ask to change?`; + +export class VerifyStep implements PipelineStep { + readonly name = "verify"; + + constructor( + private readonly config: VerifyStepConfig = {}, + private readonly deps: VerifyStepDeps = { runAgentPrompt, getPRDiff }, + ) {} + + async run(ctx: TaskContext): Promise { + if (!ctx.enableGit) { + return { + status: StepStatus.WarnContinue, + reason: "verify skipped: git workflow disabled, no committed diff to verify", + }; + } + + const minSeverity = this.config.minSeverity ?? "high"; + const onFail = this.config.onFail ?? "loopback"; + const maxIterations = this.config.maxIterations ?? 3; + + console.log("\n🔎 Verifying implementation against task requirements..."); + + let diff: string; + try { + diff = this.deps.getPRDiff(ctx.prTargetBranch, ctx.workingDir); + } catch (diffError) { + throw new StepExecutionError( + `verify: failed to get diff against '${ctx.prTargetBranch}': ${(diffError as Error).message}`, + diffError, + ); + } + + const prompt = this.buildPrompt(ctx, diff); + + let agentOutput: string; + try { + agentOutput = await this.deps.runAgentPrompt( + prompt, + ctx.workingDir, + ctx.harness, + ctx.executablePath, + ); + } catch (agentError) { + throw new StepExecutionError( + `verify: agent run failed: ${(agentError as Error).message}`, + agentError, + ); + } + + let feedback: ReviewFeedback; + try { + feedback = parseReviewFeedback(agentOutput); + } catch (parseError) { + throw new StepExecutionError( + `verify: could not parse verdict JSON: ${(parseError as Error).message}`, + parseError, + ); + } + + this.saveArtifacts(ctx, feedback, prompt); + + const blocking = filterByPriority(feedback.items, minSeverity); + + console.log(`\n📊 Verification summary: ${feedback.summary}`); + console.log( + ` Findings: ${feedback.items.length} total, ${blocking.length} at ${minSeverity}+ severity`, + ); + + if (blocking.length === 0) { + console.log("✅ Implementation satisfies the verified requirements"); + return { status: StepStatus.Continue, data: { findings: feedback.items.length } }; + } + + for (const item of blocking) { + const location = item.file ? ` (${item.file}${item.line ? `:${item.line}` : ""})` : ""; + console.log(` [${item.priority}]${location}: ${item.issue}`); + } + + switch (onFail) { + case "halt": + return { status: StepStatus.Halt, haltKind: "incomplete", reason: feedback.summary }; + case "warn": + return { status: StepStatus.WarnContinue, reason: feedback.summary }; + default: + return { + status: StepStatus.Loopback, + loopbackTo: "implement", + loopbackFeedback: feedback, + maxLoopbacks: maxIterations, + reason: feedback.summary, + }; + } + } + + private buildPrompt(ctx: TaskContext, diff: string): string { + let instructions = DEFAULT_INSTRUCTIONS; + if (this.config.prompt) { + const candidate = isAbsolute(this.config.prompt) + ? this.config.prompt + : resolve(ctx.workingDir, this.config.prompt); + if (existsSync(candidate)) { + instructions = readFileSync(candidate, "utf8"); + } else { + instructions = this.config.prompt; + } + } + + return `${instructions} + +## Task +--- +${ctx.taskContent} +--- + +## Implementation Diff +\`\`\`diff +${diff} +\`\`\` + +## Verdict Format +Provide your verdict as JSON in the following format: + +\`\`\`json +{ + "summary": "Brief assessment of whether the requirements are met (2-3 sentences)", + "items": [ + { + "priority": "critical|high|medium|low|info", + "category": "code-quality|bug|performance|security|testing|documentation|style", + "file": "path/to/file.ts", + "line": "42" or "42-45", + "issue": "Requirement not met / defect description", + "suggestion": "Specific actionable fix" + } + ], + "approved": false +} +\`\`\` + +Set "approved": true ONLY when every functional requirement is satisfied. Report each unmet requirement as an item with priority "high" or "critical". + +**IMPORTANT**: Your response must be valid JSON only. Do not include any explanatory text outside the JSON block. +`; + } + + private saveArtifacts(ctx: TaskContext, feedback: ReviewFeedback, prompt: string): void { + try { + writeFileSync(join(ctx.taskDir, "verify-feedback.json"), JSON.stringify(feedback, null, 2)); + writeFileSync(join(ctx.taskDir, "verify-prompt.txt"), prompt); + } catch (saveError) { + console.warn(`⚠️ Failed to save verification artifacts: ${saveError}`); + } + } +} + +export const verifyStepDefinition: StepDefinition = { + name: "verify", + create: (config) => new VerifyStep(config as VerifyStepConfig), +}; diff --git a/packages/code/src/lib/pipeline/types.ts b/packages/code/src/lib/pipeline/types.ts new file mode 100644 index 0000000..1533700 --- /dev/null +++ b/packages/code/src/lib/pipeline/types.ts @@ -0,0 +1,151 @@ +/** + * Core types for the extensible task pipeline. + * + * A pipeline is an ordered list of {@link PipelineStep}s that share one + * mutable {@link TaskContext}. Steps signal outcomes via {@link StepResult}: + * + * - Execution failures (subprocess crashed, unparseable verdict JSON, ...) + * are **thrown** as {@link StepExecutionError} and retried by the runner. + * - Verdict failures (requirements genuinely not met) are **returned** as + * `status: "loopback"` so the runner can re-run an earlier step with the + * findings, bounded by `maxLoopbacks`. + */ + +import type { AgentHarness } from "@devintern/agent-harness"; +import type { ProjectSettings } from "../../types/settings"; +import type { ReviewFeedback } from "../../types/auto-review"; +import type { TaskTrackerClient } from "../task-tracker-client"; + +/** Outcome of a single pipeline step. */ +export enum StepStatus { + /** Step succeeded; run the next step. */ + Continue = "continue", + /** Stop the pipeline. See {@link StepResult.haltKind}. */ + Halt = "halt", + /** Record a warning and run the next step. */ + WarnContinue = "warn", + /** Jump back to an earlier step (bounded), carrying feedback. */ + Loopback = "loopback", +} + +/** + * How a {@link StepStatus.Halt} is handled by the runner: + * - `"incomplete"` (default): the task genuinely failed - the runner invokes + * the `onHalt` callback (post incomplete-implementation comment, revert the + * ticket to To Do). + * - `"stop"`: stop the pipeline without marking the task incomplete (e.g. a + * pre-push hook could not be fixed; today's behavior is to stop quietly). + */ +export type HaltKind = "incomplete" | "stop"; + +/** Result returned by {@link PipelineStep.run}. */ +export interface StepResult { + status: StepStatus; + /** Halt reason / warning note (also used in ticket comments and logs). */ + reason?: string; + /** Halt flavor; defaults to `"incomplete"`. Only meaningful for Halt. */ + haltKind?: HaltKind; + /** Loopback target step name (defaults to `"implement"`). */ + loopbackTo?: string; + /** Structured findings handed to the loopback target via `ctx.loopbackFeedback`. */ + loopbackFeedback?: ReviewFeedback; + /** Per-step loopback bound (defaults to the runner's `defaultMaxLoopbacks`). */ + maxLoopbacks?: number; + /** Arbitrary step-specific data, recorded in `ctx.results`. */ + data?: Record; +} + +/** + * Thrown by steps for retryable execution failures (agent subprocess failed, + * verdict JSON unparseable, diff unavailable). The runner retries the step up + * to its retry limit, then halts. + */ +export class StepExecutionError extends Error { + constructor( + message: string, + public readonly cause?: unknown, + ) { + super(message); + this.name = "StepExecutionError"; + } +} + +/** A single unit of work in the task pipeline. */ +export interface PipelineStep { + readonly name: string; + run(ctx: TaskContext): Promise; +} + +/** + * Factory for pipeline steps. Registered in the step registry and referenced + * from `settings.json` via `{ "use": "", ...config }`. + */ +export interface StepDefinition { + readonly name: string; + create(config: Record): PipelineStep; +} + +/** + * Mutable state threaded through every step of a task run. + */ +export interface TaskContext { + // --- Task identity ------------------------------------------------------- + /** Task tracker issue key (absent for some ad-hoc runs). */ + taskKey?: string; + /** Issue summary used for commit messages and comments. */ + taskSummary?: string; + /** Raw tracker task object (used for PR creation and description extraction). */ + // oxlint-disable-next-line no-explicit-any + task?: any; + + // --- Files / directories ------------------------------------------------- + /** Path to the formatted task markdown prompt. */ + taskFile: string; + /** Contents of {@link taskFile}. */ + taskContent: string; + /** Per-task output directory (summaries, artifacts, logs). */ + taskDir: string; + /** Git working directory for the run. */ + workingDir: string; + + // --- Agent --------------------------------------------------------------- + harness: AgentHarness; + executablePath: string; + maxTurns: number; + + // --- Tracker / settings -------------------------------------------------- + tracker?: TaskTrackerClient; + projectSettings: ProjectSettings | null; + prTargetBranch: string; + hookRetries: number; + gitAuthor?: { name: string; email: string }; + + // --- Flags --------------------------------------------------------------- + enableGit: boolean; + createPr: boolean; + skipComments: boolean; + autoReview: boolean; + autoReviewIterations: number; + skipClarityCheck: boolean; + verbose: boolean; + + // --- Mutable run state --------------------------------------------------- + /** Stdout of the most recent implementation agent run. */ + implementationOutput: string; + /** Whether the current implement run is a plan-only retry. */ + isPlanRetry: boolean; + /** Findings handed to the implement step by a loopback (consumed + cleared). */ + loopbackFeedback?: ReviewFeedback; + /** Full prompt override for the next implement run (consumed + cleared). */ + pendingPromptOverride?: string; + /** Set by the commit step when changes were committed. */ + commitSucceeded: boolean; + /** Set by the auto-review step after a successful review loop. */ + autoReviewRan: boolean; + /** Set once the pre-push hook has been validated locally in this run. */ + hookValidated: boolean; + + // --- Bookkeeping --------------------------------------------------------- + results: StepResult[]; + warnings: string[]; +} diff --git a/packages/code/src/lib/project-settings.ts b/packages/code/src/lib/project-settings.ts new file mode 100644 index 0000000..1238f65 --- /dev/null +++ b/packages/code/src/lib/project-settings.ts @@ -0,0 +1,149 @@ +/** + * Project settings loading and per-project workflow status resolution. + * + * Extracted from src/index.ts so pipeline steps can consume these helpers + * without importing the CLI entrypoint (which would create an import cycle). + */ + +import { existsSync, readFileSync } from "fs"; +import { resolve } from "path"; +import type { BaseProjectConfig, ProjectSettings, TrackerSection } from "../types/settings"; + +/** Load `.devintern-code/settings.json` from the current working directory. */ +export function loadProjectSettings(): ProjectSettings | null { + const settingsPath = resolve(process.cwd(), ".devintern-code", "settings.json"); + + if (!existsSync(settingsPath)) { + return null; + } + + try { + const settingsContent = readFileSync(settingsPath, "utf8"); + const settings = JSON.parse(settingsContent) as ProjectSettings; + return settings; + } catch (error) { + console.warn(`⚠️ Failed to parse settings.json: ${error}`); + return null; + } +} + +/** + * Resolve the active tracker type from environment. + */ +export function getActiveTrackerType(): string { + return (process.env.TASK_TRACKER || "jira").toLowerCase(); +} + +/** Resolve the settings project key for a task (board key for Trello). */ +export function resolveProjectKey(taskKey: string, task?: { raw: unknown }): string { + const trackerType = getActiveTrackerType(); + if (trackerType === "trello") { + const raw = task?.raw as + | { idBoard?: string; board?: { id?: string; shortLink?: string } } + | undefined; + const boardKey = raw?.board?.shortLink ?? raw?.idBoard ?? process.env.TRELLO_DEFAULT_BOARD_ID; + if (boardKey) { + return boardKey; + } + } + if (trackerType === "github" && process.env.GITHUB_REPO) { + return process.env.GITHUB_REPO; + } + if (trackerType === "azure-devops" && process.env.AZURE_DEVOPS_PROJECT) { + return process.env.AZURE_DEVOPS_PROJECT; + } + if (trackerType === "asana") { + const raw = task?.raw as { memberships?: Array<{ project?: { gid?: string } }> } | undefined; + const projectGid = + raw?.memberships?.find((membership) => membership.project?.gid)?.project?.gid ?? + process.env.ASANA_DEFAULT_PROJECT_GID; + if (projectGid) { + return projectGid; + } + } + return taskKey.split("-")[0] ?? taskKey; +} + +/** + * Resolve tracker-specific project configuration from settings. + * + * Checks the tracker-specific section first (e.g., `settings.jira.projects`), + * then falls back to the legacy top-level `projects` map for backward + * compatibility when the active tracker is Jira. + */ +export function resolveProjectConfig( + projectKey: string, + settings: ProjectSettings | null, + trackerType?: string, +): BaseProjectConfig | undefined { + if (!settings) { + return undefined; + } + + const tracker = trackerType ? trackerType.toLowerCase() : getActiveTrackerType(); + + // 1. Check tracker-specific section first + const trackerSection = settings[tracker as keyof ProjectSettings]; + if (trackerSection && typeof trackerSection === "object" && "projects" in trackerSection) { + const projects = (trackerSection as TrackerSection).projects; + if (projects) { + const config = projects[projectKey]; + if (config) { + return config; + } + + // Trello cards expose a 24-char idBoard; settings often use the board short link. + if (tracker === "trello") { + const defaultBoardId = process.env.TRELLO_DEFAULT_BOARD_ID; + if (defaultBoardId && defaultBoardId !== projectKey && projects[defaultBoardId]) { + return projects[defaultBoardId]; + } + + const projectKeys = Object.keys(projects); + if (projectKeys.length === 1 && projectKeys[0]) { + return projects[projectKeys[0]]; + } + } + } + } + + // 2. Fall back to legacy top-level `projects` for Jira backward compatibility. + // The legacy map was originally Jira-only, so we only fall back for Jira. + if (tracker === "jira" && settings.projects) { + return settings.projects[projectKey]; + } + + return undefined; +} + +/** Resolve the status name to use after PR creation for a project. */ +export function getPrStatusForProject( + projectKey: string, + settings: ProjectSettings | null, +): string | undefined { + return resolveProjectConfig(projectKey, settings)?.prStatus; +} + +/** Resolve the "In Progress" status name for a project. */ +export function getInProgressStatusForProject( + projectKey: string, + settings: ProjectSettings | null, +): string | undefined { + return resolveProjectConfig(projectKey, settings)?.inProgressStatus; +} + +/** Resolve the "To Do" status name for a project. */ +export function getTodoStatusForProject( + projectKey: string, + settings: ProjectSettings | null, +): string | undefined { + return resolveProjectConfig(projectKey, settings)?.todoStatus; +} + +/** Return an optional story-points custom field override from project settings. */ +export function getStoryPointsFieldForProject( + projectKey: string, + settings: ProjectSettings | null, +): string | undefined { + return resolveProjectConfig(projectKey, settings)?.storyPointsField; +} diff --git a/packages/code/src/types/settings.ts b/packages/code/src/types/settings.ts index f01d1bf..a2c2579 100644 --- a/packages/code/src/types/settings.ts +++ b/packages/code/src/types/settings.ts @@ -60,6 +60,34 @@ export type GitHubProjectConfig = BaseProjectConfig; /** Markdown-specific project configuration (currently uses the common base). */ export type MarkdownProjectConfig = BaseProjectConfig; +/** + * One entry in `pipeline.steps`: the registered step name plus arbitrary + * step-specific configuration passed to the step's factory. + */ +export interface PipelineStepConfig { + /** Registered step name (e.g. "implement", "verify", or a plugin step). */ + use: string; + /** Step-specific configuration (e.g. `onFail`, `minSeverity`). */ + [key: string]: unknown; +} + +/** + * Pipeline configuration (global, not per-project). + */ +export interface PipelineConfig { + /** + * Ordered pipeline steps. When omitted, the default pipeline is used: + * implement -> commit -> auto-review -> finalize. + */ + steps?: PipelineStepConfig[]; + /** + * Step plugin modules to load before resolving `steps`. Each entry is a + * file path (resolved against the project root) or an npm package name; + * the module must default-export a StepDefinition. + */ + plugins?: string[]; +} + /** * A tracker-specific section containing per-project configurations. */ @@ -104,4 +132,7 @@ export interface ProjectSettings { github?: TrackerSection; /** Markdown-specific project configurations */ markdown?: TrackerSection; + + /** Task pipeline configuration (steps and step plugins). */ + pipeline?: PipelineConfig; } diff --git a/packages/code/tests/pipeline-config.test.ts b/packages/code/tests/pipeline-config.test.ts new file mode 100644 index 0000000..cba87ff --- /dev/null +++ b/packages/code/tests/pipeline-config.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { DEFAULT_PIPELINE, resolvePipelineSteps } from "../src/lib/pipeline/config"; +import { __resetStepsForTests, getStep, registerStep } from "../src/lib/pipeline/registry"; +import { StepStatus } from "../src/lib/pipeline/types"; +import type { PipelineStep, StepResult } from "../src/lib/pipeline/types"; + +afterEach(() => { + __resetStepsForTests(); +}); + +describe("Pipeline config", () => { + test("default pipeline is implement -> commit -> auto-review -> finalize", () => { + expect(DEFAULT_PIPELINE.map((s) => s.use)).toEqual([ + "implement", + "commit", + "auto-review", + "finalize", + ]); + }); + + test("resolves the default pipeline when steps are unset", () => { + const steps = resolvePipelineSteps(undefined); + expect(steps.map((s) => s.name)).toEqual(["implement", "commit", "auto-review", "finalize"]); + }); + + test("resolves the default pipeline when steps are an empty list", () => { + const steps = resolvePipelineSteps([]); + expect(steps.map((s) => s.name)).toEqual(["implement", "commit", "auto-review", "finalize"]); + }); + + test("passes { use, ...config } entries to the step factory without 'use'", () => { + const captured: Record[] = []; + registerStep({ + name: "config-capture-step", + create(config): PipelineStep { + captured.push(config); + return { + name: "config-capture-step", + async run(): Promise { + return { status: StepStatus.Continue }; + }, + }; + }, + }); + + const steps = resolvePipelineSteps([ + { use: "config-capture-step", threshold: 0.9, onFail: "warn" }, + ]); + + expect(steps).toHaveLength(1); + expect(captured).toEqual([{ threshold: 0.9, onFail: "warn" }]); + }); + + test("resolves built-in steps with per-instance config", () => { + const steps = resolvePipelineSteps([ + { use: "implement" }, + { use: "commit" }, + { use: "verify", onFail: "warn", minSeverity: "low" }, + { use: "finalize" }, + ]); + expect(steps.map((s) => s.name)).toEqual(["implement", "commit", "verify", "finalize"]); + }); + + test("throws a clear error listing available steps for an unknown step", () => { + expect(() => resolvePipelineSteps([{ use: "nonexistent-step" }])).toThrow( + /Unknown pipeline step 'nonexistent-step'/, + ); + expect(() => resolvePipelineSteps([{ use: "nonexistent-step" }])).toThrow(/implement/); + expect(() => resolvePipelineSteps([{ use: "nonexistent-step" }])).toThrow(/finalize/); + }); + + test("throws on a malformed entry without a 'use' field", () => { + // oxlint-disable-next-line no-explicit-any + expect(() => resolvePipelineSteps([{ foo: "bar" } as any])).toThrow(/'use' field/); + }); + + test("built-in steps are registered", () => { + for (const name of ["clarity", "implement", "commit", "auto-review", "verify", "finalize"]) { + expect(getStep(name)?.name).toBe(name); + } + }); +}); diff --git a/packages/code/tests/pipeline-plugins.test.ts b/packages/code/tests/pipeline-plugins.test.ts new file mode 100644 index 0000000..91a4b10 --- /dev/null +++ b/packages/code/tests/pipeline-plugins.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { loadPlugins, resolvePipelineSteps } from "../src/lib/pipeline/config"; +import { __resetStepsForTests, getStep } from "../src/lib/pipeline/registry"; +import { StepStatus } from "../src/lib/pipeline/types"; + +let pluginDir: string; +let unique = 0; + +beforeEach(() => { + pluginDir = mkdtempSync(join(tmpdir(), "devintern-pipeline-plugins-")); + unique++; +}); + +afterEach(() => { + rmSync(pluginDir, { recursive: true, force: true }); + __resetStepsForTests(); +}); + +/** Write a plugin module and return its file name (relative to pluginDir). */ +function writePlugin(fileName: string, contents: string): string { + writeFileSync(join(pluginDir, fileName), contents, "utf8"); + return fileName; +} + +describe("Pipeline plugins", () => { + test("loads a local module and registers its default-exported step", async () => { + const stepName = `custom-lint-${unique}-${Date.now()}`; + const file = writePlugin( + "my-lint.ts", + `const definition = { + name: "${stepName}", + create(config) { + return { + name: "${stepName}", + async run(ctx) { + return { status: "continue", data: { threshold: config.threshold } }; + }, + }; + }, +}; +export default definition; +`, + ); + + // Relative path resolved against the project root (pluginDir here). + await loadPlugins([`./${file}`], pluginDir); + + const definition = getStep(stepName); + expect(definition).toBeDefined(); + expect(definition!.name).toBe(stepName); + + // The plugin step is now usable from pipeline.steps, including config. + const steps = resolvePipelineSteps([{ use: stepName, threshold: 0.9 }]); + expect(steps).toHaveLength(1); + expect(steps[0].name).toBe(stepName); + + // And it runs like any other step. + // oxlint-disable-next-line no-explicit-any + const result = await steps[0].run({} as any); + expect(result.status).toBe(StepStatus.Continue); + expect(result.data).toEqual({ threshold: 0.9 }); + }); + + test("loads plugins referenced by absolute path", async () => { + const stepName = `abs-path-step-${unique}-${Date.now()}`; + writePlugin( + "abs-step.ts", + `export default { name: "${stepName}", create: () => ({ name: "${stepName}", run: async () => ({ status: "continue" }) }) }; +`, + ); + + await loadPlugins([join(pluginDir, "abs-step.ts")], "/somewhere/else"); + expect(getStep(stepName)).toBeDefined(); + }); + + test("loading the same plugin again is a no-op for batch task runs", async () => { + const stepName = `batch-step-${unique}-${Date.now()}`; + const file = writePlugin( + "batch-step.ts", + `export default { name: "${stepName}", create: () => ({ name: "${stepName}", run: async () => ({ status: "continue" }) }) }; +`, + ); + + await loadPlugins([`./${file}`], pluginDir); + await loadPlugins([`./${file}`], pluginDir); + + expect(getStep(stepName)).toBeDefined(); + }); + + test("throws a clear error when the module has no default export", async () => { + const file = writePlugin( + "no-default.ts", + `export const definition = { name: "named-only", create: () => ({ name: "named-only", run: async () => ({ status: "continue" }) }) }; +`, + ); + + await expect(loadPlugins([`./${file}`], pluginDir)).rejects.toThrow( + /must default-export a StepDefinition/, + ); + await expect(loadPlugins([`./${file}`], pluginDir)).rejects.toThrow(/no default export/); + expect(getStep("named-only")).toBeUndefined(); + }); + + test("throws a clear error when the default export is not a StepDefinition", async () => { + const file = writePlugin( + "bad-shape.ts", + `export default { notAName: true }; +`, + ); + + await expect(loadPlugins([`./${file}`], pluginDir)).rejects.toThrow( + /must default-export a StepDefinition/, + ); + }); + + test("throws on a name collision with a built-in step", async () => { + const file = writePlugin( + "collides.ts", + `export default { name: "implement", create: () => ({ name: "implement", run: async () => ({ status: "continue" }) }) }; +`, + ); + + await expect(loadPlugins([`./${file}`], pluginDir)).rejects.toThrow( + /'implement' is already registered/, + ); + }); + + test("throws a clear error for an unresolvable plugin path", async () => { + await expect(loadPlugins(["./does-not-exist.ts"], pluginDir)).rejects.toThrow( + /Failed to load pipeline plugin '\.\/does-not-exist\.ts'/, + ); + }); + + test("no plugins is a no-op", async () => { + await loadPlugins(undefined, pluginDir); + await loadPlugins([], pluginDir); + }); +}); diff --git a/packages/code/tests/pipeline-runner.test.ts b/packages/code/tests/pipeline-runner.test.ts new file mode 100644 index 0000000..fc38c6f --- /dev/null +++ b/packages/code/tests/pipeline-runner.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, test } from "bun:test"; +import { UsageLimitError } from "../src/lib/errors"; +import { Pipeline } from "../src/lib/pipeline/pipeline"; +import { StepExecutionError, StepStatus } from "../src/lib/pipeline/types"; +import type { PipelineStep, StepResult, TaskContext } from "../src/lib/pipeline/types"; + +/** Minimal TaskContext for runner tests (no subprocess, no git). */ +function makeCtx(): TaskContext { + return { + taskKey: "TEST-1", + taskSummary: "Test task", + taskFile: "/tmp/nonexistent-task.md", + taskContent: "task content", + taskDir: "/tmp/nonexistent-task-dir", + workingDir: "/tmp", + // oxlint-disable-next-line no-explicit-any + harness: {} as any, + executablePath: "/bin/true", + maxTurns: 5, + projectSettings: null, + prTargetBranch: "main", + hookRetries: 0, + enableGit: false, + createPr: false, + skipComments: true, + autoReview: false, + autoReviewIterations: 1, + skipClarityCheck: true, + verbose: false, + implementationOutput: "", + isPlanRetry: false, + commitSucceeded: false, + autoReviewRan: false, + hookValidated: false, + results: [], + warnings: [], + }; +} + +/** Build a fake step that records calls and returns scripted results. */ +function fakeStep(name: string, results: Array, calls: string[]): PipelineStep { + let call = 0; + return { + name, + async run(): Promise { + calls.push(name); + const scripted = results[Math.min(call, results.length - 1)]; + call++; + if (scripted instanceof Error) { + throw scripted; + } + return scripted; + }, + }; +} + +const CONTINUE: StepResult = { status: StepStatus.Continue }; + +describe("Pipeline runner", () => { + test("runs steps in order on Continue", async () => { + const calls: string[] = []; + const pipeline = new Pipeline([ + fakeStep("one", [CONTINUE], calls), + fakeStep("two", [CONTINUE], calls), + fakeStep("three", [CONTINUE], calls), + ]); + const ctx = makeCtx(); + await pipeline.run(ctx); + expect(calls).toEqual(["one", "two", "three"]); + expect(ctx.results).toHaveLength(3); + }); + + test("WarnContinue records a warning and continues", async () => { + const calls: string[] = []; + const pipeline = new Pipeline([ + fakeStep("warned", [{ status: StepStatus.WarnContinue, reason: "soft failure" }], calls), + fakeStep("after", [CONTINUE], calls), + ]); + const ctx = makeCtx(); + await pipeline.run(ctx); + expect(calls).toEqual(["warned", "after"]); + expect(ctx.warnings).toEqual(["warned: soft failure"]); + }); + + test("Halt stops the pipeline and invokes onHalt for incomplete halts", async () => { + const calls: string[] = []; + const halts: StepResult[] = []; + const pipeline = new Pipeline( + [ + fakeStep("halting", [{ status: StepStatus.Halt, reason: "did not finish" }], calls), + fakeStep("never", [CONTINUE], calls), + ], + { + onHalt: async (_ctx, result) => { + halts.push(result); + }, + }, + ); + await pipeline.run(makeCtx()); + expect(calls).toEqual(["halting"]); + expect(halts).toHaveLength(1); + expect(halts[0].reason).toBe("did not finish"); + }); + + test("Halt with haltKind 'stop' does NOT invoke onHalt", async () => { + const calls: string[] = []; + const halts: StepResult[] = []; + const pipeline = new Pipeline( + [ + fakeStep("stopper", [{ status: StepStatus.Halt, haltKind: "stop" }], calls), + fakeStep("never", [CONTINUE], calls), + ], + { + onHalt: async (_ctx, result) => { + halts.push(result); + }, + }, + ); + await pipeline.run(makeCtx()); + expect(calls).toEqual(["stopper"]); + expect(halts).toHaveLength(0); + }); + + test("retries a step on StepExecutionError up to the limit, then succeeds", async () => { + const calls: string[] = []; + const pipeline = new Pipeline( + [ + fakeStep("flaky", [new StepExecutionError("transient"), CONTINUE], calls), + fakeStep("after", [CONTINUE], calls), + ], + { maxStepRetries: 1 }, + ); + const ctx = makeCtx(); + await pipeline.run(ctx); + expect(calls).toEqual(["flaky", "flaky", "after"]); + }); + + test("halts (incomplete) when retries are exhausted", async () => { + const calls: string[] = []; + const halts: StepResult[] = []; + const pipeline = new Pipeline( + [ + fakeStep("always-fails", [new StepExecutionError("boom")], calls), + fakeStep("never", [CONTINUE], calls), + ], + { + maxStepRetries: 2, + onHalt: async (_ctx, result) => { + halts.push(result); + }, + }, + ); + await pipeline.run(makeCtx()); + expect(calls).toEqual(["always-fails", "always-fails", "always-fails"]); + expect(halts).toHaveLength(1); + expect(halts[0].reason).toContain("boom"); + }); + + test("UsageLimitError is rethrown, never retried", async () => { + const calls: string[] = []; + const pipeline = new Pipeline([fakeStep("limited", [new UsageLimitError("tomorrow")], calls)], { + maxStepRetries: 5, + }); + await expect(pipeline.run(makeCtx())).rejects.toBeInstanceOf(UsageLimitError); + expect(calls).toEqual(["limited"]); + }); + + test("non-StepExecutionError exceptions propagate", async () => { + const calls: string[] = []; + const pipeline = new Pipeline([ + fakeStep("crash", [new Error("agent exited with code 1")], calls), + ]); + await expect(pipeline.run(makeCtx())).rejects.toThrow("agent exited with code 1"); + }); + + test("loopback jumps to the target step and carries feedback", async () => { + const calls: string[] = []; + const feedback = { summary: "not done", items: [], approved: false }; + const ctx = makeCtx(); + const seenFeedback: unknown[] = []; + + const implement: PipelineStep = { + name: "implement", + async run(c): Promise { + calls.push("implement"); + seenFeedback.push(c.loopbackFeedback); + c.loopbackFeedback = undefined; // implement consumes the feedback + return CONTINUE; + }, + }; + const verify = fakeStep( + "verify", + [ + { + status: StepStatus.Loopback, + loopbackTo: "implement", + loopbackFeedback: feedback, + maxLoopbacks: 3, + }, + CONTINUE, + ], + calls, + ); + + const pipeline = new Pipeline([implement, verify]); + await pipeline.run(ctx); + expect(calls).toEqual(["implement", "verify", "implement", "verify"]); + expect(seenFeedback).toEqual([undefined, feedback]); + }); + + test("loopback is bounded by maxLoopbacks, then halts via onHalt", async () => { + const calls: string[] = []; + const halts: StepResult[] = []; + const feedback = { summary: "still failing", items: [], approved: false }; + + const pipeline = new Pipeline( + [ + fakeStep("implement", [CONTINUE], calls), + fakeStep( + "verify", + [ + { + status: StepStatus.Loopback, + loopbackTo: "implement", + loopbackFeedback: feedback, + maxLoopbacks: 2, + reason: "requirements unmet", + }, + ], + calls, + ), + fakeStep("finalize", [CONTINUE], calls), + ], + { + onHalt: async (_ctx, result) => { + halts.push(result); + }, + }, + ); + + await pipeline.run(makeCtx()); + // initial pass + 2 loopbacks, then the 3rd loopback attempt exceeds the bound + expect(calls).toEqual(["implement", "verify", "implement", "verify", "implement", "verify"]); + expect(calls).not.toContain("finalize"); + expect(halts).toHaveLength(1); + expect(halts[0].reason).toBe("requirements unmet"); + }); + + test("loopback to an unknown step halts", async () => { + const calls: string[] = []; + const halts: StepResult[] = []; + const pipeline = new Pipeline( + [fakeStep("verify", [{ status: StepStatus.Loopback, loopbackTo: "does-not-exist" }], calls)], + { + onHalt: async (_ctx, result) => { + halts.push(result); + }, + }, + ); + await pipeline.run(makeCtx()); + expect(halts).toHaveLength(1); + expect(halts[0].reason).toContain("does-not-exist"); + }); +}); diff --git a/packages/code/tests/verify-step.test.ts b/packages/code/tests/verify-step.test.ts new file mode 100644 index 0000000..025809a --- /dev/null +++ b/packages/code/tests/verify-step.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { VerifyStep } from "../src/lib/pipeline/steps/verify-step"; +import type { VerifyStepDeps } from "../src/lib/pipeline/steps/verify-step"; +import { StepExecutionError, StepStatus } from "../src/lib/pipeline/types"; +import type { TaskContext } from "../src/lib/pipeline/types"; + +let taskDir: string; + +beforeEach(() => { + taskDir = mkdtempSync(join(tmpdir(), "devintern-verify-step-")); +}); + +afterEach(() => { + rmSync(taskDir, { recursive: true, force: true }); +}); + +function makeCtx(overrides: Partial = {}): TaskContext { + return { + taskKey: "TEST-1", + taskSummary: "Test task", + taskFile: join(taskDir, "task-details.md"), + taskContent: "Implement the widget", + taskDir, + workingDir: taskDir, + // oxlint-disable-next-line no-explicit-any + harness: { displayName: "Fake Agent" } as any, + executablePath: "/bin/true", + maxTurns: 5, + projectSettings: null, + prTargetBranch: "main", + hookRetries: 0, + enableGit: true, + createPr: false, + skipComments: true, + autoReview: false, + autoReviewIterations: 1, + skipClarityCheck: true, + verbose: false, + implementationOutput: "", + isPlanRetry: false, + commitSucceeded: true, + autoReviewRan: false, + hookValidated: false, + results: [], + warnings: [], + ...overrides, + }; +} + +function makeDeps(agentOutput: string): VerifyStepDeps { + return { + runAgentPrompt: async () => agentOutput, + getPRDiff: () => "diff --git a/widget.ts b/widget.ts\n+export const widget = 1;", + }; +} + +const PASS_OUTPUT = `\`\`\`json +{ + "summary": "All requirements satisfied.", + "items": [], + "approved": true +} +\`\`\``; + +const FAIL_OUTPUT = `\`\`\`json +{ + "summary": "The widget is not wired up.", + "items": [ + { + "priority": "high", + "category": "bug", + "file": "widget.ts", + "line": "1", + "issue": "Widget is exported but never registered", + "suggestion": "Register the widget in the registry" + }, + { + "priority": "low", + "category": "style", + "issue": "Minor naming nit", + "suggestion": "Rename" + } + ], + "approved": false +} +\`\`\``; + +describe("VerifyStep", () => { + test("passing verdict returns Continue", async () => { + const step = new VerifyStep({}, makeDeps(PASS_OUTPUT)); + const result = await step.run(makeCtx()); + expect(result.status).toBe(StepStatus.Continue); + }); + + test("low-severity-only findings pass at the default 'high' threshold", async () => { + const lowOnly = FAIL_OUTPUT.replace('"priority": "high"', '"priority": "low"'); + const step = new VerifyStep({}, makeDeps(lowOnly)); + const result = await step.run(makeCtx()); + expect(result.status).toBe(StepStatus.Continue); + }); + + test("failing verdict loops back to implement with the feedback payload", async () => { + const step = new VerifyStep({ maxIterations: 2 }, makeDeps(FAIL_OUTPUT)); + const result = await step.run(makeCtx()); + + expect(result.status).toBe(StepStatus.Loopback); + expect(result.loopbackTo).toBe("implement"); + expect(result.maxLoopbacks).toBe(2); + expect(result.loopbackFeedback?.summary).toBe("The widget is not wired up."); + expect(result.loopbackFeedback?.items).toHaveLength(2); + expect(result.loopbackFeedback?.approved).toBe(false); + }); + + test("minSeverity config controls what counts as a failure", async () => { + // At minSeverity "critical", a "high" finding is not blocking. + const step = new VerifyStep({ minSeverity: "critical" }, makeDeps(FAIL_OUTPUT)); + const result = await step.run(makeCtx()); + expect(result.status).toBe(StepStatus.Continue); + }); + + test("onFail 'halt' maps to an incomplete Halt with the verdict summary", async () => { + const step = new VerifyStep({ onFail: "halt" }, makeDeps(FAIL_OUTPUT)); + const result = await step.run(makeCtx()); + expect(result.status).toBe(StepStatus.Halt); + expect(result.haltKind).toBe("incomplete"); + expect(result.reason).toBe("The widget is not wired up."); + }); + + test("onFail 'warn' maps to WarnContinue", async () => { + const step = new VerifyStep({ onFail: "warn" }, makeDeps(FAIL_OUTPUT)); + const result = await step.run(makeCtx()); + expect(result.status).toBe(StepStatus.WarnContinue); + expect(result.reason).toBe("The widget is not wired up."); + }); + + test("unparseable JSON throws a retryable StepExecutionError", async () => { + const step = new VerifyStep({}, makeDeps("I could not produce a verdict, sorry.")); + await expect(step.run(makeCtx())).rejects.toBeInstanceOf(StepExecutionError); + }); + + test("agent failure throws a retryable StepExecutionError", async () => { + const step = new VerifyStep( + {}, + { + runAgentPrompt: async () => { + throw new Error("agent exploded"); + }, + getPRDiff: () => "diff", + }, + ); + await expect(step.run(makeCtx())).rejects.toBeInstanceOf(StepExecutionError); + }); + + test("diff failure throws a retryable StepExecutionError", async () => { + const step = new VerifyStep( + {}, + { + runAgentPrompt: async () => PASS_OUTPUT, + getPRDiff: () => { + throw new Error("remote branch not found"); + }, + }, + ); + await expect(step.run(makeCtx())).rejects.toBeInstanceOf(StepExecutionError); + }); + + test("returns WarnContinue when git workflow is disabled", async () => { + const step = new VerifyStep({}, makeDeps(PASS_OUTPUT)); + const result = await step.run(makeCtx({ enableGit: false })); + expect(result.status).toBe(StepStatus.WarnContinue); + expect(result.reason).toContain("git workflow disabled"); + }); + + test("custom inline prompt is included in the agent prompt", async () => { + let seenPrompt = ""; + const step = new VerifyStep( + { prompt: "Check ONLY the accessibility requirements." }, + { + runAgentPrompt: async (prompt) => { + seenPrompt = prompt; + return PASS_OUTPUT; + }, + getPRDiff: () => "diff", + }, + ); + await step.run(makeCtx()); + expect(seenPrompt).toContain("Check ONLY the accessibility requirements."); + expect(seenPrompt).toContain("Implement the widget"); + expect(seenPrompt).toContain("Verdict Format"); + }); +});