From 89122a85c651addd84cd06afcc3d5fb2a7ea6bab Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sun, 23 Aug 2026 17:33:04 +0200 Subject: [PATCH] chore: remove Sandcastle integration (#2093) * chore: remove Sandcastle integration - delete .sandcastle agent harness, Docker sandbox and scheduled workflow - drop @ai-hero/sandcastle devDependency and 'sandcastle' script - remove .sandcastle from lint/format globs, tsconfig include and .cfignore - drop unused cspell words and pnpm minimumReleaseAgeExclude entry * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .cfignore | 1 - .github/workflows/sandcastle.yml | 62 -- .sandcastle/.env.example | 3 - .sandcastle/.gitignore | 3 - .sandcastle/Dockerfile | 49 -- .sandcastle/concurrency-pool.ts | 69 -- .sandcastle/constants.ts | 119 --- .sandcastle/finalizer.ts | 156 ---- .sandcastle/main.ts | 99 --- .sandcastle/plan-prompt.md | 40 -- .sandcastle/refinement-loop.ts | 676 ------------------ .../strategies/implement/actor-prompt.md | 52 -- .../strategies/implement/critic-prompt.md | 50 -- .sandcastle/strategies/implement/strategy.ts | 90 --- .sandcastle/strategies/index.ts | 115 --- .sandcastle/task-source.ts | 463 ------------ .sandcastle/types.ts | 141 ---- .sandcastle/utils.ts | 34 - .sandcastle/validation.ts | 50 -- cspell.config.yaml | 2 - package.json | 8 +- pnpm-lock.yaml | 63 -- pnpm-workspace.yaml | 3 - tsconfig.json | 2 +- 24 files changed, 4 insertions(+), 2346 deletions(-) delete mode 100644 .github/workflows/sandcastle.yml delete mode 100644 .sandcastle/.env.example delete mode 100644 .sandcastle/.gitignore delete mode 100644 .sandcastle/Dockerfile delete mode 100644 .sandcastle/concurrency-pool.ts delete mode 100644 .sandcastle/constants.ts delete mode 100644 .sandcastle/finalizer.ts delete mode 100644 .sandcastle/main.ts delete mode 100644 .sandcastle/plan-prompt.md delete mode 100644 .sandcastle/refinement-loop.ts delete mode 100644 .sandcastle/strategies/implement/actor-prompt.md delete mode 100644 .sandcastle/strategies/implement/critic-prompt.md delete mode 100644 .sandcastle/strategies/implement/strategy.ts delete mode 100644 .sandcastle/strategies/index.ts delete mode 100644 .sandcastle/task-source.ts delete mode 100644 .sandcastle/types.ts delete mode 100644 .sandcastle/utils.ts delete mode 100644 .sandcastle/validation.ts diff --git a/.cfignore b/.cfignore index 3b623ae1..75f8e717 100644 --- a/.cfignore +++ b/.cfignore @@ -20,7 +20,6 @@ eslint.config.js .nyc_output .reuse .xmake.cfg -.sandcastle/ # Logs logs/ *.log diff --git a/.github/workflows/sandcastle.yml b/.github/workflows/sandcastle.yml deleted file mode 100644 index 11e4a67c..00000000 --- a/.github/workflows/sandcastle.yml +++ /dev/null @@ -1,62 +0,0 @@ -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 }} diff --git a/.sandcastle/.env.example b/.sandcastle/.env.example deleted file mode 100644 index 74b4246c..00000000 --- a/.sandcastle/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -GH_TOKEN= -GITHUB_TOKEN= -OPENCODE_AUTH_CONTENT= diff --git a/.sandcastle/.gitignore b/.sandcastle/.gitignore deleted file mode 100644 index 49a21266..00000000 --- a/.sandcastle/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.env -logs/ -worktrees/ diff --git a/.sandcastle/Dockerfile b/.sandcastle/Dockerfile deleted file mode 100644 index 8f44181e..00000000 --- a/.sandcastle/Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -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"] diff --git a/.sandcastle/concurrency-pool.ts b/.sandcastle/concurrency-pool.ts deleted file mode 100644 index 52488cf7..00000000 --- a/.sandcastle/concurrency-pool.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** 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(fn: () => Promise): Promise { - await this.acquire() - try { - return await fn() - } finally { - this.release() - } - } - - private acquire (): Promise { - if (this.running < this.max) { - this.running++ - return Promise.resolve() - } - return new Promise(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() - } - } -} diff --git a/.sandcastle/constants.ts b/.sandcastle/constants.ts deleted file mode 100644 index bfc3334e..00000000 --- a/.sandcastle/constants.ts +++ /dev/null @@ -1,119 +0,0 @@ -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 = 'COMPLETE' - -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 diff --git a/.sandcastle/finalizer.ts b/.sandcastle/finalizer.ts deleted file mode 100644 index f4518239..00000000 --- a/.sandcastle/finalizer.ts +++ /dev/null @@ -1,156 +0,0 @@ -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 { - 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 { - 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 - } - } -} diff --git a/.sandcastle/main.ts b/.sandcastle/main.ts deleted file mode 100644 index 8bad05bb..00000000 --- a/.sandcastle/main.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 - } -} diff --git a/.sandcastle/plan-prompt.md b/.sandcastle/plan-prompt.md deleted file mode 100644 index 9325cfd0..00000000 --- a/.sandcastle/plan-prompt.md +++ /dev/null @@ -1,40 +0,0 @@ -# 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 -{"issues":[{"id":"","slug":"","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> diff --git a/.sandcastle/refinement-loop.ts b/.sandcastle/refinement-loop.ts deleted file mode 100644 index 49f9981e..00000000 --- a/.sandcastle/refinement-loop.ts +++ /dev/null @@ -1,676 +0,0 @@ -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) -} diff --git a/.sandcastle/strategies/implement/actor-prompt.md b/.sandcastle/strategies/implement/actor-prompt.md deleted file mode 100644 index 903e0a74..00000000 --- a/.sandcastle/strategies/implement/actor-prompt.md +++ /dev/null @@ -1,52 +0,0 @@ -# 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> diff --git a/.sandcastle/strategies/implement/critic-prompt.md b/.sandcastle/strategies/implement/critic-prompt.md deleted file mode 100644 index b80f2a8e..00000000 --- a/.sandcastle/strategies/implement/critic-prompt.md +++ /dev/null @@ -1,50 +0,0 @@ -# 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> diff --git a/.sandcastle/strategies/implement/strategy.ts b/.sandcastle/strategies/implement/strategy.ts deleted file mode 100644 index f1370b23..00000000 --- a/.sandcastle/strategies/implement/strategy.ts +++ /dev/null @@ -1,90 +0,0 @@ -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, -} diff --git a/.sandcastle/strategies/index.ts b/.sandcastle/strategies/index.ts deleted file mode 100644 index debece0e..00000000 --- a/.sandcastle/strategies/index.ts +++ /dev/null @@ -1,115 +0,0 @@ -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 -} diff --git a/.sandcastle/task-source.ts b/.sandcastle/task-source.ts deleted file mode 100644 index 96ee1774..00000000 --- a/.sandcastle/task-source.ts +++ /dev/null @@ -1,463 +0,0 @@ -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') -} diff --git a/.sandcastle/types.ts b/.sandcastle/types.ts deleted file mode 100644 index 7ddc6f77..00000000 --- a/.sandcastle/types.ts +++ /dev/null @@ -1,141 +0,0 @@ -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) -} diff --git a/.sandcastle/utils.ts b/.sandcastle/utils.ts deleted file mode 100644 index efddc89d..00000000 --- a/.sandcastle/utils.ts +++ /dev/null @@ -1,34 +0,0 @@ -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) -} diff --git a/.sandcastle/validation.ts b/.sandcastle/validation.ts deleted file mode 100644 index ee4730ae..00000000 --- a/.sandcastle/validation.ts +++ /dev/null @@ -1,50 +0,0 @@ -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) - : '' -} diff --git a/cspell.config.yaml b/cspell.config.yaml index a56b7c4d..65900ccb 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -23,9 +23,7 @@ words: - varh - rfid - workerset - - worktree - dedup - - unpushed - logform - mnemonist - multiton diff --git a/package.json b/package.json index 7bcf4fda..1a045e2a 100644 --- a/package.json +++ b/package.json @@ -63,13 +63,12 @@ "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": { @@ -100,7 +99,6 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 109b6a51..c0d8dd00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,9 +72,6 @@ importers: 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) @@ -261,18 +258,6 @@ importers: 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'} @@ -431,14 +416,6 @@ packages: 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'} @@ -2613,18 +2590,9 @@ packages: 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==} @@ -4137,9 +4105,6 @@ packages: 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==} @@ -4880,10 +4845,6 @@ packages: snapshots: - '@ai-hero/sandcastle@0.12.0': - dependencies: - '@clack/prompts': 1.7.0 - '@arr/every@1.0.1': {} '@asamuzakjp/css-color@6.0.7': @@ -5096,18 +5057,6 @@ snapshots: 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 @@ -7553,18 +7502,8 @@ snapshots: 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 @@ -9067,8 +9006,6 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 - sisteransi@1.0.5: {} - skott-webapp@2.3.0: dependencies: digraph-js: 2.2.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b48407ca..355f4820 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,3 @@ allowBuilds: esbuild: false msgpackr-extract: false utf-8-validate: false - -minimumReleaseAgeExclude: - - '@ai-hero/sandcastle@0.6.6' diff --git a/tsconfig.json b/tsconfig.json index 09e8d899..bdded257 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "forceConsistentCasingInFileNames": true, "noImplicitOverride": true }, - "include": ["*.ts", "src/**/*.ts", ".sandcastle/**/*.ts", "tests/**/*.ts"] + "include": ["*.ts", "src/**/*.ts", "tests/**/*.ts"] } -- 2.53.0