Local Development
On this page
Prerequisites
-
Node.js ≥ 20, pnpm ≥ 9 (
corepack enable) -
pi (
@earendil-works/pi-coding-agent) for end-to-end extension testing -
gcloudCLI with ADC configured, for Vertex integration testing -
GPG key configured for commit signing (required)
Setup
git clone git@gitlab.com:gadhs/standard/package/pi.git && cd pi
pnpm install
git config core.hooksPath .githooks && chmod +x .githooks/* # mandatory
git config commit.gpgsign true && git config user.signingkey <YOUR_KEY>
The task runner
node tools/task.mjs <verb> is the single automation entrypoint (ADR-001; no
loose shell scripts):
| Verb | Does |
|---|---|
|
Biome format (writes) |
|
Biome check (no writes) |
|
|
|
vitest per package with tests |
|
SPDX header check on tracked |
|
canonical Status vocabulary in plan pages |
|
regenerate the TUI themes from the Orchard palette data ( |
|
synced-standards drift gate — stub until upstream supports language profiles (ADR-002) |
|
all of the above — the pre-push battery (fast, offline, free) |
|
suites that make real model calls (needs ADC) |
|
the judge corpus — the gate for any judge change ( |
|
the adjudication audit trail, with asks and reasons |
|
drift between the live permission seed and the package default |
|
which GitLab identity is actually in effect |
GitLab writes (glab issue/MR operations) authenticate via GITLAB_TOKEN
in the environment — load it per shell (set -a; source .env.local; set +a)
or via direnv, or on the same command line ahead of glab (the guard reads
that as the token being present; the set -a is what makes the file’s
KEY=value lines reach glab); the file itself stays gitignored. The workflow
guard blocks glab writes when the token is absent, because glab otherwise
falls back SILENTLY to whatever ~/.config/glab-cli holds — on a shared machine,
likely another team’s bot, and a write that succeeds under the wrong
identity emits no signal at all (#23). task gitlab-check
project_85791508 confirms who you actually are before it matters.
validate is deliberately offline and free; the live suites and the judge
corpus cost real model calls and are separate verbs.
Debugging a live session
# Isolated pi with the working-tree extensions (throwaway agent dir):
PI_CODING_AGENT_DIR=$(mktemp -d) pi -e ./packages/pi-modes/index.ts ...
# Mode/model lifecycle diagnostics (pi itself has no logging):
GADHS_PI_MODES_DEBUG=/tmp/d.jsonl pi
This repo runs the PUBLISHED @gadhs/pi like every other repo on the box —
there is no project-local .pi/ harness (removed at the 0.2.0 cutover).
Working-tree changes are exercised by the unit suites, by task
consumer-sim (which packs and boots the tree), and on demand by the
isolated pi -e recipe above; they reach a normal session only through a
release, which is exactly the discipline consumers live under.
Hooks
.githooks/ (activated via core.hooksPath):
-
commit-msg— type-prefixed subject (feat|fix|chore|refactor|docs|test), ≤72 chars,Co-Authored-Byreminder. -
pre-commit— staged SPDX check, untracked-debt-marker gate. The J1–J8 reflection pause is NOT here: it ships in the extension as a tool_call interceptor so it applies in every repo, not only those that vendored these hooks and setcore.hooksPath. -
pre-push— signature verification +task validate.
Never --no-verify. If a hook is wrong, change the hook.
Testing extensions against pi safely
Use an isolated agent dir so your real ~/.pi/agent is untouched:
TMP=$(mktemp -d)
PI_CODING_AGENT_DIR="$TMP" pi -e ./packages/pi-vertex/index.ts --list-models
rm -rf "$TMP"
For Vertex live tests you also need ANTHROPIC_VERTEX_PROJECT_ID and a seeded
sentinel credential; see the package README.
Publishing (GitLab npm registry)
@gadhs-scoped packages publish to the GitLab registry (see .npmrc; auth via
${GITLAB_TOKEN} in the environment). Publishing runs from CI on tag push.
Versioning is SemVer 2.0.0 with annotated signed tags on main and a matching
CHANGELOG.adoc entry.
Two rehearsals, one gate each way: node tools/task.mjs consumer-sim packs
the working tree and proves the stack comes up from an install of it (the
pre-publish gate); consumer-sim --registry [version] skips packing and
installs the published package from the group registry — the exact
experience of pi install npm:@gadhs/pi on a fresh box, network included
(the post-publish verification). The tag pipeline runs the pre-publish
half itself, as the rehearse job before publish, with --no-model:
every check that needs no model — the pack, the install, the flat root,
the files lists, the loaders resolving, pi loading every extension
from the installed tree against a model that does not exist, and the two
seedings that load performs — runs, and the rest are skipped by name
(#129). That last check is the one 0.38.1
needed: a pi-vertex whose entry imported a module its files list did not
name resolved and did not load, and only pi starting could see it. Run
consumer-sim in full, with a model, before a tag anyway; the job is the
net, not the habit.
A third rehearsal answers a standing question rather than gating a release:
node tools/task.mjs landstrip-probe [--keep] [--gadhs <ver>] [--landstrip <ver>]
installs the published stack beside pi-landstrip in a scratch agent dir and
measures, from session records, whether a kernel sandbox composes with the
guards and what it costs (#18). It is expected to report the agent-file
incompatibility as a FAIL until landstrip fixes it; re-run it when landstrip
releases.
A fourth checks the Vertex catalog against Vertex: node
tools/window-probe.mjs [--models ref,ref] [--below 0.85] [--above 1.1]
[--out file] sends each model (every MaaS entry by default) one headless pi
prompt just under its claimed contextWindow and one just over, halving on
refusal until a run answers, and reports whether the claim holds, the error
text the adapter surfaced, and whether pi-ai calls it an overflow. Run it
when an entry’s window changes or a new one lands; a window claimed larger
than the real one deadlocks a session (#78). It spends real tokens - about
two windows' worth per model at the model’s input rate, more when it halves.
The model battery (#79, reshaped by #108) is the rehearsal that measures the models themselves, one permutation at a time - a task × a subject (a model and its knobs) × a stack (the extensions the lane runs under):
node tools/task.mjs battery run --task judge --model haiku --sweep thinking=off,low --runs 3 node tools/task.mjs battery run --task explore --model 3.8-flash --max-tokens 8000 --case pi/ --stack bare node tools/task.mjs battery fill [--retry-errors] [--refresh REF] [--task T] # the manifest's gaps node tools/task.mjs battery summarise | render | tasks | models | knobs REF
Tasks are files under tools/model-battery/tasks/ (production’s prompts,
read at run time - or any task file by path); records land in
tools/model-battery/store/<task>/<stack>/<subject>/, one JSON per call
with the fingerprint of what it was measured against, and a slice already
there is kept on a relaunch (--force remakes it; fill --retry-errors
remakes only the records whose error was a rate limit or a wall-clock
timeout - run it at --jobs 1 after a parallel fill). The fingerprint is
provenance, not a to-do: a record is kept whatever it was measured
against, nothing re-measures because the tree moved, and a re-measure is
a person’s decision made with fill --refresh REF, run --force, or
run --store DIR for a confirmation off to the side (#130). battery.json is the
manifest - the permutations the page argues from and the matrix rules, and
battery render writes Model battery: every shipped model, per role, as permutations from the store’s summary
and the manifest. Every verb that would call a model prints a cost projection
first and refuses over --budget (default US$300) before any call; a knob
the model cannot honour is refused before any call too (battery knobs REF
prints what a model accepts). The coder task gives
the model bash inside a throwaway copy outside the repo, under bubblewrap
with everything but the copy read-only and the battery’s own tree - the
hidden suites, the reference solutions, every other lane’s recorded output,
the store wherever it lives - masked behind an empty
tmpfs, along with ~/.ssh, ~/.gnupg, ~/.aws
and the operator’s session transcripts (bwrap must be installed; the role
refuses to run without it). The lane is its own PID namespace, so whatever
the model started - a test runner’s workers, a server it forgot - dies with
the lane, whether pi exits or the timeout kills it; three vitest workers
from a lane run before the sandbox existed were found four days later at a
core each (#127), and coder.test.mjs pins both ways of ending a lane
against that. The hidden suite then runs the model’s code
under the same sandbox, its report written to a fresh directory outside
the copy, so a file the model pre-wrote is never read as a score. A lane
with a write path (write, edit, bash or subagent among its tools) still
aborts the run if the repository’s git status changes under it - the
first unsandboxed run had a model find the repo through its copy’s
node_modules link and rewrite a task’s starter in place; a read-only
lane is not guarded by that, so an explore fill survives you editing
files that are not the harness. The harness itself - the battery’s code
and tasks, tools/lib, packages/pi-vertex - is a different matter
(#116): each lane spawns pi, which imports yield-tool.mjs and
pi-vertex from the working tree at that moment, while the runner keeps
the code it started with, so a merge under a running fill measures a
mixed harness without any lane failing. The battery digests those files
as it starts, again before every lane spawns and again before its record
is written, and refuses, naming the changed paths, on any difference; a
lane in flight during the move is dropped, every record in the store was
measured under the start harness, and battery fill resumes from them. Do not edit or merge
battery or pi-vertex code while a fill runs - finish it or stop it. The
ADC credential under ~/.config/gcloud is masked too (#106): a sandboxed
lane’s pi mints its Vertex tokens from a GCE-metadata shim the battery
runs on loopback outside the sandbox, serving a one-hour token minted
from the real credential, so what the model’s bash can reach is that
token, never the refresh credential. An unsandboxed lane (explore) reads
the file as any session does.
Evidence. A lane’s record keeps the numbers; what they were computed from
sits beside it in <case>-<run>.evidence/ (#99). A coder lane keeps
patch.diff (the model’s src/ against the starter, roots rewritten to
starter/ and model/), hidden-report.json (vitest’s full report) and
provenance.json (sha256 of every task file, the prompt and the packaged
catalog; the repository commit; model, thinking level and tools). Explore
lanes keep provenance too. Every headless lane also writes
transcript.jsonl - assistant text, each tool call’s arguments and each
result’s text, clipped per entry - which is where a yield rejection’s
wording or a 300-call loop can actually be read; transcripts are large and
stay on the box that ran the lane (.gitignore), the rest is committed with
the run. Run the battery on purpose, not from a hook. Its own tests (task test
includes tools/model-battery) cover the scorers, the runner, the corpora
and the renderer without a model; the reviewer scorer’s calibration against
the 2026-09-07 hand scores runs with them.
Explore corpora. The explore role asks its questions of more than one
repository, because a helper that finds its way around this small
TypeScript monorepo says nothing about a large one (#86). Each corpus is a
directory under tools/model-battery/cases/explore/ with a pin.json
naming a commit and, for a repository other than this one, a public clone
URL and tag; the runner makes that clone shallow into
~/.cache/gadhs-battery/repos/<corpus> on first use (no credentials) and
refuses the corpus if the clone’s HEAD is not the pin. Two ship: pi
(this repository) and ruff (astral-sh/ruff 0.16.6, ~800k lines of Rust
across 52 crates). Path recall is reported per corpus and the matrix
reads the worst, so fit means fit on the large repository too.
The questions are not written by whoever writes the key. The first set was, and it showed (#87): the phrasing paraphrased the identifier, the architecture was stated rather than left to be discovered, and scoring function names forced the question to describe the function - a grep task keyed to one author’s answer, on which strong models sit at ceiling. The rule now: a fresh-context model that has never seen a key reads the repository and writes what a new developer would ask (a symptom, a task, a how-does-it-work), naming no identifier from the repository’s code - no file, crate, package, type, function, constant or configuration key of its own; a domain term such as a Python packaging field is allowed - and stating no mechanism; a different agent then derives the key from the tree - the files a correct answer must cite, with alternatives where more than one is legitimate - and symbols are not scored. The pin records who authored the questions and when. A test forbids any key identifier or multi-word file stem in a question and refuses symbol keys. Adding a corpus is a directory, a pin, and twelve questions produced that way; the role checks every key path exists at the pin before any call.
The ripwire arm. --role explore-ripwire runs the same helper, corpora
and cases with one addition: a read-only ripwire tool (verbs for,
callers, impact, expand, situ) and a paragraph on when to reach
for it, so the page can show whether a call-graph map changes what the
helper finds (#85). It is not a matrix role; its table sits beside
explore’s. The runner fetches the pinned ripwire release into
~/.cache/gadhs-battery/bin/ on first use and verifies it against the
sha256 pinned in tools/model-battery/lib/ripwire.mjs before trusting
it (the digest GitHub serves beside the tarball proves only that the
download arrived intact); a platform with no pin is refused.
BATTERY_RIPWIRE_BIN names another binary instead, and the record then
says an override ran. --role explore-ripwire-first is the same arm
with the note inverted (answer from the map; file tools as a last
resort), for asking whether a model’s reads are habit or instruction. A box that cannot obtain one gets a refusal
when the role is asked for, never a silent run without the tool.
When to re-run it. Not per release. The battery measures production’s
prompts against the catalog, so it has something new to say only when one
of those changed: a model added or a window, allowance or rate corrected; a
shipped prompt or contract edited (the review prompt, the judge rubric, an
agent body); or a default being argued. Most re-runs should be partial -
one new model across every role is a few dollars, --role explore --role
judge for everyone about $30, the reviewer role on one candidate about a
dollar - and land in the same --out directory as the full run they
extend. A full grid is ~$60 and three to four hours at --jobs 6; Vertex
rate-limits a model hit from several lanes at once (429s recorded as
errors), so after a full run delete the 429 records and relaunch at
--jobs 1 - the relaunch keeps every record already on disk and makes
only the missing calls.
Dogfooding
The dogfood gate (#9) was passed under a working-tree harness
(.pi/settings.json loading the extensions as local paths); the harness was
retired at the 0.2.0 cutover, and this repo now consumes the published
distribution like everyone else. What remains of that era, for anyone who
needs a live session against UNRELEASED tree code:
-
The isolated recipe:
PI_CODING_AGENT_DIR=$(mktemp -d) pi -e ./packages/<pkg>/index.ts— a throwaway agent dir, explicit extension loading, nothing global touched. Add a fabricatedsettings.jsonwith{"packages": ["npm:@gadhs/pi"]}in that dir to compose against the published siblings. -
task consumer-simpacks the tree and boots it cold — the closest thing to the old always-on harness, and unlike it, provenance-exact. -
If a standing project harness is ever reintroduced, pi’s scope/dedup rule is the tool: a project entry with the same npm identity as a global package wins, and empty-resource entries (
"extensions": []…) silence a global package per-project. That is how the old harness suppressed overlapping globals without touching anyone’s settings.
Documentation site
The docs site uses the shared GADHS Orchard theme
(antora-theme) in the
human-services palette.
pnpm install # theme extensions must be installed
npx antora antora-playbook.yml # output in build/site
Two independent parts. The UI bundle is a prebuilt ui-bundle.zip fetched
by pinned, immutable URL — nothing is compiled for it, and bumping the version
in the playbook is the whole upgrade. The pipeline extensions are npm
packages that execute inside the Antora build, so they must be installed in a
node_modules beside the playbook (our workspace root). Their require:
values are deliberately extensionless: Antora treats a require containing a
file extension as a path relative to the playbook rather than a package.
What the extensions add: SVG admonition icons, a .md mirror of every page,
and /llms.txt + /llms-full.txt — machine-readable outputs for agents
consuming these docs.
Search dual-ships Orama and Pagefind, with a runtime "Low bandwidth" toggle
that swaps providers. Orama also builds an opt-in in-browser semantic lane
(semantic: true). That self-hosts a ~58 MB MiniLM model, but ordinary
readers never download it — it is fetched only when someone turns semantic
search on. The costs are Pages storage and an embedding pass per build.
The semantic embedder (onnxruntime-node) is a native module with no musl
prebuild, so the pages CI job runs on Debian node:22 and cannot extend the
alpine .node-pnpm base. Turning semantic off is what would allow alpine
again.
The @gadhs scope resolves from the group registry, which serves reads
publicly, so a docs build needs no token. Publishing still targets each
package’s own project endpoint via publishConfig.