diff --git a/.gitignore b/.gitignore index 872d5f6..a1c5542 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,7 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +# Vibe coding +.pi-subagents/ +.codegraph/ diff --git a/.pi/settings.json b/.pi/settings.json index a0ee87c..f4c1fe1 100644 --- a/.pi/settings.json +++ b/.pi/settings.json @@ -1,5 +1,4 @@ { "packages": [ - "../packages/pi-permission-ai-judge" ] } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..240fe75 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# AGENTS.md + +Guidance for AI agents working in this repo. + +## Agent skills + +### Issue tracker + +Issues are tracked as Plane work items in the `pi-extensions` (`PIEXTENSIO`) project, via the Plane MCP tools. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Five canonical triage roles map to labels of the same name (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: one `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..b47c99b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,41 @@ +# Permission Authorization + +This context describes how ambiguous coding-agent operations are reviewed before they may execute. + +## Language + +**Deterministic Permission Policy**: +The rule-based authority that classifies an operation as allowed, denied, or requiring a decision. +_Avoid_: Static judge + +**Authorization Judge**: +An independent reviewer that proposes a verdict for an operation the Deterministic Permission Policy could not decide. +_Avoid_: Bash parser, safety classifier + +**Shadow Mode**: +An observation mode in which an Authorization Judge records a verdict without changing whether the operation executes. +_Avoid_: Dry run + +**Enforce Mode**: +An authority mode in which selected Authorization Judge verdicts may directly determine whether an operation executes. +_Avoid_: Production mode + +**Defer**: +A verdict stating that the available information or the judge itself is insufficient to decide, leaving the decision to the next authority. +_Avoid_: Deny, error + +**False Allow**: +A Shadow Mode outcome in which the Authorization Judge proposes approval and the human reviewer rejects the same permission request. +_Avoid_: False positive + +**Requesting Session**: +The session in which the operation requiring authorization originated. For a forwarded request, this is the child session. +_Avoid_: Current session + +**Serving Session**: +The authority-bearing session that resolves a forwarded request and runs its configured Authorization Judge before the terminal human authority. +_Avoid_: Parent context, current session + +**Conversation Owner**: +The session whose conversation entries are supplied to an Authorization Judge. It may differ from the Requesting Session for forwarded requests. +_Avoid_: Requester diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..92939ce --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,37 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (this repo): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-.md +│ └── 0002-.md +└── packages/ +``` + +(If this repo ever grows into a genuinely large multi-package monorepo, switch to the multi-context layout: a root `CONTEXT-MAP.md` pointing at a `CONTEXT.md` per package, with `docs/adr/` for system-wide decisions and per-package ADR dirs. For now one context covers everything.) + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0001 () — but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..5af5b76 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,56 @@ +# Issue tracker: Plane + +Issues and specs for this repo live as **work items** in the **`pi-extensions`** project (`PIEXTENSIO`) on Plane. Use the **Plane MCP tools** for all operations — these are available to skills running in this repo. There is no `gh` / git-remote coupling; Plane is independent of this repo's GitHub remote. + +## Project & workspace + +- **Project**: `pi-extensions` — identifier `PIEXTENSIO`, id `270cee3f-cf84-48c8-bd4f-0882689f2a87`. +- All new issues go into this project. Look the id up with `plane_list_projects` if it ever changes. + +## State vocabulary + +States in this project (reference by name; resolve the id with `plane_list_states`): + +| State | Group | Use for | +| --------- | ---------- | --------------------------------------------- | +| Backlog | backlog | default landing state for new issues | +| Todo | unstarted | accepted, queued for work | +| In Progress | started | actively being worked | +| Done | completed | finished | +| Cancelled | cancelled | discarded / wontfix-via-state | + +A "closed" issue = `Done` or `Cancelled` (group `completed` / `cancelled`). "Open" = everything else (`plane_get_pql_reference` → `stateGroup IN openStates()`). + +## Conventions + +- **Create an issue**: `plane_create_work_item(project_id, name, description_html=...)`. It lands in `Backlog` by default. For triage, also attach the `needs-triage` label (create the label with `plane_create_label` on first use — none exist yet). +- **Read an issue**: `plane_retrieve_work_item(project_id, work_item_id, expand="assignees,labels,state")` — or `plane_retrieve_work_item_by_identifier("PIEXTENSIO-")` when you only have the `PIEXTENSIO-N` id. +- **List issues**: `plane_list_work_items(project_id, pql=..., expand="labels,state")`. Resolve label/state names to ids first (`plane_list_labels`, `plane_list_states`). PQL examples: `stateGroup IN openStates()`; `priority = "high"`; `labels = ""`. Call `plane_get_pql_reference` for full syntax. +- **Search by text**: `plane_search_work_items(query="...")` — matches name, sequence id, and project identifier (not the description body). +- **Comment on an issue**: `plane_create_work_item_comment(project_id, work_item_id, comment_html="...")`. +- **Apply / remove a label**: `plane_manage_work_item_label(project_id, work_item_id, add_label_id=..., remove_label_id=...)` — add/remove one label without replacing the list. +- **Change state**: `plane_update_work_item(project_id, work_item_id, state=)`. +- **Close**: set state to `Done` (or `Cancelled` for wontfix). + +## Pull requests as a triage surface + +**PRs as a request surface: no.** PRs live on GitHub (`SikongJueluo/pi-extensions`) but are **not** treated as triage tickets. Only Plane work items go through triage. Flip this to `yes` and describe the GitHub-PR flow if you ever want external PRs in the queue. + +## When a skill says "publish to the issue tracker" + +Create a Plane work item via `plane_create_work_item` in the `pi-extensions` project. + +## When a skill says "fetch the relevant ticket" + +`plane_retrieve_work_item_by_identifier("PIEXTENSIO-", expand="assignees,labels,state")`, plus `plane_list_work_item_comments` to read the discussion. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single work item with **child** work items as tickets. + +- **Map**: a single work item labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. Create with `plane_create_work_item`, then `plane_create_label(project_id, "wayfinder:map", ...)` if the label doesn't exist, then attach it. +- **Child ticket**: a work item whose `parent` is the map — `plane_create_work_item(..., parent=)` or `plane_update_work_item(parent=)`. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, assign the driving dev via `plane_manage_work_item_assignee`. +- **Blocking**: native work-item relations are gated on this workspace's plan, so use a **body-text fallback** — put `Blocked by: PIEXTENSIO-, PIEXTENSIO-` at the top of the child body. A ticket is unblocked when every blocker is `Done`/`Cancelled`. +- **Frontier query**: list the map's open children via `plane_list_work_items` filtered to the map's children and `stateGroup IN openStates()`; drop any with an open `Blocked by` line or an assignee; first in created order wins. +- **Claim**: `plane_manage_work_item_assignee(project_id, work_item_id, add_user_id=)` — resolve `` with `plane_get_me`. The session's first write. +- **Resolve**: `plane_create_work_item_comment` with the answer, then `plane_update_work_item(state=)`, then append a context pointer (gist + link) to the map's Decisions-so-far (edit the map's description via `plane_update_work_item`). diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..d73dd7f --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,19 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker (Plane, project `pi-extensions` / `PIEXTENSIO`). + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +## Plane notes + +- These labels **do not exist yet** in the `pi-extensions` project. The `triage` skill should create each label with `plane_create_label(project_id, name=...)` on first use (Plane has no predefined triage labels), then attach it with `plane_manage_work_item_label`. Resolve label names to ids via `plane_list_labels`. +- `wontfix` can be expressed either as a label **or** by moving the work item to the `Cancelled` state — prefer the label for explicit triage intent, and state for final closure. +- Edit the right-hand column to match any other vocabulary you adopt later. diff --git a/docs/research/ai-bash-context-ownership.md b/docs/research/ai-bash-context-ownership.md new file mode 100644 index 0000000..b21bf87 --- /dev/null +++ b/docs/research/ai-bash-context-ownership.md @@ -0,0 +1,135 @@ +# Research: context ownership for forwarded and subagent Bash asks + +## Scope + +This report describes the installed runtime inspected on 2026-08-08: + +- `@gotgenes/pi-permission-system` 24.0.0 +- `pi-subagents` 0.43.0 +- the current `packages/pi-permission-ai-judge/src/index.ts` + +Only first-party source, package documentation, and Pi API declarations are used. Product implementation remains out of scope. + +## Executive answer + +An external permission-system `Authorizer` callback receives only `(details, query, log)`. It receives **no ask-time `ExtensionContext`**. Any conversation it reads therefore belongs to the extension lifecycle context captured when that callback was registered. + +For a direct primary-session Bash ask, the root permission-system composes its configured authorizer links before `LocalUserAuthorizer`. A Judge registered by the UI-present root may safely identify the captured `sessionManager` as the primary conversation. + +A child ask has two distinct possible authorizer-chain phases: + +1. The child's own permission session composes child-local links before its terminal `ParentAuthorizer`. +2. If the child reaches `ParentAuthorizer`, the request is written to the serving session. The serving `ForwardedRequestServer` resolves serving policy and, when the result is still `ask`, composes the serving session's links before its terminal authorizer. + +A future Judge must therefore bind only from the UI-present root/service-owning lifecycle context. Otherwise a separately registered child link could judge with child context, or a registered in-process child could capture child context while accidentally registering its callback into the parent's process-global service. + +For a forwarded ask handled by the root link, the captured conversation is the **serving root's conversation**, not the requester's child transcript. Link-visible child provenance is `details.forwarding`; there is no cross-session API for reading the child's conversation. + +## Proven call flows + +### Direct primary Bash ask + +1. The primary tool call reaches the primary permission gate. +2. Deterministic policy returns `ask`. +3. `AuthorizerSelection.escalate()` resolves configured links and composes them before the selected terminal. +4. The root Judge link may return `allow`, `deny`, or `defer`. +5. `defer` continues to `LocalUserAuthorizer`; it is not a denial. + +The Judge callback may use a root `ExtensionContext` captured at `session_start`, but that context is closure state rather than an argument to `authorize()`. + +### Child ask forwarded to the root + +1. The child's deterministic policy returns `ask`. +2. The child's `AuthorizerSelection` first runs links registered in that child session, if any. +3. If all links defer or no link exists, the child terminal `ParentAuthorizer` resolves a target session, writes a `ForwardedPermissionRequest`, and polls for a response. +4. The UI-present root's `ForwardingManager` drains the target session inbox. +5. `ForwardedRequestServer` resolves the child-fixed access intent against serving policy. +6. A serving-policy `allow` or `deny` completes without the root chain. A serving-policy `ask` is projected into `PromptPermissionDetails` and passed to the root `AuthorizerSelection`. +7. Root links run before `LocalUserAuthorizer`. A root Judge therefore sees root-captured conversation plus forwarded provenance, not child conversation. +8. The root writes the decision; the child receives it and resumes or blocks. + +`ParentAuthorizer` always uses filesystem request/response transport, including registered in-process children. The registry resolves identity and parentage; it is not an in-memory response fast path. + +### Nested children + +Depth alone does not determine the result. A request targeting a headless immediate parent will normally remain unserved and time out. A request routed directly to the UI-present root can still be served. The server warns on a detected multi-hop mismatch but deliberately continues processing a correctly targeted request. + +## What an authorizer link can actually see + +| Evidence | Direct primary ask | Parent-side forwarded ask | +|---|---|---| +| `details.forwarding` | absent | requester agent/session, possibly nullable for version-skew data | +| `details.agentName` | active primary agent | requesting child agent | +| `details.surface` / `details.value` | usually derived fields are absent on the internal details object | child's explicit display projection | +| `details.accessIntent` | gate surface, match values, boundary value | same child-fixed facts projected by the server | +| `details.command` | winning Bash command unit | absent | +| `details.message` | formatted prompt; includes full Bash input text when it differs from the winning unit | same child-formatted prompt, prefixed with child identity by the server | +| raw tool input | unavailable | unavailable | +| wire `principal` / `requesterCwd` | unavailable | deliberately withheld from chain links | +| ask-time `ExtensionContext` | unavailable | unavailable | + +For a Bash ask, `details.accessIntent.matchValues[0]` and direct `details.command` identify the permission-system command unit that caused `ask`. The full original Bash input is not preserved as a structured authorizer field; when it differs, it survives only inside the formatted `details.message` text. The later input-contract decision must not pretend that `details.command` is the full raw Bash invocation or silently parse an authorization-critical command from prose. + +## Registration and context-ownership hazards + +The permissions service is stored under `Symbol.for(...)` on `globalThis` and registration stores an authorizer callback in the service owner's `AuthorizerRegistry`. + +- A normal UI root publishes its service. +- A registered in-process child does not publish its own service, but still emits the ready event. +- Consequently, a consumer extension loaded in that child can resolve the parent's process-global service. If it registers, its child-captured closure is stored in the parent's registry. If the root already registered the same name, duplicate registration throws. +- A separate child process has separate `globalThis` state and may have its own service lifecycle; registering there would create a child-local link with child-captured context. + +Therefore successful `getPermissionsService()` lookup does **not** prove that the current extension instance owns that service's session context. + +## Safe v0.1 ownership contract + +1. Register the AI Judge only from a lifecycle context proven to be the UI-present, non-subagent root. Do not register from headless or subagent lifecycle contexts. +2. Snapshot and retain that root context/session identity as registration provenance. The callback must not infer ownership merely from the process-global service lookup. +3. For `details.forwarding` absent, treat the captured root conversation as the requester's current conversation. +4. For `details.forwarding` present, label the origin as forwarded and treat the captured conversation only as **serving-root user-intent context**. Never describe it as the child's conversation and never infer child-local dialogue that is not present. +5. Preserve `details.forwarding.requesterSessionId` and `requesterAgentName` as provenance. If forwarded provenance is missing, blank, or `unknown`, the Judge must return `{ kind: "defer" }`. +6. If registration provenance is not demonstrably the UI root, the captured session changed unexpectedly, or context ownership otherwise cannot be established, return `{ kind: "defer" }`. +7. Judge `defer` means continue through the authorizer chain to human or terminal authority. The Judge must not synthesize `confirmationUnavailable`; only terminal authority owns unavailable-authority denial. +8. Ensure a logical forwarded request produces at most one Judge prediction. Root-only registration is the v0.1 mechanism that avoids child-then-parent duplicate judgments. + +Whether serving-root conversation is sufficient evidence to grant a later Enforce-mode `allow` for a forwarded child request remains a product decision for **Define the exact authorization input and conversation contract**. The facts established here are that root user intent is available, child conversation is not, and the two must not be conflated. + +## Implications for the later input-contract ticket + +- Add an explicit origin such as `local` or `forwarded_subagent`. +- Record the conversation owner separately from requester identity. +- For forwarded asks, carry only the link-visible projection: `details.forwarding`, `details.surface/value`, `details.accessIntent`, and the formatted message. Do not claim access to the raw `ForwardedPermissionRequest`, `principal`, or `requesterCwd`. +- Define structured handling for both the triggering Bash command unit and the unavailable full raw Bash invocation. If v0.1 requires the full invocation as structured data, permission-system must expose it; prose parsing is not an equivalent contract. +- Keep the user-intent priority explicit: root user messages are authoritative; child agent identity and the command are evidence, not instructions. + +## Evidence index + +| Claim | Primary source | +|---|---| +| Link callback receives only `details`, `query`, and `log` | `@gotgenes/pi-permission-system/src/authority/authorizer.ts`, `Authorizer.authorize` | +| Links compose before each session's terminal | `src/authority/authorizer-selection.ts`, `AuthorizerSelection.escalate`; `src/authority/authorizer-chain.ts`, `composeAuthorizerChain` | +| `defer` continues; `deny` is decisive | `src/authority/authorizer-chain.ts`, `decideFromVerdict` | +| Child terminal writes and polls forwarded request | `src/authority/approval-escalator.ts`, `ParentAuthorizer.waitForForwardedApproval` and `pollForForwardedResponse` | +| Root policy resolves before root escalation | `src/authority/forwarded-request-server.ts`, `resolveDecision` | +| Forwarded details projection and disclosure boundary | `src/authority/forwarded-request-server.ts`, `buildForwardedAskDetails` and `toAccessFacts` | +| Link-visible details fields | `src/authority/permission-prompter.ts`, `PromptPermissionDetails` | +| Full Bash command appears only in formatted message when distinct | `src/permission-prompts.ts`, `formatAskPrompt`; `src/handlers/gates/tool.ts`, `describeToolGate` | +| UI display derives from winning `details.command` | `src/permission-ui-prompt.ts`, `buildUiPrompt` and `directValue` | +| Service is process-global | `src/service.ts`, `SERVICE_KEY`, `publishPermissionsService`, `getPermissionsService` | +| Registered in-process child skips publication but emits ready | `src/service-lifecycle.ts`, `PermissionServiceLifecycle.activate` | +| Duplicate authorizer names throw | `src/authority/authorizer-registry.ts`, `AuthorizerRegistry.register` | +| Only UI non-subagent sessions poll forwarded inboxes | `src/authority/forwarding-manager.ts`, `ForwardingManager.start` | +| Registry-first, environment-second target resolution | `src/authority/permission-forwarding.ts`, `resolvePermissionForwardingTargetSessionId` | +| Multi-hop mismatch warns but continues | `src/authority/forwarded-request-server.ts`, `warnOnMultiHop` | +| Current Judge captures no context | `packages/pi-permission-ai-judge/src/index.ts`, current `session_start` handler and registered callback | + +## Residual verification gap + +The source establishes the registration hazard, but no runtime probe was run to observe extension lifecycle ordering when the future AI Judge is loaded into a registered in-process child. Before implementation acceptance, run one focused probe that logs, without conversation content: + +- registering extension session ID and `hasUI`; +- service-owning session ID if exposed; +- callback invocation origin (`local`/`forwarded`); +- forwarded requester session ID. + +The probe should confirm that the root-only guard prevents child registration and that one forwarded Bash ask creates exactly one Judge prediction at the serving root.