import type { LoopResult, TaskSpec } from './types.js'
import {
+ GIT_BASE_BRANCH,
GIT_PUSH_TIMEOUT_MS,
GIT_TIMEOUT_MS,
MAX_STDERR_CHARS,
import { execFileAsync, toErrorMessage } from './utils.js'
/**
- * Fetches origin/main and rebases the current branch onto it.
+ * Fetches the base branch and rebases the current branch onto it.
* On failure, aborts the rebase cleanly.
* @param cwd - Working directory (worktree path).
+ * @param baseBranch - Target branch for rebase.
* @returns `true` if rebase succeeded, `false` otherwise.
*/
-export async function attemptRebase (cwd: string): Promise<boolean> {
+export async function attemptRebase (cwd: string, baseBranch = GIT_BASE_BRANCH): Promise<boolean> {
try {
- await execFileAsync('git', ['fetch', 'origin', 'main'], {
+ await execFileAsync('git', ['fetch', 'origin', baseBranch], {
+ cwd,
+ timeout: GIT_TIMEOUT_MS,
+ })
+ await execFileAsync('git', ['rebase', `origin/${baseBranch}`], {
cwd,
timeout: GIT_TIMEOUT_MS,
})
- await execFileAsync('git', ['rebase', 'origin/main'], { cwd, timeout: GIT_TIMEOUT_MS })
return true
} catch {
try {
* @param spec - The task specification.
* @param loopResult - The result from the refinement loop.
* @param validationPassed - Whether the validation suite passed.
- * @param rebaseSucceeded - Whether the rebase onto main succeeded.
+ * @param rebaseSucceeded - Whether the rebase onto the base branch succeeded.
+ * @param baseBranch - Target branch for PR base.
* @returns Object with `isDraft` flag and `prArgs` string array.
*/
export function buildPrArgs (
spec: TaskSpec,
loopResult: LoopResult,
validationPassed: boolean,
- rebaseSucceeded: boolean
+ rebaseSucceeded: boolean,
+ baseBranch = GIT_BASE_BRANCH
): { isDraft: boolean; prArgs: string[] } {
const converged = loopResult.status === 'converged'
const isDraft = !converged || !validationPassed
? '\n\n⚠️ Validation did not pass. Manual review required.'
: ''
const rebaseNote = !rebaseSucceeded
- ? '\n\n⚠️ Rebase failed. Branch is not rebased onto main.'
+ ? `\n\n⚠️ Rebase failed. Branch is not rebased onto ${baseBranch}.`
: ''
const validationCheck = validationPassed ? '- [x]' : '- [ ]'
- const commitPrefix = spec.labels.includes('enhancement')
+ const labels = spec.labels ?? []
+ const commitPrefix = labels.includes('enhancement')
? 'feat'
- : spec.labels.includes('bug')
+ : labels.includes('bug')
? 'fix'
: 'chore'
const cleanTitle = spec.title.replace(/^\[(?:FEATURE|BUG|FIX|CHORE)\]\s*/i, '')
'--head',
spec.branch,
'--base',
- 'main',
+ baseBranch,
'--title',
prTitle,
'--body',
prBody,
- ...spec.labels.flatMap(label => ['--label', label]),
+ ...labels.flatMap(label => ['--label', label]),
]
return { isDraft, prArgs }
/**
* Pushes the branch to origin. When rebase succeeded, uses force-with-lease
* with a rescue-branch fallback. When rebase was aborted, does a plain push.
- * @param cwd - Working directory (worktree path).
* @param spec - The task specification.
+ * @param cwd - Working directory (worktree path).
* @param rebaseSucceeded - Whether the preceding rebase completed successfully.
* @returns `true` if the primary push succeeded, `false` otherwise.
*/
export async function pushBranch (
- cwd: string,
spec: TaskSpec,
+ cwd: string,
rebaseSucceeded: boolean
): Promise<boolean> {
if (rebaseSucceeded) {
import type {
Finding,
+ LoopContext,
LoopResult,
LoopStatus,
LoopStrategy,
AGENT_MAX_CRITIC_ROUNDS,
COMPLETION_SIGNAL,
CONTEXT_HASH_RADIUS,
+ GIT_BASE_BRANCH,
HASH_PREFIX_LENGTH,
} from './constants.js'
import { runValidation } from './finalizer.js'
/** Options for configuring the refinement loop. */
export interface RefinementLoopOptions {
+ /** Base branch for commit counting (default: 'main'). */
+ baseBranch?: string
/** Budget of iterations per round (flat constant applied to every round). */
iterationBudget?: number
/** Maximum number of implement↔critic rounds. */
maxRounds?: number
/** Optional callback invoked after each round completes. */
onRoundComplete?: (round: number, findings: Finding[]) => void
- /** When true, run one extra implementer attempt if post-loop validation fails. */
+ /** When true, run one extra actor attempt if post-loop validation fails. */
postLoopValidationRetry?: boolean
/** Abort signal for cooperative cancellation (kills in-flight agent subprocesses). */
signal?: AbortSignal
* Groups the per-round identifiers needed for regression detection and rollback.
*/
interface RatchetContext {
- /** SHA of HEAD before the implementer ran (used for rollback). */
+ /** SHA of HEAD before the actor ran (used for rollback). */
readonly beforeSha: string
/** Working directory for git operations. */
readonly cwd: string
/** Resolved loop options with defaults applied. */
interface ResolvedLoopOptions {
+ /** Base branch for commit counting. */
+ baseBranch: string
/** Iteration budget per round. */
budget: number
/** Maximum number of rounds. */
/** Result of a single implement↔critic round. */
interface RoundResult {
- /** SHA of HEAD before the implementer ran. */
+ /** SHA of HEAD before the actor ran. */
beforeSha: string
- /** Number of commits made by the implementer. */
+ /** Number of commits made by the actor. */
commits: number
/** Parsed findings from the critic, or null on critic failure. */
findings: Finding[] | null
strategy: LoopStrategy,
opts?: RefinementLoopOptions
): Promise<LoopResult> {
- const { budget, maxRounds, onRoundComplete } = resolveLoopOptions(opts)
+ const { baseBranch, budget, maxRounds, onRoundComplete } = resolveLoopOptions(opts)
const signal = opts?.signal
+ const validate = strategy.validate ?? ((cwd: string, s: TaskSpec) => runValidation(cwd, s))
+
+ const ctx: LoopContext = { baseBranch, sandbox, signal, spec, strategy }
const seenKeys = new Set<string>()
let lastFindings: Finding[] = []
` #${spec.id} round ${String(round)}/${String(maxRounds)} (budget: ${String(budget)})`
)
- const result = await executeRound(spec, sandbox, round, budget, lastFindings, strategy, signal)
+ const result = await executeRound(ctx, round, budget, lastFindings)
const earlyExit = checkEarlyExit(spec, round, result, totalCommits)
if (earlyExit !== null) {
if (result.findings === null) break
const findings: Finding[] = result.findings
- if (result.commits > 0 && (await runValidation(sandbox.worktreePath, spec))) {
+ if (result.commits > 0 && (await validate(sandbox.worktreePath, spec))) {
totalCommits += result.commits
status = 'converged'
break
}
const cwd = sandbox.worktreePath
- const newFindings = await deduplicateFindings(findings, seenKeys, cwd)
+ const newFindings = await deduplicateFindings(findings, cwd, seenKeys)
console.log(
` #${spec.id}: ${String(findings.length)} findings, ${String(newFindings.length)} new`
// Post-loop validation retry (if enabled)
if (opts?.postLoopValidationRetry && totalCommits > 0 && status !== 'converged') {
signal?.throwIfAborted()
- const validationPassed = await runValidation(sandbox.worktreePath, spec)
+ const validationPassed = await validate(sandbox.worktreePath, spec)
if (validationPassed) {
status = 'converged'
} else if (roundsCompleted < maxRounds) {
- const result = await executeRound(
- spec,
- sandbox,
- roundsCompleted + 1,
- budget,
- lastFindings,
- strategy,
- signal
- )
+ const result = await executeRound(ctx, roundsCompleted + 1, budget, lastFindings)
if (result.commits > 0) {
totalCommits += result.commits
- if (await runValidation(sandbox.worktreePath, spec)) {
+ if (await validate(sandbox.worktreePath, spec)) {
status = 'converged'
}
}
}
if (shouldResetToBest(status, bestSha)) {
- totalCommits = await resetToBestState(sandbox.worktreePath, bestSha, totalCommits)
+ totalCommits = await resetToBestState(sandbox.worktreePath, bestSha, totalCommits, baseBranch)
}
- return { lastFindings, roundsCompleted, status, totalCommits }
+ return { baseBranch, lastFindings, roundsCompleted, status, totalCommits }
}
/**
/**
* Filters findings by confidence and deduplicates against previously seen keys.
* @param findings - Raw findings from the critic.
- * @param seenKeys - Set of previously seen dedup keys (mutated: new keys are added).
* @param cwd - Working directory for context hashing.
+ * @param seenKeys - Set of previously seen dedup keys (mutated: new keys are added).
* @returns Array of new, non-LOW-confidence findings.
*/
async function deduplicateFindings (
findings: Finding[],
- seenKeys: Set<string>,
- cwd: string
+ cwd: string,
+ seenKeys: Set<string>
): Promise<Finding[]> {
const fileCache = new Map<string, string>()
const keys = await Promise.all(findings.map(f => computeFindingKey(f, cwd, fileCache)))
/**
* Executes a single implement↔critic round.
- * @param spec - The task specification.
- * @param sandbox - The sandcastle sandbox instance.
+ * @param ctx - Loop context containing spec, sandbox, strategy, baseBranch, and signal.
* @param round - Current round number (1-indexed).
- * @param budget - Iteration budget for the implementer.
- * @param lastFindings - Findings from the previous round to feed to the implementer.
- * @param strategy - Strategy config for prompt/arg customization.
- * @param signal - Abort signal for cooperative cancellation.
+ * @param budget - Iteration budget for the actor.
+ * @param lastFindings - Findings from the previous round to feed to the actor.
* @returns The round result containing commits, findings, and the pre-round SHA.
*/
async function executeRound (
- spec: TaskSpec,
- sandbox: SandboxInstance,
+ ctx: LoopContext,
round: number,
budget: number,
- lastFindings: Finding[],
- strategy: LoopStrategy,
- signal?: AbortSignal
+ lastFindings: Finding[]
): Promise<RoundResult> {
- // Capture SHA before implementer runs (for quality ratchet rollback)
+ const { sandbox, signal, spec, strategy } = ctx
+
+ // Capture SHA before actor runs (for quality ratchet rollback)
let beforeSha = ''
try {
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
console.warn(` #${spec.id}: Failed to capture HEAD SHA before round ${String(round)}.`)
}
- // Implementer
- let implementerResult: Awaited<ReturnType<typeof sandbox.run>>
+ // Actor
+ let actorResult: Awaited<ReturnType<typeof sandbox.run>>
try {
- implementerResult = await sandbox.run({
- agent: sandcastle.opencode(AGENT_ACTOR_MODEL),
+ actorResult = await sandbox.run({
+ agent: sandcastle.opencode(strategy.actorModel ?? AGENT_ACTOR_MODEL),
completionSignal: COMPLETION_SIGNAL,
idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
maxIterations: budget,
- name: `Implementer #${spec.id} R${String(round)}`,
+ name: `Actor #${spec.id} R${String(round)}`,
promptArgs: strategy.buildActorArgs(spec, lastFindings),
promptFile: strategy.actorPromptFile,
signal,
throw err
}
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err)
- console.error(` #${spec.id} R${String(round)}: Implementer threw: ${msg}`)
+ console.error(` #${spec.id} R${String(round)}: Actor threw: ${msg}`)
return { beforeSha, commits: 0, findings: null }
}
const nonce = crypto.randomBytes(4).toString('hex')
let findings: Finding[] | null
try {
- findings = await runCritic(sandbox, spec, round, nonce, strategy, signal)
+ findings = await runCritic(ctx, round, nonce)
} catch (err: unknown) {
if (signal?.aborted === true) {
throw err
findings = null
}
- return { beforeSha, commits: implementerResult.commits.length, findings }
+ return { beforeSha, commits: actorResult.commits.length, findings }
}
/**
* @param cwd - Working directory for git operations.
* @param bestSha - The SHA to reset to.
* @param currentCommits - Current total commits (fallback if recount fails).
+ * @param baseBranch - Base branch for commit counting.
* @returns Updated total commit count.
*/
async function resetToBestState (
cwd: string,
bestSha: string,
- currentCommits: number
+ currentCommits: number,
+ baseBranch: string
): Promise<number> {
if (!/^[0-9a-f]{40}$/.test(bestSha)) return currentCommits
try {
await execFileAsync('git', ['reset', '--hard', bestSha], { cwd })
- const { stdout } = await execFileAsync('git', ['rev-list', '--count', 'main..HEAD'], { cwd })
+ const { stdout } = await execFileAsync('git', ['rev-list', '--count', `${baseBranch}..HEAD`], {
+ cwd,
+ })
return parseInt(stdout.trim(), 10) || 0
} catch {
return currentCommits
*/
function resolveLoopOptions (opts: RefinementLoopOptions | undefined): ResolvedLoopOptions {
return {
+ baseBranch: opts?.baseBranch ?? GIT_BASE_BRANCH,
budget: opts?.iterationBudget ?? AGENT_ITERATION_BUDGET,
maxRounds: opts?.maxRounds ?? AGENT_MAX_CRITIC_ROUNDS,
onRoundComplete: opts?.onRoundComplete ?? (() => undefined),
/**
* Runs the critic agent, retrying once on parse failure.
- * @param sandbox - The sandcastle sandbox instance.
- * @param spec - The task specification.
+ * @param ctx - Loop context containing spec, sandbox, strategy, baseBranch, and signal.
* @param round - Current round number.
* @param nonce - Unique nonce for parsing.
- * @param strategy - Strategy config for prompt/arg customization.
- * @param signal - Abort signal for cooperative cancellation.
* @returns Parsed findings or null if both attempts failed.
*/
async function runCritic (
- sandbox: SandboxInstance,
- spec: TaskSpec,
+ ctx: LoopContext,
round: number,
- nonce: string,
- strategy: LoopStrategy,
- signal?: AbortSignal
+ nonce: string
): Promise<Finding[] | null> {
+ const { baseBranch, sandbox, signal, spec, strategy } = ctx
+
let critic = await sandbox.run({
- agent: sandcastle.opencode(AGENT_CRITIC_MODEL),
+ agent: sandcastle.opencode(strategy.criticModel ?? AGENT_CRITIC_MODEL),
completionSignal: COMPLETION_SIGNAL,
idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
maxIterations: 1,
name: `Critic #${spec.id} R${String(round)}`,
- promptArgs: strategy.buildCriticArgs(spec, nonce),
+ promptArgs: strategy.buildCriticArgs(spec, nonce, baseBranch),
promptFile: strategy.criticPromptFile,
signal,
})
if (findings === null) {
console.warn(` #${spec.id}: Critic parse failed. Retrying.`)
critic = await sandbox.run({
- agent: sandcastle.opencode(AGENT_CRITIC_MODEL),
+ agent: sandcastle.opencode(strategy.criticModel ?? AGENT_CRITIC_MODEL),
completionSignal: COMPLETION_SIGNAL,
idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
maxIterations: 1,
name: `Critic #${spec.id} R${String(round)} retry`,
- promptArgs: strategy.buildCriticArgs(spec, nonce),
+ promptArgs: strategy.buildCriticArgs(spec, nonce, baseBranch),
promptFile: strategy.criticPromptFile,
signal,
})
/** Zod schema for a single critic finding. */
export const FindingSchema = z.object({
- category: z.enum(['security', 'logic', 'performance', 'architecture', 'style']),
+ category: z.string(),
confidence: z.enum(['HIGH', 'MEDIUM', 'LOW']),
description: z.string(),
file: z.string(),
/** A single critic finding parsed from agent output. */
export type Finding = z.infer<typeof FindingSchema>
+/** Invariant context for a refinement loop run. */
+export interface LoopContext {
+ readonly baseBranch: string
+ readonly sandbox: SandboxInstance
+ readonly signal?: AbortSignal
+ readonly spec: TaskSpec
+ readonly strategy: LoopStrategy
+}
+
/** Result returned by the refinement loop. */
export interface LoopResult {
+ /** Base branch used for this loop run. */
+ baseBranch: string
/** Outstanding findings from the last round. */
lastFindings: Finding[]
/** Number of rounds completed. */
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type LoopStrategy = {
- /** Path to the actor (implementer) prompt file. */
+ /** Model for the actor agent. Defaults to AGENT_ACTOR_MODEL constant. */
+ actorModel?: string
+ /** Path to the actor prompt file. */
actorPromptFile: string
/** Builds promptArgs for the actor run from task spec and previous findings. */
buildActorArgs: (spec: TaskSpec, findings: Finding[]) => Record<string, string>
- /** Builds promptArgs for the critic run from task spec and nonce. */
- buildCriticArgs: (spec: TaskSpec, nonce: string) => Record<string, string>
+ /** Builds promptArgs for the critic run from task spec, nonce, and base branch. */
+ buildCriticArgs: (spec: TaskSpec, nonce: string, baseBranch: string) => Record<string, string>
+ /** Model for the critic agent. Defaults to AGENT_CRITIC_MODEL constant. */
+ criticModel?: string
/** Path to the critic prompt file. */
criticPromptFile: string
/** Optional custom convergence check. When omitted, default loop logic applies. */
shouldConverge?: (findings: Finding[], round: number, totalCommits: number) => boolean
+ /** Optional mid-loop validation. Return true if work passes. When omitted, uses default validation command. */
+ validate?: (cwd: string, spec: TaskSpec) => Promise<boolean>
}
/** Type alias for a sandcastle sandbox instance. */
branch: string
/** Task identifier (e.g. GitHub issue number as string). */
id: string
- /** Label names associated with the task. */
- labels: string[]
+ /** Label names associated with the task (platform-specific, optional). */
+ labels?: string[]
/** Task title. */
title: string
}