pi-daddy
Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.
Package details
Install pi-daddy from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-daddy- Package
pi-daddy- Version
0.22.0- Published
- Sep 4, 2026
- Downloads
- 1,384/mo · 95/wk
- Author
- mojo_manyana
- License
- MIT
- Types
- extension
- Size
- 2 MB
- Dependencies
- 0 dependencies · 3 peers
Pi manifest JSON
{
"extensions": [
"./extensions/grants.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-daddy
Capability governance for pi sub-agents. A grant can only ever shrink as it passes down a delegation
tree, so a sub-agent can never hold more of the tool surface than its parent — enforced by pi's own
--tools allowlist, with an append-only ledger of what was granted and what was refused whenever one is
configured.
Both qualifiers are load-bearing and are not buried: a child granted bash can escape governance entirely
(ADR-0012, measured), and recording is opt-in — explicitly by environment or by running /grants init once
for this project. See What this governs, and what it does not.
0.13.0 was the first published release. Earlier versions were developed in-repo and never shipped, so the breaking changes in the changelog describe how this package arrived at its current behaviour rather than anything you need to migrate from.
It has been reviewed twice — once by its author against written hypotheses, then by four independent agents each given one hypothesis to attack. That pass found eight further defects, including a file lock that admitted two writers into its critical section, and all of them are fixed here. The reasoning for every decision is in CHANGELOG.md and in the
docs/06-decisions/ADRs upstream.Known gaps are stated rather than implied — see Status at the end of this file. The largest is deliberate: a child granted
bashescapes governance entirely, by decision.
What this governs, and what it does not
It governs the tool surface: which tools pi exposes to a model. That part is structural, not advisory —
--tools is enforced by pi core, and an -e-loaded extension cannot re-add its own tool past it
(measured). A child granted read has no write tool, and no prompt can talk it into having one.
It does not contain an agent that holds an execution primitive. A child granted bash can run
env -u PI_GRANTS_GRANT pi … and obtain a completely ungoverned descendant — no ledger entry, no depth
increment, no grant. Measured, not theorised: docs/probes/g5-bash-escape. env -u is incidental; the
mechanism is "the child can execute programs", and governance state lives in that program's environment.
Containing that is the operating system's job, and is out of scope here (ADR-0012).
So: bash is gated by default in a governed session — a human is asked before any child receives it —
and gating is closed under subsumption, so gating write gates bash too. Neither makes the escape
impossible. Both stop it happening silently, which is the difference that matters when the realistic threat
is a confused or prompt-injected agent rather than a determined one.
The invariant
effective = ( requested ∩ parentGrant ∩ ceiling ) \ (gated \ approved)
Escalation is impossible by construction, not by policy. The root holds the full catalog, so grants are free from the top; every level below can only subtract. No policy engine, no LLM on the security path.
Depth control falls out for free: spawning is itself a capability. Withhold tool:delegate and the child is
a leaf — it receives neither delegate nor delegate_all.
Cardinality is bounded separately (ADR-0008, amended 2026-08-12). The invariant above says what a child
may hold and nothing about how many children exist; a blocking delegate bounded that to one by accident,
and fan-out removes the accident. PI_GRANTS_FANOUT is a subtree budget: a call
spends from B before dividing the remainder among the children, so no subtree can exceed what its root
held. A per-call cap of K with depth D would still permit K^D — the same exponential wearing a smaller
number — so the bound is subtractive instead, and composes across processes with no shared state.
It is not a session total, and the distinction is measurable: the value is read once from the
environment and never decremented, so one session may issue successive delegate_all calls at the full
width. What is bounded is the shape of any one tree, not how many trees a turn builds. Bound depth with
PI_GRANTS_MAX_DEPTH; nothing bounds the number of turns.
Why pi's --tools is the enforcement point
Measured, not assumed (probes 9–11 in docs/probes/pi-fabric-eval):
pi --tools read -e npm:pi-fabric→ the model cannot callfabric_exec.pi --no-tools -e npm:pi-fabric→ likewise blocked.- No flag →
fabric_execworks.
So pi core hard-blocks extension tools, and an explicitly -e-loaded extension cannot re-add its own tool
past the allowlist. That is why this package needs no runtime inside the descendant: it computes the
allowlist and hands it to pi.
Definitions are Agent Skills, and allowed-tools is the ceiling
A sub-agent is a skill you spawn. Definitions are SKILL.md files under pi's own skill roots — a directory
containing SKILL.md is one definition named after the directory, and a top-level .md is one named
after the file:
.pi/skills/deploy/SKILL.md project, wins on a name collision
~/.pi/agent/skills/review/SKILL.md global
---
name: docs-writer
description: Fixes documentation typos.
allowed-tools: Read, Write
---
Fix typos in the documentation. Do not restructure anything.
The body becomes the child's system prompt (--append-system-prompt); allowed-tools becomes its ceiling.
allowed-tools |
Ceiling |
|---|---|
| absent | undeclared — not spawnable at all. The refusal names the file. |
| empty | nothing; a child with no tools |
Read, Write or Read Write |
tool:read, tool:write. Space-separated is the spec's form; commas are tolerated because it is what people type |
ext:pkg/tool, skill:x, agent:y |
passed through as written |
| anything else | lowercased and prefixed tool: — so Glob becomes tool:glob, which the catalog then refuses as unknown |
contains a pattern, e.g. Bash(git:*) |
refused, not reinterpreted |
Two of those rows are the load-bearing ones.
Absent means undeclared. Under the pi-subagents frontmatter this package used to read, a missing tools:
key meant pi's full default toolset, so an undeclared definition was the most powerful kind and every
parse failure produced a wildcard — the direction that caused R-28 and review finding F18. The sense is now
inverted: a typo or an unreadable YAML form costs a refusal instead of a grant.
A sub-tool pattern is refused because every reinterpretation of it is wrong. pi's --tools matches whole
tool names, so granting bare bash for Bash(git:*) would widen a deliberately narrow declaration,
dropping it would silently narrow and yield a child that mysteriously cannot work, and matching the
pattern inside a wrapper would be a security control implemented by string-matching a shell command.
Identity comes from the path, never the frontmatter name. pi keys skills by their directory, so
trusting the frontmatter lets our view and the loader's disagree about which file a name refers to. The spec
requires them to match anyway, so a mismatch is the file's defect and not something to honour.
Universal capabilities
fabric_exec is treated as universal — granting it is equivalent to granting the whole catalog, because
it reaches pi.write, pi.bash, and unrestricted agents.run. This is measured, not theoretical: a child
granted tools: [] (nothing at all) plus recursive: true still spawned a grandchild that wrote to disk.
assertNarrowing() therefore throws if a supposedly narrow grant contains one. A narrow grant with
fabric_exec in it is full authority wearing a narrow grant's clothing.
Narrowing is checked before the gate, and the order is load-bearing rather than stylistic: because
assertNarrowing refuses whatever a human says, the old order reported "requires explicit approval" for a
spawn that could never be approved — telling the operator to go and find a human who cannot help. For the
same reason no dialog is raised for a spawn retaining a universal capability: asking would be worse than
useless, since a session- or always-scoped yes given there is banked and reused for later spawns that
do proceed.
A delegator that legitimately holds fabric_exec and knowingly wants a child to have it cannot spawn
that child. assertNarrowing's allowUniversal flag exists but is deliberately not plumbed through; the
first real need for that override is the evidence it should be added.
The three tools
delegate({ task: "summarise src/", agent: "docs-writer" }) // preferred: an operator-authored definition
delegate({ task: "summarise src/", tools: ["read"] }) // when no definition fits
delegate_all({ children: [ {…}, {…}, {…} ] }) // several at once, each independently governed
- Prefer
agent. Its capabilities and its instructions were written by the operator, and the session must holdagent:<name>to name it at all.tools:is the escape hatch: the model chooses the tool list, which is why that form can never persist an approval (see below). - You cannot grant what you do not hold. Refusals name the capability and are recorded.
- Spawning is itself a capability. Grant
delegateand the child can sub-delegate; withhold it and the child is a leaf — the extension is only passed to children that hold it, so the machinery isn't even present.PI_GRANTS_MAX_DEPTHremains a backstop. - A refusal is a tool error, not an answer. All three tools throw, because
AgentToolResulthas noisErrorfield — pi sets it only whenexecutethrows, and a normal return is hardcodedisError: false. Until 0.5.0 the tool returnedisError: true, which was silently discarded, so every refusal this package made was recorded by pi as a successful tool call. Found by the integration suite on its first run. - A child cannot outlive or overwhelm you. Output is capped (1 MiB), there is a wall-clock timeout
(
PI_GRANTS_CHILD_TIMEOUT, default 1200s) withSIGTERM→SIGKILLescalation so a child cannot ignore its way past it, an abort is honoured even if it arrived before the spawn, and a child that exits non-zero, times out or is cancelled comes back as a tool error naming which — not as an answer. - Fan-out is synchronous and bounded. At most 8 children per call, and the subtree budget bounds the
total across the whole tree. Every child goes through the same plan-gate-audit path as a single
delegate;delegate_alladds only cardinality and sibling identity. There is deliberately no background mode (ADR-0015): fan-out carried most of the value and background carries nearly all of the lifecycle holes, and because the turn still owns its children the parent cannot exit before them, the tool-call signal is still live, and there are no ids to dangle across a compaction. - One child can be refused while its siblings succeed, and every outcome is reported. A fan-out that hid its refusals would let an orchestrator summarise four reviews when only three happened.
Verified live, with a real model (test-integration/delegation.it.ts):
| Scenario | Result |
|---|---|
Holds read,write,delegate; delegates tools:["read"]; child told to write a file |
No file exists. --tools is the enforcement point and this is what it buys |
Holds read,delegate; tries tools:["read","write"] |
Tool error: cannot grant tool:write — this session does not hold it (capability escalation blocked); ledger denied:["tool:write"], blocked:true |
PI_GRANTS_LEDGER pointed somewhere unwritable |
Delegation refused — asking for an audit trail makes it a precondition |
Three ways to delegate
| Tool | Shape |
|---|---|
delegate |
one sub-agent |
delegate_all |
several at once, independent and unaware of each other |
delegate_chain |
several in order, each receiving the previous one's output |
A chain's handoff is wrapped in a labelled, nonce-delimited fence, so the previous agent's output arrives as data — and because the nonce is minted after that agent finished, it cannot forge a closing delimiter to escape its own fence. The label itself is framing and this README will not pretend otherwise; the nonce is the mechanism.
A legacy uncorrelated chain is gated upfront: every step is planned first, and every approval it needs is
asked for before the first step starts. Exact correlated steps gate only after their composed task exists;
binding a template and spending it on different generated instructions would be false assurance. One dialog
per capability and definition names the step that needs it. Decline an upfront gate and nothing runs. A step
that could never run refuses before anyone is asked; Allow once covers exactly the step it named. A failed
step stops the rest, and you still get everything completed—except BLOCKED_CRITICAL_ASSURANCE, which
remains a failed tool call. Each step spends one unit of the fan-out budget.
Two executors, one plan
Either a captured child process, or a visible, attachable herdr pane — the same governed argv, the same
--tools enforcement, somewhere you can watch it.
Which one runs is decided by a probe (ADR-0031). PI_GRANTS_HERDR is three-state: unset probes once at
session start (herdr tab list, 2s bound) and uses panes if a server answers; 1 demands herdr and
refuses every delegation if it is unreachable, rather than quietly relocating; 0 demands subprocesses and
skips the probe. Which one was chosen is printed at session start, shown by /grants, and recorded per child in
the ledger.
Still never auto-detected from herdr being on PATH — a binary with no server behind it would make every
delegation fail at tab create, on a path nobody chose. Only a reachable server counts.
A child's pane goes in your own herdr workspace by default, so switching to one is a tab away. Constraints found by building it are in docs/probes/g16-herdr — herdr has
no --env (the grant rides on the pane, which the agent's shell inherits), agent start types argv into a
shell so a multi-line argument must be staged to a file, and agent wait --until idle matches the state the
agent was already in, so settling requires a state counter to advance.
Persistent Herdr dashboard
When this pi process is itself hosted inside Herdr 0.8+, /grants dashboard opens a managed right split:
PI-DADDY
◆ principal-feature · critical · declared
● plan running 0:42 · pane w7:p13
└─ ⛔ deploy refused 0:00
depth 2 · 1 active
The plugin ships inside pi-daddy and is linked globally only after an explicit Install and open choice.
Literal Not now and Never ask choices are persisted; dismissing or losing the dialog stores nothing.
/grants dashboard never installs silently and prints the exact manual command when the plugin is absent. It
checks the bundled plugin root and protocol before suggesting that a disabled plugin be enabled. Panes and
ledgers stay workspace/tab-specific: reuse rechecks the pane's current workspace/tab, a wrong-host open is
closed and rejected, and malformed nested pane state refuses rather than risking a duplicate. A stored entry's
workspace/tab/ledger must also agree with its hash key before reuse. Invocation cwd sets the first pane
process directory but is not pane identity, so the same workspace/tab/ledger still reuses one pane across caller directories. Opening uses a right split targeted at this pi pane with --no-focus.
The view is a read-only ledger projection. Yellow is authorised/starting/running, green completed, red failed or refused, and grey incomplete/historical. Old completed subtrees collapse; active ancestry stays visible. It never displays task text, prompts, tool arguments, child output, or raw corrupt lines. Displayed ledger values must satisfy the v3 identifier grammars (unsafe frozen-v2 values are redacted), and Unicode C1/bidi or other control/format characters are removed before terminal output.
Provenance markers are explicit: P planned phase, O observed inline activity, V
controller-validated transition, E pi-daddy-enforced child, D caller-declared correlation. A principal
run can label itself through run_id, phase, effective assurance and policy_label; pi-daddy does not parse
principal workflow prose or invent pending/completed phases.
Approving a gated capability
gated capabilities are ones a session holds but may not pass on without a human saying so.
grants: approve tool:bash for deploy?
task: roll out the staging build
> Deny
Allow once
Allow for this session
Always allow in this project (30 days)
What a yes is about differs by call form, and that decides which scopes are offered.
delegate({agent: X})is approved againstXitself — an operator-authored file the session must holdagent:Xto name at all. That is a human-authored subject, soalwaysis on offer.delegate({tools: […]})is approved against the constant<delegate>and is offered onlyonceandsession. The only things naming that child are the task and the tool list, both model-chosen, and a key the model controls is not a key.
A persisted approval is void the moment what it was granted for changes. It pins both the definition's
allowed-tools and its body digest, and /grants approvals reports which check failed rather than
silently showing fewer rows:
| Verdict | Means |
|---|---|
type-changed |
allowed-tools gained or lost a capability after approval |
instructions-changed |
the tools are untouched but the body was rewritten — or the entry carries no body pin at all, which fails closed |
expired |
past its 30 days. An unparseable expiry is treated as expired: a cache we cannot read the expiry of is a cache we do not trust |
foreign-cwd |
the entry belongs to another directory. Nobody in this checkout was asked |
type-missing |
the definition was deleted or renamed, and a new file could later claim the name |
The store lives outside the governed workspace, one file per governed directory under
$PI_CODING_AGENT_DIR/grants-approvals/ (default ~/.pi/agent/). It used to sit at <cwd>/.pi/grants-approvals.json, which was self-defeating in
this package's own recommended configuration: PI_GRANTS_GATED=tool:write means "may use write, may not
pass it down without a human" — and a session that may use write can write the approvals file. A
reviewer forged an entry end to end, including a matching definition so the ceiling compared equal, and got
a ledger line reading approvalSource: "persisted" that was indistinguishable from a real approval. A
legacy in-workspace file is ignored, not migrated, with a warning naming it: importing it would import
exactly the entries the move exists to stop trusting. This does not defend against a child holding
bash (ADR-0012); it closes the self-defeating case, not a boundary the package does not have.
Writes are atomic (temp file plus rename, in the same directory so the rename really is atomic) and refuse
to follow a symlink, so a crash or a concurrent writer cannot leave a half-written file, and a corrupt file
cannot make the next legitimate write destroy every other entry.
Other properties worth knowing:
- Legacy session/always approvals ride down the tree with the grant, intersected with what each child
actually receives, so
approved ⊆ grantholds at every level.onceis dropped. A correlated approval instead binds the exact definition, task, requested/effective sets, workspace/context and parent, and never crosses a delegation boundary — approval for one probe cannot become subtree authority. - Concurrent callers share one dialog per
capability@subject, but only share the answer when it was about more than one spawn.session,always, a decline and an error answer everyone; aonceis consumed by exactly one caller and the rest are asked their own question. Measured before the fix: four concurrent delegations gatingtool:bash, one dialog, one click of Allow once → four grants, with the human having seen only the first caller's task. - Holding
tool:*is authority to grant widely, never authority to skip a human. A gate is the operator's, not the delegator's. A wildcard holder reaches the ordinary dialog like anyone else. - A child can never be asked anything. It runs
--printwith no interactive user, so a gate it hits has only two outcomes: satisfied by an inherited approval, or refused with a reason naming the fix. This is pi's own behaviour — non-interactive modes install a no-op UI context whoseselectresolvesundefined— so a background delegation hitting a gate is refused, not hung. - The ledger distinguishes three flavours of "no":
denied(an agent asked for more than it holds — an escalation attempt),humanDenied(a person was asked and declined — working as designed), andgatedBlockedwith noapprovalSource(nobody was there to ask — an operator should pre-approve).humanDeniedis set only for a genuine decline, never for a dismissal, a timeout, or a dialog error — those get their own outcome kinds so a caller can tell them apart.
/grants approvals list them, with why any are being ignored
/grants revoke tool:bash@deploy take one back
/grants revoke --all
Pruning is lazy and scoped to one project. Entries are validated on read and removed only on write, so
an expired or type-changed entry lingers in the file until the next approval or revoke. It cannot reach
another project: since ADR-0020 each governed directory has its own file, which is what makes
/grants revoke --all mean this project rather than this machine.
Verified live, end to end
The whole lifecycle is exercised by test-integration/approval.it.ts against a real pi process, and its
model tier watches a real model, a real dialog and a real file. In one test, in this order: the model called
delegate({agent: "bash-user"}); the dialog was raised naming the definition as its subject with
Always allow on offer; the entry landed on disk pinning the ceiling and the body digest; the ledger
recorded approvalScope: "always", approvalSource: "prompt"; a different pi process then ran the same
delegation with zero dialogs and a ledger line reading approvalSource: "persisted"; and after
rewriting the body — frontmatter byte-identical, so only the digest can catch it — the dialog was raised
again and the dismissed delegation failed.
Seven further tests in that file cost no model tokens and cover the reload and every void reason above.
What this does not establish. Dialogs are driven through pi --mode rpc, which is the same
ctx.ui.select call the TUI dialog serves; the TUI's own rendering is not exercised. The earlier
transcripts in docs/probes/approval-ux describe an interceptor path that no longer exists — they are kept
as the record of that run and are not a description of this version.
Runtime enforcement for external controllers (0.18.0)
All fields are optional; existing callers behave unchanged.
correlationcarries join-only run/task/workspace/context IDs, opaque policy metadata, base/head/tree SHAs and sequence floors. Supplied IDs/digests never authorize; trusted digests are computed separately.workspace: {workspace_id, access}resolves throughPI_GRANTS_WORKSPACE_REGISTRY, validates a canonical Git worktree root, and sets initial CWD. A caller cannot label a write-capable grant read-only. Kernel util-linuxflockallows one pi-daddy-governed writer per canonical root;setpriv --pdeathsigplus helper attachment stops the writer process or herdr tab on parent death before release. This does not confine paths or exclude unrelated writers;bashremains an escape.BREAKING in 0.19.0 — routing now requires a capability. A delegation naming
workspace_id: Wneedsworkspace:Win the caller's grant, or it is refusedWORKSPACE_NOT_AUTHORIZED. Every grant that routes must add it:PI_GRANTS_GRANT="tool:read,tool:delegate,workspace:W".pi-daddy initlists the registered ids commented in.pi/grants.env. A child can only route on to ids it was granted itself, so this is also the list of what any descendant could reach;workspace:*exists but is held and never inherited, which makes it the wrong answer for anything but a single-worktree setup.PI_GRANTS_GATED=workspace:Wasks a human first. Enforced by pi-daddy before the spawn, not by pi's--tools— seedocs/SPEC.mdon the enforcement classes.BREAKING in 0.19.0 — a registry id must match
[A-Za-z0-9][A-Za-z0-9._/-]*. An id is now the tail of a capability id, so it has to survive the grant grammar. Slashes and dots are fine, so a worktree named after its branch (feature/x) works. Refused, with the file and the id named: whitespace (it splits a definition'sallowed-tools), commas and newlines (they split a grant),*(it collided withworkspace:*), shell metacharacters (they reach a generated file you are told to paste from), non-ASCII, and@ + % = ^ ! ? ~ { } [ ]or a leading_,-or.. One bad entry refuses the whole registry, so rename before upgrading. The regex is the specification; that list is a summary.Also new in 0.19.0: the registry must be a regular file under 1 MiB. A FIFO or device there would block session start rather than fail, and the read is bounded so a file that grows after its size is checked is refused rather than allocated. What is not checked: ownership, permissions, and whether a descendant holding a write tool repointed an entry — routing attenuates by id, not by destination (
docs/probes/g37-registry-tamper, tracked as R-137).Refusals retain current prose and add stable codes such as
CAPABILITY_ESCALATION,GATED_UNAPPROVED,APPROVAL_SCOPE_MISMATCH, andWORKSPACE_WRITE_CONFLICT.Ledger v3 adds unique execution/parent identity, joinable capability/lease/lifecycle/check events, and provenance-labelled workflow facts while retaining frozen v2 and legacy readers.
pi-daddy/check-runnerselects an operator-named absolute executable+argv definition, never a shell command string. Check IDs use the v3 ASCII identifier alphabet and are refused before execution if they do not fit their generated receipt identity. It strips sensitive inherited environment, enforces timeout/output caps, executes a private copy of the exact executable bytes it hashed, and pre/post-verifies Git head/candidate-tree identity under an exclusive coordination lease. The executable remains arbitrary code; no filesystem or network sandbox is claimed.
Public subpaths: pi-daddy/correlation, pi-daddy/refusals, pi-daddy/workspace,
pi-daddy/check-runner.
Canonical ledger v3 contract
Machine consumers should import or resolve
pi-daddy/contracts/ledger/v3/ledger-event.schema.json, not infer a format from prose. Generated fixtures for
all five events are adjacent. v3 adds globally unique executionId, explicit parentExecutionId, bounded
start deadlines, optional Herdr pane identity, and provenance-labelled workflow facts. childId remains the
readable logical tree position and is never an occurrence join.
Dispatch on version before event: no version/discriminator is a legacy 0.17 grant record; explicit v2 uses the
frozen published v2 contract; explicit v3 uses the new closed contract; every other explicit version fails
closed and is never read as legacy. verifyLedger and the dashboard share exact runtime v3 validation, so a
lookalike string version, missing join identity or malformed nested correlation cannot be OK in one and
corrupt in the other. Explicit v2 is checked against its exact frozen schema before the dashboard labels it
historical; malformed v2 never becomes a grey row or orphan count. A field/event/enum/requiredness or semantic change requires another ledger version. See
contracts/ledger/v3/README.md; the v2 path remains available unchanged.
Running it
# `agent:` ids say WHICH definitions this session may spawn (0.8.0); `tool:` ids say what it may grant them.
PI_GRANTS_GRANT="agent:review,tool:read,tool:grep,tool:find,tool:ls,tool:delegate" \
PI_GRANTS_LEDGER=.pi/grants.jsonl \
PI_GRANTS_MAX_DEPTH=2 \
pi
# The relative ledger is resolved once at session start and inherited as one absolute path,
# so routed descendants changing cwd still append to this tree.
Plain pi, no -e, when you installed this from npm — the package declares pi.extensions and pi
auto-loads it (verified by execution). -e ./extensions/grants.ts is for running from a clone of the
repository, where there is no node_modules/pi-daddy for pi to find.
/grants shows the session's grant, its depth, the catalog by kind, and an allow/BLOCK verdict per known
definition — computed by the same function a real spawn uses, so the diagnostic cannot disagree with the
enforcer. That is not cosmetic, and it has been got wrong twice. R-28 was a /grants that reported "allow"
for spawns the enforcement path refused with a reason misstating the definition file. R-38 was the same
shape one layer up: the listing shared the planner with enforcement but not the sequence, so a
definition covered by a valid persisted approval was reported as blocked while a real spawn proceeded with
no human in the loop. Both paths now go through one planWithApprovals, differing in a single argument —
the preview never asks a human and never claims one is missing — and an allow that rests on a standing
approval says so:
allow deploy tool:bash, tool:read (tool:bash approved: persisted)
BLOCK undeclared — agent "undeclared" declares no `allowed-tools`, so it cannot be spawned — add
one to …/.pi/skills/undeclared/SKILL.md. An undeclared capability set is treated
as NONE, never as everything.
/grants dashboard verifies that this exact pi PID is hosted in its declared Herdr pane, verifies the ledger
and plugin, then opens or reuses a right split without changing focus. /grants ledger reads the audit file
back and reports its integrity — record count, escalation attempts, any unparseable lines with line numbers
and content-free reasons (never copied ledger bytes), and which instructions actually ran: records grouped by definition
digest, each compared against the file on disk (current / CHANGED since), which is what makes ADR-0018's
definitionDigest answerable rather than decorative. It exists because nothing in this package had ever read a ledger
back, so a torn line was indistinguishable from a spawn that never happened. A corrupt line is evidence
and is left alone rather than repaired. Session start checks integrity automatically; /grants ledger gives
the full report.
Worked example: governing principal-pi-skills
The abstract examples above use invented definitions. This one uses the seven skills people actually have
installed. Every transcript below is copied from a run, not retyped — the run is
docs/probes/b2-init-principal-pi-skills (2026-08-17, principal-pi-skills@2.3.1, pi 0.84.1), and where
this section abridges output it says so.
pi install npm:pi-daddy
pi install npm:principal-pi-skills
pi # then, inside pi:
/grants init
pi install, not npm install — it registers the package with pi, which is what makes the extension
auto-load; presence in node_modules alone does nothing. /grants init asks only about the capabilities
that can change your machine and applies the answer to the running session, no restart (ADR-0030). That one
project opt-in now also enables .pi/grants.jsonl immediately and for every later plain pi start
(ADR-0037), so /grants ledger and /grants dashboard need no shell export. Merely installing the package
does not initialize unrelated directories. npx pi-daddy init does the same scaffolding from a shell, and
both search pi's install root as well as the project's. A pre-0.21 stored grant is not retroactive recording
consent; run /grants init once after upgrading to enable its default ledger.
found principal-pi-skills@2.3.1 — 7 skill(s), 0 declaring allowed-tools
wrote .pi/skills/decide/SKILL.md
… six more `wrote` lines, one per skill …
wrote .pi/grants.env
7 skill(s) declare no allowed-tools and cannot be spawned until they do: decide, architect, plan,
build, review, debug, git-ops. Each copy carries a commented `allowed-tools:` line. pi-daddy does
not choose ceilings — that decision is what you review and commit, so it is yours to write.
Live grant (1 capabilities): tool:delegate
That is the honest state today, and the zero is the interesting number. principal-pi-skills@2.3.1 —
the published version — declares allowed-tools on none of its seven skills, so none of them is spawnable
and init says so rather than inventing capability sets to make its output look better. (It is being fixed
at the source, in their PR #30; until that ships, this is what npm install gives you.) init did the
mechanical work — seven directories, seven copies, the grant file — and left the one decision that has to be
a human's.
You make it, in the file. This is the copy init wrote for decide, with its commented block elided, and
the ceiling is the one principal-pi-skills PR #30 settled on by re-deriving it from the skill's own body:
---
name: decide
description: >
Use when a decision needs making …
# pi-daddy: this skill declares no `allowed-tools`, so it CANNOT be spawned as a governed sub-agent —
… four more comment lines …
- # allowed-tools: <list the tools this skill needs, e.g. Read, Grep>
+ allowed-tools: read, grep, find, ls
---
and in .pi/grants.env, whose PI_GRANTS_GRANT line init wrote as "tool:delegate" — you add the rest:
export PI_GRANTS_GRANT="agent:decide,tool:read,tool:grep,tool:find,tool:ls,tool:delegate"
source .pi/grants.env && pi
grants: depth 0/2, holding [agent:decide, tool:read, tool:grep, tool:find, tool:ls, tool:delegate]
grants: 1 of 7 definitions spawnable — decide
withheld: architect (needs agent:architect); build (needs agent:build); debug (needs agent:debug);
git-ops (needs agent:git-ops); plan (needs agent:plan); review (needs agent:review)
The second line is the one worth having. A decide sub-agent that physically cannot write is a
different object from one that has been asked not to — and now that is enforced by --tools rather than
requested in prose. The other six are visibly withheld, each naming its own missing capability, which is the
difference between governance is working and did the install fail?
decide and not review, and the reason is worth a sentence. An earlier version of this section used
review as the read-only example and sourced that to its description saying "Reports findings; never
edits" — a string that appears nowhere in principal-pi-skills. It was invented for a demo file in
this repository and then cited back as their evidence. review's real ceiling, settled upstream from its
body, is read, grep, find, ls, bash: it creates a disposable worktree and runs the tests, and denied
bash every verdict it returns is UNVERIFIED. The structurally read-only tier is decide, architect
and plan.
Capabilities that can change your machine are written commented (ADR-0029). When the seven skills do
declare their ceilings, init puts tool:read, tool:grep and friends in the live grant and writes
tool:bash, tool:write and tool:edit — plus the agent: ids of the definitions that need them — as
commented lines naming who asked for what. init + source gives you a working read-only setup; the wide
half costs one deliberate uncomment. The reason is that PI_GRANTS_GRANT is what bounds a declared
ceiling, so generating it from those same ceilings would give the bound and the bounded one author, and it
would not be you.
Two things this example does not claim. pi-daddy governs which tools, never which paths: a
Write(docs/**) is refused rather than reinterpreted, so "an architect that may write an ADR but not your
source" is not expressible — the honest choice is between a document-producing agent with real write power
and a read-only one that hands its output back for the parent to write. And the ceilings above are an
example, not a recommendation: what each skill needs is the skill author's call.
The tripwire
In a governed session the tool_call hook refuses third-party spawn tools (Agent, subagent,
spawn_agent) and records the refusal, because such a spawn would create a descendant this package did not
provision, does not bound by depth, and does not record. Installing such an extension is a single command,
so refusing is cheap and silence is not.
It is a tripwire, not a boundary, and the difference is measured: subagents:rpc:spawn reaches
manager.spawn() over the event bus and never produces a tool_call at all (ADR-0013 Finding 6), so a
tool-name check cannot see it. It catches the ordinary case loudly. It is not containment.
Governance is opt-in. With PI_GRANTS_GRANT unset, the session holds the wildcard and nothing is
blocked — this extension must never silently tighten a normal workflow. Since 0.5.0 that holds for
descendants too: an ungoverned session publishes no governance variables at all. It previously
exported its own observed tool surface as its children's grant, so "inactive" governance quietly governed
everything below it.
Configuration, and how it fails
| Variable | Default | Notes |
|---|---|---|
PI_GRANTS_GRANT |
unset → use this directory's stored init choice, or ungoverned when none exists | Presence switches governance on and bypasses the whole cwd store; this is how children and CI stay environment-only. |
PI_GRANTS_MAX_DEPTH |
2 |
Child-depth bound. 0 disables spawning. |
PI_GRANTS_DEPTH |
0 |
This session's own depth; set by the parent, not by hand. |
PI_GRANTS_GATED |
tool:bash in a governed session |
Capabilities needing human approval. Set to "" to gate nothing. Gating is closed under subsumption, so this also covers write/edit/read/grep/find/ls (ADR-0012). |
PI_GRANTS_APPROVED |
unset | Inherited capability@subject#sha256 entries; set by the parent, clamped to the child's own grant, and honoured only against the definition body the child itself loaded (ADR-0022). |
PI_GRANTS_APPROVAL_TIMEOUT |
120 (seconds) |
How long a dialog waits. 0 or an unreadable value means no timeout: waiting forever denies nothing, so it is the safe reading of a value we do not understand. |
PI_GRANTS_LEDGER |
unset → a v2 /grants init choice uses <cwd>/.pi/grants.jsonl; otherwise not recording |
Presence overrides the project default; "" disables it for one run. Any effective path is load-bearing. |
PI_GRANTS_WORKSPACE_REGISTRY |
unset | Operator-owned {version:1, workspaces:{id:{path}}} file, required only for workspace-routed spawns. |
PI_GRANTS_WORKSPACE_LEASE_DIR |
under $PI_CODING_AGENT_DIR/pi-daddy/ |
Kernel writer locks and ownership metadata. |
PI_GRANTS_CHILD_TIMEOUT |
1200 (seconds) |
Wall-clock limit for a child. Inherited by descendants — an operator preference, deliberately not attenuating state. |
PI_GRANTS_ALLOW_UNRESOLVED_MODELS |
unset | Exact 1 lets pi attempt custom model resolution; otherwise an explicit provider/id missing from pi's session catalogue refuses before lease, approval or spawn. |
PI_GRANTS_FANOUT |
8 |
Per-call width and downward subtree budget; not a session-total counter. Malformed or 0 falls back to the default. |
PI_GRANTS_PARENT_ID |
d0 |
Readable logical tree position; set by the parent and allowed to repeat across calls. |
PI_GRANTS_EXECUTION_ID |
unset at a root | Unique governed execution occurrence; set by the parent. Lifecycle/lease joins use this, never PI_GRANTS_PARENT_ID. |
PI_GRANTS_HERDR |
unset ⇒ probe | Three-state. Unset probes for a reachable herdr and uses panes if one answers; 1 demands panes and refuses every delegation if herdr is unreachable; 0 demands captured subprocesses. Never detected from herdr merely being on PATH. |
PI_GRANTS_HERDR_WORKSPACE |
the parent's HERDR_WORKSPACE_ID |
herdr workspace for spawned panes. Defaults to the workspace this session is in, so a child is a tab away rather than a workspace away. |
PI_GRANTS_HERDR_KEEP_PANE |
unset | 1 keeps each child's pane for inspection, and no sweep closes it. Off by default: a fan-out would flood the workspace. |
PI_CODING_AGENT_DIR |
~/.pi/agent |
pi's own variable, not ours — but it decides where stored project grants/ledger consent and persisted approvals live. |
A malformed value disables spawning; it never falls back to a default. Stored grants are likewise
tri-state: only a missing file is opt-out; malformed, unsupported-version, unreadable or wrong-cwd state is a
loud empty-grant session with a GRANT_STORE_INVALID ledger line. An unreadable
PI_GRANTS_MAX_DEPTH or PI_GRANTS_DEPTH yields maxDepth: 0 and a startup warning naming the
variable. Before 0.5.0 these were read with parseInt, which accepts numeric prefixes ("2abc" → 2)
and otherwise gives NaN — and since every comparison against NaN is false, a typo did not tighten the
depth limit, it removed it.
Configuring a ledger makes it a precondition, not a log. This applies equally to an explicit
PI_GRANTS_LEDGER and to the project default accepted through /grants init: if the write fails, delegation
is refused. Asking for an audit trail is an explicit act, and ledger.ts has always documented that an
unrecorded grant should fail closed; until 0.5.0 both call sites silently swallowed the error. Legacy v1 grant
stores and sessions with no ledger configured are unaffected. Concurrent appends are serialised by a lock file
with a short timeout — failing closed beats hanging — and a lock abandoned by a killed process is broken after
10s.
The ledger
Append-only JSONL. Version 3 records capability decisions, workspace leases, child lifecycle, check receipts and workflow facts. Every per-child capability decision is present, including refusals. The reader still accepts frozen v2 and legacy grant-only lines.
childId is hierarchical and readable (d0.1, d0.1.2) but repeated/parallel calls may reuse it. v3 adds a
random executionId and explicit parentExecutionId; those are the only lifecycle/lease occurrence join.
A v2 lifecycle is therefore shown historical/unjoined rather than guessed. A v3 starting deadline and the
executor timer share one absolute budget — waiting for the strict starting append consumes it, a later running
event cannot replace it, and process SIGTERM grace stays inside it under an independent hard-kill timer. That
timer bounds the governed PID while live. Soft and hard deadline callbacks first allow one event-loop turn for
pending child exit delivery; retained descendant pipes cannot rewrite a PID that already exited successfully as
timed out. A pending running append always lands before a terminal event. denied non-empty remains the designated escalation signal — an agent asking for what it does not hold is an escalation attempt, and it
is invisible without a record.
Workflow facts are identifier-only and mark planned, observed, or controller_validated; they can never
claim pi-daddy enforcement. Capability/lifecycle records are the enforced class. Correlation remains
caller-declared: optional schema_version is exactly 1.0, and assurance_scope is either entire-run with an
empty selector list or selectors with one or more non-empty strings. Fields eligible for display use the v3
ASCII identifier grammar rather than prose.
Privacy is a property of this file, and the boundary is exact: capability ids, counts and identifiers only
— never prompts, task text, tool arguments or results. Trusted definitionDigest and taskDigest values
identify the exact operator body and task. A predictable task can be guessed from SHA-256, so its digest is
sensitive/linkable metadata, not anonymization. Caller-supplied digest-looking values remain under
correlation and never authorize. Public v3 builders validate their serialized wire form against the same
closed runtime contract the readers use; deadlineAt therefore cannot be a Date.parse lookalike. Schema and
runtime share the seconds 00–59 timestamp profile because JavaScript deadline arithmetic cannot represent
leap seconds, and a normalized-away top-level null assurance scope is not schema-valid.
Propagation is race-free by construction
An earlier version wrote each child's computed grant into process.env. The environment is process-global,
so concurrent spawns could read each other's values — a real hole. The fix removes the need for a per-child
channel rather than building one:
- Everything published to the environment is a parent-level fact — the parent's own grant, the child
depth (
parent + 1), the configured bounds, and this session's own approvals. Identical for every sibling, so there is nothing to race on. It is written once at session start and republished only when this session's own approvals change, never per spawn. - Each child is spawned with its own explicit
envobject. It is built by stripping everyPI_GRANTS_*variable from this process's environment and then applying the plan, so the plan is the only source of all of them — a key the plan does not set cannot let the parent's value through. - Each child derives its own grant on arrival:
inheritedGrant ∩ ownObservedTools, where the observed set comes from thetoolsarray of its first provider request — authoritative, because it is exactly what pi sent the model. (A session's first provider request always precedes its first tool call, so the grant is settled before it can delegate.) Onlytool:andext:ids are filtered this way; an observation says nothing aboutskill:oragent:(R-36).
The invariant holds transitively — own = observed ∩ inherited ⊆ inherited — and it doubles as defence in
depth: a child clamps itself even if it were handed too much.
The wildcard is held but never inherited. A root may hold tool:* (authority to grant anything), but
handing it down would let every descendant reacquire the full catalog and make attenuation meaningless
below the root. Children inherit the enumerated grant only. A wildcard root that has not yet observed its
tools hands children an empty grant — fail closed.
The task never touches argv. pi dispatches @file and -flag on the first character of an argv
element, and @file is read before any tool exists, so --tools cannot stop it: a task beginning @
made pi read an arbitrary file into a child holding no tools at all. The task is now the final argv element,
prefixed with one space, unconditionally — a positional guarantee rather than a pattern match against pi's
current parser. Reproduced and closed; docs/probes/g1-argv.
Functional subsumption: bash is not one capability among eight
pi's default tool surface is read, bash, edit, write — measured, not assumed (grep, find,
and ls exist but are not default). So a definition declaring Read, Grep, Find, Ls would look
like an escalation from any normal parent, despite being strictly weaker.
It isn't, because bash can run grep, find, ls, cat, and sed. SUBSUMPTION models that
explicitly, which removes the false positives and makes the uncomfortable part visible: a grant
containing bash is not a narrow grant. result.subsumedBy lists what the parent covers only indirectly,
so a reviewer can see what a grant really means. Pass subsumption: false for a strict name-equality check.
Live capability catalog
Grants are validated against what actually exists in the session, not just against definition files:
| Source | Gives | Why it's trusted |
|---|---|---|
provider request tools array |
tool: capabilities, including extension-provided ones |
authoritative — it is exactly what pi sent the model, and reflects any --tools allowlist already in force |
skill roots (.pi/skills, ~/.pi/agent/skills) |
skill: capabilities |
SKILL.md directories and top-level .md files, per pi's convention |
| the same roots | agent: capabilities |
one per definition, from the same discovery delegate spawns from — so a definition can never be grantable but unspawnable, or listed but unknown |
This closes the skills half of "skills and tools" — previously ungovernable — and makes extension tools
visible, which is the only way ext:/tool: grants can be validated at all.
Unknown is reported separately from denied, because the causes and fixes differ: denied means the
delegator lacks authority; unknown means the capability does not exist here — a typo, or an uninstalled
package. Collapsing them would hide both. Verified live: tools:["reed"] →
unknown capability: tool:reed — not present in this session's catalog (typo, or an uninstalled package?),
with no mention of escalation.
Provenance caveat: a provider payload gives tool names, not owning packages, so extension tools are
catalogued as tool:<name> (which is also how pi's --tools matches) and marked kind: "extension" for
display rather than qualified as ext:<pkg>/<tool>.
PI_BUILTIN_TOOLS is a pinned observation of pi 0.84.1. Drift misfiles a capability in the catalog; it
cannot grant one, because --tools is the authority.
Use as a library
The resolver, ledger, spawn planner and the whole approval model are pure functions, exported and usable without pi:
import { resolve, assertNarrowing, planSpawn, buildRecord, appendRecord, digestTask, newExecutionId } from "pi-daddy";
const result = resolve({
requested: ["tool:read", "tool:grep"],
parentGrant: ["tool:read", "tool:grep", "tool:write"], // what the delegator holds
ceiling: ["tool:read", "tool:grep"], // the definition's declared maximum
gated: ["tool:write"], // needs human approval, ever
});
assertNarrowing(result); // throws on a smuggled universal capability
const plan = planSpawn({ effective: result.effective, prompt: task });
// -> ["--print","--no-session","--no-extensions","--no-skills","--no-context-files",
// "--no-prompt-templates","--tools","grep,read"," summarise src/"]
await appendRecord({ path: ".pi/grants.jsonl" }, buildRecord({
/* capability fields … */ result, blocked: false, executor: "process",
executionId: newExecutionId(), parentExecutionId: null, taskDigest: digestTask(task), now: new Date(),
}));
Subpaths are exported individually (pi-daddy/resolve, /ledger, /spawn, /delegate, /catalog,
/propagation, /definitions, /fanout, /pi-tools, /approval, /approval-store, /approval-prompt,
/run-child, /run-herdr, /dashboard-projection, /dashboard-render).
Design decisions worth knowing
- A zero grant is
--no-tools, never "no flag". pi rejects an empty--tools, and omitting the flag silently falls back to pi's defaults — the opposite of a zero grant. --no-extensionsis always passed, so ambient user extensions cannot widen a governed child. An explicit-estill loads, which is exactly why the extension is re-added by hand for a child that holdstool:delegate, and never otherwise.- Each resource class needs its own switch.
--no-extensionsdoes not disable skills, context files or prompt templates; all three are passed explicitly, and--skilladds to the discovered set unless--no-skillsgoes with it. A granted skill is passed by path and refused if it cannot be located — granting a capability the child would silently lack is a lie in the ledger. - The ledger fails closed by default. An unrecorded grant is a hole;
strict: falseonly where the ledger is advisory. - Skills and definitions are capabilities too (
skill:,agent:), governed by the same machinery — but they are not--toolsentries, sotoPiToolsAllowlist()filters them out. - Rejection reasons never mask one another —
denied(escalation),clipped(ceiling), andgatedBlocked(needs approval) are computed independently and reported together, so a request with two problems does not report one and hide the other.
Install
pi install npm:pi-daddy # as a pi extension
npm i pi-daddy # as a library (the resolver, ledger and spawn planner are pure)
npx pi-daddy init # as a command: scaffold .pi/skills/ and .pi/grants.env from installed
# skill packages — see the worked example above
The package is also the source of the optional Herdr plugin: the extension offers to link the trusted
herdr-plugin/ directory explicitly, and pi-daddy-dashboard is the terminal renderer binary. pi loads
extensions/grants.ts through its own transpiling loader, which reads
TypeScript from node_modules quite happily; Node does not — it refuses to strip types under
node_modules — so the library entry points are compiled to dist/. Until 0.6.0 exports pointed at
./src/*.ts, and every consumer import failed with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING while
every in-repo test passed. npm run test:smoke packs a tarball, installs it into a scratch project and
uses it, so that gap cannot reopen silently.
Testing
npm test # 741 unit tests. Fast, pure, no pi, no network.
npm run typecheck # src + extensions + tests + integration tests
npm run test:integration # 48 tests against a REAL pi process/Herdr server, no model tokens.
npm run test:smoke # pack/install; exercise library exports, both bins, the v2/v3 contracts,
# bundled Herdr plugin, dashboard, and `pi-daddy init`
PI_GRANTS_IT_MODEL=1 npm run test:integration # + 10 end-to-end tests with a real model. Costs money.
The resolver is a pure function, which is deliberate: it is the only place an escalation could be
introduced, so it is the only place needing exhaustive tests — and being pure, it can have them. Coverage
includes three-level transitive attenuation, approval-cannot-conjure-a-capability, empty vs. absent
ceilings, and the measured fabric_exec escalation.
The integration suite exists because the extension is where the wiring bugs live — every defect the live
probes ever found was there rather than in src/, and a pure test cannot see how configuration is read.
Its default tier drives slash commands, whose handlers run the real decision path over real SKILL.md files
in a real pi process without a model deciding anything, so it is deterministic and free. The opt-in tier
adds a model choosing to call tools, and asserts on structure (isError, the ledger JSON, whether a file
appeared on disk) rather than on model wording.
It earned itself immediately: its first run found that every delegation refusal was being recorded by pi
as a success. It is also checked against reintroduced defects, which is the point — restoring the G7 NaN
bug makes two of its tests fail, and the approval tests were verified by mutation (making an unpinned body
digest fail open fails exactly one test; deleting the body comparison fails three).
test/file-size.test.ts fails the build if any file in src/ or extensions/ exceeds 400 lines. It caught
its own author the day after it was added: rather than raise the cap, delegation.ts was split.
Status
0.20.0 — live Herdr dashboard and ledger v3. Adds the explicit installation handshake,
/grants dashboard, duplicate-safe right split, pure live projection, unique execution/parent identity,
workflow provenance facts and principal correlation labels. Enforcement is unchanged.
0.19.0 — workspace routing is a capability. Routing now attenuates through workspace:<id>; see the
changelog for the breaking migration.
0.18.1 — security fix for malformed capability IDs. Capability IDs containing comma, CR, LF, NUL or surrounding whitespace are refused before resolution and again before grant serialization, preventing a wildcard-covered string from splitting into authority the parent never held.
0.18.0 — generic runtime enforcement, still honest about scope. The governed spawn path now includes optional correlation, exact task-bound approvals, registered-worktree CWD validation, OS-backed governed writer leases, structured refusals, lifecycle/lease ledger events, and a no-shell named-check subpath. All new spawn fields are optional; legacy callers retain their behavior.
Known gaps, stated because a gap nobody wrote down is the one that surprises somebody:
bashescapes governance. Out of scope by decision (ADR-0012); workspace routing and leases do not contain it.- A workspace lease coordinates only pi-daddy-governed writers. It is not a filesystem sandbox and cannot stop unrelated processes. The measured writer path currently requires util-linux
flock. - A named check is arbitrary code without shell interpolation. It may write, use the network, invoke a shell or leave descendants; no OS containment is claimed.
subagents:rpc:spawnbypasses the tripwire. Unfixable from here.- The ledger is verified at session start when one is configured: a damaged trail announces itself, an intact one stays quiet.
- A pane outlives its tool call only if its child settled. A child that answered keeps its pane so you can
read it, and it is swept when you get your prompt back (
agent_settled), with processexitas a backstop. A child that did not settle — timeout, abort, failed start — loses its tab at once, because closing the tab is the only way to stop a herdr agent (herdr agent stopdoes not exist). At most 8 panes are open at once, and only settled ones are ever reclaimed: if they are all live the cap yields rather than killing a child. - Pane cleanup does not cover being killed outright. SIGKILL, and a SIGTERM nothing else is listening for,
run no
exithandlers — by Node's design — so a pane can be orphaned;herdr tab close <id>is the remedy. No signal handler is installed, deliberately: one here would suppress Node's default termination and turn pi's "interrupt this turn" into "exit pi" (R-62, re-rated M×L now that panes are the default path). - A running delegation is visible. One status block per call — per child: its definition, its herdr agent, its pane id, its state, elapsed time, and the last three lines it printed. Bounded in height and width, so a fan-out cannot flood your screen. It is a display, never the result.
- The dashboard whole-file polls. The MVP is sized for 10 MiB; 50 MiB or 100 ms p95 projection is the switch point for incremental replay.
- v2 is historical in the dashboard. It has no unique occurrence ID, so lifecycle is reported unjoined
rather than matched by reusable
childId. - principal-pi-skills does not yet publish a generated graph declaration. Explicit correlation and provenance facts render; prompt prose is never parsed into a graph.
- A definition's instructions are governed only by identity.
agent:<name>says which file may be spawned and the digest says which version ran, but nothing reads a body and judges what it says — the operator authorises a file, and its contents are their responsibility. - No background delegation.
delegateruns to completion and returns the child's output (ADR-0015). If one is ever built, ADR-0026 fixes the rule that blocked it twice: a background spawn whose gates are unresolved when its tool call returns is refused, and an approval arriving later starts nothing — otherwise a child's capability set would depend on when a human reached the dialog. - Whether persisted approvals earn their keep is still unmeasured.
/grants ledgernow counts where every approval came from, so the question ADR-0020 left open has a command; what it does not have yet is a few weeks of real use to answer it.
docs/SPEC.md in the repository is the authoritative current-state document; the ADRs hold the reasoning.
Requires pi ≥ 0.83.0, Node ≥ 22.19. MIT.