Architecture
On this page
Principles
-
Never patch upstream security code. The permission engine (
@gotgenes/pi-permission-system) is consumed unmodified through its public extension points. Upstream fixes arrive by bumping the pinned version, never by carrying local patches. -
Simple checks are simple code. Anything a pattern or a file read can decide is decided deterministically, in about a millisecond. A model is consulted only for the genuinely ambiguous — and only about danger.
-
Ship policy in the package, not on disk. Rules, guidance, and themes live inside
@gadhs/piand update with it. The only things written to a user’s machine are a tiny routing config (seeded once) and files the user asks for. -
All model calls go through pi’s composed provider, so ADC/Vertex models work everywhere a model is needed — including the judge.
-
"Verified" means observed. Every enforcement claim in this repo is backed by a live run showing the mechanism firing, not by reading configuration.
What is in the box
@gadhs/pi (the one install)
├── @gotgenes/pi-permission-system vendored — deterministic permission engine
├── @gotgenes/pi-subagents vendored — helper agents
├── pi-web-access vendored — web search / fetch
├── @gadhs/pi-vertex ours — every publisher on Vertex AI
│ Claude · Gemini · MaaS — per-model regions · ADC auth · rotation pickup · turn-preserving retry
├── @gadhs/pi-modes ours — the policy layer
│ modes · rules · judge · subagent gate · themes · traces · status
├── @gadhs/pi-workflow ours — delivery-workflow enforcement
│ commit guards · cold-reader review · SPDX · setup drift (every mode)
├── @gadhs/pi-guidance ours — agency standards content
│ global digest · language profiles · composition seam · /gadhs-init
├── @gadhs/pi-agents ours — subagent definitions, seeded
│ Explore · Verify · Research · managed-file semantics
└── @gadhs/pi-remote ours — pivot's box side: /remote-control
relay link · Noise via @gadhs/pivot-wire · virtual RPC host · the ask broker's answerer
The meta package reaches its pieces through small loader shims (static re-export files), because npm cannot bundle workspace packages safely. Order matters: pi-modes loads first so its first-run seeding happens before the permission system reads its config.
Inside pi-modes
packages/pi-modes/index.ts is a composition root, not the code (#63). It
creates one mode controller, registers four wiring modules against it, and
keeps only what composes them: the bridge object, the two entry renderers,
the session hooks and the tools that are already thin over a pure module
(ask, count, /mode).
| Module | Owns |
|---|---|
|
The session’s mode state - mode id, tool names, applied-model ledger, plan
entry, status counters, last context, last-seen model - behind methods.
|
|
The governed memory store: load/save/archive, |
|
Spawn tracking, output contracts and their enforcement on foreground
results and fetched background reports, sidecar collection, the |
|
|
|
The advisory judge: authorizer registration, per-ask normalisation and stamping, status counters, the adjudication trace, the warm-up. |
The session hooks are registered in index.ts only, so their order is one
visible list: plan review cleared, contracts loaded, memory loaded, the
controller restores mode and resumed model (reading the resumed model
before any apply), the judge captures its registry and warms, then the
startup announcement. Modules register their own tools, commands and
tool-execution hooks, which are order-independent. The pure logic each
module wires lives beside it (plan-mode.ts, memory.ts,
agent-contract.ts, yield-tool.ts, authorize.ts, judge.ts,
review-pane.ts) and is tested without pi.
The life of one action
Every tool call the agent attempts flows through the same pipeline. Each stage either decides or passes the question down; the first decision wins.
agent wants to run something
│
├─ workflow guards (tool-call seam, guarded modes)
│ commit format · --no-verify · main-branch · SPDX · debt markers ·
│ glab identity · reflection pause [block, or FIX the input in place]
│
├─ permission engine (vendored, deterministic)
│ decomposes bash, resolves paths, applies the seeded routing
│
└─ "ask" → our authorizer chain link:
1. delegation gate may this mode spawn that agent? (list lookup)
2. mode rules allow / ask / deny by pattern (~1 ms)
3. highConsequence operator-named → ask the human (~1 ms)
4. workspace writes inside the repo → allow (~1 ms)
5. denial-loop guard too many refusals → ask the human
6. the judge one question: is this dangerous? (~5 s)
└─ anything deferred → the human's normal permission prompt
Two details that took real debugging to learn, recorded so nobody relearns them:
-
The permission engine matches rules against decomposed pieces of a bash command, but hands the authorizer the full command as evidence: an ask’s
details.commandis the matched unit (echo xforecho x | sh), and the whole line travels inpayload.evidence[]under the labelfull command. Rules and the judge here use both, so a pipeline cannot hide its dangerous half — and a rule pattern containing a pipe can never fire, because no decomposed unit contains one. -
write/editasks carry their destination in a different field thanreadasks; the extension normalises this before anything matches.
The bridge between pi-modes and pi-workflow
ADR-004 forbids the two packages importing each other: pi-workflow’s
guards must decide correctly with `pi-modes absent, and pi-modes must not
depend on a package whose release cadence it does not control. What they
share instead is a small object pi-modes publishes at
Symbol.for("gadhs:pi-modes-runtime") on globalThis at session start and
withdraws at shutdown; pi-workflow looks it up per call and falls back to a
no-op when it is absent (runtime-bridge.ts, modesBridge()).
What crosses it, and in which direction:
| Member | Direction | Purpose |
|---|---|---|
|
modes → workflow |
Annotates traces and logs with the current mode. Never a decision input: the guards run identically in every mode. |
|
modes → workflow |
One debug log for both
packages ( |
|
modes → workflow |
A guard’s deny or defer becomes a status-line count and an inline trace; the decision was already made before the call. |
|
modes → workflow |
Where the plan draft lives, so the
cold review reads the SAME file |
|
workflow → modes |
The one member that runs the
other way: the outcome of each plan review (verdict, reviewer, duration,
the reviewer’s words, the exact text reviewed), so the approval pane can
show it beside the plan (#43). Matched by trimmed plan text on the
|
Two properties the design leans on. Decisions never cross it: everything
above is observation or location, so a missing or stale bridge cannot weaken
a guard — standalone pi-workflow (no pi-modes installed) blocks exactly
what the bundled one blocks, pinned in packages/pi-workflow/test/standalone.test.ts.
It is withdrawn at shutdown: a reinitialised pi-modes republishes fresh
callbacks, and a withdrawn bridge leaves the no-op fallback rather than a
stale closure over a dead session.
Observability
Everything that decides, logs: inline traces in the transcript (who blocked
what, and why), a status line (mode, enforcement, verdict counts, running
helpers), a review log readable with node tools/task.mjs verdicts --why,
and an opt-in diagnostic stream (GADHS_PI_MODES_DEBUG=/path.jsonl) for
session lifecycle questions.
One principle binds every layer that rewrites an action rather than adjudicating it: the rewrite must be declared to downstream reviewers as a labelled fact. The commit guard’s mechanically appended trailer is announced to the reflection reviewer as tooling; an undeclared mutation is indistinguishable from scope drift, and a reviewer that flags it is right.
Key decisions
-
ADR-001 — TypeScript toolchain (pnpm, Biome, vitest, Node task runner).
-
ADR-002 — downstream-in-spirit of claude-quickstart: same standards, native pi mechanisms instead of copied files and shell hooks.
-
ADR-003 — agent configuration lives in
@gadhs/pi; claude-quickstart sunsets as the agent vehicle and keeps Rust project bootstrapping. Amends ADR-002’s sync-cutover direction. -
ADR-004 — package boundaries: policy (
pi-modes), workflow enforcement (pi-workflow), content (pi-guidance,pi-agents) and the remote client’s box side (pi-remote) are separate packages with separate review postures; the eval harness stops shipping. -
Rejected for v2:
@gotgenes/pi-subagents-worktrees— a worktree’d child loses the whole permission stack, and its failure path bypasses commit hooks. Revisit only alongside a writing subagent and upstream fixes.