Slice 1 — Walking skeleton: shared core + planner lane, driven end-to-end from nibble #2

Closed
opened 2026-07-08 02:52:35 +08:00 by weiwen · 1 comment
Owner

Parent

weiwen/sandcastle#1

What to build

The walking skeleton for the extracted sandcastle core, proven end-to-end through the read-only planner lane driven from nibble. This slice establishes all shared infrastructure and the first consumer wiring; slices #2 and #3 add the write lanes on top without touching the infra again.

Shared core (weiwen/sandcastle):

  • flake.nix in the shared shape (flake-utils.eachDefaultSystem + rust-overlay), inputs nixpkgs, flake-utils, rust-overlay, llm-agents (re-exported).
  • lib.${system}.mkSandcastleImage { name, tag, extraContents ? [], extraEnv ? [], forgeUrl } — wraps dockerTools.fakeNss + streamLayeredImage over the base package set + extraContents; sets PATH/HOME/cert/locale env plus extraEnv; entrypoint sleep infinity, workdir /home/agent.
  • Base sandcastlePackages (bash, coreutils, curl, findutils, gawk, git, gnugrep, gnused, jq, nix, nodejs, openssl, pkg-config, which, tea, cacert) + claude-code + the three baked writeShellScriptBin helpers: configure-tea (parameterized by forgeUrl, writes ~/.config/tea/config.yml from FORGEJO_TOKEN/FORGEJO_USER at runtime), fmt (cargo clippy --fix --allow-dirty --allow-staged -- -D warnings && cargo fmt), check (cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test).
  • apps.${system}.sandcastle — orchestrator as a self-contained buildNpmPackage node app bundling @ai-hero/sandcastle + zod + the plan prompt; committed package-lock.json + npmDepsHash.
  • Orchestrator with a pure loadConfig(tomlString) → ResolvedConfig step producing { repo, forgeHost, imageName, maxIterations, maxConcurrency, models: { planner, implementer, reviewer, prFixer } }, applying defaults and throwing on missing repo/forgeHost. The loop runs the planner only this slice: reads config, builds a sandbox from imageName with the configure-tea onSandboxReady hook, runs the planner (model from models.planner), and parses the <plan> JSON via the schema. No execute phase yet.
  • Genericized plan prompt: --repo dropped from all tea calls (slug auto-derived from the sandbox remote); branch format sandcastle/issue-{id} and label lifecycle hardcoded.

First consumer (nibble):

  • flake.nix rewritten to the shared shape; adds the sandcastle input (git+https://forge.weiwen.dev/weiwen/sandcastle.git); defines packages.sandcastleImage = sandcastle.lib.${system}.mkSandcastleImage { name = "sandcastle"; tag = "nibble"; extraContents = [ rustToolchain pkg-config libjxl libheif ]; forgeUrl = "https://forge.weiwen.dev"; }; re-exposes apps.sandcastle; adds checks.sandcastleImage; preserves the existing Playwright dev shell.
  • .sandcastle/ with config.toml (schema below), .env (Anthropic + Forgejo secrets only), CODING_STANDARDS.md, .gitignore (.env, logs/, worktrees/). No main.mts, package.json, node_modules, or Justfile.
  • origin remote (git + jj) realigned from the internal SSH host to ssh://forgejo@forge.weiwen.dev/weiwen/nibble.git.

config.toml schema (from PRD):

repo           = "weiwen/nibble"
forgeHost      = "forge.weiwen.dev"
imageName      = "sandcastle:nibble"
maxIterations  = 5
maxConcurrency = 1

[models]
planner     = "claude-opus-4-8"
implementer = "claude-sonnet-4-6"
reviewer    = "claude-opus-4-8"
prFixer     = "claude-sonnet-4-6"

Acceptance criteria

  • nix flake check in the shared repo passes, including a loadConfig unit test covering: valid TOML → expected ResolvedConfig; omitted optional fields → documented defaults; missing repo or forgeHost → throws.
  • nix run .#sandcastleImage | docker load in nibble builds and loads the sandcastle:nibble image; claude-code, tea, configure-tea, fmt, check are all on PATH inside it.
  • nix flake check in nibble passes, including checks.sandcastleImage.
  • nix run .#sandcastle in nibble runs the planner and emits a valid <plan> JSON validated against the schema, with no --repo flags in any tea call.
  • The plan prompt contains no hardcoded repo slug or forge URL.
  • nibble's origin (git and jj) resolves to forge.weiwen.dev; tea slug auto-derivation works from within a sandbox built off that remote.
  • nibble's Playwright dev shell still functions (scripts/preview.mjs runnable).
  • nibble's .sandcastle/ contains only config.toml, .env, CODING_STANDARDS.md, .gitignore — no JS or lockfiles.

Blocked by

None - can start immediately.

## Parent weiwen/sandcastle#1 ## What to build The walking skeleton for the extracted sandcastle core, proven end-to-end through the read-only **planner** lane driven from `nibble`. This slice establishes all shared infrastructure and the first consumer wiring; slices #2 and #3 add the write lanes on top without touching the infra again. Shared core (`weiwen/sandcastle`): - `flake.nix` in the shared shape (`flake-utils.eachDefaultSystem` + `rust-overlay`), inputs `nixpkgs`, `flake-utils`, `rust-overlay`, `llm-agents` (re-exported). - `lib.${system}.mkSandcastleImage { name, tag, extraContents ? [], extraEnv ? [], forgeUrl }` — wraps `dockerTools.fakeNss` + `streamLayeredImage` over the base package set + `extraContents`; sets PATH/HOME/cert/locale env plus `extraEnv`; entrypoint `sleep infinity`, workdir `/home/agent`. - Base `sandcastlePackages` (bash, coreutils, curl, findutils, gawk, git, gnugrep, gnused, jq, nix, nodejs, openssl, pkg-config, which, `tea`, `cacert`) + `claude-code` + the three baked `writeShellScriptBin` helpers: `configure-tea` (parameterized by `forgeUrl`, writes `~/.config/tea/config.yml` from `FORGEJO_TOKEN`/`FORGEJO_USER` at runtime), `fmt` (`cargo clippy --fix --allow-dirty --allow-staged -- -D warnings && cargo fmt`), `check` (`cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test`). - `apps.${system}.sandcastle` — orchestrator as a self-contained `buildNpmPackage` node app bundling `@ai-hero/sandcastle` + `zod` + the plan prompt; committed `package-lock.json` + `npmDepsHash`. - Orchestrator with a pure `loadConfig(tomlString) → ResolvedConfig` step producing `{ repo, forgeHost, imageName, maxIterations, maxConcurrency, models: { planner, implementer, reviewer, prFixer } }`, applying defaults and throwing on missing `repo`/`forgeHost`. The loop runs the **planner only** this slice: reads config, builds a sandbox from `imageName` with the `configure-tea` `onSandboxReady` hook, runs the planner (model from `models.planner`), and parses the `<plan>` JSON via the schema. No execute phase yet. - Genericized **plan** prompt: `--repo` dropped from all `tea` calls (slug auto-derived from the sandbox remote); branch format `sandcastle/issue-{id}` and label lifecycle hardcoded. First consumer (`nibble`): - `flake.nix` rewritten to the shared shape; adds the `sandcastle` input (`git+https://forge.weiwen.dev/weiwen/sandcastle.git`); defines `packages.sandcastleImage = sandcastle.lib.${system}.mkSandcastleImage { name = "sandcastle"; tag = "nibble"; extraContents = [ rustToolchain pkg-config libjxl libheif ]; forgeUrl = "https://forge.weiwen.dev"; }`; re-exposes `apps.sandcastle`; adds `checks.sandcastleImage`; preserves the existing Playwright dev shell. - `.sandcastle/` with `config.toml` (schema below), `.env` (Anthropic + Forgejo secrets only), `CODING_STANDARDS.md`, `.gitignore` (`.env`, `logs/`, `worktrees/`). No `main.mts`, `package.json`, `node_modules`, or `Justfile`. - `origin` remote (git + jj) realigned from the internal SSH host to `ssh://forgejo@forge.weiwen.dev/weiwen/nibble.git`. `config.toml` schema (from PRD): ```toml repo = "weiwen/nibble" forgeHost = "forge.weiwen.dev" imageName = "sandcastle:nibble" maxIterations = 5 maxConcurrency = 1 [models] planner = "claude-opus-4-8" implementer = "claude-sonnet-4-6" reviewer = "claude-opus-4-8" prFixer = "claude-sonnet-4-6" ``` ## Acceptance criteria - [ ] `nix flake check` in the shared repo passes, including a `loadConfig` unit test covering: valid TOML → expected `ResolvedConfig`; omitted optional fields → documented defaults; missing `repo` or `forgeHost` → throws. - [ ] `nix run .#sandcastleImage | docker load` in `nibble` builds and loads the `sandcastle:nibble` image; `claude-code`, `tea`, `configure-tea`, `fmt`, `check` are all on `PATH` inside it. - [ ] `nix flake check` in `nibble` passes, including `checks.sandcastleImage`. - [ ] `nix run .#sandcastle` in `nibble` runs the planner and emits a valid `<plan>` JSON validated against the schema, with no `--repo` flags in any `tea` call. - [ ] The plan prompt contains no hardcoded repo slug or forge URL. - [ ] `nibble`'s `origin` (git and jj) resolves to `forge.weiwen.dev`; `tea` slug auto-derivation works from within a sandbox built off that remote. - [ ] `nibble`'s Playwright dev shell still functions (`scripts/preview.mjs` runnable). - [ ] `nibble`'s `.sandcastle/` contains only `config.toml`, `.env`, `CODING_STANDARDS.md`, `.gitignore` — no JS or lockfiles. ## Blocked by None - can start immediately.
Author
Owner

Slice 1 implemented — walking skeleton + planner lane

Shared core pushed to main (50532720); nibble wired and committed locally.

sandcastle core: shared-shape flake.nix (flake-utils + rust-overlay + llm-agents) exposing lib.${system}.mkSandcastleImage, apps.${system}.sandcastle (self-contained buildNpmPackage), checks.${system}.sandcastle, re-exported claude-code. Base package set + baked configure-tea/fmt/check. Orchestrator has a pure loadConfig(tomlString) → ResolvedConfig (defaults applied, throws on missing repo/forgeHost) and runs the planner lane only. Plan prompt genericized (--repo dropped everywhere; no hardcoded slug/URL).

nibble: flake rewritten to the shared shape consuming git+https://forge.weiwen.dev/weiwen/sandcastle.git; packages.sandcastleImage, re-exposed apps.sandcastle, checks.sandcastleImage; Playwright dev shell preserved; .sandcastle/ = {config.toml, .env, CODING_STANDARDS.md, .gitignore}; git+jj origin realigned to forge.weiwen.dev.

Acceptance criteria

  • shared nix flake check passes, incl. loadConfig unit tests (valid→config, defaults, missing→throws)
  • nix run .#sandcastleImage | docker load builds/loads sandcastle:nibble; claude, tea, configure-tea, fmt, check all on PATH
  • nibble nix flake check passes, incl. checks.sandcastleImage (verified clean-room, no override)
  • plan prompt has no hardcoded repo slug or forge URL
  • nibble origin (git + jj) resolves to forge.weiwen.dev; tea slug auto-derivation works from a sandbox built off that remote (tea derived weiwen/nibble, failed only on a dummy token)
  • Playwright dev shell functions (scripts/preview.mjs runnable)
  • nibble .sandcastle/ contains only config.toml, .env, CODING_STANDARDS.md, .gitignore
  • nix run .#sandcastle full planner run — pending maintainer secrets: needs real ANTHROPIC_API_KEY/OAuth + FORGEJO_TOKEN/FORGEJO_USER in .sandcastle/.env (a real Claude API call). Verified up to that boundary: config loads, bundled prompt resolves, planner/sandbox start.

Notes

  • Bug fixed: bare fmt collided with coreutils' fmt on PATH (evie avoided this via just fmt). The shared flake now orders extraContents + baked helpers ahead of the base set so fmt/check resolve to our scripts.
  • checks.clippy deferred on nibble: nibble has pre-existing clippy debt (type_complexity, too_many_arguments, manual_contains, collapsible_if, useless_vec, items_after_test_module). Gating flake check on it would fail. Left for a separate cleanup — required before the write lanes (which run check) land.
  • nibble is committed locally, not pushed (out of scope for this issue).
## Slice 1 implemented — walking skeleton + planner lane Shared core pushed to `main` (`50532720`); nibble wired and committed locally. **sandcastle core:** shared-shape `flake.nix` (flake-utils + rust-overlay + llm-agents) exposing `lib.${system}.mkSandcastleImage`, `apps.${system}.sandcastle` (self-contained `buildNpmPackage`), `checks.${system}.sandcastle`, re-exported `claude-code`. Base package set + baked `configure-tea`/`fmt`/`check`. Orchestrator has a pure `loadConfig(tomlString) → ResolvedConfig` (defaults applied, throws on missing `repo`/`forgeHost`) and runs the planner lane only. Plan prompt genericized (`--repo` dropped everywhere; no hardcoded slug/URL). **nibble:** flake rewritten to the shared shape consuming `git+https://forge.weiwen.dev/weiwen/sandcastle.git`; `packages.sandcastleImage`, re-exposed `apps.sandcastle`, `checks.sandcastleImage`; Playwright dev shell preserved; `.sandcastle/` = {config.toml, .env, CODING_STANDARDS.md, .gitignore}; git+jj origin realigned to forge.weiwen.dev. ### Acceptance criteria - [x] shared `nix flake check` passes, incl. loadConfig unit tests (valid→config, defaults, missing→throws) - [x] `nix run .#sandcastleImage | docker load` builds/loads `sandcastle:nibble`; `claude`, `tea`, `configure-tea`, `fmt`, `check` all on PATH - [x] nibble `nix flake check` passes, incl. `checks.sandcastleImage` (verified clean-room, no override) - [x] plan prompt has no hardcoded repo slug or forge URL - [x] nibble origin (git + jj) resolves to forge.weiwen.dev; tea slug auto-derivation works from a sandbox built off that remote (tea derived `weiwen/nibble`, failed only on a dummy token) - [x] Playwright dev shell functions (`scripts/preview.mjs` runnable) - [x] nibble `.sandcastle/` contains only config.toml, .env, CODING_STANDARDS.md, .gitignore - [ ] `nix run .#sandcastle` full planner run — **pending maintainer secrets**: needs real `ANTHROPIC_API_KEY`/OAuth + `FORGEJO_TOKEN`/`FORGEJO_USER` in `.sandcastle/.env` (a real Claude API call). Verified up to that boundary: config loads, bundled prompt resolves, planner/sandbox start. ### Notes - **Bug fixed:** bare `fmt` collided with coreutils' `fmt` on PATH (evie avoided this via `just fmt`). The shared flake now orders `extraContents` + baked helpers ahead of the base set so `fmt`/`check` resolve to our scripts. - **`checks.clippy` deferred on nibble:** nibble has pre-existing clippy debt (`type_complexity`, `too_many_arguments`, `manual_contains`, `collapsible_if`, `useless_vec`, `items_after_test_module`). Gating flake check on it would fail. Left for a separate cleanup — required before the write lanes (which run `check`) land. - nibble is committed locally, not pushed (out of scope for this issue).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
weiwen/sandcastle#2
No description provided.