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

  • gcloud CLI 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

fmt

Biome format (writes)

lint

Biome check (no writes)

typecheck

tsc --noEmit per package

test

vitest per package with tests

spdx

SPDX header check on tracked .ts/.mjs

plan-lint

canonical Status vocabulary in plan pages

themes

regenerate the TUI themes from the Orchard palette data (--check for drift)

check-docs

synced-standards drift gate — stub until upstream supports language profiles (ADR-002)

validate

all of the above — the pre-push battery (fast, offline, free)

test:live

suites that make real model calls (needs ADC)

eval [--repeat N] [--tag T]

the judge corpus — the gate for any judge change (--repeat 3 minimum)

verdicts [n] [--why]

the adjudication audit trail, with asks and reasons

policy-diff [--write]

drift between the live permission seed and the package default

gitlab-check [expected]

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-By reminder.

  • 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 set core.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 fabricated settings.json with {"packages": ["npm:@gadhs/pi"]} in that dir to compose against the published siblings.

  • task consumer-sim packs 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.

IMPORTANT

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.

Edit this page · latest