diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 4e8ac70f22..25c7fa1372 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -79,7 +79,7 @@ Atomic ships with four workflows you can run immediately. Use `/workflow list` t

Workflow List

-Inputs are bare `key=value` tokens. Values are JSON-parsed when possible, so `count=5`, `flag=true`, and `objective="multi word value"` preserve useful types. If you call `/workflow ` without required inputs, the TUI opens an inline picker; pass `--no-picker` to skip it. +Inputs are bare `key=value` tokens. Values are JSON-parsed when possible, so `count=5`, `flag=true`, and `objective="multi word value"` preserve useful types. Some workflows expose reusable worktree inputs; for example, add `git_worktree_dir=../atomic-ralph-wt` to `ralph` to run its stages in a created/reused Git worktree while preserving your current repo-relative cwd. If you call `/workflow ` without required inputs, the TUI opens an inline picker; pass `--no-picker` to skip it. You can also launch workflows with **natural language** — just describe the task in chat and ask Atomic to run the matching workflow: diff --git a/packages/coding-agent/docs/workflows.md b/packages/coding-agent/docs/workflows.md index 148ee5facd..4b0ad5fc1d 100644 --- a/packages/coding-agent/docs/workflows.md +++ b/packages/coding-agent/docs/workflows.md @@ -237,17 +237,21 @@ Inputs: |---|---|---|---|---| | `prompt` | text | yes | — | Task, feature request, issue summary, or spec path to plan, execute, refine, review, and prepare for PR. | | `max_loops` | number | no | `10` | Maximum plan/orchestrate/review iterations before the workflow proceeds to PR handoff without reviewer approval. | -| `base_branch` | string | no | `origin/main` | Branch reviewers and the PR-prep stage compare the current code delta against. | +| `base_branch` | string | no | `origin/main` | Branch reviewers and the PR-prep stage compare the current code delta against; also used to create a missing worktree. | +| `git_worktree_dir` | string | no | `""` | Optional reusable Git worktree root. Empty runs in the invoking checkout; non-empty values run Ralph stages in the created/reused worktree. | Run examples: ```text /workflow ralph prompt="Plan and migrate the database layer to Drizzle" max_loops=3 base_branch=develop /workflow ralph prompt="Refactor authentication across the API, CLI, and web UI, then prepare the PR" +/workflow ralph prompt="Safely implement the API refactor" git_worktree_dir=../atomic-ralph-api-wt base_branch=main ``` Each `ralph` iteration writes an RFC-style technical design document under `specs/`, initializes an OS-temp implementation notes file, delegates implementation through sub-agents, runs a behavior-preserving code simplifier, discovers review infrastructure, and asks two reviewers to inspect the patch against `base_branch`. The loop stops when every reviewer approves or `max_loops` is reached, then runs a pull-request preparation stage. +Set `git_worktree_dir` when you want Ralph's worker stages isolated in a reusable Git worktree. Relative paths resolve from the invoking repository root, existing same-repository worktree roots are reused, and missing paths are created from `base_branch`. Ralph preserves the invoking repo-relative cwd inside the worktree, so launching from `repo/packages/api` with `git_worktree_dir=../repo-wt` runs stages from `../repo-wt/packages/api`. + Result fields: | Field | Meaning | @@ -663,7 +667,7 @@ workflow({ }) ``` -Direct mode supports top-level/default options and per-task options such as `context`, `forkFromSessionFile`, `model`, `fallbackModels`, `thinkingLevel`, `tools`, `noTools`, `customTools`, `mcp`, `output`, `outputMode`, `reads`, `worktree`, `maxOutput`, `artifacts`, `sessionDir`, `cwd`, and `agentDir`. Direct chains also support `chainName`, `chainDir`, and `failFast`. +Direct mode supports top-level/default options and per-task options such as `context`, `forkFromSessionFile`, `model`, `fallbackModels`, `thinkingLevel`, `tools`, `noTools`, `customTools`, `mcp`, `output`, `outputMode`, `reads`, `worktree`, `gitWorktreeDir`, `baseBranch`, `maxOutput`, `artifacts`, `sessionDir`, `cwd`, and `agentDir`. Direct chains also support `chainName`, `chainDir`, and `failFast`. For large fan-outs, prefer `outputMode: "file-only"` so the parent result contains compact file references instead of full output. Treat intercom payloads from async direct runs as user-visible workflow output. @@ -713,6 +717,7 @@ Builder basics: - Workflow names normalize for lookup: trim, lowercase, convert whitespace/underscore to hyphen, remove other punctuation, and collapse hyphens. - `.description(text)` sets the listing text. - `.input(key, schema)` declares typed user inputs. +- `.worktreeFromInputs({ gitWorktreeDir, baseBranch })` optionally maps input names to workflow-wide reusable Git worktree defaults. - `.run(async (ctx) => { ... })` defines the workflow body. - `.compile()` returns the workflow definition for discovery. @@ -773,9 +778,28 @@ Common task/stage options include: - `context: "fresh" | "fork"`, `forkFromSessionFile` - `model`, `fallbackModels`, `thinkingLevel`, `scopedModels`, `modelRegistry` - `tools`, `noTools`, `customTools`, `mcp: { allow?: string[], deny?: string[] }` -- `output`, `outputMode`, `reads`, `worktree`, `maxOutput`, `artifacts`, `sessionDir`, `cwd`, `agentDir` +- `output`, `outputMode`, `reads`, `worktree`, `gitWorktreeDir`, `baseBranch`, `maxOutput`, `artifacts`, `sessionDir`, `cwd`, `agentDir` - advanced host-supplied SDK seams: `authStorage`, `resourceLoader`, `sessionManager`, `settingsManager`, `sessionStartEvent` +`gitWorktreeDir` selects a reusable Git worktree root for `ctx.stage`, `ctx.task`, `ctx.chain`, and `ctx.parallel`. If the path is missing, Atomic creates it with `git worktree add --detach `; if it exists, it must be a same-repository worktree root. The default stage cwd becomes the matching cwd inside the worktree and preserves the invoking repo-relative subdirectory. Explicit `cwd` still wins; relative `cwd` values resolve from the worktree cwd, while absolute `cwd` values are used as provided. `gitWorktreeDir` is mutually exclusive with `worktree: true`: use `gitWorktreeDir` for named/reusable worktrees and `worktree: true` for temporary direct-mode worktrees that are cleaned up after the run. + +To bind user inputs to a workflow-wide worktree default, use the builder method: + +```ts +export default defineWorkflow("safe-implementation") + .input("task", { type: "text", required: true }) + .input("git_worktree_dir", { type: "string", default: "" }) + .input("base_branch", { type: "string", default: "origin/main" }) + .worktreeFromInputs({ gitWorktreeDir: "git_worktree_dir", baseBranch: "base_branch" }) + .run(async (ctx) => { + const result = await ctx.task("implement", { task: String(ctx.inputs.task) }); + return { result: result.text }; + }) + .compile(); +``` + +For lower-level integrations, `@bastani/workflows` also exports `setupGitWorktree({ gitWorktreeDir, baseBranch, cwd })`, returning `{ worktreeRoot, cwd, repositoryRoot, created }` with the same validation, symlink-preserving path handling, and cwd-preservation behavior used by workflow stages. + `fallbackModels` retries transient provider/model failures with the primary `model` first, then each fallback, then the current Atomic-selected model when available. It is for rate limits, quota/auth/provider outages, unavailable models, network timeouts, and 5xx errors — not workflow-code errors, tool failures, validation failures, or cancellations. ## Programmatic Usage diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index 330fa12bd0..0c84574176 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Added Ralph `git_worktree_dir` support for running stages from an optional Git worktree, reusing/sharing existing worktrees from the invoking repository as-is and leaving worktrees in place for retries. + +### Changed + +- Replaced regex-based workflow discovery stage validation with runtime empty-graph validation based on actual stage creation while keeping discovery side-effect-free. +- Threaded named workflow invocation cwd into workflow run contexts so workflow-owned artifacts can use the explicit runner cwd. +- Split worker project-initialization preflight guidance from Goal receipt/reporting instructions. + +### Fixed + +- Avoided blank deep-research display paths when a displayed artifact path equals the workflow invocation directory. +- Distinguished Ralph same-repository worktree classification and canonicalization failures from definitely non-Git existing `git_worktree_dir` paths. +- Updated Ralph to revise a stable original spec file across planner iterations and clarified `git_worktree_dir` null-byte diagnostics. + ## [0.8.17] - 2026-05-26 ### Changed diff --git a/packages/workflows/README.md b/packages/workflows/README.md index 5ef13cc9fa..bc490f9a4f 100644 --- a/packages/workflows/README.md +++ b/packages/workflows/README.md @@ -105,6 +105,64 @@ export default defineWorkflow("review-and-merge") .compile(); ``` +### Reusable Git worktrees + +Use `gitWorktreeDir` when a workflow should run stages in a reusable Git worktree instead of the invoking checkout. The executor creates the worktree if it is missing, reuses it when it already exists as a same-repository worktree root, and defaults the stage/task `cwd` to the matching path inside that worktree. + +```typescript +import { defineWorkflow } from "@bastani/workflows"; + +export default defineWorkflow("safe-implementation") + .description("Run implementation stages in a reusable worktree.") + .input("task", { type: "text", required: true }) + .input("worktree", { type: "string", default: "" }) + .input("base_branch", { type: "string", default: "origin/main" }) + .worktreeFromInputs({ + gitWorktreeDir: "worktree", + baseBranch: "base_branch", + }) + .run(async (ctx) => { + const result = await ctx.task("implement", { + task: String(ctx.inputs.task), + // No cwd needed: when `worktree` is non-empty, this task runs from the + // corresponding cwd inside that reusable Git worktree. + }); + return { result: result.text }; + }) + .compile(); +``` + +You can also pass worktree options per stage/task or as shared chain/parallel defaults: + +```typescript +await ctx.stage("review", { + gitWorktreeDir: "../review-worktree", + baseBranch: "origin/main", +}).prompt("Review the current changes."); + +await ctx.parallel([ + { name: "security", task: "Security review" }, + { name: "runtime", task: "Runtime review" }, +], { + gitWorktreeDir: "../review-worktree", + baseBranch: "origin/main", + failFast: false, +}); +``` + +Worktree semantics: + +- `gitWorktreeDir` must be used from inside a Git repository. Relative paths resolve from the logical invoking repository root; absolute paths are used as-is. +- If the requested path exists, it must be an actual Git worktree/checkout root belonging to the invoking repository. Existing subdirectories are rejected so writes do not silently land in the main checkout. +- If the path is missing, the parent directory is created and Git runs `git worktree add --detach `. `baseBranch` defaults to `HEAD` when omitted. +- The default execution cwd preserves the caller's repo-relative cwd inside the worktree. For example, invoking a workflow from `repo/packages/api` with `gitWorktreeDir=../repo-wt` runs stages from `../repo-wt/packages/api`. +- Symlinked repo/worktree paths preserve their logical spelling in the default cwd, matching Codex-style worktree behavior. +- Explicit `cwd` still wins. Relative `cwd` values are resolved against the worktree default cwd; absolute `cwd` values are used as provided. + +`worktree: true` is different: it creates temporary isolated worktrees for direct task/parallel/chain execution and cleans them up afterward. It is mutually exclusive with `gitWorktreeDir`, which is intended for named/reusable worktrees that remain available across retries. + +For advanced integrations, the SDK also exports `setupGitWorktree(options)`, which returns `{ worktreeRoot, cwd, repositoryRoot, created }` and uses the same validation/path behavior as the executor. + ### Model fallbacks Stages and high-level task helpers can retry transient provider/model failures with an ordered `fallbackModels` list. The primary `model` is tried first, then each fallback, and finally the current pi-selected model when available. Fallbacks are only used for retryable model/provider failures such as rate limits, quota/auth/provider outages, unavailable models, network timeouts, and 5xx errors — ordinary tool, shell, validation, cancellation, and workflow-code failures are not retried. @@ -280,7 +338,7 @@ await runWorkflow({ }); ``` -The programmatic definition object mirrors the workflow tool: named workflow runs, single-task runs, parallel `tasks`, and mixed `chain` runs accept the same direct options (`reads`, `output`, `outputMode`, `worktree`, `maxOutput`, `artifacts`, `concurrency`, `failFast`, and stage/session options such as `cwd`, `agentDir`, `model`, `tools`, `context`, and `sessionDir`). `chainDir` is chain-only: it provides the shared artifact directory for chain reads, outputs, and worktree diffs. +The programmatic definition object mirrors the workflow tool: named workflow runs, single-task runs, parallel `tasks`, and mixed `chain` runs accept the same direct options (`reads`, `output`, `outputMode`, `worktree`, `gitWorktreeDir`, `baseBranch`, `maxOutput`, `artifacts`, `concurrency`, `failFast`, and stage/session options such as `cwd`, `agentDir`, `model`, `tools`, `context`, and `sessionDir`). `chainDir` is chain-only: it provides the shared artifact directory for chain reads, outputs, and worktree diffs. Workflow stage sessions follow Atomic SDK directory defaults: `DefaultResourceLoader` is initialized with the project `cwd` and the Atomic default `~/.atomic/agent` directory, while legacy `.pi` paths remain readable where the SDK supports multiple config directories. A stage-supplied `agentDir` is treated as an explicit user override; a stage-supplied `resourceLoader` owns discovery, with `cwd`/`agentDir` left for session naming and tool path resolution. @@ -333,8 +391,9 @@ Plan → orchestrate → simplify → discover → review → PR-handoff workflo | Input | Type | Required | Default | Description | | ------------- | -------- | -------- | ------------- | ------------------------------------------------------------- | | `prompt` | `text` | ✓ | — | Task, feature request, issue summary, or spec path to plan, execute, refine, review, and prepare for PR. | -| `max_loops` | `number` | — | `10` | Maximum plan/orchestrate/review iterations before PR handoff. | -| `base_branch` | `string` | — | `origin/main` | Branch reviewers and PR-prep compare the current delta with. | +| `max_loops` | `number` | — | `10` | Maximum plan/orchestrate/review iterations before PR handoff. | +| `base_branch` | `string` | — | `origin/main` | Branch reviewers and PR-prep compare the current delta with; also used to create a missing worktree. | +| `git_worktree_dir` | `string` | — | `""` | Optional reusable Git worktree root. Empty runs in the invoking checkout; non-empty values run Ralph stages in the created/reused worktree. | ### `open-claude-design` diff --git a/packages/workflows/builtin/deep-research-codebase.ts b/packages/workflows/builtin/deep-research-codebase.ts index 0b1057b1bb..38ad86cbab 100644 --- a/packages/workflows/builtin/deep-research-codebase.ts +++ b/packages/workflows/builtin/deep-research-codebase.ts @@ -9,10 +9,11 @@ */ import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, extname, join } from "node:path"; +import { dirname, extname, isAbsolute, join, relative } from "node:path"; import { defineWorkflow } from "../src/index.js"; import type { WorkflowOutputMode, + WorkflowRunContext, WorkflowTaskResult, WorkflowTaskStep, } from "../src/shared/types.js"; @@ -73,12 +74,13 @@ function positiveInteger(value: number | undefined, fallback: number): number { : fallback; } -function countCodebaseLines(): number { +function countCodebaseLines(cwd = process.cwd()): number { try { const gitFiles = Bun.spawnSync({ cmd: ["git", "ls-files", "--cached", "--others", "--exclude-standard"], stdout: "pipe", stderr: "pipe", + cwd, }); const files = gitFiles.success && gitFiles.stdout @@ -98,6 +100,7 @@ function countCodebaseLines(): number { cmd: ["wc", "-l", "--", ...batch], stdout: "pipe", stderr: "pipe", + cwd, }); if (!wc.stdout) continue; @@ -180,13 +183,14 @@ interface DeepResearchArtifactRoot { readonly artifactRoot: string; } -async function createArtifactRoot(startedAt: Date): Promise { - await mkdir(DEFAULT_RESEARCH_DOC_DIR, { recursive: true }); +async function createArtifactRoot(startedAt: Date, cwd = process.cwd()): Promise { + const researchDocDir = join(cwd, DEFAULT_RESEARCH_DOC_DIR); + await mkdir(researchDocDir, { recursive: true }); const baseRunId = timestampRunId(startedAt); for (let suffix = 0; ; suffix += 1) { const runId = suffix === 0 ? baseRunId : `${baseRunId}-${suffix + 1}`; const artifactRoot = join( - DEFAULT_RESEARCH_DOC_DIR, + researchDocDir, `${DEEP_RESEARCH_RUN_DIR_PREFIX}${runId}`, ); try { @@ -270,12 +274,13 @@ async function specialistHandoffFromArtifacts( function manifestArtifactPaths( artifactPathsByStage: ReadonlyMap, manifestPath: string, + display: (path: string) => string, ): Record { const artifacts: Record = {}; for (const [stage, path] of artifactPathsByStage) { - artifacts[stage] = displayPath(path); + artifacts[stage] = display(path); } - artifacts.manifest = displayPath(manifestPath); + artifacts.manifest = display(manifestPath); return artifacts; } @@ -292,32 +297,23 @@ function displayPath(path: string): string { return path.replace(/\\/g, "/"); } +function displayPathFrom(cwd: string, path: string): string { + const relativePath = relative(cwd, path); + if (relativePath.length === 0) return "."; + if (!relativePath.startsWith("..") && !isAbsolute(relativePath)) { + return displayPath(relativePath); + } + return displayPath(path); +} + function displayPaths(paths: readonly string[]): string { return paths.map(displayPath).join(", "); } -export default defineWorkflow("deep-research-codebase") - .description( - "Scout + research-history chain → parallel specialist waves → aggregator for deep codebase research.", - ) - .input("prompt", { - type: "text", - required: true, - description: "Research question or investigation focus for the codebase.", - }) - .input("max_partitions", { - type: "number", - default: DEFAULT_MAX_PARTITIONS, - description: - "Maximum number of codebase partitions to explore in parallel. Actual partitions scale by one per 10K LoC, capped by this value.", - }) - .input("max_concurrency", { - type: "number", - default: DEFAULT_MAX_CONCURRENCY, - description: - "Maximum number of workflow stages to run concurrently during deep research.", - }) - .run(async (ctx) => { +export async function runDeepResearchCodebaseWorkflow( + ctx: WorkflowRunContext>, + workflowStartCwd = process.cwd(), +): Promise { const inputs = ctx.inputs as { prompt?: string; max_partitions?: number; @@ -333,13 +329,13 @@ export default defineWorkflow("deep-research-codebase") DEFAULT_MAX_CONCURRENCY, ); const startedAt = new Date(); - const finalResearchDocPath = defaultResearchDocPath(prompt); - const codebaseLines = countCodebaseLines(); + const finalResearchDocPath = join(workflowStartCwd, defaultResearchDocPath(prompt)); + const codebaseLines = countCodebaseLines(workflowStartCwd); const partitionCap = calculatePartitionCap( requestedMaxPartitions, codebaseLines, ); - const { runId, artifactRoot } = await createArtifactRoot(startedAt); + const { runId, artifactRoot } = await createArtifactRoot(startedAt, workflowStartCwd); const artifactPathsByStage = new Map(); const addArtifact = (stage: string, path: string) => { artifactPathsByStage.set(stage, path); @@ -352,6 +348,8 @@ export default defineWorkflow("deep-research-codebase") output, outputMode: FILE_ONLY_OUTPUT, }); + const displayWorkflowPath = (path: string): string => displayPathFrom(workflowStartCwd, path); + const displayWorkflowPaths = (paths: readonly string[]): string => paths.map(displayWorkflowPath).join(", "); const scoutPath = addArtifact( "codebase-scout", @@ -579,7 +577,7 @@ export default defineWorkflow("deep-research-codebase") ["research_question", prompt], [ "scout_context", - `Read the scout artifact before making evidence claims: ${displayPath(scoutPath)}\nCompact saved-output reference: {previous}`, + `Read the scout artifact before making evidence claims: ${displayWorkflowPath(scoutPath)}\nCompact saved-output reference: {previous}`, ], ["codebase_skills", codebaseSkillGuidance("locator")], [ @@ -618,7 +616,7 @@ export default defineWorkflow("deep-research-codebase") ["research_question", prompt], [ "scout_context", - `Read the scout artifact before making evidence claims: ${displayPath(scoutPath)}\nCompact saved-output reference: {previous}`, + `Read the scout artifact before making evidence claims: ${displayWorkflowPath(scoutPath)}\nCompact saved-output reference: {previous}`, ], ["codebase_skills", codebaseSkillGuidance("patternFinder")], [ @@ -667,8 +665,8 @@ export default defineWorkflow("deep-research-codebase") locatorPath === undefined ? [scoutPath] : [locatorPath]; const onlineResearcherLocalContext = locatorPath === undefined - ? `Read scout context before researching: ${displayPath(scoutPath)}\nCompact saved-output reference: {previous}` - : `Read local artifact context before researching: ${displayPath(locatorPath)}\nCompact saved-output reference: {previous}`; + ? `Read scout context before researching: ${displayWorkflowPath(scoutPath)}\nCompact saved-output reference: {previous}` + : `Read local artifact context before researching: ${displayWorkflowPath(locatorPath)}\nCompact saved-output reference: {previous}`; const analyzerPath = addArtifact( `analyzer-${i}`, join(artifactRoot, `analyzer-${i}.md`), @@ -692,7 +690,7 @@ export default defineWorkflow("deep-research-codebase") ["research_question", prompt], [ "context", - `Read these artifacts before analyzing: ${displayPaths(analyzerReads)}\nCompact saved-output reference: {previous}`, + `Read these artifacts before analyzing: ${displayWorkflowPaths(analyzerReads)}\nCompact saved-output reference: {previous}`, ], ["codebase_skills", codebaseSkillGuidance("analyzer")], [ @@ -804,22 +802,22 @@ export default defineWorkflow("deep-research-codebase") [ "context_artifacts", [ - `Read the scout artifact at ${displayPath(scoutPath)}.`, - `Read the partition plan artifact at ${displayPath(partitionPlanPath)}.`, + `Read the scout artifact at ${displayWorkflowPath(scoutPath)}.`, + `Read the partition plan artifact at ${displayWorkflowPath(partitionPlanPath)}.`, historyOverview === "" ? "No prior research overview artifact is available." - : `Read the prior research overview artifact at ${displayPath(historyAnalyzerPath)}.`, + : `Read the prior research overview artifact at ${displayWorkflowPath(historyAnalyzerPath)}.`, ].join("\n"), ], [ "prior_research_overview", historyOverview === "" ? "(no prior research found)" - : `Read the prior research overview artifact at ${displayPath(historyAnalyzerPath)}.`, + : `Read the prior research overview artifact at ${displayWorkflowPath(historyAnalyzerPath)}.`, ], [ "specialist_reports", - `Read the complete explorer handoff artifact(s) at ${displayPaths(explorerPaths)}. They preserve every partition's Locator, Pattern Finder, Analyzer, and Online Researcher output from the original inline specialist handoff while keeping this prompt bounded.`, + `Read the complete explorer handoff artifact(s) at ${displayWorkflowPaths(explorerPaths)}. They preserve every partition's Locator, Pattern Finder, Analyzer, and Online Researcher output from the original inline specialist handoff while keeping this prompt bounded.`, ], [ "codebase_skills", @@ -867,15 +865,15 @@ export default defineWorkflow("deep-research-codebase") startedAt: startedAt.toISOString(), completedAt: completedAt.toISOString(), researchQuestion: prompt, - finalAsset: displayPath(writtenResearchDocPath), - artifacts: manifestArtifactPaths(artifactPathsByStage, manifestPath), + finalAsset: displayWorkflowPath(writtenResearchDocPath), + artifacts: manifestArtifactPaths(artifactPathsByStage, manifestPath, displayWorkflowPath), }); const result: DeepResearchCodebaseResult = { findings: aggregate.text, - research_doc_path: displayPath(writtenResearchDocPath), - artifact_dir: displayPath(artifactRoot), - manifest_path: displayPath(manifestPath), + research_doc_path: displayWorkflowPath(writtenResearchDocPath), + artifact_dir: displayWorkflowPath(artifactRoot), + manifest_path: displayWorkflowPath(manifestPath), partitions, explorer_count: partitions.length, specialist_count: wave1.length + wave2.length, @@ -883,5 +881,29 @@ export default defineWorkflow("deep-research-codebase") history: historyOverview, }; return result; + +} + +export default defineWorkflow("deep-research-codebase") + .description( + "Scout + research-history chain → parallel specialist waves → aggregator for deep codebase research.", + ) + .input("prompt", { + type: "text", + required: true, + description: "Research question or investigation focus for the codebase.", + }) + .input("max_partitions", { + type: "number", + default: DEFAULT_MAX_PARTITIONS, + description: + "Maximum number of codebase partitions to explore in parallel. Actual partitions scale by one per 10K LoC, capped by this value.", + }) + .input("max_concurrency", { + type: "number", + default: DEFAULT_MAX_CONCURRENCY, + description: + "Maximum number of workflow stages to run concurrently during deep research.", }) + .run(async (ctx) => runDeepResearchCodebaseWorkflow(ctx, ctx.cwd)) .compile(); diff --git a/packages/workflows/builtin/goal.ts b/packages/workflows/builtin/goal.ts index 97135d0748..311721f401 100644 --- a/packages/workflows/builtin/goal.ts +++ b/packages/workflows/builtin/goal.ts @@ -12,6 +12,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { defineWorkflow } from "../src/index.js"; import type { WorkflowTaskResult } from "../src/shared/types.js"; +import { WORKER_PREFLIGHT_CONTRACT } from "./shared-prompts.js"; const DEFAULT_MAX_TURNS = 10; // Goal Runner runs three independent reviewer personas; two approvals form a majority. @@ -1038,6 +1039,10 @@ export default defineWorkflow("goal") prompt: [ goalContext, "", + "", + WORKER_PREFLIGHT_CONTRACT, + "", + "", "", WORKER_RECEIPT_CONTRACT, "", diff --git a/packages/workflows/builtin/open-claude-design.ts b/packages/workflows/builtin/open-claude-design.ts index 652f626fc5..e594089f74 100644 --- a/packages/workflows/builtin/open-claude-design.ts +++ b/packages/workflows/builtin/open-claude-design.ts @@ -114,7 +114,7 @@ function joinResults(results: readonly WorkflowTaskResult[]): string { * stay next to the project and are discoverable by pi. Falls back to the OS * tmpdir when the project tree is not writable (CI sandboxes, mocks, etc.). */ -function prepareArtifactDir(): { +function prepareArtifactDir(cwd = process.cwd()): { readonly runId: string; readonly artifactDir: string; readonly previewPath: string; @@ -122,7 +122,7 @@ function prepareArtifactDir(): { } { const runId = `${new Date().toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}`; const candidates = [ - join(process.cwd(), "specs", "design", runId), + join(cwd, "specs", "design", runId), join(tmpdir(), "open-claude-design", runId), ]; for (const candidate of candidates) { @@ -222,7 +222,7 @@ export default defineWorkflow("open-claude-design") DEFAULT_MAX_REFINEMENTS, ); - const { runId, artifactDir, previewPath, specPath } = prepareArtifactDir(); + const { runId, artifactDir, previewPath, specPath } = prepareArtifactDir(ctx.cwd); const previewFileUrl = `file://${previewPath}`; const specFileUrl = `file://${specPath}`; diff --git a/packages/workflows/builtin/ralph.ts b/packages/workflows/builtin/ralph.ts index c8b6fd51bf..3fb0b98aca 100644 --- a/packages/workflows/builtin/ralph.ts +++ b/packages/workflows/builtin/ralph.ts @@ -9,9 +9,10 @@ import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, extname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { defineWorkflow } from "../src/index.js"; -import type { WorkflowTaskResult } from "../src/shared/types.js"; +import type { WorkflowRunContext, WorkflowTaskResult } from "../src/shared/types.js"; +import { WORKER_PREFLIGHT_CONTRACT } from "./shared-prompts.js"; const DEFAULT_MAX_LOOPS = 10; const DEFAULT_SPEC_DIR = "specs"; @@ -260,32 +261,12 @@ function defaultSpecPath(prompt: string, now = new Date()): string { return join(DEFAULT_SPEC_DIR, `${date}-${slugifySpecTopic(prompt)}.md`); } -function suffixedPath(path: string, suffix: number): string { - const extension = extname(path); - const stem = extension.length === 0 ? path : path.slice(0, -extension.length); - return `${stem}-${suffix}${extension}`; -} - -function isFileExistsError(error: unknown): boolean { - return error instanceof Error && (error as { readonly code?: string }).code === "EEXIST"; -} - async function writeSpecFile(path: string, content: string): Promise { await mkdir(dirname(path), { recursive: true }); - - for (let suffix = 0; ; suffix += 1) { - const candidate = suffix === 0 ? path : suffixedPath(path, suffix + 1); - try { - await writeFile(candidate, content.endsWith("\n") ? content : `${content}\n`, { - encoding: "utf8", - flag: "wx", - }); - return candidate; - } catch (error) { - if (isFileExistsError(error)) continue; - throw error; - } - } + await writeFile(path, content.endsWith("\n") ? content : `${content}\n`, { + encoding: "utf8", + }); + return path; } async function createImplementationNotesFile(prompt: string): Promise { @@ -381,774 +362,832 @@ function formatReview(results: readonly WorkflowTaskResult[]): string { .join("\n\n---\n\n"); } -export default defineWorkflow("ralph") - .description( - "Plan → orchestrate → simplify → parallel review loop with bounded iteration.", - ) - .input("prompt", { - type: "text", - required: true, - description: "The task or goal to plan, execute, and refine.", - }) - .input("max_loops", { - type: "number", - default: DEFAULT_MAX_LOOPS, - description: `Maximum plan/orchestrate/review iterations (default ${DEFAULT_MAX_LOOPS}).`, - }) - .input("base_branch", { - type: "string", - default: "origin/main", - description: - "Branch reviewers compare the current code delta against (default origin/main).", - }) - .run(async (ctx) => { - const inputs = ctx.inputs as { - prompt?: string; - max_loops?: number; - base_branch?: string; - }; - const prompt = inputs.prompt ?? ""; - const maxLoops = positiveInteger(inputs.max_loops, DEFAULT_MAX_LOOPS); - const comparisonBaseBranch = normalizeBranchInput(inputs.base_branch, "origin/main"); +type RalphInputs = { + readonly prompt?: string; + readonly max_loops?: number; + readonly base_branch?: string; + readonly git_worktree_dir?: string; +}; - let reviewReport = ""; - let finalPlan = ""; - let finalPlanPath = ""; - let finalResult = ""; - let finalPrReport = ""; - const implementationNotesPath = await createImplementationNotesFile(prompt); - let approved = false; - let iterationsCompleted = 0; - - let noAskQuestionToolSet = [ - "read", - "bash", - "edit", - "write", - "todo", - "subagent", - "web_search", - "code_search", - "fetch_content", - "get_search_content", - "intercom", - ]; - - let plannerModelConfig = { - model: "openai/gpt-5.5", - fallbackModels: [ - "openai-codex/gpt-5.5", - "github-copilot/gpt-5.5", - "anthropic/claude-opus-4-7", - "github-copilot/claude-opus-4.7", - ], - thinkingLevel: "high" as const, - tools: noAskQuestionToolSet, - }; +type RalphWorkflowOptions = { + readonly prompt: string; + readonly maxLoops: number; + readonly comparisonBaseBranch: string; + readonly workflowStartCwd: string; +}; - let orchestratorModelConfig = { - model: "openai/gpt-5.5", - fallbackModels: [ - "openai-codex/gpt-5.5", - "github-copilot/gpt-5.5", - "anthropic/claude-sonnet-4-6", - "github-copilot/claude-sonnet-4.6", - ], - thinkingLevel: "medium" as const, - tools: noAskQuestionToolSet, - }; +type RalphWorkflowResult = { + readonly result: string; + readonly plan: string; + readonly plan_path: string; + readonly implementation_notes_path: string; + readonly pr_report: string; + readonly approved: boolean; + readonly iterations_completed: number; + readonly review_report: string; +}; - let simplifierModelConfig = { - model: "openai/gpt-5.5", - fallbackModels: [ - "openai-codex/gpt-5.5", - "github-copilot/gpt-5.5", - "anthropic/claude-sonnet-4-6", - "github-copilot/claude-sonnet-4.6", - ], - thinkingLevel: "medium" as const, - tools: noAskQuestionToolSet, - }; +async function runRalphWorkflow( + ctx: WorkflowRunContext, + options: RalphWorkflowOptions, +): Promise { + const { + prompt, + maxLoops, + comparisonBaseBranch, + workflowStartCwd, + } = options; + + let reviewReport = ""; + let finalPlan = ""; + let finalPlanPath = ""; + let finalResult = ""; + let finalPrReport = ""; + // Keep generated specs under the directory where Ralph was invoked, not in + // the worktree, so plan artifacts remain easy to find across retries. + const workflowSpecPath = resolve(workflowStartCwd, defaultSpecPath(prompt)); + const implementationNotesPath = await createImplementationNotesFile(prompt); + let approved = false; + let iterationsCompleted = 0; + + const noAskQuestionToolSet = [ + "read", + "bash", + "edit", + "write", + "todo", + "subagent", + "web_search", + "code_search", + "fetch_content", + "get_search_content", + "intercom", + ]; + + const plannerModelConfig = { + model: "openai/gpt-5.5", + fallbackModels: [ + "openai-codex/gpt-5.5", + "github-copilot/gpt-5.5", + "anthropic/claude-opus-4-7", + "github-copilot/claude-opus-4.7", + ], + thinkingLevel: "high" as const, + tools: noAskQuestionToolSet, + }; - let reviewerModelConfig = { - model: "openai/gpt-5.5", - fallbackModels: [ - "openai-codex/gpt-5.5", - "github-copilot/gpt-5.5", - "anthropic/claude-opus-4-7", - "github-copilot/claude-opus-4.7", - ], - thinkingLevel: "high" as const, - tools: noAskQuestionToolSet, - customTools: [reviewDecisionTool], - }; + const orchestratorModelConfig = { + model: "openai/gpt-5.5", + fallbackModels: [ + "openai-codex/gpt-5.5", + "github-copilot/gpt-5.5", + "anthropic/claude-sonnet-4-6", + "github-copilot/claude-sonnet-4.6", + ], + thinkingLevel: "medium" as const, + tools: noAskQuestionToolSet, + }; - let explorerModelConfig = { - model: "openai/gpt-5.4-mini", - fallbackModels: [ - "openai-codex/gpt-5.4-mini", - "github-copilot/gpt-5.4-mini", - "anthropic/claude-haiku-4-5", - "github-copilot/claude-haiku-4.5", - ], - thinkingLevel: "low" as const, - tools: noAskQuestionToolSet, - }; + const simplifierModelConfig = { + model: "openai/gpt-5.5", + fallbackModels: [ + "openai-codex/gpt-5.5", + "github-copilot/gpt-5.5", + "anthropic/claude-sonnet-4-6", + "github-copilot/claude-sonnet-4.6", + ], + thinkingLevel: "medium" as const, + tools: noAskQuestionToolSet, + }; - for (let iteration = 1; iteration <= maxLoops; iteration += 1) { - iterationsCompleted = iteration; + const reviewerModelConfig = { + model: "openai/gpt-5.5", + fallbackModels: [ + "openai-codex/gpt-5.5", + "github-copilot/gpt-5.5", + "anthropic/claude-opus-4-7", + "github-copilot/claude-opus-4.7", + ], + thinkingLevel: "high" as const, + tools: noAskQuestionToolSet, + customTools: [reviewDecisionTool], + }; - const planner = await ctx.task(`planner-${iteration}`, { - prompt: taggedPrompt([ - [ - "role", - "You are a technical architect. Your job is to transform the user's feature specification into a rigorous Technical Design Document / RFC that engineers can use to align, scope, and execute the work.", - ], - [ - "critical_deliverable", - [ - "Your final output is a filled-in RFC rendered as markdown text.", - "Render the RFC Template in this prompt with every section populated by feature-specific content drawn from the user's specification and your codebase investigation.", - "Do not implement code changes in this stage; this stage only investigates and authors the RFC.", - ].join("\n"), - ], - [ - "task", - `Plan iteration ${iteration}/${maxLoops} for this user specification:\n${prompt}`, - ], - [ - "previous_review_findings", - reviewReport - ? "Previous review findings:\n{previous}" - : "No prior review findings; this is the first iteration.", - ], - [ - "input_spec_files", - [ - "If the user specification is a file path instead of raw prose, read that file and use it as source material for the RFC.", - "Still author the RFC normally; do not output only a forwarded path.", - ].join("\n"), - ], - [ - "investigation_phase", - [ - "Before drafting, read the specification carefully and identify the concrete problem, success criteria, hard constraints, and non-goals.", - "Survey the codebase using file/search tools such as read plus grep/rg/find/glob-style shell commands to ground the RFC in current architecture.", - "Name concrete services, modules, files, tests, data models, APIs, CLIs, config files, and external integrations this work will touch.", - "Capture metadata with bash: `git config user.name` for Author(s), and `date '+%Y-%m-%d'` for Created / Last Updated.", - "Look for prior art: existing RFCs, ADRs, README files, specs, docs, tests, or code comments that explain why the current state exists.", - ].join("\n"), - ], - [ - "authoring_principles", - [ - "Be specific: `src/server/auth.ts:42` beats `the auth layer`.", - "Trade-offs over conclusions: Alternatives Considered must include at least two real alternatives with honest pros, cons, and rejection reasons.", - "Non-goals matter: explicitly exclude work that is out of scope to prevent scope creep.", - "Diagrams are load-bearing: Section 4.1 must include a Mermaid system architecture diagram grounded in real components.", - "Surface open questions in Section 9 with owner placeholders such as `[OWNER: infra team]`; do not paper over uncertainty.", - "Match depth to stakes: a small refactor can be concise, but every template section header must remain present.", - "If prior review findings are present, explicitly address each finding or explain why it is obsolete.", - ].join("\n"), - ], - [ - "stage_contract", - [ - "This stage is investigation-first RFC authoring. The RFC is only valid if it is grounded in repository inspection performed during this stage.", - "Do not fill the template from generic architecture guesses. Before writing the final RFC, inspect relevant code, docs, tests, configs, and prior design material.", - "Treat the output format as the report after investigation, not a substitute for investigation.", - ].join("\n"), - ], - [ - "evidence_expectations", - [ - "Every major design claim should be traceable to concrete evidence: file paths, symbols, commands, docs, tests, configs, or prior RFCs.", - "Include those concrete references inside the RFC sections where they support the design.", - "If expected evidence cannot be found, say so in the relevant RFC section or Open Questions rather than papering over the gap.", - ].join("\n"), - ], - [ - "output_discipline", - [ - "Render the RFC Template exactly as the final document structure: preserve every header and the metadata table.", - "Replace instructional placeholders with real, feature-specific content; do not leave template guidance in the final RFC.", - "Output nothing after the RFC: no meta-commentary, no summary of what you wrote, no implementation log.", - ].join("\n"), - ], - ["rfc_template", PLANNER_RFC_TEMPLATE], - ]), - ...(reviewReport - ? { previous: { name: "review-report", text: reviewReport } } - : {}), - ...plannerModelConfig, - }); - finalPlan = planner.text; - const specPath = await writeSpecFile(defaultSpecPath(prompt), planner.text); - finalPlanPath = specPath; - - const orchestrator = await ctx.task(`orchestrator-${iteration}`, { - prompt: taggedPrompt([ - [ - "role", - "You are a sub-agent orchestrator with many tools available. Your primary implementation tool is the `subagent` tool.", - ], - [ - "objective", - `Implement iteration ${iteration}/${maxLoops} for the task: ${prompt}`, - ], - [ - "spec_file", - [ - `The technical specification for this iteration was written to: ${specPath}`, - "Read this file before delegating or implementing anything.", - "Do not rely on an inline planner transcript; the spec file is the authoritative plan for this iteration.", - ].join("\n"), - ], - [ - "implementation_notes", - [ - `Keep a running Markdown implementation notes file at this OS temp directory path: ${implementationNotesPath}`, - "The file has already been initialized for this workflow run; update it while you implement the spec.", - "Record decisions you had to make that were not in the spec, things you had to change from the spec, tradeoffs you had to make, blockers, validation outcomes, and anything else the user should know.", - "Ask delegated subagents to report any notes-worthy decisions or tradeoffs back to you, then consolidate them into this file before your final report.", - "Do not include secrets, credentials, tokens, or unrelated environment details in the notes file.", - ].join("\n"), - ], - [ - "project_initialization_preflight", - [ - "Before normal implementation delegation, determine whether this checkout appears initialized for its actual language, framework, and build system.", - "Do not rely on hard-coded assumptions about JavaScript, TypeScript, Python, Rust, Go, Java, mobile, or any other ecosystem. Infer the project type and setup requirements from repository evidence.", - "Inspect source layout, setup docs, package/build manifests, lockfiles, toolchain files, generated-artifact conventions, CI workflows, workflow configuration, and package scripts or equivalent task definitions.", - "Look for evidence that dependencies, generated files, local toolchains, submodules, codegen outputs, or other project-specific initialization artifacts are missing for this checkout.", - "When repository evidence shows missing initialization, run or delegate the appropriate documented setup command before implementation work.", - "You are responsible for initializing the checkout when setup commands are documented; missing dependencies, generated files, or local toolchains are setup work, not user handoff work.", - "Once setup succeeds, continue normal implementation orchestration. Do not treat missing dependencies or generated setup artifacts in a fresh worktree as implementation failures.", - "If setup requirements cannot be determined confidently, delegate a focused discovery task before implementation instead of guessing.", - "If setup remains blocked after evidence-based discovery and setup attempts, report the blocker with commands tried and the exact evidence needed to continue.", - ].join("\n"), - ], - [ - "delegation_policy", - [ - "You are not the implementer. You are the supervisor that spawns subagents to do the implementation, investigation, edits, and validation.", - "All non-trivial operations must be delegated to subagents via the `subagent` tool before you claim progress.", - "Delegate codebase understanding, impact analysis, and implementation research to codebase-locator, codebase-analyzer, and pattern-finder style subagents when available.", - "Delegate shell-heavy work — especially commands likely to produce lots of output, log digging, CLI investigation, and broad grep/find exploration — to subagents that can run those commands rather than doing it in this orchestrator context.", - "Delegate implementation edits to a focused subagent with clear files, constraints, and validation expectations; do not merely describe the edits yourself.", - "Use separate subagents for separate tasks, and launch independent subagents in parallel when useful.", - "Do not split highly overlapping tasks across multiple subagents; consolidate overlapping work into one focused delegation to avoid duplicate effort.", - "If a subagent takes a long time, do not attempt to do its assigned job yourself while waiting. Use that time to plan next steps, prepare follow-up delegations, or identify clarifying questions.", - ].join("\n"), - ], - [ - "execution_contract", - [ - "The required output format is a completion report, not the task itself.", - "Do not jump straight to the report. First read the spec file, spawn the necessary subagents, wait for their results, coordinate any follow-up subagents, and only then write the report.", - "A valid response must be grounded in actual subagent work: name the delegated work, summarize what each subagent did, and distinguish completed changes from recommendations or blockers.", - "If you cannot read the spec file, spawn subagents, or use subagents, treat that as a blocker and report it honestly instead of pretending the requested work was done.", - ].join("\n"), - ], - [ - "subagent_tracking", - [ - "Use the `todo` tool as your active control ledger for subagent work.", - "Before launching subagents, create todo items for each delegated task with enough detail to identify owner, purpose, and expected output.", - "Mark todo items in_progress when the corresponding subagent starts, append progress/results as subagents report back, and close them only after you have incorporated or explicitly rejected their result.", - "Keep pending, in_progress, blocked, and completed work accurate so you do not lose track of parallel subagents or unresolved follow-ups.", - "Before writing the final report, review the todo list and resolve every pending/in_progress item as completed, blocked, or deferred with an explanation.", - ].join("\n"), - ], - [ - "instructions", - [ - `Start by reading the spec file at ${specPath}.`, - "Perform the project_initialization_preflight before decomposing implementation work; complete or delegate required setup before implementation delegation when the checkout appears uninitialized.", - "Decompose the work into delegated subagent tasks based on that spec file.", - "Pass each subagent the relevant task, constraints, files, validation expectations, any prior review findings from the spec, and instructions to report implementation-note-worthy decisions or tradeoffs.", - "Coordinate subagent results into the smallest coherent set of changes that satisfies the spec.", - "Preserve existing architecture and repository conventions unless the spec explicitly justifies a change.", - "Run or delegate the most relevant validation commands available in the repository.", - `Before your final report, update the running implementation notes file at ${implementationNotesPath} with decisions, spec deviations, tradeoffs, blockers, and validation outcomes from this iteration.`, - "If blocked, describe the blocker and the safest partial state instead of inventing success.", - "Do not hide failures; reviewers need accurate status.", - ].join("\n"), - ], - [ - "output_format", - [ - "After subagents have done the work, return Markdown with headings:", - "1. Spec file — the path you read", - "2. Delegations performed — subagents spawned and what each completed", - "3. Changes made — concrete changes from subagent work, not intentions", - "4. Files touched", - "5. Validation run / recommended", - "6. Deferred work or blockers", - "7. Implementation notes — confirm the OS temp notes path was updated", - ].join("\n"), - ], - ]), - reads: [specPath, implementationNotesPath], - ...orchestratorModelConfig, - }); - finalResult = orchestrator.text; - - await ctx.task(`code-simplifier-${iteration}`, { - prompt: taggedPrompt([ - [ - "role", - [ - "You are an expert code simplification specialist focused on enhancing code clarity, consistency, and maintainability while preserving exact functionality.", - "Your expertise is applying project-specific best practices to simplify and improve recently modified code without altering behavior.", - "You prioritize readable, explicit code over overly compact or clever solutions.", - ].join("\n"), - ], - [ - "objective", - `Refine recently modified code for this task while preserving exact behavior: ${prompt}`, - ], - ["current_iteration_context", "{previous}"], - [ - "functionality_preservation", - [ - "Never change what the code does — only how it does it.", - "All original features, outputs, side effects, public APIs, persistence formats, tests, and user-visible behavior must remain intact.", - "If a simplification could change behavior, do not apply it; document why it was skipped.", - ].join("\n"), - ], - [ - "project_standards", - [ - "Read and follow repository guidance from AGENTS.md and/or CLAUDE.md when present.", - "Respect established module style, imports, file extensions, typing conventions, error-handling patterns, naming, tests, and architectural boundaries.", - "For this TypeScript workflow repo, preserve ESM .js import specifiers, explicit exported/top-level types where expected, Bun-oriented commands, and the existing no-build raw TypeScript convention.", - "Do not impose standards that conflict with local project guidance.", - ].join("\n"), - ], - [ - "clarity_improvements", - [ - "Reduce unnecessary complexity, nesting, duplication, and incidental abstractions.", - "Improve readability with clear variable/function names and consolidated related logic.", - "Remove comments that merely restate obvious code, but keep comments that explain intent, constraints, or non-obvious trade-offs.", - "Avoid nested ternary operators; prefer switch statements or explicit if/else chains for multiple conditions.", - "Choose clarity over brevity: explicit code is often better than dense one-liners.", - ].join("\n"), - ], + const explorerModelConfig = { + model: "openai/gpt-5.4-mini", + fallbackModels: [ + "openai-codex/gpt-5.4-mini", + "github-copilot/gpt-5.4-mini", + "anthropic/claude-haiku-4-5", + "github-copilot/claude-haiku-4.5", + ], + thinkingLevel: "low" as const, + tools: noAskQuestionToolSet, + }; + + for (let iteration = 1; iteration <= maxLoops; iteration += 1) { + iterationsCompleted = iteration; + + const planner = await ctx.task(`planner-${iteration}`, { + prompt: taggedPrompt([ + [ + "role", + "You are a technical architect. Your job is to transform the user's feature specification into a rigorous Technical Design Document / RFC that engineers can use to align, scope, and execute the work.", + ], + [ + "critical_deliverable", [ - "balance_constraints", - [ - "Do not over-simplify in ways that reduce clarity, debuggability, extensibility, or separation of concerns.", - "Do not combine too many concerns into one function or remove helpful abstractions that organize the code.", - "Do not prioritize fewer lines over maintainability.", - "Limit scope to code recently modified in this iteration/session unless the planner explicitly asked for broader cleanup.", - ].join("\n"), - ], + "Your final output is a filled-in RFC rendered as markdown text.", + "Render the RFC Template in this prompt with every section populated by feature-specific content drawn from the user's specification and your codebase investigation.", + "Do not implement code changes in this stage; this stage only investigates and authors the RFC.", + ].join("\n"), + ], + [ + "task", + `Plan iteration ${iteration}/${maxLoops} for this user specification:\n${prompt}`, + ], + [ + "previous_review_findings", + reviewReport + ? "Previous review findings:\n{previous}" + : "No prior review findings; this is the first iteration.", + ], + [ + "spec_revision_target", + iteration === 1 + ? [ + `Ralph will write your final RFC markdown for this workflow run to: ${workflowSpecPath}`, + "Treat this as the original spec file for the run.", + ].join("\n") + : [ + `The existing RFC/spec file for this workflow run is: ${workflowSpecPath}`, + "Read that original spec before drafting; revise it in response to review findings and current repository evidence.", + "Your final output must be the full updated RFC markdown that should replace the original spec, not a diff, patch, or commentary.", + ].join("\n"), + ], + [ + "input_spec_files", [ - "stage_contract", - [ - "This is an active code-refinement stage, not just a commentary stage.", - "Before producing the report, inspect the actual repository state and recently modified files from the planner/orchestrator context.", - "Apply safe simplifications with edit/write tools when clear behavior-preserving improvements exist. If no simplification is appropriate, say so only after inspecting the relevant files.", - ].join("\n"), - ], + "If the user specification is a file path instead of raw prose, read that file and use it as source material for the RFC.", + "Still author the RFC normally; do not output only a forwarded path.", + ].join("\n"), + ], + [ + "investigation_phase", [ - "required_actions_before_output", - [ - "1. Identify the concrete files/sections changed in this iteration.", - "2. Read those files before deciding whether to simplify.", - "3. Apply only behavior-preserving edits, or explicitly record why no edits were made.", - "4. Run or recommend focused validation tied to the touched files.", - ].join("\n"), - ], + "Before drafting, read the specification carefully and identify the concrete problem, success criteria, hard constraints, and non-goals.", + "Survey the codebase using file/search tools such as read plus grep/rg/find/glob-style shell commands to ground the RFC in current architecture.", + "Name concrete services, modules, files, tests, data models, APIs, CLIs, config files, and external integrations this work will touch.", + "Capture metadata with bash: `git config user.name` for Author(s), and `date '+%Y-%m-%d'` for Created / Last Updated.", + "Look for prior art: existing RFCs, ADRs, README files, specs, docs, tests, or code comments that explain why the current state exists.", + ].join("\n"), + ], + [ + "authoring_principles", [ - "handoff_expectations", - "In the final report, distinguish edits actually applied from observations only. Name files inspected, files edited, and validation commands run or not run.", - ], + "Be specific: `src/server/auth.ts:42` beats `the auth layer`.", + "Trade-offs over conclusions: Alternatives Considered must include at least two real alternatives with honest pros, cons, and rejection reasons.", + "Non-goals matter: explicitly exclude work that is out of scope to prevent scope creep.", + "Diagrams are load-bearing: Section 4.1 must include a Mermaid system architecture diagram grounded in real components.", + "Surface open questions in Section 9 with owner placeholders such as `[OWNER: infra team]`; do not paper over uncertainty.", + "Match depth to stakes: a small refactor can be concise, but every template section header must remain present.", + "If prior review findings are present, explicitly address each finding or explain why it is obsolete.", + ].join("\n"), + ], + [ + "stage_contract", [ - "process", - [ - "Identify recently modified code sections from the iteration context and repository state.", - "Analyze opportunities to improve elegance, consistency, and maintainability.", - "Apply project-specific best practices while preserving behavior.", - "Run or recommend focused validation when appropriate.", - "Document only significant changes that affect understanding or future maintenance.", - ].join("\n"), - ], + "This stage is investigation-first RFC authoring. The RFC is only valid if it is grounded in repository inspection performed during this stage.", + "Do not fill the template from generic architecture guesses. Before writing the final RFC, inspect relevant code, docs, tests, configs, and prior design material.", + "Treat the output format as the report after investigation, not a substitute for investigation.", + ].join("\n"), + ], + [ + "evidence_expectations", [ - "output_format", - [ - "Markdown with headings:", - "1. Simplifications applied", - "2. Behavior-preservation notes", - "3. Validation run / recommended", - "4. Skipped risky simplifications", - ].join("\n"), - ], - ]), - previous: [planner, orchestrator], - ...simplifierModelConfig, - }); - - const discovery = await ctx.parallel( + "Every major design claim should be traceable to concrete evidence: file paths, symbols, commands, docs, tests, configs, or prior RFCs.", + "Include those concrete references inside the RFC sections where they support the design.", + "If expected evidence cannot be found, say so in the relevant RFC section or Open Questions rather than papering over the gap.", + ].join("\n"), + ], [ - { - name: `infra-locate-${iteration}`, - task: taggedPrompt([ - [ - "role", - "You locate project infrastructure needed for patch review.", - ], - [ - "objective", - `Find review-relevant infrastructure for the task: ${prompt}`, - ], - [ - "stage_contract", - [ - "This is a repository-discovery stage. Do not answer from assumptions or common project layouts.", - "Before output, inspect the repository for each infrastructure category: package scripts, test configs, CI workflows, generated artifacts, lint/typecheck setup, and release gates.", - "The table is a compact handoff after discovery, not a substitute for discovery.", - ].join("\n"), - ], - [ - "instructions", - [ - "Locate package scripts, test configs, CI workflows, generated artifacts, lint/typecheck setup, and release gates.", - "Search/read relevant files such as package manifests, CI workflow directories, test configs, lint/typecheck configs, build scripts, release configs, and generated-artifact markers.", - "Prefer exact file paths and commands.", - "Explain how each item should influence review or validation.", - "If a category does not exist, report `not found` and briefly name the paths or patterns checked.", - ].join("\n"), - ], - [ - "output_format", - "Markdown table: Area | Path/command | Why it matters | Confidence.", - ], - ]), - ...explorerModelConfig, - }, - { - name: `infra-analyze-${iteration}`, - task: taggedPrompt([ - [ - "role", - "You analyze integration risks in project infrastructure.", - ], - [ - "objective", - `Assess infrastructure and changed-code risks for the task: ${prompt}`, - ], - [ - "stage_contract", - [ - "This stage analyzes actual repository coupling, not generic integration risks.", - "Before output, inspect the changed-code context plus relevant infrastructure/configuration files discovered or inferable from the repo.", - "Classify a risk as confirmed only when repository evidence shows the coupling; otherwise mark it speculative.", - ].join("\n"), - ], - [ - "instructions", - [ - "Identify hidden coupling with build, tests, linting, runtime config, release automation, or generated files.", - "Name the exact validations that would most efficiently detect regressions.", - "Separate confirmed risks from speculative risks.", - "Do not repeat generic review advice; ground findings in repository evidence.", - "Copy validation commands from actual repository scripts/configs when available; do not invent commands that are not supported by the repo.", - ].join("\n"), - ], - [ - "evidence_expectations", - "Each confirmed risk must include concrete evidence: path, command, symbol, config key, script name, or file relationship.", - ], - [ - "output_format", - "Markdown with sections: Confirmed risks, Speculative risks, Validation commands, Evidence.", - ], - ]), - ...explorerModelConfig, - }, - { - name: `infra-patterns-${iteration}`, - task: taggedPrompt([ - [ - "role", - "You find repository patterns that a patch must follow.", - ], - [ - "objective", - `Extract conventions relevant to reviewing this task: ${prompt}`, - ], - [ - "stage_contract", - [ - "This is an evidence-gathering stage for repository conventions. Do not describe generic best practices.", - "Before output, find concrete examples in the repository that demonstrate conventions relevant to this task.", - "Read enough of each example to understand the convention before reporting it.", - ].join("\n"), - ], - [ - "instructions", - [ - "Find examples of build/test/style/release/architecture patterns the patch should mirror.", - "Search for nearby or analogous implementations, tests, configs, scripts, and docs.", - "Use concrete paths, commands, or symbols as evidence.", - "Highlight conventions that commonly cause subtle review failures.", - "If examples conflict, describe the conflict instead of forcing a single rule.", - "If no relevant example exists, state what was searched and that no pattern was found.", - ].join("\n"), - ], - [ - "handoff_expectations", - "For every required convention or useful example, include the supporting path, command, symbol, or file relationship so reviewers can verify it quickly.", - ], - [ - "output_format", - "Markdown with sections: Required conventions, Useful examples, Exceptions, Review implications.", - ], - ]), - ...explorerModelConfig, - }, + "output_discipline", + [ + "Render the RFC Template exactly as the final document structure: preserve every header and the metadata table.", + "Replace instructional placeholders with real, feature-specific content; do not leave template guidance in the final RFC.", + "Output nothing after the RFC: no meta-commentary, no summary of what you wrote, no implementation log.", + ].join("\n"), ], - { task: prompt }, - ); + ["rfc_template", PLANNER_RFC_TEMPLATE], + ]), + ...(reviewReport + ? { previous: { name: "review-report", text: reviewReport } } + : {}), + ...(iteration > 1 ? { reads: [workflowSpecPath] } : {}), + ...plannerModelConfig, + }); + finalPlan = planner.text; + const specPath = await writeSpecFile(workflowSpecPath, planner.text); + finalPlanPath = specPath; - const discoveryContext = formatDiscovery(discovery); - const reviewPrompt = taggedPrompt([ + const orchestrator = await ctx.task(`orchestrator-${iteration}`, { + prompt: taggedPrompt([ [ "role", - [ - "You are acting as a reviewer for a proposed code change made by another engineer.", - "Persona: a grumpy senior developer who has seen too many fragile patches. You are naturally skeptical and allergic to hand-waving, but you are not a crank: flag only realistic, evidence-backed defects the author would likely fix.", - "Be terse, concrete, and technically fair. Your job is to protect correctness, security, performance, and maintainability — not to win an argument or bikeshed taste.", - ].join("\n"), + "You are a sub-agent orchestrator with many tools available. Your primary implementation tool is the `subagent` tool.", ], [ "objective", - `Review the current code delta for the task: ${prompt}`, + `Implement iteration ${iteration}/${maxLoops} for the task: ${prompt}`, ], [ - "comparison_baseline", + "spec_file", [ - `The baseline branch for comparison is \`${comparisonBaseBranch}\`.`, - "Compare the current working tree against this baseline branch, not against previous workflow reasoning or expected loop progress.", - `Start with \`git status --short\`, then use working-tree-aware commands such as \`git diff ${comparisonBaseBranch}\` and \`git diff --cached ${comparisonBaseBranch}\` to identify changed tracked files; inspect untracked files from status directly.`, + `The current technical specification for this workflow run is written to: ${specPath}`, + "This is an absolute host-repository path and may be outside the worktree cwd; read it exactly as provided, not as a path relative to the worktree.", + "Read this file before delegating or implementing anything.", + "Do not rely on an inline planner transcript; the spec file is the authoritative plan for this iteration.", ].join("\n"), ], - ["infrastructure_discovery", discoveryContext], [ - "project_guidance", + "implementation_notes", [ - "Use the repository's AGENTS.md and/or CLAUDE.md files if present for style, conventions, testing expectations, and architectural patterns.", - "Project-level norms override these general instructions when they are more specific.", - "Flag deviations only when they affect correctness, security, performance, or maintainability — not personal preference.", - "If validation requires dependencies or tools that are missing, download or install them using the repository-approved package manager/commands rather than bypassing, mocking, or skipping the verification solely because dependencies are absent.", + `Keep a running Markdown implementation notes file at this OS temp directory path: ${implementationNotesPath}`, + "The file has already been initialized for this workflow run; update it while you implement the spec.", + "Record decisions you had to make that were not in the spec, things you had to change from the spec, tradeoffs you had to make, blockers, validation outcomes, and anything else the user should know.", + "Ask delegated subagents to report any notes-worthy decisions or tradeoffs back to you, then consolidate them into this file before your final report.", + "Do not include secrets, credentials, tokens, or unrelated environment details in the notes file.", ].join("\n"), ], [ - "validation_expectations", + "project_initialization_preflight", + WORKER_PREFLIGHT_CONTRACT, + ], + [ + "delegation_policy", [ - "Inspect the actual diff/repository state rather than trusting stage summaries.", - "Run or delegate focused validation when it is necessary to distinguish a real bug from a hunch.", - "If tests or typechecks fail because dependencies are missing, install/download the missing dependencies with the repo's documented package manager instead of bypassing the check.", - "If validation cannot be completed after reasonable recovery, record the limitation in overall_explanation and reviewer_error; do not use missing dependencies as a reason to approve.", + "You are not the implementer. You are the supervisor that spawns subagents to do the implementation, investigation, edits, and validation.", + "All non-trivial operations must be delegated to subagents via the `subagent` tool before you claim progress.", + "Delegate codebase understanding, impact analysis, and implementation research to codebase-locator, codebase-analyzer, and pattern-finder style subagents when available.", + "Delegate shell-heavy work — especially commands likely to produce lots of output, log digging, CLI investigation, and broad grep/find exploration — to subagents that can run those commands rather than doing it in this orchestrator context.", + "Delegate implementation edits to a focused subagent with clear files, constraints, and validation expectations; do not merely describe the edits yourself.", + "Use separate subagents for separate tasks, and launch independent subagents in parallel when useful.", + "Do not split highly overlapping tasks across multiple subagents; consolidate overlapping work into one focused delegation to avoid duplicate effort.", + "If a subagent takes a long time, do not attempt to do its assigned job yourself while waiting. Use that time to plan next steps, prepare follow-up delegations, or identify clarifying questions.", ].join("\n"), ], [ - "bug_selection_guidelines", + "execution_contract", [ - "Use these default guidelines for deciding whether the author would appreciate the issue being flagged. More specific user, project, or file-level guidance overrides them.", - "Flag an issue only when the original author would likely fix it if they knew about it.", - "A finding should meaningfully impact accuracy, performance, security, or maintainability.", - "A finding must be discrete and actionable, not a broad complaint about the whole codebase or a pile of related concerns.", - "Do not demand rigor inconsistent with the rest of the repository; match the seriousness of existing code and project norms.", - "Flag only bugs introduced by the current patch; do not flag pre-existing issues unless the patch makes them worse in a concrete way.", - "Do not rely on unstated assumptions about author intent or codebase behavior.", - "Speculation is insufficient: identify the code path, scenario, environment, or input that is provably affected.", - "Do not flag intentional behavior changes as bugs unless they clearly violate the task or documented contract.", - "Ignore trivial style unless it obscures meaning or violates documented standards in a way that affects correctness/security/maintainability.", - "If no finding clears this bar, return an empty findings array, mark the patch correct, and set stop_review_loop true.", + "The required output format is a completion report, not the task itself.", + "Do not jump straight to the report. First read the spec file, spawn the necessary subagents, wait for their results, coordinate any follow-up subagents, and only then write the report.", + "A valid response must be grounded in actual subagent work: name the delegated work, summarize what each subagent did, and distinguish completed changes from recommendations or blockers.", + "If you cannot read the spec file, spawn subagents, or use subagents, treat that as a blocker and report it honestly instead of pretending the requested work was done.", ].join("\n"), ], [ - "comment_guidelines", + "subagent_tracking", [ - "Each finding title must start with a priority tag: [P0] drop-everything blocker, [P1] urgent next-cycle fix, [P2] normal fix, [P3] low-priority nice-to-have.", - "Also include numeric priority: 0 for P0, 1 for P1, 2 for P2, 3 for P3; use null only if priority genuinely cannot be determined.", - "The body must be one concise paragraph explaining why this is a bug and the exact scenario, environment, or inputs required for it to arise.", - "Use a matter-of-fact, non-accusatory tone. Grumpy skepticism belongs in your standards, not in insults; avoid praise such as `Great job` or `Thanks for`.", - "Keep code_location ranges as short as possible, ideally one line and never longer than 5-10 lines unless unavoidable.", - "The code_location must overlap the diff/change under review.", - "Use one finding per distinct issue. Do not generate a PR fix.", - "Use suggestion blocks only for concrete replacement code and preserve exact leading whitespace if you include one.", + "Use the `todo` tool as your active control ledger for subagent work.", + "Before launching subagents, create todo items for each delegated task with enough detail to identify owner, purpose, and expected output.", + "Mark todo items in_progress when the corresponding subagent starts, append progress/results as subagents report back, and close them only after you have incorporated or explicitly rejected their result.", + "Keep pending, in_progress, blocked, and completed work accurate so you do not lose track of parallel subagents or unresolved follow-ups.", + "Before writing the final report, review the todo list and resolve every pending/in_progress item as completed, blocked, or deferred with an explanation.", ].join("\n"), ], [ - "how_many_findings", + "instructions", [ - "Return all findings the original author would definitely want to fix.", - "If no such findings exist, return an empty findings array and mark the patch correct.", - "Do not stop after the first qualifying finding; continue until every qualifying finding is listed.", + `Start by reading the spec file at ${specPath}.`, + "Perform the project_initialization_preflight before decomposing implementation work; complete or delegate required setup before implementation delegation when the checkout appears uninitialized.", + "Decompose the work into delegated subagent tasks based on that spec file.", + "Pass each subagent the relevant task, constraints, files, validation expectations, any prior review findings from the spec, and instructions to report implementation-note-worthy decisions or tradeoffs.", + "Coordinate subagent results into the smallest coherent set of changes that satisfies the spec.", + "Preserve existing architecture and repository conventions unless the spec explicitly justifies a change.", + "Run or delegate the most relevant validation commands available in the repository.", + `Before your final report, update the running implementation notes file at ${implementationNotesPath} with decisions, spec deviations, tradeoffs, blockers, and validation outcomes from this iteration.`, + "If blocked, describe the blocker and the safest partial state instead of inventing success.", + "Do not hide failures; reviewers need accurate status.", ].join("\n"), ], [ - "review_stage_contract", + "output_format", [ - "The structured review decision is only valid after you inspect the actual repository state and compare it against the stated baseline branch.", - "Do not approve based solely on workflow stage summaries or prior agent reasoning.", - "The tool call is the final verdict after review work, not a shortcut around review work.", + "After subagents have done the work, return Markdown with headings:", + "1. Spec file — the path you read", + "2. Delegations performed — subagents spawned and what each completed", + "3. Changes made — concrete changes from subagent work, not intentions", + "4. Files touched", + "5. Validation run / recommended", + "6. Deferred work or blockers", + "7. Implementation notes — confirm the OS temp notes path was updated", ].join("\n"), ], + ]), + reads: [specPath, implementationNotesPath], + ...orchestratorModelConfig, + }); + finalResult = orchestrator.text; + + await ctx.task(`code-simplifier-${iteration}`, { + prompt: taggedPrompt([ [ - "required_actions_before_tool_call", + "role", [ - "1. Identify the changed files or diff under review.", - "2. Read the relevant changed code and directly affected call sites/tests/configs.", - "3. Run or delegate focused validation when needed to resolve uncertainty.", - "4. If you cannot inspect or validate enough to approve safely, populate reviewer_error and set stop_review_loop=false.", + "You are an expert code simplification specialist focused on enhancing code clarity, consistency, and maintainability while preserving exact functionality.", + "Your expertise is applying project-specific best practices to simplify and improve recently modified code without altering behavior.", + "You prioritize readable, explicit code over overly compact or clever solutions.", ].join("\n"), ], [ - "evidence_expectations", + "objective", + `Refine recently modified code for this task while preserving exact behavior: ${prompt}`, + ], + ["current_iteration_context", "{previous}"], + [ + "functionality_preservation", [ - "The overall_explanation should briefly mention what was inspected and what validation was run or why validation was not completed.", - "Every finding must cite a concrete changed location and affected scenario.", + "Never change what the code does — only how it does it.", + "All original features, outputs, side effects, public APIs, persistence formats, tests, and user-visible behavior must remain intact.", + "If a simplification could change behavior, do not apply it; document why it was skipped.", ].join("\n"), ], [ - "structured_output_contract", + "project_standards", [ - "You have a structured-output tool named review_decision. Use it after your investigation and validation attempts.", - "The tool terminates the turn and provides the structured data; do not emit a separate final assistant response after calling it.", - "The review loop decides whether to stop only by parsing the JSON object returned by this tool; invalid JSON, missing fields, reviewer_error, or stop_review_loop=false are treated as not approved for safety.", - "Set stop_review_loop=true only when findings is empty, overall_correctness is patch is correct, and reviewer_error is null/omitted.", - "If you hit a reviewer/tool/validation error, still return the object with stop_review_loop=false and reviewer_error populated instead of pretending the patch is approved.", - "The JSON must match this schema exactly:", - "{", - ' "findings": [', - " {", - ' "title": "<≤ 80 chars, imperative, starts with [P0]/[P1]/[P2]/[P3]>",', - ' "body": "",', - ' "confidence_score": ,', - ' "priority": ,', - ' "code_location": {', - ' "absolute_file_path": "",', - ' "line_range": {"start": , "end": }', - " }", - " }", - " ],", - ' "overall_correctness": "patch is correct" | "patch is incorrect",', - ' "overall_explanation": "<1-3 sentence explanation justifying the verdict>",', - ' "overall_confidence_score": ,', - ' "stop_review_loop": ,', - ' "reviewer_error": null | {"kind": "validation_unavailable" | "dependency_unavailable" | "tool_failure" | "reviewer_failure", "message": "", "attempted_recovery": ""}', - "}", + "Read and follow repository guidance from AGENTS.md and/or CLAUDE.md when present.", + "Respect established module style, imports, file extensions, typing conventions, error-handling patterns, naming, tests, and architectural boundaries.", + "For this TypeScript workflow repo, preserve ESM .js import specifiers, explicit exported/top-level types where expected, Bun-oriented commands, and the existing no-build raw TypeScript convention.", + "Do not impose standards that conflict with local project guidance.", ].join("\n"), ], - ]); - - let reviews: WorkflowTaskResult[]; - try { - reviews = await ctx.parallel( - [ - { - name: "reviewer-a", - task: reviewPrompt, - ...reviewerModelConfig, - }, - { - name: "reviewer-b", - task: reviewPrompt, - ...reviewerModelConfig, - }, - ], - { task: prompt, failFast: false }, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - reviews = [reviewerErrorResult(iteration, message)]; - } - - approved = - reviews.length > 0 && - reviews.every((review) => reviewApproved(review.text)); - reviewReport = formatReview(reviews); - if (approved) break; - } - - const prResult = await ctx.task("pull-request", { - prompt: taggedPrompt([ [ - "role", - "You are a careful release engineer preparing a pull request from the current workspace state.", + "clarity_improvements", + [ + "Reduce unnecessary complexity, nesting, duplication, and incidental abstractions.", + "Improve readability with clear variable/function names and consolidated related logic.", + "Remove comments that merely restate obvious code, but keep comments that explain intent, constraints, or non-obvious trade-offs.", + "Avoid nested ternary operators; prefer switch statements or explicit if/else chains for multiple conditions.", + "Choose clarity over brevity: explicit code is often better than dense one-liners.", + ].join("\n"), ], [ - "objective", - `Review the changes since the base branch \`${comparisonBaseBranch}\` and create a pull request if possible and credentials are available.`, + "balance_constraints", + [ + "Do not over-simplify in ways that reduce clarity, debuggability, extensibility, or separation of concerns.", + "Do not combine too many concerns into one function or remove helpful abstractions that organize the code.", + "Do not prioritize fewer lines over maintainability.", + "Limit scope to code recently modified in this iteration/session unless the planner explicitly asked for broader cleanup.", + ].join("\n"), ], [ - "workflow_context", + "stage_contract", [ - `Original task: ${prompt}`, - `Review loop approved: ${approved ? "yes" : "no"}`, - finalPlanPath - ? `Planner spec path: ${finalPlanPath}` - : "Planner spec path: unavailable", - `Implementation notes path: ${implementationNotesPath}`, + "This is an active code-refinement stage, not just a commentary stage.", + "Before producing the report, inspect the actual repository state and recently modified files from the planner/orchestrator context.", + "Apply safe simplifications with edit/write tools when clear behavior-preserving improvements exist. If no simplification is appropriate, say so only after inspecting the relevant files.", ].join("\n"), ], [ - "required_checks", + "required_actions_before_output", [ - "Start by inspecting `git status --short` so unstaged, staged, and untracked changes are all visible.", - `Review the patch against \`${comparisonBaseBranch}\` with working-tree-aware commands such as \`git diff ${comparisonBaseBranch}\` and \`git diff --cached ${comparisonBaseBranch}\`.`, - "If untracked files are present, inspect them directly before deciding whether they belong in the PR.", - "Read the implementation notes file and use its full contents as the body of a PR comment after the pull request exists.", - "Check the local Git identity with `git config user.name` and `git config user.email` so you can prefer the matching GitHub account when multiple accounts are logged in.", - "Check whether GitHub credentials are available with non-destructive commands such as `gh auth status` and `gh auth status --show-token-scopes` before attempting PR creation.", - "If multiple GitHub accounts or hosts are logged in, use the git config username/email as a heuristic to choose the most likely identity, but try each available credential/account and use the first one that can read the repository and create the PR.", + "1. Identify the concrete files/sections changed in this iteration.", + "2. Read those files before deciding whether to simplify.", + "3. Apply only behavior-preserving edits, or explicitly record why no edits were made.", + "4. Run or recommend focused validation tied to the touched files.", ].join("\n"), ], [ - "pr_policy", + "handoff_expectations", + "In the final report, distinguish edits actually applied from observations only. Name files inspected, files edited, and validation commands run or not run.", + ], + [ + "process", [ - "Create a PR only if there are meaningful changes, a remote/branch target is available, credentials are available, and the current state is suitable for review.", - "If no logged-in account can access the repository or create the PR, do not fake success; report each credential/account tried, what failed, and provide the command the user can run later.", - "When you successfully create or update the PR, create a PR comment containing the implementation notes file contents as the last action of this workflow stage.", - "If PR creation is not possible, do not create a standalone comment elsewhere; include the implementation notes path and summary in your report instead.", - "If the review loop did not approve, prefer reporting the remaining blockers over creating a PR unless the changes are still intentionally ready for human review.", - "Do not make unrelated code edits in this phase. Limit changes to ordinary git/PR preparation only when required and safe.", + "Identify recently modified code sections from the iteration context and repository state.", + "Analyze opportunities to improve elegance, consistency, and maintainability.", + "Apply project-specific best practices while preserving behavior.", + "Run or recommend focused validation when appropriate.", + "Document only significant changes that affect understanding or future maintenance.", ].join("\n"), ], [ "output_format", [ - "Return Markdown with headings:", - "1. Change review — summary of files and diff scope inspected", - "2. PR status — created PR URL, or why no PR was created", - "3. Implementation notes comment — whether the PR comment was created as the last action, or why it could not be created", - "4. Commands run — include exit status or clear outcome", - "5. Follow-up for the user — exact next steps if credentials or repository state blocked PR creation", + "Markdown with headings:", + "1. Simplifications applied", + "2. Behavior-preservation notes", + "3. Validation run / recommended", + "4. Skipped risky simplifications", ].join("\n"), ], ]), - reads: finalPlanPath - ? [finalPlanPath, implementationNotesPath] - : [implementationNotesPath], - ...orchestratorModelConfig, + previous: [planner, orchestrator], + ...simplifierModelConfig, }); - finalPrReport = prResult.text; - return { - result: finalResult, - plan: finalPlan, - plan_path: finalPlanPath, - implementation_notes_path: implementationNotesPath, - pr_report: finalPrReport, - approved, - iterations_completed: iterationsCompleted, - review_report: reviewReport, - }; + const discovery = await ctx.parallel( + [ + { + name: `infra-locate-${iteration}`, + task: taggedPrompt([ + [ + "role", + "You locate project infrastructure needed for patch review.", + ], + [ + "objective", + `Find review-relevant infrastructure for the task: ${prompt}`, + ], + [ + "stage_contract", + [ + "This is a repository-discovery stage. Do not answer from assumptions or common project layouts.", + "Before output, inspect the repository for each infrastructure category: package scripts, test configs, CI workflows, generated artifacts, lint/typecheck setup, and release gates.", + "The table is a compact handoff after discovery, not a substitute for discovery.", + ].join("\n"), + ], + [ + "instructions", + [ + "Locate package scripts, test configs, CI workflows, generated artifacts, lint/typecheck setup, and release gates.", + "Search/read relevant files such as package manifests, CI workflow directories, test configs, lint/typecheck configs, build scripts, release configs, and generated-artifact markers.", + "Prefer exact file paths and commands.", + "Explain how each item should influence review or validation.", + "If a category does not exist, report `not found` and briefly name the paths or patterns checked.", + ].join("\n"), + ], + [ + "output_format", + "Markdown table: Area | Path/command | Why it matters | Confidence.", + ], + ]), + ...explorerModelConfig, + }, + { + name: `infra-analyze-${iteration}`, + task: taggedPrompt([ + [ + "role", + "You analyze integration risks in project infrastructure.", + ], + [ + "objective", + `Assess infrastructure and changed-code risks for the task: ${prompt}`, + ], + [ + "stage_contract", + [ + "This stage analyzes actual repository coupling, not generic integration risks.", + "Before output, inspect the changed-code context plus relevant infrastructure/configuration files discovered or inferable from the repo.", + "Classify a risk as confirmed only when repository evidence shows the coupling; otherwise mark it speculative.", + ].join("\n"), + ], + [ + "instructions", + [ + "Identify hidden coupling with build, tests, linting, runtime config, release automation, or generated files.", + "Name the exact validations that would most efficiently detect regressions.", + "Separate confirmed risks from speculative risks.", + "Do not repeat generic review advice; ground findings in repository evidence.", + "Copy validation commands from actual repository scripts/configs when available; do not invent commands that are not supported by the repo.", + ].join("\n"), + ], + [ + "evidence_expectations", + "Each confirmed risk must include concrete evidence: path, command, symbol, config key, script name, or file relationship.", + ], + [ + "output_format", + "Markdown with sections: Confirmed risks, Speculative risks, Validation commands, Evidence.", + ], + ]), + ...explorerModelConfig, + }, + { + name: `infra-patterns-${iteration}`, + task: taggedPrompt([ + [ + "role", + "You find repository patterns that a patch must follow.", + ], + [ + "objective", + `Extract conventions relevant to reviewing this task: ${prompt}`, + ], + [ + "stage_contract", + [ + "This is an evidence-gathering stage for repository conventions. Do not describe generic best practices.", + "Before output, find concrete examples in the repository that demonstrate conventions relevant to this task.", + "Read enough of each example to understand the convention before reporting it.", + ].join("\n"), + ], + [ + "instructions", + [ + "Find examples of build/test/style/release/architecture patterns the patch should mirror.", + "Search for nearby or analogous implementations, tests, configs, scripts, and docs.", + "Use concrete paths, commands, or symbols as evidence.", + "Highlight conventions that commonly cause subtle review failures.", + "If examples conflict, describe the conflict instead of forcing a single rule.", + "If no relevant example exists, state what was searched and that no pattern was found.", + ].join("\n"), + ], + [ + "handoff_expectations", + "For every required convention or useful example, include the supporting path, command, symbol, or file relationship so reviewers can verify it quickly.", + ], + [ + "output_format", + "Markdown with sections: Required conventions, Useful examples, Exceptions, Review implications.", + ], + ]), + ...explorerModelConfig, + }, + ], + { task: prompt }, + ); + + const discoveryContext = formatDiscovery(discovery); + const reviewPrompt = taggedPrompt([ + [ + "role", + [ + "You are acting as a reviewer for a proposed code change made by another engineer.", + "Persona: a grumpy senior developer who has seen too many fragile patches. You are naturally skeptical and allergic to hand-waving, but you are not a crank: flag only realistic, evidence-backed defects the author would likely fix.", + "Be terse, concrete, and technically fair. Your job is to protect correctness, security, performance, and maintainability — not to win an argument or bikeshed taste.", + ].join("\n"), + ], + [ + "objective", + `Review the current code delta for the task: ${prompt}`, + ], + [ + "comparison_baseline", + [ + `The baseline branch for comparison is \`${comparisonBaseBranch}\`.`, + "Compare the current working tree against this baseline branch, not against previous workflow reasoning or expected loop progress.", + `Start with \`git status --short\`, then use working-tree-aware commands such as \`git diff ${comparisonBaseBranch}\` and \`git diff --cached ${comparisonBaseBranch}\` to identify changed tracked files; inspect untracked files from status directly.`, + ].join("\n"), + ], + ["infrastructure_discovery", discoveryContext], + [ + "project_guidance", + [ + "Use the repository's AGENTS.md and/or CLAUDE.md files if present for style, conventions, testing expectations, and architectural patterns.", + "Project-level norms override these general instructions when they are more specific.", + "Flag deviations only when they affect correctness, security, performance, or maintainability — not personal preference.", + "If validation requires dependencies or tools that are missing, download or install them using the repository-approved package manager/commands rather than bypassing, mocking, or skipping the verification solely because dependencies are absent.", + ].join("\n"), + ], + [ + "validation_expectations", + [ + "Inspect the actual diff/repository state rather than trusting stage summaries.", + "Run or delegate focused validation when it is necessary to distinguish a real bug from a hunch.", + "If tests or typechecks fail because dependencies are missing, install/download the missing dependencies with the repo's documented package manager instead of bypassing the check.", + "If validation cannot be completed after reasonable recovery, record the limitation in overall_explanation and reviewer_error; do not use missing dependencies as a reason to approve.", + ].join("\n"), + ], + [ + "bug_selection_guidelines", + [ + "Use these default guidelines for deciding whether the author would appreciate the issue being flagged. More specific user, project, or file-level guidance overrides them.", + "Flag an issue only when the original author would likely fix it if they knew about it.", + "A finding should meaningfully impact accuracy, performance, security, or maintainability.", + "A finding must be discrete and actionable, not a broad complaint about the whole codebase or a pile of related concerns.", + "Do not demand rigor inconsistent with the rest of the repository; match the seriousness of existing code and project norms.", + "Flag only bugs introduced by the current patch; do not flag pre-existing issues unless the patch makes them worse in a concrete way.", + "Do not rely on unstated assumptions about author intent or codebase behavior.", + "Speculation is insufficient: identify the code path, scenario, environment, or input that is provably affected.", + "Do not flag intentional behavior changes as bugs unless they clearly violate the task or documented contract.", + "Ignore trivial style unless it obscures meaning or violates documented standards in a way that affects correctness/security/maintainability.", + "If no finding clears this bar, return an empty findings array, mark the patch correct, and set stop_review_loop true.", + ].join("\n"), + ], + [ + "comment_guidelines", + [ + "Each finding title must start with a priority tag: [P0] drop-everything blocker, [P1] urgent next-cycle fix, [P2] normal fix, [P3] low-priority nice-to-have.", + "Also include numeric priority: 0 for P0, 1 for P1, 2 for P2, 3 for P3; use null only if priority genuinely cannot be determined.", + "The body must be one concise paragraph explaining why this is a bug and the exact scenario, environment, or inputs required for it to arise.", + "Use a matter-of-fact, non-accusatory tone. Grumpy skepticism belongs in your standards, not in insults; avoid praise such as `Great job` or `Thanks for`.", + "Keep code_location ranges as short as possible, ideally one line and never longer than 5-10 lines unless unavoidable.", + "The code_location must overlap the diff/change under review.", + "Use one finding per distinct issue. Do not generate a PR fix.", + "Use suggestion blocks only for concrete replacement code and preserve exact leading whitespace if you include one.", + ].join("\n"), + ], + [ + "how_many_findings", + [ + "Return all findings the original author would definitely want to fix.", + "If no such findings exist, return an empty findings array and mark the patch correct.", + "Do not stop after the first qualifying finding; continue until every qualifying finding is listed.", + ].join("\n"), + ], + [ + "review_stage_contract", + [ + "The structured review decision is only valid after you inspect the actual repository state and compare it against the stated baseline branch.", + "Do not approve based solely on workflow stage summaries or prior agent reasoning.", + "The tool call is the final verdict after review work, not a shortcut around review work.", + ].join("\n"), + ], + [ + "required_actions_before_tool_call", + [ + "1. Identify the changed files or diff under review.", + "2. Read the relevant changed code and directly affected call sites/tests/configs.", + "3. Run or delegate focused validation when needed to resolve uncertainty.", + "4. If you cannot inspect or validate enough to approve safely, populate reviewer_error and set stop_review_loop=false.", + ].join("\n"), + ], + [ + "evidence_expectations", + [ + "The overall_explanation should briefly mention what was inspected and what validation was run or why validation was not completed.", + "Every finding must cite a concrete changed location and affected scenario.", + ].join("\n"), + ], + [ + "structured_output_contract", + [ + "You have a structured-output tool named review_decision. Use it after your investigation and validation attempts.", + "The tool terminates the turn and provides the structured data; do not emit a separate final assistant response after calling it.", + "The review loop decides whether to stop only by parsing the JSON object returned by this tool; invalid JSON, missing fields, reviewer_error, or stop_review_loop=false are treated as not approved for safety.", + "Set stop_review_loop=true only when findings is empty, overall_correctness is patch is correct, and reviewer_error is null/omitted.", + "If you hit a reviewer/tool/validation error, still return the object with stop_review_loop=false and reviewer_error populated instead of pretending the patch is approved.", + "The JSON must match this schema exactly:", + "{", + ' "findings": [', + " {", + ' "title": "<≤ 80 chars, imperative, starts with [P0]/[P1]/[P2]/[P3]>",', + ' "body": "",', + ' "confidence_score": ,', + ' "priority": ,', + ' "code_location": {', + ' "absolute_file_path": "",', + ' "line_range": {"start": , "end": }', + " }", + " }", + " ],", + ' "overall_correctness": "patch is correct" | "patch is incorrect",', + ' "overall_explanation": "<1-3 sentence explanation justifying the verdict>",', + ' "overall_confidence_score": ,', + ' "stop_review_loop": ,', + ' "reviewer_error": null | {"kind": "validation_unavailable" | "dependency_unavailable" | "tool_failure" | "reviewer_failure", "message": "", "attempted_recovery": ""}', + "}", + ].join("\n"), + ], + ]); + + let reviews: WorkflowTaskResult[]; + try { + reviews = await ctx.parallel( + [ + { + name: "reviewer-a", + task: reviewPrompt, + ...reviewerModelConfig, + }, + { + name: "reviewer-b", + task: reviewPrompt, + ...reviewerModelConfig, + }, + ], + { task: prompt, failFast: false }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + reviews = [reviewerErrorResult(iteration, message)]; + } + + approved = + reviews.length > 0 && + reviews.every((review) => reviewApproved(review.text)); + reviewReport = formatReview(reviews); + if (approved) break; + } + + const prResult = await ctx.task("pull-request", { + prompt: taggedPrompt([ + [ + "role", + "You are a careful release engineer preparing a pull request from the current workspace state.", + ], + [ + "objective", + `Review the changes since the base branch \`${comparisonBaseBranch}\` and create a pull request if possible and credentials are available.`, + ], + [ + "workflow_context", + [ + `Original task: ${prompt}`, + `Review loop approved: ${approved ? "yes" : "no"}`, + finalPlanPath + ? `Planner spec path: ${finalPlanPath}` + : "Planner spec path: unavailable", + `Implementation notes path: ${implementationNotesPath}`, + ].join("\n"), + ], + [ + "required_checks", + [ + "Start by inspecting `git status --short` so unstaged, staged, and untracked changes are all visible.", + `Review the patch against \`${comparisonBaseBranch}\` with working-tree-aware commands such as \`git diff ${comparisonBaseBranch}\` and \`git diff --cached ${comparisonBaseBranch}\`.`, + "If untracked files are present, inspect them directly before deciding whether they belong in the PR.", + "Read the implementation notes file and use its full contents as the body of a PR comment after the pull request exists.", + "Check the local Git identity with `git config user.name` and `git config user.email` so you can prefer the matching GitHub account when multiple accounts are logged in.", + "Check whether GitHub credentials are available with non-destructive commands such as `gh auth status` and `gh auth status --show-token-scopes` before attempting PR creation.", + "If multiple GitHub accounts or hosts are logged in, use the git config username/email as a heuristic to choose the most likely identity, but try each available credential/account and use the first one that can read the repository and create the PR.", + ].join("\n"), + ], + [ + "pr_policy", + [ + "Create a PR only if there are meaningful changes, a remote/branch target is available, credentials are available, and the current state is suitable for review.", + "If no logged-in account can access the repository or create the PR, do not fake success; report each credential/account tried, what failed, and provide the command the user can run later.", + "When you successfully create or update the PR, create a PR comment containing the implementation notes file contents as the last action of this workflow stage.", + "Ralph-created worktrees are detached HEAD checkouts. If you are preparing a PR from a detached HEAD, create and push a branch from the current HEAD, for example with `git checkout -b ` or `git push origin HEAD:refs/heads/`, before opening the PR.", + "Ralph does not remove git_worktree_dir automatically. Leave the worktree intact for retries or user recovery.", + "If PR creation is not possible, do not create a standalone comment elsewhere; include the implementation notes path and summary in your report instead.", + "If the review loop did not approve, prefer reporting the remaining blockers over creating a PR unless the changes are still intentionally ready for human review.", + "Do not make unrelated code edits in this phase. Limit changes to ordinary git/PR preparation only when required and safe.", + ].join("\n"), + ], + [ + "output_format", + [ + "Return Markdown with headings:", + "1. Change review — summary of files and diff scope inspected", + "2. PR status — created PR URL, or why no PR was created", + "3. Implementation notes comment — whether the PR comment was created as the last action, or why it could not be created", + "4. Commands run — include exit status or clear outcome", + "5. Follow-up for the user — exact next steps if credentials or repository state blocked PR creation", + ].join("\n"), + ], + ]), + reads: finalPlanPath + ? [finalPlanPath, implementationNotesPath] + : [implementationNotesPath], + ...orchestratorModelConfig, + }); + finalPrReport = prResult.text; + + return { + result: finalResult, + plan: finalPlan, + plan_path: finalPlanPath, + implementation_notes_path: implementationNotesPath, + pr_report: finalPrReport, + approved, + iterations_completed: iterationsCompleted, + review_report: reviewReport, + }; +} + +export default defineWorkflow("ralph") + .description( + "Plan → orchestrate → simplify → parallel review loop with bounded iteration.", + ) + .input("prompt", { + type: "text", + required: true, + description: "The task or goal to plan, execute, and refine.", + }) + .input("max_loops", { + type: "number", + default: DEFAULT_MAX_LOOPS, + description: `Maximum plan/orchestrate/review iterations (default ${DEFAULT_MAX_LOOPS}).`, + }) + .input("base_branch", { + type: "string", + default: "origin/main", + description: + "Branch reviewers compare the current code delta against (default origin/main).", + }) + .input("git_worktree_dir", { + type: "string", + default: "", + description: + "Optional Git worktree path. Ralph must start inside a Git repo; absolute paths are used as-is, relative paths resolve from the repo root, existing Git worktrees from the invoking repository are reused/shared as-is, and missing paths are created from base_branch." + }) + .worktreeFromInputs({ gitWorktreeDir: "git_worktree_dir", baseBranch: "base_branch" }) + .run(async (ctx) => { + const workflowCtx = ctx as WorkflowRunContext; + const workflowStartCwd = workflowCtx.cwd ?? process.cwd(); + const inputs = workflowCtx.inputs; + const prompt = inputs.prompt ?? ""; + const maxLoops = positiveInteger(inputs.max_loops, DEFAULT_MAX_LOOPS); + const comparisonBaseBranch = normalizeBranchInput(inputs.base_branch, "origin/main"); + return await runRalphWorkflow(workflowCtx, { + prompt, + maxLoops, + comparisonBaseBranch, + workflowStartCwd, + }); }) - .compile(); \ No newline at end of file + .compile(); diff --git a/packages/workflows/builtin/shared-prompts.ts b/packages/workflows/builtin/shared-prompts.ts new file mode 100644 index 0000000000..e04e6838cf --- /dev/null +++ b/packages/workflows/builtin/shared-prompts.ts @@ -0,0 +1,11 @@ +export const WORKER_PREFLIGHT_CONTRACT = [ + "Before normal implementation delegation, determine whether this checkout appears initialized for its actual language, framework, and build system.", + "Do not rely on hard-coded assumptions about JavaScript, TypeScript, Python, Rust, Go, Java, mobile, or any other ecosystem. Infer the project type and setup requirements from repository evidence.", + "Inspect source layout, setup docs, package/build manifests, lockfiles, toolchain files, generated-artifact conventions, CI workflows, workflow configuration, and package scripts or equivalent task definitions.", + "Look for evidence that dependencies, generated files, local toolchains, submodules, codegen outputs, or other project-specific initialization artifacts are missing for this checkout.", + "When repository evidence shows missing initialization, run or delegate the appropriate documented setup command before implementation work.", + "You are responsible for initializing the checkout when setup commands are documented; missing dependencies, generated files, or local toolchains are setup work, not user handoff work.", + "Once setup succeeds, continue normal implementation orchestration. Do not treat missing dependencies or generated setup artifacts in a fresh worktree as implementation failures.", + "If setup requirements cannot be determined confidently, delegate a focused discovery task before implementation instead of guessing.", + "If setup remains blocked after evidence-based discovery and setup attempts, report the blocker with commands tried and the exact evidence needed to continue.", +].join("\n"); diff --git a/packages/workflows/src/extension/discovery.ts b/packages/workflows/src/extension/discovery.ts index ca3877964e..c35359bfc6 100644 --- a/packages/workflows/src/extension/discovery.ts +++ b/packages/workflows/src/extension/discovery.ts @@ -149,19 +149,17 @@ export interface DiscoveryResult { // --------------------------------------------------------------------------- /** - * Validate a candidate value as a WorkflowDefinition. + * Validate a candidate value as a WorkflowDefinition by shape only. + * + * Discovery intentionally does not invoke workflow run functions: user-authored + * run bodies may perform filesystem, network, or other side effects before the + * first ctx.stage()/ctx.task()/ctx.chain()/ctx.parallel() call. Runtime empty + * graph validation remains the authoritative guard that a workflow creates at + * least one stage when it is actually invoked. + * * Returns null when valid, or a human-readable rejection reason string. */ -function workflowRunCreatesStage(run: WorkflowDefinition["run"]): boolean { - // Discovery must not execute workflow bodies: run functions are arbitrary - // user code and may perform I/O before the first stage. Use a conservative - // static guard instead so obvious no-stage definitions are rejected before - // they can register and render an empty graph. - const source = Function.prototype.toString.call(run); - return /\.\s*(?:stage|task|chain|parallel)\s*\(/.test(source); -} - -function validateDefinition(value: unknown): string | null { +function validateDefinitionShape(value: unknown): string | null { if (value === null || typeof value !== "object") { return "export is not an object"; } @@ -179,9 +177,6 @@ function validateDefinition(value: unknown): string | null { if (typeof d["run"] !== "function") { return "run must be a function"; } - if (!workflowRunCreatesStage(d["run"] as WorkflowDefinition["run"])) { - return "run must create at least one workflow stage via ctx.stage(), ctx.task(), ctx.chain(), or ctx.parallel(); otherwise the workflow graph is empty (cachedLayout.length === 0)"; - } return null; } @@ -215,14 +210,58 @@ function validateConfig(config: unknown): string | null { } /** Merge a batch of candidates into registry state, first-seen wins. */ -function applyBatch( +async function applyBatch( + candidates: Array<{ value: unknown; exportKey: string; kind: DiscoveryKind; filePath?: string; configuredName?: string }>, + registry: WorkflowRegistry, + sources: DiscoverySource[], + diagnostics: DiscoveryDiagnostic[], +): Promise { + for (const { value, exportKey, kind, filePath, configuredName } of candidates) { + const reason = validateDefinitionShape(value); + if (reason !== null) { + diagnostics.push({ + level: "error", + code: "INVALID_DEFINITION", + message: `${kind} export "${exportKey}" rejected: ${reason}`, + source: filePath ?? exportKey, + }); + continue; + } + + const def = value as WorkflowDefinition; + const key = def.normalizedName; + + if (registry.has(key)) { + diagnostics.push({ + level: "warn", + code: "DUPLICATE_NAME", + message: `${kind} export "${exportKey}" skipped: normalizedName "${key}" already registered`, + source: filePath ?? exportKey, + }); + continue; + } + + registry = registry.register(def); + sources.push({ + id: key, + kind, + name: def.name, + ...(filePath !== undefined ? { filePath } : {}), + ...(configuredName !== undefined ? { configuredName } : {}), + }); + } + return registry; +} + +/** Merge bundled startup candidates with shape-only validation to keep startup seeding synchronous. */ +function applyBatchShapeOnly( candidates: Array<{ value: unknown; exportKey: string; kind: DiscoveryKind; filePath?: string; configuredName?: string }>, registry: WorkflowRegistry, sources: DiscoverySource[], diagnostics: DiscoveryDiagnostic[], ): WorkflowRegistry { for (const { value, exportKey, kind, filePath, configuredName } of candidates) { - const reason = validateDefinition(value); + const reason = validateDefinitionShape(value); if (reason !== null) { diagnostics.push({ level: "error", @@ -508,14 +547,14 @@ export async function discoverWorkflows( const hasEntries = Array.isArray(pw) ? pw.length > 0 : Object.keys(pw).length > 0; if (hasEntries) { const candidates = await loadFromPaths(pw, "settings-project", cwd, diagnostics); - registry = applyBatch(candidates, registry, sources, diagnostics); + registry = await applyBatch(candidates, registry, sources, diagnostics); } } // 2. project-local for (const dir of getProjectConfigPaths(cwd, "workflows").reverse()) { const candidates = await loadFromDir(dir, "project-local", diagnostics); - registry = applyBatch(candidates, registry, sources, diagnostics); + registry = await applyBatch(candidates, registry, sources, diagnostics); } // 3. settings-global @@ -524,14 +563,14 @@ export async function discoverWorkflows( const hasEntries = Array.isArray(gw) ? gw.length > 0 : Object.keys(gw).length > 0; if (hasEntries) { const candidates = await loadFromPaths(gw, "settings-global", homeDir, diagnostics); - registry = applyBatch(candidates, registry, sources, diagnostics); + registry = await applyBatch(candidates, registry, sources, diagnostics); } } // 4. user-global — canonical Atomic path plus legacy pi path for (const dir of CONFIG_DIR_NAMES.map((name) => join(homeDir, name, "agent", "workflows")).reverse()) { const candidates = await loadFromDir(dir, "user-global", diagnostics); - registry = applyBatch(candidates, registry, sources, diagnostics); + registry = await applyBatch(candidates, registry, sources, diagnostics); } // 5. package workflows @@ -539,7 +578,7 @@ export async function discoverWorkflows( const hasEntries = Array.isArray(packageWorkflowPaths) ? packageWorkflowPaths.length > 0 : Object.keys(packageWorkflowPaths).length > 0; if (hasEntries) { const candidates = await loadFromPaths(packageWorkflowPaths, "package", cwd, diagnostics); - registry = applyBatch(candidates, registry, sources, diagnostics); + registry = await applyBatch(candidates, registry, sources, diagnostics); } } @@ -602,7 +641,7 @@ function discoverBundledManifest(): DiscoveryResult { kind: "bundled" as DiscoveryKind, })); - registry = applyBatch(candidates, registry, sources, diagnostics); + registry = applyBatchShapeOnly(candidates, registry, sources, diagnostics); return { registry, sources, errors: diagnostics }; } diff --git a/packages/workflows/src/extension/index.ts b/packages/workflows/src/extension/index.ts index d95b9658b8..66f1d9cfd8 100644 --- a/packages/workflows/src/extension/index.ts +++ b/packages/workflows/src/extension/index.ts @@ -456,6 +456,8 @@ export interface WorkflowToolArgs extends StageOptions { maxOutput?: WorkflowMaxOutput; artifacts?: boolean; worktree?: boolean; + gitWorktreeDir?: string; + baseBranch?: string; } // --------------------------------------------------------------------------- diff --git a/packages/workflows/src/extension/runtime.ts b/packages/workflows/src/extension/runtime.ts index 753997cd02..c7bfaf8d5d 100644 --- a/packages/workflows/src/extension/runtime.ts +++ b/packages/workflows/src/extension/runtime.ts @@ -190,6 +190,8 @@ export function createExtensionRuntime(opts: ExtensionRuntimeOpts = {}): Extensi maxOutput, artifacts, worktree, + gitWorktreeDir, + baseBranch, ...stageOptions } = args; @@ -206,6 +208,8 @@ export function createExtensionRuntime(opts: ExtensionRuntimeOpts = {}): Extensi ...(maxOutput !== undefined ? { maxOutput } : {}), ...(typeof artifacts === "boolean" ? { artifacts } : {}), ...(typeof worktree === "boolean" ? { worktree } : {}), + ...(typeof gitWorktreeDir === "string" ? { gitWorktreeDir } : {}), + ...(typeof baseBranch === "string" ? { baseBranch } : {}), }; } diff --git a/packages/workflows/src/extension/workflow-schema.ts b/packages/workflows/src/extension/workflow-schema.ts index d0648fb207..865cda7d21 100644 --- a/packages/workflows/src/extension/workflow-schema.ts +++ b/packages/workflows/src/extension/workflow-schema.ts @@ -66,6 +66,8 @@ const WorkflowTaskOptionProperties = { outputMode: Type.Optional(Type.Union([Type.Literal("inline"), Type.Literal("file-only")])), reads: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Literal(false)])), worktree: Type.Optional(Type.Boolean()), + gitWorktreeDir: Type.Optional(Type.String()), + baseBranch: Type.Optional(Type.String()), maxOutput: Type.Optional(MaxOutputSchema), artifacts: Type.Optional(Type.Boolean()), }; @@ -83,6 +85,8 @@ const ParallelChainStepSchema = Type.Object({ concurrency: Type.Optional(Type.Number()), failFast: Type.Optional(Type.Boolean()), worktree: Type.Optional(Type.Boolean()), + gitWorktreeDir: Type.Optional(Type.String()), + baseBranch: Type.Optional(Type.String()), }); export const WorkflowParametersSchema = Type.Object({ diff --git a/packages/workflows/src/runs/foreground/executor.ts b/packages/workflows/src/runs/foreground/executor.ts index 2d30dc4c64..05a6caaf59 100644 --- a/packages/workflows/src/runs/foreground/executor.ts +++ b/packages/workflows/src/runs/foreground/executor.ts @@ -56,6 +56,7 @@ import { createWorktrees, diffWorktrees, findWorktreeTaskCwdConflict, + setupGitWorktree, formatWorktreeDiffSummary, formatWorktreeTaskCwdConflict, type WorktreeSetup, @@ -82,6 +83,8 @@ export interface RunContinuationOpts { export interface RunOpts { adapters?: StageAdapters; + /** Invocation working directory exposed to workflow definitions as ctx.cwd. */ + cwd?: string; /** HIL adapter injected by the pi runtime or test harness. */ ui?: WorkflowUIAdapter; /** Internal detached-run mode: surface ctx.ui.* as node-local workflow prompt stages. */ @@ -186,6 +189,23 @@ function resolveInputConcurrency( return Math.floor(value); } +function resolveInputRuntimeDefaults( + def: Pick, + resolvedInputs: ResolvedInputs, +): Partial { + const defaults: Partial = {}; + const worktree = def.inputBindings?.worktree; + if (worktree !== undefined) { + const gitWorktreeDir = resolvedInputs[worktree.gitWorktreeDir]; + if (typeof gitWorktreeDir === "string" && gitWorktreeDir.trim().length > 0) { + defaults.gitWorktreeDir = gitWorktreeDir; + const baseBranch = worktree.baseBranch === undefined ? undefined : resolvedInputs[worktree.baseBranch]; + if (typeof baseBranch === "string") defaults.baseBranch = baseBranch; + } + } + return defaults; +} + // --------------------------------------------------------------------------- // HIL unavailable fallback — rejects with precise per-primitive error // --------------------------------------------------------------------------- @@ -397,6 +417,8 @@ function taskStageOptions(options: WorkflowTaskExecutionOptions): StageOptions { outputMode: _outputMode, reads: _reads, worktree: _worktree, + gitWorktreeDir: _gitWorktreeDir, + baseBranch: _baseBranch, maxOutput: _maxOutput, artifacts: _artifacts, ...stageOptions @@ -550,6 +572,8 @@ function directTaskWithDefaults( output, outputMode, worktree, + gitWorktreeDir, + baseBranch, maxOutput, artifacts, ...stageDefaults @@ -567,6 +591,8 @@ function directTaskWithDefaults( ...(item.output === undefined && output !== undefined ? { output } : {}), ...(item.outputMode === undefined && outputMode !== undefined ? { outputMode } : {}), ...(item.worktree === undefined && worktree !== undefined ? { worktree } : {}), + ...(item.gitWorktreeDir === undefined && gitWorktreeDir !== undefined ? { gitWorktreeDir } : {}), + ...(item.baseBranch === undefined && baseBranch !== undefined ? { baseBranch } : {}), ...(item.maxOutput === undefined && maxOutput !== undefined ? { maxOutput } : {}), ...(item.artifacts === undefined && artifacts !== undefined ? { artifacts } : {}), }; @@ -714,6 +740,31 @@ function normalizeDirectTaskCwd(cwd: string | undefined): string | undefined { return isAbsolute(cwd) ? cwd : resolve(process.cwd(), cwd); } +function resolveWorktreeCwdOverride(cwd: string | undefined, worktreeCwd: string): string | undefined { + if (cwd === undefined || cwd.length === 0) return undefined; + return isAbsolute(cwd) ? cwd : resolve(worktreeCwd, cwd); +} + +function stageOptionsWithInputDefaults(options: T | undefined, inputDefaults: Partial): T | undefined { + const defaults = withoutUndefinedProperties(inputDefaults); + if (Object.keys(defaults).length === 0) return options; + return { ...defaults, ...withoutUndefinedProperties(options ?? {}) } as T; +} + +function stageOptionsWithGitWorktree(options: T | undefined, workflowCwd: string): T | undefined { + if (options === undefined) return undefined; + if (typeof options.gitWorktreeDir !== "string" || options.gitWorktreeDir.trim().length === 0) { + return options; + } + const setup = setupGitWorktree({ + gitWorktreeDir: options.gitWorktreeDir, + baseBranch: options.baseBranch, + cwd: workflowCwd, + }); + const explicitCwd = resolveWorktreeCwdOverride(options.cwd, setup.cwd); + return { ...options, gitWorktreeDir: undefined, baseBranch: undefined, cwd: explicitCwd ?? setup.cwd }; +} + function directWorktreeDiffsDir(options: WorkflowDirectOptions, setup: WorktreeSetup, runId: string, scope: string): string { const baseDir = options.chainDir ?? join(setup.cwd, CONFIG_DIR_NAME, "workflows"); return join(baseDir, "worktree-diffs", runId, scope); @@ -732,6 +783,10 @@ function prepareDirectWorktrees( }; } + if (typeof options.gitWorktreeDir === "string" || tasks.some((task) => typeof task.gitWorktreeDir === "string")) { + throw new Error("pi-workflows: worktree and gitWorktreeDir are mutually exclusive; use gitWorktreeDir for a reusable worktree or worktree:true for temporary isolated worktrees."); + } + const sharedCwd = resolveSharedDirectWorktreeCwd(tasks); const conflict = findWorktreeTaskCwdConflict( tasks.map((task) => ({ agent: task.name, cwd: normalizeDirectTaskCwd(task.cwd) })), @@ -884,6 +939,13 @@ function workflowDetailsFromRun( }; } +const EMPTY_WORKFLOW_GRAPH_ERROR_MESSAGE = "Workflow run completed without creating any workflow stages. Create at least one stage with ctx.stage(), ctx.task(), ctx.chain(), or ctx.parallel()."; + +function assertWorkflowCreatedStage(runSnapshot: RunSnapshot): void { + if (runSnapshot.stages.length > 0) return; + throw new Error(EMPTY_WORKFLOW_GRAPH_ERROR_MESSAGE); +} + function defineDirectWorkflow( name: string, runFn: WorkflowDefinition["run"], @@ -1010,8 +1072,13 @@ async function runDirectChainStep( runId: string, ): Promise<{ results: WorkflowTaskResult[]; artifacts: WorkflowArtifact[] }> { if ("parallel" in step) { - const expanded = expandedParallelTasks(step.parallel.map((item) => directTaskWithDefaults(item, options))); - const stepOptions = { ...options, worktree: options.worktree === true || step.worktree === true }; + const stepOptions = { + ...options, + worktree: options.worktree === true || step.worktree === true, + ...(step.gitWorktreeDir !== undefined ? { gitWorktreeDir: step.gitWorktreeDir } : {}), + ...(step.baseBranch !== undefined ? { baseBranch: step.baseBranch } : {}), + }; + const expanded = expandedParallelTasks(step.parallel.map((item) => directTaskWithDefaults(item, stepOptions))); const prepared = prepareDirectWorktrees(expanded, stepOptions, `${runId}-s${index}`, `step-${index}`); try { const steps = prepared.tasks.map((item) => @@ -1485,6 +1552,7 @@ export async function run>( // 4. Create GraphFrontierTracker and per-run ConcurrencyLimiter const tracker = new GraphFrontierTracker(); const inputConcurrency = resolveInputConcurrency(def.inputs, resolvedInputs); + const inputRuntimeDefaults = resolveInputRuntimeDefaults(def, resolvedInputs); const limiter = createRunLimiter(inputConcurrency ?? opts.config?.defaultConcurrency); interface ReleaseBarrier { readonly promise: Promise; @@ -1817,13 +1885,16 @@ export async function run>( }; // 5. Build WorkflowRunContext + const workflowCwd = opts.cwd ?? process.cwd(); const ctx: WorkflowRunContext = { inputs: resolvedInputs as TInputs, + cwd: workflowCwd, // Prompt nodes and caller-provided UI adapters are mutually exclusive; // executor-owned prompt nodes intentionally take precedence when enabled. ui: opts.usePromptNodesForUi === true ? buildPromptNodeUiAdapter() : opts.ui ?? makeUnavailableUIContext(), stage(name: string, options?: StageOptions, stageFailFastScope?: ParallelFailFastScope) { + options = stageOptionsWithGitWorktree(stageOptionsWithInputDefaults(options, inputRuntimeDefaults), workflowCwd); // a. Generate stageId const stageId = crypto.randomUUID(); @@ -2369,16 +2440,17 @@ export async function run>( async task(name: string, options: WorkflowTaskOptions, stageFailFastScope?: ParallelFailFastScope): Promise { const runTaskOnce = async (taskOptions: WorkflowTaskOptions): Promise => { + const resolvedTaskOptions = stageOptionsWithGitWorktree(stageOptionsWithInputDefaults(taskOptions, inputRuntimeDefaults), workflowCwd) ?? taskOptions; const stage = (ctx.stage as typeof ctx.stage & ((stageName: string, stageOptions?: StageOptions, scope?: ParallelFailFastScope) => StageContext))( name, - taskStageOptions(taskOptions), + taskStageOptions(resolvedTaskOptions), stageFailFastScope, ); const rawText = await stage.prompt( - applyTaskContext(`${taskReadInstruction(taskOptions)}${taskPrompt(taskOptions)}`, taskPrevious(taskOptions)), - taskPromptOptions(taskOptions), + applyTaskContext(`${taskReadInstruction(resolvedTaskOptions)}${taskPrompt(resolvedTaskOptions)}`, taskPrevious(resolvedTaskOptions)), + taskPromptOptions(resolvedTaskOptions), ); - const text = truncateTaskOutput(rawText, taskOptions.maxOutput); + const text = truncateTaskOutput(rawText, resolvedTaskOptions.maxOutput); const sessionId = (() => { try { return stage.sessionId; @@ -2475,6 +2547,8 @@ export async function run>( return finalizeKilled(runId, runSnapshot, activeStore, opts.persistence, opts.onRunEnd); } + assertWorkflowCreatedStage(runSnapshot); + const recorded = activeStore.recordRunEnd(runId, "completed", result); opts.onRunEnd?.(runId, "completed", result); diff --git a/packages/workflows/src/runs/foreground/stage-runner.ts b/packages/workflows/src/runs/foreground/stage-runner.ts index ab33dad51b..b24a5c8c7a 100644 --- a/packages/workflows/src/runs/foreground/stage-runner.ts +++ b/packages/workflows/src/runs/foreground/stage-runner.ts @@ -144,6 +144,8 @@ function stripWorkflowOnlyOptions(options: StageOptions | undefined): CreateAgen context, forkFromSessionFile, sessionDir, + gitWorktreeDir: _gitWorktreeDir, + baseBranch: _baseBranch, ...sessionOptions } = options; if (sessionOptions.sessionManager === undefined) { diff --git a/packages/workflows/src/runs/shared/workflow-runner.ts b/packages/workflows/src/runs/shared/workflow-runner.ts index d6dbc84217..9fada52c4a 100644 --- a/packages/workflows/src/runs/shared/workflow-runner.ts +++ b/packages/workflows/src/runs/shared/workflow-runner.ts @@ -45,6 +45,8 @@ export interface WorkflowDefinition extends StageOptions { output?: string | false; outputMode?: WorkflowOutputMode; worktree?: boolean; + gitWorktreeDir?: string; + baseBranch?: string; maxOutput?: WorkflowMaxOutput; artifacts?: boolean; } @@ -91,6 +93,7 @@ function runOptionsWithAdapters( return { ...options.runOptions, + cwd: options.cwd ?? options.runOptions?.cwd, ...(config !== undefined ? { config } : {}), adapters: buildRuntimeAdapters(options.pi ?? {}, adapterOptions), store: createStore(), @@ -164,6 +167,8 @@ function directOptions(definition: WorkflowDefinition): WorkflowDirectOptions { output, outputMode, worktree, + gitWorktreeDir, + baseBranch, maxOutput, artifacts, ...stageOptions @@ -180,6 +185,8 @@ function directOptions(definition: WorkflowDefinition): WorkflowDirectOptions { ...(output !== undefined ? { output } : {}), ...(outputMode !== undefined ? { outputMode } : {}), ...(typeof worktree === "boolean" ? { worktree } : {}), + ...(typeof gitWorktreeDir === "string" ? { gitWorktreeDir } : {}), + ...(typeof baseBranch === "string" ? { baseBranch } : {}), ...(maxOutput !== undefined ? { maxOutput } : {}), ...(typeof artifacts === "boolean" ? { artifacts } : {}), }; diff --git a/packages/workflows/src/runs/shared/worktree.ts b/packages/workflows/src/runs/shared/worktree.ts index 1aead90e36..c32a89aa31 100644 --- a/packages/workflows/src/runs/shared/worktree.ts +++ b/packages/workflows/src/runs/shared/worktree.ts @@ -71,6 +71,24 @@ interface GitResult { stdout: string; stderr: string; status: number | null; + error?: Error; +} + +export interface GitWorktreeSetupOptions { + gitWorktreeDir: string; + baseBranch?: string; + cwd: string; +} + +export interface GitWorktreeSetupResult { + /** Root checkout path that was requested/created/reused. */ + worktreeRoot: string; + /** Effective workflow cwd, preserving the caller's repo-relative subdirectory inside the worktree. */ + cwd: string; + /** Invoking checkout root reported by Git. */ + repositoryRoot: string; + /** Whether this call created a new linked worktree. Existing roots are reused as-is. */ + created: boolean; } interface RepoState { @@ -80,13 +98,26 @@ interface RepoState { } const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000; +const DISABLED_GIT_HOOKS_PATH = process.platform === "win32" ? "NUL" : "/dev/null"; function runGit(cwd: string, args: string[]): GitResult { - const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" }); + const result = spawnSync("git", [ + "-c", + `core.hooksPath=${DISABLED_GIT_HOOKS_PATH}`, + "-c", + "core.fsmonitor=false", + ...args, + ], { + cwd, + encoding: "utf-8", + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + timeout: 5000, + }); return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", status: result.status, + ...(result.error === undefined ? {} : { error: result.error }), }; } @@ -100,6 +131,188 @@ function runGitChecked(cwd: string, args: string[]): string { return result.stdout; } +function gitFailureMessage(result: GitResult): string { + if (result.error !== undefined) return result.error.message; + return result.stderr.trim() || result.stdout.trim() || `git exited with status ${result.status}`; +} + +function quoteShellArg(value: string): string { + if (process.platform === "win32") return `"${value.replace(/"/g, "\"\"")}"`; + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function worktreeRecoveryCommand(repositoryRoot: string, worktreeDir: string): string { + return `git -C ${quoteShellArg(repositoryRoot)} worktree remove --force ${quoteShellArg(worktreeDir)}`; +} + +function hasGrandparent(value: string): boolean { + const parent = path.dirname(value); + if (parent === value) return false; + const grandparent = path.dirname(parent); + return grandparent !== parent; +} + +function pathAncestors(value: string): string[] { + const ancestors: string[] = []; + let current = value; + while (true) { + ancestors.push(current); + const parent = path.dirname(current); + if (parent === current) return ancestors; + current = parent; + } +} + +function shouldPreserveLogicalPath(logicalPath: string): boolean { + return pathAncestors(logicalPath).some((ancestor) => { + try { + return fs.lstatSync(ancestor).isSymbolicLink() && hasGrandparent(ancestor); + } catch { + return false; + } + }); +} + +function canonicalizePreservingSymlinks(value: string): string { + const logicalPath = path.resolve(value); + const preserveLogicalPath = shouldPreserveLogicalPath(logicalPath); + try { + const canonical = fs.realpathSync.native(logicalPath); + return preserveLogicalPath && canonical !== logicalPath ? logicalPath : canonical; + } catch { + return logicalPath; + } +} + +function resolveGitWorktreePath(value: string, repoRoot: string): string { + const trimmed = value.trim(); + if (!trimmed) throw new Error("gitWorktreeDir cannot be empty"); + if (trimmed.includes("\0")) { + throw new Error("gitWorktreeDir contains an unusable null byte; provide a valid path or omit gitWorktreeDir."); + } + return canonicalizePreservingSymlinks(path.isAbsolute(trimmed) ? trimmed : path.resolve(repoRoot, trimmed)); +} + +function comparableRealPath(value: string): string { + const realpath = fs.realpathSync.native(value).replace(/\\/g, "/"); + return process.platform === "win32" ? realpath.toLowerCase() : realpath; +} + +function gitPathFromOutput(value: string, cwd: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + return path.isAbsolute(trimmed) ? path.resolve(trimmed) : path.resolve(cwd, trimmed); +} + +function pathExistsSync(value: string): boolean { + try { + fs.statSync(value); + return true; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? (error as { code?: unknown }).code : undefined; + if (code === "ENOENT" || code === "ENOTDIR") return false; + throw error; + } +} + +function repositoryRootForGitWorktree(cwd: string): string { + const result = runGit(cwd, ["rev-parse", "--show-toplevel"]); + if (result.status !== 0) { + throw new Error(`gitWorktreeDir requires the workflow to be invoked from inside a Git repository. Start from a Git checkout or omit gitWorktreeDir. Git reported: ${gitFailureMessage(result)}`); + } + return result.stdout.trim(); +} + +function gitTopLevel(cwd: string): string | undefined { + const result = runGit(cwd, ["rev-parse", "--show-toplevel"]); + if (result.status !== 0) return undefined; + return gitPathFromOutput(result.stdout, cwd); +} + +function gitCommonDirForWorktree(cwd: string): string { + const result = runGit(cwd, ["rev-parse", "--git-common-dir"]); + if (result.status !== 0) throw new Error(gitFailureMessage(result)); + const gitPath = gitPathFromOutput(result.stdout, cwd); + if (gitPath === undefined) throw new Error("git rev-parse --git-common-dir returned an empty path"); + return gitPath; +} + +function dirnameForEachRelativeComponent(base: string, relativePath: string): string | undefined { + if (relativePath === "") return base; + let current = base; + for (const component of relativePath.split(/[\\/]+/).filter(Boolean)) { + if (component === ".") continue; + if (component === "..") return undefined; + current = path.dirname(current); + } + return current; +} + +function cwdWithinGitRepository(cwd: string, repoRoot: string): { relativeCwd: string; logicalRepoRoot: string } { + const sourceCwd = fs.realpathSync.native(cwd); + const sourceRepoRoot = fs.realpathSync.native(repoRoot); + const relativeCwd = path.relative(sourceRepoRoot, sourceCwd); + const safeRelativeCwd = relativeCwd === "" || relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd) ? "" : relativeCwd; + const logicalCwd = canonicalizePreservingSymlinks(cwd); + return { + relativeCwd: safeRelativeCwd, + logicalRepoRoot: dirnameForEachRelativeComponent(logicalCwd, safeRelativeCwd) ?? repoRoot, + }; +} + +function workspaceCwdForGitWorktreeRoot(worktreeRoot: string, relativeCwd: string): string { + return relativeCwd === "" ? worktreeRoot : path.join(worktreeRoot, relativeCwd); +} + +function validateExistingGitWorktreeRoot(worktreeRoot: string, repoRoot: string): void { + const topLevel = gitTopLevel(worktreeRoot); + if (topLevel === undefined) { + throw new Error(`gitWorktreeDir already exists but is not a Git worktree: ${worktreeRoot}`); + } + if (comparableRealPath(worktreeRoot) !== comparableRealPath(topLevel)) { + throw new Error(`gitWorktreeDir already exists but is not a Git worktree root: ${worktreeRoot}. Git top-level checkout is ${topLevel}`); + } + if (comparableRealPath(gitCommonDirForWorktree(repoRoot)) !== comparableRealPath(gitCommonDirForWorktree(topLevel))) { + throw new Error(`gitWorktreeDir already exists but does not belong to the invoking Git repository: ${worktreeRoot}`); + } +} + +export function setupGitWorktree(options: GitWorktreeSetupOptions): GitWorktreeSetupResult { + const repoRoot = repositoryRootForGitWorktree(options.cwd); + const { relativeCwd, logicalRepoRoot } = cwdWithinGitRepository(options.cwd, repoRoot); + const worktreeRoot = resolveGitWorktreePath(options.gitWorktreeDir, logicalRepoRoot); + if (pathExistsSync(worktreeRoot)) { + validateExistingGitWorktreeRoot(worktreeRoot, repoRoot); + return { + worktreeRoot, + cwd: workspaceCwdForGitWorktreeRoot(worktreeRoot, relativeCwd), + repositoryRoot: repoRoot, + created: false, + }; + } + + try { + fs.mkdirSync(path.dirname(worktreeRoot), { recursive: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to create parent directory for requested gitWorktreeDir ${worktreeRoot}: ${message}`); + } + const baseRef = options.baseBranch?.trim() || "HEAD"; + const result = runGit(repoRoot, ["worktree", "add", "--detach", worktreeRoot, baseRef]); + if (result.status !== 0) { + throw new Error([ + `Failed to create git worktree at requested gitWorktreeDir ${worktreeRoot} from ${baseRef}. Git reported: ${gitFailureMessage(result)}`, + `If another process just created this same-repository worktree, rerun the workflow to resume it. If this is an orphaned worktree from an interrupted run, recover or remove it with: ${worktreeRecoveryCommand(repoRoot, worktreeRoot)}`, + ].join("\n")); + } + return { + worktreeRoot, + cwd: workspaceCwdForGitWorktreeRoot(worktreeRoot, relativeCwd), + repositoryRoot: repoRoot, + created: true, + }; +} + function resolveRepoState(cwd: string): RepoState { const cwdRelative = resolveRepoCwdRelative(cwd); const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim(); diff --git a/packages/workflows/src/sdk-surface.ts b/packages/workflows/src/sdk-surface.ts index eee34ebd05..00db296edc 100644 --- a/packages/workflows/src/sdk-surface.ts +++ b/packages/workflows/src/sdk-surface.ts @@ -18,6 +18,8 @@ export type { RunOpts, RunResult, ResolvedInputs } from "./runs/foreground/execu export type { AgentSessionAdapter, StageAdapters } from "./runs/foreground/stage-runner.js"; export { GraphFrontierTracker } from "./runs/shared/graph-inference.js"; export type { StageNode } from "./runs/shared/graph-inference.js"; +export { setupGitWorktree } from "./runs/shared/worktree.js"; +export type { GitWorktreeSetupOptions, GitWorktreeSetupResult } from "./runs/shared/worktree.js"; export { createStore, store } from "./shared/store.js"; export type { RunStatus, StageStatus, ToolEvent, StageSnapshot, RunSnapshot, StoreSnapshot, WorkflowNotice, NoticeLevel, WorkflowOverlayAdapter, PromptKind, PendingPrompt } from "./shared/store-types.js"; diff --git a/packages/workflows/src/shared/types.ts b/packages/workflows/src/shared/types.ts index 1dc36f4bde..0a4026471b 100644 --- a/packages/workflows/src/shared/types.ts +++ b/packages/workflows/src/shared/types.ts @@ -140,13 +140,17 @@ export interface StageMcpOptions { /** * Options accepted by WorkflowRunContext.stage(name, options?). * All pi SDK createAgentSession options are forwarded to the stage session; - * `mcp` remains workflow-owned and is stripped before SDK session creation. + * workflow-owned options such as `mcp` and `gitWorktreeDir` are stripped before SDK session creation. */ export interface StageOptions extends Omit, WorkflowModelFallbackFields { /** Model id or pi SDK model object used as the primary stage model. */ model?: WorkflowModelValue; /** Per-stage MCP server gating. No-op when no WorkflowMcpPort is configured. */ mcp?: StageMcpOptions; + /** Reusable Git worktree root. Defaults this stage cwd to the corresponding worktree cwd unless cwd is explicitly provided. */ + gitWorktreeDir?: string; + /** Git ref used when creating gitWorktreeDir. Defaults to HEAD. */ + baseBranch?: string; /** * Override the session log directory for this stage. * Converted to a pi SessionManager before createAgentSession() is called. @@ -271,8 +275,12 @@ export interface WorkflowSharedTaskDefaults extends StageOptions { outputMode?: WorkflowOutputMode; /** Files the task should read before responding; relative paths resolve via chainDir for chains, otherwise cwd. */ reads?: readonly string[] | false; - /** Workflow-owned isolation flag; not forwarded to createAgentSession(). */ + /** Workflow-owned temporary isolation flag; not forwarded to createAgentSession(). */ worktree?: boolean; + /** Reusable Git worktree root. Defaults cwd to the corresponding worktree cwd unless cwd is explicitly provided. */ + gitWorktreeDir?: string; + /** Git ref used when creating gitWorktreeDir. Defaults to HEAD. */ + baseBranch?: string; /** Default output truncation limits for steps that do not set one. */ maxOutput?: WorkflowMaxOutput; /** Whether to include debug artifacts such as sessions and worktree diffs. */ @@ -400,8 +408,12 @@ export interface WorkflowTaskSessionFields { output?: string | false; outputMode?: WorkflowOutputMode; reads?: readonly string[] | false; - /** Workflow-owned isolation flag; not forwarded to createAgentSession(). */ + /** Workflow-owned temporary isolation flag; not forwarded to createAgentSession(). */ worktree?: boolean; + /** Reusable Git worktree root. Defaults cwd to the corresponding worktree cwd unless cwd is explicitly provided. */ + gitWorktreeDir?: string; + /** Git ref used when creating gitWorktreeDir. Defaults to HEAD. */ + baseBranch?: string; maxOutput?: WorkflowMaxOutput; /** Whether to include debug artifacts such as sessions and worktree diffs. */ artifacts?: boolean; @@ -421,6 +433,8 @@ export interface WorkflowParallelChainStep { readonly concurrency?: number; readonly failFast?: boolean; readonly worktree?: boolean; + readonly gitWorktreeDir?: string; + readonly baseBranch?: string; } export type WorkflowChainStep = WorkflowDirectTaskItem | WorkflowParallelChainStep; @@ -438,6 +452,8 @@ export interface WorkflowDirectOptions extends StageOptions { output?: string | false; outputMode?: WorkflowOutputMode; worktree?: boolean; + gitWorktreeDir?: string; + baseBranch?: string; maxOutput?: WorkflowMaxOutput; artifacts?: boolean; } @@ -505,6 +521,8 @@ export interface StageContext { export interface WorkflowRunContext = Record> { /** Typed inputs provided by the caller, validated against the input schema. */ readonly inputs: TInputs; + /** Invocation working directory for workflow-owned artifacts. Defaults to the host process cwd when omitted. */ + readonly cwd?: string; /** * Create and register a named stage synchronously. Stage work starts when * a stage method such as prompt() or complete() is awaited; the executor @@ -571,6 +589,15 @@ export type WorkflowRunFn = Record = Record> { /** Sentinel consumed by the registry loader to validate the export. */ readonly __piWorkflow: true; @@ -579,5 +606,7 @@ export interface WorkflowDefinition = Re readonly normalizedName: string; readonly description: string; readonly inputs: Readonly>; + /** Optional input-to-runtime defaults declared by the workflow builder. */ + readonly inputBindings?: WorkflowInputBindings; readonly run: WorkflowRunFn; } diff --git a/packages/workflows/src/workflows/define-workflow.ts b/packages/workflows/src/workflows/define-workflow.ts index c8b5699ba2..dcdc680f8f 100644 --- a/packages/workflows/src/workflows/define-workflow.ts +++ b/packages/workflows/src/workflows/define-workflow.ts @@ -8,7 +8,7 @@ * cross-ref: v0.x packages/atomic-sdk/src/define-workflow.ts */ -import type { WorkflowDefinition, WorkflowInputSchema, WorkflowRunFn } from "../shared/types.js"; +import type { WorkflowDefinition, WorkflowInputBindings, WorkflowInputSchema, WorkflowRunFn, WorkflowWorktreeInputBinding } from "../shared/types.js"; import { normalizeWorkflowName } from "./identity.js"; // --------------------------------------------------------------------------- @@ -19,6 +19,7 @@ interface BuilderState> { readonly name: string; readonly description: string; readonly inputs: Readonly>; + readonly inputBindings: WorkflowInputBindings; readonly runFn: WorkflowRunFn | undefined; } @@ -45,6 +46,8 @@ export interface WorkflowBuilder = Recor key: K, schema: WorkflowInputSchema, ): WorkflowBuilder>; + /** Bind workflow inputs to reusable git worktree runtime defaults. */ + worktreeFromInputs(binding: WorkflowWorktreeInputBinding): WorkflowBuilder; /** Seal the run function. Returns a builder on which .compile() is available. */ run(fn: WorkflowRunFn): CompletedWorkflowBuilder; } @@ -59,6 +62,7 @@ export interface CompletedWorkflowBuilder>; + worktreeFromInputs(binding: WorkflowWorktreeInputBinding): CompletedWorkflowBuilder; run(fn: WorkflowRunFn): CompletedWorkflowBuilder; /** Freeze and return the completed WorkflowDefinition. */ compile(): WorkflowDefinition; @@ -83,6 +87,16 @@ function makeBuilder>( } as BuilderState>); }, + worktreeFromInputs(binding: WorkflowWorktreeInputBinding) { + return makeBuilder({ + ...state, + inputBindings: { + ...state.inputBindings, + worktree: { ...binding }, + }, + }); + }, + run(fn: WorkflowRunFn) { return makeBuilder({ ...state, runFn: fn }); }, @@ -98,6 +112,7 @@ function makeBuilder>( // Deep-freeze inputs map first, then the top-level definition. const frozenInputs = Object.freeze({ ...state.inputs }); + const inputBindings = Object.freeze({ ...state.inputBindings }); const definition: WorkflowDefinition = { __piWorkflow: true, @@ -105,6 +120,7 @@ function makeBuilder>( normalizedName, description: state.description, inputs: frozenInputs, + ...(Object.keys(inputBindings).length > 0 ? { inputBindings } : {}), run: state.runFn, }; @@ -143,6 +159,7 @@ export function defineWorkflow(name: string): WorkflowBuilder { name, description: "", inputs: {}, + inputBindings: {}, runFn: undefined, }; diff --git a/research/docs/2026-05-12-workflow-authoring-registry-core.md b/research/docs/2026-05-12-workflow-authoring-registry-core.md index 88d8fecc34..72c8640067 100644 --- a/research/docs/2026-05-12-workflow-authoring-registry-core.md +++ b/research/docs/2026-05-12-workflow-authoring-registry-core.md @@ -21,7 +21,7 @@ Scout context included adjacent areas: tests (`test/unit`, `test/integration`, ` ## Summary -The workflow authoring core is a small TypeScript DSL centered on `defineWorkflow(name).description(...).input(...).run(fn).compile()`. A compiled workflow is a frozen `WorkflowDefinition` object with a `__piWorkflow: true` sentinel, authored name, normalized registry key, input schema map, and async run function. Registry state is immutable-style: operations return a new registry backed by an ordered `Map` keyed by normalized workflow name. +The workflow authoring core is a small TypeScript DSL centered on `defineWorkflow(name).description(...).input(...).run(fn).compile()`. A compiled workflow is a frozen `WorkflowDefinition` object with a `__piWorkflow: true` sentinel, authored name, normalized registry key, input schema map, optional input bindings such as `.worktreeFromInputs(...)`, and async run function. Registry state is immutable-style: operations return a new registry backed by an ordered `Map` keyed by normalized workflow name. Builtin workflows are authored using the same public DSL under `workflows/` and re-exported from `workflows/index.ts`. The extension discovery layer imports that manifest for bundled workflows and also discovers project/user/settings workflow files, validates exported workflow definitions structurally, applies source precedence, and returns a populated `WorkflowRegistry` plus source records and diagnostics. Runtime dispatch uses the registry for `list`, `inputs`, and `run`, then forwards execution to foreground or background runners. @@ -34,17 +34,18 @@ Builtin workflows are authored using the same public DSL under `workflows/` and - The builder is typed in two phases: - `WorkflowBuilder` exposes `description()`, `input()`, and `run()` before compilation (`src/workflows/define-workflow.ts:37-50`). - `CompletedWorkflowBuilder` exposes `compile()` only after `run()` has been called at the type level (`src/workflows/define-workflow.ts:56-64`). -- Builder methods are immutable/chained. `description()`, `input()`, and `run()` call `makeBuilder()` with copied state rather than mutating the previous builder (`src/workflows/define-workflow.ts:71-88`). +- Builder methods are immutable/chained. `description()`, `input()`, `worktreeFromInputs()`, and `run()` call `makeBuilder()` with copied state rather than mutating the previous builder (`src/workflows/define-workflow.ts:71-88`). - Runtime `compile()` still guards against missing `.run(fn)` and throws `defineWorkflow("..."): .run(fn) must be called before .compile()` (`src/workflows/define-workflow.ts:90-95`). - `compile()` computes `normalizedName`, freezes a shallow copy of the inputs map, constructs the sentinel-bearing definition, and freezes the top-level definition (`src/workflows/define-workflow.ts:97-111`). ### 2. Workflow definition and context shape -- `WorkflowDefinition` is defined in shared types and consists of `__piWorkflow: true`, `name`, `normalizedName`, `description`, readonly `inputs`, and async `run` (`src/shared/types.ts:291-300`). +- `WorkflowDefinition` is defined in shared types and consists of `__piWorkflow: true`, `name`, `normalizedName`, `description`, readonly `inputs`, optional `inputBindings`, and async `run` (`src/shared/types.ts:291-300`). - Input schemas support `text`/`string`, `number`, `boolean`, and `select`, with optional `description`, `required`, and type-specific defaults. `select` includes a readonly `choices` array (`src/shared/types.ts:21-56`). - Workflow run functions receive a `WorkflowRunContext` with readonly `inputs`, a `stage(name, options?)` factory, and HIL `ui` primitives (`src/shared/types.ts:231-245`). - `StageContext` intentionally mirrors much of pi's `AgentSession` surface while wrapping `prompt()` and `subscribe()` and retaining deprecated `subagent()` and `complete()` helpers (`src/shared/types.ts:177-225`). -- `StageOptions` extends pi `CreateAgentSessionOptions` and adds optional per-stage MCP gating (`src/shared/types.ts:89-109`). +- `StageOptions` extends pi `CreateAgentSessionOptions` and adds optional per-stage MCP gating plus reusable Git worktree defaults (`gitWorktreeDir`, `baseBranch`) (`src/shared/types.ts:89-109`). +- `.worktreeFromInputs({ gitWorktreeDir, baseBranch })` binds resolved workflow input values to workflow-wide worktree defaults. When the bound worktree input is non-empty, `ctx.stage`, `ctx.task`, `ctx.chain`, and `ctx.parallel` default their cwd to the corresponding reusable Git worktree cwd unless explicitly overridden. ### 3. Identity normalization diff --git a/research/docs/2026-05-14-local-workflow-patterns.md b/research/docs/2026-05-14-local-workflow-patterns.md index 5cc230084b..d75ced54aa 100644 --- a/research/docs/2026-05-14-local-workflow-patterns.md +++ b/research/docs/2026-05-14-local-workflow-patterns.md @@ -155,12 +155,13 @@ const definition: WorkflowDefinition = { normalizedName, description: state.description, inputs: frozenInputs, + inputBindings, run: state.runFn, }; ``` **Key aspects**: -- Definitions carry a `__piWorkflow: true` sentinel, authored name, normalized registry key, description, input schema map, and run function. +- Definitions carry a `__piWorkflow: true` sentinel, authored name, normalized registry key, description, input schema map, optional input bindings, and run function. - Registry operations include `register`, `upsert`, `merge`, `get`, `has`, `remove`, `names`, and `all`. - Discovery supports bundled, project-local, user-global, settings-project, and settings-global sources. - Discovery validates exported workflow definitions before registering them. @@ -218,6 +219,8 @@ const specialistResults = await Promise.all( - `chain` passes prior task output via `{previous}` / context handoff semantics. - `parallel` uses `Promise.all` over task/stage operations. - Built-in workflows show sequential pipelines and parallel fan-out/fan-in. +- Reusable Git worktree defaults can be declared once with `defineWorkflow(...).worktreeFromInputs({ gitWorktreeDir, baseBranch })`; the executor applies the resolved inputs as workflow-wide defaults for `ctx.stage`, `ctx.task`, `ctx.chain`, and `ctx.parallel`. +- Per-stage/task `gitWorktreeDir` and `baseBranch` can still be supplied directly. `gitWorktreeDir` creates/reuses a named worktree, preserves the invoking repo-relative cwd inside it, and differs from temporary `worktree: true` isolation. ### Pattern 7: Subagent tool-call adapter shape **Found in**: `src/shared/types.ts:91-106`, `src/extension/wiring.ts:213-239`, `test/unit/executor-subagent-call-shape.test.ts:1-124` diff --git a/test/integration/ralph-worktree.test.ts b/test/integration/ralph-worktree.test.ts new file mode 100644 index 0000000000..0f2c05b41b --- /dev/null +++ b/test/integration/ralph-worktree.test.ts @@ -0,0 +1,569 @@ +import { afterEach, beforeEach, describe, test } from "bun:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { setupGitWorktree } from "../../packages/workflows/src/index.js"; +import type { + WorkflowChainOptions, + WorkflowParallelOptions, + WorkflowRunContext, + WorkflowTaskOptions, + WorkflowTaskResult, + WorkflowTaskStep, + WorkflowUIContext, +} from "../../packages/workflows/src/shared/types.js"; + +type RalphTestModule = { + default: { + run(ctx: WorkflowRunContext>): Promise>; + }; +}; + +interface MockCalls { + readonly task: string[]; + readonly parallelOptions: WorkflowParallelOptions[]; + readonly taskOptions: Record; +} + +interface MockResponders { + task?: (name: string, options: WorkflowTaskOptions, calls: MockCalls) => string | undefined; + parallel?: ( + steps: readonly WorkflowTaskStep[], + options: WorkflowParallelOptions, + calls: MockCalls, + ) => Promise | WorkflowTaskResult[] | undefined; +} + +function promptText(options: WorkflowTaskOptions): string { + return options.prompt ?? options.task ?? ""; +} + +function makeTaskResult(name: string, text: string): WorkflowTaskResult { + return { name, stageName: name, text }; +} + +function ralphWorktreeDefaults( + inputs: Record, + cwd: string | undefined, +): { cwd?: string } { + if (typeof cwd !== "string") return {}; + const gitWorktreeDir = inputs["git_worktree_dir"]; + if (typeof gitWorktreeDir !== "string" || gitWorktreeDir.trim().length === 0) return {}; + const baseBranch = typeof inputs["base_branch"] === "string" ? inputs["base_branch"] : undefined; + try { + return { cwd: setupGitWorktree({ gitWorktreeDir, baseBranch, cwd }).cwd }; + } catch (error) { + if (error instanceof Error) { + throw new Error(error.message + .replaceAll("gitWorktreeDir", "git_worktree_dir") + .replace("requires the workflow to be invoked", "requires Ralph to be invoked")); + } + throw error; + } +} + +function withRalphWorktreeDefaults( + inputs: Record, + workflowCwd: string | undefined, + options: TOptions, +): TOptions { + if (options.cwd !== undefined) return options; + return { ...options, ...ralphWorktreeDefaults(inputs, workflowCwd) }; +} + +function makeMockCtx>( + inputs: TInputs, + responders: MockResponders = {}, +): WorkflowRunContext & { calls: MockCalls } { + const calls: MockCalls = { + task: [], + parallelOptions: [], + taskOptions: {}, + }; + + const ui: WorkflowUIContext = { + input: async (prompt: string) => `mock-input:${prompt.slice(0, 20)}`, + confirm: async () => false, + select: async (_message: string, options: readonly T[]) => options[0]!, + editor: async (initial?: string) => initial ?? "mock-editor-content", + }; + + const ctx = { + inputs, + calls, + stage: (name: string) => { + throw new Error(`ctx.stage should not be used by builtin workflow ${name}`); + }, + async task(this: WorkflowRunContext, name: string, options: WorkflowTaskOptions): Promise { + const taskOptions = withRalphWorktreeDefaults(inputs, this.cwd, options); + calls.task.push(name); + calls.taskOptions[name] = [...(calls.taskOptions[name] ?? []), taskOptions]; + const text = promptText(taskOptions); + const override = responders.task?.(name, taskOptions, calls); + return makeTaskResult(name, override ?? `[mock-task:${name}] ${text.slice(0, 80)}`); + }, + async chain(this: WorkflowRunContext, steps: readonly WorkflowTaskStep[], _options?: WorkflowChainOptions): Promise { + const results: WorkflowTaskResult[] = []; + for (const step of steps) { + results.push(await this.task(step.name, step)); + } + return results; + }, + async parallel(this: WorkflowRunContext, steps: readonly WorkflowTaskStep[], options: WorkflowParallelOptions = {}): Promise { + const parallelOptions = withRalphWorktreeDefaults(inputs, this.cwd, options); + calls.parallelOptions.push(parallelOptions); + const preparedSteps = steps.map((step) => withRalphWorktreeDefaults(inputs, this.cwd, step)); + const override = await responders.parallel?.(preparedSteps, parallelOptions, calls); + if (override !== undefined) return override; + return Promise.all(preparedSteps.map((step) => this.task(step.name, step))); + }, + ui, + }; + return ctx; +} + +describe("ralph git worktree integration", () => { + let tempRoot: string | undefined; + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "atomic-ralph-integration-")); + }); + + afterEach(() => { + if (tempRoot !== undefined) { + rmSync(tempRoot, { recursive: true, force: true }); + tempRoot = undefined; + } + }); + + function requireTempRoot(): string { + if (tempRoot === undefined) throw new Error("expected Ralph integration temp root"); + return tempRoot; + } + + function assertRalphResultShape(result: Record, artifactRoot: string): void { + assert.equal(typeof result["result"], "string"); + assert.equal(typeof result["plan"], "string"); + assert.equal(typeof result["plan_path"], "string"); + assert.ok(String(result["plan_path"]).startsWith(join(artifactRoot, "specs"))); + assert.equal(typeof result["implementation_notes_path"], "string"); + assert.equal(typeof result["pr_report"], "string"); + assert.equal(typeof result["approved"], "boolean"); + assert.equal(typeof result["iterations_completed"], "number"); + assert.equal(typeof result["review_report"], "string"); + } + + function initializeGitRepository(name = "repo"): string { + const repo = join(requireTempRoot(), name); + mkdirSync(repo, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repo, stdio: "ignore" }); + writeFileSync(join(repo, "README.md"), "# test repo\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { cwd: repo, stdio: "ignore" }); + return repo; + } + + function assertSamePath(actual: string | undefined, expected: string | undefined, message: string): void { + if (actual === undefined || expected === undefined) { + assert.equal(actual, expected, message); + return; + } + assert.equal(canonicalPathForComparison(actual), canonicalPathForComparison(expected), message); + } + + function assertEveryRalphStageCwd( + ctx: { readonly calls: MockCalls }, + expectedCwd: string | undefined, + ): void { + for (const [taskName, entries] of Object.entries(ctx.calls.taskOptions)) { + for (const options of entries) { + assertSamePath(options.cwd, expectedCwd, `unexpected cwd for ${taskName}`); + } + } + for (const options of ctx.calls.parallelOptions) { + assertSamePath(options.cwd, expectedCwd, "unexpected cwd for parallel stage"); + } + } + + function canonicalPathForComparison(path: string): string { + let canonical = path; + try { + canonical = realpathSync.native(path); + } catch { + try { + canonical = join(realpathSync.native(dirname(path)), basename(path)); + } catch { + canonical = path; + } + } + const normalized = canonical.replace(/\\/g, "/"); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; + } + + function assertWorktreeRegistered(_repo: string, worktreePath: string): void { + assert.equal(existsSync(join(worktreePath, ".git")), true, "expected git worktree checkout"); + assert.equal( + execFileSync("git", ["-C", worktreePath, "rev-parse", "--is-inside-work-tree"]).toString().trim(), + "true", + "expected git to recognize the worktree checkout", + ); + } + + function addDetachedWorktree(repo: string, worktreePath: string): void { + execFileSync("git", ["worktree", "add", "--detach", worktreePath, "main"], { cwd: repo, stdio: "ignore" }); + } + + test("creates a relative git_worktree_dir from repo root and preserves the relative cwd", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const subdir = join(repo, "nested"); + mkdirSync(subdir, { recursive: true }); + writeFileSync(join(subdir, "fixture.txt"), "nested fixture\n", "utf8"); + execFileSync("git", ["add", "nested/fixture.txt"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested fixture"], { cwd: repo, stdio: "ignore" }); + const expectedWorktreeRoot = join(repo, "worktrees", "ralph"); + const expectedCwd = join(expectedWorktreeRoot, "nested"); + const ctx = makeMockCtx( + { + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: join("worktrees", "ralph"), + }, + { + task: (name, options) => { + if (name === "planner-1") { + assertSamePath(options.cwd, expectedCwd, "expected planner to run from corresponding worktree subdirectory"); + assertWorktreeRegistered(repo, expectedWorktreeRoot); + assert.equal( + execFileSync("git", ["-C", expectedWorktreeRoot, "rev-parse", "HEAD"]).toString().trim(), + execFileSync("git", ["-C", repo, "rev-parse", "main"]).toString().trim(), + ); + } + return undefined; + }, + }, + ); + + const result = await mod.default.run({ ...ctx, cwd: subdir }); + + assertRalphResultShape(result, subdir); + const planPath = String(result["plan_path"]); + assert.equal(planPath.startsWith(expectedWorktreeRoot), false); + const orchestratorReads = ctx.calls.taskOptions["orchestrator-1"]?.[0]?.reads; + assert.ok(Array.isArray(orchestratorReads) && orchestratorReads.includes(planPath)); + assertEveryRalphStageCwd(ctx, expectedCwd); + assertWorktreeRegistered(repo, expectedWorktreeRoot); + }); + + test("fails fast outside a git repo when git_worktree_dir is requested", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const requestedWorktree = join(requireTempRoot(), "outside-repo-worktree"); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: requestedWorktree, + }); + + await assert.rejects( + () => mod.default.run({ ...ctx, cwd: requireTempRoot() }), + /git_worktree_dir requires Ralph to be invoked from inside a Git repository/, + ); + assert.deepEqual(ctx.calls.task, []); + assert.equal(existsSync(requestedWorktree), false); + }); + + test("creates an absolute git_worktree_dir and leaves it after success", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const expectedWorktree = join(requireTempRoot(), "absolute-worktrees", "ralph"); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: expectedWorktree, + }); + + await mod.default.run({ ...ctx, cwd: repo }); + + assertEveryRalphStageCwd(ctx, expectedWorktree); + assertWorktreeRegistered(repo, expectedWorktree); + }); + + test("reuses an existing git worktree in its current state", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const expectedWorktree = join(requireTempRoot(), "existing-worktree"); + addDetachedWorktree(repo, expectedWorktree); + const uncommittedPath = join(expectedWorktree, "uncommitted.txt"); + writeFileSync(uncommittedPath, "keep me\n", "utf8"); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "missing-branch", + git_worktree_dir: expectedWorktree, + }); + + await mod.default.run({ ...ctx, cwd: repo }); + + assertEveryRalphStageCwd(ctx, expectedWorktree); + assert.equal(existsSync(uncommittedPath), true); + assertWorktreeRegistered(repo, expectedWorktree); + }); + + test("fails fast when existing git_worktree_dir is a subdirectory of the invoking repository", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const requestedWorktree = join(repo, "src"); + mkdirSync(requestedWorktree, { recursive: true }); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: "src", + }); + + await assert.rejects( + () => mod.default.run({ ...ctx, cwd: repo }), + /git_worktree_dir already exists but is not a Git worktree root/, + ); + assert.deepEqual(ctx.calls.task, []); + }); + + test("fails fast when existing git_worktree_dir is a subdirectory of a same-repository worktree", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const existingWorktree = join(requireTempRoot(), "existing-worktree-with-subdir"); + addDetachedWorktree(repo, existingWorktree); + const requestedWorktree = join(existingWorktree, "nested"); + mkdirSync(requestedWorktree, { recursive: true }); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: requestedWorktree, + }); + + await assert.rejects( + () => mod.default.run({ ...ctx, cwd: repo }), + /git_worktree_dir already exists but is not a Git worktree root/, + ); + assert.deepEqual(ctx.calls.task, []); + }); + + test("fails fast when existing git_worktree_dir is a worktree from another repository", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository("repo"); + const otherRepo = initializeGitRepository("other-repo"); + const foreignWorktree = join(requireTempRoot(), "foreign-worktree"); + addDetachedWorktree(otherRepo, foreignWorktree); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: foreignWorktree, + }); + + await assert.rejects( + () => mod.default.run({ ...ctx, cwd: repo }), + /git_worktree_dir already exists but does not belong to the invoking Git repository/, + ); + assert.deepEqual(ctx.calls.task, []); + }); + + test("fails fast when existing git_worktree_dir is another repository checkout", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository("repo"); + const otherRepo = initializeGitRepository("other-repo"); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: otherRepo, + }); + + await assert.rejects( + () => mod.default.run({ ...ctx, cwd: repo }), + /git_worktree_dir already exists but does not belong to the invoking Git repository/, + ); + assert.deepEqual(ctx.calls.task, []); + }); + + test("can re-run with the same git_worktree_dir without cleanup", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const expectedWorktree = join(requireTempRoot(), "repeat-worktree"); + + const firstCtx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: expectedWorktree, + }); + const secondCtx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: expectedWorktree, + }); + + await mod.default.run({ ...firstCtx, cwd: repo }); + assertWorktreeRegistered(repo, expectedWorktree); + await mod.default.run({ ...secondCtx, cwd: repo }); + + assertEveryRalphStageCwd(firstCtx, expectedWorktree); + assertEveryRalphStageCwd(secondCtx, expectedWorktree); + assertWorktreeRegistered(repo, expectedWorktree); + }); + + test("fails fast when base_branch does not exist for a missing worktree path", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const requestedWorktree = join(requireTempRoot(), "missing-base-worktree"); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "missing-branch", + git_worktree_dir: requestedWorktree, + }); + + await assert.rejects( + () => mod.default.run({ ...ctx, cwd: repo }), + /Failed to create git worktree at requested git_worktree_dir/, + ); + assert.deepEqual(ctx.calls.task, []); + }); + + test("reports filesystem failures before invoking git worktree add", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const fileWhereParentDirectoryShouldBe = join(requireTempRoot(), "not-a-directory"); + const requestedWorktree = join(fileWhereParentDirectoryShouldBe, "worktree"); + writeFileSync(fileWhereParentDirectoryShouldBe, "blocks mkdir\n", "utf8"); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: requestedWorktree, + }); + + await assert.rejects( + () => mod.default.run({ ...ctx, cwd: repo }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /Failed to create parent directory for requested git_worktree_dir/); + assert.doesNotMatch(error.message, /Git reported/); + return true; + }, + ); + assert.deepEqual(ctx.calls.task, []); + }); + + test("fails fast when requested git_worktree_dir is a non-empty non-git directory", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const requestedWorktree = join(requireTempRoot(), "non-empty-directory"); + mkdirSync(requestedWorktree, { recursive: true }); + writeFileSync(join(requestedWorktree, "README.md"), "already here\n", "utf8"); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: requestedWorktree, + }); + + await assert.rejects( + () => mod.default.run({ ...ctx, cwd: repo }), + /git_worktree_dir already exists but is not a Git worktree/, + ); + assert.deepEqual(ctx.calls.task, []); + }); + + test("fails fast when git_worktree_dir is unusable", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: "invalid\0path", + }); + + await assert.rejects( + () => mod.default.run({ ...ctx, cwd: repo }), + /git_worktree_dir contains an unusable null byte/, + ); + assert.deepEqual(ctx.calls.task, []); + }); + + test("propagates worktree cwd across multiple iterations", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const expectedWorktree = join(requireTempRoot(), "multi-iteration-worktree"); + const ctx = makeMockCtx( + { + prompt: "Add a small feature", + max_loops: 2, + base_branch: "main", + git_worktree_dir: expectedWorktree, + }, + { + parallel: (steps) => { + if (steps.some((step) => step.name.startsWith("reviewer-"))) { + return steps.map((step) => makeTaskResult(step.name, JSON.stringify({ + findings: [{ + title: "[P2] Continue first pass", + body: "Force a second iteration for cwd coverage.", + confidence_score: 0.9, + code_location: { absolute_file_path: join(repo, "README.md"), line_range: { start: 1, end: 1 } }, + }], + overall_correctness: "patch is incorrect", + overall_explanation: "continue", + overall_confidence_score: 0.9, + stop_review_loop: false, + reviewer_error: null, + }))); + } + return undefined; + }, + }, + ); + + await mod.default.run({ ...ctx, cwd: repo }); + + for (const name of ["planner-1", "orchestrator-1", "code-simplifier-1", "planner-2", "orchestrator-2", "code-simplifier-2"]) { + assertSamePath(ctx.calls.taskOptions[name]?.[0]?.cwd, expectedWorktree, `unexpected cwd for ${name}`); + } + assertEveryRalphStageCwd(ctx, expectedWorktree); + assertWorktreeRegistered(repo, expectedWorktree); + }); + + test("leaves the worktree for recovery when the workflow fails", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js") as unknown as RalphTestModule; + const repo = initializeGitRepository(); + const expectedWorktree = join(requireTempRoot(), "failed-run-worktree"); + const ctx = makeMockCtx( + { + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: expectedWorktree, + }, + { + task: (name) => { + if (name === "planner-1") throw new Error("planner failed"); + return undefined; + }, + }, + ); + + await assert.rejects(() => mod.default.run({ ...ctx, cwd: repo }), /planner failed/); + + assertWorktreeRegistered(repo, expectedWorktree); + }); +}); diff --git a/test/integration/runtime-tunables.test.ts b/test/integration/runtime-tunables.test.ts index 80cd8b6995..26c826adef 100644 --- a/test/integration/runtime-tunables.test.ts +++ b/test/integration/runtime-tunables.test.ts @@ -94,10 +94,14 @@ describe("runtime tunables — maxDepth", () => { test("depth < maxDepth executes normally", async () => { const wf = defineWorkflow("rt-below-max-depth") - .run(async () => ({ ran: true })) + .run(async (ctx) => { + await ctx.task("depth-check", { prompt: "depth check" }); + return { ran: true }; + }) .compile(); const result = await run(wf, {}, { + adapters: { prompt: { prompt: async () => "ok" } }, store: createStore(), config: baseConfig({ maxDepth: 4 }), depth: 3, diff --git a/test/unit/builtin-workflows.test.ts b/test/unit/builtin-workflows.test.ts index e49ba9f7fe..64b06d9542 100644 --- a/test/unit/builtin-workflows.test.ts +++ b/test/unit/builtin-workflows.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, isAbsolute, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import type { WorkflowChainOptions, WorkflowDefinition, @@ -174,22 +174,23 @@ function assertWorkflowDefinition(def: unknown): asserts def is WorkflowDefiniti // --------------------------------------------------------------------------- describe("deep-research-codebase", () => { - let previousCwd = process.cwd(); let tempCwd: string | undefined; beforeEach(() => { - previousCwd = process.cwd(); tempCwd = mkdtempSync(join(tmpdir(), "atomic-deep-research-test-")); - process.chdir(tempCwd); }); afterEach(() => { - process.chdir(previousCwd); if (tempCwd !== undefined) { rmSync(tempCwd, { recursive: true, force: true }); tempCwd = undefined; } }); + + function requireDeepResearchTempCwd(): string { + if (tempCwd === undefined) throw new Error("expected deep research temp cwd"); + return tempCwd; + } test("loads and has correct shape", async () => { const mod = await import("../../packages/workflows/builtin/deep-research-codebase.js"); const def = mod.default as unknown as WorkflowDefinition; @@ -227,7 +228,7 @@ describe("deep-research-codebase", () => { }, ); - const result = await d.run(ctx); + const result = await mod.runDeepResearchCodebaseWorkflow(ctx, requireDeepResearchTempCwd()); assert.deepEqual(ctx.calls.stage, []); assert.ok(ctx.calls.parallel.some((names) => names.includes("codebase-scout") && names.includes("history-locator"))); @@ -262,7 +263,7 @@ describe("deep-research-codebase", () => { }, ); - const result = await d.run(ctx); + const result = await mod.runDeepResearchCodebaseWorkflow(ctx, requireDeepResearchTempCwd()); const aggregatorOptions = ctx.calls.taskOptions["aggregator"]?.[0]; const aggregatorPrompt = ctx.calls.prompts["aggregator"]?.[0] ?? ""; const normalizedAggregatorPrompt = normalizePathSeparators(aggregatorPrompt); @@ -323,7 +324,7 @@ describe("deep-research-codebase", () => { }, ); - const result = await d.run(ctx); + const result = await mod.runDeepResearchCodebaseWorkflow(ctx, requireDeepResearchTempCwd()); const aggregatorPrompt = ctx.calls.prompts["aggregator"]?.[0] ?? ""; assert.doesNotMatch(aggregatorPrompt, /Output saved to:/); @@ -345,7 +346,7 @@ describe("deep-research-codebase", () => { }, ); - await d.run(ctx); + await mod.runDeepResearchCodebaseWorkflow(ctx, requireDeepResearchTempCwd()); const analyzerOptions = ctx.calls.taskOptions["analyzer-1"]?.[0]; const onlineOptions = ctx.calls.taskOptions["online-researcher-1"]?.[0]; @@ -386,20 +387,21 @@ describe("deep-research-codebase", () => { }, ); - const result = await d.run(ctx); + const result = await mod.runDeepResearchCodebaseWorkflow(ctx, requireDeepResearchTempCwd()); assert.equal(result["findings"], "final synthesized findings"); assert.equal(result["research_doc_path"], normalizePathSeparators(join("research", `${new Date().toISOString().slice(0, 10)}-trace-auth-behavior.md`))); - assert.equal(readFileSync(result["research_doc_path"] as string, "utf8"), "final synthesized findings"); - assert.equal(existsSync("context-build"), false); + assert.equal(readFileSync(join(requireDeepResearchTempCwd(), result["research_doc_path"] as string), "utf8"), "final synthesized findings"); + assert.equal(existsSync(join(requireDeepResearchTempCwd(), "context-build")), false); const artifactDirValue = result["artifact_dir"]; if (typeof artifactDirValue !== "string") { throw new Error("expected artifact_dir to be a string"); } const artifactDir = artifactDirValue; + const artifactDirFsPath = join(requireDeepResearchTempCwd(), artifactDir); assert.match(normalizePathSeparators(artifactDir), /^research\/\.deep-research-/); - assert.equal(existsSync(artifactDir), true); + assert.equal(existsSync(artifactDirFsPath), true); for (const filename of [ "00-codebase-scout.md", @@ -413,14 +415,14 @@ describe("deep-research-codebase", () => { "explorer-1.md", "manifest.json", ]) { - assert.equal(existsSync(join(artifactDir, filename)), true, `expected ${filename}`); + assert.equal(existsSync(join(artifactDirFsPath, filename)), true, `expected ${filename}`); } for (const path of aggregatorReadPaths) { assert.equal(existsSync(path), true, `expected handoff artifact to persist: ${path}`); assert.equal(/(^|\/)context-build\//.test(normalizePathSeparators(path)), false); } - const manifest = JSON.parse(readFileSync(join(artifactDir, "manifest.json"), "utf8")) as { + const manifest = JSON.parse(readFileSync(join(artifactDirFsPath, "manifest.json"), "utf8")) as { runId?: string; startedAt?: string; completedAt?: string; @@ -428,7 +430,7 @@ describe("deep-research-codebase", () => { finalAsset?: string; artifacts?: Record; }; - assert.equal(manifest.runId, artifactDir.replace(/^research\/\.deep-research-/, "")); + assert.equal(manifest.runId, basename(artifactDir).replace(/^\.deep-research-/, "")); assert.equal(typeof manifest.startedAt, "string"); assert.equal(typeof manifest.completedAt, "string"); assert.equal(manifest.researchQuestion, "Trace auth behavior"); @@ -451,7 +453,7 @@ describe("deep-research-codebase", () => { const mod = await import("../../packages/workflows/builtin/deep-research-codebase.js"); const d = mod.default as unknown as WorkflowDefinition; const date = new Date().toISOString().slice(0, 10); - const existingPath = join("research", `${date}-trace-auth-behavior.md`); + const existingPath = join(requireDeepResearchTempCwd(), "research", `${date}-trace-auth-behavior.md`); mkdirSync(dirname(existingPath), { recursive: true }); writeFileSync(existingPath, "existing research", "utf8"); const ctx = makeMockCtx( @@ -465,13 +467,13 @@ describe("deep-research-codebase", () => { }, ); - const result = await d.run(ctx); + const result = await mod.runDeepResearchCodebaseWorkflow(ctx, requireDeepResearchTempCwd()); const researchDocPath = result["research_doc_path"]; assert.equal(readFileSync(existingPath, "utf8"), "existing research"); assert.ok(typeof researchDocPath === "string"); assert.ok(normalizePathSeparators(researchDocPath).endsWith(`${date}-trace-auth-behavior-2.md`)); - assert.equal(readFileSync(researchDocPath, "utf8"), "final synthesized findings"); + assert.equal(readFileSync(join(requireDeepResearchTempCwd(), researchDocPath), "utf8"), "final synthesized findings"); }); test("does not create a top-level context-build directory", async () => { @@ -488,10 +490,10 @@ describe("deep-research-codebase", () => { }, ); - await d.run(ctx); + await mod.runDeepResearchCodebaseWorkflow(ctx, requireDeepResearchTempCwd()); - assert.equal(existsSync("context-build"), false); - assert.deepEqual(readdirSync("research").filter((entry) => entry === "context-build"), []); + assert.equal(existsSync(join(requireDeepResearchTempCwd(), "context-build")), false); + assert.deepEqual(readdirSync(join(requireDeepResearchTempCwd(), "research")).filter((entry) => entry === "context-build"), []); }); }); @@ -500,22 +502,6 @@ describe("deep-research-codebase", () => { // --------------------------------------------------------------------------- describe("goal", () => { - let previousCwd = process.cwd(); - let tempCwd: string | undefined; - - beforeEach(() => { - previousCwd = process.cwd(); - tempCwd = mkdtempSync(join(tmpdir(), "atomic-goal-test-")); - process.chdir(tempCwd); - }); - - afterEach(() => { - process.chdir(previousCwd); - if (tempCwd !== undefined) { - rmSync(tempCwd, { recursive: true, force: true }); - tempCwd = undefined; - } - }); type ReviewJsonFinding = { readonly title: string; @@ -1267,13 +1253,45 @@ describe("goal", () => { // --------------------------------------------------------------------------- describe("ralph", () => { + let tempCwd: string | undefined; + + beforeEach(() => { + tempCwd = mkdtempSync(join(tmpdir(), "atomic-ralph-unit-")); + }); + + afterEach(() => { + if (tempCwd !== undefined) { + rmSync(tempCwd, { recursive: true, force: true }); + tempCwd = undefined; + } + }); + + function requireRalphTempCwd(): string { + if (tempCwd === undefined) throw new Error("expected Ralph temp cwd"); + return tempCwd; + } + + function assertEveryRalphStageCwd( + ctx: { readonly calls: MockCalls }, + expectedCwd: string | undefined, + ): void { + for (const [taskName, entries] of Object.entries(ctx.calls.taskOptions)) { + for (const options of entries) { + assert.equal(options.cwd, expectedCwd, `unexpected cwd for ${taskName}`); + } + } + for (const options of ctx.calls.parallelOptions) { + assert.equal(options.cwd, expectedCwd, "unexpected cwd for parallel stage"); + } + } + test("loads and has Ralph workflow shape", async () => { const mod = await import("../../packages/workflows/builtin/ralph.js"); assertWorkflowDefinition(mod.default); assert.equal(mod.default.name, "ralph"); }); - test("declares prompt, max_loops, and base_branch inputs", async () => { + test("declares prompt, max_loops, base_branch, and git_worktree_dir inputs", async () => { const mod = await import("../../packages/workflows/builtin/ralph.js"); assert.equal(mod.default.inputs["prompt"]?.type, "text"); assert.equal(mod.default.inputs["prompt"]?.required, true); @@ -1287,7 +1305,87 @@ describe("ralph", () => { (mod.default.inputs["base_branch"] as { default?: string }).default, "origin/main", ); - assert.deepEqual(Object.keys(mod.default.inputs).sort(), ["base_branch", "max_loops", "prompt"]); + assert.equal(mod.default.inputs["git_worktree_dir"]?.type, "string"); + assert.equal( + (mod.default.inputs["git_worktree_dir"] as { default?: string }).default, + "", + ); + const description = mod.default.inputs["git_worktree_dir"]?.description ?? ""; + assert.match(description, /inside a Git repo/); + assert.match(description, /absolute paths are used as-is/); + assert.match(description, /relative paths resolve from the repo root/); + assert.match(description, /existing Git worktrees from the invoking repository are reused\/shared as-is/); + assert.deepEqual(Object.keys(mod.default.inputs).sort(), ["base_branch", "git_worktree_dir", "max_loops", "prompt"]); + }); + + test("leaves stage cwd unset when git_worktree_dir is not provided", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js"); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: "", + }); + + await mod.default.run({ ...ctx, cwd: requireRalphTempCwd() }); + + assertEveryRalphStageCwd(ctx, undefined); + }); + + test("pull-request stage documents detached HEAD branch handoff without cleanup markers", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js"); + const ctx = makeMockCtx({ + prompt: "Add a small feature", + max_loops: 1, + base_branch: "main", + git_worktree_dir: "", + }); + + await mod.default.run({ ...ctx, cwd: requireRalphTempCwd() }); + + const prompt = ctx.calls.prompts["pull-request"]?.[0] ?? ""; + assert.match(prompt, /detached HEAD/); + assert.match(prompt, /git checkout -b /); + assert.ok(prompt.includes("git push origin HEAD:refs/heads/")); + assert.match(prompt, /does not remove git_worktree_dir automatically/); + assert.equal(prompt.includes("Worktree cleanup: safe-to-remove"), false); + assert.equal(prompt.includes("Worktree cleanup: preserve"), false); + }); + + test("revises the original Ralph spec file across planner iterations", async () => { + const mod = await import("../../packages/workflows/builtin/ralph.js"); + const prompt = "Collision spec"; + const cwd = requireRalphTempCwd(); + const specsDir = join(cwd, "specs"); + const date = new Date().toISOString().slice(0, 10); + const expectedSpecPath = join(specsDir, `${date}-collision-spec.md`); + mkdirSync(specsDir, { recursive: true }); + writeFileSync(expectedSpecPath, "pre-existing spec\n", "utf8"); + + const ctx = makeMockCtx( + { + prompt, + max_loops: 2, + base_branch: "main", + git_worktree_dir: "", + }, + { + task: (name) => { + if (name === "planner-1") return "first generated spec"; + if (name === "planner-2") return "second revised spec"; + return undefined; + }, + }, + ); + + const result = await mod.default.run({ ...ctx, cwd }); + + assert.equal(result["plan_path"], expectedSpecPath); + assert.equal(readFileSync(expectedSpecPath, "utf8"), "second revised spec\n"); + assert.deepEqual(readPaths(ctx.calls.taskOptions["planner-1"]?.[0]), []); + assert.deepEqual(readPaths(ctx.calls.taskOptions["planner-2"]?.[0]), [expectedSpecPath]); + assert.match(ctx.calls.prompts["planner-2"]?.[0] ?? "", /full updated RFC markdown that should replace the original spec/); + assert.equal(existsSync(join(specsDir, `${date}-collision-spec-2.md`)), false); }); }); diff --git a/test/unit/define-workflow.test.ts b/test/unit/define-workflow.test.ts index e754772245..5f5ffa26c2 100644 --- a/test/unit/define-workflow.test.ts +++ b/test/unit/define-workflow.test.ts @@ -50,4 +50,18 @@ describe("defineWorkflow builder", () => { assert.deepEqual(Object.keys(def.inputs), ["a", "b"]); assert.deepEqual(def.inputs["b"], { type: "number", default: 4 }); }); + + test("worktreeFromInputs stores workflow input bindings", () => { + const def = defineWorkflow("worktree-inputs") + .input("git_worktree_dir", { type: "string", default: "" }) + .input("base_branch", { type: "string", default: "main" }) + .worktreeFromInputs({ gitWorktreeDir: "git_worktree_dir", baseBranch: "base_branch" }) + .run(async () => ({})) + .compile(); + + assert.deepEqual(def.inputBindings?.worktree, { + gitWorktreeDir: "git_worktree_dir", + baseBranch: "base_branch", + }); + }); }); diff --git a/test/unit/depth-enforcement.test.ts b/test/unit/depth-enforcement.test.ts index b6e1ee2bd6..3ee39936c9 100644 --- a/test/unit/depth-enforcement.test.ts +++ b/test/unit/depth-enforcement.test.ts @@ -24,10 +24,15 @@ import type { WorkflowDefinition } from "../../packages/workflows/src/shared/typ function makeWf(name = "depth-test-wf"): WorkflowDefinition { return defineWorkflow(name) - .run(async (_ctx) => ({ ok: true })) + .run(async (ctx) => { + await ctx.task("depth-check", { prompt: "depth check" }); + return { ok: true }; + }) .compile() as WorkflowDefinition; } +const promptAdapter = { prompt: async () => "ok" }; + const configMaxDepth2: WorkflowRuntimeConfig = { maxDepth: 2, defaultConcurrency: 4, @@ -69,6 +74,7 @@ describe("maxDepth enforcement — executor.run", () => { test("depth < maxDepth executes normally", async () => { const wf = makeWf("shallow-wf"); const result = await run(wf, {}, { + adapters: { prompt: promptAdapter }, store: createStore(), config: configMaxDepth2, depth: 1, // one below maxDepth → should run @@ -81,6 +87,7 @@ describe("maxDepth enforcement — executor.run", () => { test("depth 0 (default) executes normally with maxDepth 2", async () => { const wf = makeWf("top-level-wf"); const result = await run(wf, {}, { + adapters: { prompt: promptAdapter }, store: createStore(), config: configMaxDepth2, // depth omitted → defaults to 0 @@ -131,7 +138,7 @@ describe("maxDepth enforcement — executor.run", () => { const wf = makeWf("md1-wf"); const config: WorkflowRuntimeConfig = { ...configMaxDepth2, maxDepth: 1 }; - const depthZero = await run(wf, {}, { store: createStore(), config, depth: 0 }); + const depthZero = await run(wf, {}, { adapters: { prompt: promptAdapter }, store: createStore(), config, depth: 0 }); assert.equal(depthZero.status, "completed"); const depthOne = await run(wf, {}, { store: createStore(), config, depth: 1 }); @@ -143,7 +150,7 @@ describe("maxDepth enforcement — executor.run", () => { const config: WorkflowRuntimeConfig = { ...configMaxDepth2, maxDepth: 4 }; const wf = makeWf("md4-wf"); - const atBoundary = await run(wf, {}, { store: createStore(), config, depth: 3 }); + const atBoundary = await run(wf, {}, { adapters: { prompt: promptAdapter }, store: createStore(), config, depth: 3 }); assert.equal(atBoundary.status, "completed"); const exceeded = await run(wf, {}, { store: createStore(), config, depth: 4 }); diff --git a/test/unit/discovery.test.ts b/test/unit/discovery.test.ts index bafda8d640..130904a78f 100644 --- a/test/unit/discovery.test.ts +++ b/test/unit/discovery.test.ts @@ -13,7 +13,7 @@ import { afterAll, describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -320,7 +320,7 @@ function writeNoStageWorkflowJs(dir: string, filename: string): string { ` __piWorkflow: true,`, ` name: "No Stage Workflow",`, ` normalizedName: "no-stage-workflow",`, - ` description: "Should be rejected because it has no workflow stages",`, + ` description: "Discovery rejects this because it creates no stages",`, ` inputs: {},`, ` run: async () => ({ ok: true }),`, `};`, @@ -694,19 +694,69 @@ describe("discoverWorkflows — INVALID_DEFINITION diagnostics", () => { assert.equal(registry.names().length, 0); }); - test("workflow with no stage-producing primitive is rejected and not registered", async () => { - const cwd = makeTempDir("invalid-no-stages"); + test("workflow that completes without creating stages registers structurally", async () => { + const cwd = makeTempDir("structural-no-stages"); const wfDir = join(cwd, ".atomic", "workflows"); mkdirSync(wfDir, { recursive: true }); - const fp = writeNoStageWorkflowJs(wfDir, "no-stage.js"); + writeNoStageWorkflowJs(wfDir, "no-stage.js"); - const { registry, errors } = await discoverWorkflows({ cwd, homeDir: makeTempDir("empty-no-stages"), includeBundled: false }); + const { registry, errors } = await discoverWorkflows({ cwd, homeDir: makeTempDir("empty-structural-no-stages"), includeBundled: false }); - assert.equal(registry.has("no-stage-workflow"), false); - const inv = errors.filter((e) => e.code === "INVALID_DEFINITION"); - assert.equal(inv.length, 1); - assert.equal(inv[0]!.source, fp); - assert.match(inv[0]!.message, /must create at least one workflow stage/i); + assert.equal(registry.has("no-stage-workflow"), true); + assert.equal(errors.filter((e) => e.code === "INVALID_DEFINITION").length, 0); + }); + + test("discovery does not invoke workflow run bodies", async () => { + const cwd = makeTempDir("no-run-body-side-effects"); + const wfDir = join(cwd, ".atomic", "workflows"); + mkdirSync(wfDir, { recursive: true }); + const sideEffectPath = join(cwd, "side-effect.txt"); + writeFileSync( + join(wfDir, "side-effect.js"), + [ + `import { writeFileSync } from "node:fs";`, + `export default {`, + ` __piWorkflow: true,`, + ` name: "Side Effect Workflow",`, + ` normalizedName: "side-effect-workflow",`, + ` description: "Would write during run if discovery invoked it",`, + ` inputs: {},`, + ` run: async () => { writeFileSync(new URL("../../side-effect.txt", import.meta.url), "ran"); return {}; },`, + `};`, + ].join("\n"), + "utf-8", + ); + + const { registry, errors } = await discoverWorkflows({ cwd, homeDir: makeTempDir("empty-no-run-body-side-effects"), includeBundled: false }); + + assert.equal(registry.has("side-effect-workflow"), true); + assert.equal(errors.length, 0); + assert.equal(existsSync(sideEffectPath), false); + }); + + test("workflow that reaches a stage through an aliased primitive registers structurally", async () => { + const cwd = makeTempDir("valid-aliased-stage-primitive"); + const wfDir = join(cwd, ".atomic", "workflows"); + mkdirSync(wfDir, { recursive: true }); + writeFileSync( + join(wfDir, "aliased.js"), + [ + `export default {`, + ` __piWorkflow: true,`, + ` name: "Aliased Stage Workflow",`, + ` normalizedName: "aliased-stage-workflow",`, + ` description: "Uses an aliased task primitive",`, + ` inputs: {},`, + ` run: async (ctx) => { const { task } = ctx; await task("validation-smoke", { prompt: "validation smoke" }); return {}; },`, + `};`, + ].join("\n"), + "utf-8", + ); + + const { registry, errors } = await discoverWorkflows({ cwd, homeDir: makeTempDir("empty-aliased-stage-primitive"), includeBundled: false }); + + assert.equal(registry.has("aliased-stage-workflow"), true); + assert.equal(errors.filter((e) => e.code === "INVALID_DEFINITION").length, 0); }); test("PATH_NOT_FOUND for configured path that does not exist", async () => { diff --git a/test/unit/executor-phase-c.test.ts b/test/unit/executor-phase-c.test.ts index 8e8d22af2d..6d65a1dc33 100644 --- a/test/unit/executor-phase-c.test.ts +++ b/test/unit/executor-phase-c.test.ts @@ -216,6 +216,7 @@ describe("executor input resolution — Phase C", () => { const def = defineWorkflow("phaseC-defaults") .input("greeting", { type: "text", default: "hi" }) .run(async (ctx) => { + await ctx.stage("read-default").prompt("x"); return { greeting: ctx.inputs["greeting"] }; }) .compile() as WorkflowDefinition; @@ -228,7 +229,10 @@ describe("executor input resolution — Phase C", () => { test("caller-provided value takes precedence over default", async () => { const def = defineWorkflow("phaseC-override") .input("name", { type: "text", default: "default-name" }) - .run(async (ctx) => ({ name: ctx.inputs["name"] })) + .run(async (ctx) => { + await ctx.stage("read-override").prompt("x"); + return { name: ctx.inputs["name"] }; + }) .compile() as WorkflowDefinition; const result = await run(def, { name: "custom" }, { diff --git a/test/unit/executor.test.ts b/test/unit/executor.test.ts index 0175d32c46..92b107ab68 100644 --- a/test/unit/executor.test.ts +++ b/test/unit/executor.test.ts @@ -1,6 +1,7 @@ -import { describe, test } from "bun:test"; +import { afterEach, beforeEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { run, runChain, runParallel, runTask, resolveInputs } from "../../packages/workflows/src/runs/foreground/executor.js"; @@ -42,6 +43,25 @@ function callThroughStack(depth: number, fn: () => Promise): Promise { return callThroughStack(depth - 1, fn); } +let savedGitEnv: Map | undefined; + +beforeEach(() => { + savedGitEnv = new Map(); + for (const key of Object.keys(process.env).filter((candidate) => candidate.startsWith("GIT_"))) { + savedGitEnv.set(key, process.env[key]); + delete process.env[key]; + } +}); + +afterEach(() => { + if (savedGitEnv === undefined) return; + for (const [key, value] of savedGitEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + savedGitEnv = undefined; +}); + // --------------------------------------------------------------------------- // resolveInputs // --------------------------------------------------------------------------- @@ -117,6 +137,18 @@ describe("executor.run", () => { assert.equal(wfResult.stages[0]?.status, "completed"); }); + test("fails completed workflows that create no stages", async () => { + const def = defineWorkflow("empty-graph-wf") + .run(async () => ({ ok: true })) + .compile(); + + const wfResult = await run(def, {}, { store: createStore() }); + + assert.equal(wfResult.status, "failed"); + assert.equal(wfResult.stages.length, 0); + assert.match(wfResult.error ?? "", /completed without creating any workflow stages/); + }); + test("ctx.task creates a tracked stage and returns reusable previous output", async () => { const seenPrompts: string[] = []; const def = defineWorkflow("task-wf") @@ -239,6 +271,290 @@ describe("executor.run", () => { assert.equal(wfResult.result?.["count"], 2); }); + test("ctx.stage defaults cwd to gitWorktreeDir while preserving the workflow relative cwd", async () => { + const tempRoot = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-workflow-git-worktree-"))); + const repo = join(tempRoot, "repo"); + mkdirSync(repo, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repo, stdio: "ignore" }); + mkdirSync(join(repo, "nested"), { recursive: true }); + writeFileSync(join(repo, "nested", "fixture.txt"), "fixture\n", "utf8"); + execFileSync("git", ["add", "nested/fixture.txt"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { cwd: repo, stdio: "ignore" }); + const workflowCwd = join(repo, "nested"); + const worktreeRoot = join(repo, "worktrees", "sdk"); + const expectedStageCwd = join(worktreeRoot, "nested"); + const calls: CreateAgentSessionOptions[] = []; + const def = defineWorkflow("stage-git-worktree-wf") + .run(async (ctx) => { + await ctx.stage("worker", { + gitWorktreeDir: join("worktrees", "sdk"), + baseBranch: "main", + }).prompt("inspect"); + return { ok: true }; + }) + .compile(); + + const wfResult = await run(def, {}, { + cwd: workflowCwd, + adapters: { + agentSession: { + async create(options) { + calls.push(options); + return mockSession(); + }, + }, + }, + store: createStore(), + }); + + assert.equal(wfResult.status, "completed"); + assert.equal(calls[0]?.cwd, expectedStageCwd); + assert.equal(existsSync(join(worktreeRoot, ".git")), true); + }); + + test("ctx.stage preserves explicit absolute cwd when gitWorktreeDir is set", async () => { + const tempRoot = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-workflow-git-worktree-"))); + const repo = join(tempRoot, "repo"); + mkdirSync(repo, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repo, stdio: "ignore" }); + writeFileSync(join(repo, "README.md"), "# repo\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { cwd: repo, stdio: "ignore" }); + const explicitCwd = join(tempRoot, "explicit-cwd"); + mkdirSync(explicitCwd, { recursive: true }); + const calls: CreateAgentSessionOptions[] = []; + const def = defineWorkflow("stage-git-worktree-explicit-cwd-wf") + .run(async (ctx) => { + await ctx.stage("worker", { + gitWorktreeDir: join("worktrees", "sdk"), + baseBranch: "main", + cwd: explicitCwd, + }).prompt("inspect"); + return { ok: true }; + }) + .compile(); + + const wfResult = await run(def, {}, { + cwd: repo, + adapters: { + agentSession: { + async create(options) { + calls.push(options); + return mockSession(); + }, + }, + }, + store: createStore(), + }); + + assert.equal(wfResult.status, "completed"); + assert.equal(calls[0]?.cwd, explicitCwd); + assert.equal(existsSync(join(repo, "worktrees", "sdk", ".git")), true); + }); + + test("ctx.stage preserves logical symlink repo cwd for gitWorktreeDir", async () => { + if (process.platform === "win32") return; + const tempRoot = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-workflow-git-worktree-"))); + const repo = join(tempRoot, "real-repo"); + mkdirSync(join(repo, "nested"), { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repo, stdio: "ignore" }); + writeFileSync(join(repo, "nested", "fixture.txt"), "fixture\n", "utf8"); + execFileSync("git", ["add", "nested/fixture.txt"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { cwd: repo, stdio: "ignore" }); + const repoLink = join(tempRoot, "repo-link"); + symlinkSync(repo, repoLink, "dir"); + const calls: CreateAgentSessionOptions[] = []; + const def = defineWorkflow("stage-git-worktree-symlink-repo-wf") + .run(async (ctx) => { + await ctx.stage("worker", { + gitWorktreeDir: join("worktrees", "sdk"), + baseBranch: "main", + }).prompt("inspect"); + return { ok: true }; + }) + .compile(); + + const wfResult = await run(def, {}, { + cwd: join(repoLink, "nested"), + adapters: { + agentSession: { + async create(options) { + calls.push(options); + return mockSession(); + }, + }, + }, + store: createStore(), + }); + + assert.equal(wfResult.status, "completed"); + assert.equal(calls[0]?.cwd, join(repoLink, "worktrees", "sdk", "nested")); + }); + + test("ctx.stage preserves logical symlink worktree parent for gitWorktreeDir", async () => { + if (process.platform === "win32") return; + const tempRoot = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-workflow-git-worktree-"))); + const repo = join(tempRoot, "repo"); + mkdirSync(repo, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repo, stdio: "ignore" }); + writeFileSync(join(repo, "README.md"), "# repo\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { cwd: repo, stdio: "ignore" }); + const realWorktrees = join(tempRoot, "real-worktrees"); + mkdirSync(realWorktrees, { recursive: true }); + const worktreesLink = join(tempRoot, "worktrees-link"); + symlinkSync(realWorktrees, worktreesLink, "dir"); + const calls: CreateAgentSessionOptions[] = []; + const def = defineWorkflow("stage-git-worktree-symlink-parent-wf") + .run(async (ctx) => { + await ctx.stage("worker", { + gitWorktreeDir: join(worktreesLink, "sdk"), + baseBranch: "main", + }).prompt("inspect"); + return { ok: true }; + }) + .compile(); + + const wfResult = await run(def, {}, { + cwd: repo, + adapters: { + agentSession: { + async create(options) { + calls.push(options); + return mockSession(); + }, + }, + }, + store: createStore(), + }); + + assert.equal(wfResult.status, "completed"); + assert.equal(calls[0]?.cwd, join(worktreesLink, "sdk")); + assert.equal(existsSync(join(worktreesLink, "sdk", ".git")), true); + }); + + test("ctx.stage resolves explicit relative cwd against the gitWorktreeDir cwd", async () => { + const tempRoot = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-workflow-git-worktree-"))); + const repo = join(tempRoot, "repo"); + mkdirSync(join(repo, "nested", "deeper"), { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repo, stdio: "ignore" }); + writeFileSync(join(repo, "nested", "deeper", "fixture.txt"), "fixture\n", "utf8"); + execFileSync("git", ["add", "nested/deeper/fixture.txt"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { cwd: repo, stdio: "ignore" }); + const calls: CreateAgentSessionOptions[] = []; + const def = defineWorkflow("stage-git-worktree-relative-cwd-wf") + .run(async (ctx) => { + await ctx.stage("worker", { + gitWorktreeDir: join("worktrees", "sdk"), + baseBranch: "main", + cwd: "deeper", + }).prompt("inspect"); + return { ok: true }; + }) + .compile(); + + const wfResult = await run(def, {}, { + cwd: join(repo, "nested"), + adapters: { + agentSession: { + async create(options) { + calls.push(options); + return mockSession(); + }, + }, + }, + store: createStore(), + }); + + assert.equal(wfResult.status, "completed"); + assert.equal(calls[0]?.cwd, join(repo, "worktrees", "sdk", "nested", "deeper")); + }); + + test("ctx.task, ctx.parallel, and ctx.chain inherit gitWorktreeDir cwd defaults", async () => { + const tempRoot = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-workflow-git-worktree-"))); + const repo = join(tempRoot, "repo"); + mkdirSync(join(repo, "nested"), { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repo, stdio: "ignore" }); + writeFileSync(join(repo, "nested", "fixture.txt"), "fixture\n", "utf8"); + execFileSync("git", ["add", "nested/fixture.txt"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { cwd: repo, stdio: "ignore" }); + const calls: CreateAgentSessionOptions[] = []; + const expectedCwd = join(repo, "worktrees", "sdk", "nested"); + const def = defineWorkflow("task-parallel-chain-git-worktree-wf") + .run(async (ctx) => { + await ctx.task("task", { task: "inspect", gitWorktreeDir: join("worktrees", "sdk"), baseBranch: "main" }); + await ctx.parallel([ + { name: "parallel-a", task: "inspect a" }, + { name: "parallel-b", task: "inspect b" }, + ], { gitWorktreeDir: join("worktrees", "sdk"), baseBranch: "main" }); + await ctx.chain([ + { name: "chain-a", task: "inspect chain" }, + ], { gitWorktreeDir: join("worktrees", "sdk"), baseBranch: "main" }); + return { ok: true }; + }) + .compile(); + + const wfResult = await run(def, {}, { + cwd: join(repo, "nested"), + adapters: { + agentSession: { + async create(options) { + calls.push(options); + return mockSession(); + }, + }, + }, + store: createStore(), + }); + + assert.equal(wfResult.status, "completed"); + assert.equal(calls.length, 4); + assert.deepEqual(calls.map((call) => call.cwd), [expectedCwd, expectedCwd, expectedCwd, expectedCwd]); + }); + + test("worktree and gitWorktreeDir are mutually exclusive", async () => { + const tempRoot = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-workflow-git-worktree-"))); + const repo = join(tempRoot, "repo"); + mkdirSync(repo, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repo, stdio: "ignore" }); + writeFileSync(join(repo, "README.md"), "# repo\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { cwd: repo, stdio: "ignore" }); + const def = defineWorkflow("mixed-worktree-mode-wf") + .run(async (ctx) => { + await ctx.task("worker", { + task: "inspect", + worktree: true, + gitWorktreeDir: join("worktrees", "sdk"), + }); + return { ok: true }; + }) + .compile(); + + const wfResult = await run(def, {}, { + cwd: repo, + adapters: { agentSession: { async create() { return mockSession(); } } }, + store: createStore(), + }); + + assert.equal(wfResult.status, "failed"); + assert.match(wfResult.error ?? "", /worktree and gitWorktreeDir are mutually exclusive/); + }); + test("ctx.task forwards createAgentSession options to the SDK session", async () => { const calls: CreateAgentSessionOptions[] = []; const def = defineWorkflow("task-session-options-wf") @@ -711,10 +1027,14 @@ describe("executor.run", () => { try { const st = createStore(); const def = defineWorkflow("prompt-node-ui-precedence-wf") - .run(async () => ({})) + .run(async (ctx) => { + await ctx.task("warning-smoke", { prompt: "go" }); + return {}; + }) .compile(); const result = await run(def, {}, { + adapters: { prompt: { prompt: async () => "ok" } }, store: st, usePromptNodesForUi: true, ui: { @@ -2106,11 +2426,16 @@ describe("executor.run — HIL adapter injection", () => { const def = defineWorkflow("hil-input-wf") .run(async (ctx) => { const value = await ctx.ui.input("What is your name?"); + await ctx.task("after-input", { prompt: "record input" }); return { value }; }) .compile(); - const wfResult = await run(def, {}, { ui: uiAdapter, store: createStore() }); + const wfResult = await run(def, {}, { + adapters: { prompt: { prompt: async () => "ok" } }, + ui: uiAdapter, + store: createStore(), + }); assert.equal(wfResult.status, "completed"); assert.equal(wfResult.result?.["value"], "user-input"); @@ -2128,11 +2453,16 @@ describe("executor.run — HIL adapter injection", () => { const def = defineWorkflow("hil-confirm-wf") .run(async (ctx) => { const ok = await ctx.ui.confirm("Continue?"); + await ctx.task("after-confirm", { prompt: "record confirm" }); return { ok }; }) .compile(); - const wfResult = await run(def, {}, { ui: uiAdapter, store: createStore() }); + const wfResult = await run(def, {}, { + adapters: { prompt: { prompt: async () => "ok" } }, + ui: uiAdapter, + store: createStore(), + }); assert.equal(wfResult.status, "completed"); assert.equal(wfResult.result?.["ok"], true); @@ -2149,11 +2479,16 @@ describe("executor.run — HIL adapter injection", () => { const def = defineWorkflow("hil-select-wf") .run(async (ctx) => { const choice = await ctx.ui.select("Pick one", ["a", "b", "c"] as const); + await ctx.task("after-select", { prompt: "record select" }); return { choice }; }) .compile(); - const wfResult = await run(def, {}, { ui: uiAdapter, store: createStore() }); + const wfResult = await run(def, {}, { + adapters: { prompt: { prompt: async () => "ok" } }, + ui: uiAdapter, + store: createStore(), + }); assert.equal(wfResult.status, "completed"); assert.equal(wfResult.result?.["choice"], "b"); @@ -2170,11 +2505,16 @@ describe("executor.run — HIL adapter injection", () => { const def = defineWorkflow("hil-editor-wf") .run(async (ctx) => { const content = await ctx.ui.editor("draft"); + await ctx.task("after-editor", { prompt: "record editor" }); return { content }; }) .compile(); - const wfResult = await run(def, {}, { ui: uiAdapter, store: createStore() }); + const wfResult = await run(def, {}, { + adapters: { prompt: { prompt: async () => "ok" } }, + ui: uiAdapter, + store: createStore(), + }); assert.equal(wfResult.status, "completed"); assert.equal(wfResult.result?.["content"], "edited: draft"); @@ -2302,10 +2642,14 @@ describe("executor.run — lifecycle persistence", () => { const { persistence, calls } = makePersistence(); const def = defineWorkflow("payload-wf") - .run(async (_ctx) => ({})) + .run(async (ctx) => { + await ctx.task("payload-smoke", { prompt: "go" }); + return {}; + }) .compile(); const wfResult = await run(def, { x: 1 }, { + adapters: { prompt: { prompt: async () => "ok" } }, store: createStore(), persistence, }); @@ -2365,16 +2709,43 @@ describe("executor.run — lifecycle persistence", () => { const { persistence, calls } = makePersistence(); const def = defineWorkflow("run-end-wf") - .run(async (_ctx) => ({ x: 1 })) + .run(async (ctx) => { + await ctx.task("run-end-smoke", { prompt: "go" }); + return { x: 1 }; + }) .compile(); - await run(def, {}, { store: createStore(), persistence }); + await run(def, {}, { + adapters: { prompt: { prompt: async () => "ok" } }, + store: createStore(), + persistence, + }); const runEnd = calls.find((c) => c.type === "workflow.run.end"); assert.equal(runEnd?.payload["status"], "completed"); assert.equal(typeof runEnd?.payload["ts"], "number"); }); + test("empty graph validation appends failed run.end without stage entries", async () => { + const { persistence, calls } = makePersistence(); + + const def = defineWorkflow("empty-persist-wf") + .run(async () => ({ ok: true })) + .compile(); + + const wfResult = await run(def, {}, { + store: createStore(), + persistence, + }); + + assert.equal(wfResult.status, "failed"); + assert.match(wfResult.error ?? "", /completed without creating any workflow stages/); + assert.deepEqual(calls.map((c) => c.type), ["workflow.run.start", "workflow.run.end"]); + const runEnd = calls.find((c) => c.type === "workflow.run.end"); + assert.equal(runEnd?.payload["status"], "failed"); + assert.match(String(runEnd?.payload["error"] ?? ""), /completed without creating any workflow stages/); + }); + test("failed stage: stage.end status=failed, run.end status=failed", async () => { const { persistence, calls } = makePersistence(); @@ -2502,10 +2873,14 @@ describe("executor.run — lifecycle persistence", () => { }; const def = defineWorkflow("guard-wf") - .run(async (_ctx) => ({})) + .run(async (ctx) => { + await ctx.task("guard-smoke", { prompt: "go" }); + return {}; + }) .compile(); await run(def, {}, { + adapters: { prompt: { prompt: async () => "ok" } }, store: guardedStore as import("../../packages/workflows/src/shared/store.js").Store, persistence, }); diff --git a/test/unit/extension.test.ts b/test/unit/extension.test.ts index c07091252d..632289008e 100644 --- a/test/unit/extension.test.ts +++ b/test/unit/extension.test.ts @@ -19,15 +19,14 @@ test("session_start warns when discovered workflows fail validation", async () = try { const workflowDir = join(root, "workflows"); mkdirSync(workflowDir, { recursive: true }); - const workflowPath = join(workflowDir, "empty-graph.js"); + const workflowPath = join(workflowDir, "invalid-shape.js"); writeFileSync( workflowPath, [ "export default {", - " __piWorkflow: true,", - " name: 'Empty Graph',", - " normalizedName: 'empty-graph',", - " description: 'invalid because no stage is created',", + " name: 'Invalid Workflow',", + " normalizedName: 'invalid-workflow',", + " description: 'invalid because it is missing the workflow sentinel',", " inputs: {},", " run: async () => ({ ok: true }),", "};", @@ -64,8 +63,8 @@ test("session_start warns when discovered workflows fail validation", async () = const warning = notifications.find((entry) => entry.message.includes("Workflow discovery diagnostics")); assert.notEqual(warning, undefined); assert.equal(warning?.type, "warning"); - assert.match(warning!.message, /empty-graph\.js/); - assert.match(warning!.message, /must create at least one workflow stage/i); + assert.match(warning!.message, /invalid-shape\.js/); + assert.match(warning!.message, /missing or incorrect __piWorkflow sentinel/); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/test/unit/mcp-oauth-startup.test.ts b/test/unit/mcp-oauth-startup.test.ts index e1ce58b3d1..d1f0bff85a 100644 --- a/test/unit/mcp-oauth-startup.test.ts +++ b/test/unit/mcp-oauth-startup.test.ts @@ -13,7 +13,6 @@ type SessionStartContext = { readonly cwd: string; readonly hasUI: false; readon type SessionStartHandler = (event: SessionStartEvent, ctx: SessionStartContext) => Promise | void; const originalArgv = [...process.argv]; -const originalCwd = process.cwd(); const originalAtomicAgentDir = process.env.ATOMIC_CODING_AGENT_DIR; const originalMcpDirectTools = process.env.MCP_DIRECT_TOOLS; @@ -23,7 +22,6 @@ beforeEach(async () => { afterEach(async () => { process.argv = [...originalArgv]; - process.chdir(originalCwd); if (originalAtomicAgentDir === undefined) { delete process.env.ATOMIC_CODING_AGENT_DIR; } else { @@ -42,7 +40,6 @@ test("MCP session startup leaves OAuth callback handling lazy", async () => { const configPath = join(tempDir, "mcp.json"); const remoteServer = { url: "https://example.invalid/mcp" } satisfies ServerEntry; writeFileSync(configPath, JSON.stringify({ mcpServers: { remote: remoteServer } })); - process.chdir(tempDir); process.env.ATOMIC_CODING_AGENT_DIR = join(tempDir, "agent"); process.env.MCP_DIRECT_TOOLS = "__none__"; process.argv = [...originalArgv, "--mcp-config", configPath]; diff --git a/test/unit/workflow-runner.test.ts b/test/unit/workflow-runner.test.ts index d71d47b9ec..097bbb4612 100644 --- a/test/unit/workflow-runner.test.ts +++ b/test/unit/workflow-runner.test.ts @@ -1,6 +1,6 @@ import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CreateAgentSessionOptions } from "@bastani/atomic"; @@ -177,11 +177,8 @@ describe("programmatic workflow runner", () => { test("runs a named workflow from an explicit definition object", async () => { const prompts: string[] = []; const dir = mkdtempSync(join(tmpdir(), "workflow-runner-deep-research-")); - const previousCwd = process.cwd(); - let result: Awaited>; try { - process.chdir(dir); - result = await runWorkflow( + const result = await runWorkflow( { mode: "workflow", workflow: "deep-research-codebase", @@ -190,16 +187,22 @@ describe("programmatic workflow runner", () => { max_partitions: 1, }, }, - { adapterOptions: { createAgentSession: makeSessionFactory(prompts) } }, + { cwd: dir, adapterOptions: { createAgentSession: makeSessionFactory(prompts) } }, ); + + assert.equal(result.mode, "named"); + assert.equal(result.status, "completed"); + assert.equal(result.output?.["specialist_count"], 4); + assert.ok(prompts.some((prompt) => prompt.includes("Research question: map workflow sdk"))); + const researchDocPath = result.output?.["research_doc_path"]; + const artifactDir = result.output?.["artifact_dir"]; + assert.equal(typeof researchDocPath, "string"); + assert.equal(typeof artifactDir, "string"); + assert.equal(existsSync(join(dir, researchDocPath as string)), true); + assert.equal(existsSync(join(dir, artifactDir as string)), true); } finally { - process.chdir(previousCwd); + rmSync(dir, { recursive: true, force: true }); } - - assert.equal(result.mode, "named"); - assert.equal(result.status, "completed"); - assert.equal(result.output?.["specialist_count"], 4); - assert.ok(prompts.some((prompt) => prompt.includes("Research question: map workflow sdk"))); }); test("validates named workflow inputs before starting a session", async () => {