mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
多个扩展各自整段改写系统提示词并按加载顺序接力,任一扩展定死提示词后 其余只能做字符串手术,且各上游的工具说明与组合包实际工具格局冲突。 - 新增 extensions/system-prompt.ts 作为唯一出口(加载列表最后),按 config/prompt/(preamble.md、tools.jsonc、routing.jsonc、sections.jsonc) 组合开场白、工具简介/规则与 <tool_routing> 分区;替代原封面与 tool-routing 扩展,/dump-system-prompt 输出最终提示词。 - pi-chrome、pi-ssh、pi-hermes-memory 改为写命名分区,pi-rtk-optimizer 改写 promptGuidelines,pi-permission-system 直接过滤结构化 skills, 均不再返回 systemPrompt;pi-chrome 未授权时不再每轮冻结提示词。 - 新增 prompt-guard 代理加载第三方扩展(Hashline、FFF、LSP、 context-mode):拦截直接改写提示词并按目录覆盖工具说明;context-mode 去掉与组合包路由冲突的锚点段,保留记忆与恢复快照。 - 消除冲突:bash 简介不再建议 grep/find;Hashline edit 不再称可用 grep 锚点、不再介绍已关闭的 replace_text;context-mode 读写/网页路由提示。 - 守门测试:工具目录覆盖率、禁用用法检查、context-mode 上游原文锁定; AGENTS.md 新增「系统提示词单一所有权」。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
947 lines
82 KiB
Markdown
947 lines
82 KiB
Markdown
# Architecture
|
||
|
||
This document describes the internal design of the permission system, informed by [OpenCode's permission model](https://opencode.ai/docs/permissions/).
|
||
|
||
## Design principles
|
||
|
||
1. **Unified rule model** - one `Rule` type, one evaluation function, all surfaces.
|
||
2. **Pure evaluation** - permission decisions are pure functions of (surface, pattern, rules).
|
||
IO stays at the edges.
|
||
3. **Session approvals are just more rules** - no separate matching engine, no separate pre-check.
|
||
4. **MCP stays special** - multi-name target derivation is pre-processing, not a special evaluation path.
|
||
5. **Defaults are rules** - the universal default (`permission["*"]`) is synthesized as a low-priority rule in the array.
|
||
No side-channel fallbacks.
|
||
6. **Flat config format** - the flat `permission: { ... }` object where each key is a surface.
|
||
The config IS the ruleset in human-friendly form.
|
||
7. **Preserve the two-phase model** - tool filtering (before_agent_start) and invocation gating (tool_call) remain separate.
|
||
8. **Ask = cache miss** - "ask" is the absence of a matching rule.
|
||
The human is the oracle.
|
||
Their decision is a rule.
|
||
Persistence determines lifetime (once / session / config).
|
||
9. **Single-agent core, multi-agent by extension** - Pi is single-agent by deliberate design; the notion of multiple named agents is introduced entirely by external extensions (pi-subagents, pi-agent-router, some MasuRii packages), not by Pi itself.
|
||
Per-agent `permission:` frontmatter is therefore an extension bridge layered on this single-agent core, not a core responsibility.
|
||
The package learns the active agent from a generic `<active_agent>` signal (a system-prompt tag or an `active_agent` session entry), never from a hard dependency on any one multi-agent extension, so the bridge works with any tool that emits the signal.
|
||
|
||
## Core data model
|
||
|
||
### Rule
|
||
|
||
```typescript
|
||
/**
|
||
* Provenance of a rule - which source contributed it.
|
||
*
|
||
* Config scopes: "global", "project", "agent", "project-agent".
|
||
* Synthesized: "builtin" (universal default / evaluate() fallback),
|
||
* "baseline" (conditional MCP metadata auto-allow).
|
||
* Runtime: "session" (session approvals).
|
||
* Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode),
|
||
* "fail-closed" (composition-stage allow→ask floor when an
|
||
* invalid non-global config scope is detected).
|
||
*/
|
||
type RuleOrigin =
|
||
| "global"
|
||
| "project"
|
||
| "agent"
|
||
| "project-agent"
|
||
| "builtin"
|
||
| "baseline"
|
||
| "session"
|
||
| "yolo"
|
||
| "fail-closed";
|
||
|
||
interface Rule {
|
||
/** The permission surface: "bash", "edit", "mcp", "skill", "path", etc. */
|
||
surface: string;
|
||
/** The match pattern: a command glob, tool name, file path, skill name, or "*". */
|
||
pattern: string;
|
||
/** The decision. */
|
||
action: PermissionState;
|
||
/** Custom denial reason for deny rules (optional). */
|
||
reason?: string;
|
||
/**
|
||
* Origin layer - used to derive PermissionCheckResult.source after evaluation.
|
||
* Not used by evaluate(); purely informational metadata.
|
||
*/
|
||
layer?: "default" | "baseline" | "config" | "session";
|
||
/** Which source contributed this rule. */
|
||
origin: RuleOrigin;
|
||
}
|
||
```
|
||
|
||
Every config entry, default policy, session approval, and agent override normalizes into `Rule[]`.
|
||
|
||
### Ruleset
|
||
|
||
```typescript
|
||
type Ruleset = Rule[];
|
||
```
|
||
|
||
A `Ruleset` keeps declaration order for deterministic tie-breaking, but declaration order is not the primary precedence rule.
|
||
For all matching rules on the requested surface, `evaluate()` chooses the most specific pattern:
|
||
|
||
1. exact match;
|
||
2. more literal characters;
|
||
3. fewer `*` wildcards;
|
||
4. longer pattern;
|
||
5. later declaration only for a complete tie.
|
||
|
||
The synthesized universal `*` is therefore a fallback. A specific deny cannot be shadowed by a later broad allow.
|
||
|
||
### Evaluate
|
||
|
||
`evaluate()` filters to matching surface/value candidates, ranks them with the specificity comparator, and returns the highest-ranked rule. If none match, it returns the built-in `ask` fallback.
|
||
|
||
## Decision composition
|
||
|
||
PermissionManager does not load an ordinary project policy. It composes these authorities:
|
||
|
||
1. global-only Hard Guards (first and final for matching `deny`);
|
||
2. session approvals;
|
||
3. active agent restrictions from global/project agent Markdown;
|
||
4. the single global ordinary policy;
|
||
5. built-in `ask`.
|
||
|
||
Rules inside one authority use pattern specificity. Results from independent gates (`path`, per-tool, Bash) compose most-restrictive-wins. AutoReview is not a ruleset layer: it only receives a completed ordinary-policy `ask`.
|
||
|
||
## Architecture overview
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
HG["Global hard-guards.jsonc"] --> Intent["Resolved access intent"]
|
||
GP["Global policy.jsonc"] --> PM["PermissionManager"]
|
||
AF["Optional active-agent frontmatter"] --> PM
|
||
SR["SessionRules"] --> PM
|
||
Intent --> Pipeline["skill -> path -> bash -> tool gates"]
|
||
Pipeline --> PM
|
||
PM -->|allow| Allow["execute"]
|
||
PM -->|deny| Deny["block"]
|
||
PM -->|ask| Chain["authorizerChain"]
|
||
Chain -->|allow/deny| Result["resolve"]
|
||
Chain -->|defer or exhausted| Human["human prompt"]
|
||
```
|
||
|
||
Project-level `policy.jsonc` is intentionally absent. Project agent Markdown may restrict one active agent, but it is not a second ordinary configuration scope.
|
||
|
||
Tool Search alone owns `getActiveTools()` / `setActiveTools()`. Permission System does not participate in tool visibility; it receives only calls Pi actually invokes.
|
||
## Config format
|
||
|
||
```jsonc
|
||
{
|
||
"permission": {
|
||
"*": "ask",
|
||
"read": "allow",
|
||
"bash": { "*": "allow", "git *": "allow", "npm *": "allow", "rm *": "deny" },
|
||
"mcp": { "*": "ask", "exa:*": "allow" },
|
||
"skill": { "*": "ask", "librarian": "allow" },
|
||
"path": { "*": "allow", "*.env": "deny" },
|
||
}
|
||
}
|
||
```
|
||
|
||
Each top-level key in `permission` is a surface name.
|
||
A string value is shorthand for `{ "*": action }` (surface-level catch-all).
|
||
An object value maps patterns to actions.
|
||
`permission["*"]` is the universal fallback.
|
||
|
||
### Normalization to Rule[]
|
||
|
||
`normalizeFlatConfig` (`src/normalize.ts`) flattens each `permission` entry into `Rule`s: a string value expands to a single surface catch-all (`{ surface, pattern: "*", action }`), and an object value expands each `pattern → action` pair to one `Rule`.
|
||
|
||
## MCP pre-processing
|
||
|
||
MCP is the one surface that requires pre-processing **before** evaluation.
|
||
The multi-name target derivation stays, but it feeds candidate values into `evaluate()` rather than a separate code path:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Input["MCP tool call input"] --> Derive["createMcpTargets(input)"]
|
||
Derive --> Candidates["[exa_search, exa:search, exa, search, mcp_call]"]
|
||
Candidates --> Loop{"For each candidate"}
|
||
Loop --> Eval["evaluate('mcp', candidate, rules)"]
|
||
Eval --> Found{"Explicit match?"}
|
||
Found -->|Yes| Return["Return rule"]
|
||
Found -->|No| Next["Next candidate"]
|
||
Next --> Loop
|
||
Loop -->|Exhausted| Fallback["evaluate('mcp', '*', rules)<br/>(hits synthesized default)"]
|
||
```
|
||
|
||
The priority ordering of candidates is preserved.
|
||
The evaluation function is unchanged - MCP just calls it multiple times with different values.
|
||
MCP target derivation helpers live in `src/access-intent/mcp-targets.ts`.
|
||
Input normalization for all surfaces lives in `src/access-intent/input-normalizer.ts`.
|
||
|
||
### Path-bearing tool normalization
|
||
|
||
Per-tool path patterns are emitted as `path-values` intents. Relative inputs may carry lexical, cwd-normalized absolute, and canonical aliases so one configured pattern can protect the same file across equivalent spellings.
|
||
|
||
There is no outside-CWD classifier and no `external_directory` intent. Paths outside the working directory use the same `path`, per-tool, and Bash policy evaluation as paths inside it.
|
||
|
||
Tools such as SSH that carry remote paths register an explicit extractor so local path normalization is not inferred from `input.path`.
|
||
## Session approvals: the cache-miss model
|
||
|
||
Session rules are stored as `Ruleset` and are generalized to all surfaces.
|
||
|
||
`evaluate()` is a **lookup** against cached decisions.
|
||
When no rule matches (or the matching rule says "ask"), the system has a cache miss - it needs the human oracle to produce a decision.
|
||
|
||
The human's response is simultaneously:
|
||
|
||
1. **The answer** for this request (allow or deny).
|
||
2. **A rule** that can be cached for future lookups.
|
||
|
||
The dialog determines **persistence** - where the rule lives:
|
||
|
||
```text
|
||
evaluate(surface, value, composedRules)
|
||
│
|
||
├── match.action = "allow" → proceed (cache hit)
|
||
├── match.action = "deny" → block (cache hit)
|
||
│
|
||
└── match.action = "ask" → cache miss, query oracle
|
||
│
|
||
▼
|
||
Dialog: "[surface] wants to [value]"
|
||
│
|
||
├── "Yes" → allow this request (no persistence)
|
||
├── "Yes, for session" → allow + store in session layer
|
||
│ (future lookups hit without asking)
|
||
├── "No" → deny this request (no persistence)
|
||
└── (future: "Always") → allow + store in config layer (disk)
|
||
```
|
||
|
||
### Pattern suggestions
|
||
|
||
When prompting, each surface suggests a **pattern** for the "for session" option.
|
||
The pattern determines what class of future requests auto-approve:
|
||
|
||
| Surface | Input value | Suggested session pattern | Mechanism |
|
||
| ---------------------- | --------------------------- | --------------------------- | ------------------------ |
|
||
| bash | `git checkout main` | `git checkout *` | Arity table |
|
||
| bash | `npm run dev` | `npm run dev` | Arity table |
|
||
| tool (read/write/etc.) | tool surface itself | `*` (all uses of that tool) | Tool-level |
|
||
| mcp | `exa:search` | `exa:*` | Server-level wildcard |
|
||
| skill | `librarian` | `librarian` | Exact name |
|
||
|
||
The suggestion is shown in the dialog text so the user sees what they're approving:
|
||
|
||
```text
|
||
● Allow once
|
||
● Allow "git checkout *" for this session
|
||
● Deny
|
||
```
|
||
|
||
### Implementation
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User
|
||
participant Gate as Elicitor (ask-path)
|
||
participant Eval as evaluate()
|
||
participant Session as Session Rules (Ruleset)
|
||
|
||
Gate->>Eval: evaluate("bash", "git status", composedRules)
|
||
Eval-->>Gate: { action: "ask" } (cache miss)
|
||
Gate->>User: "Allow 'git status'? [Once / Session: 'git status*' / Deny]"
|
||
User-->>Gate: "Session"
|
||
Gate->>Session: append { surface: "bash", pattern: "git status*", action: "allow" }
|
||
|
||
Note over Gate,Session: Next similar call - cache hit
|
||
Gate->>Eval: evaluate("bash", "git status --short", composedRules incl. session)
|
||
Eval-->>Gate: { action: "allow" } (matched session rule)
|
||
Note over Gate: No prompt needed
|
||
```
|
||
|
||
## Prompt presentation
|
||
|
||
What a prompt must show, what a renderer may elide, and what bounds its size are settled by [ADR 0011](../decisions/0011-prompt-presentation-contract.md).
|
||
The contract in one line: **the payload is complete, and elision is a property of a render, never of the payload**.
|
||
|
||
A gate emits structured facts rather than a sentence.
|
||
The payload's `request` group — requester and forwarded-ness, tool name and invoked tool name, gate surface and matched rule, the decision-relevant value, and for bash the unit that will actually run — is never elided by any renderer.
|
||
`evidence` is complete on the payload and elided to fit each renderer's budget, with the elision marked but uncounted; an operator must still be able to reach the complete information while the decision is pending.
|
||
The dialog is bounded by a row budget plus a per-field width cap, the review log by its own configured limits, and the `permissions:ui_prompt` broadcast receives the `request` facts only — the narrowest renderer, because the bus is the one channel an extension observes without the operator having named it.
|
||
Denial text is a fifth render of the same facts under one extra rule: it identifies the call rather than reproducing it, since the agent already holds its own tool input.
|
||
|
||
The payload exists, and the human-facing renderers are bounded.
|
||
Every gate emits a `PromptPayload` (`src/presentation/`), and `PromptPermissionDetails` requires one, so the six former assembly sites are gone.
|
||
`renderPromptDialog` renders it for the inline dialog and the `select`/`input` fallback under `promptMaxRows` plus `promptFieldMaxWidth`, and `Ctrl+O` expands the dialog to the complete request.
|
||
The cap applies to the `request` facts too: never elided means never *omitted* — a long one is shortened, marked, and reachable in full rather than dropped.
|
||
Without that reading a bounded render is unreachable, since the decision-relevant value is itself the pathological field in the reported case ([#710]).
|
||
A fact an adjacent line already states is not repeated — a bash ask's gate surface is its tool name, and a path ask's is the word its value line is labelled with — so the render spends a line only where it adds something.
|
||
That is a redundancy rule, not an elision: the fact is still on screen, which is what §3 requires.
|
||
|
||
The two cross-boundary contracts now carry facts rather than prose.
|
||
The forwarded-request wire carries the child's `PromptPayload`, so the serving node renders the child's own facts under the *parent's* budget — a forwarded bash ask reads `command : …` exactly as a local one does, and `kind: "forwarded"` narrows to meaning one thing: this ask arrived without a payload.
|
||
`permissions:ui_prompt` carries `request`, the payload's invariant core, and no evidence at all, which makes the bus the narrowest renderer (ADR 0011 §6): any loaded extension observes it without the operator having named that extension.
|
||
`toolInputPreviewMaxLength` and `toolTextSummaryMaxLength` are deprecated and ignored, superseded by the renderer budgets.
|
||
|
||
The last two consumers are renderers too, so the flat `message` string is gone.
|
||
The agent-facing text identifies a refused call rather than reproducing it (§7): it names the surface, the tool, the rule with its nested context, the flagged path or target or skill, and the operator's or human's reason — never the bash command, which is the payload that took over the viewport in [#710] and the agent's context window on every denial.
|
||
The flagged element is agent input, so it is capped rather than structurally bounded; naming it is what makes a denial correctable, since which of a call's operands a rule fired on is below tool-call granularity and the agent cannot recover it from its own arguments.
|
||
The review log persists the payload's request facts rather than the prompt sentence — stamped by `GateRunner` beside the request id, so no gate can forget them — and every string it writes is narrowed to `reviewLogFieldMaxWidth`.
|
||
That bound lives in `writeLine` beside the key-name mask, which makes the log's growth a decision the operator makes rather than a consequence of how long a command happened to be.
|
||
ADR 0011 records what each dependent item becomes under the contract.
|
||
|
||
## Two-phase checking
|
||
|
||
### Phase 1: Tool filtering (`before_agent_start`)
|
||
|
||
`shouldExposeTool` (`src/handlers/before-agent-start.ts`) calls `evaluate(toolName, "*", rules)` and exposes the tool unless the surface-level result is `deny` — "is this tool denied regardless of specific input?"
|
||
|
||
### Phase 2: Invocation gating (`tool_call`)
|
||
|
||
The gate pipeline (`src/handlers/gates/`) normalizes the input to `(surface, value)`, evaluates it against the composed ruleset, and acts on the result: `allow` proceeds, `deny` blocks, and `ask` elicits from the session's `Authorizer` — a persisted "session" decision appends a `Rule` to `sessionRules` so the next similar call is a cache hit.
|
||
|
||
Same `evaluate()`, same ruleset.
|
||
The only surface-specific logic is input normalization (what `surface` and `value` to look up) and pattern suggestion (what glob to offer for "session" approval).
|
||
|
||
`checkPermission()` uses a single evaluate path: `normalizeInput()` → `evaluateFirst()` → `deriveSource()` → single result object.
|
||
|
||
## Subagent detection and permission forwarding
|
||
|
||
When `ask`-state permissions arise in a headless subagent child process, the extension forwards the dialog to the parent session rather than silently denying.
|
||
This requires two detections:
|
||
|
||
1. **Is the current process a subagent?**
|
||
- `isSubagentExecutionContext()` in `src/authority/subagent-context.ts`.
|
||
2. **What is the parent session ID?**
|
||
- `resolvePermissionForwardingTargetSessionId()` in `src/authority/permission-forwarding.ts`.
|
||
|
||
### Known extension env var inventory
|
||
|
||
| Extension | Child-process env vars | Parent-session env var |
|
||
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------- |
|
||
| pi-agent-router (original) | `PI_IS_SUBAGENT`, `PI_SUBAGENT_SESSION_ID`, `PI_AGENT_ROUTER_SUBAGENT` | `PI_AGENT_ROUTER_PARENT_SESSION_ID` |
|
||
| [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) | `PI_SUBAGENT_CHILD`, `PI_SUBAGENT_RUN_ID`, `PI_SUBAGENT_CHILD_AGENT`, `PI_SUBAGENT_DEPTH` | none set (see #98) |
|
||
| [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | none - runs fully in-process via `createAgentSession()`; the maintained `my-pi` fork uses the registry lifecycle | n/a |
|
||
| [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents) | `PI_SUBAGENT_NAME`, `PI_SUBAGENT_ID`, `PI_SUBAGENT_SESSION`, `PI_SUBAGENT_ACTIVITY_FILE` | none set (see #98) |
|
||
|
||
### Detection (`isSubagentExecutionContext`)
|
||
|
||
`isSubagentExecutionContext()` checks three sources in priority order:
|
||
|
||
1. **Explicit registry** - a compatible in-process spawner (`@gotgenes/pi-subagents` or the maintained `my-pi` Tintinweb fork) emits `subagents:child:session-created` before `bindExtensions()`; the permission system's subscriber writes the entry into `SubagentSessionRegistry` synchronously.
|
||
The registry (keyed by **child session id**) is checked first.
|
||
Each concurrent sibling child of the same parent receives a unique session id from `sessionManager.newSession()`, so siblings occupy distinct keys - one sibling's `disposed` event cannot evict another's entry (fixes #298).
|
||
The registry is a process-global singleton (via `getSubagentSessionRegistry()`, backed by `globalThis` + `Symbol.for()`) because each session's `ResourceLoader` creates its own `pi.events` bus: the parent's instance registers the child over the parent bus, while the child's separate jiti instance reads the same global store to detect itself and resolve its forwarding target.
|
||
2. **Env vars** (`SUBAGENT_ENV_HINT_KEYS`) - returns `true` when any key is set to a non-empty, non-whitespace value.
|
||
Used by process-based subagent extensions.
|
||
3. **Filesystem path** - session-directory path-based fallback (child session dir is nested under `subagentSessionsDir`).
|
||
|
||
### Parent-session resolution (`resolvePermissionForwardingTargetSessionId`)
|
||
|
||
`resolvePermissionForwardingTargetSessionId()` checks two sources in priority order:
|
||
|
||
1. **Explicit registry** - if the caller provides a `sessionId` and `registry`, the registry entry's `parentSessionId` is returned when present.
|
||
Used by in-process subagent extensions.
|
||
2. **Env vars** (`SUBAGENT_PARENT_SESSION_ENV_CANDIDATES`) - iterates candidates and returns the first non-empty, non-`"unknown"` value.
|
||
Used by process-based subagent extensions.
|
||
|
||
Neither nicobailon nor HazAT sets a parent-session env var today, so forwarding still fails for those extensions with an explicit log message pointing to #98.
|
||
Adding a new env var candidate when an extension adopts the convention is a one-line change to the array.
|
||
|
||
### In-process case (resolved)
|
||
|
||
In-process subagent extensions call `createAgentSession()` directly - no child process is spawned and no env vars are ever set.
|
||
`@gotgenes/pi-subagents` and the maintained `my-pi` Tintinweb fork publish `subagents:child:session-created` before `bindExtensions()` and `subagents:child:disposed` after the child closes; `src/authority/subagent-lifecycle-events.ts` subscribes and writes/removes the entry in `SubagentSessionRegistry` synchronously.
|
||
The registry is process-global (see `getSubagentSessionRegistry()` in `src/authority/subagent-registry.ts`) so the child's separate jiti instance reads the same store as the parent.
|
||
See `src/authority/subagent-registry.ts` and [Subagent Integration](../subagent-integration.md) for details.
|
||
|
||
### External convention guide
|
||
|
||
A [permission frontmatter convention guide](../guides/permission-frontmatter-for-subagent-extensions.md) documents how upstream subagent extensions can adopt the `permission:` frontmatter key as a shared convention.
|
||
This is a documentation-only proposal - no code dependency is required.
|
||
The guide covers the two-layer model, flat format reference, composition examples, and the optional event bus runtime integration.
|
||
|
||
## Cross-extension service accessor
|
||
|
||
The primary cross-extension API is a `Symbol.for()`-backed service object on `globalThis`.
|
||
|
||
Pi's extension loader creates a fresh jiti instance per extension with `moduleCache: false`, isolating module-scoped state.
|
||
`Symbol.for()` and `globalThis` are process-global by spec, so they survive this isolation.
|
||
|
||
The extension publishes a `PermissionsService` object via `publishPermissionsService()` at `session_start`, gated so an in-process subagent child does not clobber the parent's service (#302).
|
||
Other extensions retrieve it with `getPermissionsService()` from `import("@gotgenes/pi-permission-system")`.
|
||
The `package.json` `exports` field's `default` condition points to `src/service.ts`, which contains the interface, the accessor functions, and the `Symbol.for()` key - no extension machinery.
|
||
The `types` condition instead resolves to a bundled `dist/public.d.ts` (built by `rollup-plugin-dts` from `rollup.dts.config.mjs`, published via `prepack`) so a downstream consumer's `tsc` never follows the raw `#src/*` module graph - only the `default` condition (the jiti runtime) reads `src/` directly (#592).
|
||
|
||
The `PermissionsService` interface exposes five methods:
|
||
|
||
- `checkPermission(surface, value?, agentName?)` - full policy query.
|
||
- `getToolPermission(toolName, agentName?)` - tool-level permission state (`allow`/`deny`/`ask`) for pre-filtering.
|
||
- `registerToolInputFormatter(toolName, formatter)` - register a custom ask-prompt preview for a tool name; returns a disposer (#283).
|
||
- `registerToolAccessExtractor(toolName, extractor)` - declare the local filesystem path a non-conventional tool accesses so the cross-cutting `path` gate sees it, or explicitly suppress local-path inference for remote tools; returns a disposer.
|
||
- `registerAuthorizer(name, authorize)` - register a named live-authority chain link (`allow | deny | defer`); it decides nothing until the operator names it in `authorizerChain`, then `allow` / `deny` is final for any ordinary-policy `ask`, including `path`; returns a disposer.
|
||
|
||
`permissions:decision` and `permissions:ui_prompt` broadcasts remain on the event bus - fire-and-forget observation is the right abstraction for those channels ([#531] removed the event-bus RPC channel; the service accessor is now the sole cross-extension policy/prompt surface).
|
||
|
||
## The authority model
|
||
|
||
This section records the organizing concept the package is built around — the spine the elicitation, forwarding, and yolo machinery collapse into — plus the still-open directions that extend it.
|
||
It is current state, not a target: the `Authorizer` interface, its three implementations, once-per-activation selection, `canConfirm()`'s dissolution, serving-as-resolution, human-selectable grant-scope, and the `authority/` directory migration all shipped in Phase 9 (see [history/phase-9-authorizer-spine.md](history/phase-9-authorizer-spine.md) for why the spine is the correct model of the `@gotgenes/pi-subagents` integration — the anonymous cross-session-authority recursion behind the [#296]/[#298]/[#302] bug history — and not merely an internal tidy).
|
||
Of the ["beyond the target"](#beyond-the-target-a-non-deterministic-access-intent-classifier) extension points below, the model-triage `Authorizer` chain is now implemented (Phase 12; [ADR 0007](../decisions/0007-model-judge-authorizer-chain-adr.md)), and its named-link registration subsumes the pluggable escalation seam; the deny-first slice is dogfooded by `packages/pi-permission-model-judge`, and the allow-capable opaque-bash adjudicator ([#620]) remains the sole open Track B slice.
|
||
A non-deterministic access-intent classifier remains aspirational.
|
||
|
||
### The spine
|
||
|
||
Every action resolves against an **authority** — an entity empowered to permit or forbid it.
|
||
The only questions are *which* authority and how we reach it.
|
||
|
||
This sharpens principle 8.
|
||
That principle calls the human "the oracle," borrowing the computer-science term for a black box consulted for an answer the system cannot compute.
|
||
But a permission decision is not epistemic (who *knows* the answer); it is deontic (who has the *right* to decide).
|
||
If a bystander happened to know what the user wanted, their saying "allow" would authorize nothing.
|
||
What makes a decision binding is authority, not knowledge — so the organizing concept is authority, and the entity that holds it is an **`Authorizer`**.
|
||
The human is merely the `Authorizer` at the interactive root; another agent can hold the role equally well.
|
||
|
||
### Authority lives in three places
|
||
|
||
1. **Recorded authority** — the ruleset.
|
||
Config (durable, on disk), session rules (this session), and synthesized defaults/baseline are all prior rulings.
|
||
`evaluate()` *is* "consult recorded authority": an `allow` or `deny` means recorded authority is sufficient, and the decision is final.
|
||
2. **Live authority** — reached only on `ask`, when recorded authority is silent.
|
||
An entity empowered to rule *now*, reached through one of three channels (below).
|
||
3. **Absent authority** — nothing recorded, nothing reachable.
|
||
Least privilege applies: no authority means the action is unauthorized, so it is denied.
|
||
|
||
The three are one thing at different lifetimes.
|
||
A live ruling, once persisted, *becomes* recorded authority — principle 8's "their decision is a rule."
|
||
The "for this session" dialog option writes a session rule; a future "always" writes config.
|
||
|
||
### The `Authorizer` role
|
||
|
||
On `ask`, the gate escalates to **one `Authorizer`, selected once per session from context**, and is told the decision.
|
||
|
||
1. **`LocalUserAuthorizer`** — the session has UI; prompt the human here.
|
||
2. **`ParentAuthorizer`** — the session is a subagent; escalate up the tree to the parent's authority.
|
||
3. **`DenyingAuthorizer`** — no authority is reachable; deny (least privilege).
|
||
|
||
There is no "can anyone answer" pre-check.
|
||
`canConfirm()` — today a boolean smeared across the gateway, prompter, and forwarder — dissolves: every `Authorizer` answers, the `DenyingAuthorizer` by denying.
|
||
The three context predicates (`hasUI`, `isSubagent`, yolo) are evaluated once, at selection, instead of repeatedly down the prompt path.
|
||
|
||
```text
|
||
evaluate(action, recorded authority)
|
||
├─ allow / deny ------------------> decided (recorded authority sufficient)
|
||
└─ ask (recorded authority silent)
|
||
└─ escalate to the session's Authorizer
|
||
├─ LocalUserAuthorizer -> prompt the human here
|
||
├─ ParentAuthorizer -> forward up the tree, await the parent's ruling
|
||
└─ DenyingAuthorizer -> deny (no authority reachable)
|
||
|
|
||
(a persisted ruling becomes recorded authority)
|
||
```
|
||
|
||
### The recursion
|
||
|
||
Authority is delegated **down** the session tree: the human drives the root, which spawns subagents that hold no inherent authority to approve a novel action.
|
||
So an `ask` a subagent cannot answer **escalates up** to where authority resides.
|
||
Permission-system instances form a tree mirroring the session tree, and `ParentAuthorizer` is the edge that routes a child's escalation toward the human at the root.
|
||
This is the same recursion pi-subagents describes (a subagent is a child Pi), viewed from the permission system's side: the package is itself one of the hooks on that child, and it recurses by forwarding.
|
||
|
||
### Reconstruction fidelity at the serving node
|
||
|
||
The courier hop carries facts, not judgment — but what the serving node reconstructs from a forwarded request differs by audience, and the two directions are the same rule applied to different trust boundaries.
|
||
|
||
An **in-process seam** — the `Authorizer` chain, reached through `PromptPermissionDetails` — receives the full child-fixed fact set.
|
||
A chain link is operator-opted-in via `authorizerChain` and must decide from evidence, not from parsed display text or a parent-side re-derivation of the child's path ([ADR 0008](../decisions/0008-cross-session-access-intent.md) forbids the latter outright).
|
||
The same child-fixed facts let the serving node's configured authorizer decide a forwarded ask without re-deriving its path or trust context.
|
||
|
||
A **cross-extension broadcast** — `permissions:ui_prompt` / `permissions:decision` on `pi.events` — receives the minimum needed to stay correlatable, because any loaded extension can observe it.
|
||
|
||
Maximum fidelity to the decider; minimum disclosure to the observer.
|
||
Requester identity (`requesterCwd`, `principal`) crosses to neither: it is the serving node's own resolution input (ADR 0008 §3) and stays on the wire object, with the ask details carrying only the `forwarding` provenance.
|
||
|
||
### yolo is recorded authority
|
||
|
||
yolo is not a channel and not a live concern — it is a standing authorization, and it belongs in the ruleset, not in the prompt path.
|
||
It is a composition-stage rewrite: when enabled, every `ask` action in the composed ruleset is rewritten to `allow`, tagged `origin: "yolo"` so the review log still distinguishes a yolo grant from a policy allow.
|
||
|
||
```typescript
|
||
const effective = yolo
|
||
? composed.map((r) => (r.action === "ask" ? { ...r, action: "allow", origin: "yolo" } : r))
|
||
: composed;
|
||
```
|
||
|
||
This is faithful to current behavior exactly: explicit `deny` rules are not `ask`, so they pass through untouched — yolo suppresses prompts but **preserves hard denies**.
|
||
It honors principle 5 (defaults are rules; no side-channel fallbacks): `evaluate()` runs pure over the rewritten ruleset, and the prompt path loses all yolo knowledge (`shouldAutoApprovePermissionState` and `canResolveAskPermissionRequest`'s yolo arm dissolve).
|
||
|
||
The ruleset is the whole story for asks the ruleset produces.
|
||
An `ask` synthesized *after* resolution is not one: the bash wrapper floor (#481, #490) and the fail-closed `<unparseable-bash-command>` sentinel (#452) are properties of a parsed command unit, not of a pattern, so there is no rule for the rewrite to touch and they reached the prompter under yolo (#712).
|
||
The reconciliation has exactly one home — `resolveYoloGrant` at `GateRunner`'s auto-approve fast path, the single choke point every gate passes through before escalating — so the contract "an `ask` never reaches `PermissionPrompter` under yolo" holds structurally for whatever floor is added next.
|
||
It is the same deny-preserving shape as the rewrite: a `deny` is not an `ask`, so it matches neither arm.
|
||
A future "disable everything" mode — overriding denies too — would require a separate, deliberately named bypass. Appending a broad allow cannot override a more-specific deny under specificity-based evaluation.
|
||
It is not built, and it would be requested by name, never conflated with yolo.
|
||
|
||
### Fail-closed on an invalid non-global scope
|
||
|
||
The mirror image of the yolo rewrite.
|
||
When a non-global config scope (project, agent, or project-agent) is present but fails to load or validate, the loader marks it invalid (`ScopeConfig.invalid`) instead of silently substituting an empty scope.
|
||
At composition the manager floors every `allow` in the composed ruleset to `ask`, tagged `origin: "fail-closed"`, so a permissive rule inherited from a lower-precedence scope cannot remain effective behind a higher scope that was meant to tighten it (#646).
|
||
|
||
```typescript
|
||
const effective =
|
||
failClosedScopes.length > 0
|
||
? composed.map((r) => (r.action === "allow" ? { ...r, action: "ask", origin: "fail-closed" } : r))
|
||
: composed;
|
||
```
|
||
|
||
Like yolo it is deny-preserving (only `allow` is touched) and applied at composition, so the display surfaces (`getComposedConfigRules`, `getToolPermission`) reflect the clamp too.
|
||
Global is excluded — it is the lowest precedence, so nothing more permissive is inherited when it fails.
|
||
The two overlays stack in order: fail-closed floors `allow`→`ask` first, then yolo (if enabled) rewrites `ask`→`allow`, so an explicit yolo opt-in still wins.
|
||
|
||
### Discriminating delegation: the built-in model `Authorizer`
|
||
|
||
Nothing constrains an `Authorizer` to be deterministic. `LocalUserAuthorizer` is already a non-deterministic oracle — the human — and the determinism principle governs *recorded* authority (`evaluate()`), never the live-authority layer.
|
||
|
||
This package now owns the default model reviewer as the named `auto-review` authorizer. It is still downstream of deterministic policy: only an ordinary `ask` enters the authorizer chain, and a hard-guard `deny` never reaches it. The reviewer returns `allow`, `deny`, or `defer`; only `defer` falls through to the terminal human authority. Its transcript boundary admits direct user messages and recognized non-cancelled structured answers, never assistant/tool/compaction content.
|
||
|
||
The composition root registers the authorizer on the same `LocalPermissionsService` instance it publishes. `installBuiltinAutoReview()` returns lifecycle methods instead of registering a second set of Pi session handlers. The root's single `session_start` and `session_shutdown` handlers coordinate service publication and reviewer state, and `turn_start` resets the denial circuit breaker.
|
||
|
||
The review model is configuration-only. `resolveReviewModel()` reads `autoReview.provider` / `autoReview.model` and looks the pair up in the live registry; it neither defaults the pair nor synthesizes a stand-in. An unconfigured, unknown-provider, or unpublished-model pair resolves to a failure category, which the reviewer records and turns into `defer`, so the ask reaches the human prompt instead of being decided by an unintended model.
|
||
|
||
A separate `HardGuardPolicyLoader` supplies a global-only manager to `PermissionResolver`. Resolution is deliberately asymmetric:
|
||
|
||
```text
|
||
hardGuardManager.check(intent, no session rules)
|
||
deny -> final
|
||
allow -> continue
|
||
ordinaryManager.check(intent, session rules)
|
||
allow/deny -> final
|
||
ask -> authorizer chain -> human terminal fallback
|
||
```
|
||
|
||
Thus hard-file `allow` means “not blocked by hard guards,” not “approved overall.” Ordinary policy, per-agent restrictions, session grants, yolo mode, and model authority cannot loosen a hard deny.
|
||
|
||
The public authorizer registry remains available for other named authorities. Configuration order determines chain order, unregistered names are skipped fail-safe, and registration alone grants no authority.
|
||
|
||
Authorizers are named capabilities registered on `permissions:ready`; the integrated `auto-review` authorizer uses the same chain contract as third-party links. Once configured, a link's `allow` or `deny` is final for any ordinary-policy `ask`, including `path`; `defer` advances to the next link or terminal.
|
||
|
||
One configured path bypasses the chain entirely: `protectedWrites` in the hard-guard file marks write targets as human-only. The protected-write gate produces its `ask` last in the tool-call pipeline (a hard deny still blocks first); the descriptor's `humanOnly` marker skips the runner's session-approval and yolo fast paths, and `AuthorizerSelection` composes it with zero links so no link — including `auto-review` — is consulted. A `LocalUserAuthorizer` terminal prompts without a "for this session" option; any other terminal (`DenyingAuthorizer`, a `ParentAuthorizer` relay) denies without prompting. Reads never reach the gate.
|
||
|
||
This is the principled successor to the per-command argument-position work deferred from [#509].
|
||
The bash path projection surfaces a bare token that names a real file ([#645]) and deliberately accepts a fail-safe false positive (`grep id_rsa secrets.txt` prompts when an `id_rsa` file happens to exist); that false positive lives on the *ask-producing* side of `evaluate()`, and the model link dismisses it on the *ask-consuming* side without hard-coding per-command file-argument tables.
|
||
This split is the layering principle of [ADR 0009](../decisions/0009-bash-path-projection-completeness-contract.md): the deterministic layer biases toward surfacing because over-suppression is unrecoverable, and the judge absorbs the surplus.
|
||
The two compose cleanly because a promoted token emits the same structured descriptor a prefixed path does, so a link needs no promotion-specific knowledge.
|
||
|
||
**Dogfooded:** a first-party monorepo package (`packages/pi-permission-model-judge`) implements the deny-first typo-path reviewer against the real seam, so `registerAuthorizer` is born consumed (the [#267] vacant-surface guard).
|
||
|
||
### Resolved direction
|
||
|
||
The deterministic core accepts a closed `ResolvedAccessIntent` union emitted by the gate pipeline. One resolver answers those intents; adding a gate does not add a side-channel policy method.
|
||
|
||
The current classifier is deterministic. Bash parsing and registered tool extractors may emit `path-values`; there is no outside-CWD intent. Model judgment is downstream and receives only completed `ask` decisions through the authorizer chain.
|
||
|
||
This preserves the core property that the same policy and resolved intent produce the same deterministic decision.
|
||
### Beyond the target: a pluggable escalation seam
|
||
|
||
The **registration seam** this section anticipated is now designed: [ADR 0007](../decisions/0007-model-judge-authorizer-chain-adr.md) settles the `Authorizer` chain and its named-link registration (`registerAuthorizer`), with the model judge as its first consumer.
|
||
What remains a **more distant** direction — a candidate extension point, not planned work — is applying that same seam to *replace the terminal* (a delegation framework other than pi-subagents, a chat-approval bot, or a remote review surface *as* the authority) and refactoring the built-in subagent integration to register through it.
|
||
|
||
The [#261]/[#267] inversion made pi-subagents pure — it publishes its child lifecycle and knows nothing about consumers ([ADR-0002]) — but the purity is one-sided: this package is the integration owner.
|
||
It knows pi-subagents' event channel names (`subagent-lifecycle-events.ts`), hardcodes an env-hint inventory of known third-party subagent extensions (`SUBAGENT_ENV_HINT_KEYS`), and bakes in a session-directory heuristic.
|
||
Supporting a new delegation framework — or something that is not a subagent extension at all, such as a chat-approval bot or a remote review surface — means editing this package.
|
||
|
||
The subagent machinery decomposes into three roles a seam would name and separate:
|
||
|
||
- **Detection** — is this session a delegated context?
|
||
This is an Authorizer-selection predicate; [#529]'s `SubagentDetection` gives it one owner.
|
||
- **Target resolution** — where does authority live for this session; which node serves the escalation (`resolvePermissionForwardingTargetSessionId` today).
|
||
- **Transport** — how an `ask` travels to that authority and the ruling returns (the file-based request/response polling today; [#530]'s escalation-up role, `ParentAuthorizer` since [#555]).
|
||
|
||
A registered provider is exactly a selection predicate plus a `ParentAuthorizer`-shaped transport: "when my predicate matches this session and recorded authority is silent, escalate through me."
|
||
The `Authorizer` spine is therefore the seam — this direction is the spine's registration story, not a mechanism beside it.
|
||
|
||
Two shapes, the second generalizing the first:
|
||
|
||
1. **A bridge extension** — a third package subscribes to pi-subagents' lifecycle and registers with this package's public seam, leaving both cores pure.
|
||
A dedicated glue extension knowing both ends is the sanctioned complement of the rule against outbound bridges *from a core*.
|
||
2. **A dogfooded provider seam** — this package defines the registration API and implements its own built-in pi-subagents integration through it, the way `registerToolAccessExtractor` / `registerToolInputFormatter` already let extensions plug the gates; third parties register on equal terms and the zero-config default survives.
|
||
|
||
A history guard: this re-introduces an inbound registration surface of the kind [#267] retired.
|
||
It differs in kind — consumer-agnostic, documented for third parties, and consumed by the built-in provider itself, so it cannot go vacant the way the two-method `registerSubagentSession` RPC did.
|
||
|
||
Any design must honor the standing constraints: registration lands synchronously before `bindExtensions()`; cross-session visibility rides `globalThis` + `Symbol.for()` (the [#296] bus-split lesson); a provider is live authority only and never touches `evaluate()`; and a session no provider claims selects `DenyingAuthorizer` — least privilege, unchanged.
|
||
It sequences after the Phase 9 spine and warrants its own decision record.
|
||
|
||
### Naming
|
||
|
||
The concept and the code role take two grammatical forms of one root, each for what it correctly denotes:
|
||
|
||
- **`authority`** (mass noun) — the right to decide; used for the concept ("recorded authority," "where authority lives").
|
||
- **`Authorizer`** (count noun) — the entity that holds it; used for the interface and its implementations.
|
||
|
||
`Authorizer` is domain-idiomatic: AWS Lambda "authorizers" and OAuth's authorization server return allow/deny, so the term already denotes an entity that can refuse.
|
||
|
||
## Module structure
|
||
|
||
```text
|
||
src/
|
||
├── index.ts Composition root and Pi event registration
|
||
├── config-loader.ts Global ordinary policy loading
|
||
├── policy-loader.ts Global policy + active-agent frontmatter gateway
|
||
├── hard-guard-loader.ts Independent global-only hard-guard + protectedWrites loading
|
||
├── rule.ts Pattern matching and specificity ranking
|
||
├── permission-manager.ts Unified intent evaluation
|
||
├── access-intent/ Resolved intent types and resolver
|
||
├── handlers/
|
||
│ ├── before-agent-start.ts Session warmup + denied-skill filtering (structured skills array, prompt-text fallback); no active-tool control
|
||
│ └── gates/ skill -> path -> bash -> tool pipelines
|
||
├── authority/
|
||
│ ├── authorizer-selection.ts Configured authorizer resolution
|
||
│ └── authorizer-chain.ts Named authorizer ordering and terminal fallback
|
||
├── auto-review/ Integrated reviewer
|
||
├── service.ts Cross-extension permission service
|
||
└── types.ts Shared domain types
|
||
```
|
||
|
||
Tool visibility is deliberately absent from this tree's responsibilities. It belongs to Tool Search.
|
||
## Improvement roadmap — Phase 13: The prompt-presentation seam
|
||
|
||
### Findings (planned 2026-08-15)
|
||
|
||
The declared candidate is [ADR 0011](../decisions/0011-prompt-presentation-contract.md) (the prompt-presentation contract), whose Staging section assigns its decomposition to this planning pass.
|
||
The cause is a structural fusion of presentation with decision-making, recorded in the [Prompt presentation](#prompt-presentation) section above: six sites assemble a flat prompt `message` string (`formatAskPrompt`'s three branches, the skill prompts, the external-directory prompts, `formatPathAskPrompt`, the per-tool previews, and the parent-side forwarded prefix) that travels unchanged to every consumer — the inline dialog, the `select`/`input` fallback, the review log, the `permissions:ui_prompt` broadcast, and the forwarded wire.
|
||
Because the payload is a pre-rendered sentence, elision is a payload property rather than a render property: the bash branch has no cap, nothing bounds height ([#710]), a forwarded ask is assembled twice under two configs, and every denial path echoes unbounded input into the agent's context.
|
||
The phase implements the contract's staged first step — the complete payload and the renderer seam — so [#710] is fixed by construction and [#713] becomes a conformance requirement of the payload's invariant core rather than a separate enhancement.
|
||
|
||
Corroboration (fallow + sweeps, 2026-08-15): health 88 (A; deductions are unit size and coupling), dead code 0, duplication 0.2% (the documented intentional `literalTextOf`/`resolveNodeText` pair plus one new 16-line internal clone in `token-collection.ts`).
|
||
The repeated-discriminator sweep found no new family — survivors are validation-edge `typeof` guards, per-node AST dispatch, and boundary translation, idiomatic per the taxonomy.
|
||
The `value-guards.ts` refactoring target remains rejected (healthy high-fan-in leaf).
|
||
The craftsmanship scout re-refuted all three fallow giant-test flags (nested `describe` trees of small behavior-named tests, unchanged since Phase 12) and found one concentrated cluster: six duplicated local test factories (`PermissionCheckResult` builders and `ToolPreviewFormatter` options literals) across `denial-messages.test.ts`, `permission-prompts.test.ts`, and `tool-preview-formatter.test.ts` — exactly the presentation test files the spine rewrites, so the extraction rides Step 1 as a tidy-first prep commit.
|
||
Directory check: the spine rewrites the ~8 cohesive presentation modules at the flat `src/` root, so per the recorded reorg convention this phase seeds `src/presentation/` and the touched modules reach their final home the first time.
|
||
|
||
Open-issue sweep dispositions (user-decided):
|
||
|
||
- [#710] — adopted as Step 2 (the bounded local renderers are its fix by construction); closed with it.
|
||
- PR [#738] (highlight the flagged element in TUI prompts) — swept in during Step 1 planning, having been filed the day before this phase was scoped.
|
||
Highlighting is a **render** concern under ADR 0011, so its intent is adopted in Step 2's dialog renderer with authorship credited, and the PR closes as superseded rather than being rebased — the same disposition [#716] received.
|
||
Both were adopted and credited when Step 2 landed.
|
||
- [#713] — its inner-command fact enters the payload in Step 1 and becomes visible in every render in Step 2 (the `runs` line); it closed with Step 2.
|
||
- [#721] / [#735] — adopted as Step 5: out-of-process forwarding liveness; [#735]'s scenario 1 (dead parent) is resolved by it, while scenario 2 (a parent whose turn is occupied) stays with the [#722] diagnosis, which remains open and out of scope.
|
||
- [#726] — adopted as Step 6 (decision provenance).
|
||
- [#732] — adopted as Step 7 (model-judge `agentDir` fix).
|
||
- [#655] — adopted as Step 8 (`deriveApprovalPattern` flavor injection).
|
||
- [#620] — deferred with recorded rationale: one phase old, non-gating, the `registerAuthorizer` seam it consumes exists, and the phase's capacity goes to the presentation spine; [#698] and [#706] express user demand for the same capability and fold into it when it is scheduled.
|
||
- [#519] — kept open with recorded rationale (not a silent re-defer): still externally blocked on Pi SDK `UIContext` evolution; it closes or schedules when the SDK ships the capability.
|
||
- [#639] — deferred to a later phase: first sweep since filing, and its policy-model design budget does not fit alongside the presentation spine.
|
||
- [#742] — swept out of scope this phase by composition decision (first explicit sweep); it is the last member of the #306/#741 nested-command bypass family and is a strong candidate for the next phase's spine or an independent step.
|
||
- [#610] — adopted mid-phase as Step 10, after a [#745] planning question surfaced the gap underneath it.
|
||
It was originally swept out as a feature issue; the sweep read the symptom (a parent-side consumer with no terminal signal) without reading the cause.
|
||
Tracing it found three id conventions and no id at all on any non-prompting path, so the local foundation split out as Step 9 ([#752]) and this issue narrowed to the cross-session half it actually reported.
|
||
- [#752] — filed mid-phase as Step 9, the foundation Step 10 needs.
|
||
- Feature issues [#736], [#720], [#691], [#688], [#687], [#686], [#680], [#658], [#609], [#604], [#603], [#699] — out of scope for a structural phase; [#654] and [#648] become downstream packages over the annotator and evidence-formatter seams per ADR 0011 §8, which are themselves deferred until the payload exists.
|
||
|
||
Trajectory: Phase 12's maximum step priority was 20; this phase's is 20 (Step 1).
|
||
No decline, so the regular rotation continues.
|
||
|
||
### Health metrics
|
||
|
||
| Metric | Baseline (2026-08-15) | Phase 13 target |
|
||
| ----------------------------------------------------------------------- | --------------------- | --------------- |
|
||
| Flat-assembler sites (`formatAskPrompt` references in `src/`) | 4 | 0 ✅ |
|
||
| Forwarded-wire `message: string` field (`permission-forwarding.ts`) | 1 | 0 ✅ |
|
||
| Broadcast `message: string` field (`permission-ui-prompt.ts`) | 1 | 0 ✅ |
|
||
| `src/presentation/` domain directory present | 0 | 1 ✅ |
|
||
| Legacy `message` render sites (`renderLegacyMessage` in `src/`) | 17 | 0 ✅ |
|
||
| Forwarding-liveness module present (`authority/forwarding-liveness.ts`) | 0 | 1 ✅ |
|
||
| `decidedBy` provenance sites in `src/` | 0 | ≥ 1 ✅ |
|
||
| Request-id mint sites in `src/` | 2 | 1 ✅ |
|
||
| `requestId` fields in `permission-events.ts` (ui\_prompt + decision) | 1 | 2 ✅ |
|
||
| Model-judge resolves `agentDir` via `getAgentDir` (`config-loader.ts`) | 0 | ≥ 1 |
|
||
| Ambient `node:path` import in `session-rules.ts` | 1 | 0 |
|
||
| fallow health score | 88 (A) | ≥ 88 |
|
||
| Production duplication | 0.2% | ≤ 0.2% |
|
||
| Dead exports | 0 | 0 |
|
||
|
||
Recompute commands (run from the repo root):
|
||
|
||
- Flat-assembler sites: `grep -rn "formatAskPrompt" packages/pi-permission-system/src --include="*.ts" | wc -l`
|
||
- Wire message field: `grep -c "message: string" packages/pi-permission-system/src/authority/permission-forwarding.ts`
|
||
- Broadcast message field: `grep -c "message: string" packages/pi-permission-system/src/permission-ui-prompt.ts`
|
||
- Presentation directory: `ls packages/pi-permission-system/src | grep -c presentation`
|
||
- Legacy message sites: `grep -rn "renderLegacyMessage" packages/pi-permission-system/src --include="*.ts" | wc -l`
|
||
- Liveness module: `ls packages/pi-permission-system/src/authority | grep -c "forwarding-liveness"`
|
||
- Provenance sites: `grep -rn "decidedBy" packages/pi-permission-system/src | wc -l`
|
||
- Id mint sites: `grep -rnE "Math\.random\(\)\.toString\(36\)|randomUUID\(\)" packages/pi-permission-system/src --include="*.ts" | wc -l`
|
||
- Event request ids: `grep -c "requestId" packages/pi-permission-system/src/permission-events.ts`
|
||
- Model-judge agentDir: `grep -c "getAgentDir" packages/pi-permission-model-judge/src/config-loader.ts`
|
||
- Ambient path import: `grep -c "node:path" packages/pi-permission-system/src/session-rules.ts`
|
||
- Health/duplication/dead exports: `pnpm fallow health --score --workspace @gotgenes/pi-permission-system` / `pnpm fallow dupes --workspace @gotgenes/pi-permission-system` / `pnpm fallow dead-code --workspace @gotgenes/pi-permission-system`
|
||
|
||
The presentation-directory, liveness-module, `decidedBy`, and `getAgentDir` rows grep for names the phase has not created yet; the step that creates each (Steps 1, 5, 6, 7 respectively) must either use the roadmap's name or update the metric row in the same commit.
|
||
The two request-id rows were added mid-phase with Steps 9 and 10, and the legacy-message row with Step 4, so their baselines are measured at that point rather than at the phase-open snapshot.
|
||
|
||
### Steps
|
||
|
||
#### ✅ Step 1: `PromptPayload` and its builders — the assembly sites become one payload ([#744])
|
||
|
||
**Cause:** presentation is fused with decision-making — each gate renders its facts into a sentence at the point of decision, so no consumer downstream can render under its own budget; the flat `message` string is the fusion made concrete.
|
||
|
||
- **Smell:** Category C (coupling/boundary flaw — the payload/render boundary does not exist).
|
||
- **Target:** new `src/presentation/prompt-payload.ts` (the `PromptPayload` type per ADR 0011 §2 — `request` invariant core, `evidence`, `annotations` slot — plus builders); `permission-prompts.ts`, `handlers/gates/external-directory-messages.ts`, and the skill-prompt formatting migrate into `src/presentation/` as payload builders; the gate descriptors emit the payload alongside the facts they already compute; `PromptPermissionDetails` carries it; `message` is derived *from* the payload during the transition (lift-and-shift, no consumer changes yet).
|
||
The payload's `request.executedUnit` carries the inner command of an unstrippable wrapper — [#713]'s fact, entering here.
|
||
Tidy-first prep commit: extract the scout's duplicated fixtures (`makePermissionCheckResult`, a shared `ToolPreviewFormatter` factory) into `test/helpers/` and migrate the three presentation test files.
|
||
- **Outcome:** every ask has a complete structured payload; `grep -rn "formatAskPrompt" packages/pi-permission-system/src --include="*.ts" | wc -l` goes 4 → 0; `ls packages/pi-permission-system/src | grep -c presentation` goes 0 → 1; behavior is unchanged (the derived `message` is byte-compatible or near-compatible, pinned by existing tests).
|
||
- **Landed:** both metrics hit their targets, and `message` is byte-identical — every former assembler's string assertion now runs against `renderLegacyMessage`, which reads the payload alone, so the suite is the proof the payload is complete.
|
||
`PromptPermissionDetails.payload` is **required**, making "every ask carries a complete payload" a compile-time guarantee rather than a convention.
|
||
Planning found a **sixth** assembler the issue and ADR 0011 both omit — `formatPathAskPrompt`, with two consumers — and found that [#713]'s fact had no source at all: `classifyWrapperCommand` only flagged a wrapper, so the new `wrapper-analysis.ts` resolves what one actually runs.
|
||
Three departures from ADR 0011 §2's illustrative type are documented at their declarations: a `kind` discriminant, `| null` over `| undefined` (the payload goes on the JSON wire in Step 3), and `commandContext` as a request fact.
|
||
- **Impact 5 / Risk 2 / Priority 20.**
|
||
|
||
Release: batch "presentation-payload"
|
||
|
||
#### ✅ Step 2: Bounded local renderers — the dialog and fallback render the payload under a budget ([#710])
|
||
|
||
**Cause:** same cause, consumed at the human's decision surface — with no renderer layer, the dialog shows whatever the assembler produced, so a subagent's oversized tool input takes over the parent's viewport and the operator decides blind or scrolls away the transcript.
|
||
|
||
- **Smell:** Category C, with the user-visible symptom filed as the [#710] bug.
|
||
- **Target:** new `src/presentation/dialog-renderer.ts` rendering the payload for the inline TUI dialog and the `select`/`input` fallback under a row budget plus a per-field width cap (ADR 0011 §5), with marked elision and a reachable complete view (§4); [#716]'s aligned one-fact-per-line rendering intent adopted here; the invariant core (§3) — including `executedUnit` — always visible, which closes [#713]; the row-budget config field follows the established `config-schema.ts` → `extension-config.ts` → `mergeUnifiedConfigs()` path (the #332/#347 drop class) with `pnpm run gen:schema`.
|
||
- **Outcome:** a forwarded ask with pathological input renders within the budget with the complete view reachable; [#710] and [#713] close; the local prompt path no longer reads `details.message`.
|
||
- **Landed:** the reported ask — a 200-line here-string, measured at 202 rows locally and 205 forwarded, identically at widths 80/120/160 — renders inside the 24-row default with its request facts intact.
|
||
Planning settled the reading that makes that possible: §3's "never elided" means never *omitted*, so the field cap applies to the core and §5's own here-string rationale is coherent with it.
|
||
The row budget therefore bounds evidence and the field cap bounds the core, with an entry admitted whole or dropped.
|
||
`Ctrl+O` gained the dialog's own expansion alongside its host forward ([#642]) rather than a second binding, and the hint names it only when the render dropped something.
|
||
PR [#738]'s highlight target is derived from the payload rather than carried as a `PromptPermissionDetails` field, so it cannot drift from the rendered text.
|
||
- **Impact 5 / Risk 3 / Priority 15.**
|
||
|
||
Release: batch "presentation-payload"
|
||
|
||
#### ✅ Step 3: The cross-boundary swap — payload replaces `message` on the wire and the broadcast ([#745])
|
||
|
||
**Cause:** same cause at the two cross-boundary contracts — the forwarded wire relays the child's prose (assembled under the child's config) and the broadcast ships the full sentence to any unconsented observer, so consistency across local and forwarded asks is structurally unattainable and the bus over-discloses.
|
||
|
||
- **Smell:** Category C (boundary flaw), with the ADR 0011 §6 broadcast narrowing as the disclosure fix.
|
||
- **Target:** `src/authority/permission-forwarding.ts` (the request carries the payload, `message` removed), `src/authority/approval-escalator.ts` (child serializes it), `src/authority/forwarded-request-server.ts` (serving renders the child's facts under the parent's budget; a version-skewed request without a payload renders from whatever fields it carries, never empty — ADR 0011 §9), `src/permission-ui-prompt.ts` (broadcast narrowed to the `request` facts; forwarded provenance retained in full), soft-deprecation of `toolInputPreviewMaxLength`/`toolTextSummaryMaxLength` via the config-issue channel (§5).
|
||
Breaking: `feat!:` with a migration note naming the payload fields that supersede `message` on both contracts.
|
||
- **Outcome:** `grep -c "message: string"` goes 1 → 0 in both `permission-forwarding.ts` and `permission-ui-prompt.ts`; a forwarded ask renders identically in kind to a local one; the bus discloses request facts and verdicts only.
|
||
- **Impact 4 / Risk 3 / Priority 12.**
|
||
- **Landed:** both metric rows are `0`; `asPromptPayload` (an all-or-nothing tolerant guard beside its type) admits the payload through the wire's `asX` reader, and the required-core gate no longer demands `message`, so an older child's request is served from its display fields rather than rejected.
|
||
`docs/migration/0745-prompt-payload-contracts.md` names the superseding fields and the upgrade-the-parent-first ordering.
|
||
The [#710] row-budget invariant was re-measured at the new shape — the reported here-string ask arriving as `kind: "bash"` with the child's real evidence — and stays inside the 24-row default.
|
||
|
||
Release: batch "presentation-contract"
|
||
|
||
#### ✅ Step 4: The agent-facing and review-log renderers ([#746])
|
||
|
||
**Cause:** the same unbounded payload that took over the viewport is echoed verbatim into the agent's context on every denial (the human's constraint is rows; the agent's is tokens), and the review log persists prompt wording as a side effect of assembly rather than as a configured render.
|
||
|
||
- **Smell:** Category C, plus the log-growth concern of `docs/decisions/0010-permission-log-secret-exposure.md`.
|
||
- **Target:** `denial-messages.ts` migrates to `src/presentation/agent-renderer.ts` under ADR 0011 §7 — the agent renderer identifies the call (surface, matched pattern, verdict, the human's typed reason) and never reproduces its input; the review-log write path (`permission-prompter.ts` / `session-logger.ts`) renders the payload under its existing configured limits instead of persisting `message`.
|
||
- **Outcome:** denial text is structurally bounded (no raw-command interpolation on any denial path); the review log's growth is a configured decision; key-name redaction unchanged.
|
||
- **Impact 3 / Risk 2 / Priority 12.**
|
||
- **Landed:** `grep -rn "renderLegacyMessage" src` goes 17 → 0 — the review log was the last `message` reader, so `legacy-message.ts` went with it and `PromptPermissionDetails.message` is gone.
|
||
Planning settled the reading §7 leaves open: *identifying* a call includes naming which of its operands the rule fired on, while *reproducing* it means echoing the command or the tool-input body, which no render does.
|
||
The flagged element is therefore rendered under a field cap rather than structurally excluded — the departure is deliberate, because correlation is already structural (Pi returns a block reason as that call's own tool result, stamped with its `toolCallId`, with the call's arguments retained in context) and what the agent cannot recover is sub-call granularity.
|
||
`DenialContext` dissolved into `PromptPayload`: every field it uniquely held is one §7 forbids rendering, and the operator's deny-with-reason text passes from the resolved check as an argument, which also makes it reach the agent on every surface rather than tool and bash alone.
|
||
Measured on a live 7.07 MB review log: dropping `message` (21.5%) and capping every field at the new `reviewLogFieldMaxWidth` (a further 7.1%, all of it `command`) removes 28.7%, shortening 4.3% of command entries.
|
||
The bound went to `writeLine` rather than each renderer, and the log facts to `GateRunner` rather than each of the seven gates, on the same reasoning: a producer cannot forget what it never supplies.
|
||
|
||
Release: batch "presentation-contract"
|
||
|
||
#### ✅ Step 5: Out-of-process forwarding liveness ([#721], fixes [#735] scenario 1)
|
||
|
||
**Cause:** the forwarding timeout conflates "a human is deliberating" with "nobody is home" — for an out-of-process child (which shares no `globalThis` with its parent) the 10-minute `PERMISSION_FORWARDING_TIMEOUT_MS` is the only signal, so every ask forwarded to a dead parent burns the full timeout and reports a denial the user never made.
|
||
The in-process serving registry (#719) already made the two distinguishable for in-process children; the filesystem channel lacks the equivalent.
|
||
|
||
- **Smell:** Category C (lifecycle/boundary flaw at the cross-process edge).
|
||
- **Target:** new `src/authority/forwarding-liveness.ts` (a filesystem liveness signal — [#721] names two candidate mechanisms, claim artifact or serving heartbeat; `/plan-issue` picks on ergonomics), `src/authority/forwarding-manager.ts` (serving node maintains the signal), `src/authority/approval-escalator.ts` (child fast-fails on absent/stale liveness after a short grace, with a path-naming `denialReason` and `confirmationUnavailable`, matching the in-process judgement's safe direction).
|
||
- **Outcome:** a child forwarding to a target no live session is draining abandons in seconds instead of 600, resolving [#735] scenario 1; scenario 2 stays with [#722]; `ls packages/pi-permission-system/src/authority | grep -c "forwarding-liveness"` goes 0 → 1.
|
||
- **Impact 4 / Risk 3 / Priority 12.**
|
||
- **Landed:** the metric is 1, and the mechanism choice went to the heartbeat on a constraint the issue's framing did not carry: `processInbox` drains serially, awaiting each escalation, so a per-request claim would leave a second request unclaimed for as long as a human deliberates on the first — and claiming the whole batch up front degrades the artifact to "the loop saw you" while adding a third file to the tree whose removal ordering produced [#398].
|
||
The records therefore live beside `sessions/`, never inside it, which is what keeps that ordering untouched.
|
||
The load-bearing detail is where the re-announcement sits: `ForwardingManager`'s tick refreshes ahead of its processing guard, because a parent holding `processInbox` open for a deliberating human is serving throughout, and refreshing behind the guard would let its record decay exactly when it is most demonstrably alive.
|
||
The two-channel dispatch went into `ForwardingLivenessJudge` rather than `ParentAuthorizer`, so the poll loop asks one question about a target and the in-process and out-of-process rules cannot drift.
|
||
Absence of a record counts as unserved (user decision at the clarification gate), which is what resolves the reported case — a cleanly exited parent leaves nothing behind — at the cost of an upgrade-ordering requirement now documented in `docs/subagent-integration.md`.
|
||
This deliberately reverses [#719]'s rule that an `env`-resolved target is never fast-failed; a dead pid is judged immediately, while a merely stale record still waits out the staleness window, and no new config field was added.
|
||
|
||
Release: independent
|
||
|
||
#### ✅ Step 6: Decision provenance — `decidedBy` on permission decisions ([#726])
|
||
|
||
**Cause:** the decision path knows what decided (human prompt, session approval, config rule, authorizer link, auto-allow, timeout) and discards it before the log write, so an audit cannot distinguish a human approval from an auto-approval — the decision-provenance principle: record what decided and on what basis, not only the outcome.
|
||
|
||
- **Smell:** Category C (a fact established at the decision point dies before its consumer).
|
||
- **Target:** a `decidedBy` discriminated union threaded from the decision sites (`GateRunner`'s fast paths, `PermissionPrompter`, the `Authorizer` chain, `ForwardedRequestServer`) into the review-log entries and the forwarded response; lands after Step 4 so the provenance fields ride the new log renderer rather than the retiring `message` shape.
|
||
- **Outcome:** every terminal `permission_request.*` / `forwarded_permission.*` entry names its decider with enough detail to reconstruct the decision; `grep -rn "decidedBy" packages/pi-permission-system/src | wc -l` goes 0 → ≥ 1.
|
||
- **Impact 3 / Risk 1 / Priority 15.**
|
||
- **Landed:** the metric is 37, and the scope narrowed by user decision during planning: `PermissionDecisionEvent` is **not** touched, because the bus channel's consumers are not yet known and it is the narrowest renderer under ADR 0011 §6.
|
||
Two of the issue's three asks were already answered — [#752] made the request id shared across the forwarding hop, and there is no `/permissions` history view to surface into.
|
||
Each variant is self-contained (it repeats its own surface, pattern, origin, link name, or reason) rather than leaning on a sibling log column: that duplicates `surface` and the pattern locally, and it is the only shape that survives onto the response file, which has no such columns.
|
||
The `forwarded` variant is recursive, so the requesting side records *which session* answered and *what within it* decided as two facts — flattening would make a remote decision read as local.
|
||
Its guard is depth-bounded because the value is read off disk.
|
||
Attribution is stamped at the site that decides, never derived: the mode dispatcher names the human's surface (the dialog model and the fallback return an `UnattributedDecision`, the shape `GateBypass.decision` already used for the request id), the chain names the link at the point its loop breaks, and both absent-authority paths reuse the string they already report to the model so the two cannot drift.
|
||
`decidedBy` is required on `PermissionPromptDecision` and `GateBypass`, making completeness a compile-time guarantee rather than a convention.
|
||
Measured on a live 7.44 MB review log: 1432 terminal prompted decisions carried no decider, and the record adds 95–134 bytes to 5777 decision-bearing lines — a 7.4% worst case, against the 28.7% [#746] removed.
|
||
|
||
Release: independent
|
||
|
||
#### Step 7: Model-judge honors `PI_CODING_AGENT_DIR` ([#732])
|
||
|
||
**Cause:** `pi-permission-model-judge` recomputes the global config scope from a hardcoded `~/.pi/agent` instead of the SDK's `getAgentDir()`, so the two packages disagree about where the global scope lives whenever `PI_CODING_AGENT_DIR` is set — and the configured judge silently never registers, indistinguishable in the review log from "not installed".
|
||
|
||
- **Smell:** Category F (cross-package divergence on a single source of truth).
|
||
- **Target:** `packages/pi-permission-model-judge/src/config-loader.ts` resolves `agentDir` via `getAgentDir()` from `@earendil-works/pi-coding-agent`, as pi-permission-system does.
|
||
- **Outcome:** both packages read the global scope from the same directory; `grep -c "getAgentDir" packages/pi-permission-model-judge/src/config-loader.ts` goes 0 → ≥ 1; ships as a `fix:` in the model-judge component.
|
||
- **Impact 3 / Risk 1 / Priority 15.**
|
||
|
||
Release: independent
|
||
|
||
#### Step 8: `deriveApprovalPattern` takes the injected `PathFlavor` ([#655])
|
||
|
||
**Cause:** `deriveApprovalPattern` (`session-rules.ts`) reads `node:path`'s ambient `dirname`/`sep`, bypassing the injected `PathFlavor` that owns every other platform decision — the one surviving violation of the #562/#510 invariant, producing mixed-separator patterns on a real Windows host and untestable win32 behavior on POSIX CI.
|
||
|
||
- **Smell:** Category C (ambient platform read; decide-once violation).
|
||
- **Target:** `src/session-rules.ts` — derive the pattern through the flavor's `impl`/separator, threading the flavor from the call sites that already hold a `PathNormalizer`.
|
||
- **Outcome:** `grep -c "node:path" packages/pi-permission-system/src/session-rules.ts` goes 1 → 0; a win32 unit test can pin the derived pattern; `refactor:` (hidden type — cuts no release on its own).
|
||
- **Impact 2 / Risk 1 / Priority 10.**
|
||
|
||
Release: independent
|
||
|
||
#### ✅ Step 9: A minted request id, carried on every decision ([#752])
|
||
|
||
**Cause:** there is no permission request id — there are three conventions, and none covers a request that never prompts.
|
||
The tool-call gates borrow the SDK's `toolCallId`, the skill-input gate mints its own, and the escalation edge mints a third that discards the one it was handed; the id attaches inside `promptForApproval`, so session-approved, yolo, infrastructure-auto-allowed and policy-blocked resolutions carry no id at all, and `PermissionDecisionEvent` carries none ever.
|
||
|
||
- **Smell:** Category C (a fact established at request creation dies before its consumers), the same shape as Step 6.
|
||
- **Target:** a single mint at the top of `GateRunner.run` shared by the bypass and descriptor branches; the id carried on the non-prompting review-log writes and added to `PermissionDecisionEvent`; `GateBypass.decision` becomes an `Omit<PermissionDecisionEvent, "requestId">` so a gate keeps emitting only what it knows; `createSkillInputRequestId` deleted.
|
||
`toolCallId` keeps flowing untouched — it is the join back to the Pi transcript, and a distinct fact from the request id.
|
||
- **Outcome:** every permission request is correlatable from creation regardless of how it resolves; the mint-site count goes 2 → 1; `grep -c "requestId" packages/pi-permission-system/src/permission-events.ts` goes 1 → 2.
|
||
Additive for consumers, so it needs no major bump of its own.
|
||
- **Landed:** both metrics hit their targets, and three of the issue's own shape claims did not survive the code.
|
||
The non-prompting writes are **four**, not three — `policy_denied` is written by `applyPermissionGate` from the log context the runner hands it, so injecting the id there covers it.
|
||
Exactly **one** `GateBypass` carries a `decision` (the infrastructure-read bypass), not three; the other two carry only a `log`.
|
||
And `run`'s third parameter is **deleted**, not narrowed: `requestId: toolCallId` was its only reader.
|
||
The forwarding edge stopped minting its third id and adopts the one it is handed, so a forwarded ask carries one id from the child's gate to the human's decision — which supersedes Step 3's planned `requesterRequestId` wire field.
|
||
Two decisions beyond the issue: `randomUUID` over the package's `<ts>-<rand>-<pid>` convention (Node has no UUIDv7 — `randomUUID({ version: 7 })` silently returns a v4), and a filename-safety guard on the adopted id, since adoption is what first lets an inbound id name an outbound file.
|
||
The gate-error boundary's missing `permissions:decision` was found here and filed as [#753].
|
||
- **Impact 3 / Risk 1 / Priority 15.**
|
||
|
||
Release: independent
|
||
|
||
#### Step 10: Cross-session prompt/decision correlation ([#610])
|
||
|
||
**Cause:** a forwarded ask's prompt is emitted by the parent and its terminal decision by the child, on a different event bus for an out-of-process child — so a parent-side consumer that marks an agent blocked on `permissions:ui_prompt` has no public signal to clear it and can stay blocked forever.
|
||
Measured on the review log: 53 of 57 `forwarded_permission.request_created` entries carry an id appearing on no `permission_request.*` entry, the child's ask and the request the parent serves joined by nothing but a one-millisecond timestamp gap.
|
||
|
||
- **Smell:** Category C (boundary flaw — a lifecycle observable on one side of the forwarding edge and not the other).
|
||
- **Target:** `src/authority/forwarded-request-server.ts` emits a parent-side `permissions:decision` after the serving session's human decision, reusing the request id its own `permissions:ui_prompt` carried; `ForwardedPermissionRequest.id` **is** the child's originating `requestId` (Step 9), so it already joins the two sides' log entries.
|
||
Silent policy resolutions stay silent — no prompt, no terminal event, unchanged.
|
||
- **Outcome:** a direct prompt and its decision share one id; a forwarded prompt and its parent-side decision share one id on one bus; concurrent equivalent prompts stay independently correlatable.
|
||
- **Impact 3 / Risk 2 / Priority 12.**
|
||
|
||
Release: independent
|
||
|
||
### Step dependency diagram
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
S1["✅ Step 1 (#744): PromptPayload + builders"] --> S2["✅ Step 2 (#710): bounded local renderers"]
|
||
S2 --> S3["✅ Step 3 (#745): cross-boundary swap (feat!)"]
|
||
S2 --> S4["✅ Step 4 (#746): agent + review-log renderers"]
|
||
S4 --> S6["✅ Step 6: decidedBy provenance (#726)"]
|
||
S9["✅ Step 9 (#752): minted request id"] --> S3
|
||
S9 --> S10["Step 10 (#610): cross-session correlation"]
|
||
S3 --> S10
|
||
S4 --> S10
|
||
S5["✅ Step 5: forwarding liveness (#721)"]
|
||
S7["Step 7: model-judge agentDir (#732)"]
|
||
S8["Step 8: deriveApprovalPattern flavor (#655)"]
|
||
```
|
||
|
||
### Parallel tracks
|
||
|
||
- **Track A — prompt-presentation spine:** Steps 1 → 2 → {3, 4}.
|
||
- **Track B — forwarding liveness:** Step 5 (touches `authority/` forwarding files only; disjoint from Track A apart from `approval-escalator.ts`, which Track A's Step 3 also edits — land Step 5 before or after Step 3, not concurrently).
|
||
- **Track C — decision provenance:** Step 6, after Step 4.
|
||
- **Track D — independent fixes:** Steps 7 and 8, any time.
|
||
- **Track E — request identity:** Step 9, then Step 10 after Step 4.
|
||
Step 9 was disjoint from Track A apart from `permission-events.ts`, which Step 3 also edits (different interfaces in the same file) — it landed **before** Step 3, and its id adoption retired the `requesterRequestId` wire field Step 3 had planned, so Step 10 finds both halves in place.
|
||
Step 10 and Step 6 both enrich the review-log write path; land them in sequence.
|
||
|
||
The step numbers are discovery order, not execution order: Steps 9 and 10 were added mid-phase, and Step 9 runs before Step 3.
|
||
The diagram above is the authority on sequencing.
|
||
|
||
### Release batches
|
||
|
||
- **Batch "presentation-payload":** Steps 1, 2 (ship together; tail = Step 2; release vehicle = Step 2's `fix:` for [#710] — Step 1 is a hidden `refactor:`).
|
||
- **Batch "presentation-contract":** Steps 3, 4 (ship together; tail = Step 4; release vehicle = Step 3's `feat!:` breaking release with the `message`-replacement migration note).
|
||
- Independently releasable: Step 5 (`fix:`), Step 6 (`feat:`), Step 7 (`fix:`, model-judge component), Step 8 (`refactor:` — hidden type, batches into the next release), Step 9 (`feat:`), Step 10 (`feat:`).
|
||
|
||
## Refactoring history
|
||
|
||
The architecture above is the product of twelve completed improvement phases.
|
||
Each phase's findings, numbered plan, dependency diagram, and health metrics are preserved in a per-phase history file under [`history/`](history/).
|
||
|
||
| Phase | Theme | History |
|
||
| ----- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||
| 1 | Preview formatter extension seam | [phase-1-preview-formatter-seam.md](history/phase-1-preview-formatter-seam.md) |
|
||
| 2 | Complexity and duplication paydown | [phase-2-complexity-duplication.md](history/phase-2-complexity-duplication.md) |
|
||
| 3 | State-owning collaborators | [phase-3-collaborator-encapsulation.md](history/phase-3-collaborator-encapsulation.md) |
|
||
| 4 | Constructibility and god-object decomposition | [phase-4-constructibility.md](history/phase-4-constructibility.md) |
|
||
| 5 | Tell-Don't-Ask and decoupling sweep | [phase-5-tell-dont-ask-sweep.md](history/phase-5-tell-dont-ask-sweep.md) |
|
||
| 6 | Access-intent extraction | [phase-6-access-intent-extraction.md](history/phase-6-access-intent-extraction.md) |
|
||
| 7 | AccessPath as the universal path representation | [phase-7-accesspath-universal-representation.md](history/phase-7-accesspath-universal-representation.md) |
|
||
| 8 | Tidy first for the authority spine | [phase-8-tidy-first-authority-spine.md](history/phase-8-tidy-first-authority-spine.md) |
|
||
| 9 | The Authorizer spine | [phase-9-authorizer-spine.md](history/phase-9-authorizer-spine.md) |
|
||
| 10 | Decide-once dispatch and bash-surface hardening | [phase-10-decide-once-dispatch-bash-surface-hardening.md](history/phase-10-decide-once-dispatch-bash-surface-hardening.md) |
|
||
| 11 | Shell-tool aliasing and elicitation UX | [phase-11-shell-tool-aliasing-elicitation-ux.md](history/phase-11-shell-tool-aliasing-elicitation-ux.md) |
|
||
| 12 | Cross-session access intent and the Authorizer chain | [phase-12-cross-session-intent-authorizer-chain.md](history/phase-12-cross-session-intent-authorizer-chain.md) |
|
||
|
||
[#261]: https://github.com/gotgenes/pi-packages/issues/261
|
||
[#267]: https://github.com/gotgenes/pi-packages/issues/267
|
||
[#296]: https://github.com/gotgenes/pi-packages/issues/296
|
||
[#298]: https://github.com/gotgenes/pi-packages/issues/298
|
||
[#302]: https://github.com/gotgenes/pi-packages/issues/302
|
||
[#620]: https://github.com/gotgenes/pi-packages/issues/620
|
||
[#393]: https://github.com/gotgenes/pi-packages/issues/393
|
||
[#418]: https://github.com/gotgenes/pi-packages/issues/418
|
||
[#529]: https://github.com/gotgenes/pi-packages/issues/529
|
||
[#530]: https://github.com/gotgenes/pi-packages/issues/530
|
||
[#531]: https://github.com/gotgenes/pi-packages/issues/531
|
||
[#476]: https://github.com/gotgenes/pi-packages/issues/476
|
||
[#478]: https://github.com/gotgenes/pi-packages/issues/478
|
||
[#502]: https://github.com/gotgenes/pi-packages/issues/502
|
||
[#509]: https://github.com/gotgenes/pi-packages/issues/509
|
||
[#555]: https://github.com/gotgenes/pi-packages/issues/555
|
||
[#710]: https://github.com/gotgenes/pi-packages/issues/710
|
||
[#713]: https://github.com/gotgenes/pi-packages/issues/713
|
||
[#716]: https://github.com/gotgenes/pi-packages/pull/716
|
||
[#738]: https://github.com/gotgenes/pi-packages/pull/738
|
||
[#398]: https://github.com/gotgenes/pi-packages/issues/398
|
||
[#719]: https://github.com/gotgenes/pi-packages/issues/719
|
||
[#721]: https://github.com/gotgenes/pi-packages/issues/721
|
||
[#722]: https://github.com/gotgenes/pi-packages/issues/722
|
||
[#726]: https://github.com/gotgenes/pi-packages/issues/726
|
||
[#732]: https://github.com/gotgenes/pi-packages/issues/732
|
||
[#735]: https://github.com/gotgenes/pi-packages/issues/735
|
||
[#736]: https://github.com/gotgenes/pi-packages/issues/736
|
||
[#742]: https://github.com/gotgenes/pi-packages/issues/742
|
||
[#744]: https://github.com/gotgenes/pi-packages/issues/744
|
||
[#745]: https://github.com/gotgenes/pi-packages/issues/745
|
||
[#746]: https://github.com/gotgenes/pi-packages/issues/746
|
||
[#645]: https://github.com/gotgenes/pi-packages/issues/645
|
||
[#642]: https://github.com/gotgenes/pi-packages/issues/642
|
||
[#655]: https://github.com/gotgenes/pi-packages/issues/655
|
||
[#658]: https://github.com/gotgenes/pi-packages/issues/658
|
||
[#680]: https://github.com/gotgenes/pi-packages/issues/680
|
||
[#686]: https://github.com/gotgenes/pi-packages/issues/686
|
||
[#687]: https://github.com/gotgenes/pi-packages/issues/687
|
||
[#688]: https://github.com/gotgenes/pi-packages/issues/688
|
||
[#691]: https://github.com/gotgenes/pi-packages/issues/691
|
||
[#698]: https://github.com/gotgenes/pi-packages/issues/698
|
||
[#699]: https://github.com/gotgenes/pi-packages/issues/699
|
||
[#706]: https://github.com/gotgenes/pi-packages/issues/706
|
||
[#720]: https://github.com/gotgenes/pi-packages/issues/720
|
||
[#639]: https://github.com/gotgenes/pi-packages/issues/639
|
||
[#648]: https://github.com/gotgenes/pi-packages/issues/648
|
||
[#654]: https://github.com/gotgenes/pi-packages/issues/654
|
||
[#603]: https://github.com/gotgenes/pi-packages/issues/603
|
||
[#604]: https://github.com/gotgenes/pi-packages/issues/604
|
||
[#609]: https://github.com/gotgenes/pi-packages/issues/609
|
||
[#610]: https://github.com/gotgenes/pi-packages/issues/610
|
||
[#519]: https://github.com/gotgenes/pi-packages/issues/519
|
||
[#752]: https://github.com/gotgenes/pi-packages/issues/752
|
||
[#753]: https://github.com/gotgenes/pi-packages/issues/753
|
||
[ADR-0002]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-subagents/docs/decisions/0002-extensions-on-a-minimal-core.md
|