Extract sandcastle core into shared flake; wire nibble as first consumer #1
Labels
No labels
epic
in-review
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
weiwen/sandcastle#1
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem Statement
The sandcastle autonomous-agent PR loop (planner → implementer → reviewer → pr-fixer) currently lives entirely inside the
evierepo: its Nix image builder,configure-teahelper, orchestrator (.sandcastle/main.mts), and the four agent prompts are all evie-specific. Every prompt hardcodes--repo weiwen/evieand theforge.weiwen.dev/weiwen/evie.gitpush URL; the image tag, models, and iteration/concurrency knobs are baked intomain.mts. As a result there is no way to run the same loop against a second repository without copy-pasting and hand-editing the whole.sandcastle/tree and the flake — which then drifts. The maintainer wants to run the loop againstnibble(and later others) from a single, shared source of truth.Solution
Extract a reusable sandcastle core into a new standalone repo (
weiwen/sandcastle) that both current and future projects consume as a Nix flake input. The core owns everything generic: the sandbox-image builder, the baked helper scripts (configure-tea,fmt,check), the base package set (includingclaude-code), the orchestrator, and the four genericized prompts. Each consuming repo contributes only what is genuinely project-specific: a small.sandcastle/config.toml, secrets in.sandcastle/.env, a.sandcastle/CODING_STANDARDS.md, the project's own binary/toolchain/native-lib wiring in its flake, and the forge-side triage workflow.nibbleis the first consumer (the guinea pig): its flake is rewritten to the shared shape, wired to the sandcastle input, and itsoriginremote is aligned to the forge host soteaauto-derives the repo slug.evieis left untouched in this work and migrated to the extracted core later.From the maintainer's perspective: edit the loop once in
weiwen/sandcastle, bump the flake input in each consumer, and every project's loop updates together — while each project keeps full control over its build, its coding standards, and its tuning knobs.User Stories
flake.nixplus a small config file.mkSandcastleImage { name, tag, extraContents, extraEnv, forgeUrl }function, so that each repo supplies only its own toolchain and native libraries and gets a correct sandbox image.claude-code(viallm-agents), so that consumers don't each have to declare that input.configure-tea,fmt, andcheckbaked into the shared base package set as shell scripts, so that the prompts can call barefmt/checkand no consumer needs aJustfile.apps.sandcastle), so that no consumer repo needs its ownpackage.json,node_modules, ormain.mts../.sandcastle/config.tomlat runtime, so that the same binary drives any repo without recompilation.config.tomlto carryrepo,forgeHost,imageName,maxIterations,maxConcurrency, and per-rolemodels, so that each project tunes cost/behaviour independently.--repodropped from allteainvocations so the slug is auto-derived from the sandbox's git remote, so that the prompts carry no project identity.{{FORGE_HOST}}+{{REPO}}injected from config, so that pushes target the right repo without editing prompts.@-include.sandcastle/CODING_STANDARDS.mdrelative to the sandbox cwd, so that each project's standards are applied without changing the shared prompt.nibble's flake rewritten to the shared shape (flake-utils + rust-overlay +checks), so that it and future consumers look and build the same way.nibble's sandbox image to include its native build deps (libjxl,libheif,pkg-config) plus the rust toolchain, so thatcargo build/cargo testlink correctly inside the sandbox.nibble's existing Playwright dev shell preserved, so that the manual screenshot workflow (scripts/preview.mjs) still works outside the sandbox.nix flake checkgate that builds the sandcastle image, so that a broken image or toolchain regression is caught before running the loop.nibble'soriginremote moved from the internal SSH host to the forge host (forge.weiwen.dev), so that the sandbox's inherited remote matches thetealogin and slug auto-derivation works.nibble, so that a humanREQUEST_CHANGESreview auto-appliesready-for-agentand the pr-rework lane fires.ready-for-agent,in-review,ready-for-human,epic) present onnibble, so that the loop's label lifecycle functions.nibble's.sandcastle/to contain onlyconfig.toml,.env, andCODING_STANDARDS.md(no JS, no lockfiles), so that the consumer footprint is minimal..sandcastle/.env(Anthropic/Forgejo only — no Immich, since tests run offline against cached fixtures), so that the sandbox carries the least credential surface needed.nix run .#sandcastle, so that invocation is uniform across consumers.nix run .#sandcastleImage | docker load, so that the sandbox is reproducible from the flake.evieleft completely untouched by this work, so that the extraction is validated onnibblefirst andeviemigrates on its own schedule.sandcastle/issue-{id}, label lifecycle, theconfigure-teaonboarding hook) baked into the shared core, so that I inherit correct loop semantics for free.Implementation Decisions
New repo
weiwen/sandcastle(the shared core). Standalone Nix flake plus the orchestrator source and prompts. This is the single source of truth for everything generic.flake.nix— inputs:nixpkgs,flake-utils,rust-overlay,llm-agents(the latter re-exported so consumers need not declare it). Structured withflake-utils.lib.eachDefaultSystem. Outputs:lib.${system}.mkSandcastleImage { name, tag, extraContents ? [], extraEnv ? [], forgeUrl }— wrapsdockerTools.fakeNss+dockerTools.streamLayeredImageover the base package set +extraContents; setsPATH,HOME, cert, and locale env plusextraEnv; entrypointsleep infinity, workdir/home/agent.sandcastlePackages— the toolchain-agnostic set (bash, coreutils, curl, findutils, gawk, git, gnugrep, gnused, jq, nix, nodejs, openssl, pkg-config, which,tea,cacert) plusclaude-codeand the three bakedwriteShellScriptBinhelpers below.configure-tea— baked script parameterized byforgeUrl; writes~/.config/tea/config.ymlfromFORGEJO_TOKEN/FORGEJO_USERat runtime.fmtandcheck— baked scripts with hardcoded Rust commands:checkrunscargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test;fmtrunscargo clippy --fix --allow-dirty --allow-staged -- -D warnings && cargo fmt. (Rust assumption is intentional; a future non-Rust consumer overrides viaextraContentsPATH precedence.)apps.${system}.sandcastle— the orchestrator built as a self-containedbuildNpmPackagenode app bundling@ai-hero/sandcastle+zod+ the four prompt files; requires a committedpackage-lock.jsonandnpmDepsHashin this repo.main.mts) — generic. New responsibility: a pureloadConfig(tomlString) → ResolvedConfigstep producing{ repo, forgeHost, imageName, maxIterations, maxConcurrency, models: { planner, implementer, reviewer, prFixer } }, applying documented defaults and throwing on missing required fields (repo,forgeHost). All downstream behaviour (sandboximageName, per-role model selection, and the{{REPO}}/{{FORGE_HOST}}prompt args) consumes this one object. TheonSandboxReadyhook reduces toconfigure-teaonly (nonpm install);copyToWorktreeis removed (nonode_modules).teacall drops--repo(relying on remote-derived slug); the push URL becomeshttps://${FORGEJO_USER}:${FORGEJO_TOKEN}@{{FORGE_HOST}}/{{REPO}}.git;just fmt/just checkbecome barefmt/check. The reviewer prompt retains the cwd-relative@.sandcastle/CODING_STANDARDS.mdinclude. The branch formatsandcastle/issue-{id}and the label lifecycle remain hardcoded.nibble(first consumer).flake.nix— rewritten to the shared shape (flake-utils.eachDefaultSystem+rust-overlay,checks.clippy,devShells.default+devShells.ci). Adds thesandcastleinput (git+https://forge.weiwen.dev/weiwen/sandcastle.git). Definespackages.sandcastleImage = sandcastle.lib.${system}.mkSandcastleImage { name = "sandcastle"; tag = "nibble"; extraContents = [ rustToolchain pkg-config libjxl libheif ]; forgeUrl = "https://forge.weiwen.dev"; }and re-exposesapps.sandcastle. Preserves the existing Playwright dev shell (PLAYWRIGHT_*env,playwright-test,python3) forscripts/preview.mjs. Addschecks.sandcastleImagesonix flake checkbuilds the image..sandcastle/—config.toml(schema below),.env(Anthropic + Forgejo secrets only),CODING_STANDARDS.md(nibble-specific),.gitignore(.env,logs/,worktrees/). Nomain.mts,package.json,node_modules, orJustfile..forgejo/workflows/— the triage workflow ported from evie's reference (label names identical → expected zero content changes).originupdated fromssh://forgejo@matsutake/weiwen/nibble.gittossh://forgejo@forge.weiwen.dev/weiwen/nibble.git.ready-for-agent,in-review,ready-for-human,epiccreated onweiwen/nibble(done as part of setup).config.tomlschema (consumer-supplied):Testing Decisions
Good tests here exercise external behaviour, not implementation detail. The overwhelming majority of this work is declarative (Nix expressions, prompt markdown, forge YAML, a remote change) and is validated by building, not by unit tests. Exactly one seam carries real branching logic, and it is the one seam that gets a unit test.
loadConfig(inweiwen/sandcastle). Test the pure function only through its contract: valid TOML → the expectedResolvedConfigobject; omitted optional fields → documented defaults applied (maxIterations,maxConcurrency,models.*); a missing required field (repo,forgeHost) → throws. Do not test how it parses internally or reach into private state. This is the single highest seam through which all downstream orchestrator behaviour flows, so testing it covers config resolution, model selection, and prompt-arg derivation at one point. Prior art: evie's existingmain.mtshad no such seam (values were hardcoded), so this is a new, deliberately-introduced testable boundary; the repo'stsc --noEmittypecheck script runs alongside it as the type gate.checks.sandcastleImagein nibble's flake.nix flake checkbuilds the image; it fails if the image won't build or the toolchain/native-lib wiring regresses. This gates the consumer wiring without executing the loop. Prior art: evie'schecks.clippy/checks.testsflake-check pattern.nix run .#sandcastleImage | docker loadthennix run .#sandcastleagainst a realready-for-agentissue on nibble, validated by hand once. Deliberately not automated: it spawns agents and mutates forge state, so it is too expensive and nondeterministic to gate CI.Out of Scope
evie. It stays as-is and is migrated to the extracted core in later, separate work.token-usage.shanalytics helper — referenced by nothing in the loop; may be revisited as a shared utility later.fmt/checkfor non-Rust consumers — the Rust commands are intentionally hardcoded now; generalize only when a non-Rust consumer appears.flake-utils.eachDefaultSystemyields incidentally; the only exercised system isx86_64-linux.Further Notes
nibblefirst precisely because it is lower-stakes thanevie; a successful nibble loop is the acceptance signal for the core's genericity.tea's slug auto-derivation depends on the local git remote's host matching a configuredtealogin host — which is exactly why nibble'soriginis realigned toforge.weiwen.dev. The push URL still needs the explicit slug, which is whyreporemains inconfig.tomleven though--repois dropped fromteacalls.configure-tea/fmt/checkscripts are baked into the image the same wayconfigure-teaalready is in evie — fixed script bodies, runtime env for the only varying parts (forge URL, credentials).sandcastleflake input +mkSandcastleImagecall + a.sandcastle/{config.toml,.env,CODING_STANDARDS.md}triplet + the triage workflow + label vocabulary — nothing more.