sandcastle-kit
AGENTS.md1 starsChanged 27 days agoMITView on GitHub
Two kinds of task happen in this repository. Decide which one you are doing first. 1. Using the kit (install it, set up a project, triage, run): follow README.md -> "If you are an AI coding agent reading this", starting with sandcastle doctor. You will mostly work in the user's project, not here. 2. Changing the kit (fix a bug, add a feature): read on. It is published on GitHub and used daily on the maintainer's machine, so everything committed…
- Reads credentials
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,774 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository3 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- site/llms.txtllms.txt
- sandcastleSkill
The file
We split this AGENTS.md into the rules it gives an agent
6 rules. About 0 are common, 2 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 151
Write ./bin/sandcastle, not a bare sandcastle: from a clone or a worktree, the one on PATH is the installed kit, so those checks would …
- Very specific: line 157
Before a branch's work is pushed, bash test/full-check.sh repeats the checks on this machine and in Linux (agents' sandboxes are Linux, so …
- Distinctive: line 171
Test a guard (git hooks, protected paths, fingerprint) in a throwaway clone under a temp directory, never in a real project.
- Distinctive: line 181
Every file write outside a temp dir targets the project root.
- Very specific: line 189
Commit messages: type: subject (Conventional Commits), imperative, body says why.
- Very specific: line 193
Every user-facing change gets a CHANGELOG.md line under [Unreleased].
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
sandcastle-kit - instructions for coding agents
Two kinds of task happen in this repository. Decide which one you are doing first.
- Using the kit (install it, set up a project, triage, run): follow
README.md-> "If you are an AI coding agent reading this", starting withsandcastle doctor. You will mostly work in the user's project, not here. - Changing the kit (fix a bug, add a feature): read on.
This repository is public
It is published on GitHub and used daily on the maintainer's machine, so everything committed here is generic: no names, emails, tokens, private repo or client names, private ticket numbers or home-directory paths. Write a lesson as a pattern ("a review caught a money-handling bug no gate would fail"), never as an incident from a named project.
- Personal things live in
~/.config/sandcastle-kit/(.env,config.json,denylist); project things live in each project's.sandcastle/. A user's preference ("always keep skill X") belongs in their memory or project config, not inskill/or the README. - At runtime the kit writes only to the project's
.sandcastle/(gitignored there), a temp directory, or its machine-wide live-runs directory (src/live-runs.ts) - its own directory stays read-only. - The pre-commit hook (
git config core.hooksPath .githooks) runs gitleaks and the user's denylist. Every commit goes through it; a finding is fixed, not bypassed with--no-verify.
Layout
One line per path. What the larger modules own, and the rules each keeps (the scheduler's
requeue-once rule, the file hold, landing, the guard's fingerprint), is in docs/architecture.md:
read a module's section there before changing it.
| Path | What |
bin/sandcastle | Shell entry: runs src/cli.ts on Node's own type stripping, after src/node-check.mjs; sandcastle herdr ... runs src/herdr-plugin.ts alone, and sandcastle slot ... src/core-slot.ts |
src/node-check.mjs | Preloaded by bin/sandcastle ahead of the CLI: plain JavaScript that refuses, with the fix, a Node that cannot strip TypeScript types (older than 22.18, or stripping turned off) |
src/cli.ts | Every command (sandcastle help lists them) and the autonomy loop around burndown(); the help text is the file's header comment (read by src/help.ts), held by test/help.test.ts and test/command-help*.test.ts |
src/help.ts | The help text (the header comment of src/cli.ts), helpFor(command) and wantsHelp: a --help or -h anywhere in a command's arguments prints its help before anything runs; the Herdr plugin's entry shares it |
src/init.ts | sandcastle init: stack detection, config and Dockerfile scaffolding |
src/land.ts | Landing one branch in a sandbox: merge, regenerate generated files, gate, fast-forward the base; sandcastle land |
src/preview.ts | Landing preview: git merge-tree of each unlanded branch in the project image, nothing written to the repo |
src/burndown.ts | The orchestrator: base gates, then every attempt and landing through createSchedule, then verify and report; one ticket's pipeline (createPipeline, over ports a test fakes), the file hold's inputs, re-runs of carried branches, timings, what a pass said (saidOf: its tags or its REPORT_SCHEMA report) |
src/landing.ts | Landing one green branch (landOne, which returns facts and writes no verdict), the scheduler's land and host ports, and createHostGit, the mutex every host git write goes through |
src/ledger.ts | The ticket ledger: describe(ending), pure and exhaustive - the ticket state, outcome, view word and tracker text of every ending - and the writer that records it, and each requeue, as the scheduler tells them; also recordHandLanding, the merged outcome of a ticket sandcastle land merged |
src/resolution.ts | withObjectsOnly: every host merge-tree runs in a temp git directory that borrows only the shared objects, so a merge driver a sandbox wrote into .git never runs; mergeTree and mergeTreeSupported (git's own merge through it and its git 2.38 gate, shared with landOne's conflict precheck and conflictBefore); strayChanges: a conflict resolution checked against git's own automatic merge, so a resolution that dropped another ticket's lines is held; rebuildOnBase: a carried branch that merged a base since rewritten, re-created on the base's tip from its own commits in the sandbox (or held, with the cause named) |
src/schedule.ts | createSchedule(plan): the run's one path for attempts and landings - the requeue-once rule, the release of dependants, the file hold, the run's demand for slots, createQueue, the fix board (createFixBoard: who is repairing which failure) and the pause (PauseSource: no ticket or agent pass starts, at.juncture) |
src/report.ts | The closing summary (sandcastle report, and the end of every run): gather facts from run.json, git and the tracker (and the same run's earlier turns from history.jsonl, Facts.carried: what they left for a person is carried into the last turn's summary, marked (turn N)); render the seven sections. Also changelogSince (sandcastle report --changelog): the suggested changelog lines of every ticket landed since a ref, from history.jsonl and run.json, and the tickets sandcastle land merged, found by their merge subject on the base |
src/autonomy.ts | Autonomy levels: how many turns one sandcastle run may make, which tickets are re-runnable, the follow-on turn for tickets queued while a run was live (followOnTurn, lateTickets) and the level-1 question |
src/run-settings.ts | The run settings: resolveSettings (environment, project config and machine settings in, the settings out: the autonomy level, repair attempts and concurrency, asked and effective, and the usage pause's threshold) and settingsGroup, the group each turn's run record carries and the status view's SETTINGS lane shows |
src/size.ts | sandcastle size: recommends the pool's maxSandboxes and maxGates from the runtime's VM (docker info) and the measured sandbox peaks (measuredPeak), through injectable Readers; read-only, the assumed figures are constants at its top |
src/peaks.ts | Sandbox peak memory: memory.peak read inside the container (samplePeak, recordPeak), the memory pressure (PSI avg10) read in the same loop (readPressure), the CPU pressure beside it (readMachineCpu: the host's own /proc/pressure/cpu, or on macOS an estimate from the load average, the judged figure with CPU_PRESSURE_HIGH and cpuHigh; readCpuPressure: the sandbox cgroup's own, recorded and never judged; SANDCASTLE_TEST_MACHINE_CPU=none is the tests' seam), and the machine-wide peaks.jsonl (readPeaks) that size reads |
src/usage.ts | Opt-in plan usage guard (USAGE_CHECK=1); usageToken picks its credential, only when the sandboxes spend CLAUDE_CODE_OAUTH_TOKEN and no ANTHROPIC_API_KEY: the host's Claude Code login, read-only, else that token. Also the plan's usage on screen, with no request: usageFromEvent (a rate_limit_event line of an agent's stream) and usageFromCodexEvent (Codex's rate_limits, by window length), one reader per provider, and watchUsage, which keeps each provider's newest reading across the run's agent logs in the run record's usage, and each ticket's running pass's tokens (tokensFromEvent, UsageWatch.settle) for the status view's TOKENS column. Also USAGE_PAUSE (parseUsagePause, createUsagePause: the run pauses itself when a window of a provider it uses reaches the threshold, or an agent hits the limit, and resumes a minute after the window's reset; createPauseHandling: the run's side of a pause, over injected ports) and the guard's preference for the agents' readings under 10 minutes old. sandcastle usage is usageCommand (a fresh reading from recordedUsage, else one request, or the API-key note) |
src/load-pause.ts | LOAD_PAUSE: parseLoadPause (60 by default, 0 / false off), createLoadPause (the hold a gate pass waits in before its gates slot while the machine's CPU pressure is high, over injectable ports: the 2-minute release, the 30-minute give-up per run, onChange), loadPauseLine and loadRecord; src/gates.ts keeps the run's hold (setLoadHold) and runGates awaits it |
src/api-key.ts | Spending an API key is never silent: the red line (red, as the status view's NO_COLOR and terminal rule), doctor's and the start line's words, and confirmApiKey, the question before a run, preflight or lean --measure bills API credits (--api-key or SANDCASTLE_API_KEY=1 with no terminal); projectApiKeySpend in src/sandbox.ts says whether one would |
src/notify.ts | End-of-run notify command from the personal config.json |
src/upgrading.ts | Unacted Upgrading notes against the project's update record (.sandcastle/.run/kit-updated), changesLines (sandcastle changes), and kitVersion |
src/herdr.ts | Herdr helpers and the run's view: the tab, panes, agent-state reports, sidebar tokens |
src/terminal-host.ts | terminalHost / inHerdr1: which terminal host a command runs in - herdr-core (hc) only when the terminal HC_TERMINAL_ID names is an ancestor of this process, else Herdr TUI by HERDR_ENV as always, else none. Asked lazily (never at import, so queue --json and --version --json never probe); with no HC_TERMINAL_ID nothing is spawned. Every Herdr TUI check in src/ goes through inHerdr1. A --detach child is handed its parent's host (SANDCASTLE_TERMINAL_HOST, HOST_ENV) and keeps it without probing (adoptHost, which removes it from the environment so nothing the run starts inherits it) |
src/herdr-core.ts | The herdr-core (herdr-core) view: openCoreStatus (the status view as an hc terminal in the caller's tab, argv only, --outlive-parent --no-notify --keep, reused by name), its own record .sandcastle/logs/core-view.json (never read by the Herdr TUI plugin's readers), teardown (only what this run instance recorded), openCoreView (what openSandboxView returns on core: with herdr.panes: "all", a slot terminal per sandbox, --rm --parent <status>, its state in core-slot-N.json and its log link herdr-pane-N.log), slotAddresses (the status view's terminals: note), askingInCore, and the status view's OSC 7501 records (runRecords, coreRecords: only with SANDCASTLE_CORE_VIEW matching the record's instance and a terminal on stdout, only on change). Light: the status view imports it |
src/core-slot.ts | sandcastle slot <n> --instance <id>, what a core slot terminal runs: tail -F of its log link printed as plain text (plainLog: no escape an agent log holds reaches the terminal), its ticket and step as the terminal's OSC 7501 record and OSC 2 title on each change, and its own end once the run is not live (liveness) or core-view.json names another instance |
src/osc7501.ts | OSC 7501 strings (record, clear) and the OSC 2 title (windowTitle): pure, the format checked against hc |
src/view-facts.ts | What both hosts' views share: runCounts, progress, a foreground's showsStatus / bareShell, and PHASE_LOG (the log a phase writes); pure and light |
src/click-hint.ts | The status view's click hint: the outer terminal sensed from the attached Herdr clients (ps, herdr session list), the override (SANDCASTLE_CLICK_HINT, herdr.clickHint), and the modifier sandcastle status hands status.sh |
src/live-runs.ts | The machine-wide live-runs directory (RUNS_DIR, under KIT_CACHE) that the Herdr tab bar and the Claude Code mod read |
src/herdr-plugin.ts | sandcastle herdr: configure (link the plugin, add or remove the config block), and the plugin's own verbs - the tab bar line, popups, the ticket card a Ctrl-click opens, the Agents view |
herdr/ | The Herdr plugin: herdr-plugin.toml (its version follows each release; a test checks) and entry.sh, through which every action, pane and hook runs |
src/tracker.ts | The Tracker interface and its adapters: GitHub Issues, and Markdown ticket files. Which one a project uses, and its triage label names (detectFromDocs) |
src/blockers.ts | What holds a ticket back: Blocked by refs (GitHub, Linear, ticket files), openBlockersNow, blockedNote |
src/touches.ts | The Touches: line of a ticket body, and unmergeableFiles; a scheduling hint, never a guard |
src/lint.ts | lintQueue(): the queue's shape for sandcastle queue --lint; read-only advice |
src/agents.ts | Models, effort, review fallback, Codex cross-review (and the readout its command prints for the plan usage row); with REPORT_SCHEMA=1, --json-schema on Claude's command and reportCapture (the schema-validated report, and when the signal and the result line came) |
src/sandbox.ts | Credentials (and token policy), images (hash tags, pruning), sandbox mounts and hooks, ownCommits and staleBaseParents (the old base a rewrite left in a carried branch's history, read from its merge commits and from the fork point recordFork keeps), the CPU limit by kind of sandbox (sandboxCpus: a ticket's, or gate-only); KIT, the kit's own directory |
src/claude-tools.ts | The kit's one list of Claude Code's built-in tool names (CLAUDE_CODE_TOOLS), managedDenied() (the managed settings' deny list) and nearestTool: what config.ts checks a lean.keep tool:<Name> entry against |
src/gates.ts | Gate runs, and the green-base check before any agent starts (sandcastle gates) |
src/lean.ts | Lean inventory and plan, per-worktree strip, hook check, token measurement; hooksPathOutside: a core.hooksPath no sandbox has (absolute or outside the project), which doctor, lean and the base check (hooksPathWarning, src/gates.ts) flag |
src/detach.ts | sandcastle run --detach (output to .sandcastle/logs/run-output.log), wait, stop, and pause and resume (the pause's control file, .sandcastle/.run/paused, which names the run it was asked of and, for a pause the run took for its plan's usage, the cause and the time it resumes at) |
src/guard.ts | Host safety: git hooks, auto-gc and background maintenance off, the .git fingerprint and branch-tip backup (.sandcastle/backup.git), a sandbox's worktree records held to git's own, the check before every sandbox closes (checkBeforeClose), protected paths, the run lock |
src/pool.ts | Machine-wide sandbox and gate slots (a freed slot goes to the longest wait across runs, and within that run a landing, base or verify gate's wait goes before its ticket gates'; a slot names its run; live runs register and split the sandbox slots by share, up to each run's demand), and the lock-file helper the run lock shares (pid and token, guarded takeover) |
src/start-clock.ts | startClock(now): where a run's start spent its time, before the status view opens. step(name), aside(name, fn) (a wait left out of the total, the API-key question) and line(): the one Start took N s: ... line burndown() prints after Status view: when the start took 10 s or more, naming the steps of 1 s or more, heaviest first. Writes nothing (not timings.jsonl, not the run record) |
src/stamp.ts | localStamp: local time with its offset, the one zone a gates log's section headers (markLog) and gate lines share |
src/run.ts | Preconditions, prompt rendering, agent logs, the run record (.sandcastle/logs/run.json) and history, the estimate, the status pane |
src/worktree-lock.ts | Worktree locks against git worktree prune; time-bounded gates, run without the kit's tokens and with their values redacted from the output |
src/setup.ts | Interactive install: links, credentials file, then doctor |
src/doctor.ts | Setup self-check; the single source of truth for what a working install needs |
src/runtime.ts | runtimeProblem: the setups a run cannot work on (root anywhere; Podman behind docker, rootless or userns-remapped Docker on Linux), over injectable docker reads; doctor prints it as a FIX, sandcastle run refuses with it. readDockerInfo: the one docker info a start reads (10 s; no answer is an OperatorError), which cli.ts hands to the check and burndown() to the CPU limit and the pool warning, and a detached child takes from its parent (DOCKER_INFO_ENV) |
src/errors.ts | OperatorError: a refusal the operator acts on; cli.ts prints its message with no stack trace and exits 1, any other error keeps its stack; containerStartTimeout and dockerRunTimeout turn a container-start timeout (Sandcastle's 120 s, openOrAbandon; lean's hook check) into a SlowStartError (an OperatorError) that names the cause and the fix, which a landing tells apart from a failed .git check: it costs that ticket only |
src/generated.ts | Generated files: covers, regensFor, and resolveGenerated (take a side, rerun setup and regen in the sandbox, commit); also the shell quoting and host git identity the base merge uses |
src/config.ts | The ProjectConfig type, loader and validation; pnpmStore's run-time mount and setup step |
src/versions.ts | Which Claude Code and Codex versions the image gets, resolved on the host and part of the image tag |
prompts/ | Implement, review, repair and resolve templates; the kit fills {{KIT_*}}, Sandcastle the rest |
container/ | Mounted read-only at /etc/claude-code in every sandbox: managed settings and git-guard.sh, the hook that refuses damage to the shared .git; report.schema.json, the report schema REPORT_SCHEMA=1 gives Claude passes |
docker/base.Dockerfile | The shared base image. PID 1 is tini (it reaps orphaned processes) and CMD is sleep infinity: a debugging docker run -it <image> bash gets a shell |
docker/agents.Dockerfile | The shared agents image, FROM the base: Claude Code and Codex, whose versions src/versions.ts resolves (the file's ARG defaults are its offline fallback). ensureImage copies them onto each project's image, so a release rebuilds this image and a copy layer, never the base or a project's layer; a path the file installs to is a path finalDockerfile (src/sandbox.ts) copies |
status.sh | The status view's entry, a wrapper of a few lines: it execs src/status-view.ts with its arguments, so sandcastle status, status 0 and the Herdr popup run the TypeScript view and the pane's command line names status-view.ts, which Herdr looks for. Bash 3.2-safe (macOS) and it holds no logic: a status fix goes to the TypeScript view. No jq anywhere on the host: the view reads its records itself |
src/status-grid.ts | The TypeScript status view's terminal-width and grid helpers, width in cells (colour codes and links none, wide characters two, combining none), cutting with … that never splits an escape or leaves a link or colour open, aligning, splitting a width by weights and the rule between two rows of cells; test/status-grid.test.ts holds it to the figures the old bash view's functions gave (test/status-grid-golden.json) |
src/status-snapshot.ts | The status view's snapshot: the run record and its tickets, the logs' last activity, branch facts, sandboxes, the pool and the queue (sandcastle queue --json as a child process, never the tracker imported: ADR 0003), each source read by a collector (createCollector, createSources: in the background, one refresh at a time, within a deadline, the last good reading kept and marked stale on a failure) and drawn by snapshotOf; readSnapshot reads each once, for status 0 |
src/status-rows.ts | ticketRows(snapshot): pure, the table's rows - group, word, derived state, note, time, link, order - and the legend's counts, by the shared tables of mod/hooks/run-record.ts; test/status-rows.test.ts drives it with built snapshots |
src/status-render.ts | renderFrame(snapshot, table, size): pure, the frame's lines with the grid helpers - the logo and the lanes beside it while three fit (else under it), the rest on one summary line, and the table's columns at set widths with ACTIVITY the rest; test/status-render.test.ts draws it at every width from 60 to 330 columns |
src/status-view.ts | The TypeScript status view's entry: .sandcastle or exit 1, the pane's size and colour, one frame in one write; the live view (keys, signals, the quit mark, the terminal handed back), which paints from the snapshot and never waits on a source - on a reading's change, a 1 s tick and a resize once the pane is still - while the collectors refresh on the interval, and stops them on every way out |
src/status-keys.ts | keysOf: the live view's keys from one read - an escape sequence is one key, the bytes after it keys of their own, and a sequence a read ends inside is held for the next (an arrow and q in one read used to swallow the q) |
test/fixtures.ts | fakeTracker(overrides?): a project's tracker from resolveTracker's own defaults. A test builds its tracker with it, never a full literal (test/fixtures.test.ts holds that); import it after XDG_CONFIG_HOME is set, like src/ |
test/mod-retry.ts | retryOnTimeout(output): whether test/mod.test.ts runs claude plugin test mod a second time - only when every failure in the output is the harness's own timed out after limit, never an assertion failure (test/mod-retry.test.ts proves it from output samples) |
test/pool-sim.ts | What the pool tests drive src/pool.ts with: the other runs as files a made-up pid owns, and inject's probe answering "is that pid the kit" for all of them. A test that takes pool slots without that probe sees its own slot as stale and waits until it is killed |
test/kit-process.ts | everyPidIsTheKit (a process check that says every pid is the kit) and kitLikeProcess (a real process ps reads as one): what a test of a live run passes |
test/cli-spawn.ts | runKit, runNode, startKit, startNode: how a test starts the kit as a child process - process.execPath, the launcher's V8 flags read from bin/sandcastle (the Node 24 exit deadlock, #164) and a time limit that fails the test with the command named, and children that die with the test process (test/parent-watch.ts); test/cli-spawn.test.ts refuses a test file that names a tsx entry itself |
test/in-temp.sh | Runs a command with its own TMPDIR, removed when the command ends or the script is killed (INT, TERM; then every process the command started is ended first): pnpm test, test:shard, test:file, test:related, test:weights and test/run-shards.sh go through it, so a host run leaves no sandcastle-* directory behind; test/temp-cleanup.test.ts holds it |
test/parent-watch.ts | Preloaded into every node child test/cli-spawn.ts starts: the child kills itself once the test process that started it is gone (a killed run leaves no fixture alive with parent pid 1); startNode also kills its children when the test process exits |
test/hermetic-env.ts | Preloaded into every test file by pnpm test, test:shard, test:file, test:related, test:weights and test/run-shards.sh (--import ./test/hermetic-env.ts, beside no-stray.ts): TMPDIR is the realpath of os.tmpdir(), the running node's directory leads PATH and a no-op docker (exit 0: lists nothing, stops nothing) follows it - a test never reaches the machine's real Docker, and one that needs its own puts it ahead (test/docker-stub.ts) - and HERDR_*, TMUX*, SANDCASTLE_* (not SANDCASTLE_TEST_*) and the settings and credential names src/ reads are removed; a test that needs one sets it itself; no git identity is inherited (GIT_CONFIG_GLOBAL an empty file, GIT_CONFIG_NOSYSTEM=1, user.useConfigOnly=true), so a fixture repo sets its own, and git's background maintenance is off (maintenance.auto=false: its detached process holds a lock in .git/objects); no remote is reached (GIT_ALLOW_PROTOCOL=file: doctor and --version --json look up the kit's newest release) and XDG_CACHE_HOME is a temp directory of the test file's own, so nothing a test caches lands in the developer's ~/.cache. test/hermetic-env.test.ts fails when src/ reads a name that is neither scrubbed (SCRUBBED) nor on its keep list |
test/no-stray.ts | Preloaded into every test file by pnpm test, test:shard, test:file, test:related, test:weights and test/run-shards.sh (--import ./test/no-stray.ts): a test file that writes to stdout or stderr outside quietly (test/quiet.ts) fails, so a green gate log has only reporter lines (test/no-stray.test.ts holds that) |
test/shard.ts | CI's shards: the test files of TEST_SHARD=i/n, packed by measured weight (pnpm test:shard); a file it has no weight for still runs. Its WEIGHTS table is written by test/weights.ts |
test/related.ts | pnpm test:related <files...>: the test/*.test.ts files that mention a given path (repo-relative, or its basename with the extension: run.ts, never the bare word run) or any name that file exports, printed and then run through the runner test:file uses; no match says so and exits 0, and more than 40 files is refused with the list (exit 3) unless --all is given - a final check beside the typecheck when a change touches text other tests pin (test/related.test.ts) |
test/weights-reporter.ts | A node:test reporter: each file's top-level test:pass and test:fail durations summed, printed as one JSON reading. Every CI shard runs it beside the spec reporter and keeps the reading as a weights-<os>-<shard> artifact; pnpm test:weights runs it one file at a time here |
test/weights.ts | test/shard.ts's WEIGHTS table from readings (files, or directories of them): each file's mean per platform, then across platforms, the files at 2.5 s or more; --write puts it in place. Refresh from a green CI run: gh run download RUN_ID --pattern 'weights-*' --dir DIR, then node test/weights.ts --write DIR |
test/status.test.sh | The status view against a made-up repo and run records, through status.sh (STATUS_BASH names the bash that runs it); each frame rendered once and each row's state checked, at 80 columns and again at 180 (pnpm test runs both, through test/suite.sh), and the second pass under bash32 |
test/suite.sh | pnpm test's runner: the node:test command it is given, with test/status.test.sh's two passes started beside it rather than before it (they are serial node starts, once about half of every test gate), their output printed after the reporter's and a red one failing the suite; test/status-wide-band.test.ts holds it |
test/status-fixture.ts | The status view's project fixtures (buildFixture: live, legacy, ended): run record, logs, branches, a fake sandcastle queue, a fake docker, and fakes of date, nproc and sysctl for a fixed clock (SANDCASTLE_TEST_NOW) and zone, so a frame is the same on every machine. Every fake logs its pid, which the orphan check reads |
test/status-run.ts | How a test starts the status view: the TypeScript view with the fixture clock preloaded (spawnStatus, scriptArgs, statusCommand, drawFrame, never status.sh by name; test/status-wrapper.test.ts holds the wrapper); drawFrames, one reading of a fixture laid out at several widths (test/status-frames.ts), for a test of the layout, its first frame held to the view's own; readFacts and compareFacts (the facts of a status 0 frame without its layout, a hidden fact unknown and a cut note a prefix); startSession, the live view on a pty driven from outside (keys, Ctrl-C as a byte, resize, INT, TERM, HUP), and orphans |
test/status-clock.ts | Preloaded into the TypeScript status view by test/status-run.ts: SANDCASTLE_TEST_NOW is its clock, so the view has no test seam |
test/*.test.ts | One file per behaviour, named after it (ls test/ first: a test file a ticket names may not exist). The skill* tests read the skill files and this table's skill/ row |
skill/ | The sandcastle agent skill, shared by Claude Code, Codex and OpenCode: SKILL.md (the router, loaded for every action) and one file per action: init.md, audit.md, queue.md (with the ticket-body rules audit.md shares), run.md (start to closing summary, models and effort), status.md, pause.md (pause and resume a run, and why a hold is a pause and not a stop) and update.md. Before editing it, read its portability rules in docs/architecture.md |
mod/ | The optional Claude Code mod: hooks/register.tsx (the hooks), hooks/run-record.ts (the run record's types, imported by src/ too), hooks/run-live.ts (the one "is this run live" rule, imported by src/ too), hooks/run-state.ts (the drawing). Runs inside Claude Code, imports nothing from src/ |
templates/ | What sandcastle init copies into a project |
examples/ | Invented example project configs |
docs/INSTALL.md | Requirements, what setup does, the manual install, updating |
docs/setup.md, queue.md, running.md, commands.md, configuration.md, concurrency.md, safety.md, herdr.md, claude-mod.md, troubleshooting.md, how-it-works.md | The user documentation the README's Documentation table points to, one topic each. A behaviour's reference lives in its topic file; the README keeps the pitch, quick start, a short how-it-works, the command list and the agent section. docs/configuration.md's tables are what sandcastle changes compares between releases |
docs/img/ | The README's pictures. GitHub plays no SVG animation in a README, so the moving ones are GIFs, rebuilt by record-gifs.sh there; the castle's colours are the status view's |
docs/releasing.md | Version bumps, the tag and the release notes' shape |
test/full-check.sh | Every check below on this machine, in a Linux container and the outbound scan of the commits not yet on origin/main, all legs side by side; the suite runs as test/shard.ts's shards through test/run-shards.sh, their count from test/shard-count.sh (the cores shared between the passes at once) |
site/ | The project website on GitHub Pages: static HTML, CSS and plain scripts, no build step. site/js/status-render.js is generated from the status view's renderer by scripts/site-renderer.mjs (pnpm site:renderer): rerun it and commit the output after changing src/status-grid.ts or src/status-render.ts (test/site-renderer-copy.test.ts fails otherwise) |
CHANGELOG.md | Keep a Changelog; each release's Upgrading notes are what /sandcastle update acts on |
@ai-hero/sandcastle is a dependency, not vendored, with a pnpm patch (patches/; why, in
docs/architecture.md -> "patched dependency"). Its behaviour is in
node_modules/@ai-hero/sandcastle/dist - read the source there when unsure.
Verifying a change
A change is ready to commit when README.md, skill/ and docs/ no longer describe the old
behaviour (grep them for its words, not only for the function's name), and all of these pass:
pnpm lint # oxlint (.oxlintrc.json); a false positive is disabled at its line, with the reason
pnpm exec tsc --noEmit
pnpm test # every test/ file against fixtures and temp repos (no Docker, no model calls),
# with `bash -n` on every tracked shell script
./bin/sandcastle doctor
# from inside a test project (no model calls), giving the path to this checkout's bin/sandcastle:
/path/to/this/checkout/bin/sandcastle status 0
/path/to/this/checkout/bin/sandcastle leanWrite ./bin/sandcastle, not a bare sandcastle: from a clone or a worktree, the one on PATH is the
installed kit, so those checks would never exercise the change (doctor notes this, it does not fail).
A change to mod/ has checks of its own, which pnpm test skips without a recent Claude Code:
read the mod/ section of docs/architecture.md.
Before a branch's work is pushed, bash test/full-check.sh repeats the checks on this machine and
in Linux (agents' sandboxes are Linux, so BSD tools break only here), and scans the new commits
for secrets, the denylist and home-directory paths. It takes minutes: it names its log directory at
once and each leg's result on stderr as the leg ends, but the summary only when every leg is done,
so start it in the background with both streams in a file, and read its RESULT: line.
In this repository's sandbox image (.sandcastle/Dockerfile), bash32 is macOS's bash 3.2 built
from source, and test/status.test.sh (part of pnpm test) runs a second time under it. Every
host shell script (all but container/) parses under it too, and test/host-shell-portability.test.ts
refuses a bash 4 construct or a GNU-only flag without a fallback in one: shellcheck has no 3.2
dialect. A line that must keep one says why with # portability-ok: <reason>. Before
committing a change to a shell script, run shellcheck -S error on it; shellcheck is installed there too.
sandcastle run, preflight and lean --measure spend the user's model allowance; ask first.
Test a guard (git hooks, protected paths, fingerprint) in a throwaway clone under a temp
directory, never in a real project.
Conventions
- TypeScript run by Node's own type stripping (Node 22.18+), ESM,
.tsimport extensions, strict mode, erasable syntax only (no enums, namespaces or parameter properties;tscrefuses them). No build step. - A test that reads a function out of a shell script uses
eval "$(sed ...)", neversource <(sed ...): macOS CI's bash is 3.2, which cannot source a process substitution (test/bash32-source.test.tsholds it). - Comments explain why - the failure a line prevents - not what the code does.
- Every file write outside a temp dir targets the project root. Every shell call passes arguments as an array; an interpolated string is only for a script run inside the container.
- British English in prose; hyphens with spaces ( - ) rather than em dashes.
- Wrap prose in
docs/*.mdat about 100 columns, a sentence to start a line, and never keep a paragraph on one line: a grep or diff of a 10,000-character line overflows an agent's tool output, and two tickets that append to it conflict on it.test/docs-line-length.test.tsrefuses a line over 400 characters outside tables and fenced code. A wrapped line never starts with a spaced hyphen ( - ): Markdown reads it as a list item; the hyphen ends the line before. - Commit messages:
type: subject(Conventional Commits), imperative, body says why. - A change made by hand goes on a
feature/,fix/,docs/orrefactor/branch cut fromorigin/main, and reaches main through a pull request the maintainer merges. A run's own landings and the release commit are the exceptions. - Every user-facing change gets a
CHANGELOG.mdline under[Unreleased]. One an existing project may need to act on - a new default that changes what a run does or spends, a new convention, anythingsandcastle initnow writes differently - also gets an Upgrading note, and if a project needs a check or a fix, a step in the skill'supdateaction (skill/update.md). Write that step as a check that is safe to repeat, never as "since version X". A pull request that changes shipped files and adds nothing under[Unreleased]fails the Changelog check (scripts/changelog-check.mjs, which lists the paths that count); label itno-changelogwhen nobody outside the code would notice, a refactor say.
Releases
Releasing - a version bump, a tag, release notes - follows docs/releasing.md: read it first.
Agent skills
Issue tracker
This repository's own work is tracked in GitHub Issues (gh). See docs/agents/issue-tracker.md.
Triage labels
Matt Pocock's five default role names, unchanged (the kit reads this file too). See docs/agents/triage-labels.md.
Domain docs
Single-context: GLOSSARY.md and docs/adr/ at the root, created when a term or decision settles. See docs/agents/domain.md.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

