Architecture

On this page

Principles

  1. 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.

  2. 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.

  3. Ship policy in the package, not on disk. Rules, guidance, and themes live inside @gadhs/pi and 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.

  4. All model calls go through pi’s composed provider, so ADC/Vertex models work everywhere a model is needed — including the judge.

  5. "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

mode-controller.ts

The session’s mode state - mode id, tool names, applied-model ledger, plan entry, status counters, last context, last-seen model - behind methods. applyMode, switchMode, refreshStatus, the startup restore sequence, the #32 drift watchdog. Every other module reads through it; nothing copies its state at registration.

memory-wiring.ts

The governed memory store: load/save/archive, /remember, /memory, the remember tool, the staging prune, the system-prompt block.

subagent-wiring.ts

Spawn tracking, output contracts and their enforcement on foreground results and fetched background reports, sidecar collection, the yield tool.

plan-tools.ts

enter_plan_mode, exit_plan_mode (read the draft, mount the approval pane, keep or approve), the last plan review reported over the bridge, the model-neutral round trip.

judge-wiring.ts

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.command is the matched unit (echo x for echo x | sh), and the whole line travels in payload.evidence[] under the label full 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/edit asks carry their destination in a different field than read asks; 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

modeId()

modes → workflow

Annotates traces and logs with the current mode. Never a decision input: the guards run identically in every mode.

debugLog(event, details)

modes → workflow

One debug log for both packages (GADHS_PI_MODES_DEBUG), so a guard’s record and the mode’s sit in one stream.

guardDeny(rec) / guardTrace(rec)

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.

planDraftPath(cwd)

modes → workflow

Where the plan draft lives, so the cold review reads the SAME file exit_plan_mode will present (#44). The slug rule stays in pi-modes, published rather than duplicated. Optional, because the bridge is optional.

planReviewed(record)

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 pi-modes side — no key of `pi-workflow’s is ever reproduced there.

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.

Edit this page · latest