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");
+ });
+});