]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
chore: remove Sandcastle integration (#2093)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sun, 23 Aug 2026 15:33:04 +0000 (17:33 +0200)
committerGitHub <noreply@github.com>
Sun, 23 Aug 2026 15:33:04 +0000 (17:33 +0200)
* 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>
24 files changed:
.cfignore
.github/workflows/sandcastle.yml [deleted file]
.sandcastle/.env.example [deleted file]
.sandcastle/.gitignore [deleted file]
.sandcastle/Dockerfile [deleted file]
.sandcastle/concurrency-pool.ts [deleted file]
.sandcastle/constants.ts [deleted file]
.sandcastle/finalizer.ts [deleted file]
.sandcastle/main.ts [deleted file]
.sandcastle/plan-prompt.md [deleted file]
.sandcastle/refinement-loop.ts [deleted file]
.sandcastle/strategies/implement/actor-prompt.md [deleted file]
.sandcastle/strategies/implement/critic-prompt.md [deleted file]
.sandcastle/strategies/implement/strategy.ts [deleted file]
.sandcastle/strategies/index.ts [deleted file]
.sandcastle/task-source.ts [deleted file]
.sandcastle/types.ts [deleted file]
.sandcastle/utils.ts [deleted file]
.sandcastle/validation.ts [deleted file]
cspell.config.yaml
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
tsconfig.json

index 3b623ae1ecc652068b1417ca1a146eb53ac22222..75f8e7172febff3d2ac47096a0ecf5e4c56dbc80 100644 (file)
--- 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 (file)
index 11e4a67..0000000
+++ /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 (file)
index 74b4246..0000000
+++ /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 (file)
index 49a2126..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-.env
-logs/
-worktrees/
diff --git a/.sandcastle/Dockerfile b/.sandcastle/Dockerfile
deleted file mode 100644 (file)
index 8f44181..0000000
+++ /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 (file)
index 52488cf..0000000
+++ /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<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()
-    }
-  }
-}
diff --git a/.sandcastle/constants.ts b/.sandcastle/constants.ts
deleted file mode 100644 (file)
index bfc3334..0000000
+++ /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 = '<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
diff --git a/.sandcastle/finalizer.ts b/.sandcastle/finalizer.ts
deleted file mode 100644 (file)
index f451823..0000000
+++ /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<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
-    }
-  }
-}
diff --git a/.sandcastle/main.ts b/.sandcastle/main.ts
deleted file mode 100644 (file)
index 8bad05b..0000000
+++ /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 (file)
index 9325cfd..0000000
+++ /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
-<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>
diff --git a/.sandcastle/refinement-loop.ts b/.sandcastle/refinement-loop.ts
deleted file mode 100644 (file)
index 49f9981..0000000
+++ /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 (file)
index 903e0a7..0000000
+++ /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 (file)
index b80f2a8..0000000
+++ /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 (file)
index f1370b2..0000000
+++ /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 (file)
index debece0..0000000
+++ /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 (file)
index 96ee177..0000000
+++ /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 (file)
index 7ddc6f7..0000000
+++ /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 (file)
index efddc89..0000000
+++ /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 (file)
index ee4730a..0000000
+++ /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)
-    : ''
-}
index a56b7c4db14f37b92a73a10746c96c49a8423950..65900ccbfda4a4242ec8645476b2ed302f8748ff 100644 (file)
@@ -23,9 +23,7 @@ words:
   - varh
   - rfid
   - workerset
-  - worktree
   - dedup
-  - unpushed
   - logform
   - mnemonist
   - multiton
index 7bcf4fda422f09dfec4648da2f7beac07306ced3..1a045e2ae69654b524ac74d275dc64f50f33387e 100644 (file)
     "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",
index 109b6a5182bbe36c2582d172bcaa590cc6cf2c6a..c0d8dd002fdfad8c43fdb704e1d008e4c3a4b8ab 100644 (file)
@@ -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
index b48407caed4f51ad72dd36896038fd9b538e15be..355f4820eba38187d0398eb0e6ebfc12794b9b8c 100644 (file)
@@ -12,6 +12,3 @@ allowBuilds:
   esbuild: false
   msgpackr-extract: false
   utf-8-validate: false
-
-minimumReleaseAgeExclude:
-  - '@ai-hero/sandcastle@0.6.6'
index 09e8d899aa2e68edfb341dbeec51915f5d48e339..bdded25755086915ca312901484b68070d3028ca 100644 (file)
@@ -15,5 +15,5 @@
     "forceConsistentCasingInFileNames": true,
     "noImplicitOverride": true
   },
-  "include": ["*.ts", "src/**/*.ts", ".sandcastle/**/*.ts", "tests/**/*.ts"]
+  "include": ["*.ts", "src/**/*.ts", "tests/**/*.ts"]
 }