.nyc_output
.reuse
.xmake.cfg
-.sandcastle/
# Logs
logs/
*.log
+++ /dev/null
-name: Sandcastle
-
-on:
- schedule:
- - cron: '0 22 * * *'
- workflow_dispatch:
-
-concurrency:
- group: sandcastle
- cancel-in-progress: false
-
-permissions:
- contents: write
- pull-requests: write
- issues: read
-
-jobs:
- sandcastle:
- runs-on: ubuntu-latest
- timeout-minutes: 540
- steps:
- - uses: actions/checkout@v7
-
- - uses: pnpm/action-setup@v6
-
- - uses: actions/setup-node@v7
- with:
- node-version: 24
- cache: 'pnpm'
-
- - run: pnpm install --ignore-scripts --frozen-lockfile
-
- - name: Validate secrets
- run: |
- if [ -z "$PI_AUTH_CONTENT" ]; then
- echo "::error::PI_AUTH_CONTENT secret is not configured"
- exit 1
- fi
- if [ -z "$OPENCODE_AUTH_CONTENT" ]; then
- echo "::error::OPENCODE_AUTH_CONTENT secret is not configured"
- exit 1
- fi
- env:
- PI_AUTH_CONTENT: ${{ secrets.PI_AUTH_CONTENT }}
- OPENCODE_AUTH_CONTENT: ${{ secrets.OPENCODE_AUTH_CONTENT }}
-
- - run: docker build -t sandcastle-sandbox .sandcastle/
-
- - name: Create sandbox env
- run: |
- printf 'GH_TOKEN=%s\n' "$GH_TOKEN" > .sandcastle/.env
- printf 'GITHUB_TOKEN=%s\n' "$GITHUB_TOKEN" >> .sandcastle/.env
- printf 'PI_AUTH_CONTENT=%s\n' "$PI_AUTH_CONTENT" >> .sandcastle/.env
- printf 'OPENCODE_AUTH_CONTENT=%s\n' "$OPENCODE_AUTH_CONTENT" >> .sandcastle/.env
- env:
- GH_TOKEN: ${{ github.token }}
- PI_AUTH_CONTENT: ${{ secrets.PI_AUTH_CONTENT }}
- OPENCODE_AUTH_CONTENT: ${{ secrets.OPENCODE_AUTH_CONTENT }}
-
- - run: pnpm run sandcastle
- env:
- GH_TOKEN: ${{ github.token }}
+++ /dev/null
-GH_TOKEN=
-GITHUB_TOKEN=
-OPENCODE_AUTH_CONTENT=
+++ /dev/null
-.env
-logs/
-worktrees/
+++ /dev/null
-FROM node:24-trixie
-
-RUN apt-get update && apt-get install -y --no-install-recommends \
- git \
- curl \
- jq \
- ca-certificates \
- && rm -rf /var/lib/apt/lists/*
-
-# uv
-RUN install -m 0755 -d /etc/apt/keyrings \
- && curl -fsSL https://debian.griffo.io/EA0F721D231FDD3A0A17B9AC7808B4DD62C41256.asc \
- -o /etc/apt/keyrings/debian.griffo.io.asc \
- && chmod a+r /etc/apt/keyrings/debian.griffo.io.asc \
- && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/debian.griffo.io.asc] https://debian.griffo.io/apt trixie main" \
- > /etc/apt/sources.list.d/debian.griffo.io.list \
- && apt-get update && apt-get install -y --no-install-recommends uv \
- && rm -rf /var/lib/apt/lists/*
-
-# GitHub CLI
-RUN install -m 0755 -d /etc/apt/keyrings \
- && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
- -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
- && chmod a+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
- && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
- > /etc/apt/sources.list.d/github-cli.list \
- && apt-get update && apt-get install -y --no-install-recommends gh \
- && rm -rf /var/lib/apt/lists/*
-
-# Install coding agents globally
-RUN npm install -g \
- @anthropic-ai/claude-code \
- @earendil-works/pi-coding-agent \
- @openai/codex \
- opencode-ai
-
-# Rename the base image's "node" user to "agent" and align UID/GID.
-# Default 1001 matches GitHub Actions runner UID (ubuntu-latest since May 2025).
-# On macOS, build with --build-arg AGENT_UID=$(id -u) to match host UID.
-ARG AGENT_UID=1001
-RUN usermod -u ${AGENT_UID} -d /home/agent -m -l agent node \
- && groupmod -g ${AGENT_UID} -n agent node \
- && mkdir -p /home/agent/.local/share/pnpm/store \
- /home/agent/.local/share/opencode \
- && chown -R ${AGENT_UID}:${AGENT_UID} /home/agent
-USER agent
-
-WORKDIR /home/agent
-ENTRYPOINT ["sleep", "infinity"]
+++ /dev/null
-/** Internal node for the O(1) FIFO waiting queue. Not exported. */
-interface QueueNode {
- next: null | QueueNode
- resolve: () => void
-}
-
-/**
- * A concurrency limiter that restricts parallel execution to a maximum number of tasks.
- * Queue operations are O(1) amortized (singly-linked list).
- */
-export class ConcurrencyPool {
- private head: null | QueueNode = null
- private running = 0
- private tail: null | QueueNode = null
-
- /**
- * @param max - Maximum number of concurrent tasks. Must be a positive integer >= 1.
- */
- constructor (private readonly max: number) {
- if (!Number.isInteger(max) || max < 1) {
- throw new RangeError('ConcurrencyPool max must be a positive integer >= 1')
- }
- }
-
- /**
- * Executes the given async function, waiting if the pool is at capacity.
- * @param fn - Async function to execute within the pool.
- * @returns The result of the function.
- * @remarks Re-entrant calls using the same pool instance may deadlock when all slots are occupied.
- */
- async run<T>(fn: () => Promise<T>): Promise<T> {
- await this.acquire()
- try {
- return await fn()
- } finally {
- this.release()
- }
- }
-
- private acquire (): Promise<void> {
- if (this.running < this.max) {
- this.running++
- return Promise.resolve()
- }
- return new Promise<void>(resolve => {
- const node: QueueNode = { next: null, resolve }
- if (this.tail === null) {
- this.head = node
- this.tail = node
- } else {
- this.tail.next = node
- this.tail = node
- }
- })
- }
-
- private release (): void {
- this.running--
- const next = this.head
- if (next !== null) {
- this.head = next.next
- if (this.head === null) {
- this.tail = null
- }
- this.running++
- next.resolve()
- }
- }
-}
+++ /dev/null
-import { execFileSync } from 'node:child_process'
-import { existsSync } from 'node:fs'
-
-// ── Agent ────────────────────────────────────────────────────────────────────
-
-export type AgentProviderType = 'opencode' | 'pi'
-
-export const AGENT_PROVIDER = 'pi' as AgentProviderType
-
-export const AGENT_ACTOR_EFFORT = 'high' as const
-
-export const AGENT_ACTOR_MODEL = 'github-copilot/claude-opus-4.6'
-
-export const AGENT_CRITIC_EFFORT = 'medium' as const
-
-export const AGENT_CRITIC_MODEL = 'github-copilot/gpt-5.4'
-
-export const AGENT_IDLE_TIMEOUT_S = 720
-
-export const AGENT_ITERATION_BUDGET = 50
-
-export const AGENT_MAX_CRITIC_ROUNDS = 10
-
-export const AGENT_PLANNER_EFFORT = 'medium' as const
-
-export const AGENT_PLANNER_MODEL = 'github-copilot/claude-sonnet-4.6'
-
-export const AGENT_TASK_TIMEOUT_MS = 30_000_000
-
-export const COMPLETION_SIGNAL = '<promise>COMPLETE</promise>'
-
-export const MAX_PARALLEL = 5
-
-// ── Git ──────────────────────────────────────────────────────────────────────
-
-export const GIT_BASE_BRANCH = 'main'
-
-export const GIT_PUSH_TIMEOUT_MS = 60_000
-
-export const GIT_TIMEOUT_MS = 30_000
-
-// ── Docker ───────────────────────────────────────────────────────────────────
-
-export const DOCKER_IMAGE = 'sandcastle-sandbox'
-
-export const DOCKER_MOUNTS = resolveDockerMounts()
-
-export const SANDBOX_AUTH_HOOKS = {
- sandbox: {
- onSandboxReady: [
- ...(AGENT_PROVIDER === 'pi'
- ? [
- {
- command:
- 'mkdir -p ~/.pi/agent && printf \'%s\' "$PI_AUTH_CONTENT" > ~/.pi/agent/auth.json',
- },
- ]
- : []),
- ],
- },
-}
-
-export const SANDBOX_BUILD_HOOKS = {
- sandbox: {
- onSandboxReady: [
- ...SANDBOX_AUTH_HOOKS.sandbox.onSandboxReady,
- { command: 'pnpm install && pnpm run build' },
- ],
- },
-}
-
-/**
- * @returns Mount entries for pnpm store, or empty if store path is unavailable.
- */
-function resolveDockerMounts (): { hostPath: string; readonly: boolean; sandboxPath: string }[] {
- const pnpmStore = resolvePnpmStorePath()
- if (pnpmStore != null && existsSync(pnpmStore)) {
- return [
- { hostPath: pnpmStore, readonly: true, sandboxPath: '/home/agent/.local/share/pnpm/store' },
- ]
- }
- return []
-}
-
-/**
- * @returns The pnpm store directory path, or undefined if pnpm is unavailable.
- */
-function resolvePnpmStorePath (): string | undefined {
- try {
- return execFileSync('pnpm', ['store', 'path'], { encoding: 'utf-8' }).trim()
- } catch {
- return undefined
- }
-}
-
-// ── GitHub ───────────────────────────────────────────────────────────────────
-
-export const GITHUB_MAX_ISSUES_FETCH = 50
-
-export const GITHUB_MAX_PRS_FETCH = 200
-
-export const MAX_SLUG_CHARS = 40
-
-export const MAX_TITLE_CHARS = 200
-
-// ── Validation ───────────────────────────────────────────────────────────────
-
-export const MAX_STDERR_CHARS = 500
-
-export const VALIDATION_COMMAND =
- 'pnpm -r format && pnpm -r typecheck && pnpm -r lint && pnpm -r build && pnpm -r test'
-
-export const VALIDATION_TIMEOUT_MS = 900_000
-
-// ── Deduplication ────────────────────────────────────────────────────────────
-
-export const CONTEXT_HASH_RADIUS = 3
-
-export const HASH_PREFIX_LENGTH = 16
+++ /dev/null
-import crypto from 'node:crypto'
-
-import type { LoopResult, TaskSpec } from './types.js'
-
-import { GIT_BASE_BRANCH, GIT_PUSH_TIMEOUT_MS, GIT_TIMEOUT_MS } from './constants.js'
-import { execFileAsync, toErrorMessage } from './utils.js'
-
-/**
- * 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, baseBranch = GIT_BASE_BRANCH): Promise<boolean> {
- try {
- await execFileAsync('git', ['fetch', 'origin', baseBranch], {
- cwd,
- timeout: GIT_TIMEOUT_MS,
- })
- await execFileAsync('git', ['rebase', `origin/${baseBranch}`], {
- cwd,
- timeout: GIT_TIMEOUT_MS,
- })
- return true
- } catch {
- try {
- await execFileAsync('git', ['rebase', '--abort'], { cwd })
- } catch {
- /* empty */
- }
- return false
- }
-}
-
-/**
- * Builds the PR title, body, and `gh pr create` argument list.
- * @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 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,
- baseBranch = GIT_BASE_BRANCH
-): { isDraft: boolean; prArgs: string[] } {
- const converged = loopResult.status === 'converged'
- const isDraft = !converged || !validationPassed
- const lastFindings = loopResult.roundHistory.at(-1)?.findings ?? []
- const outstandingNote =
- lastFindings.length > 0
- ? `\n\n${converged ? 'ℹ️ Known findings (not addressed):' : '⚠️ Outstanding findings:'}\n${lastFindings.map(f => `- [${f.severity}] ${f.file}: ${f.title}`).join('\n')}`
- : ''
- const validationNote = !validationPassed
- ? '\n\n⚠️ Validation did not pass. Manual review required.'
- : ''
- const rebaseNote = !rebaseSucceeded
- ? `\n\n⚠️ Rebase failed. Branch is not rebased onto ${baseBranch}.`
- : ''
-
- const validationCheck = validationPassed ? '- [x]' : '- [ ]'
- const labels = spec.labels ?? []
- const commitPrefix = labels.includes('enhancement')
- ? 'feat'
- : labels.includes('bug')
- ? 'fix'
- : 'chore'
- const cleanTitle = spec.title.replace(/^\[(?:FEATURE|BUG|FIX|CHORE)\]\s*/i, '')
- const prTitle = `${commitPrefix}: resolve #${spec.id} \u2014 ${cleanTitle}`
- const typeOfChange =
- commitPrefix === 'feat'
- ? 'New feature (non-breaking change that adds functionality)'
- : commitPrefix === 'fix'
- ? 'Bug fix (non-breaking change that fixes an issue)'
- : 'Refactoring (no functional changes)'
- const prBody = `## Description\n\nAutomated ${commitPrefix} for #${spec.id}: ${cleanTitle}\n\n## Type of Change\n\n- [x] ${typeOfChange}\n\n## Checklist\n\n${validationCheck} I have run validation suite\n- [x] My changes follow the existing code style\n\n## Related Issues\n\n${isDraft ? 'Relates to' : 'Fixes'} #${spec.id}${outstandingNote}${validationNote}${rebaseNote}`
-
- const prArgs = [
- 'pr',
- 'create',
- ...(isDraft ? ['--draft'] : []),
- '--head',
- spec.branch,
- '--base',
- baseBranch,
- '--title',
- prTitle,
- '--body',
- prBody,
- ...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 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 (
- spec: TaskSpec,
- cwd: string,
- rebaseSucceeded: boolean
-): Promise<boolean> {
- if (rebaseSucceeded) {
- try {
- await execFileAsync('git', ['push', '--force-with-lease', 'origin', 'HEAD'], {
- cwd,
- timeout: GIT_PUSH_TIMEOUT_MS,
- })
- return true
- } catch (pushErr: unknown) {
- const pushMsg = toErrorMessage(pushErr)
- try {
- const suffix = crypto.randomBytes(4).toString('hex')
- await execFileAsync(
- 'git',
- ['push', 'origin', `HEAD:refs/heads/rescue/${spec.branch}-${suffix}`],
- {
- cwd,
- timeout: GIT_PUSH_TIMEOUT_MS,
- }
- )
- console.warn(
- ` #${spec.id}: Push failed. Commits preserved at rescue/${spec.branch}-${suffix}`
- )
- } catch {
- console.error(
- ` #${spec.id}: Push failed and rescue failed. Commits will be lost on sandbox disposal: ${pushMsg}`
- )
- }
- return false
- }
- } else {
- try {
- await execFileAsync('git', ['push', '-u', 'origin', 'HEAD'], {
- cwd,
- timeout: GIT_PUSH_TIMEOUT_MS,
- })
- return true
- } catch (pushErr: unknown) {
- const pushMsg = toErrorMessage(pushErr)
- console.warn(` #${spec.id}: git push failed after rebase abort: ${pushMsg}`)
- return false
- }
- }
-}
+++ /dev/null
-import * as sandcastle from '@ai-hero/sandcastle'
-import { docker } from '@ai-hero/sandcastle/sandboxes/docker'
-
-import type { TaskSpec } from './types.js'
-
-import { ConcurrencyPool } from './concurrency-pool.js'
-import {
- AGENT_ITERATION_BUDGET,
- AGENT_MAX_CRITIC_ROUNDS,
- AGENT_TASK_TIMEOUT_MS,
- DOCKER_IMAGE,
- DOCKER_MOUNTS,
- MAX_PARALLEL,
- SANDBOX_BUILD_HOOKS,
-} from './constants.js'
-import { runRefinementLoop } from './refinement-loop.js'
-import { STRATEGY_BY_KEY, STRATEGY_REGISTRY } from './strategies/index.js'
-import { GithubIssueSource } from './task-source.js'
-
-const source = new GithubIssueSource({
- dockerImage: DOCKER_IMAGE,
- strategies: STRATEGY_REGISTRY,
-})
-
-let tasks: TaskSpec[]
-try {
- tasks = await source.discover()
-} catch (err) {
- console.error(err instanceof Error ? err.message : String(err))
- process.exitCode = 1
- process.exit()
-}
-
-if (tasks.length === 0) {
- console.log('No tasks to process.')
-} else {
- const pool = new ConcurrencyPool(MAX_PARALLEL)
-
- const settled = await Promise.allSettled(
- tasks.map(spec =>
- pool.run(async () => {
- const entry = STRATEGY_BY_KEY.get(spec.strategyKey)
- if (!entry) {
- throw new Error(
- `Task #${spec.id}: unknown strategy '${spec.strategyKey}' (not in registry).`
- )
- }
- const ac = new AbortController()
- const timer = setTimeout(() => {
- ac.abort(new Error(`Task #${spec.id} timed out after ${String(AGENT_TASK_TIMEOUT_MS)}ms`))
- }, AGENT_TASK_TIMEOUT_MS)
- timer.unref()
-
- try {
- await using sandbox = await sandcastle.createSandbox({
- branch: spec.branch,
- hooks: SANDBOX_BUILD_HOOKS,
- sandbox: docker({ imageName: DOCKER_IMAGE, mounts: [...DOCKER_MOUNTS] }),
- })
-
- const loopResult = await runRefinementLoop(spec, sandbox, entry.strategy, {
- iterationBudget: AGENT_ITERATION_BUDGET,
- maxRounds: AGENT_MAX_CRITIC_ROUNDS,
- postLoopValidationRetry: true,
- signal: ac.signal,
- })
-
- let workSuccess = false
- if (loopResult.totalCommits > 0) {
- const finalizeResult = await entry.strategy.finalize(spec, loopResult, sandbox)
- workSuccess = entry.strategy.isWorkComplete(finalizeResult)
- }
-
- return { spec, success: workSuccess }
- } finally {
- clearTimeout(timer)
- }
- })
- )
- )
-
- const workCompleted = settled.some(
- outcome => outcome.status === 'fulfilled' && outcome.value.success
- )
-
- for (const [i, outcome] of settled.entries()) {
- if (outcome.status === 'rejected') {
- const reason: unknown = outcome.reason
- const msg = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)
- console.error(` ✗ #${tasks[i]?.id ?? String(i)} failed: ${msg}`)
- }
- }
-
- console.log('\nAll done.')
-
- if (!workCompleted) {
- process.exitCode = 1
- }
-}
+++ /dev/null
-# Plan
-
-Read open GitHub issues and produce a parallelizable execution plan.
-
-## Inputs
-
-- `ISSUES_JSON` — JSON array of open issues, each entry `{ number, title, body, labels }`.
-
-{{ISSUES_JSON}}
-
-## Task
-
-1. Read `AGENTS.md` and `.serena/memories/project_overview` for repository context.
-2. For each issue, decide whether it is independently actionable: scope is clear and there is no blocking dependency on another open issue.
-3. Drop issues labelled `wontfix`, `duplicate`, or `question`, or blocked by another open issue. If every issue is blocked, keep only the highest-priority candidate.
-4. For each kept issue, derive:
- - `id`: the issue number as a string.
- - `slug`: a short kebab-case summary of the change matching `^[a-z0-9]+(?:-[a-z0-9]+)*$` (≤ 40 chars).
- - `title`: the issue title.
- - `issueType`: `bug-fix`, `feature`, or `refactor`.
- - `confidence`: `high` (clear scope), `medium` (some ambiguity), or `low` (unclear scope).
- - `rootCauseHypothesis`: a specific hypothesis (modules, patterns, behaviours) for the implementer to validate — not a restatement of the title.
- - `acceptanceCriteria`: 2–4 conditions verifiable by static inspection of the diff (code structure, logic, algorithms — not runtime behaviour).
-5. Do not implement anything; output only the plan.
-
-## Output
-
-```text
-<plan>{"issues":[{"id":"<number>","slug":"<kebab-slug>","title":"<title>","issueType":"bug-fix|feature|refactor","confidence":"high|medium|low","rootCauseHypothesis":"...","acceptanceCriteria":["..."]}]}</plan>
-```
-
-When no issue is actionable: `<plan>{"issues":[]}</plan>`.
-
-## Rules
-
-- Prefer single-file scope over cross-cutting refactors.
-
-## Done
-
-<promise>COMPLETE</promise>
+++ /dev/null
-import type { SandboxRunResult } from '@ai-hero/sandcastle'
-
-import crypto from 'node:crypto'
-import { readFile, realpath } from 'node:fs/promises'
-import { join, sep } from 'node:path'
-
-import type {
- Finding,
- LoopContext,
- LoopResult,
- LoopStatus,
- LoopStrategy,
- RoundSnapshot,
- SandboxInstance,
- TaskSpec,
-} from './types.js'
-
-import {
- AGENT_ACTOR_EFFORT,
- AGENT_ACTOR_MODEL,
- AGENT_CRITIC_EFFORT,
- AGENT_CRITIC_MODEL,
- AGENT_IDLE_TIMEOUT_S,
- AGENT_ITERATION_BUDGET,
- AGENT_MAX_CRITIC_ROUNDS,
- COMPLETION_SIGNAL,
- CONTEXT_HASH_RADIUS,
- GIT_BASE_BRANCH,
- HASH_PREFIX_LENGTH,
-} from './constants.js'
-import { parseFindingsSafe } from './types.js'
-import { agentProvider, execFileAsync } from './utils.js'
-import { runValidation } from './validation.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
- /** 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
-}
-
-/** Result of a convergence check. */
-interface ConvergenceResult {
- /** Best SHA to restore (null = no update). */
- bestSha: null | string
- /** Updated last findings. */
- lastFindings: Finding[]
- /** New loop status. */
- status: LoopStatus
-}
-
-/**
- * Input descriptor for hashing a window of source lines around a finding.
- */
-interface HashInput {
- /** Working directory (worktree path) for resolving the file. */
- readonly cwd: string
- /** Relative file path of the finding. */
- readonly file: string
- /** Line number of the finding (1-indexed). */
- readonly line: number
-}
-
-/**
- * Context passed to the quality ratchet check.
- * Groups the per-round identifiers needed for regression detection and rollback.
- */
-interface RatchetContext {
- /** SHA of HEAD before the actor ran (used for rollback). */
- readonly beforeSha: string
- /** Working directory for git operations. */
- readonly cwd: string
- /** Current round number (1-indexed). */
- readonly round: number
- /** The task specification. */
- readonly spec: TaskSpec
-}
-
-/** 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. */
- maxRounds: number
-}
-
-/** Result of a single implement↔critic round. */
-interface RoundResult {
- /** SHA of HEAD before the actor ran. */
- beforeSha: string
- /** Number of commits made by the actor. */
- commits: number
- /** Parsed findings from the critic, or null on critic failure. */
- findings: Finding[] | null
-}
-
-/**
- * Runs the implement↔critic refinement loop for a given task.
- * @param spec - The task specification.
- * @param sandbox - The sandcastle sandbox instance.
- * @param strategy - Strategy config for prompt/arg customization.
- * @param opts - Optional configuration for rounds, budget, and callbacks.
- * @returns The loop result with status, commits, findings, and rounds completed.
- */
-export async function runRefinementLoop (
- spec: TaskSpec,
- sandbox: SandboxInstance,
- strategy: LoopStrategy,
- opts?: RefinementLoopOptions
-): Promise<LoopResult> {
- const { baseBranch, budget, maxRounds } = 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>()
- const roundHistory: RoundSnapshot[] = []
- let failureReason: string | undefined
- let lastFindings: Finding[] = []
- let status: LoopStatus = 'exhausted'
- let totalCommits = 0
- let roundsCompleted = 0
- let previousFindingsCount = Infinity
- let bestSha: null | string = null
- let bestFindingsCount = Infinity
-
- for (let round = 1; round <= maxRounds; round++) {
- signal?.throwIfAborted()
- roundsCompleted = round
-
- console.log(
- ` #${spec.id} round ${String(round)}/${String(maxRounds)} (budget: ${String(budget)})`
- )
-
- const result = await executeRound(ctx, round, budget, lastFindings)
-
- roundHistory.push(buildRoundSnapshot(result, round))
-
- const earlyExit = checkEarlyExit(spec, round, result, totalCommits)
- if (earlyExit !== null) {
- totalCommits = earlyExit.totalCommits
- status = earlyExit.status
- if (earlyExit.status === 'failed') {
- failureReason = result.commits === 0 ? 'actor_error' : 'critic_parse_failed'
- }
- break
- }
-
- if (result.findings === null) break
- const findings: Finding[] = result.findings
-
- if (result.commits > 0 && (await validate(sandbox.worktreePath, spec))) {
- totalCommits += result.commits
- status = 'converged'
- break
- }
-
- const cwd = sandbox.worktreePath
- const newFindings = await deduplicateFindings(findings, cwd, seenKeys)
-
- console.log(
- ` #${spec.id}: ${String(findings.length)} findings, ${String(newFindings.length)} new`
- )
-
- const nonLowFindings = findings.filter(f => f.confidence !== 'LOW')
- if (
- await checkQualityRatchet(
- { beforeSha: result.beforeSha, cwd, round, spec },
- nonLowFindings.length,
- previousFindingsCount
- )
- ) {
- failureReason = 'quality_regression'
- status = 'exhausted'
- break
- }
-
- if (newFindings.length < bestFindingsCount) {
- bestFindingsCount = newFindings.length
- bestSha = await captureHeadSha(cwd)
- }
-
- totalCommits += result.commits
- previousFindingsCount = nonLowFindings.length
-
- if (strategy.shouldConverge?.(findings, round, totalCommits)) {
- lastFindings = findings
- status = 'converged'
- break
- }
-
- const convergenceResult = await checkConvergence(cwd, findings, newFindings, nonLowFindings)
- if (convergenceResult !== null) {
- lastFindings = convergenceResult.lastFindings
- status = convergenceResult.status
- bestSha = convergenceResult.bestSha
- break
- }
-
- lastFindings = newFindings
- }
-
- // Post-loop validation retry (if enabled)
- if (opts?.postLoopValidationRetry && totalCommits > 0 && status !== 'converged') {
- signal?.throwIfAborted()
- const validationPassed = await validate(sandbox.worktreePath, spec)
- if (validationPassed) {
- status = 'converged'
- } else if (roundsCompleted < maxRounds) {
- const result = await executeRound(ctx, roundsCompleted + 1, budget, lastFindings)
- roundHistory.push(buildRoundSnapshot(result, roundsCompleted + 1))
- if (result.commits > 0) {
- totalCommits += result.commits
- if (await validate(sandbox.worktreePath, spec)) {
- status = 'converged'
- }
- }
- }
- }
-
- if (shouldResetToBest(status, bestSha)) {
- totalCommits = await resetToBestState(sandbox.worktreePath, bestSha, totalCommits, baseBranch)
- }
-
- return {
- baseBranch,
- failureReason,
- roundHistory,
- roundsCompleted,
- status,
- totalCommits,
- }
-}
-
-/**
- * @param result - The round execution result.
- * @param round - 1-indexed round number.
- * @returns A snapshot for the round history.
- */
-function buildRoundSnapshot (result: RoundResult, round: number): RoundSnapshot {
- return {
- commits: result.commits,
- findings: result.findings ?? [],
- round,
- status:
- result.findings === null
- ? 'critic_errored'
- : result.findings.length > 0
- ? 'has_findings'
- : 'no_findings',
- }
-}
-
-/**
- * Captures the current HEAD SHA, returning null on failure.
- * @param cwd - Working directory for git operations.
- * @returns The HEAD SHA or null.
- */
-async function captureHeadSha (cwd: string): Promise<null | string> {
- try {
- const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd })
- return stdout.trim()
- } catch {
- return null
- }
-}
-
-/**
- * Checks whether the current round converged (no new findings).
- * @param cwd - Working directory for git operations.
- * @param allFindings - All findings from the critic.
- * @param newFindings - Deduplicated new findings.
- * @param nonLowFindings - Non-LOW-confidence findings.
- * @returns A ConvergenceResult if the loop should break, or null to continue.
- */
-async function checkConvergence (
- cwd: string,
- allFindings: Finding[],
- newFindings: Finding[],
- nonLowFindings: Finding[]
-): Promise<ConvergenceResult | null> {
- if (newFindings.length !== 0) return null
-
- // Severity-weighted convergence (OpenHands pattern):
- // Don't converge if CRITICAL/HIGH findings persist, even if already seen
- const criticalPersistent = allFindings.filter(
- f => (f.severity === 'CRITICAL' || f.severity === 'HIGH') && f.confidence !== 'LOW'
- )
- if (criticalPersistent.length > 0) {
- // Capture current HEAD so post-loop reset is a no-op (code matches findings)
- return {
- bestSha: await captureHeadSha(cwd),
- lastFindings: criticalPersistent,
- status: 'exhausted',
- }
- }
-
- return {
- bestSha: null,
- lastFindings: nonLowFindings.length > 0 ? nonLowFindings : [],
- status: 'converged',
- }
-}
-
-/**
- * Checks whether the round result warrants an early exit from the loop.
- * @param spec - The task specification.
- * @param round - Current round number.
- * @param result - The round result.
- * @param totalCommits - Running total of commits before this round.
- * @returns An object with updated status and totalCommits if early exit, or null to continue.
- */
-function checkEarlyExit (
- spec: TaskSpec,
- round: number,
- result: RoundResult,
- totalCommits: number
-): null | { status: LoopStatus; totalCommits: number } {
- if (round === 1 && result.commits === 0) {
- console.warn(` #${spec.id}: 0 commits on round 1. Skipping.`)
- return { status: 'skipped', totalCommits }
- }
- if (result.findings === null) {
- console.warn(` #${spec.id}: Critic failed twice. Breaking (non-converged).`)
- return { status: 'failed', totalCommits: totalCommits + result.commits }
- }
- if (round > 1 && result.commits === 0) {
- return { status: 'exhausted', totalCommits }
- }
- return null
-}
-
-/**
- * @param ctx - Ratchet context containing spec, round, beforeSha, and cwd.
- * @param findingsCount - Number of non-LOW findings this round.
- * @param previousCount - Number of non-LOW findings from the previous round.
- * @returns True if a regression was detected and rollback performed.
- */
-async function checkQualityRatchet (
- ctx: RatchetContext,
- findingsCount: number,
- previousCount: number
-): Promise<boolean> {
- const { beforeSha, cwd, round, spec } = ctx
- if (round <= 2 || findingsCount <= previousCount) {
- return false
- }
-
- // Validate SHA format before passing to execFileAsync
- if (!/^[0-9a-f]{40}$/.test(beforeSha)) {
- console.warn(` #${spec.id}: Invalid SHA for rollback, skipping reset.`)
- return true
- }
-
- try {
- await execFileAsync('git', ['reset', '--hard', beforeSha], { cwd })
- console.warn(
- ` #${spec.id} R${String(round)}: Regression detected (${String(previousCount)} → ${String(findingsCount)}). Rolled back.`
- )
- } catch {
- console.warn(` #${spec.id}: Failed to reset to ${beforeSha} after regression.`)
- }
-
- return true
-}
-
-/**
- * Computes a deduplication key for a finding using a context hash of surrounding lines.
- * @param f - Finding to compute a key for.
- * @param cwd - Working directory (worktree path) for reading file context.
- * @param fileCache - Optional cache of file contents keyed by resolved path.
- * @returns Composite dedup key.
- */
-async function computeFindingKey (
- f: Finding,
- cwd: string,
- fileCache?: Map<string, string>
-): Promise<string> {
- if (!f.file || f.line == null) {
- const normalizedTitle = f.title
- .toLowerCase()
- .replace(/[^\w\s]/g, '')
- .replace(/\s+/g, ' ')
- .trim()
- const titleHash = crypto
- .createHash('sha256')
- .update(normalizedTitle)
- .digest('hex')
- .slice(0, HASH_PREFIX_LENGTH)
- return `${f.file || 'global'}::${f.category}::${titleHash}`
- }
- const contextHash = await hashContextLines(
- { cwd, file: f.file, line: f.line },
- CONTEXT_HASH_RADIUS,
- fileCache
- )
- return `${f.file}::${f.category}::${contextHash}`
-}
-
-/**
- * Filters findings by confidence and deduplicates against previously seen keys.
- * @param findings - Raw findings from the critic.
- * @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[],
- 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)))
- const newFindings = findings.filter((f, i) => {
- const key = keys[i]
- return f.confidence !== 'LOW' && !seenKeys.has(key)
- })
- for (const f of newFindings) {
- const idx = findings.indexOf(f)
- const key = keys[idx]
- seenKeys.add(key)
- }
- return newFindings
-}
-
-/**
- * Executes a single implement↔critic round.
- * @param ctx - Loop context containing spec, sandbox, strategy, baseBranch, and signal.
- * @param round - Current round number (1-indexed).
- * @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 (
- ctx: LoopContext,
- round: number,
- budget: number,
- lastFindings: Finding[]
-): Promise<RoundResult> {
- 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'], {
- cwd: sandbox.worktreePath,
- })
- beforeSha = stdout.trim()
- } catch {
- console.warn(` #${spec.id}: Failed to capture HEAD SHA before round ${String(round)}.`)
- }
-
- // Actor
- let actorResult: SandboxRunResult
- try {
- actorResult = await sandbox.run({
- agent: agentProvider(
- strategy.actorModel ?? AGENT_ACTOR_MODEL,
- strategy.actorEffort ?? AGENT_ACTOR_EFFORT
- ),
- completionSignal: COMPLETION_SIGNAL,
- idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
- maxIterations: budget,
- name: `Actor #${spec.id} R${String(round)}`,
- promptArgs: strategy.buildActorArgs(spec, lastFindings),
- promptFile: strategy.actorPromptFile,
- signal,
- })
- } catch (err: unknown) {
- if (signal?.aborted === true) {
- throw err
- }
- const msg = err instanceof Error ? (err.stack ?? err.message) : String(err)
- console.error(` #${spec.id} R${String(round)}: Actor threw: ${msg}`)
- return { beforeSha, commits: 0, findings: null }
- }
-
- // Critic
- const nonce = crypto.randomBytes(4).toString('hex')
- let findings: Finding[] | null
- try {
- findings = await runCritic(ctx, round, nonce)
- } catch (err: unknown) {
- if (signal?.aborted === true) {
- throw err
- }
- const msg = err instanceof Error ? err.message : String(err)
- console.error(` #${spec.id} R${String(round)}: Critic threw: ${msg}`)
- findings = null
- }
-
- return { beforeSha, commits: actorResult.commits.length, findings }
-}
-
-/**
- * Hashes a window of lines around the finding for dedup stability.
- * @param input - Hash input containing cwd, file, and line.
- * @param radius - Number of lines above/below to include in the context window.
- * @param fileCache - Optional cache of file contents keyed by resolved path.
- * @returns Truncated SHA-256 hex digest.
- */
-async function hashContextLines (
- input: HashInput,
- radius: number,
- fileCache?: Map<string, string>
-): Promise<string> {
- const { cwd, file, line } = input
- try {
- const fullPath = await realpath(join(cwd, file))
- if (!fullPath.startsWith((await realpath(cwd)) + sep)) {
- throw new Error('Path traversal')
- }
- let raw: string
- const cached = fileCache?.get(fullPath)
- if (cached !== undefined) {
- raw = cached
- } else {
- raw = await readFile(fullPath, 'utf-8')
- if (fileCache) fileCache.set(fullPath, raw)
- }
- const lines = raw.split('\n')
- const idx = Math.min(Math.max(0, line - 1), lines.length - 1)
- const start = Math.max(0, idx - radius)
- const end = Math.min(lines.length - 1, idx + radius)
- const window = lines.slice(start, end + 1).join('\n')
- const normalized = window.replace(/\s+/g, ' ').trim()
- return crypto
- .createHash('sha256')
- .update(`${file}:${String(line)}:${normalized}`)
- .digest('hex')
- .slice(0, HASH_PREFIX_LENGTH)
- } catch {
- console.debug(` hashContextLines: fallback for ${file}:${String(line)}`)
- return crypto
- .createHash('sha256')
- .update(`${file}:${String(line)}:fallback`)
- .digest('hex')
- .slice(0, HASH_PREFIX_LENGTH)
- }
-}
-
-/**
- * Parses findings from agent stdout using nonce-tagged delimiters.
- * @param stdout - Agent stdout to parse findings from.
- * @param nonce - Unique tag identifier for this run.
- * @returns Parsed findings array or null on parse failure.
- */
-function parseFindings (stdout: string, nonce: string): Finding[] | null {
- if (!/^[0-9a-f]+$/.test(nonce)) return null
- const tagPattern = new RegExp(`<findings-${nonce}>([\\s\\S]*?)<\\/findings-${nonce}>`, 'g')
- const matches = [...stdout.matchAll(tagPattern)]
- if (matches.length === 0) return null
- // Find last non-trivial match
- for (let i = matches.length - 1; i >= 0; i--) {
- const raw = matches[i]?.[1]?.trim() ?? ''
- if (raw.length < 2) continue
- const cleaned = raw.replace(/^```(?:json)?\s*\n?/g, '').replace(/\n?```\s*$/g, '')
- try {
- return parseFindingsSafe(JSON.parse(cleaned))
- } catch {
- continue
- }
- }
- return null
-}
-
-/**
- * Resets the worktree to the best intermediate state and recounts commits.
- * @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: null | string,
- currentCommits: number,
- baseBranch: string
-): Promise<number> {
- if (bestSha === null) return currentCommits
- 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', `${baseBranch}..HEAD`], {
- cwd,
- })
- return parseInt(stdout.trim(), 10) || 0
- } catch {
- return currentCommits
- }
-}
-
-/**
- * Resolves loop options, applying defaults for missing values.
- * @param opts - Optional loop options.
- * @returns Resolved options with all fields populated.
- */
-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,
- }
-}
-
-/**
- * Runs the critic agent, retrying once on parse failure.
- * @param ctx - Loop context containing spec, sandbox, strategy, baseBranch, and signal.
- * @param round - Current round number.
- * @param nonce - Unique nonce for parsing.
- * @returns Parsed findings or null if both attempts failed.
- */
-async function runCritic (
- ctx: LoopContext,
- round: number,
- nonce: string
-): Promise<Finding[] | null> {
- const { baseBranch, sandbox, signal, spec, strategy } = ctx
-
- let critic = await sandbox.run({
- agent: agentProvider(
- strategy.criticModel ?? AGENT_CRITIC_MODEL,
- strategy.criticEffort ?? AGENT_CRITIC_EFFORT
- ),
- completionSignal: COMPLETION_SIGNAL,
- idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
- maxIterations: 1,
- name: `Critic #${spec.id} R${String(round)}`,
- promptArgs: { ...strategy.buildCriticArgs(spec, baseBranch), NONCE: nonce },
- promptFile: strategy.criticPromptFile,
- signal,
- })
-
- let findings = parseFindings(critic.stdout, nonce)
-
- if (findings === null) {
- console.warn(` #${spec.id}: Critic parse failed. Retrying.`)
- critic = await sandbox.run({
- agent: agentProvider(
- strategy.criticModel ?? AGENT_CRITIC_MODEL,
- strategy.criticEffort ?? AGENT_CRITIC_EFFORT
- ),
- completionSignal: COMPLETION_SIGNAL,
- idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
- maxIterations: 1,
- name: `Critic #${spec.id} R${String(round)} retry`,
- promptArgs: { ...strategy.buildCriticArgs(spec, baseBranch), NONCE: nonce },
- promptFile: strategy.criticPromptFile,
- signal,
- })
- findings = parseFindings(critic.stdout, nonce)
- }
-
- return findings
-}
-
-/**
- * Returns true if the best-state reset should be applied after the loop.
- * @param status - Final loop status.
- * @param bestSha - Best intermediate SHA (null if none captured).
- * @returns True if reset should be applied.
- */
-function shouldResetToBest (status: LoopStatus, bestSha: null | string): boolean {
- return status !== 'converged' && bestSha !== null && /^[0-9a-f]{40}$/.test(bestSha)
-}
+++ /dev/null
-# Actor
-
-Implement issue **#{{ISSUE_NUMBER}}** ("{{ISSUE_TITLE}}") on branch `{{BRANCH}}`, or address review findings if present.
-
-## Inputs
-
-- `ISSUE_NUMBER` — GitHub issue number.
-- `ISSUE_TITLE` — issue title.
-- `BRANCH` — working branch, already checked out.
-- `ISSUE_BODY` — sanitised issue body.
-- `PLAN_CONTEXT` — optional planner analysis (hypothesis, acceptance criteria); empty when absent.
-- `FINDINGS` — optional JSON array of critic findings from the previous round; empty on the first round.
-
-{{ISSUE_BODY}}
-
-{{PLAN_CONTEXT}}
-
-{{FINDINGS}}
-
-## Task
-
-1. Read `AGENTS.md`, `CONTRIBUTING.md`, `.serena/memories/code_style_conventions`, and `.serena/memories/task_completion_checklist`. Explore files surrounding the issue and similar patterns in the repo.
-2. If `FINDINGS` is non-empty, cross-validate each finding against the code; fix the ones you agree with, ignore the rest.
-3. Otherwise implement the issue end-to-end, including matching tests using `node:test` + `node:assert`.
-4. Before every commit, run the full quality-gate chain in every affected sub-project. Root-level chain:
-
- ```bash
- pnpm format && pnpm typecheck && pnpm lint && pnpm build && pnpm test
- ```
-
- For changes inside `ui/web` or `ui/cli`, run the same chain in that directory (substitute `pnpm test:coverage` for `pnpm test` in `ui/web`).
-
-5. Commit one logical change at a time using Conventional Commits (`fix:`, `feat:`, `refactor:`, `chore:`).
-6. Push the branch:
-
- ```bash
- git push -u origin {{BRANCH}}
- ```
-
-## Output
-
-Commits on `{{BRANCH}}` pushed to `origin`. No structured stdout payload.
-
-## Rules
-
-- Strict TypeScript: no `any`, no `@ts-ignore`, no non-null `!`; use the existing typed errors (`BaseError`, `OCPPError`).
-- Do not modify unrelated files; do not bump version numbers.
-- Push before signaling completion; HEAD must have zero type errors and zero test failures.
-
-## Done
-
-<promise>COMPLETE</promise>
+++ /dev/null
-# Critic
-
-Review the diff on `{{BRANCH}}` against `{{BASE_BRANCH}}` and emit structured findings.
-
-## Inputs
-
-- `BRANCH` — branch under review.
-- `BASE_BRANCH` — branch to diff against.
-- `NONCE` — unique tag id used to delimit the findings payload.
-- `ACCEPTANCE_CRITERIA` — numbered acceptance criteria from the planner; empty when absent.
-
-{{ACCEPTANCE_CRITERIA}}
-
-## Task
-
-1. Read `AGENTS.md`, `CONTRIBUTING.md`, and `.serena/memories/code_style_conventions`.
-2. Run `git diff {{BASE_BRANCH}}...{{BRANCH}}` and inspect every changed line.
-3. For each acceptance criterion (if any), decide from the diff whether it is satisfied; report a `HIGH` finding for any unmet criterion, judged on observable diff content, not implementation approach.
-4. Surface other defects in the changed code: logic errors, missing edge cases, security issues, type-safety violations, test gaps.
-
-## Output
-
-```text
-<findings-{{NONCE}}>[
- {
- "file": "path/to/file.ts",
- "line": 42,
- "title": "short description of the issue",
- "severity": "CRITICAL|HIGH|MEDIUM|LOW",
- "category": "security|logic|performance|architecture|style",
- "confidence": "HIGH|MEDIUM|LOW",
- "description": "why this is a problem",
- "suggestion": "how to fix it"
- }
-]</findings-{{NONCE}}>
-```
-
-When nothing is wrong: `<findings-{{NONCE}}>[]</findings-{{NONCE}}>`.
-
-## Rules
-
-- Report at most 5 findings, `HIGH` or `CRITICAL` only; include `LOW`/`MEDIUM` only when no higher-severity issue exists. If more than 5 `HIGH`/`CRITICAL` issues exist, report the top 5 and add a summary line in the last finding's `description`.
-- Confidence: `HIGH` after reading the relevant code; `MEDIUM` for pattern-based detection; `LOW` for style preference or uncertainty.
-- Only flag changed code; ignore pre-existing issues. Do not flag formatting.
-- Do not modify, commit, or push files.
-- Do not flag the following intentional design decisions: mid-loop validation convergence bypassing the critic (ARCS pattern); cooperative cancellation via `idleTimeoutSeconds` + `completionSignal`; line-number-aware dedup hash.
-
-## Done
-
-<promise>COMPLETE</promise>
+++ /dev/null
-import type { FinalizationConfig, LoopStrategy, TaskSpec } from '../../types.js'
-
-import { GIT_TIMEOUT_MS } from '../../constants.js'
-import { attemptRebase, buildPrArgs, pushBranch } from '../../finalizer.js'
-import { execFileAsync, toErrorMessage } from '../../utils.js'
-import { runValidation } from '../../validation.js'
-
-/**
- * @param spec - Task specification with optional plan metadata.
- * @returns Formatted plan context string for the actor prompt, or empty if no context.
- */
-function buildPlanContext (spec: TaskSpec): string {
- const parts: string[] = []
- const includeHypothesis = spec.confidence === 'high' || spec.confidence === undefined
-
- if (includeHypothesis && spec.rootCauseHypothesis) {
- parts.push(`HYPOTHESIS (may be wrong — verify independently): ${spec.rootCauseHypothesis}`)
- }
- if (spec.acceptanceCriteria && spec.acceptanceCriteria.length > 0) {
- parts.push(
- `Acceptance criteria:\n${spec.acceptanceCriteria.map((c, i) => `${String(i + 1)}. ${c}`).join('\n')}`
- )
- }
- return parts.join('\n\n')
-}
-
-export const implementStrategy: FinalizationConfig & LoopStrategy = {
- actorPromptFile: './.sandcastle/strategies/implement/actor-prompt.md',
-
- buildActorArgs: (spec, findings) => ({
- BRANCH: spec.branch,
- FINDINGS: findings.length > 0 ? JSON.stringify(findings, null, 2) : '',
- ISSUE_BODY: spec.body,
- ISSUE_NUMBER: spec.id,
- ISSUE_TITLE: spec.title,
- PLAN_CONTEXT: buildPlanContext(spec),
- }),
-
- buildCriticArgs: (spec, baseBranch) => ({
- ACCEPTANCE_CRITERIA:
- spec.acceptanceCriteria?.map((c, i) => `${String(i + 1)}. ${c}`).join('\n') ?? '',
- BASE_BRANCH: baseBranch,
- BRANCH: spec.branch,
- }),
-
- criticPromptFile: './.sandcastle/strategies/implement/critic-prompt.md',
-
- finalize: async (spec, loopResult, sandbox) => {
- const cwd = sandbox.worktreePath
- let validationPassed = await runValidation(cwd, spec)
-
- const rebaseSucceeded = await attemptRebase(cwd, loopResult.baseBranch)
- if (rebaseSucceeded && validationPassed) {
- if (!(await runValidation(cwd, spec))) {
- validationPassed = false
- }
- }
-
- const pushSucceeded = await pushBranch(spec, cwd, rebaseSucceeded)
- if (!pushSucceeded) {
- console.error(` #${spec.id}: Push failed; cannot create PR without remote branch.`)
- return { success: false }
- }
-
- const { isDraft, prArgs } = buildPrArgs(
- spec,
- loopResult,
- validationPassed,
- rebaseSucceeded,
- loopResult.baseBranch
- )
-
- let prCreated = false
- try {
- await execFileAsync('gh', prArgs, {
- cwd,
- maxBuffer: 8 * 1024 * 1024,
- timeout: GIT_TIMEOUT_MS,
- })
- console.log(` #${spec.id}: PR created${isDraft ? ' (draft)' : ''}.`)
- prCreated = true
- } catch (err: unknown) {
- console.error(` #${spec.id}: PR creation failed: ${toErrorMessage(err)}`)
- }
-
- return { success: prCreated }
- },
-
- isWorkComplete: result => result.success,
-}
+++ /dev/null
-import type { FinalizationConfig, LoopStrategy } from '../types.js'
-
-import { implementStrategy } from './implement/strategy.js'
-
-/**
- * A registered strategy: the canonical declaration that maps a key to its
- * actor/critic loop and finalization configuration. Labels and branch prefixes
- * are derived from the key to keep a single source of truth.
- */
-export interface StrategyEntry {
- /**
- * Additional XML-like tag names (besides `key`) that this strategy uses
- * inside prompts. They are stripped from issue text to harden against
- * prompt injection. The strategy `key` is always added implicitly.
- */
- readonly controlTags?: readonly string[]
- /** Strategy key (kebab-case). Used in TaskSpec.strategyKey and to derive label/branchPrefix. */
- readonly key: string
- /** The actor/critic loop and finalization configuration. */
- readonly strategy: FinalizationConfig & LoopStrategy
-}
-
-/**
- * Canonical registry of strategies. Order matters: when an issue carries
- * several `sandcastle-*` labels, the first matching entry wins.
- *
- * Adding a new strategy is one line + one `strategies/<key>/` sub-directory.
- */
-export const STRATEGY_REGISTRY: readonly StrategyEntry[] = [
- { controlTags: ['review'], key: 'implement', strategy: implementStrategy },
-] as const
-
-/**
- * Strict kebab-case: lowercase letters/digits, hyphen-separated, must start
- * with a letter. Constrains `key` because it flows verbatim into the GitHub
- * label `sandcastle-<key>` and the git branch prefix `agent/<key>`.
- */
-const STRATEGY_KEY_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
-
-/**
- * XML-name-safe subset for `controlTags`: must start with a letter, followed
- * by letters, digits, `_` or `-`. Looser than {@link STRATEGY_KEY_PATTERN}
- * to accept agent vocabulary such as `tool_call` while still rejecting
- * empty strings and angle-bracket characters that would corrupt the
- * sanitizer regex.
- */
-const CONTROL_TAG_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/
-
-/** Indexed view: strategy key → entry. Throws on duplicate keys at load time. */
-export const STRATEGY_BY_KEY: ReadonlyMap<string, StrategyEntry> = indexByKey(STRATEGY_REGISTRY)
-
-/**
- * Derives the git branch prefix used for tasks of a strategy.
- * @param key - Strategy key.
- * @returns Branch prefix of the form `agent/<key>`.
- */
-export function branchPrefixOf (key: string): string {
- return `agent/${key}`
-}
-
-/**
- * Derives the GitHub issue label that triggers a strategy.
- * @param key - Strategy key.
- * @returns Label of the form `sandcastle-<key>`.
- */
-export function labelOf (key: string): string {
- return `sandcastle-${key}`
-}
-
-/**
- * Builds the strategy-key index, validating each entry and throwing on
- * malformed, duplicate, or prefix-overlapping keys so registry mistakes
- * (typos, wrong casing, empty tag, key colliding with another key's branch
- * prefix) fail loudly at module load instead of silently producing
- * undiscoverable labels, invalid git branches, empty regex alternatives in
- * the prompt sanitizer, or ambiguous open-PR dedup matches.
- * @param entries - Canonical registry entries.
- * @returns Map from key to entry.
- * @throws {Error} when an entry has an invalid key, an invalid controlTag,
- * a duplicate key, or a key whose branch prefix overlaps a previously
- * registered one.
- */
-function indexByKey (entries: readonly StrategyEntry[]): ReadonlyMap<string, StrategyEntry> {
- const map = new Map<string, StrategyEntry>()
- for (const entry of entries) {
- if (!STRATEGY_KEY_PATTERN.test(entry.key)) {
- throw new Error(
- `Invalid strategy key '${entry.key}' in STRATEGY_REGISTRY: ` +
- `must match ${STRATEGY_KEY_PATTERN.source} (kebab-case).`
- )
- }
- for (const tag of entry.controlTags ?? []) {
- if (!CONTROL_TAG_PATTERN.test(tag)) {
- throw new Error(
- `Invalid controlTag '${tag}' for strategy '${entry.key}' in STRATEGY_REGISTRY: ` +
- `must match ${CONTROL_TAG_PATTERN.source}.`
- )
- }
- }
- if (map.has(entry.key)) {
- throw new Error(`Duplicate strategy key in STRATEGY_REGISTRY: '${entry.key}'.`)
- }
- for (const existing of map.keys()) {
- if (entry.key.startsWith(`${existing}-`) || existing.startsWith(`${entry.key}-`)) {
- throw new Error(
- `Strategy key '${entry.key}' overlaps with '${existing}' in STRATEGY_REGISTRY: ` +
- `branch '${branchPrefixOf(existing)}-<n>-…' would also match the regex derived ` +
- `from '${branchPrefixOf(entry.key)}-' (or vice versa), making open-PR dedup ambiguous.`
- )
- }
- }
- map.set(entry.key, entry)
- }
- return map
-}
+++ /dev/null
-import type { RunResult } from '@ai-hero/sandcastle'
-
-import * as sandcastle from '@ai-hero/sandcastle'
-import { docker } from '@ai-hero/sandcastle/sandboxes/docker'
-import { z } from 'zod'
-
-import type { TaskSpec } from './types.js'
-
-import {
- AGENT_IDLE_TIMEOUT_S,
- AGENT_PLANNER_EFFORT,
- AGENT_PLANNER_MODEL,
- AGENT_TASK_TIMEOUT_MS,
- COMPLETION_SIGNAL,
- DOCKER_MOUNTS,
- GIT_TIMEOUT_MS,
- GITHUB_MAX_ISSUES_FETCH,
- GITHUB_MAX_PRS_FETCH,
- MAX_SLUG_CHARS,
- MAX_TITLE_CHARS,
- SANDBOX_AUTH_HOOKS,
-} from './constants.js'
-import { branchPrefixOf, labelOf, type StrategyEntry } from './strategies/index.js'
-import { agentProvider, execFileAsync, toErrorMessage } from './utils.js'
-
-const RawIssueSchema = z.object({
- body: z
- .string()
- .nullable()
- .transform(b => b ?? ''),
- labels: z.array(z.object({ name: z.string() })),
- number: z.number(),
- title: z.string(),
-})
-const RawIssuesSchema = z.array(RawIssueSchema)
-
-/** Configuration for the GitHub issue task source. */
-export interface GithubIssueSourceConfig {
- /** Docker image name for the sandbox. */
- dockerImage: string
- /** Maximum planner retries. */
- maxRetries?: number
- /** Strategies to run, in priority order (first matching label wins). */
- strategies: readonly StrategyEntry[]
-}
-
-/** Interface for task discovery sources. */
-export interface TaskSource {
- /** Discovers tasks to work on. */
- discover(): Promise<TaskSpec[]>
-}
-
-/** A sanitized issue resolved to the strategy that will handle it. */
-interface ResolvedIssue {
- body: string
- branchPrefix: string
- labels: string[]
- number: number
- strategyKey: string
- title: string
-}
-
-/**
- * Task source that discovers work from GitHub issues via planner agent.
- * Each strategy in the registry is associated with the GitHub label
- * `sandcastle-<key>` and produces tasks on branches `agent/<key>-<n>-<slug>`.
- */
-export class GithubIssueSource implements TaskSource {
- private readonly branchPatterns: readonly RegExp[]
- private readonly controlTagPattern: RegExp
- private readonly dockerImage: string
- private readonly maxRetries: number
- private readonly strategies: readonly StrategyEntry[]
-
- /**
- * @param config - Configuration for the GitHub issue source.
- */
- constructor (config: GithubIssueSourceConfig) {
- if (config.strategies.length === 0) {
- throw new Error('GithubIssueSource requires at least one strategy.')
- }
- this.dockerImage = config.dockerImage
- this.maxRetries = config.maxRetries ?? 5
- this.strategies = config.strategies
-
- this.branchPatterns = this.strategies.map(
- entry => new RegExp(`^${escapeRegex(branchPrefixOf(entry.key))}-(\\d+)-${SLUG_PATTERN_BODY}$`)
- )
- this.controlTagPattern = buildControlTagPattern(this.strategies)
- }
-
- /**
- * Discovers tasks by fetching GitHub issues per strategy, running the planner,
- * and validating the plan.
- * @returns Array of task specifications to implement.
- */
- async discover (): Promise<TaskSpec[]> {
- const issues = await this.fetchAndSanitizeIssues()
-
- if (issues.length === 0) {
- console.log(
- 'No issues with labels [%s]. Exiting.',
- this.strategies.map(s => labelOf(s.key)).join(', ')
- )
- return []
- }
-
- const coveredIssues = await this.fetchIssuesWithOpenPRs()
- const actionableIssues = issues.filter(issue => !coveredIssues.has(issue.number))
-
- if (actionableIssues.length === 0) {
- console.log(
- 'All issues with labels [%s] already have open PRs. Exiting.',
- this.strategies.map(s => labelOf(s.key)).join(', ')
- )
- return []
- }
-
- for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
- console.log(`\n=== Planner attempt ${String(attempt)}/${String(this.maxRetries)} ===\n`)
-
- let plan: RunResult
- try {
- plan = await sandcastle.run({
- agent: agentProvider(AGENT_PLANNER_MODEL, AGENT_PLANNER_EFFORT),
- completionSignal: COMPLETION_SIGNAL,
- hooks: SANDBOX_AUTH_HOOKS,
- idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
- maxIterations: 5,
- name: 'Planner',
- promptArgs: {
- ISSUES_JSON: JSON.stringify(
- actionableIssues.map(({ body, labels, number, title }) => ({
- body,
- labels,
- number,
- title,
- })),
- null,
- 2
- ),
- },
- promptFile: './.sandcastle/plan-prompt.md',
- sandbox: docker({ imageName: this.dockerImage, mounts: [...DOCKER_MOUNTS] }),
- signal: AbortSignal.timeout(AGENT_TASK_TIMEOUT_MS),
- })
- } catch (err: unknown) {
- console.error(`Planner timed out or failed: ${toErrorMessage(err)}`)
- continue
- }
-
- const planMatches = [...plan.stdout.matchAll(/<plan>([\s\S]*?)<\/plan>/g)]
- const planMatch = planMatches.at(-1)
- if (!planMatch) {
- console.error('Planner did not produce a <plan> tag. Retrying.')
- continue
- }
-
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- regex group always captures but TS types array access as possibly undefined
- const planContent = planMatch[1] ?? ''
- const tasks = this.validatePlan(planContent, actionableIssues)
- if (tasks === null) {
- continue
- }
-
- if (tasks.length === 0) {
- console.log('No actionable issues. Exiting.')
- return []
- }
-
- console.log(`Plan: ${String(tasks.length)} issue(s) to work on:`)
- for (const task of tasks) {
- console.log(` #${task.id} [${task.strategyKey}]: ${task.title} → ${task.branch}`)
- }
-
- return tasks
- }
-
- throw new Error('Planner failed to produce a valid plan after all retries.')
- }
-
- /**
- * Fetches issues for each registered strategy in parallel, then deduplicates
- * by issue number in registry order (first strategy registered wins). Each
- * issue is annotated with the strategy that will handle it and the
- * corresponding branch prefix.
- * @returns Sanitized issues with their resolved strategy.
- */
- private async fetchAndSanitizeIssues (): Promise<ResolvedIssue[]> {
- const fetched = await Promise.all(
- this.strategies.map(async entry => ({
- entry,
- rawIssues: await this.fetchIssuesByLabel(labelOf(entry.key)),
- }))
- )
- const seen = new Map<number, ResolvedIssue>()
- for (const { entry, rawIssues } of fetched) {
- for (const issue of rawIssues) {
- const previous = seen.get(issue.number)
- if (previous !== undefined) {
- const winnerLabel = labelOf(previous.strategyKey)
- const droppedLabel = labelOf(entry.key)
- console.warn(
- `Issue #${String(issue.number)} carries multiple strategy labels ` +
- `('${winnerLabel}' and '${droppedLabel}'); processing as '${winnerLabel}' ` +
- `(registered first), skipping '${droppedLabel}'. ` +
- 'To remove the unwanted label: ' +
- `gh issue edit ${String(issue.number)} --remove-label ${droppedLabel}`
- )
- continue
- }
- seen.set(issue.number, {
- body: this.sanitizeForPrompt(issue.body),
- branchPrefix: branchPrefixOf(entry.key),
- labels: issue.labels.map(l => l.name),
- number: issue.number,
- strategyKey: entry.key,
- title: this.sanitizeForPrompt(issue.title),
- })
- }
- }
- return [...seen.values()]
- }
-
- private async fetchIssuesByLabel (label: string): Promise<z.infer<typeof RawIssuesSchema>> {
- let rawIssuesJson: string
- try {
- const { stdout } = await execFileAsync(
- 'gh',
- [
- 'issue',
- 'list',
- '--state',
- 'open',
- '--json',
- 'number,title,labels,body',
- '--limit',
- String(GITHUB_MAX_ISSUES_FETCH),
- '--label',
- label,
- ],
- { encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024, timeout: GIT_TIMEOUT_MS }
- )
- rawIssuesJson = stdout
- } catch (err: unknown) {
- throw new Error(
- `Failed to fetch issues with label '${label}': ${toErrorMessage(err)}. Ensure gh is installed and authenticated.`,
- { cause: err }
- )
- }
-
- try {
- return RawIssuesSchema.parse(JSON.parse(rawIssuesJson))
- } catch (err: unknown) {
- throw new Error(
- `Failed to parse issues JSON for label '${label}': ${toErrorMessage(err)}. Unexpected format from gh CLI.`,
- { cause: err }
- )
- }
- }
-
- private async fetchIssuesWithOpenPRs (): Promise<Set<number>> {
- try {
- const { stdout } = await execFileAsync(
- 'gh',
- [
- 'pr',
- 'list',
- '--state',
- 'open',
- '--json',
- 'headRefName',
- '--limit',
- String(GITHUB_MAX_PRS_FETCH),
- ],
- { encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024, timeout: GIT_TIMEOUT_MS }
- )
- const prs = z.array(z.object({ headRefName: z.string() })).parse(JSON.parse(stdout))
- const issueNumbers = new Set<number>()
- for (const pr of prs) {
- for (const pattern of this.branchPatterns) {
- const match = pattern.exec(pr.headRefName)
- if (match) {
- issueNumbers.add(Number(match[1]))
- break
- }
- }
- }
- return issueNumbers
- } catch (err: unknown) {
- console.warn(`Failed to check open PRs: ${toErrorMessage(err)}. Processing all issues.`)
- return new Set()
- }
- }
-
- /**
- * Strips agent-control tags from text to reduce prompt-injection risk.
- * The deny-list is derived once from the registry at construction time.
- * @param text - Raw text to sanitize.
- * @returns Text with all control tags removed.
- */
- private sanitizeForPrompt (text: string): string {
- return text.normalize('NFKC').replace(this.controlTagPattern, '')
- }
-
- private validatePlan (planContent: string, actionableIssues: ResolvedIssue[]): null | TaskSpec[] {
- try {
- const PlanSchema = z.object({ issues: z.array(z.unknown()) })
- const parseResult = PlanSchema.safeParse(JSON.parse(planContent))
- if (!parseResult.success) {
- console.error('Planner output missing valid issues array. Retrying.')
- return null
- }
- const parsed = parseResult.data
- const issueMap = new Map(actionableIssues.map(issue => [String(issue.number), issue]))
-
- const seenIds = new Set<string>()
- const validated: TaskSpec[] = []
- for (const entry of parsed.issues) {
- const spec = this.validatePlanEntry(entry, issueMap)
- if (spec === null) continue
- if (seenIds.has(spec.id)) {
- console.warn(
- `Planner produced duplicate id '${spec.id}'; keeping first occurrence and dropping the rest.`
- )
- continue
- }
- seenIds.add(spec.id)
- validated.push(spec)
- }
-
- if (parsed.issues.length > 0 && validated.length === 0) {
- console.error(
- `Planner produced ${String(parsed.issues.length)} entries but none passed validation. Retrying.`
- )
- return null
- }
- return validated
- } catch (err: unknown) {
- console.error(`Planner produced invalid JSON: ${toErrorMessage(err)}. Retrying.`)
- return null
- }
- }
-
- private validatePlanEntry (entry: unknown, issueMap: Map<string, ResolvedIssue>): null | TaskSpec {
- if (typeof entry !== 'object' || entry === null) return null
- const item = entry as Record<string, unknown>
- if (typeof item.id !== 'string' || !/^\d+$/.test(item.id)) return null
- if (typeof item.slug !== 'string') return null
- if (item.slug.length > MAX_SLUG_CHARS || !SLUG_PATTERN.test(item.slug)) return null
- if (typeof item.title !== 'string') return null
- if (item.title.length > MAX_TITLE_CHARS) return null
- // eslint-disable-next-line no-control-regex -- guard against control characters in titles
- if (/[\x00-\x1f]/.test(item.title)) return null
- const sanitizedTitle = this.sanitizeForPrompt(item.title).trim()
- if (sanitizedTitle.length === 0) return null
-
- const source = issueMap.get(item.id)
- if (!source) return null
-
- const spec: TaskSpec = {
- body: source.body,
- branch: `${source.branchPrefix}-${item.id}-${item.slug}`,
- id: item.id,
- labels: source.labels,
- strategyKey: source.strategyKey,
- title: sanitizedTitle,
- }
- if (isValidIssueType(item.issueType)) {
- spec.issueType = item.issueType
- }
- if (isValidConfidence(item.confidence)) {
- spec.confidence = item.confidence
- }
- if (typeof item.rootCauseHypothesis === 'string' && item.rootCauseHypothesis.length > 0) {
- spec.rootCauseHypothesis = this.sanitizeForPrompt(item.rootCauseHypothesis).slice(0, 500)
- }
- if (Array.isArray(item.acceptanceCriteria)) {
- const criteria = item.acceptanceCriteria
- .filter((c): c is string => typeof c === 'string' && c.length > 0)
- .map(c => this.sanitizeForPrompt(c).slice(0, 200))
- if (criteria.length > 0) {
- spec.acceptanceCriteria = criteria.slice(0, 5)
- }
- }
- return spec
- }
-}
-
-/**
- * Strict kebab-case slug body shared by plan validation and PR-coverage
- * branch parsing: lowercase letters/digits, hyphen-separated, no leading,
- * trailing or double hyphen, no underscore. Anchored consumers wrap it in
- * `^...$`. Mirrors the strategy-key shape so the assembled branch
- * `<branchPrefix>-<id>-<slug>` is uniformly kebab-cased.
- */
-const SLUG_PATTERN_BODY = '[a-z0-9]+(?:-[a-z0-9]+)*'
-
-const SLUG_PATTERN = new RegExp(`^${SLUG_PATTERN_BODY}$`)
-
-const VALID_CONFIDENCE = new Set(['high', 'low', 'medium'])
-const VALID_ISSUE_TYPES = new Set(['bug-fix', 'feature', 'refactor'])
-
-/**
- * @param value - Value to escape for safe interpolation in a regex.
- * @returns The value with regex metacharacters escaped.
- */
-function escapeRegex (value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
-/**
- * @param value - Value to check.
- * @returns Whether value is a valid confidence level.
- */
-function isValidConfidence (value: unknown): value is 'high' | 'low' | 'medium' {
- return typeof value === 'string' && VALID_CONFIDENCE.has(value)
-}
-
-/**
- * @param value - Value to check.
- * @returns Whether value is a valid issue type.
- */
-function isValidIssueType (value: unknown): value is 'bug-fix' | 'feature' | 'refactor' {
- return typeof value === 'string' && VALID_ISSUE_TYPES.has(value)
-}
-
-/**
- * Universal agent-control tags shared by every strategy: orchestrator-level
- * vocabulary (planner output, completion signal) and common prompt-injection
- * vectors. Strategy-specific tags come from `StrategyEntry.key` and
- * `StrategyEntry.controlTags`.
- */
-const UNIVERSAL_CONTROL_TAGS: readonly string[] = [
- 'code',
- 'findings',
- 'instructions',
- 'plan',
- 'promise',
- 'system',
- 'tool_call',
-] as const
-
-/**
- * Builds the regex that strips agent-control tags. The deny-list is the union
- * of {@link UNIVERSAL_CONTROL_TAGS} and, for every registered strategy, its
- * `key` plus optional `controlTags`. Adding a strategy automatically extends
- * the deny-list — no edit to the task source is required.
- * @param strategies - Registered strategies whose vocabulary participates.
- * @returns Compiled regex matching opening or closing tags of any control name.
- */
-function buildControlTagPattern (strategies: readonly StrategyEntry[]): RegExp {
- const tags = new Set<string>(UNIVERSAL_CONTROL_TAGS)
- for (const entry of strategies) {
- tags.add(entry.key)
- for (const tag of entry.controlTags ?? []) tags.add(tag)
- }
- const alternation = [...tags].map(escapeRegex).join('|')
- // Lookahead asserts the tag name ends at an XML-tag-name boundary
- // (whitespace, `/` for self-closing, or `>`), preventing prefix collisions
- // such as `<plant>` matching alternative `plan`.
- return new RegExp(`</?(?:${alternation})(?=[\\s/>])[^>]*>`, 'gi')
-}
+++ /dev/null
-import type { PiOptions, Sandbox } from '@ai-hero/sandcastle'
-
-import { z } from 'zod'
-
-/** Zod schema for a single critic finding. */
-const FindingSchema = z.object({
- category: z.string(),
- confidence: z.enum(['HIGH', 'MEDIUM', 'LOW']),
- description: z.string(),
- file: z.string(),
- line: z.number().optional(),
- severity: z.enum(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']),
- suggestion: z.string().optional(),
- title: z.string(),
-})
-
-/**
- * Configuration for post-loop finalization (PR creation, push, etc.).
- */
-// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
-export type FinalizationConfig = {
- /** Finalizes the task after the loop completes. Returns success indicator. */
- finalize: (
- spec: TaskSpec,
- loopResult: LoopResult,
- sandbox: SandboxInstance
- ) => Promise<{ success: boolean }>
- /** Determines if the finalization result counts as completed work. */
- isWorkComplete: (finalizeResult: { success: boolean }) => boolean
-}
-
-/** 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
- /** Reason for non-converged termination, if applicable. */
- failureReason?: string
- /** Complete findings history across all rounds. */
- roundHistory: RoundSnapshot[]
- /** Number of main-loop rounds completed (excludes post-loop validation retry). */
- roundsCompleted: number
- /** Termination status. */
- status: LoopStatus
- /** Total commits produced across all rounds. */
- totalCommits: number
-}
-
-/** Outcome status of the refinement loop. */
-export type LoopStatus = 'converged' | 'exhausted' | 'failed' | 'skipped'
-
-/**
- * Configuration for the refinement loop strategy.
- * Defines prompts, argument builders, and optional convergence logic.
- */
-// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
-export type LoopStrategy = {
- /** Reasoning effort for the actor agent. Defaults to AGENT_ACTOR_EFFORT constant. */
- actorEffort?: PiOptions['thinking']
- /** 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 base branch. */
- buildCriticArgs: (spec: TaskSpec, baseBranch: string) => Record<string, string>
- /** Reasoning effort for the critic agent. Defaults to AGENT_CRITIC_EFFORT constant. */
- criticEffort?: PiOptions['thinking']
- /** 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>
-}
-
-/** Snapshot of a single implement↔critic round. */
-export interface RoundSnapshot {
- /** Number of commits the actor produced this round. */
- commits: number
- /** Findings from the critic (empty array if critic errored). */
- findings: Finding[]
- /** 1-indexed round number. */
- round: number
- /** Outcome of the critic phase for this round. */
- status: 'critic_errored' | 'has_findings' | 'no_findings'
-}
-
-/** Type alias for a sandcastle sandbox instance. */
-export type SandboxInstance = Sandbox
-
-/** Specification for a task to be implemented. */
-export interface TaskSpec {
- /** Verifiable conditions that must hold when implementation is complete. */
- acceptanceCriteria?: string[]
- /** Sanitized issue body text. */
- body: string
- /** Git branch name for this task. */
- branch: string
- /** Planner's confidence in its analysis: controls plan specificity injected into actor. */
- confidence?: 'high' | 'low' | 'medium'
- /** Task identifier (e.g. GitHub issue number as string). */
- id: string
- /** Classification of the issue. */
- issueType?: 'bug-fix' | 'feature' | 'refactor'
- /** Label names associated with the task (platform-specific, optional). */
- labels?: string[]
- /** Planner's hypothesis about what is broken/missing — for actor to validate, not follow blindly. */
- rootCauseHypothesis?: string
- /** Strategy key from the registry that drives the actor/critic loop for this task. */
- strategyKey: string
- /** Task title. */
- title: string
-}
-
-/**
- * Parses a findings array with partial recovery — invalid entries are discarded.
- * @param data - Raw parsed JSON value to validate as a findings array.
- * @returns Array of valid findings (may be empty).
- */
-export function parseFindingsSafe (data: unknown): Finding[] {
- if (!Array.isArray(data)) return []
- return data
- .map(entry => FindingSchema.safeParse(entry))
- .filter((r): r is z.ZodSafeParseSuccess<Finding> => r.success)
- .map(r => r.data)
-}
+++ /dev/null
-import type { AgentProvider, PiOptions } from '@ai-hero/sandcastle'
-
-import * as sandcastle from '@ai-hero/sandcastle'
-import { execFile } from 'node:child_process'
-import util from 'node:util'
-
-import { AGENT_PROVIDER } from './constants.js'
-
-/** Async execFile — does not block the event loop. Same error shape as execFileSync. */
-export const execFileAsync = util.promisify(execFile)
-
-/**
- * Returns a sandcastle agent provider for the given model, selected by AGENT_PROVIDER constant.
- * @param model - The model identifier (e.g., 'github-copilot/claude-sonnet-4.6').
- * @param effort - Reasoning effort level passed as `variant` to opencode or `thinking` to pi.
- * @returns The configured agent provider.
- */
-export function agentProvider (model: string, effort?: PiOptions['thinking']): AgentProvider {
- switch (AGENT_PROVIDER) {
- case 'opencode':
- return sandcastle.opencode(model, effort ? { variant: effort } : undefined)
- case 'pi':
- return sandcastle.pi(model, effort ? { thinking: effort } : undefined)
- }
-}
-
-/**
- * Converts an unknown thrown value to a human-readable error message.
- * @param err - The caught value (may be an `Error` or any other type).
- * @returns The `message` property if `err` is an `Error`, otherwise `String(err)`.
- */
-export function toErrorMessage (err: unknown): string {
- return err instanceof Error ? err.message : String(err)
-}
+++ /dev/null
-import type { TaskSpec } from './types.js'
-
-import { MAX_STDERR_CHARS, VALIDATION_COMMAND, VALIDATION_TIMEOUT_MS } from './constants.js'
-import { execFileAsync } from './utils.js'
-
-/**
- * Runs the full validation suite.
- * @param cwd - Working directory (worktree path).
- * @param spec - Optional task specification (used for logging).
- * @param signal - Optional abort signal for cooperative cancellation.
- * @returns `true` if validation passed, `false` otherwise.
- */
-export async function runValidation (
- cwd: string,
- spec?: TaskSpec,
- signal?: AbortSignal
-): Promise<boolean> {
- try {
- await execFileAsync('sh', ['-c', VALIDATION_COMMAND], {
- cwd,
- maxBuffer: 8 * 1024 * 1024,
- signal,
- timeout: VALIDATION_TIMEOUT_MS,
- })
- return true
- } catch (err: unknown) {
- if (signal?.aborted === true) {
- throw err
- }
- if (err && typeof err === 'object' && 'killed' in err && (err as { killed: boolean }).killed) {
- const label = spec ? `#${spec.id}` : 'mid-loop'
- console.warn(` ${label}: Validation timed out after ${String(VALIDATION_TIMEOUT_MS)}ms.`)
- } else if (spec) {
- const stderr = extractStderr(err)
- console.warn(` #${spec.id}: Validation failed.${stderr ? `\n${stderr}` : ''}`)
- }
- return false
- }
-}
-
-/**
- * Extracts stderr from a caught error, truncated to 500 chars.
- * @param err - The caught error value.
- * @returns Stderr string or empty string if unavailable.
- */
-function extractStderr (err: unknown): string {
- return err instanceof Error && 'stderr' in err
- ? String((err as { stderr: unknown }).stderr).slice(0, MAX_STDERR_CHARS)
- : ''
-}
- varh
- rfid
- workerset
- - worktree
- dedup
- - unpushed
- logform
- mnemonist
- multiton
"clean:node_modules": "pnpm exec rimraf node_modules",
"typecheck": "tsc --noEmit --skipLibCheck",
"circular-deps": "skott --no-trackTypeOnlyDependencies --showCircularDependencies --exitCodeOnCircularDependencies 0 --displayMode=raw --fileExtensions=.ts --cwd=src",
- "lint": "cross-env TIMING=1 eslint --cache src .sandcastle tests scripts ./*.js ./*.ts",
- "lint:fix": "cross-env TIMING=1 eslint --cache --fix src .sandcastle tests scripts ./*.js ./*.ts",
- "format": "prettier --cache --write .; eslint --cache --fix src .sandcastle tests scripts ./*.js ./*.ts",
+ "lint": "cross-env TIMING=1 eslint --cache src tests scripts ./*.js ./*.ts",
+ "lint:fix": "cross-env TIMING=1 eslint --cache --fix src tests scripts ./*.js ./*.ts",
+ "format": "prettier --cache --write .; eslint --cache --fix src tests scripts ./*.js ./*.ts",
"test": "cross-env NODE_ENV=test node --import tsx --test --test-force-exit 'tests/**/*.test.ts'",
"test:debug": "cross-env NODE_ENV=test node --import tsx --test --inspect 'tests/**/*.test.ts'",
"test:coverage": "mkdir -p coverage && cross-env NODE_ENV=test node --import tsx --test --test-force-exit --experimental-test-coverage --test-coverage-include='src/**/*.ts' --test-reporter=lcov --test-reporter-destination=coverage/lcov.info 'tests/**/*.test.ts'",
- "sandcastle": "tsx .sandcastle/main.ts",
"sea": "pnpm exec rimraf ./dist/evse-simulator ./dist/evse-simulator.blob && node --experimental-sea-config sea-config.json && pnpm dlx ncp $(volta which node || n which lts || nvm which node || command -v node) ./dist/evse-simulator && pnpm dlx postject ./dist/evse-simulator NODE_SEA_BLOB ./dist/evse-simulator.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 && pnpm exec rimraf ./dist/evse-simulator.blob"
},
"dependencies": {
"utf-8-validate": "^6.0.6"
},
"devDependencies": {
- "@ai-hero/sandcastle": "^0.12.0",
"@commitlint/cli": "^21.2.2",
"@commitlint/config-conventional": "^21.2.2",
"@cspell/eslint-plugin": "^10.0.1",
specifier: ^4.4.3
version: 4.4.3
devDependencies:
- '@ai-hero/sandcastle':
- specifier: ^0.12.0
- version: 0.12.0
'@commitlint/cli':
specifier: ^21.2.2
version: 21.2.2(@types/node@24.13.3)(conventional-commits-parser@7.1.2)(typescript@6.0.3)
packages:
- '@ai-hero/sandcastle@0.12.0':
- resolution: {integrity: sha512-kdQ414rM8t1QiWeqZ3Klz4KSd0PqQG4bRVuqGpRDUomWhojSZkEAc1tbcEcThVmBEaHkCt8LmYR49vqEPNIoYQ==}
- hasBin: true
- peerDependencies:
- '@daytona/sdk': ^0.164.0
- '@vercel/sandbox': '>=1.0.0'
- peerDependenciesMeta:
- '@daytona/sdk':
- optional: true
- '@vercel/sandbox':
- optional: true
-
'@arr/every@1.0.1':
resolution: {integrity: sha512-UQFQ6SgyJ6LX42W8rHCs8KVc0JS0tzVL9ct4XYedJukskYVWTo49tNiMEK9C2HTyarbNiT/RVIRSY82vH+6sTg==}
engines: {node: '>=4'}
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
- '@clack/core@1.4.3':
- resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==}
- engines: {node: '>= 20.12.0'}
-
- '@clack/prompts@1.7.0':
- resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
- engines: {node: '>= 20.12.0'}
-
'@colors/colors@1.5.0':
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
- fast-string-truncated-width@3.0.3:
- resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
-
- fast-string-width@3.0.2:
- resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
-
fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
- fast-wrap-ansi@0.2.2:
- resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
-
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
- sisteransi@1.0.5:
- resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
-
skott-webapp@2.3.0:
resolution: {integrity: sha512-nmt+ilxGOqX5zN2WDKv1Y5gLfxy/lceHgbB8HM/ym/Cm8572ypD1s2S+pcN+jOw13xqoavHJPonX1WT2QvkpDg==}
snapshots:
- '@ai-hero/sandcastle@0.12.0':
- dependencies:
- '@clack/prompts': 1.7.0
-
'@arr/every@1.0.1': {}
'@asamuzakjp/css-color@6.0.7':
dependencies:
css-tree: 3.2.1
- '@clack/core@1.4.3':
- dependencies:
- fast-wrap-ansi: 0.2.2
- sisteransi: 1.0.5
-
- '@clack/prompts@1.7.0':
- dependencies:
- '@clack/core': 1.4.3
- fast-string-width: 3.0.2
- fast-wrap-ansi: 0.2.2
- sisteransi: 1.0.5
-
'@colors/colors@1.5.0':
optional: true
fast-levenshtein@2.0.6: {}
- fast-string-truncated-width@3.0.3: {}
-
- fast-string-width@3.0.2:
- dependencies:
- fast-string-truncated-width: 3.0.3
-
fast-uri@3.1.5: {}
- fast-wrap-ansi@0.2.2:
- dependencies:
- fast-string-width: 3.0.2
-
fastq@1.20.1:
dependencies:
reusify: 1.1.0
mrmime: 2.0.1
totalist: 3.0.1
- sisteransi@1.0.5: {}
-
skott-webapp@2.3.0:
dependencies:
digraph-js: 2.2.4
esbuild: false
msgpackr-extract: false
utf-8-validate: false
-
-minimumReleaseAgeExclude:
- - '@ai-hero/sandcastle@0.6.6'
"forceConsistentCasingInFileNames": true,
"noImplicitOverride": true
},
- "include": ["*.ts", "src/**/*.ts", ".sandcastle/**/*.ts", "tests/**/*.ts"]
+ "include": ["*.ts", "src/**/*.ts", "tests/**/*.ts"]
}