mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Architecture
|
||||
|
||||
This directory documents the permission system's internal architecture, informed by [OpenCode's permission model](https://opencode.ai/docs/permissions/).
|
||||
|
||||
## Documents
|
||||
|
||||
| Document | Purpose |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
|
||||
| [v3-architecture.md](./v3-architecture.md) | Architecture as of v3.4–3.9 (pre-flat-config, pre-unified-evaluate). Retained as historical reference. |
|
||||
| [architecture.md](./architecture.md) | Current architecture: unified rule model, evaluated ruleset, and session approval generalization |
|
||||
| [history/](./history/) | Per-phase records of the completed improvement phases (findings, plans, dependency graphs, metrics). |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
# Phase 1: Preview formatter extension seam
|
||||
|
||||
Goal: make issue [#266] (configurable preview limits + future formatter extension seam) easy to implement.
|
||||
|
||||
Today, `tool-input-preview.ts` uses module-level constants (`TOOL_INPUT_PREVIEW_MAX_LENGTH = 200`, `TOOL_TEXT_SUMMARY_MAX_LENGTH = 80`) and there is no path for extension config to reach the formatting layer.
|
||||
The call chain from handler → gate descriptor → prompt → formatting spans 5 pure-function layers with no config parameter.
|
||||
The config normalizer only handles booleans and arrays - no pattern exists for optional numeric fields.
|
||||
|
||||
## Current health metrics
|
||||
|
||||
| Metric | Value |
|
||||
| -------------------- | -------------------- |
|
||||
| Health score | 74 B |
|
||||
| LOC | 30,893 |
|
||||
| Dead files / exports | 0% |
|
||||
| Avg cyclomatic | 1.4 |
|
||||
| p90 cyclomatic | 2 |
|
||||
| Maintainability | 91.2 (good) |
|
||||
| Duplication | 9.3% (2,853 lines) |
|
||||
| Churn hotspots | 41 files |
|
||||
| Refactoring targets | 5 (4 medium, 1 high) |
|
||||
|
||||
## Findings
|
||||
|
||||
Filtered to what blocks or complicates [#266]:
|
||||
|
||||
| # | Finding | Category | Files | Impact | Risk | Priority |
|
||||
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------- | ------ | ---- | -------- |
|
||||
| 1 | `tool-input-preview.ts` is a flat bag of 15 exports (3 constants + 12 functions) mixing prompt formatting, log formatting, and text utilities - no cohesive object to receive config | B: oversized / C: coupling | `tool-input-preview.ts` | 5 | 2 | 20 |
|
||||
| 2 | No config path to gate descriptors - `describeToolGate(tcc, check)` and `formatAskPrompt(result, agent, input)` are pure functions with no config parameter; adding one requires threading through 5 layers | C: parameter relay | `permission-gate-handler.ts`, `tool.ts`, `permission-prompts.ts`, `tool-input-preview.ts` | 4 | 2 | 16 |
|
||||
| 3 | Config normalizer (`normalizePermissionSystemConfig`) has no pattern for optional numeric fields with defaults and bounds checking - only booleans and arrays | C: coupling (missing abstraction) | `extension-config.ts` | 3 | 1 | 15 |
|
||||
| 4 | `formatToolInputForPrompt` switch statement is the natural home for the future formatter extension seam but is buried in a utility module with no object to hang a `register()` method on | C: coupling (missing collaborator) | `tool-input-preview.ts` | 4 | 2 | 16 |
|
||||
| 5 | `permission-prompts.ts` test mocks `tool-input-preview` at module level - extracting a formatter object would let the test inject it directly, removing the `vi.mock()` | D: testability | `permission-prompts.test.ts` | 2 | 1 | 10 |
|
||||
|
||||
## Steps
|
||||
|
||||
1. ✅ **Extract `ToolPreviewFormatter` class from `tool-input-preview.ts`** ([#282])
|
||||
- Created `tool-preview-formatter.ts` with `ToolPreviewFormatter` class accepting `ToolPreviewFormatterOptions` in its constructor
|
||||
- Moved 7 config-dependent methods onto the class: `formatToolInputForPrompt`, `formatJsonInputForPrompt`, `formatSearchInputForPrompt`, `sanitizeInlineText`, `formatGenericToolInputForLog`, `getToolInputPreviewForLog`, `getPermissionLogContext`
|
||||
- `tool-input-preview.ts` retains 8 pure utilities + 3 default constants
|
||||
- Outcome: formatter is a single injectable object; [#266] passes config by constructing the formatter with user-configured limits
|
||||
|
||||
2. ✅ **Thread `ToolPreviewFormatter` through the gate descriptor chain** ([#282])
|
||||
- `describeToolGate(tcc, check, formatter)` - accepts the formatter as third parameter
|
||||
- `formatAskPrompt(result, agentName, input, formatter?)` - accepts an optional formatter
|
||||
- `PermissionGateHandler.handleToolCall` constructs the formatter with default constant values and passes it to the tool gate producer
|
||||
- `permission-prompts.test.ts` `vi.mock` removed - formatter is injected directly
|
||||
- Outcome: config reaches formatting with one parameter instead of threading through 5 layers
|
||||
|
||||
3. ✅ **Add numeric config normalization to `extension-config.ts`** ([#266])
|
||||
- Added `normalizeOptionalPositiveInt` helper (exported; validates positive integer)
|
||||
- Added `toolInputPreviewMaxLength` and `toolTextSummaryMaxLength` as optional fields to `PermissionSystemExtensionConfig`
|
||||
- Updated `normalizePermissionSystemConfig` to parse both fields (omit when invalid/absent)
|
||||
- Updated `permissions.schema.json` (`type: "integer"`, `minimum: 1`) and `config.example.json`
|
||||
- Outcome: config system handles numeric fields; fallback to 200/80 constants when fields are absent
|
||||
- Commit: `feat: add toolInputPreviewMaxLength and toolTextSummaryMaxLength config fields (#266)`
|
||||
|
||||
4. ✅ **Wire config to `ToolPreviewFormatter` construction** ([#266])
|
||||
- Added `resolveToolPreviewLimits(config)` to `tool-preview-formatter.ts` (narrow `Pick` param; applies `??` fallbacks to the three formatter options)
|
||||
- `PermissionGateHandler.handleToolCall` now constructs the formatter with `resolveToolPreviewLimits(this.session.config)`
|
||||
- `session.config` is read fresh on every tool call - config reloads take effect automatically
|
||||
- Outcome: user-configured limits take effect at runtime; [#266] is complete
|
||||
- Commit: `feat: use configured preview limits in permission prompts (#266)`
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["Step 1: Extract ToolPreviewFormatter"]
|
||||
S2["Step 2: Thread formatter through gates"]
|
||||
S3["Step 3: Add numeric config fields"]
|
||||
S4["Step 4: Wire config to formatter"]
|
||||
|
||||
S1 --> S2
|
||||
S1 --> S4
|
||||
S3 --> S4
|
||||
S2 --> S4
|
||||
```
|
||||
|
||||
## Tracks
|
||||
|
||||
| Track | Steps | Description |
|
||||
| -------------------- | ----- | ---------------------------------------------------------- |
|
||||
| Formatter extraction | 1 → 2 | Extract the collaborator, thread it through the call chain |
|
||||
| Config schema | 3 | Add numeric fields to config (independent of extraction) |
|
||||
| Integration | 4 | Wire config to formatter (depends on both tracks) |
|
||||
|
||||
[#266]: https://github.com/gotgenes/pi-packages/issues/266
|
||||
[#282]: https://github.com/gotgenes/pi-packages/issues/282
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
# Phase 10: Decide-once dispatch and bash-surface hardening
|
||||
|
||||
Phase 10 cleared the two repeated-discriminator families filed as planning input against the [target architecture](../architecture.md#the-authority-model) — tool-kind dispatch and the win32 path flavor — plus scheduled bash-surface work (advisory decomposition parity, indirection-wrapper flooring) and a documentation recipe.
|
||||
|
||||
## Findings (planned 2026-07-10)
|
||||
|
||||
Phase 9 completed the declared [authority model](../architecture.md#the-authority-model) target, so Phase 10 planning started from the doc's one remaining first-principles gap: the cross-session access intent ([remaining design work](../architecture.md#remaining-design-work) — principal identity and path portability across cwds).
|
||||
Deep-tracing corroborated that gap as live code, not theory: `ForwardedPermissionRequest` is a stringly `(surface?, value?)` payload, the serving node's `ServingPolicy` normalizes a child's forwarded value against the **parent's** `PathNormalizer`/cwd, and pi-subagents' `WorkspaceProvider` seam makes cross-cwd children real.
|
||||
The owner chose to defer that spine to a later phase (it remains the leading Phase 11 candidate) and focus Phase 10 on the two repeated-discriminator families filed as planning input ([#561], [#562]) plus scheduled bash-surface work ([#309], [#490]) and a docs recipe ([#521]).
|
||||
|
||||
Both discriminator families are cause-level Category C coupling flaws traced to the decide-once principle (OCP), not fallow findings — fallow is structurally blind to scattered one-line comparisons; the repeated-discriminator grep sweep found them.
|
||||
[#562] additionally carries the must-agree security property: a leaf that misses the win32 case/separator fold is a silent permission bypass (the [#382]/[#508] class).
|
||||
|
||||
### Health metrics
|
||||
|
||||
| Metric | Baseline (2026-07-10) | Phase 10 target |
|
||||
| -------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Tool-kind discriminator sites (`src/`) | 21 | ≤ 4, all in `access-intent/tool-kind.ts` (met: 2, Step 2) |
|
||||
| `platform === "win32"` sites (`src/`) | 13 | 1 (the `PathFlavor` construction) (met: 1, Step 3) |
|
||||
| win32 match-fold derivations (`caseInsensitive` occurrences, `src/`) | 6 | ≤ 2 (met: 1 derivation — the sole `PathFlavor` literal; the grep reports 4, the other 3 being the intrinsic `WildcardMatchOptions` definition in `wildcard-matcher.ts`, Step 3) |
|
||||
| Advisory bash fidelity | whole-string match | decomposed parity with the gate (test-gated) |
|
||||
| Indirection-wrapper coverage | inline-shell wrappers only ([#481]) | all listed wrappers floored to `ask` (`<indirection-bash-wrapper>`) |
|
||||
| Flat `src/` root modules | 62 | ≤ 59 (`src/path/` seeded) (met: 59, Step 3) |
|
||||
| fallow health score | 88 (A) | ≥ 88 |
|
||||
| Production duplication | 0.2% | ≤ 0.2% |
|
||||
|
||||
Recompute commands (run from the repo root):
|
||||
|
||||
- Tool-kind sites: `grep -rE 'toolName === "(bash|mcp)"|source === "mcp"' packages/pi-permission-system/src --include="*.ts" | wc -l`
|
||||
- win32 sites: `grep -r 'platform === "win32"' packages/pi-permission-system/src --include="*.ts" | wc -l`
|
||||
- Match-fold derivations: `grep -r 'caseInsensitive' packages/pi-permission-system/src --include="*.ts" | wc -l`
|
||||
- Root modules: `ls packages/pi-permission-system/src | grep -c '\.ts$'`
|
||||
- Health/duplication: `pnpm fallow health --score --workspace @gotgenes/pi-permission-system` / `pnpm fallow dupes --workspace @gotgenes/pi-permission-system`
|
||||
|
||||
### Open-issue sweep dispositions
|
||||
|
||||
- [#23] — closed as resolved-by-events (2026-07-10): the "no real-world consumers" premise is stale — `@gotgenes/pi-subagents` emits `<active_agent name="…"/>` in child system prompts, so the per-agent override system is live.
|
||||
- [#561] — superseded by Steps 1–2 below; closed at issue-filing time with a pointer to the step issues.
|
||||
- [#562], [#309], [#490], [#521] — scheduled as Steps 3–6 below.
|
||||
- [#564] — mislabeled for this package: its targets are `packages/pi-github-tools/src/lib/ci.ts`; the `pkg:pi-permission-system` label was removed.
|
||||
- [#519] — explicit deferral (recorded, not a silent sweep): blocked on Pi SDK UIContext surface evolution; revisit when the SDK exposes a custom-UI approval channel.
|
||||
- [#472] — remains deferred by name; the Phase 9 spine is its extension point, and it warrants its own decision record before scheduling.
|
||||
- [#565] — stays open as the non-gating post-ship observation of [#557].
|
||||
|
||||
## Steps
|
||||
|
||||
### ✅ Step 1: Introduce a tool-kind classification decided once at the normalize boundary ([#568])
|
||||
|
||||
**Cause:** the extraction question — "what does this invocation access: a bash command, an MCP target, or a path?"
|
||||
— is a domain decision re-derived by silent string comparison at every consumer instead of decided once where the invocation enters the system ([#561]).
|
||||
The 21 grep sites are the symptom; the cause is the missing dispatch point.
|
||||
|
||||
- **Smell:** Category C (repeated discriminator / OCP).
|
||||
- **Target:** new `src/access-intent/tool-kind.ts` (the classification and its per-kind extraction product); migrate the extraction consumers: `input-normalizer.ts`, `tool-input-path.ts`, `handlers/gates/tool.ts`, `handlers/gates/tool-call-gate-pipeline.ts`, `permission-manager.ts`.
|
||||
Constraint: `permission-manager.ts` stays string-based per `docs/decisions/0002-path-values-string-boundary.md`; the classification value is plain data, safe to consume there.
|
||||
- **Outcome:** extraction-family `toolName === "bash"`/`"mcp"` sites drop to 0 outside `access-intent/tool-kind.ts`; total family sites 21 → ≤ 12 (presentation family remains until Step 2).
|
||||
- **Landed:** `access-intent/tool-kind.ts` (`ToolKind` + `classifyToolKind`) added; the five extraction consumers migrated onto it; total family 21 → 12 (all remaining sites are the presentation family Step 2 clears, plus one docstring inside `tool-kind.ts`). `getToolPermission`'s dead per-kind branches collapsed to a single `evaluate(…, "*", …)` in the same pass.
|
||||
- **Impact 4 / Risk 2 / Priority 16.**
|
||||
|
||||
Release: batch "tool-kind-dispatch"
|
||||
|
||||
### ✅ Step 2: Move the presentation family onto the tool-kind product ([#569])
|
||||
|
||||
**Cause:** the same discriminator on the presentation side — prompt, preview, denial-message, and decision-value projections each re-decide the kind per formatter, including a private `isMcpCheck()` helper that two sibling call sites re-derive instead of sharing.
|
||||
|
||||
- **Smell:** Category C (repeated discriminator / OCP).
|
||||
- **Target:** `tool-preview-formatter.ts`, `permission-prompts.ts`, `denial-messages.ts` (delete `isMcpCheck`), `handlers/gates/helpers.ts` (`deriveDecisionValue`).
|
||||
- **Outcome:** total family sites 21 → ≤ 4, all inside `access-intent/tool-kind.ts` (the recompute command above hits the target).
|
||||
- **Landed:** `isMcpCheck` promoted from a private `denial-messages.ts` helper to a shared export in `access-intent/tool-kind.ts` (keeps the `source === "mcp"` disjunct that `classifyToolKind` cannot express); the four presentation consumers (`denial-messages`, `permission-prompts`, `tool-preview-formatter`, `deriveDecisionValue`) migrated onto `classifyToolKind`/`isMcpCheck`, with the `&& target` guard hoisted to the call sites that display it.
|
||||
Recompute 12 → 2 (both inside `tool-kind.ts`: the docstring and the `isMcpCheck` disjunct); `deriveDecisionValue` became an exhaustive `switch`; suite +4 (`isMcpCheck` unit tests).
|
||||
- **Impact 3 / Risk 1 / Priority 15.**
|
||||
|
||||
Release: batch "tool-kind-dispatch"
|
||||
|
||||
### ✅ Step 3: `PathFlavor` — pass the resolved win32 capability, not the raw platform ([#562])
|
||||
|
||||
**Cause:** the win32 path-interpretation mapping (path impl, case fold, separator fold, match options) is re-derived from a raw `platform: NodeJS.Platform` parameter at 13 sites; connascence of algorithm where one divergent leaf is a silent permission bypass (the [#382]/[#508] class).
|
||||
The [#505]/[#510] seam fixed *where the platform is read* but threaded the raw discriminator instead of the resolved product.
|
||||
|
||||
- **Smell:** Category C (thread decisions, not discriminators).
|
||||
- **Target:** new `src/path/path-flavor.ts`; relocate the co-rewritten leaves `path-containment.ts`, `canonicalize-path.ts`, `pi-infrastructure-read.ts` into `src/path/` (tidy-first: touched files reach their final home); edit `path-normalizer.ts`, `access-intent/path-normalization.ts`, `authority/subagent-context.ts`, `rule.ts` (`pathMatchOptions`), `permission-manager.ts`, and `index.ts` (single `PathFlavor` construction from the one `process.platform` read).
|
||||
- **Outcome:** `platform === "win32"` sites 13 → 1; `caseInsensitive` derivations 6 → ≤ 2; flat `src/` root 62 → 59.
|
||||
- **Landed:** `src/path/path-flavor.ts` added — `PathFlavor` is a behavioral collaborator (the platform's path *language*: `impl`, `matchOptions`, `fold`, `comparable`, `isWithin`, `hasPathSeparator`, `bashTokenShape`) resolved by `pathFlavorForPlatform` into one of two cached singletons holding the package's only `=== "win32"` comparison.
|
||||
`path-containment.ts` / `canonicalize-path.ts` / `pi-infrastructure-read.ts` relocated into `src/path/` (flat `src/` root 62 → 59); the standalone `isPathWithinDirectory` was dissolved onto `PathFlavor.isWithin` and `PathNormalizer.usesWindowsSeparators()` onto `PathFlavor.hasPathSeparator`.
|
||||
The flavor is injected once from `index.ts` into `PermissionManager` / `PermissionSession` (→ `PathNormalizer`) / `SubagentDetection`; `PathNormalizer` dropped both `!== "win32"` bash-token guards via `bashTokenShape` and now holds no platform discriminator.
|
||||
`platform === "win32"` code comparisons 13 → 1; the win32 match-options literal derivation 2 → 1; suite +16 (`path-flavor.test.ts`), net −5 across removed `usesWindowsSeparators`/duplicate classifier tests.
|
||||
Follow-up [#571] filed for the deferred `subagent-context` containment unification.
|
||||
- **Impact 4 / Risk 2 / Priority 16.**
|
||||
|
||||
Release: independent
|
||||
|
||||
### ✅ Step 4: Advisory bash decomposition parity ([#309])
|
||||
|
||||
**Cause:** two answers to one question — the advisory `PermissionsService.checkPermission("bash", …)` matches the whole string while the gate decomposes, because the sync-by-contract service cannot await tree-sitter init; decision fidelity depends on which door you ask at.
|
||||
Feasibility probe passed: `TSParser.parse` is synchronous once initialized (`BashProgram.parse` is async only for `await getParser()`), and the async `before_agent_start` hook precedes any tool call, so a warm-then-sync path exists.
|
||||
|
||||
- **Smell:** Category C (dual fidelity at a public boundary).
|
||||
- **Target:** `access-intent/bash/parser.ts` (warm-up + sync accessor), `handlers/before-agent-start.ts` (warm-up trigger), `permissions-service.ts` / `input-normalizer.ts` (route bash advisory queries through `BashProgram.commands()` + `resolveBashCommandCheck`); the cold-start fallback stays whole-string.
|
||||
- **Outcome:** an advisory chained-command query returns the gate's decomposed decision (test-gated); the public-semantics strengthening is noted in the release notes.
|
||||
- **Impact 2 / Risk 3 / Priority 6** — scheduled by owner decision (2026-07-10) despite the low score; no external consumer exercises bash advisory queries yet.
|
||||
|
||||
Release: independent
|
||||
|
||||
### ✅ Step 5: Floor indirection wrappers ([#490])
|
||||
|
||||
**Cause:** enforcement authority is evadable through a wrapper — `sudo <cmd>` / `env VAR=x <cmd>` make the gated inner command an argument, so the rule that should decide never matches (least-privilege flaw, the [#481] class).
|
||||
Direction confirmed 2026-07-12, superseding the earlier 2026-07-10 re-target proposal: floor **all** listed wrappers to `ask` like the opaque wrappers, rather than re-targeting prefix wrappers at the inner command — re-targeting would need a per-wrapper option-arity table whose errors silently under-match (a bypass), while the uniform floor needs none.
|
||||
|
||||
- **Smell:** Category C (boundary flaw in bash command enumeration).
|
||||
- **Target:** `access-intent/bash/command-enumeration.ts` (the `wrapperKind` discriminant + `INDIRECTION_WRAPPER_NAMES` / `EXEC_CONDITIONAL_WRAPPERS` tables), `handlers/gates/bash-command.ts` (the `WRAPPER_SENTINEL` map), `docs/configuration.md` / `README.md` / this file / the package skill (+ tests).
|
||||
- **Outcome:** `sudo`/`env`/`xargs`/`time`/`nohup`/`timeout`/`nice` and `find`/`fd` (with an exec flag) cannot ride a permissive allow — their `allow` is floored to `ask` with the `<indirection-bash-wrapper>` sentinel (test-gated); a bare `find`/`fd` search is unaffected.
|
||||
- **Landed:** #481's `BashCommand.opaque?: boolean` generalized to a `wrapperKind?: "opaque-payload" | "indirection"` discriminant (byte-identical `<opaque-bash-wrapper>` sentinel preserved); `isOpaqueWrapperCommand` → `classifyWrapperCommand` gains `INDIRECTION_WRAPPER_NAMES` (always-invoke) and `EXEC_CONDITIONAL_WRAPPERS` (`find`/`fd` exec-flag-gated); the floor maps the kind to its sentinel via `WRAPPER_SENTINEL`.
|
||||
Follow-up [#575] filed to survey other exec-capable CLI rewrites.
|
||||
- **Impact 4 / Risk 3 / Priority 12.**
|
||||
|
||||
Release: independent
|
||||
|
||||
### ✅ Step 6: Read-only bash allowlist recipe ([#521])
|
||||
|
||||
**Cause:** none (documentation) — answers a standing user question with a config pattern instead of new runtime mechanism, per the package's mechanism-is-forever preference.
|
||||
|
||||
- **Smell:** n/a (documentation).
|
||||
- **Target:** `docs/configuration.md` (a "read-only command allowlist" recipe enumerating read-only commands as bash allow rules); close [#521] on ship.
|
||||
- **Outcome:** documented recipe; issue closed.
|
||||
- **Landed:** added the "Read-Only Bash Command Allowlist" recipe to `docs/configuration.md`'s Common Recipes — a conservative curated allowlist (file inspection, listing/metadata, search, comparison/hashing, system info, and enumerated `git` read subcommands) paired with `write`/`edit` deny and a `path` deny block.
|
||||
The prose documents the four safety nets that keep it safe (redirect gating on the `path` surface, the `find`/`fd` exec-flag floor, chain most-restrictive, and the wrapper floors), directly answering [#521]'s `find *` + `-exec` + chains question; `echo`/`printf`/`tee`/`sort`/`sed`/`awk` are omitted with a stated rationale.
|
||||
- **Impact 2 / Risk 1 / Priority 10.**
|
||||
|
||||
Release: independent
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["✅ Step 1 - Tool-kind classification decided once (#568)"] --> S2["✅ Step 2 - Presentation family onto the tool-kind product (#569)"]
|
||||
S1 -.->|"soft ordering — shared input-normalizer.ts churn"| S4["✅ Step 4 - Advisory bash decomposition parity (#309)"]
|
||||
S3["✅ Step 3 - PathFlavor + src/path/ domain (#562)"]
|
||||
S5["✅ Step 5 - Indirection-wrapper floor (#490)"]
|
||||
S6["✅ Step 6 - Read-only allowlist recipe (#521)"]
|
||||
```
|
||||
|
||||
## Parallel tracks
|
||||
|
||||
- **Track A — tool-kind dispatch:** Steps 1 → 2.
|
||||
- **Track B — win32 flavor:** Step 3.
|
||||
- **Track C — bash surface:** Steps 4 and 5 (independent of each other; Step 4 prefers landing after Step 1 to avoid `input-normalizer.ts` churn, a soft ordering only).
|
||||
- **Track D — docs:** Step 6.
|
||||
|
||||
## Release batches
|
||||
|
||||
- **Batch "tool-kind-dispatch":** Steps 1, 2 (ship together; tail = Step 2).
|
||||
- Independently releasable: Steps 3, 4, 5, 6.
|
||||
|
||||
Release-type note: Steps 1–3 are `refactor:` (hidden changelog type — they batch into the next release rather than cutting one); Step 4 and Step 5 are behavior changes (`feat:`/`fix:`) that cut releases; Step 6 is an unhidden `docs:` change.
|
||||
|
||||
## Completion
|
||||
|
||||
All 6 steps are closed: [#568], [#569], [#562], [#309], [#490], [#521].
|
||||
Follow-on issues filed during the phase: [#571] (unify `subagent-context` containment onto `PathFlavor.isWithin`) and [#575] (survey other exec-capable CLI rewrites for indirection-wrapper flooring); both remain open and non-gating.
|
||||
Open issues swept and confirmed out of scope during planning: [#561] (superseded by Steps 1–2), [#564] (mislabeled for this package), [#519] (deferred — SDK `UIContext` evolution), [#472] (deferred — `ModelTriageAuthorizer`), [#565] (stays open — non-gating Phase 9 post-ship observation), [#23] (closed as resolved-by-events).
|
||||
|
||||
### Delivered vs. predicted metrics
|
||||
|
||||
Recomputed at archive time (`pnpm fallow health --score --workspace @gotgenes/pi-permission-system` / `pnpm fallow dupes --workspace @gotgenes/pi-permission-system`):
|
||||
|
||||
| Metric | Phase 10 target | Delivered |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Tool-kind discriminator sites (`src/`) | ≤ 4, all in `access-intent/tool-kind.ts` | 2 — met |
|
||||
| `platform === "win32"` sites (`src/`) | 1 (the `PathFlavor` construction) | 1 — met |
|
||||
| win32 match-fold derivations (`caseInsensitive` occurrences, `src/`) | ≤ 2 | 4 total occurrences (1 `PathFlavor` derivation + 3 intrinsic to `WildcardMatchOptions`) — met (target scoped to the derivation, not the raw grep count) |
|
||||
| Advisory bash fidelity | decomposed parity with the gate (test-gated) | delivered, test-gated — met |
|
||||
| Indirection-wrapper coverage | all listed wrappers floored to `ask` | delivered — met |
|
||||
| Flat `src/` root modules | ≤ 59 (`src/path/` seeded) | 59 at Step 3 landing; 60 at phase close (`bash-advisory-check.ts` added by Step 4) — met at Step 3, expected drift after |
|
||||
| fallow health score | ≥ 88 | 88 (A) — met |
|
||||
| Production duplication | ≤ 0.2% | 0.2% (58 lines, 2 clone groups) — met |
|
||||
|
||||
[#23]: https://github.com/gotgenes/pi-packages/issues/23
|
||||
[#309]: https://github.com/gotgenes/pi-packages/issues/309
|
||||
[#382]: https://github.com/gotgenes/pi-packages/issues/382
|
||||
[#472]: https://github.com/gotgenes/pi-packages/issues/472
|
||||
[#481]: https://github.com/gotgenes/pi-packages/issues/481
|
||||
[#490]: https://github.com/gotgenes/pi-packages/issues/490
|
||||
[#505]: https://github.com/gotgenes/pi-packages/issues/505
|
||||
[#508]: https://github.com/gotgenes/pi-packages/issues/508
|
||||
[#510]: https://github.com/gotgenes/pi-packages/issues/510
|
||||
[#519]: https://github.com/gotgenes/pi-packages/issues/519
|
||||
[#521]: https://github.com/gotgenes/pi-packages/issues/521
|
||||
[#557]: https://github.com/gotgenes/pi-packages/issues/557
|
||||
[#561]: https://github.com/gotgenes/pi-packages/issues/561
|
||||
[#562]: https://github.com/gotgenes/pi-packages/issues/562
|
||||
[#564]: https://github.com/gotgenes/pi-packages/issues/564
|
||||
[#565]: https://github.com/gotgenes/pi-packages/issues/565
|
||||
[#568]: https://github.com/gotgenes/pi-packages/issues/568
|
||||
[#569]: https://github.com/gotgenes/pi-packages/issues/569
|
||||
[#571]: https://github.com/gotgenes/pi-packages/issues/571
|
||||
[#575]: https://github.com/gotgenes/pi-packages/issues/575
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
# Phase 11: Shell-tool aliasing and elicitation UX
|
||||
|
||||
## Findings (planned 2026-07-12)
|
||||
|
||||
Phase 10 closed with the cross-session access-intent spine (principal identity on forwarded asks, path portability across cwds) named the leading Phase 11 candidate.
|
||||
That direction is deferred to Phase 12 by decision, not silence: the Phase 9 serving machinery has just shipped, [#565] is the designated post-ship observation of exactly the behaviors that spine would rebuild, and two fresh user-reported requests arrived during Phase 10's ship window ([#573], [#574]).
|
||||
Letting [#565] gather real-session evidence before fixing the forwarded intent's schema is better sequencing, and a structural-improvement cadence must not starve user-facing work.
|
||||
The cross-session intent spine is recorded as the leading Phase 12 candidate.
|
||||
|
||||
The Phase 11 spine is a cause-level boundary flaw in the same first-principles domain ([remaining design work](../architecture.md#remaining-design-work)): the access-intent boundary — turning `(toolName, input)` into "what is being accessed" — is closed against the real tool ecosystem.
|
||||
`classifyToolKind` decides that question from hardcoded built-in names, so a tool that carries bash semantics under another name bypasses the entire bash enforcement stack.
|
||||
Issue [#574] is the live instance: `@howaboua/pi-codex-conversion` replaces the native `bash` tool with `exec_command` (`cmd` + optional `workdir` fields), and the permission system gates it as a generic extension tool — no command decomposition, no wrapper flooring ([#490]), no bash path or external-directory token gates, no `bash:` config rules.
|
||||
The same shell operation is gated differently depending on which toolset is active, and a user's `bash` deny rules silently do not apply — an enforcement gap, not a polish item. (The existing `registerToolAccessExtractor` seam could recover only the `workdir` path gating; the command-surface gap is structural.)
|
||||
|
||||
Corroboration (fallow + sweeps, 2026-07-12): health 78 (B; deductions are unit size and cooling churn hotspots), dead code 0, duplication 0.4%.
|
||||
Both clone groups are intentional near-duplicates (`literalTextOf` fails closed on non-literal nodes where `resolveNodeText` best-effort resolves; the two bash gate preambles), kept per the wrong-abstraction rule.
|
||||
The repeated-discriminator sweep found no new family — the survivors are validation-edge `typeof` guards and per-node AST dispatch, idiomatic per the taxonomy.
|
||||
The `value-guards.ts` "split" refactoring target is rejected: a 17-LOC pure-guard leaf with high fan-in is a healthy utility, not a coupling smell.
|
||||
Feasibility probes: `ctx.ui.custom<T>()` exists on the current SDK and renders inline by default (`overlay ?? false` in `interactive-mode.ts`), so [#573] needs no SDK evolution; the pi-ask extension's inline flow (a pure input-command decision layer, hotkeys, back-navigation between steps) is the UX model.
|
||||
|
||||
## Health metrics
|
||||
|
||||
| Metric | Baseline (2026-07-12) | Phase 11 target |
|
||||
| --------------------------------------------------------------- | --------------------- | ------------------------------------------ |
|
||||
| `shellTools` schema sites (`config-schema.ts`) | 0 | ≥ 1 (config-driven, gate-parity tested) |
|
||||
| Flat `src/` root modules | 60 | ≤ 56 |
|
||||
| Subagent prefix-containment sites (`startsWith(prefix)`) | 1 | 0 (unified onto `PathFlavor.isWithin`) |
|
||||
| Inline prompt component files (`ui.custom` in `src/authority/`) | 0 | 1 (TUI-gated; `select` fallback preserved) |
|
||||
| fallow health score | 78 (B) | ≥ 78 |
|
||||
| Production duplication | 0.4% | ≤ 0.4% |
|
||||
| Dead exports | 0 | 0 |
|
||||
|
||||
Recompute commands (run from the repo root):
|
||||
|
||||
- `shellTools` schema sites: `grep -c shellTools packages/pi-permission-system/src/config-schema.ts`
|
||||
- Root modules: `ls packages/pi-permission-system/src | grep -c '\.ts$'`
|
||||
- Prefix-containment sites: `grep -c 'startsWith(prefix)' packages/pi-permission-system/src/authority/subagent-context.ts`
|
||||
- Inline prompt component files: `grep -rl 'ui\.custom' packages/pi-permission-system/src/authority | wc -l`
|
||||
- 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`
|
||||
|
||||
## Open-issue sweep dispositions
|
||||
|
||||
- [#574] — scheduled as Steps 2–3 (the phase spine).
|
||||
- [#573] — scheduled as Step 4.
|
||||
- [#571] — scheduled as Step 5.
|
||||
- [#575] — scheduled as Step 6.
|
||||
- [#472] — two-phase repeat deferral, now resolved: [#581]'s mechanical ADR was premature and reverted; the real design (two concrete use cases, the tool-augmented `Authorizer` chain) landed as [ADR 0007](../../decisions/0007-model-judge-authorizer-chain-adr.md) under [#591] (Step 7), which supersedes [#581] and makes [#472] schedulable.
|
||||
- [#519] — stays open by decision (not a silent sweep): blocked on Pi SDK UIContext evolution; Step 4's select-fallback constraint keeps frontend-driven flows working meanwhile.
|
||||
- [#565] — stays open, non-gating: the designated post-ship observation of the Phase 9 serving decisions, and now also the evidence-gathering input for the Phase 12 cross-session intent spine.
|
||||
|
||||
## Steps
|
||||
|
||||
### ✅ Step 1: Fold the access-intent stragglers into `src/access-intent/` ([#579])
|
||||
|
||||
**Cause:** the access-intent domain is named in the first-principles section and has a directory, but four of its modules still sit in the flat root, hiding the seam the aliasing steps extend.
|
||||
|
||||
- **Smell:** Category E (organization).
|
||||
- **Target:** `src/input-normalizer.ts` → `src/access-intent/input-normalizer.ts`; `src/mcp-targets.ts` → `src/access-intent/mcp-targets.ts`; `src/tool-input-path.ts` → `src/access-intent/tool-input-path.ts`; `src/path-surfaces.ts` → `src/access-intent/path-surfaces.ts`.
|
||||
Mechanical `#src/` import rewrites; `bash-advisory-check.ts` deliberately stays out (it composes the service with a gate orchestrator, and a domain module must not import from handlers).
|
||||
- **Outcome:** flat root 60 → 56 modules; no behavior change; Steps 2–3 then land in final locations (tidy-first).
|
||||
- **Impact 2 / Risk 1 / Priority 10.**
|
||||
|
||||
Release: independent
|
||||
|
||||
### ✅ Step 2: Shell-tool alias config model (`shellTools`) ([#580])
|
||||
|
||||
**Cause:** the access-intent boundary has no way to record that a foreign tool name carries bash semantics — the tool-kind variant set is closed to the tool ecosystem, and the recording belongs in config (design priority: config files are the source of truth; prefer config patterns over new runtime mechanisms).
|
||||
|
||||
- **Smell:** Category C (OCP at the access-intent boundary) with a Category F flavor (cross-package enforcement gap).
|
||||
- **Target:** `src/config-schema.ts` (a `shellTools` map: tool name → `{ commandArgument, workdirArgument? }`, with `.meta` descriptions and strict validation), regenerated `schemas/permissions.schema.json`, carry-through in `extension-config.ts` + `mergeUnifiedConfigs()` (the [#332]/[#347] drop class — post-[#356] the compiler flags the gap), `config/config.example.json`, `docs/configuration.md`, `README.md`.
|
||||
- **Outcome:** a validated, merged, documented `shellTools` config surface; no runtime behavior change yet (Step 3 consumes it); `grep -c shellTools src/config-schema.ts` goes 0 → ≥ 1.
|
||||
- **Impact 5 / Risk 2 / Priority 20.**
|
||||
|
||||
Release: batch "shell-tool-aliases"
|
||||
|
||||
### ✅ Step 3: Gate aliased shell invocations through the bash stack ([#574])
|
||||
|
||||
**Cause:** same cause as Step 2, consumed: once the alias is recorded, the dispatch point must route an aliased invocation through the same enforcement the native bash tool gets — otherwise "what is being accessed" still depends on which toolset is active ([#574]).
|
||||
|
||||
- **Smell:** Category C / F.
|
||||
- **Target:** `src/access-intent/tool-kind.ts` (`resolveShellInvocation` — the single dispatch point deciding "is this a shell, and what is its command + workdir?"
|
||||
for native bash and aliased tools alike), `src/access-intent/bash/program.ts` + `bash-path-resolver.ts` (`BashProgram` owns its source command via `commandText()`; a `workdir` seeds the path-walk base and is flagged external), `src/handlers/gates/tool-call-gate-pipeline.ts` + `bash-path.ts` + `bash-external-directory.ts` (consume the resolved command from `BashProgram`, no re-derived `input.command`), `src/handlers/gates/tool.ts` (bash-surface presentation for aliased tools, tool name preserved in logs), `src/permission-session.ts` (`getShellToolAliases` via `ToolCallGateInputs`), gate-parity + integration tests.
|
||||
- **Outcome:** with `shellTools: { "exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" } }`, an `exec_command` call gets command decomposition, wrapper flooring, bash path + external-directory token gates, and `bash:` rules at parity with native bash (including the `<unparseable-bash-command>` fail-closed sentinel); `workdir` is the effective base for relative tokens and is itself gated by `external_directory` when outside the session cwd; the review log records both the invoked tool name and the effective command.
|
||||
- **Landed:** the `command`-vs-`bashProgram` redundancy was collapsed by giving `BashProgram` its source command (`commandText()`) rather than threading a separate `command` parameter; `input-normalizer.ts`/`tool-input-path.ts` were left untouched — the enforcement path is the gate pipeline (which consults `resolveShellInvocation` directly), and the advisory service resolves `bash` by explicit command string, so neither needed alias-awareness.
|
||||
- **Impact 5 / Risk 3 / Priority 15.**
|
||||
|
||||
Release: batch "shell-tool-aliases"
|
||||
|
||||
### ✅ Step 4: Inline keybind permission dialog ([#573])
|
||||
|
||||
**Cause:** elicitation is the highest-frequency human touchpoint of the live-authority layer, and the stock two-select modal spends three keypresses where one would do; the `Authorizer` spine deliberately owns presentation, so this is a pure live-authority change — `evaluate()` and the ruleset are untouched ([#573]).
|
||||
|
||||
**Landed:** the mode dispatch lives in `permission-prompt-component.ts` (`requestPermissionDecision`), not `permission-dialog.ts`, to avoid a dialog↔component import cycle; `PermissionDecisionUi` stays narrow (`select`/`input`) and the inline view's wider `custom`-capable UI is a separate `PermissionPromptUi` type (ISP); the double-press affordance is a config toggle (`doublePressToConfirm`, default on) read live at prompt time; deny-with-reason requires a non-empty reason.
|
||||
|
||||
- **Smell:** none — user-requested feature on the authority spine, scheduled per the no-starvation rule.
|
||||
- **Target:** `src/authority/permission-dialog.ts` (mode dispatch + option semantics stay the single entry), new `src/authority/permission-prompt-component.ts` (inline `ctx.ui.custom<PermissionPromptDecision>` component modeled on the pi-ask flow: a pure input-command decision layer; `y` approve, `s` approve-for-session, `n` deny, `r` deny-with-reason hotkeys shown beside each option label; arrow/j/k navigation; enter confirm; esc deny; the forwarded-ask grant-scope select becomes an in-component second step with back-navigation), `src/authority/local-user-authorizer.ts` / `src/authority/authorizer.ts` (thread the widened UI view).
|
||||
- **Outcome:** TUI sessions get the inline keybind dialog (inline, never overlay); non-TUI contexts (RPC/frontend — the [#519] constraint) keep the current `select()`/`input()` flow unchanged; deny-with-reason drops from 3 keypresses to 1 plus typing.
|
||||
- **Impact 4 / Risk 2 / Priority 16.**
|
||||
|
||||
Release: independent
|
||||
|
||||
### ✅ Step 5: Unify subagent-context containment onto `PathFlavor.isWithin` ([#571])
|
||||
|
||||
**Cause:** two containment algorithms answer "is this path inside that directory?"
|
||||
— the path gates use `PathFlavor.isWithin` (Node `path.relative` geometry) while subagent detection uses a string-prefix check that diverges on `..` segments and prefix-sharing siblings; a must-agree pair with two algorithms is the [#562] connascence class, behavior-affecting where they diverge ([#571]).
|
||||
|
||||
**Landed:** the swap is a one-line call replacement plus deletion of the 13-line private helper; behavior is preserved for every realistic input, because `isSubagentExecutionContext` normalizes both operands through `normalizeFilesystemPath` first — `..` collapses and the trailing-separator prefix already rejected sibling-prefix dirs, so the two algorithms agree on all normalized-absolute session paths (session dirs are always absolute).
|
||||
Characterization tests pin the edge families on both flavors as an equivalence net rather than a behavior change.
|
||||
|
||||
- **Smell:** Category C (must-agree duplicate algorithm).
|
||||
- **Target:** `src/authority/subagent-context.ts` (replace the prefix check with `flavor.isWithin`), pinned edge-case tests (`..` in a session dir, sibling directory sharing a prefix, cross-root), delete the private helper.
|
||||
- **Outcome:** one containment algorithm package-wide; `grep -c 'startsWith(prefix)' src/authority/subagent-context.ts` goes 1 → 0.
|
||||
- **Impact 3 / Risk 2 / Priority 12.**
|
||||
|
||||
Release: independent
|
||||
|
||||
### ✅ Step 6: Survey exec-capable CLI rewrites for indirection-wrapper flooring ([#575])
|
||||
|
||||
**Cause:** the [#490] wrapper tables were seeded from a fixed inventory; exec-capable rewrites outside it (`parallel`, `setsid`, `stdbuf`, `watch`, …) can still launder a payload under a permissive `allow` — the fail-safe floor is only as good as the inventory ([#575]).
|
||||
|
||||
- **Smell:** Category C residue (bash-surface hardening).
|
||||
- **Target:** survey, then extend `INDIRECTION_WRAPPER_NAMES` / `EXEC_CONDITIONAL_WRAPPERS` (`src/access-intent/bash/command-enumeration.ts`) with adopted entries plus tests; record rejected candidates in the issue.
|
||||
- **Landed:** eight always-invoke wrappers added to `INDIRECTION_WRAPPER_NAMES` — the parallelizers `parallel`/`rust-parallel`/`rush`, the `sudo` rewrite `doas`, and the prefix wrappers `setsid`/`stdbuf`/`watch`/`flock` — each pinned by a `program.test.ts` classifier row.
|
||||
None is exec-flag-conditional (each always invokes its command), so `EXEC_CONDITIONAL_WRAPPERS` was untouched.
|
||||
Rejected as non-exec: `sad` (batch file editor), `fselect` (SQL file search), `runiq` (line dedupe); `gargs` is exec-capable but declined this round (niche).
|
||||
- **Outcome:** a documented inventory decision; each adopted wrapper floored to `ask` with a test.
|
||||
- **Impact 2 / Risk 1 / Priority 10.**
|
||||
|
||||
Release: independent
|
||||
|
||||
### ✅ Step 7: Decision record for the case-by-case judge ([#581] → [#591])
|
||||
|
||||
**Cause:** [#472] was deferred by name in Phases 9 and 10; the repeat-deferral rule required a decision this phase.
|
||||
[#581]'s first attempt transcribed the [architecture prose](../architecture.md#discriminating-delegation-a-model-authorizer) and was reverted as premature; [#591] re-derived the design interactively (two concrete use cases) and landed it as ADR 0007.
|
||||
|
||||
- **Smell:** process debt (repeat deferral), resolved as documentation.
|
||||
- **Target:** new [`docs/decisions/0007-model-judge-authorizer-chain-adr.md`](../../decisions/0007-model-judge-authorizer-chain-adr.md): the `Authorizer` chain (verdict range `allow | deny | defer`, type-level non-deferring terminal), the model judge as a non-terminal link, injected `PermissionQuery`, named opt-in `registerAuthorizer` registration, the config split, and the two-slice capability gradient — superseding the reverted ask-only decorator ADR.
|
||||
- **Outcome:** [#472] carries a linked ADR and becomes schedulable on its own merits; the deny-first slice is dogfooded by a first-party `packages/pi-permission-model-judge`; no code change.
|
||||
- **Impact 3 / Risk 1 / Priority 15.**
|
||||
|
||||
Release: independent
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["✅ Step 1: Move access-intent stragglers (#579)"]
|
||||
S2["✅ Step 2: shellTools alias config model (#580)"]
|
||||
S3["✅ Step 3: Bash-stack gating for aliased shell tools (#574)"]
|
||||
S4["✅ Step 4: Inline keybind permission dialog (#573)"]
|
||||
S5["✅ Step 5: Containment unification (#571)"]
|
||||
S6["✅ Step 6: Indirection-wrapper survey (#575)"]
|
||||
S7["✅ Step 7: Model-judge decision record (#581 → #591)"]
|
||||
S1 --> S3
|
||||
S2 --> S3
|
||||
```
|
||||
|
||||
## Parallel tracks
|
||||
|
||||
- **Track A — access-intent openness (spine):** Steps 1 → 2 → 3 (Steps 1 and 2 are mutually independent; both precede Step 3).
|
||||
- **Track B — elicitation UX:** Step 4.
|
||||
- **Track C — path semantics:** Step 5.
|
||||
- **Track D — bash hardening:** Step 6.
|
||||
- **Track E — authority direction:** Step 7.
|
||||
|
||||
Tracks B–E are independent of Track A and of each other.
|
||||
|
||||
## Release batches
|
||||
|
||||
- **Batch "shell-tool-aliases":** Steps 2, 3 (ship together; tail = Step 3).
|
||||
- Independently releasable: Steps 1, 4, 5, 6, 7.
|
||||
|
||||
[#332]: https://github.com/gotgenes/pi-packages/issues/332
|
||||
[#347]: https://github.com/gotgenes/pi-packages/issues/347
|
||||
[#356]: https://github.com/gotgenes/pi-packages/issues/356
|
||||
[#472]: https://github.com/gotgenes/pi-packages/issues/472
|
||||
[#490]: https://github.com/gotgenes/pi-packages/issues/490
|
||||
[#519]: https://github.com/gotgenes/pi-packages/issues/519
|
||||
[#562]: https://github.com/gotgenes/pi-packages/issues/562
|
||||
[#565]: https://github.com/gotgenes/pi-packages/issues/565
|
||||
[#571]: https://github.com/gotgenes/pi-packages/issues/571
|
||||
[#573]: https://github.com/gotgenes/pi-packages/issues/573
|
||||
[#574]: https://github.com/gotgenes/pi-packages/issues/574
|
||||
[#575]: https://github.com/gotgenes/pi-packages/issues/575
|
||||
[#579]: https://github.com/gotgenes/pi-packages/issues/579
|
||||
[#580]: https://github.com/gotgenes/pi-packages/issues/580
|
||||
[#581]: https://github.com/gotgenes/pi-packages/issues/581
|
||||
[#591]: https://github.com/gotgenes/pi-packages/issues/591
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
# Phase 12: Cross-session access intent and the Authorizer chain
|
||||
|
||||
## Findings (planned 2026-07-15)
|
||||
|
||||
Phase 11 closed with the cross-session access-intent spine (principal identity on forwarded asks, path portability across cwds) recorded as the leading Phase 12 candidate, and discovery corroborates it as the phase's cause-level spine.
|
||||
The cause is a boundary flaw in the escalation edge, named in [remaining design work](../architecture.md#remaining-design-work): the gate's structured `AccessIntent`/`AccessPath` product dies at the session boundary.
|
||||
`ForwardedPermissionRequest` carries a pre-rendered `message` plus *display-only* `surface`/`value` strings, so the serving node's `ServingPolicy.check(surface, value)` must re-derive an intent from a bare string through the **parent's** `PathNormalizer` and cwd — the path's meaning is re-interpreted at the wrong node (a child in a worktree resolves against a different root), the child's lexical ∪ canonical alias set (the [#418]/[#486] match contract) never crosses the wire, and a request without display fields floors to `ask`.
|
||||
Serving is agent-neutral with the semantics explicitly undefined.
|
||||
Issue [#565] items 2–3 name both losses; they were accepted at [#557] ship time pending exactly this spine.
|
||||
|
||||
The second track is the `Authorizer` chain ([#472]): ADR 0007 ([docs/decisions/0007-model-judge-authorizer-chain-adr.md](../../decisions/0007-model-judge-authorizer-chain-adr.md)) is accepted and explicitly assigns the implementation's decomposition to this planning pass.
|
||||
The cause is an OCP gap at the live-authority layer: its shape (one terminal `Authorizer` selected once) cannot seat a non-terminal link that reviews an ask and defers, so a case-by-case judge has no home.
|
||||
After three consecutive phase deferrals, [#472] is scheduled by user decision.
|
||||
Feasibility probes: `@earendil-works/pi-ai` exports `complete`/`completeSimple` and pi-subagents already depends on it, so the dogfood judge package can invoke a model on the real surface; `registerAuthorizer` mirrors the existing `registerToolAccessExtractor`/`registerToolInputFormatter` service precedent.
|
||||
|
||||
Corroboration (fallow + sweeps, 2026-07-15): health 88 (A; deductions are unit size and cooling churn), dead code 0, duplication 0.1% (the one clone group is the documented intentional `literalTextOf`/`resolveNodeText` pair).
|
||||
The repeated-discriminator sweep found no new family — survivors are validation-edge `typeof` guards, per-node AST dispatch, and presentation dispatch, idiomatic per the taxonomy.
|
||||
The `value-guards.ts` refactoring target remains rejected (healthy high-fan-in leaf).
|
||||
The craftsmanship scout found no concentrated debt: the two fallow "giant function" test flags (`program.test.ts`, `bash-external-directory.test.ts`) are false positives (nested `describe` trees of small behavior-named tests), churn-hotspot test files all use the shared `test/helpers/` fixtures cleanly, and the only real finding (a flat ungrouped test run in `permission-manager-unified.test.ts`) is scattered mechanical trivia deferred to boy-scout tidying.
|
||||
No directory reorg rides this phase: both tracks land in the existing `authority/` domain plus a new package, and the 56-module flat root's next grouping opportunity should ride a phase that rewrites those files.
|
||||
|
||||
## Health metrics
|
||||
|
||||
| Metric | Baseline (2026-07-15) | Phase 12 target |
|
||||
| --------------------------------------------------------------------------------------------- | --------------------- | --------------- |
|
||||
| Forwarded-wire structured intent (`ForwardedAccessIntent` in `permission-forwarding.ts`) | 0 | ≥ 1 |
|
||||
| Serving reads the forwarded intent (`ForwardedAccessIntent` in `forwarded-request-server.ts`) | 0 | ≥ 1 |
|
||||
| `registerAuthorizer` service surface (`service.ts`) | 0 | ≥ 1 |
|
||||
| `authorizerChain` schema sites (`config-schema.ts`) | 0 | ≥ 1 |
|
||||
| Model-judge package present | 0 | 1 |
|
||||
| fallow health score | 88 (A) | ≥ 88 |
|
||||
| Production duplication | 0.1% | ≤ 0.2% |
|
||||
| Dead exports | 0 | 0 |
|
||||
|
||||
Recompute commands (run from the repo root):
|
||||
|
||||
- Forwarded-wire intent: `grep -c ForwardedAccessIntent packages/pi-permission-system/src/authority/permission-forwarding.ts`
|
||||
- Serving intent read: `grep -c ForwardedAccessIntent packages/pi-permission-system/src/authority/forwarded-request-server.ts`
|
||||
- Service surface: `grep -c registerAuthorizer packages/pi-permission-system/src/service.ts`
|
||||
- Schema sites: `grep -c authorizerChain packages/pi-permission-system/src/config-schema.ts`
|
||||
- Model-judge package: `ls packages | grep -c pi-permission-model-judge`
|
||||
- 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`
|
||||
|
||||
## Open-issue sweep dispositions
|
||||
|
||||
- [#565] — kept open through Phase 12 by decision: Steps 1–3 dissolve its items 2 (agent-scope semantics) and 3 (single-`(surface, value)` re-resolution lossiness) structurally; it closes at phase end with a note recording that item 1 (forwarded-prompt fidelity against a real external notification consumer) stays best-effort, since no consumer exists to verify against.
|
||||
**Closed** at phase end per this disposition (items 2–3 dissolved by Steps 1–3; item 1 recorded best-effort).
|
||||
- [#472] — scheduled as Steps 4–6 (Track B) by user decision after three consecutive phase deferrals; ADR 0007 settles the design and this phase implements its deny-first slice.
|
||||
- [#519] — stays open by decision with recorded rationale (not a silent re-defer): it is externally blocked on Pi SDK `UIContext` evolution, and the `select`/`input` fallback keeps frontend-driven flows working meanwhile; it closes or schedules when the SDK ships the capability.
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 1: ADR 0008 — forwarded access-intent portability and principal identity ([#595]) ✅
|
||||
|
||||
**Cause:** the escalation edge has no defined semantics for what a forwarded path *means* across cwds nor for which agent identity governs serving evaluation — [#565] items 2–3 are unanswerable because the questions were never decided, only accepted as failure modes at [#557] ship time.
|
||||
|
||||
- **Smell:** Category C (coupling/boundary flaw) — the decision record is the phase deliverable that names the target concept, per the first-principles rule.
|
||||
- **Target:** `docs/decisions/0008-cross-session-access-intent.md`.
|
||||
Settles: the portable meaning of a path-shaped ask is the match set fixed at the child (the child's lexical ∪ canonical `matchValues()` plus canonical `boundaryValue()`, computed where the path was typed — the parent matches its rules against those fixed values and never re-derives them); the `ForwardedAccessIntent` wire schema (surface, match values, boundary value, requester cwd, principal identity) with version-skew tolerance rules (tolerant read, `ask` floor for legacy requests); and the agent-scope semantics of serving evaluation (whether `requesterAgentName` participates or serving stays deliberately agent-neutral on the base ruleset).
|
||||
- **Outcome:** the cross-session intent contract is decided in writing before the wire changes; Steps 2–3 implement it rather than deciding it inline.
|
||||
- **Landed:** `docs/decisions/0008-cross-session-access-intent.md`, structured principle-first — *the child owns the facts; the parent owns the judgment* — with four derived consequences.
|
||||
Resolved parameters (superseding the speculative framing above): path meaning is fixed at the child (child-fixed `matchValues()` ∪ `boundaryValue()`, no parent re-derivation); serving is **agent-scoped** (`requesterAgentName` is decision-participating, a strict superset of agent-neutral); version skew is a **required field** with an `ask` floor on absence (not a tolerant dual-path).
|
||||
A composition section situates the record against ADR 0007 (Track A/B orthogonality) without re-deciding it.
|
||||
- **Impact 4 / Risk 1 / Priority 20.**
|
||||
|
||||
Release: batch "cross-session-intent"
|
||||
|
||||
### Step 2: Carry the structured intent to the escalation edge and onto the forwarded wire ([#596]) ✅
|
||||
|
||||
**Cause:** the gate computes a full `AccessIntent` (with the `AccessPath` alias set) and then discards it — `PromptPermissionDetails` and `ForwardedPermissionRequest` carry only display strings, so the intent the parent needs is unrecoverable downstream (the display-field floor in `hasDisplayFields` is the symptom).
|
||||
|
||||
- **Smell:** Category C (boundary flaw).
|
||||
- **Target:** `src/handlers/gates/descriptor.ts` + the path-gate descriptor factories (thread the emitted intent onto the descriptor/details), `src/authority/permission-prompter.ts` (`PromptPermissionDetails` carries the intent), `src/authority/approval-escalator.ts` (`ParentAuthorizer` serializes it), `src/authority/permission-forwarding.ts` (the `ForwardedAccessIntent` field per ADR 0008), `src/authority/forwarding-io.ts` (tolerant read).
|
||||
- **Outcome:** every forwarded ask carries an evaluable intent — path-shaped asks carry the child-fixed alias set and requester cwd; non-path surfaces (bash command, MCP target, skill name) carry their already-portable `(surface, value)`; an older child's request still reads (version-skew tolerant) and floors to `ask` as today.
|
||||
`grep -c ForwardedAccessIntent src/authority/permission-forwarding.ts` goes 0 → ≥ 1.
|
||||
- **Impact 4 / Risk 3 / Priority 12.**
|
||||
- **Landed:** the wire schema (`ForwardedAccessFacts`/`ForwardedAccessIntent`) lives in `permission-forwarding.ts`; each gate emits the child-fixed facts onto `PromptPermissionDetails.accessIntent` through the shared `accessFactsFromPath`/`accessFactsFromValue` helpers (`handlers/gates/helpers.ts`), so `descriptor.ts` needed no change — the facts ride on the descriptor's `promptDetails`.
|
||||
`ParentAuthorizer` completes them into a `ForwardedAccessIntent`, stamping `requesterCwd` (from `ctx.cwd`, exposed via the new `getCwd`) and `principal`; `forwarding-io.ts` reads the field tolerantly (absent/malformed → `undefined`, floored to `ask` in Step 3).
|
||||
Serving still re-derives from display strings until Step 3, so the forwarded-wire metric now reads ≥ 1 while the serving-read metric stays 0.
|
||||
|
||||
Release: batch "cross-session-intent"
|
||||
|
||||
### Step 3: Serving resolves the forwarded intent at gate parity ([#597]) ✅
|
||||
|
||||
**Cause:** same cause, consumed at the serving node — `ServingPolicy.check(surface, value)` re-interprets a child's path string through the parent's `PathNormalizer`/cwd, so a parent `allow` that would match the child's alias set can silently miss (and vice versa), and any multi-alias fidelity floors to `ask`.
|
||||
|
||||
- **Smell:** Category C (boundary flaw).
|
||||
- **Target:** `src/authority/forwarded-request-server.ts` (`ServingPolicy` becomes intent-shaped; `resolveDecision` resolves the forwarded intent directly, keeping the legacy `(surface, value)` fallback for version skew), `src/index.ts` (wiring — the serving closure hands the child's match values to `resolver.resolve` instead of rebuilding a path from a bare string via `buildAccessIntentForSurface`), agent-scope semantics applied as ADR 0008 decides.
|
||||
- **Outcome:** the parent's recorded authority governs a child's path ask against the child-fixed alias set — a `/tmp/*` allow at the parent matches exactly what the child's own gate would have matched; [#565] items 2–3 are structurally dissolved, and [#565] closes at phase end with the item-1 best-effort note.
|
||||
`grep -c ForwardedAccessIntent src/authority/forwarded-request-server.ts` goes 0 → ≥ 1.
|
||||
- **Impact 5 / Risk 2 / Priority 20.**
|
||||
- **Landed:** `ServingPolicy.resolve(intent: ForwardedAccessIntent)` replaces `check(surface, value)`; `resolveDecision` gates on `request.accessIntent` presence (ADR 0008 §4's sole-resolution-path, `ask`-floor on absence — the legacy `(surface, value)` branch was retired outright rather than kept as a dual path, an operator-confirmed deviation from this step's original "keep the legacy fallback" framing).
|
||||
Serving is agent-scoped: `buildResolvedIntentFromMatchValues` (`input-normalizer.ts`) builds a `path-values`/`tool` `ResolvedAccessIntent` straight from the wire's `matchValues` and `principal.agentName`, and the widened concrete `PermissionResolver.resolve` accepts it as a passthrough — no `PathNormalizer` re-derivation.
|
||||
`grep -c ForwardedAccessIntent src/authority/forwarded-request-server.ts` reads 4 (0 → ≥ 1, target met).
|
||||
Shipped `feat:` (non-breaking, per [#557] precedent) since the outcome changes only when the parent holds a per-agent rule for the requesting agent.
|
||||
|
||||
Release: batch "cross-session-intent"
|
||||
|
||||
### ✅ Step 4: Authorizer chain infrastructure ([#598])
|
||||
|
||||
**Cause:** the live-authority layer's shape (one terminal `Authorizer` selected once per activation) is closed against non-terminal participants — a link that reviews an ask and defers cannot be seated, which is the structural reason [#472] has had no home since Phase 9 built the spine.
|
||||
|
||||
- **Smell:** Category C (OCP at the live-authority layer).
|
||||
- **Target:** `src/authority/authorizer.ts` (`AuthorizerVerdict`: `allow | deny | defer`, with `deny` carrying an optional teaching `reason`), new `src/authority/authorizer-chain.ts` (`composeAuthorizerChain` — registered non-terminal links, then the context-selected terminal; the terminal-cannot-defer invariant is type-level), `src/authority/authorizer-selection.ts` (`selectAuthorizer` becomes the terminal-selection step; the `AskEscalator` surface is unchanged).
|
||||
- **Outcome:** refactor-only — behavior is identical with zero registered links, pinned by the existing authorizer-selection tests; the chain seam exists for Step 5 to expose.
|
||||
- **Landed:** `Authorizer` is now the non-terminal chain link (`allow | deny | defer`), `TerminalAuthorizer` is the terminal (cannot defer, type-level), and `composeAuthorizerChain([], terminal)` returns the terminal instance so behavior is byte-identical; `AuthorizerSelection.activate` routes through the empty chain.
|
||||
Seven `composeAuthorizerChain` unit tests added.
|
||||
- **Impact 4 / Risk 3 / Priority 12.**
|
||||
|
||||
Release: batch "authorizer-chain"
|
||||
|
||||
### ✅ Step 5: `registerAuthorizer` seam, `authorizerChain` config, and the enforcement checkpoint ([#599])
|
||||
|
||||
**Cause:** same cause, consumed — the chain needs a registration surface and an operator-owned naming step, honoring ADR 0007's invariants: config order (not registration order) fixes the chain order, a missing configured link is skipped fail-safe, and registration alone grants no authority.
|
||||
|
||||
- **Smell:** Category C (OCP), with the config surface following the source-of-truth priority.
|
||||
- **Target:** `src/service.ts` + `src/permissions-service.ts` (`registerAuthorizer(name, link)` with a disposer, mirroring `registerToolAccessExtractor`), `src/config-schema.ts` (an `authorizerChain: string[]` field with `.meta` descriptions) + regenerated `schemas/permissions.schema.json` + carry-through in `extension-config.ts` and `mergeUnifiedConfigs()` (the [#332]/[#347] drop class), the enforcement checkpoint in the chain owner (an excluded-surface `allow` downgrades to `defer`; `external_directory` and secret-shaped `path` always excluded), `config/config.example.json`, `docs/configuration.md`, `README.md`.
|
||||
- **Outcome:** a downstream extension can offer a named link on `permissions:ready` and it decides nothing until the operator names it in `authorizerChain`; the checkpoint caps any link's authority; `grep -c registerAuthorizer src/service.ts` and `grep -c authorizerChain src/config-schema.ts` both go 0 → ≥ 1.
|
||||
The surface ships config-gated; it is vacant only until Step 6 lands (the [#267] guard).
|
||||
- **Landed:** `registerAuthorizer(name, authorize)` on `PermissionsService` backed by `AuthorizerRegistry`; `authorizerChain: string[]` config carried through the schema, `extension-config.ts`, and `mergeUnifiedConfigs()`; a session-scoped `PermissionQuery` (Step 4's deferred injection) handed to each link via `composeAuthorizerChain(links, terminal, query)`; `AuthorizerSelection` resolves the chain **per ask** (config order, fail-safe skip, delegation-envelope wrap) so a link registered in a late `permissions:ready` handler is honored before the first ask.
|
||||
The checkpoint excludes the **whole** `path` surface (no formal secrets model to key a secret-shaped exclusion on); the secret-shaped refinement, the `origin:"authorizer:model"` audit shape, and the allow-capable adjudicator that consumes the query are deferred to [#620].
|
||||
Two preparatory refactors (`PermissionQuery` extraction, array-merge key loop) landed first.
|
||||
- **Impact 5 / Risk 2 / Priority 20.**
|
||||
|
||||
Release: batch "authorizer-chain"
|
||||
|
||||
### ✅ Step 6: Dogfood package — `@gotgenes/pi-permission-model-judge` ([#600])
|
||||
|
||||
**Cause:** the [#267] history guard — an inbound registration surface nobody consumes goes vacant; ADR 0007 requires the seam born consumed by a first-party deny-first reviewer, which also exercises the config split (chain policy here, model mechanism there) end to end.
|
||||
|
||||
- **Smell:** Category F (cross-package responsibility placement, done deliberately: this package holds no model-prompt config it does not read).
|
||||
- **Target:** new `packages/pi-permission-model-judge/` — registers `"model-judge"` on `permissions:ready`; the deny-first typo-path reviewer (verdicts `deny | defer` only in this slice; the allow-capable opaque-bash adjudicator stays deferred per ADR 0007's capability gradient); model calls via `@earendil-works/pi-ai` `complete` (feasibility-probed) with the provider/model/instructions/timeout in its own `config.json`; full monorepo wiring per AGENTS.md (`release-please-config.json` component + `docs/plans`/`docs/retro` exclude-paths, `.release-please-manifest.json` at `0.0.0`, `.pi/settings.json` load path + npm disable entry, root `README.md` packages table).
|
||||
- **Outcome:** `registerAuthorizer` has a day-one consumer; an errant typo-path `external_directory` ask can be auto-denied with a teaching reason when the operator opts in; `ls packages | grep -c pi-permission-model-judge` goes 0 → 1.
|
||||
- **Landed:** new `packages/pi-permission-model-judge/` registers `"model-judge"` on `permissions:ready` (from both its own `session_start` and the ready event, idempotently, so either extension-init order completes the registration); the deny-first reviewer gates on the `external_directory` surface, a configured `typoPatterns` regex pre-filter, then a model confirmation via `@earendil-works/pi-ai` `complete` — verdicts `deny | defer` only, fail-safe to `defer` on any uncertainty.
|
||||
Its own zod-validated `config.json` (provider/model/instructions/typoPatterns/timeout) holds the model mechanism; the chain policy stays in pi-permission-system.
|
||||
- **Impact 4 / Risk 3 / Priority 12.**
|
||||
|
||||
Release: independent
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["✅ Step 1 (#595): ADR 0008 — forwarded-intent portability + principal identity"] --> S2["✅ Step 2 (#596): structured intent on the forwarded wire"]
|
||||
S2 --> S3["✅ Step 3 (#597): serving resolves the forwarded intent"]
|
||||
S4["✅ Step 4 (#598): Authorizer chain infrastructure"] --> S5["✅ Step 5 (#599): registerAuthorizer seam + authorizerChain config"]
|
||||
S5 --> S6["✅ Step 6 (#600): pi-permission-model-judge dogfood package"]
|
||||
```
|
||||
|
||||
## Parallel tracks
|
||||
|
||||
- **Track A — cross-session intent spine:** Steps 1 → 2 → 3.
|
||||
- **Track B — Authorizer chain:** Steps 4 → 5 → 6.
|
||||
|
||||
The tracks are independent and can proceed in parallel; both touch `src/authority/`, but Track A's files (forwarding, serving) and Track B's files (authorizer selection, chain) are disjoint apart from the shared `AskEscalator` seam, which neither track changes.
|
||||
|
||||
## Release batches
|
||||
|
||||
- **Batch "cross-session-intent":** Steps 1, 2, 3 (ship together; tail = Step 3).
|
||||
- **Batch "authorizer-chain":** Steps 4, 5 (ship together; tail = Step 5).
|
||||
- Independently releasable: Step 6 (a new package with its own release component; it lands after Step 5).
|
||||
|
||||
## Completion
|
||||
|
||||
All 6 steps are closed: [#595], [#596], [#597], [#598], [#599], [#600].
|
||||
Follow-on issue [#620] (allow-capable opaque-bash adjudicator, ADR 0007's ask-consuming slice 2) was filed during Step 5's landing to track the deferred capability; it remains open and non-gating. [#565] (validate serving-is-resolution decisions post-ship, opened as a Phase 9 follow-on) closed at phase end per this phase's open-issue sweep disposition — Steps 1–3 structurally dissolved its items 2–3, and item 1 is recorded best-effort.
|
||||
Open issues swept and confirmed out of scope during planning, both by decision and non-gating: [#472] (`ModelTriageAuthorizer` — its deny-first slice shipped as Steps 4–6, but the issue stays open pending the allow-capable slice 2, [#620]), [#519] (externally blocked on Pi SDK `UIContext` evolution).
|
||||
|
||||
### Delivered vs. predicted metrics
|
||||
|
||||
Recomputed at archive time (`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`):
|
||||
|
||||
| Metric | Phase 12 target | Delivered |
|
||||
| --------------------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| Forwarded-wire structured intent (`ForwardedAccessIntent` in `permission-forwarding.ts`) | ≥ 1 | 2 — met |
|
||||
| Serving reads the forwarded intent (`ForwardedAccessIntent` in `forwarded-request-server.ts`) | ≥ 1 | 5 — met |
|
||||
| `registerAuthorizer` service surface (`service.ts`) | ≥ 1 | 1 — met |
|
||||
| `authorizerChain` schema sites (`config-schema.ts`) | ≥ 1 | 1 — met |
|
||||
| Model-judge package present | 1 | 1 (`packages/pi-permission-model-judge/`) — met |
|
||||
| fallow health score | ≥ 88 | 88 (A) — met |
|
||||
| Production duplication | ≤ 0.2% | 0.1% (34 lines, 1 clone group, the documented intentional `literalTextOf`/`resolveNodeText` pair) — met |
|
||||
| Dead exports | 0 | 0 — met |
|
||||
|
||||
[#267]: https://github.com/gotgenes/pi-packages/issues/267
|
||||
[#332]: https://github.com/gotgenes/pi-packages/issues/332
|
||||
[#347]: https://github.com/gotgenes/pi-packages/issues/347
|
||||
[#418]: https://github.com/gotgenes/pi-packages/issues/418
|
||||
[#472]: https://github.com/gotgenes/pi-packages/issues/472
|
||||
[#486]: https://github.com/gotgenes/pi-packages/issues/486
|
||||
[#519]: https://github.com/gotgenes/pi-packages/issues/519
|
||||
[#557]: https://github.com/gotgenes/pi-packages/issues/557
|
||||
[#565]: https://github.com/gotgenes/pi-packages/issues/565
|
||||
[#595]: https://github.com/gotgenes/pi-packages/issues/595
|
||||
[#596]: https://github.com/gotgenes/pi-packages/issues/596
|
||||
[#597]: https://github.com/gotgenes/pi-packages/issues/597
|
||||
[#598]: https://github.com/gotgenes/pi-packages/issues/598
|
||||
[#599]: https://github.com/gotgenes/pi-packages/issues/599
|
||||
[#600]: https://github.com/gotgenes/pi-packages/issues/600
|
||||
[#620]: https://github.com/gotgenes/pi-packages/issues/620
|
||||
@@ -0,0 +1,111 @@
|
||||
# Phase 2: Complexity and duplication paydown
|
||||
|
||||
Goal: pay down the complexity and duplication debt that `fallow` flags but no issue tracks.
|
||||
|
||||
Phase 1 is scoped to enabling [#266] (the preview formatter).
|
||||
Phase 2 is a distinct theme: it eliminates the five `fallow` refactoring targets and drives down the test-tree duplication.
|
||||
Four of the five targets sit on the security-critical `tool_call` decision path, where high untested complexity is a correctness risk, not only a maintainability one.
|
||||
|
||||
The two phases are otherwise independent and can run in either order, with one exception: do [#285] before Phase 1 step 2, since both modify the `describeToolGate` call site inside `handleToolCall`, and decomposing that function first lets the formatter thread through a clean pipeline.
|
||||
|
||||
## Current health metrics
|
||||
|
||||
| Metric | Value |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| Health score | 74 B |
|
||||
| LOC | 31,416 |
|
||||
| Dead files / exports | 0% |
|
||||
| Avg cyclomatic | 1.4 |
|
||||
| Maintainability | 91.2 (good) |
|
||||
| Duplication | 9.2% (after [#286]) |
|
||||
| Refactoring targets | 3 (2 medium, 1 high) - after [#290]; `config-loader.ts` no longer a target |
|
||||
| Worst CRAP risk | `permission-gate-handler.ts` 79.4 (handleInput) - after [#290] |
|
||||
|
||||
## Findings
|
||||
|
||||
All findings are `fallow`-confirmed and untracked before this phase.
|
||||
|
||||
| # | Finding | Category | Files | Impact | Risk | Priority |
|
||||
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------- | ------ | ---- | -------- |
|
||||
| 1 | `handleToolCall` runs six gates with a repeated bypass/runner/short-circuit shape - cognitive 52, CRAP 172, the package's worst | B: god function | `handlers/permission-gate-handler.ts` | 5 | 2 | 20 |
|
||||
| 2 | ✅ `resolvePermissions` interleaves scope merge with parallel origin-map bookkeeping - cognitive 33, CRAP 97 - resolved by [#286] | B: god function | `permission-manager.ts` | 4 | 2 | 16 |
|
||||
| 3 | ✅ `runGateCheck` carried the full check→log→emit→approve cycle as six inline phases - cognitive 32 - resolved by [#287] | B: god function | `handlers/gates/runner.ts` | 4 | 2 | 16 |
|
||||
| 4 | ✅ Two token classifiers share a 31-line rejection prelude (production clone); `collectPathCandidateTokens` (37) and `collectPatternCommandTokens` (33) are complexity hotspots - resolved by [#289] | A: duplication / B: god function | `handlers/gates/bash-path-extractor.ts` | 4 | 3 | 12 |
|
||||
| 5 | ✅ `stripJsonComments` is a five-variable character scanner - cognitive 31 - resolved by [#290] | B: god function | `config-loader.ts` | 2 | 2 | 8 |
|
||||
| 6 | ✅ 9.1% duplication concentrated in the test tree - the single largest health deduction (-4.1) - resolved by [#288] | D: test duplication | `test/` (clone families) | 3 | 1 | 15 |
|
||||
|
||||
## Steps
|
||||
|
||||
1. ✅ **Decompose `handleToolCall`** ([#285]) - **completed**
|
||||
- Extracted `validateRequestedTool` (pure, exported) for the tool-name validation prelude.
|
||||
- Extracted `runGate` closure (inside `handleToolCall`) for the unified bypass/runner/short-circuit shape.
|
||||
- Collapsed the body to validate → build context → ordered producer-array pipeline.
|
||||
- Outcome: `handleToolCall` no longer appears as a refactoring target; CRAP risk for the file dropped from 172 → 79.4 (now `handleInput`); refactoring targets 5 → 4.
|
||||
|
||||
2. ✅ **Decompose `resolvePermissions`** ([#286]) - **completed**
|
||||
- Extracted `mergeScopesWithOrigins(scopes)` (into new `src/scope-merge.ts`) returning `{ mergedPermission, origins }`, isolating origin-map bookkeeping from the resolve pipeline.
|
||||
- The remaining body reads as load scopes → merge with origins → extract universal fallback → build config rules → compose.
|
||||
- Outcome: `resolvePermissions` no longer appears as a refactoring target; `permission-manager.ts` dropped from the CRAP-risk list.
|
||||
|
||||
3. ✅ **Thin `runGateCheck`** ([#287]) - **completed**
|
||||
- Introduced `SessionApproval` value object (`src/session-approval.ts`) owning the `{pattern}|{patterns}` union; exposed `representativePattern` and `toGateApproval()`.
|
||||
- `SessionRules.record(approval)` absorbs the per-pattern loop; `GateRunnerDeps` seam renamed to `recordSessionApproval(approval)` - runner tells the store, never interrogates the union.
|
||||
- Extracted `buildDecisionEvent` into `helpers.ts` to deduplicate the `origin/agentName/matchedPattern ?? null` normalization across both emit sites.
|
||||
- Outcome: `runner.ts` no longer appears as a refactoring target; refactoring targets 4 → 3.
|
||||
|
||||
4. ✅ **Decompose `bash-path-extractor.ts`** ([#289]) - **completed**
|
||||
- Extracted pure token classifiers into new `src/handlers/gates/bash-token-classification.ts`; private `rejectNonPathToken` predicate eliminates the 31-line rejection-prelude clone.
|
||||
- Extracted `classifyPatternCommandFlag` (returns a `PatternCommandFlagDirective` discriminated union) to replace the inline flag state machine in `collectPatternCommandTokens`.
|
||||
- Extracted `collectCommandTokens`, `collectGenericCommandTokens`, `collectRedirectTokens` from `collectPathCandidateTokens`; converted both walkers from output-argument accumulator to return-based `string[]`.
|
||||
- Category: A + B (production clone + god functions)
|
||||
- Outcome: clone removed; `collectPathCandidateTokens` and `collectPatternCommandTokens` decomposed into focused helpers; `bash-token-classification.ts` has dedicated unit tests (43 tests) covering every rejection and acceptance branch.
|
||||
|
||||
5. ✅ **Reduce `stripJsonComments` complexity** ([#290]) - **completed**
|
||||
- Replaced the five-flag single-loop scanner with a stateless dispatcher delegating to three private consume helpers: `consumeLineComment`, `consumeBlockComment`, and `consumeString`, each returning a `ScanSegment` value (`{ output, nextIndex }`).
|
||||
- Added 14 direct unit tests for `stripJsonComments` (the function was exported but had no dedicated coverage) to pin the contract before the refactor.
|
||||
- Category: B (god function)
|
||||
- Outcome: `stripJsonComments` no longer appears as a refactoring target; `config-loader.ts` dropped from the CRAP-risk list; refactoring targets 4 → 3.
|
||||
- Commits: `test: add direct stripJsonComments unit tests`, `refactor: model stripJsonComments as consume helpers`
|
||||
|
||||
6. ✅ **Extract shared test fixtures** ([#288]) - **completed**
|
||||
- Created `test/helpers/handler-fixtures.ts` (`makeCtx`, `makeEvents`, `makeSession`, `makeToolRegistry`, `makeToolCallEvent`, `makeCheckResult`, `makeHandler`, `getDecisionEvents`), `test/helpers/gate-fixtures.ts` (`makeDescriptor`, `makeRunnerDeps`, `makeTcc`, `makeGateCheckResult`), and `test/helpers/manager-harness.ts` (`createManager`).
|
||||
- Migrated handler-event clone family (`tool-call-events.test.ts`, `tool-call.test.ts`, `input-events.test.ts`, `input.test.ts`, `permission-session.test.ts`), external-directory family, gate family (`runner.test.ts`, `bash-path.test.ts`, `path.test.ts`), manager harness (`permission-system.test.ts`), and lifecycle setup (`before-agent-start.test.ts`, `lifecycle.test.ts`).
|
||||
- Category: D (test duplication)
|
||||
- Outcome: duplication 9.1% → 7.1%; clone groups 122 → 113; health deduction -4.1 → -2.1.
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
Steps 1-5 are independent.
|
||||
Step 6 is best sequenced after the production refactors whose tested call sites it touches (dashed edges) - those refactors are behavior-preserving, so the soft ordering only avoids re-migrating fixtures, it does not block.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["Step 1: Decompose handleToolCall (#285)"]
|
||||
S2["Step 2: Decompose resolvePermissions (#286)"]
|
||||
S3["Step 3: Decompose runGateCheck (#287)"]
|
||||
S4["Step 4: Decompose bash-path-extractor (#289)"]
|
||||
S5["Step 5: Reduce stripJsonComments (#290)"]
|
||||
S6["Step 6: Extract shared test fixtures (#288)"]
|
||||
|
||||
S1 -.-> S6
|
||||
S2 -.-> S6
|
||||
S3 -.-> S6
|
||||
S4 -.-> S6
|
||||
```
|
||||
|
||||
## Tracks
|
||||
|
||||
| Track | Steps | Description |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------ |
|
||||
| A: Decision-path complexity | 1, 2, 3 | Decompose the three `tool_call` hotspots (independent, parallel) |
|
||||
| B: bash-path-extractor | 4 | Remove the production clone and reduce the two collect-function hotspots |
|
||||
| C: config-loader | 5 | Reduce `stripJsonComments` complexity (lowest priority) |
|
||||
| D: Duplication | 6 | Extract shared test fixtures; best sequenced after Tracks A and B |
|
||||
|
||||
[#266]: https://github.com/gotgenes/pi-packages/issues/266
|
||||
[#285]: https://github.com/gotgenes/pi-packages/issues/285
|
||||
[#286]: https://github.com/gotgenes/pi-packages/issues/286
|
||||
[#287]: https://github.com/gotgenes/pi-packages/issues/287
|
||||
[#288]: https://github.com/gotgenes/pi-packages/issues/288
|
||||
[#289]: https://github.com/gotgenes/pi-packages/issues/289
|
||||
[#290]: https://github.com/gotgenes/pi-packages/issues/290
|
||||
@@ -0,0 +1,241 @@
|
||||
# Phase 3: State-owning collaborators
|
||||
|
||||
Goal: convert the package's remaining bags-of-state-and-closures into class-based collaborators that own their state and expose behavior (Tell-Don't-Ask), then clear the one outstanding `fallow` cohesion target and the test-tree duplication.
|
||||
|
||||
Phases 1 and 2 already gave the core domain good collaborators - `PermissionSession`, `ForwardingManager`, `SessionRules`, `SessionApproval`, `BashProgram`, `PermissionManager`.
|
||||
Phase 3 finishes that arc where it stalled: the forwarding subsystem got a lifecycle class (`ForwardingManager`) but its behavior still lives as free functions reaching into a `PermissionForwardingDeps` bag that is assembled in two places.
|
||||
The lens for this phase is not "extract a function" but "which stateful owner is missing, such that a caller reaches into a bag instead of telling an object?".
|
||||
|
||||
Phase 3 is independent of any open feature issue - it is a pure debt-reduction round.
|
||||
|
||||
## Current health metrics
|
||||
|
||||
| Metric | Value |
|
||||
| ---------------------- | ------------------------------------------------- |
|
||||
| Health score | 75 B |
|
||||
| LOC | 35,515 |
|
||||
| Dead files / exports | 0% |
|
||||
| Avg cyclomatic | 1.4 |
|
||||
| p90 cyclomatic | 2 |
|
||||
| Maintainability | 91.3 (good) |
|
||||
| Duplication | 7.6% (2,700 lines, all in `test/`) |
|
||||
| Churn hotspots | 41 files |
|
||||
| Refactoring targets | 0 |
|
||||
| Dominant churn hotspot | `index.ts` 45.5 (accelerating) - 4× the next file |
|
||||
|
||||
Measurement note: `bash-token-classification.ts` reports the highest src CRAP (37.1, one function above threshold), but this is an artifact - `rejectNonPathToken` is a private helper, so `fallow` estimates 0% coverage and inflates its CRAP even though the module carries 43 dedicated unit tests.
|
||||
It is not a real finding and gets no step.
|
||||
|
||||
## Findings
|
||||
|
||||
The headline findings are coupling smells (Category C) - anemic behavior, mutable closure state, and relay-only dependency bags - that `fallow`'s complexity metrics under-weight but the composition-root and forwarding code make obvious.
|
||||
|
||||
| # | Finding | Category | Files | Impact | Risk | Priority |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ | ------ | ---- | -------- |
|
||||
| 1 | Anemic forwarding subsystem: the forwarding lifecycle has a class (`ForwardingManager`) but its behavior is three free functions (`confirmPermission`, `waitForForwardedPermissionApproval` 132 lines, `processForwardedPermissionRequests` 144 lines) that reach into a `PermissionForwardingDeps` bag (8 members). The bag is assembled in `index.ts` and re-synthesized in `PermissionPrompter.buildForwardingDeps()` with divergent values and a cluster of `eslint-disable unbound-method` lines | C: anemic / mutable closure state / relay-only deps | `forwarded-permissions/polling.ts`, `permission-prompter.ts`, `index.ts` | 5 | 3 | 15 |
|
||||
| 2 | ✅ Resolved ([#314]) - `tool-input-preview.ts` was a flat bag of 8 functions mixing prompt formatting (`format{Edit,Write,Read}InputForPrompt`, `getPromptPath`), text utilities (`truncateInlineText`, `countTextLines`, `formatCount`), and serialization (`serializeToolInputPreview`) - density 0.33, 6 dependents; `fallow`'s only refactoring target. Prompt formatters split into `tool-input-prompt-formatters.ts`; `tool-input-preview.ts` is no longer a refactoring target. | B: oversized / E: cohesion | `tool-input-preview.ts` | 4 | 2 | 16 |
|
||||
| 3 | 7.6% duplication, entirely in the test tree, is the largest single health deduction; the biggest clone families are `external-directory-integration.test.ts` (17 groups, 164 lines), `bash-path.test.ts` (9 groups, 120 lines), `runner.test.ts` (9 groups, 105 lines), and `tool-call.test.ts` (6 groups, 108 lines) | D: test duplication | `test/` (clone families) | 3 | 1 | 15 |
|
||||
| 4 | ✅ Resolved ([#318]) - `createMcpPermissionTargets` accumulated candidates through a `pushTarget` closure that mutated a local array and deduped via `includes` - every push site asked the array what it already held, then acted (Tell-Don't-Ask); mutable closure state with no owner. Replaced by the `McpTargetList` value object: `add` owns the null guard + dedup, `toArray` returns the ordered result; the per-mode branches now tell the list. | C: mutable closure state | `mcp-targets.ts` | 3 | 2 | 12 |
|
||||
| 5 | ✅ Resolved ([#320]) — `piPermissionSystemExtension` was a 206-line composition root (149 at original analysis, grown since). The two genuinely anemic constructs — the inline `permissionsService` literal and the `activateServiceForSession` + teardown closures — were promoted to `LocalPermissionsService` and `PermissionServiceLifecycle` (`ServiceLifecycle` interface). The established injection-bag construction (`PermissionSessionRuntimeDeps`, `PermissionPrompterDeps`, etc.) is legitimate wiring kept inline per the anti-procedure-splitting rule. `index.ts` now ~170 lines; the "< 100 lines" target was explicitly deferred as procedure-splitting. | C: adapter closure density / E: wiring overhead | `index.ts` | 4 | 3 | 12 |
|
||||
| 6 | ✅ `handleToolCall` hand-assembled a 7-member `GateRunnerDeps` closure bag. Investigation ([#319]) found it was really a relay (`checkPermission` + `getSessionRuleset`) plus four genuine roles (resolve, record, prompt, report). Decomposed into the relay collapse (`PermissionResolver`, [#319] ✅), a `DecisionReporter` ([#322] ✅), a `GateRunner` class injected with role collaborators ([#323] ✅), and `PermissionGateHandler` role-interface retyping ([#325]); planning #325 surfaced two preparatory refactors that shrink the handler first — unifying `handleInput` with the runner ([#326]) and extracting a `ToolCallGatePipeline` ([#327]) | C: relay-only dependencies | `handlers/permission-gate-handler.ts`, `handlers/gates/descriptor.ts` | 3 | 3 | 9 |
|
||||
|
||||
## Steps
|
||||
|
||||
1. ✅ **Split `tool-input-preview.ts` into cohesive modules** ([#314])
|
||||
- Target: `src/tool-input-preview.ts` (the sole `fallow` refactoring target).
|
||||
- Extracted the three prompt formatters plus `getPromptPath` into a new `src/tool-input-prompt-formatters.ts`; left the text utilities (`truncateInlineText`, `countTextLines`, `formatCount`), `serializeToolInputPreview`, and the three limit constants in `tool-input-preview.ts`.
|
||||
- Repointed the sole production consumer (`tool-preview-formatter.ts`) and relocated the moved functions' unit coverage into `test/tool-input-prompt-formatters.test.ts`; all four new exports are consumed, so `fallow` flags no dead re-export.
|
||||
- Smell category: B (oversized) / E (cohesion).
|
||||
- Outcome: `tool-input-preview.ts` dropped off the refactoring-target list; refactoring targets 1 → 0 (confirmed by `fallow health --targets`).
|
||||
|
||||
2. ✅ **Introduce a `PermissionForwarder` collaborator (own the state)** ([#315])
|
||||
- Target: new `src/forwarded-permissions/permission-forwarder.ts`; `forwarding-manager.ts`; `index.ts`.
|
||||
- Added a `PermissionForwarder` class exposing `requestApproval(ctx, message, options?, forwarded?)` and `processInbox(ctx)`; for this lift-and-shift step it holds the `PermissionForwardingDeps` bag privately (`shouldAutoApprove` supplied once at construction) and delegates to the existing `polling.ts` free functions, so behavior is unchanged.
|
||||
- Wired `ForwardingManager` to a narrow `InboxProcessor` seam (the manager only calls `processInbox`, mirroring the existing `ForwardingController` convention and dropping the test's `as unknown as` cast); constructed the single forwarder in `index.ts` and injected it.
|
||||
- Smell category: C (anemic domain model - give the forwarding behavior an owner).
|
||||
- Outcome: one forwarder instance replaces the threaded `index.ts` forwarding bag; `ForwardingManager` tells the forwarder instead of threading a deps bag.
|
||||
The bag interface itself is dismantled in [#317].
|
||||
|
||||
3. ✅ **Fold `PermissionPrompter.buildForwardingDeps()` into the injected forwarder** ([#316])
|
||||
- Target: `src/permission-prompter.ts`; `src/forwarded-permissions/permission-forwarder.ts`; `index.ts`.
|
||||
- Added the `ApprovalRequester` narrow seam (alongside `InboxProcessor`) to `permission-forwarder.ts`; narrowed `PermissionPrompterDeps` from 7 fields to 4 (removing `subagentSessionsDir`, `forwardingDir`, `registry`, `requestPermissionDecisionFromUi`); replaced the `confirmPermission(..., this.buildForwardingDeps(), ...)` call with `this.deps.forwarder.requestApproval(...)` and deleted `buildForwardingDeps()` and its `eslint-disable unbound-method` cluster; reordered `index.ts` to construct the single forwarder before the prompter and inject it.
|
||||
- Smell category: C (relay-only deps / duplicated bag construction).
|
||||
- Outcome: the forwarding dependency set is constructed exactly once; the prompter depends on a one-method interface instead of re-deriving a bag; `PermissionForwardingDeps` bag is dismantled in [#317].
|
||||
|
||||
4. ✅ **Remove `PermissionForwardingDeps`; inline the polling logic as forwarder methods** ([#317])
|
||||
- Target: `src/forwarded-permissions/polling.ts` → `permission-forwarder.ts` (sequence after Steps 2-3).
|
||||
- Added `PermissionForwarderDeps` (replaces `PermissionForwardingDeps`); dissolved the bag into individual `private readonly` fields on `PermissionForwarder`; inlined `waitForForwardedPermissionApproval` and `processForwardedPermissionRequests` as private methods reading `this`; extracted `buildForwardedRequest` (returns a value object), `pollForForwardedResponse` (owns the deadline loop + file cleanup), and `processSingleForwardedRequest` (per-request workflow) as focused private helpers; moved `getSessionId`, `getContextSystemPrompt`, `formatForwardedPermissionPrompt` to module-private functions (no external callers); deleted `polling.ts`; updated `index.ts` to import `PermissionForwarderDeps` from `permission-forwarder`; rewrote `permission-forwarder.test.ts` with real behavior tests (migrated from `permission-forwarding.test.ts`); removed stale `vi.mock` for polling from `runtime.test.ts`.
|
||||
- Smell category: C + B (the two god functions decompose as a consequence of the state having an owner).
|
||||
- Outcome: the 144-line and 132-line free functions became focused methods; `PermissionForwardingDeps` is gone; the forwarding subsystem is fully class-based (Track B complete).
|
||||
|
||||
5. ✅ **Introduce an `McpTargetList` value object** ([#318])
|
||||
- Target: `src/mcp-targets.ts`.
|
||||
- Added an exported `McpTargetList` class: `add(value)` owns the null/empty guard and the `includes` dedup (first-insertion wins); `toArray()` returns an independent ordered copy.
|
||||
Rewrote `createMcpPermissionTargets`, `pushMcpToolPermissionTargets`, and `addDerivedMcpServerTargets` to construct an `McpTargetList` and call `targets.add(...)` - the per-mode branches tell the list instead of asking the array.
|
||||
`McpTargetList` is exported and covered by direct unit tests (invariant: ignores null/empty, dedups, preserves order, `toArray` returns an independent copy).
|
||||
- Smell category: C (mutable closure state → value object that owns its invariant).
|
||||
- Outcome: the `pushTarget` closure and the `includes`-ask are gone; the uniqueness invariant lives in one owner; the per-mode dispatch reads as a sequence of tells; 6 new focused unit tests document the invariant in isolation (Track C complete for the accumulator).
|
||||
|
||||
6. ✅ **Introduce `PermissionResolver`; remove the session-rule relay** ([#319])
|
||||
- Target: `src/permission-resolver.ts` (new); `src/permission-session.ts`; the four gate descriptor factories (`path.ts`, `bash-path.ts`, `bash-external-directory.ts`, `bash-command.ts`); `handlers/gates/{descriptor,runner}.ts`; `handlers/permission-gate-handler.ts`.
|
||||
- `getSessionRuleset` was a pure relay - at every call site (the runner and every `describe*` gate) it only fed the next `checkPermission`.
|
||||
Collapsed the pair into a single `PermissionResolver.resolve(surface, input, agentName)` that `PermissionSession` implements; migrated all gates and the runner bag off the `(checkPermission, getSessionRuleset)` pair.
|
||||
`GateRunnerDeps` now `extends PermissionResolver`.
|
||||
- The original single-`GateRunnerContext` framing was rejected: a session-implemented interface would just re-expose the session ("glomming state").
|
||||
The bag is a relay plus four roles, decomposed across this step and two follow-ups.
|
||||
- Smell category: C (relay-only dependencies).
|
||||
- Outcome: the relay is gone from every gate; `getSessionRuleset` no longer appears in the gate-facing surface.
|
||||
The remaining roles are extracted in follow-ups - see step 7 (`DecisionReporter`, [#322] ✅) and steps 8-9 (`GateRunner`, [#323]; role-interface retyping, [#325]).
|
||||
|
||||
7. ✅ **Extract `DecisionReporter`; remove the review-log and decision-event closures** ([#322])
|
||||
- Target: `src/decision-reporter.ts` (new); `src/handlers/gates/descriptor.ts`; `src/handlers/gates/runner.ts`; `src/handlers/permission-gate-handler.ts`; `test/helpers/gate-fixtures.ts`; `test/handlers/gates/runner.test.ts`.
|
||||
- `writeReviewLog` and `emitDecision` were built as per-`handleToolCall` closures - a Law-of-Demeter reach-through (`session.logger.review`) and a bus-wrapping closure - then threaded into `GateRunnerDeps` as two flat members.
|
||||
Both fired by the runner (session-hit path, decision emit, `applyPermissionGate` callback) and the bypass branch; the same reach-through appeared again in `handleInput`.
|
||||
Extracted into a `DecisionReporter` interface + `GateDecisionReporter` class (owns `SessionLogger` + event bus); built once in `PermissionGateHandler`'s constructor and shared by `handleToolCall` and `handleInput`.
|
||||
`GateRunnerDeps` now carries `reporter: DecisionReporter` (replacing the two inline members); the runner and bypass branch fire through it.
|
||||
- Smell category: C (LoD violation + relay-only closure).
|
||||
- Outcome: the `writeReviewLog`/`emitDecision` closures are gone; two `unbound-method` eslint-disables removed; the event bus has a clear owner (`GateDecisionReporter`); `GateDecisionReporter` is directly unit-testable in isolation.
|
||||
|
||||
8. ✅ **Replace `GateRunnerDeps` with an injected `GateRunner` class** ([#323]) — **completed**
|
||||
- Target: `src/gate-prompter.ts` (new); `src/session-approval-recorder.ts` (new); `src/permission-session.ts`; `src/handlers/gates/runner.ts`; `src/handlers/gates/descriptor.ts`; `src/handlers/permission-gate-handler.ts`; `test/helpers/gate-fixtures.ts`; `test/handlers/gates/runner.test.ts`.
|
||||
- Added `GatePrompter` (`canConfirm()` + `promptPermission(details)`) and `SessionApprovalRecorder` role interfaces; `PermissionSession` implements both via stored-context adapters.
|
||||
`GateRunner` is constructed with `PermissionResolver`, `SessionApprovalRecorder`, `GatePrompter`, `DecisionReporter` and exposes `run(gate, agentName, toolCallId)` — absorbing the null/bypass/descriptor dispatch that previously lived in the handler's anonymous `runGate` closure.
|
||||
`PermissionGateHandler` constructs one `GateRunner` in its constructor and calls `runner.run(...)` per gate; the `runnerDeps` bag, the four collaborator closures, and the `runGate` closure are deleted.
|
||||
- Smell category: C (the bag's stable collaborators belong on a class, not threaded through a function).
|
||||
- Outcome: `GateRunnerDeps` is deleted; `runGateCheck` is deleted; the runner is a proper collaborator the handler constructs once and reuses; `makeRunnerDeps` replaced by `makeGateRunner({ runner, deps })` in `gate-fixtures.ts`.
|
||||
|
||||
9. **Unify `handleInput`'s skill-input gate with the `GateRunner` pipeline** ([#326])
|
||||
- Target: `src/handlers/permission-gate-handler.ts`; new `src/handlers/gates/skill-input.ts`; `src/denial-messages.ts`; `test/handlers/input*.test.ts`.
|
||||
- `handleInput` hand-rolls the `check → log → emit → approve` cycle that `GateRunner.runDescriptor` owns, with a nested eslint-disabled resolution ternary that duplicates `deriveResolution()` and direct reaches into `emitDecision` / `writeReviewLog` / `prompt` / `canPrompt` — the file's worst-CRAP function (79.4).
|
||||
Extract a `describeSkillInputGate(tcc, ...)` pure descriptor factory (mirroring `describeSkillReadGate`; `preCheck` preserving the raw `checkPermission` semantics), add a `skill_input` `DenialContext` kind, and run the descriptor through the shared `runner.run(...)`.
|
||||
- Deliberate change to settle in review: the skill-input deny messages gain the `[pi-permission-system]` tag (every other surface already carries it).
|
||||
- Smell category: A (duplication) / C (LoD reach-through).
|
||||
- Outcome: the inline gate, the nested ternary, and the direct reporter/prompter reaches are gone; `handleInput` becomes activate → resolveAgentName → describe → run; the handler's residual `PermissionSession` surface shrinks ahead of Step 11 ([#325]).
|
||||
|
||||
10. ✅ **Extract a `ToolCallGatePipeline` collaborator** ([#327])
|
||||
- Target: new `src/handlers/gates/tool-call-gate-pipeline.ts`; `src/handlers/permission-gate-handler.ts`; `src/permission-session.ts`; `src/index.ts`.
|
||||
- `handleToolCall` assembled six gate producers by reaching for anemic session getters (`getActiveSkillEntries`, `getInfrastructureDirs` + `getInfrastructureReadPaths`, `config`) — gate-construction work with no owner.
|
||||
Introduced `ToolCallGatePipeline` (constructed once in `index.ts`, injected into `PermissionGateHandler`) that owns bash-command extraction, the single `BashProgram.parse`, `ToolPreviewFormatter` construction, all six gate producers, and the run loop; `evaluate(tcc, runner)` returns the first block or allow.
|
||||
Applied Tell-Don't-Ask narrowings: `getInfrastructureReadDirs()` replaces the two-method reach + handler concat; `getToolPreviewLimits()` replaces `resolveToolPreviewLimits(session.config)`.
|
||||
Removed now-unused `getInfrastructureDirs()` / `getInfrastructureReadPaths()` from `PermissionSession`.
|
||||
- Smell category: C (anemic getters / missing collaborator).
|
||||
- Outcome: gate construction has an owner the handler tells; `handleToolCall` shrinks to activate → validate → build `tcc` → pipeline.evaluate → map outcome; the handler's residual `PermissionSession` surface ahead of Step 11 ([#325]) is `activate` + `resolveAgentName` plus the skill-input path's `checkPermission` + `createPermissionRequestId`.
|
||||
|
||||
11. ✅ **Retype `PermissionGateHandler` against narrow role interfaces** ([#325])
|
||||
- Target: new `src/gate-handler-session.ts`; `src/permission-session.ts`; `src/handlers/permission-gate-handler.ts`; `src/index.ts`; `test/helpers/handler-fixtures.ts`; `test/handlers/external-directory-integration.test.ts`; `test/handlers/external-directory-session-dedup.test.ts`.
|
||||
- The handler's constructor takes `session: PermissionSession` (concrete class, 36 public members); the `as unknown as PermissionSession` casts in every test mock disable TypeScript's structural check — the regression that prompted this (a mock missing `resolve()`) broke at runtime in [#319], not at `pnpm run check`.
|
||||
After Steps 9-10 ([#326], [#327]) the handler's residual session surface is four methods — `activate`, `resolveAgentName`, `checkPermission`, `createPermissionRequestId` — plus the `session.logger` read and the three roles passed to `GateRunner`.
|
||||
Introduced `GateHandlerSession` (those four methods, top-level `src/`, implemented by `PermissionSession`); injected the pre-built `GateRunner` (build `GateDecisionReporter` + `GateRunner` in `index.ts`) so the handler stops constructing collaborators and reaching `session.logger`, and dropped the `events` constructor param; retyped the three `makeSession` fixtures to the `MockGateHandlerSession` intersection using `vi.fn<T>()` and dropped the casts.
|
||||
- Planning surfaced three follow-ups that finish the arc: extract a `SkillInputGatePipeline` ([#329], which shrinks `GateHandlerSession` to a two-method context role), relocate `createPermissionRequestId` onto the request-creation collaborator ([#330]), and narrow `AgentPrepHandler` + `SessionLifecycleHandler` the same way ([#331]).
|
||||
- Smell category: C (concrete class dependency forces wide mocks; narrow interfaces enforce completeness at the type level).
|
||||
- Outcome: `as unknown as PermissionSession` casts are gone from the gate-handler mocks; the runner is injected, not built in the handler; a consumer calling a method the mock lacks fails at `pnpm run check`, not at runtime.
|
||||
|
||||
12. **✅ Extract a `SkillInputGatePipeline` collaborator** ([#329])
|
||||
- Target: new `src/handlers/gates/skill-input-gate-pipeline.ts`; `src/handlers/permission-gate-handler.ts`; `src/index.ts`; `test/handlers/input*.test.ts`.
|
||||
- `handleInput` hand-assembled the skill-input gate (raw `checkPermission` pre-check, deny notify, `describeSkillInputGate`, request-id mint, `runner.run`) — gate-construction work with no owner, asymmetric with the `tool_call` path's `ToolCallGatePipeline` ([#327]).
|
||||
Extracted `SkillInputGatePipeline` (constructed in `index.ts`, injected into `PermissionGateHandler`); reduced `handleInput` to activate → resolveAgentName → extract skill name → pipeline.evaluate → map outcome.
|
||||
- Smell category: C (missing collaborator).
|
||||
- Outcome: the `input` and `tool_call` paths are symmetric; `checkPermission` + `createPermissionRequestId` left the handler's session surface; `GateHandlerSession` collapsed to a two-method context role (`activate` + `resolveAgentName`).
|
||||
|
||||
13. **✅ Relocate `createPermissionRequestId` onto the request-creation collaborator** ([#330]) — folded into Step 12.
|
||||
- `createPermissionRequestId` moved into `SkillInputGatePipeline` as the module-level `createSkillInputRequestId()` helper; removed from `PermissionSession`.
|
||||
- Outcome: `PermissionSession` sheds a stateless utility; request-id creation lives next to request creation.
|
||||
|
||||
14. ✅ **Narrow `AgentPrepHandler` + `SessionLifecycleHandler` against role interfaces** ([#331])
|
||||
- Target: new `src/agent-prep-session.ts`; new `src/session-lifecycle-session.ts`; `src/gate-handler-session.ts`; `src/permission-session.ts`; `src/handlers/before-agent-start.ts`; `src/handlers/lifecycle.ts`; `test/handlers/before-agent-start.test.ts`; `test/handlers/lifecycle.test.ts`.
|
||||
- Both handlers took `session: PermissionSession` with `as unknown as PermissionSession` local mocks; the same structural smell [#325] removed from `PermissionGateHandler`.
|
||||
Introduced `AgentPrepSession` (extends `GateHandlerSession` + `SkillPermissionChecker`; adds 8 prep-specific methods) and `SessionLifecycleSession` (9-member role; intentionally omits `activate` — ISP); widened `GateHandlerSession.resolveAgentName` to accept an optional `systemPrompt` parameter so `AgentPrepHandler` reuses the shared context role without redefining it; `PermissionSession` adds both roles to its `implements` list with no method-body changes; retyped both local `makeSession` fixtures to the role with `vi.fn<T>()` per field and dropped the casts.
|
||||
- Smell category: C (concrete-class dependency forces wide mocks).
|
||||
- Outcome: no handler depends on the concrete `PermissionSession`; the last `as unknown as PermissionSession` casts in the handler test tree are gone; mock completeness is enforced at `pnpm run check` for all three handlers.
|
||||
|
||||
15. ✅ **Reframe the `index.ts` composition root as collaborator injection** ([#320])
|
||||
- Target: new `src/permissions-service.ts`; new `src/service-lifecycle.ts`; `src/handlers/lifecycle.ts`; `src/index.ts`.
|
||||
- Promoted the inline `permissionsService` literal to `LocalPermissionsService` (injected `PermissionManager` + `SessionRules` + `ToolInputFormatterRegistry`) and the `activateServiceForSession` + teardown closures to `PermissionServiceLifecycle` (implementing a narrow `ServiceLifecycle` interface); retyped `SessionLifecycleHandler` to take `ServiceLifecycle` instead of two raw callbacks.
|
||||
- The established injection-bag construction (`PermissionSessionRuntimeDeps`, `PermissionPrompterDeps`, `PermissionForwarderDeps`, command/RPC deps) was intentionally kept inline: relocating it into `buildX()` helpers would be pure statement relocation with no new collaborator — procedure-splitting per AGENTS.md.
|
||||
- Verified with `test/composition-root.test.ts`: handler registration, #302 child-gated service publish, and synchronous lifecycle subscription all unchanged.
|
||||
- Smell category: C (adapter closure density) / E (wiring overhead).
|
||||
- Outcome: `LocalPermissionsService` and `PermissionServiceLifecycle` provide testable homes for the two anemic inline constructs; `SessionLifecycleHandler` depends on a narrow two-method interface instead of raw callbacks; `index.ts` ~206 → ~170 lines.
|
||||
The "< 100 lines" target was explicitly deferred as procedure-splitting.
|
||||
|
||||
16. ✅ **Continue shared test-fixture extraction** ([#321]) — **completed**
|
||||
- Target: the four largest remaining clone families - `external-directory-integration.test.ts`, `bash-path.test.ts`, `runner.test.ts`, `tool-call.test.ts`.
|
||||
- Migrated all four families onto the existing `test/helpers/` fixtures; extended `gate-fixtures.ts` with `resolveResult` option on `makeGateRunner`, `makeDenialDescriptor`, and `makePathDispatchResolver`; extended `handler-fixtures.ts` with `makeSurfaceCheck`, `makeBashCommandCheck`, and the `tools` shortcut on `makeHandler`.
|
||||
- Smell category: D (test duplication).
|
||||
- Outcome: duplication 7.6% → 6.6%; clone groups 133 → 122.
|
||||
The <6% target was not fully reached; `external-directory-session-dedup.test.ts` carries a residual local-`makeSession` clone family that is outside the four-file scope — a follow-up issue will address it.
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
The forwarding collaborator is a lift-and-shift sequence: Step 2 introduces the class, Step 3 removes the duplicated bag, Step 4 inlines the logic and deletes the interface (introduce-new-alongside-old, remove-old-last).
|
||||
The gate-runner rework is a sequential extraction chain: Steps 6-8 are done; Steps 9-11 unify the input gate, extract the gate pipeline, and retype the handler against narrow interfaces, then Steps 12-14 are the [#325] follow-ups that finish the arc — each shrinks the handler's session surface before the next.
|
||||
Step 12 (`SkillInputGatePipeline`) depends on Step 11 and shrinks `GateHandlerSession` to a two-method context role; Step 13 relocates the request-id minter onto that pipeline; Step 14 narrows the remaining two handlers, reusing the context role (soft edge).
|
||||
Step 15 (composition root) depends on the forwarding collaborator (Steps 2-4) and the full gate-runner/handler rework (Steps 6-14); it is sequenced after Step 12 so the `SkillInputGatePipeline` already exists to be injected, rather than re-touching `index.ts` after the reframe.
|
||||
Step 16 is best sequenced after the production refactors whose tested call sites it touches (dashed edges) - those refactors are behavior-preserving, so the soft ordering only avoids re-migrating fixtures, it does not block.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["Step 1: Split tool-input-preview.ts (#314)"]
|
||||
S2["Step 2: Introduce PermissionForwarder (#315)"]
|
||||
S3["Step 3: Fold buildForwardingDeps into forwarder (#316)"]
|
||||
S4["Step 4: Remove PermissionForwardingDeps bag (#317)"]
|
||||
S5["Step 5: McpTargetList value object (#318)"]
|
||||
S6["Step 6: PermissionResolver, relay removal (#319)"]
|
||||
S7["Step 7: DecisionReporter extraction (#322)"]
|
||||
S8["Step 8: ✅ GateRunner class, role collaborators (#323)"]
|
||||
S9["Step 9: ✅ Unify handleInput with GateRunner (#326)"]
|
||||
S10["Step 10: ✅ Extract ToolCallGatePipeline (#327)"]
|
||||
S11["Step 11: ✅ PermissionGateHandler role-interface retyping (#325)"]
|
||||
S12["Step 12: Extract SkillInputGatePipeline (#329)"]
|
||||
S13["Step 13: Relocate createPermissionRequestId (#330)"]
|
||||
S14["Step 14: Narrow remaining handlers (#331)"]
|
||||
S15["Step 15: ✅ Composition root as collaborator injection (#320)"]
|
||||
S16["Step 16: ✅ Continue test-fixture extraction (#321)"]
|
||||
|
||||
S2 --> S3
|
||||
S3 --> S4
|
||||
S6 --> S7
|
||||
S7 --> S8
|
||||
S8 --> S9
|
||||
S8 --> S10
|
||||
S9 --> S11
|
||||
S10 --> S11
|
||||
S11 --> S12
|
||||
S12 --> S13
|
||||
S11 --> S14
|
||||
S12 -.-> S14
|
||||
S4 --> S15
|
||||
S11 --> S15
|
||||
S12 --> S15
|
||||
S1 -.-> S16
|
||||
S4 -.-> S16
|
||||
S5 -.-> S16
|
||||
S6 -.-> S16
|
||||
S15 -.-> S16
|
||||
```
|
||||
|
||||
## Tracks
|
||||
|
||||
| Track | Steps | Description |
|
||||
| -------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| A: Module cohesion | 1 | Split the `tool-input-preview.ts` bag (independent) |
|
||||
| B: Forwarding collaborator | 2 → 3 → 4 | Give the forwarding behavior a stateful owner; delete the duplicated bag (sequential lift-and-shift) |
|
||||
| C: State encapsulation | 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 | `McpTargetList` value object, the gate-runner collaborator rework (`PermissionResolver` → `DecisionReporter` → `GateRunner` → `handleInput` unification → `ToolCallGatePipeline` → role-interface retyping), and the #325 follow-ups (`SkillInputGatePipeline` → request-id relocation → narrowing the remaining handlers) |
|
||||
| D: Composition root | 15 | Reframe `index.ts` as collaborator injection (after Tracks B and C) |
|
||||
| E: Test duplication | 16 | Migrate the four largest clone families onto shared fixtures (best last) |
|
||||
|
||||
[#314]: https://github.com/gotgenes/pi-packages/issues/314
|
||||
[#315]: https://github.com/gotgenes/pi-packages/issues/315
|
||||
[#316]: https://github.com/gotgenes/pi-packages/issues/316
|
||||
[#317]: https://github.com/gotgenes/pi-packages/issues/317
|
||||
[#318]: https://github.com/gotgenes/pi-packages/issues/318
|
||||
[#319]: https://github.com/gotgenes/pi-packages/issues/319
|
||||
[#320]: https://github.com/gotgenes/pi-packages/issues/320
|
||||
[#321]: https://github.com/gotgenes/pi-packages/issues/321
|
||||
[#322]: https://github.com/gotgenes/pi-packages/issues/322
|
||||
[#323]: https://github.com/gotgenes/pi-packages/issues/323
|
||||
[#325]: https://github.com/gotgenes/pi-packages/issues/325
|
||||
[#326]: https://github.com/gotgenes/pi-packages/issues/326
|
||||
[#327]: https://github.com/gotgenes/pi-packages/issues/327
|
||||
[#329]: https://github.com/gotgenes/pi-packages/issues/329
|
||||
[#330]: https://github.com/gotgenes/pi-packages/issues/330
|
||||
[#331]: https://github.com/gotgenes/pi-packages/issues/331
|
||||
@@ -0,0 +1,182 @@
|
||||
# Phase 4: Constructibility and god-object decomposition
|
||||
|
||||
Goal: make the core collaborators independently constructable, then split the two god objects (`ExtensionRuntime`, `PermissionSession`) they hide behind.
|
||||
|
||||
The entry into this phase is the test tree, but the test tree is a symptom, not the disease.
|
||||
`fallow` reports the production code is "clean" (avg cyclomatic 1.4, p90 2, zero complexity targets, zero dead code, zero production duplication) — but `fallow`'s syntactic metrics do not measure constructibility, closure density, injection seams, or a god object hiding behind narrow role interfaces.
|
||||
Reading the tests as evidence of how hard the production code is to use reveals the real findings: collaborators that cannot be `new`-ed in isolation, a mutable runtime god object threaded through free functions, and a single 351-line class that implements six interfaces and is passed to one constructor three times.
|
||||
|
||||
The lens for this phase is constructibility: "why does this test need `vi.mock` of a module / a 17-field fixture / an `as unknown as` cast, and which production object is too hard to build because of it?".
|
||||
The test-tree cleanup from the first draft (retiring the `permission-system.test.ts` catch-all, de-duplicating clone families, splitting oversized arrows) is folded in at the tail as a *measured consequence* of the production refactor, not the goal — most of the duplication and fixture weight dissolves once the collaborators are injectable.
|
||||
Phase 4 is independent of any open feature issue — it is a pure structural round.
|
||||
|
||||
This phase deliberately revisits the Phase 3 approach: Phase 3 applied Interface Segregation to the *interfaces* (six narrow role interfaces) but not to the *object* (one class implements all six).
|
||||
Phase 4 splits the object so each role maps to a distinct collaborator, then retires the fig-leaf interfaces that no longer earn their keep.
|
||||
|
||||
## Current health metrics
|
||||
|
||||
`fallow`'s structural metrics (left) say the production code is healthy; the constructibility metrics (right) — which `fallow` does not score — tell the real story.
|
||||
|
||||
| Metric | Value |
|
||||
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Health score | 76 B |
|
||||
| LOC | 37,151 |
|
||||
| Dead files / exports | 0% |
|
||||
| Avg cyclomatic / p90 | 1.4 / 2 |
|
||||
| Maintainability | 91.2 (good) |
|
||||
| Complexity refactoring targets | 0 |
|
||||
| Production duplication | 0% (no `src/` clone groups) |
|
||||
| `index.ts` closures + `.bind` adapters | 10 (was 11; `canRequestPermissionConfirmation` removed by #339) |
|
||||
| `runtime`-as-first-arg free functions | 0 (all eliminated by #335–#337) |
|
||||
| `PermissionSession` role interfaces implemented by one class | 0 handler fig-leaf roles (`GateHandlerSession` / `AgentPrepSession` / `SessionLifecycleSession` retired by #341; the class now `implements ToolCallGateInputs` only — a genuine pipeline-input contract, not a one-class fig leaf) |
|
||||
| Test files using module-level `vi.mock` | 23 |
|
||||
| `as unknown as` casts in `test/` | ~31 (3× `PermissionManager`, 1× `SessionRules`; prompting casts removed by #339) |
|
||||
| Test duplication | 2,505 lines across 41 files — 3.4% (`dupes`) / 6.6% (health basis) |
|
||||
| Very-high functions (>60 LOC) | 5% — all in `test/` |
|
||||
|
||||
Health-score deductions: hotspots -10.0 · unit size -10.0 · coupling -2.4 · duplication -1.6.
|
||||
|
||||
Measurement note: the dominant production hotspots — `permission-gate-handler.ts` (42.3, accelerating) and `index.ts` (37.3, accelerating) — are not benign churn.
|
||||
`index.ts` is the closure-bag composition root this phase dismantles (Finding 4); its churn reflects the wiring friction directly.
|
||||
The hotspot deduction is expected to fall once the closure bags collapse into object references.
|
||||
|
||||
## Findings
|
||||
|
||||
The headline findings are coupling and constructibility smells (Category C): a god object that constructs its own collaborators (DIP violation), a second god object built by a mutable factory, six interfaces over one class, and a closure-bag composition root that is a *consequence* of the first three.
|
||||
Each is grounded in the specific test pain it forces.
|
||||
|
||||
| # | Finding | Category | Files | Impact | Risk | Priority |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------- | ------ | ---- | -------- |
|
||||
| 1 | `PermissionSession` constructs its own `PermissionManager` (DIP violation): the constructor, `resetForNewSession()`, and `reload()` all call the free function `createPermissionManagerForCwd(...)` — the manager is never injected. Test cost: `permission-session.test.ts` must `vi.mock("../src/runtime")` to stub the factory and route a `{...} as unknown as PermissionManager` mock through it; the object cannot be `new`-ed with a test double. | C: anemic / DIP violation | `permission-session.ts`, `runtime.ts` | 5 | 3 | 15 |
|
||||
| 2 | ~~`PermissionSession` god object behind six interfaces~~ ✓ addressed by #339–#341: the prompting role moved to `PromptingGateway` (#339), the resolve role to `PermissionResolver` (#340), and the recorder role to `SessionRules`; the three fig-leaf handler interfaces (`GateHandlerSession` / `AgentPrepSession` / `SessionLifecycleSession`) were retired (#341). `PermissionSession` is now a state/lifecycle owner that `implements ToolCallGateInputs` only; `GateRunner(resolver, recorder, prompter, reporter)` receives three distinct collaborators; the 17-field `makeSession` intersection mock is gone — handler tests build a real `PermissionSession` + `PermissionResolver` from per-collaborator fakes (`test/helpers/session-fixtures.ts`). | C: god object / ISP applied to interface not object | `permission-session.ts`, `handler-fixtures.ts` | 5 | 4 | 10 |
|
||||
| 3 | ~~`ExtensionRuntime` god object~~ ✓ addressed by #335–#337: `ConfigStore` owns config (#335); logger is injectable (#336); `runtime.ts` deleted and `index.ts` constructs `ExtensionPaths` + `PermissionManager` + `SessionRules` + `ConfigStore` + logger directly (#337). The split-brain (gate and RPC reading different `PermissionManager`/`SessionRules` instances) is closed; `as unknown as ExtensionRuntime` casts are gone; `runtime`-arg free functions eliminated. | C: mutable closure state / forward reference / split-brain state | ~~`runtime.ts`~~, `index.ts` | 4 | 4 | 8 |
|
||||
| 4 | `index.ts` is 20 closures + `.bind` adapters — a *consequence* of Findings 1-3: `() => runtime.config` (×4) exists because `config` is mutable shared state needing live reads; `runtime.writeReviewLog.bind(runtime)` (×3, duplicated in `forwardingDeps`) exists because the logging ops are free functions; `(ctx) => refreshExtensionConfig(runtime, ctx)` wraps each runtime free-function. These collapse to plain object references once the runtime ops become methods and config becomes a store with `current()`. | C: adapter closure density / E: wiring overhead | `index.ts` | 4 | 3 | 12 |
|
||||
| 5 | Test-tree symptoms (folded in at the tail as measured consequence): the 2,785-line `permission-system.test.ts` catch-all (12 clone groups), 2,505 lines of test duplication, the residual `makeSession` clone in `external-directory-session-dedup.test.ts` ([#321] deferral), and the oversized `describe` arrows. Most of the fixture weight and `vi.mock` count is downstream of Findings 1-3 and shrinks as they land; what remains (the monolith carve) gets a dedicated trailing step. | D: test duplication / E: test organization | `test/permission-system.test.ts`, `test/` clone families | 3 | 1 | 15 |
|
||||
|
||||
## Steps
|
||||
|
||||
The nine steps are filed as [#334]–[#342].
|
||||
Production first (Steps 1-8), then the test-cleanup tail (Step 9).
|
||||
Each step is a behavior-preserving refactor that leaves the suite green; the success metric is the constructibility table above moving toward zero, observed as fewer `vi.mock` module stubs, smaller fixtures, and dropped casts.
|
||||
|
||||
1. **Inject a single `PermissionManager` into `PermissionSession`** ([#334]) ✓ complete
|
||||
- Target: `permission-manager.ts` (add `configureForCwd(cwd)`); `permission-session.ts` constructor + `resetForNewSession` + `reload`; `index.ts`.
|
||||
- `PermissionSession` holds one injected `PermissionManager` and calls `configureForCwd(ctx.cwd)` once at `session_start`, instead of constructing a new manager via the `createPermissionManagerForCwd` free function on every lifecycle event; tests pass a real or fake manager directly.
|
||||
- The per-call reconstruction implied the project cwd can change across a session; it cannot (verified against Pi core — `AgentSession._cwd` and `ExtensionRunner.cwd` are each assigned once and never reassigned; `/reload` re-emits `session_start` with the same cwd).
|
||||
The instance-swapping is dead generality; the extension just does not learn cwd until `session_start`.
|
||||
- Smell category: C (DIP violation — addresses Finding 1).
|
||||
- Outcome: `vi.mock("../src/runtime")` and `as unknown as PermissionManager` leave `permission-session.test.ts`; the manager is a single injected, substitutable collaborator — no `Factory` class.
|
||||
|
||||
2. **Extract a `ConfigStore` from the runtime free-functions** ([#335]) ✓ complete
|
||||
- Target: new `src/config-store.ts` class owning `config` + `lastConfigWarning` with `current()` / `refresh(ctx?)` / `save(next, ctx)` / `logResolvedPaths()`; convert `refreshExtensionConfig` / `saveExtensionConfig` / `logResolvedConfigPaths` from `(runtime, …)` free functions into methods.
|
||||
- Consumers hold the store and call `store.current()` instead of capturing `() => runtime.config`.
|
||||
- Smell category: C (mutable shared state → owner — addresses Finding 3, part 1).
|
||||
- Outcome: 4× `() => runtime.config` closures and 3× runtime-arg config free-functions are gone; config has one owner.
|
||||
|
||||
3. **Make the logger injectable; drop `createSessionLogger(runtime)`** ([#336]) ✓ complete
|
||||
- Target: `src/session-logger.ts`, `src/logging.ts`, `index.ts`.
|
||||
- Construct the logger from `ExtensionPaths` + the `ConfigStore` (debug toggle) + a narrow notify sink — not the whole runtime; remove the `runtime.writeDebugLog` / `runtime.runtimeContext?.ui.notify` reach-through.
|
||||
- Smell category: C (Law-of-Demeter reach-through — addresses Finding 3, part 2).
|
||||
- Outcome: no module takes the whole `ExtensionRuntime` for logging; the duplicated `.bind(runtime)` logging adapters disappear.
|
||||
|
||||
4. **Dissolve `ExtensionRuntime`; one source of truth for session state** ([#337]) ✓ complete
|
||||
- Target: `runtime.ts`, `index.ts`, `permission-event-rpc.ts`, `config-modal.ts`.
|
||||
- Remove the god runtime object; point the config-modal and RPC handlers at the *same* `PermissionManager` / `SessionRules` the gate handlers use (fixing the stale-manager / empty-session-rules split-brain), backed by the `ConfigStore` + `ExtensionPaths` + `PermissionSession`.
|
||||
- Smell category: C (split-brain state — addresses Finding 3, part 3).
|
||||
- Outcome: `as unknown as ExtensionRuntime` is gone; the deprecated RPC check and the gate path read the same session rules.
|
||||
- Also injects `SessionRules` into `PermissionSession` (constructor now has 7 params) and retires `RuntimeContextRef` from `ConfigStore`.
|
||||
|
||||
5. **Collapse the `index.ts` closure bags into object references** ([#338]) ✓ complete
|
||||
- Target: `index.ts`; the deps interfaces on `PermissionPrompter`, `PermissionSession`, the command, and the RPC handlers.
|
||||
- With Steps 2-4 done, replace the remaining `() =>`/`.bind` adapters with direct collaborator references and shrink the deps bags; verify via `test/composition-root.test.ts`.
|
||||
- Smell category: C/E (adapter closure density — addresses Finding 4).
|
||||
- Outcome: `index.ts` closures 20 → 11.
|
||||
Permanent floor: 6 `pi.on` handlers + 2 `toolRegistry` adapters + 2 logger forward-reference cycle closures (`getConfig`/`notify`; idiomatic; see pi-subagents pattern).
|
||||
Transitional: 1 `canRequestPermissionConfirmation` closure removed by Step 6.
|
||||
|
||||
6. **Extract a context-owning `PromptingGateway`; collapse the prompt twins** ([#339]) ✓ complete
|
||||
- Target: new `src/prompting-gateway.ts`; `permission-session.ts`; `handlers/gates/runner.ts`; `index.ts`.
|
||||
- Move the stored context + `canConfirm()` / `prompt(details)` into one collaborator; `GateRunner` receives the gateway for the prompting role.
|
||||
The `canPrompt(ctx)`/`canConfirm()` and `prompt(ctx, details)`/`promptPermission(details)` twins collapse to a single context-bound pair.
|
||||
- Smell category: C (god object split — addresses Finding 2; depends on Step 1).
|
||||
- Outcome: the prompting role is a distinct object; `makeSession` sheds its prompt-delegation closures and the `undefined as unknown as ExtensionContext` casts.
|
||||
|
||||
7. **Extract a `PermissionResolver` collaborator out of `PermissionSession`** ([#340]) ✓ complete
|
||||
- Target: `src/permission-resolver.ts` (promote to a concrete class holding the `PermissionManager` + `SessionRules`); `permission-session.ts`; `index.ts`.
|
||||
- The resolver owns `resolve` / `checkPermission` / `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp`; `PermissionSession` no longer plays the resolver role.
|
||||
- Smell category: C (god object split — addresses Finding 2; depends on Step 1).
|
||||
- Outcome: the resolution role is a distinct object directly unit-testable without a session fixture.
|
||||
|
||||
8. **Slim `PermissionSession` to a state/lifecycle owner; unwind the fig-leaf interfaces** ([#341]) ✓ complete
|
||||
- Target: `permission-session.ts`; `gate-handler-session.ts`; `agent-prep-session.ts`; `session-lifecycle-session.ts`; the three handlers; `handler-fixtures.ts`.
|
||||
- With prompting and resolution extracted (Steps 6-7), retire or merge the `GateHandlerSession` / `AgentPrepSession` / `SessionLifecycleSession` interfaces that were one-class fig leaves; handlers depend on the distinct collaborators. `GateRunner` now receives three *different* objects.
|
||||
- Smell category: C (ISP applied to the object, not just the interface — addresses Finding 2; depends on Steps 6-7).
|
||||
- Outcome: `GateRunner(session, session, session, …)` becomes `GateRunner(resolver, recorder, prompter, …)`; the 17-field `makeSession` fixture splits into small per-collaborator fixtures or disappears.
|
||||
|
||||
9. **Retire the `permission-system.test.ts` catch-all (test-cleanup tail)** ([#342]) ✓ complete
|
||||
- Target: `test/permission-system.test.ts`; the co-located destination files.
|
||||
- Redistribute the ~80 flat tests into the existing co-located files (`yolo-mode`, `system-prompt-sanitizer`, `permission-manager-unified`, `scope-merge`, the external-directory suite, `session-rules`, …) now that the collaborators are independently constructable; delete the emptied shell.
|
||||
- Smell category: D/E (test organization — the part of Finding 5 the production refactor does not auto-resolve).
|
||||
- Outcome: the 2,785-line monolith and its 12 clone groups are gone; the suite is fully co-located.
|
||||
|
||||
Expected phase outcome: the constructibility table moves toward zero — `index.ts` closures 20 → 11 (Steps 1-5) → 10 (Step 6), `runtime`-arg free functions 5 → 0, `PermissionSession` interfaces 6 → 1-2 on distinct objects, the `../src/runtime` / `../src/permission-manager` module mocks removed, the `PermissionManager` / `ExtensionRuntime` / `SessionRules` casts → 0; `permission-system.test.ts` deleted; test duplication falls as a consequence; health score 76 → target ≥ 80.
|
||||
|
||||
Deferred to Phase 5 (the "Full" scope exceeds 9 steps): further `PermissionSession` decomposition (an `ActiveAgentTracker` for agent-name state, a cache-key owner, an infra-path/preview-limits helper), and the remaining test-tree cleanup from the first draft that the production refactor does not dissolve — de-duplicating the residual clone families (`external-directory-integration`, `permission-forwarder`, the gate families) onto shared fixtures and splitting the oversized `describe` arrows (`bash-external-directory.test.ts` 880-line, `permission-session.test.ts` 575-line).
|
||||
These are intentionally last: they are cheaper after Steps 1-8 shrink the fixtures they would otherwise migrate.
|
||||
|
||||
Phase 5 candidate — dissolve the logger `notify` cycle via the event bus: route the logger's IO-failure and `warn()` warnings through a `pi.events` channel (mirroring the existing `emitUiPromptEvent` pub-sub) instead of reaching `session.getRuntimeContext().ui.notify`.
|
||||
The logger then depends only on the bus (available at construction), breaking the logger ↔ `PermissionSession` forward-reference cycle that [#338] leaves in place; the dedup `Set` stays on the emit side.
|
||||
This is pub-sub, not the in-process Observer (`SubagentManagerObserver`) pattern pi-subagents uses — a directly-injected observer would reintroduce the cycle because the logger is constructed before any context-bearing collaborator.
|
||||
The logger ↔ `ConfigStore` `getConfig` cycle is deliberately not a candidate: the logger must exist before the store yet needs live toggle reads, so the forward-reference closure is cheaper than any untangling (a push model would require the setter the composition root avoids).
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
Two production tracks run in parallel after Step 1, joined at the composition root and the test tail.
|
||||
Track B (de-god the runtime) is the sequential chain `ConfigStore → logger → dissolve runtime → collapse index.ts closures`.
|
||||
Track C (split the session) is `PromptingGateway` + `PermissionResolver` (both after Step 1, parallel) → slim the session and unwind the interfaces.
|
||||
Step 5 and Step 8 both finalize `index.ts` wiring, so Step 8 is sequenced after Step 5 to avoid overlapping edits.
|
||||
Step 9 (test tail) depends on the full production refactor — the collaborators must be constructable before the monolith's tests redistribute cleanly.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["Step 1: Inject single PermissionManager (#334)"]
|
||||
S2["Step 2: Extract ConfigStore (#335)"]
|
||||
S3["Step 3: Make logger injectable (#336)"]
|
||||
S4["Step 4: Dissolve ExtensionRuntime (#337)"]
|
||||
S5["Step 5: Collapse index.ts closures (#338)"]
|
||||
S6["Step 6: Extract PromptingGateway (#339)"]
|
||||
S7["Step 7: Extract PermissionResolver (#340)"]
|
||||
S8["Step 8: Slim PermissionSession, unwind interfaces (#341)"]
|
||||
S9["Step 9: Retire permission-system.test.ts (#342)"]
|
||||
|
||||
S1 --> S6
|
||||
S1 --> S7
|
||||
S2 --> S3
|
||||
S3 --> S4
|
||||
S4 --> S5
|
||||
S6 --> S8
|
||||
S7 --> S8
|
||||
S5 --> S8
|
||||
S5 --> S9
|
||||
S8 --> S9
|
||||
```
|
||||
|
||||
## Tracks
|
||||
|
||||
| Track | Steps | Description |
|
||||
| ----------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| A: Injection foundation | 1 | Inject one `PermissionManager` (configured once at `session_start`) so `PermissionSession` is constructable with a test double (unblocks Tracks B and C) |
|
||||
| B: De-god the runtime | 2 → 3 → 4 → 5 | `ConfigStore` → injectable logger → dissolve `ExtensionRuntime` → collapse the `index.ts` closure bags |
|
||||
| C: Split the session | 6, 7 → 8 | Extract `PromptingGateway` + `PermissionResolver` (parallel after Step 1), then slim `PermissionSession` and unwind the fig-leaf interfaces |
|
||||
| D: Test-cleanup tail | 9 | Retire the `permission-system.test.ts` catch-all once collaborators are constructable (measured consequence) |
|
||||
|
||||
[#321]: https://github.com/gotgenes/pi-packages/issues/321
|
||||
[#334]: https://github.com/gotgenes/pi-packages/issues/334
|
||||
[#335]: https://github.com/gotgenes/pi-packages/issues/335
|
||||
[#336]: https://github.com/gotgenes/pi-packages/issues/336
|
||||
[#337]: https://github.com/gotgenes/pi-packages/issues/337
|
||||
[#338]: https://github.com/gotgenes/pi-packages/issues/338
|
||||
[#339]: https://github.com/gotgenes/pi-packages/issues/339
|
||||
[#340]: https://github.com/gotgenes/pi-packages/issues/340
|
||||
[#341]: https://github.com/gotgenes/pi-packages/issues/341
|
||||
[#342]: https://github.com/gotgenes/pi-packages/issues/342
|
||||
@@ -0,0 +1,132 @@
|
||||
# Phase 5: Tell-Don't-Ask and decoupling sweep
|
||||
|
||||
Goal: clear the residual state-encapsulation and decoupling smells that Phase 4 left behind — factory closures over mutable state, a composition-root forward-reference cycle, anemic getter/setter pairs a handler orchestrates by hand, Law-of-Demeter reach-throughs, and concrete-class dependencies that force test casts.
|
||||
|
||||
Phase 4 converted essentially every mutable-state-and-closures bag into a state-owning class, so Phase 5 is deliberately narrow.
|
||||
A targeted sweep for Tell-Don't-Ask violations turned up seven findings, and they were the only genuine state-encapsulation and decoupling work left.
|
||||
The phase does not touch `bash-program.ts` (pure AST parsing — splitting it produces free-function modules, not state-owning behavior) or reframe `Ruleset` (that would be a value object, and it would fight the intentional pure-evaluation design principle).
|
||||
|
||||
`fallow` reports a clean syntactic surface (health 76, 0% dead files, 0% reported dead exports, avg cyclomatic 1.4, no refactoring targets), which is exactly why these findings matter: they are structural smells `fallow` cannot see — a mutable Set hidden in a closure, a `null`-init cast papering over a construction cycle, an anemic accessor quartet a handler drives via ask-then-tell, a relay-only field reached through, and concrete-class constructor types that force `as unknown as` casts in tests.
|
||||
|
||||
## Findings summary
|
||||
|
||||
| Metric | Phase 5 baseline | Phase 5 target |
|
||||
| ------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| Health score | 76 (B) | ≥ 76 (structural, not score-driven) |
|
||||
| Production `as unknown as` casts | 3 (`index.ts` ×1, `config-store.ts` ×2 serialization) | 2 (serialization only) |
|
||||
| Factory closures over mutable state | 1 (`createSessionLogger`) | 0 |
|
||||
| Forward-reference `null`-init holders in `index.ts` | 2 (`configStore`, `sessionNotify`) | 0 |
|
||||
| Anemic cache accessors on `PermissionSession` | 4 methods over 2 fields | 0 (2 owned `CacheKeyGate` sub-objects) |
|
||||
| Ask-then-tell pairs in `AgentPrepHandler` | 2 | 0 |
|
||||
| Test-only-alive exports | 1 (`shouldApplyCachedAgentStartState`) | 0 |
|
||||
| `PermissionSession` constructor arity | 7 positional args | 6 (relay-only `logger` dropped) |
|
||||
| `session.logger` / `session.getRuntimeContext()?.ui` reach-throughs | 5 (1 notify sink, 3 lifecycle logger, 1 reporter wiring) | 0 |
|
||||
| `config-modal` controller reach-throughs | 1 (`permissionManager` + `session.lastKnownActiveAgentName`) | 0 |
|
||||
| `LocalPermissionsService` concrete-class deps | 3 | 0 (narrow interfaces) |
|
||||
| Test `as unknown as` casts removed | — | −8 (3 service + 5 forwarder ctx) → −8 more (8 `ExtensionContext` ctx; #367) = −16 total; 4 remain |
|
||||
|
||||
Unchanged guardrails: 0% dead code, avg cyclomatic 1.4, maintainability 91.1, no new public surface.
|
||||
|
||||
## Steps
|
||||
|
||||
The seven steps are filed as [#362]–[#368].
|
||||
Each is a behavior-preserving refactor that leaves the suite green; the success metric is the table above moving toward zero, observed as fewer production casts, dropped forward-reference holders, and fewer forced test casts.
|
||||
|
||||
### Track A — logger state + PermissionSession/composition-root coupling (serial)
|
||||
|
||||
The composition-root forward-reference cycle existed *because* the logger needed late-bound config-reading and UI-notify capability, and the `logger` field on `PermissionSession` was relayed straight back out — so these three landed in order: make the logger a state-owning class, dissolve the cycle, then drop the relay-only field.
|
||||
|
||||
1. **Convert `createSessionLogger` into a `SessionLogger` class** ([#362]) ✓ complete
|
||||
- Target: `src/session-logger.ts` — the `createSessionLogger` factory that returned an object literal closing over a mutable `reported: Set<string>` (IO-failure-warning dedup) and the writer.
|
||||
- Smell: Category C (mutable closure state) — a bag of state + closures masquerading as a factory.
|
||||
- Outcome: a `SessionLogger` class that privately owns `reported` and the writer and exposes `debug` / `review` / `warn`; constructed as `new SessionLogger(deps)`; no factory-closure mutable state remains.
|
||||
|
||||
2. **Add `PermissionSession.notify()` and dissolve the `index.ts` forward-reference cycle** ([#363]) ✓ complete
|
||||
- Target: `src/permission-session.ts` (new `notify(message)` Tell-Don't-Ask method over the owned context); `src/index.ts` (removed `let configStore = null as unknown as ConfigStore` and the `let sessionNotify` holder, wiring the logger's notify sink as `(m) => session.notify(m)`).
|
||||
- Smell: Category C (forward references + the only production `as unknown as` cast + the `getRuntimeContext()?.ui.notify` Law-of-Demeter reach-through).
|
||||
- Outcome: production `as unknown as` casts 3 → 2; `index.ts` has no `null`-init holders; the UI-notify reach-through became a single tell to the context-owning session.
|
||||
- Depended on Step 1 (the logger reshape that lets construction order resolve without the cast).
|
||||
|
||||
3. **Inject `logger` directly into the lifecycle handler and reporter; drop the relay-only field** ([#364]) ✓ complete
|
||||
- Target: `src/permission-session.ts` (removed the `readonly logger` constructor parameter — never read internally, only relayed — taking the constructor from 7 args to 6); `src/handlers/lifecycle.ts` (accept a `SessionLogger` and call `this.logger.warn/debug` instead of `this.session.logger`); `src/index.ts` (pass the composition-root `logger` to `new GateDecisionReporter(logger, …)` and `new SessionLifecycleHandler(session, resolver, serviceLifecycle, logger)`).
|
||||
- Smell: Category C (relay-only dependency / Law-of-Demeter reach-through — the handler talked to `session.logger`, a stranger reached through the session).
|
||||
- Outcome: `PermissionSession` no longer exposes `logger`; the three lifecycle reach-throughs and the one reporter-wiring reach-through are gone; the constructor narrowed to 6 args.
|
||||
- Depended on Step 2 (shares edits to `permission-session.ts` and `index.ts`; serialized to avoid conflicts).
|
||||
|
||||
### Track B — anemic cache-key state (independent)
|
||||
|
||||
4. **Encapsulate agent-start cache keys in a `CacheKeyGate` class** ([#365]) ✓ complete
|
||||
- Target: `src/permission-session.ts` (replaced the four anemic methods — `shouldUpdateActiveTools` / `commitActiveToolsCacheKey` / `shouldUpdatePromptState` / `commitPromptStateCacheKey` — and their two `string | null` fields with two `CacheKeyGate` instances); `src/handlers/before-agent-start.ts` (collapsed the two ask-then-tell pairs into `gate.runIfChanged(key, effect)`); `src/before-agent-start-cache.ts` (removed the dead-in-production `shouldApplyCachedAgentStartState` and folded its comparison into `CacheKeyGate`).
|
||||
- Smell: Category C (anemic domain / ask-then-tell — the handler asked "should I update?"
|
||||
then told "commit") plus Category A (a redundant export kept alive only by its own test, which is why `fallow`'s 0%-dead-exports missed it).
|
||||
- Outcome: a `CacheKeyGate` class owning a previous key and exposing `runIfChanged(nextKey, effect)`; `PermissionSession`'s four cache methods became two owned sub-objects; the handler's ask-then-tell pairs became single tells; one source of truth for the key comparison; the test-only-alive free function is gone.
|
||||
|
||||
### Track C — narrow-interface decoupling for testability (independent)
|
||||
|
||||
5. **Narrow `LocalPermissionsService` collaborators to interfaces** ([#366]) ✓ complete
|
||||
- Target: `src/permissions-service.ts` — the constructor typed the concrete `PermissionManager`, `SessionRules`, and `ToolInputFormatterRegistry` but only called `checkPermission` / `getToolPermission`, `getRuleset`, and `register`.
|
||||
- Smell: Category C (DIP — depending on concrete classes) / Category D (testability — concrete-class types expose private members, so `permissions-service.test.ts` was forced into `as unknown as` casts).
|
||||
- Outcome: depends on the existing `ScopedPermissionManager`, `Pick<SessionRules, "getRuleset">`, and a `{ register }` formatter interface; the three `as unknown as` casts in `permissions-service.test.ts` disappeared and mocks became plain objects.
|
||||
|
||||
6. **Narrow `PermissionForwarder`'s context dependency to a local interface** ([#367]) ✓ complete
|
||||
- Target: `src/forwarded-permissions/permission-forwarder.ts` — methods took the full SDK `ExtensionContext` rather than a narrow local interface of the fields actually read.
|
||||
- Smell: Category C (platform-type threading) / Category D (testability).
|
||||
- Outcome: the five `as unknown as ExtensionContext` casts in `permission-forwarder.test.ts` (the single biggest cluster of the 12 such casts across 7 test files) disappeared; a bounded down-payment on the systemic ctx-threading pattern.
|
||||
|
||||
### Track D — slash-command reach-through (independent)
|
||||
|
||||
7. **Remove the `config-modal` controller reach-through** ([#368]) ✓ complete
|
||||
- Target: `src/config-modal.ts` — the `show` handler chained `controller.permissionManager.getComposedConfigRules(controller.session.lastKnownActiveAgentName ?? undefined)`, reaching through the controller bag to two strangers.
|
||||
- Smell: Category C (Law-of-Demeter reach-through).
|
||||
- Outcome: collapsed the controller's `permissionManager` + `session` fields into a single `getActiveAgentConfigRules()` accessor wired in the composition root, so the command tells one collaborator; the `PermissionSession.lastKnownActiveAgentName` getter is no longer consumed via object-literal wiring (retiring the `fallow` false-positive suppression).
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["Step 1: SessionLogger class (#362)"]
|
||||
S2["Step 2: PermissionSession.notify + dissolve index.ts cycle (#363)"]
|
||||
S3["Step 3: inject logger; drop relay-only field (#364)"]
|
||||
S4["Step 4: CacheKeyGate for agent-start cache keys (#365)"]
|
||||
S5["Step 5: narrow LocalPermissionsService collaborators (#366)"]
|
||||
S6["Step 6: narrow PermissionForwarder context (#367)"]
|
||||
S7["Step 7: remove config-modal reach-through (#368)"]
|
||||
|
||||
S1 --> S2 --> S3
|
||||
|
||||
subgraph TrackA["Track A — logger state + composition-root coupling (serial)"]
|
||||
S1
|
||||
S2
|
||||
S3
|
||||
end
|
||||
|
||||
subgraph TrackB["Track B — anemic cache-key state"]
|
||||
S4
|
||||
end
|
||||
|
||||
subgraph TrackC["Track C — narrow-interface decoupling"]
|
||||
S5
|
||||
S6
|
||||
end
|
||||
|
||||
subgraph TrackD["Track D — slash-command reach-through"]
|
||||
S7
|
||||
end
|
||||
```
|
||||
|
||||
## Tracks
|
||||
|
||||
| Track | Steps | Description |
|
||||
| ---------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| A: logger state + composition-root coupling (serial) | 1 → 2 → 3 | Make the logger a state-owning class, dissolve the `index.ts` forward-reference cycle, then drop the relay-only `logger` field from `PermissionSession` |
|
||||
| B: anemic cache-key state | 4 | Replace the four anemic cache accessors and two fields with two owned `CacheKeyGate` sub-objects, collapsing the handler's ask-then-tell pairs into single tells |
|
||||
| C: narrow-interface decoupling | 5, 6 | Narrow `LocalPermissionsService` and `PermissionForwarder` to local interfaces so forced `as unknown as` test casts disappear (independent of each other and the others) |
|
||||
| D: slash-command reach-through | 7 | Collapse the `config-modal` controller's two reached-through fields into a single `getActiveAgentConfigRules()` accessor |
|
||||
|
||||
[#362]: https://github.com/gotgenes/pi-packages/issues/362
|
||||
[#363]: https://github.com/gotgenes/pi-packages/issues/363
|
||||
[#364]: https://github.com/gotgenes/pi-packages/issues/364
|
||||
[#365]: https://github.com/gotgenes/pi-packages/issues/365
|
||||
[#366]: https://github.com/gotgenes/pi-packages/issues/366
|
||||
[#367]: https://github.com/gotgenes/pi-packages/issues/367
|
||||
[#368]: https://github.com/gotgenes/pi-packages/issues/368
|
||||
@@ -0,0 +1,189 @@
|
||||
# Phase 6: Access-intent extraction
|
||||
|
||||
Goal: extract the access-intent domain — decompose the 1,143-line `bash-program.ts` god file, introduce the `AccessPath` value object, collapse the two external-directory gates, narrow the per-gate resolver surface, dissolve the `common.ts` grab-bag, and extract external-directory test fixtures.
|
||||
|
||||
Phase 5 cleared the residual state-encapsulation smells; the remaining structural debt was concentrated in the access-intent domain the "Target: the authority model" section in [architecture.md](../architecture.md) names as the one genuinely open piece.
|
||||
This phase extracted that domain: it decomposed the 1,143-line `bash-program.ts` god file (the package's #1 churn × complexity hotspot at risk 97), introduced the `AccessPath` value object the [#418] fix seeded, collapsed the two external-directory gates that independently acquired the same lexical/canonical conflation bug, and narrowed the per-gate resolver surface that produced the [#393] false-green.
|
||||
It was the doc's "tractable first slice" toward the authority model — principal identity and cross-session path portability remain deferred follow-ups, not Phase 6 scope.
|
||||
|
||||
Phase 6 also seeded the package's first domain directory.
|
||||
The `src/` tree was flat (66 top-level modules), and the access-intent work was the natural place to begin domain grouping: the bash engine it decomposed and the `AccessPath` it introduced landed in a new `src/access-intent/` directory rather than flat, so the extracted modules reached their final home the first time instead of being moved twice.
|
||||
This was a seed, not the whole reorg — see [Module structure](../architecture.md#module-structure) for the broader arc.
|
||||
|
||||
## Findings summary
|
||||
|
||||
| Metric | Phase 5 close | Phase 6 target | Phase 6 delivered |
|
||||
| -------------------------------------------- | ------------------------------- | --------------------------- | --------------------------------------- |
|
||||
| Health score | 76 (B) | ≥ 80 (B+) | 76 (B) — score unchanged (see note) |
|
||||
| `program.ts` LOC (was `bash-program.ts`) | 1,143 | ≤ 350 (value-object facade) | **102** ✅ |
|
||||
| `program.ts` risk (was `bash-program.ts`) | 97.0 | < 40 | < 40 ✅ (fallow no longer lists it) |
|
||||
| `common.ts` fallow target (pri / dependents) | 27.1 / 22 | dissolved (0 targets) | dissolved ✅ (see note on value-guards) |
|
||||
| External-directory gate duplication | 2 gates, [#418] logic twice | 1 shared policy check | 1 shared policy check ✅ |
|
||||
| `ScopedPermissionResolver` surface | `resolve` + `resolvePathPolicy` | `resolve(intent)` | `resolve(intent)` ✅ |
|
||||
| Duplication | 6.9% | ≤ 6.5% | 3.6% ✅ |
|
||||
| Dead code | 0% | 0% | 0% ✅ |
|
||||
| Test files / tests | — | — | 104 files / 2,124 tests |
|
||||
| Source files | — | — | 101 `src/` files, 12,726 LOC |
|
||||
|
||||
**Health score note:** The score held at 76 despite the god-file decomposition and duplication reduction.
|
||||
The structural improvements did not register as a composite-score gain — the score reflects LOC-weighted complexity, and spreading LOC across more focused files can keep the raw number flat.
|
||||
|
||||
**value-guards note:** `common.ts` was correctly dissolved into `value-guards.ts` and `yaml-frontmatter.ts`, but `value-guards.ts` inherited the same 22-dependent high-fan-in profile.
|
||||
Fallow reports `value-guards.ts` as its single refactoring target (pri 28.9, score 9.6) — identical amplitude to the original `common.ts` target.
|
||||
The Step 7 outcome predicted "the fallow refactoring-targets list drops to zero"; in practice the grab-bag concern migrated rather than dissolved.
|
||||
Further splitting `value-guards.ts` is a candidate for the next phase.
|
||||
|
||||
## Steps
|
||||
|
||||
### Track A — bash-program decomposition (Steps 1, 2, 3)
|
||||
|
||||
All three steps landed.
|
||||
The bash engine lives in `src/access-intent/bash/` and `BashProgram` is born-ready (102 LOC facade).
|
||||
|
||||
### Step 1 ✅ — Extract the tree-sitter parser and AST node-text resolver from `bash-program.ts` ([#473])
|
||||
|
||||
Lifted the lazy tree-sitter-bash parser (`getParser`, the `TSNode` / `TSParser` interfaces) and the quote-aware node-text resolver (`resolveNodeText`, `SKIP_SUBTREE_TYPES`) into their own modules, leaving `bash-program.ts` importing them.
|
||||
Pure lift-and-shift, no behavior change.
|
||||
|
||||
- Target: `src/handlers/gates/bash-program.ts` lines 18–58 (parser) and 273–333 (`resolveNodeText`) → `src/access-intent/bash/parser.ts` + `src/access-intent/bash/node-text.ts` (seeds the new domain directory).
|
||||
- Smell: Category B (god file — 1,143 LOC mixing parser bootstrap, AST traversal, and value-object API).
|
||||
- Outcome: ~120 LOC moved out; the parser and node-text resolver are independently testable; `bash-program.ts` drops below ~1,020 LOC.
|
||||
- Release: batch "bash-program-decomposition"
|
||||
|
||||
### Step 2 ✅ — Extract bash token collection (pattern-first command config) from `bash-program.ts` ([#474])
|
||||
|
||||
Moved the pattern-first command table (`PATTERN_FIRST_COMMANDS`, `PatternCommandConfig`), the flag classifier (`classifyPatternCommandFlag`), and the token collectors (`collectPatternCommandTokens`, `collectGenericCommandTokens`, `collectRedirectTokens`, `collectCommandTokens`, `collectPathCandidateTokens`) into `src/access-intent/bash/token-collection.ts`.
|
||||
This was the single largest cohesive block in the file.
|
||||
Two symbols shared with the staying cwd-projection were placed by layer: `ARG_NODE_TYPES` (tree-sitter grammar mechanics) → `node-text.ts` alongside `SKIP_SUBTREE_TYPES`; `extractCommandName` (bash-domain command-identity query) → `token-collection.ts` (name kept).
|
||||
|
||||
- Target: `src/handlers/gates/bash-program.ts` lines 334–687 → `src/access-intent/bash/token-collection.ts`.
|
||||
- Smell: Category B (god file — argument/flag tokenization is a distinct concern from the value-object API).
|
||||
- Outcome: ~350 LOC moved out; the per-command flag table is editable without touching the `BashProgram` class; `bash-program.ts` actual post-Step-2 LOC: 695 (Step 3 supersedes this).
|
||||
- Release: batch "bash-program-decomposition"
|
||||
|
||||
### Step 3 ✅ — Extract command enumeration and cwd projection; slim `BashProgram` to a value-object facade ([#475])
|
||||
|
||||
Moved command enumeration (`collectCommands`, `collectCommandsInto`, subshell / substitution descent) and the effective-working-directory `cd`-fold projection (`collectPathCandidates`, `walkCurrentShellSequence`, `walkPipeline`, `foldCd`, and helpers) into focused modules, then relocated the slimmed `BashProgram` (and `bash-token-classification.ts`) so the whole bash sub-domain lives under `src/access-intent/bash/`, leaving `BashProgram` a thin facade that parses once and exposes typed slices.
|
||||
The `cd`-fold logic is the subtlest region (#307, #454) — extracted it whole, behavior-preserving, with its tests following it.
|
||||
Moving `program.ts` out of `handlers/gates/` sharpened the dependency direction: the gates consume the access-intent engine, not the reverse.
|
||||
|
||||
- Target: `src/handlers/gates/bash-program.ts` lines 688–1143 → `src/access-intent/bash/command-enumeration.ts` + `src/access-intent/bash/cwd-projection.ts`; `bash-program.ts` → `src/access-intent/bash/program.ts`; `bash-token-classification.ts` → `src/access-intent/bash/token-classification.ts`.
|
||||
- Smell: Category B (god file) + Category E (flat directory — the bash engine becomes the first cohesive domain group).
|
||||
- Outcome: `access-intent/bash/program.ts` 102 LOC (born-ready `BashProgram` facade, three parameter-free getters); `cwd-projection.ts` 493 LOC (the full projection lifecycle, encapsulated); risk score < 40; `ToolCallContext.cwd` narrowed to `string`; bash sub-domain co-located; bash gates and tests import from `#src/access-intent/bash/...`.
|
||||
- Release: batch "bash-program-decomposition"
|
||||
|
||||
### Track B — access-path unification (Steps 4, 5, 6)
|
||||
|
||||
All three steps landed.
|
||||
The [#418] / [#393] semantic fixes shipped, `AccessPath` exists, the two external-directory gates are collapsed, and the resolver is narrowed to one `resolve(intent)`.
|
||||
|
||||
### Step 4 ✅ — Introduce the `AccessPath` value object ([#476])
|
||||
|
||||
Replaced the raw-string pairing that carries a path's two meanings (lexical as-typed for matching, canonical symlink-resolved for the outside-CWD boundary) with an `AccessPath` value object exposing distinct `matchValues()` and boundary accessors.
|
||||
This made the [#418] conflation — a single `string` silently used for both — a compile-time distinction, and converted `getExternalDirectoryPolicyValues` / `canonicalNormalizePathForComparison` from free helpers into `AccessPath` factories.
|
||||
`BashProgram.externalPaths(cwd)` returns `AccessPath[]` instead of lexical strings.
|
||||
|
||||
- Target: new `src/access-intent/access-path.ts`; `src/path-utils.ts` (`getExternalDirectoryPolicyValues`, `canonicalNormalizePathForComparison`); `BashProgram.externalPaths`.
|
||||
- Smell: Category C (primitive obsession / platform-type threading — one `string` carries a containment value and a match value with no type distinction).
|
||||
- Outcome: `AccessPath` type; the lexical/canonical misuse is a compile error; 5 `getExternalDirectoryPolicyValues` call sites route through the value object.
|
||||
- Release: batch "access-path-unification"
|
||||
|
||||
### Step 5 ✅ — Collapse the two external-directory gates onto one `AccessPath` policy check ([#477])
|
||||
|
||||
`describeExternalDirectoryGate` (single tool path) and `describeBashExternalDirectoryGate` (multi bash path) each independently re-derived aliases, called `resolver.resolvePathPolicy(..., "external_directory")`, and picked the worst uncovered path — and each independently acquired the [#418] bug.
|
||||
Routed both through one shared external-directory policy check over `AccessPath[]`, so the alias/boundary logic exists once.
|
||||
|
||||
- Target: `src/handlers/gates/external-directory.ts`, `src/handlers/gates/bash-external-directory.ts` (both import `AccessPath` from `#src/access-intent/access-path`); new shared helper.
|
||||
- Smell: Category A/C (production duplication — the same [#418]-prone logic in two gates — plus a Law-of-Demeter reach-through into path aliasing).
|
||||
- Outcome: one external-directory policy check; both gate factories delegate; the [#418] alias logic is single-sourced; ~60 LOC of duplication removed.
|
||||
- Release: batch "access-path-unification"
|
||||
|
||||
### Step 6 ✅ — Narrow `ScopedPermissionResolver` to a single `resolve(intent)` ([#478])
|
||||
|
||||
Each gate called either `resolve(surface, input)` or `resolvePathPolicy(values, ..., surface)`; the surface widened per gate, and a stubbed-but-unrouted method silently passed `allow` (the [#393] false-green).
|
||||
Introduced a minimal `AccessIntent` (a three-variant discriminated union — `tool | path-values | access-path`) that each gate emits, and collapsed the two resolver entry points into one `resolve(intent)`.
|
||||
The `access-path` variant lets `AccessPath` flow into the resolver, which unwraps it via `matchValues()` before handing a string-based `ResolvedAccessIntent` to the manager's single `check(intent)`; the low-level manager never imports the value object.
|
||||
Scope was the surface narrowing only — `AccessIntent` carries no principal identity, and cross-session path portability stays a deferred follow-up ([#309] tracks the related advisory-path unification).
|
||||
The broader "every path becomes an `AccessPath`" direction and the open question of whether the `path` surface should also match the canonical form are tracked in [#487] / [#486].
|
||||
|
||||
- Target: `src/access-intent/access-intent.ts` (new `AccessIntent` union); `src/permission-resolver.ts` (`ScopedPermissionResolver`); `src/permission-manager.ts` (`checkPermission` + `checkPathPolicy` → `check`); all gate descriptor factories.
|
||||
- Smell: Category C/D (widening interface per gate + testability false-green from an unrouted stub).
|
||||
- Outcome: `ScopedPermissionResolver` exposes one `resolve(intent)` and `ScopedPermissionManager` one `check(intent)`; adding a gate cannot widen the resolver surface; the [#393] false-green class is structurally impossible (no second method to forget).
|
||||
- Release: independent
|
||||
- Landed: three-variant `AccessIntent` union; resolver unwraps `access-path` via `matchValues()`, manager stays string-based; `PermissionResolver implements SkillPermissionChecker` for the raw no-session-rules path; follow-ups [#486] / [#487] filed.
|
||||
|
||||
### Track C — independent cleanup (Steps 7, 8)
|
||||
|
||||
Both steps landed.
|
||||
The `common.ts` split landed at [#479] and the external-directory test fixtures landed at [#480].
|
||||
|
||||
### Step 7 ✅ — Split the `common.ts` grab-bag ([#479])
|
||||
|
||||
`common.ts` (fallow's #1 refactoring target, pri 27.1, 22 dependents) mixed unrelated concerns: runtime type guards (`toRecord`, `getNonEmptyString`, `normalizeOptionalStringArray`, `normalizeOptionalPositiveInt`, `isPermissionState`, `isDenyWithReason`) and minimal YAML/frontmatter parsing (`parseSimpleYamlMap`, `extractFrontmatter`).
|
||||
Split into a type-guards module and a yaml-frontmatter module so the 22-dependent fan-in stops amplifying every unrelated change.
|
||||
|
||||
- Target: `src/common.ts` → `src/value-guards.ts` + `src/yaml-frontmatter.ts`.
|
||||
- Smell: Category E (grab-bag — unclear module boundary with high fan-in amplification).
|
||||
- Outcome: `common.ts` dissolved; two cohesive modules; the `common.ts` fallow target eliminated.
|
||||
Note: `value-guards.ts` inherited the 22-dependent fan-in and became the new fallow target (pri 28.9) — splitting the type guards into a more coherent home is a candidate for the next phase.
|
||||
- Release: independent
|
||||
|
||||
### Step 8 ✅ — Extract shared fixtures for the external-directory integration tests ([#480])
|
||||
|
||||
The external-directory test files duplicated setup heavily: `external-directory-integration.test.ts` (21 clone groups, 214 lines), `external-directory-session-dedup.test.ts` (3 groups, 86 lines), and the 880-line arrow in `bash-external-directory.test.ts`.
|
||||
Extracted a shared fixture into `test/helpers/` once the gates were unified (Phase 6 Step 5), so the fixture targets the single collapsed policy check.
|
||||
|
||||
- Target: `test/handlers/external-directory-integration.test.ts`, `test/handlers/external-directory-session-dedup.test.ts`, `test/bash-external-directory.test.ts` → new `test/helpers/external-directory-fixtures.ts`.
|
||||
- Smell: Category D (test duplication — the worst clone family after the gate unification).
|
||||
- Outcome: external-directory test duplication down by ~300 lines; one fixture per the collapsed gate; package duplication fell to 3.6% (well below the ≤ 6.5% target).
|
||||
- Release: independent
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["✅ Step 1 (#473) — extract parser + node-text"]
|
||||
S2["✅ Step 2 (#474) — extract token collection"]
|
||||
S3["✅ Step 3 (#475) — extract enumeration + cwd projection (BashProgram facade)"]
|
||||
S4["✅ Step 4 (#476) — introduce AccessPath value object"]
|
||||
S5["✅ Step 5 (#477) — collapse the two external-directory gates"]
|
||||
S6["✅ Step 6 (#478) — narrow resolver to resolve(intent)"]
|
||||
S7["✅ Step 7 (#479) — split common.ts grab-bag"]
|
||||
S8["✅ Step 8 (#480) — external-directory test fixtures"]
|
||||
|
||||
S1 --> S2 --> S3
|
||||
S3 --> S4
|
||||
S4 --> S5
|
||||
S4 --> S6
|
||||
S5 --> S8
|
||||
```
|
||||
|
||||
Step 7 has no dependencies and runs in parallel with everything.
|
||||
|
||||
## Tracks
|
||||
|
||||
- **Track A — bash-program decomposition** (Steps 1, 2, 3): ✅ complete — all three steps landed; the bash engine lives in `src/access-intent/bash/` and `BashProgram` is born-ready (102 LOC facade).
|
||||
- **Track B — access-path unification** (Steps 4, 5, 6): ✅ complete — all three steps landed; the [#418] / [#393] semantic fixes shipped, `AccessPath` exists, the two external-directory gates are collapsed, and the resolver is narrowed to one `resolve(intent)`.
|
||||
- **Track C — independent cleanup** (Steps 7, 8): ✅ complete — the `common.ts` split landed at [#479] and the external-directory test fixtures landed at [#480].
|
||||
|
||||
## Release batches
|
||||
|
||||
- **Batch "bash-program-decomposition":** Steps 1, 2, 3 (shipped together; tail = Step 3).
|
||||
Each was a behavior-preserving extraction, batched to release the decomposition once rather than three internal-only patch releases.
|
||||
- **Batch "access-path-unification":** Steps 4, 5 (shipped together; tail = Step 5).
|
||||
Step 4 alone left both the new `AccessPath` type and the old free helpers in place — a transitional state — so it shipped with Step 5.
|
||||
- Independently releasable: Steps 6, 7, 8.
|
||||
|
||||
[#309]: https://github.com/gotgenes/pi-packages/issues/309
|
||||
[#393]: https://github.com/gotgenes/pi-packages/issues/393
|
||||
[#418]: https://github.com/gotgenes/pi-packages/issues/418
|
||||
[#473]: https://github.com/gotgenes/pi-packages/issues/473
|
||||
[#474]: https://github.com/gotgenes/pi-packages/issues/474
|
||||
[#475]: https://github.com/gotgenes/pi-packages/issues/475
|
||||
[#476]: https://github.com/gotgenes/pi-packages/issues/476
|
||||
[#477]: https://github.com/gotgenes/pi-packages/issues/477
|
||||
[#478]: https://github.com/gotgenes/pi-packages/issues/478
|
||||
[#479]: https://github.com/gotgenes/pi-packages/issues/479
|
||||
[#480]: https://github.com/gotgenes/pi-packages/issues/480
|
||||
[#486]: https://github.com/gotgenes/pi-packages/issues/486
|
||||
[#487]: https://github.com/gotgenes/pi-packages/issues/487
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# Phase 7: AccessPath as the universal internal path representation
|
||||
|
||||
Phase 7 finished the direction opened by [#487]: make `AccessPath` the one internal representation for every concrete path the system handles.
|
||||
Phase 6 introduced `AccessPath` for the `external_directory` surface; follow-on [#486] brought the `path` surface and the bash-path tokens to lexical ∪ canonical parity and collapsed the gate-emitted `path-values` variant.
|
||||
Two ad-hoc path-derivation paths once normalized lexically only — the per-tool path-bearing gate (`read`/`write`/`edit`/`grep`/`find`/`ls`) and the service/RPC policy-query path — so a per-tool rule (`read: deny *.env`) was symlink-evadable while the cross-cutting `path` rule was not.
|
||||
Steps 1 ([#502]) and 2 ([#503]) routed both onto `AccessPath` (closing the asymmetry, a breaking change); Phase 7 also retired the now-dead lexical-only normalization, consolidated the `path-utils.ts` derivation hub behind the value object, and formalized the resolver-internal `path-values` boundary.
|
||||
|
||||
This was a direction-driven phase: [#487] set the framing, and the discovery confirmed the residual surface rather than proposing an unrelated health sweep.
|
||||
|
||||
## Findings
|
||||
|
||||
Health score 76 (B); no dead code; duplication 6.6% overall (3.6% in tests); maintainability 91.2.
|
||||
The single relevant structural signal was `path-utils.ts` — an accelerating churn hotspot (266 churn over 6 months, 13 fan-in, ▲), the ad-hoc path-derivation grab-bag the [#487] vision exists to consolidate.
|
||||
|
||||
| Metric | Before | After Phase 7 |
|
||||
| --------------------------------- | --------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `path-utils.ts` fan-in | 13 (one grab-bag) | ✅ distributed across six cohesive modules ([#505]) |
|
||||
| Lexical-only path normalizers | 2 (per-tool gate, service/RPC) | ✅ 0 (single `AccessPath` derivation) |
|
||||
| Symlink-resistant path surfaces | `path`, `external_directory`, bash | ✅ all path surfaces incl. per-tool and RPC |
|
||||
| Emitted/internal path-value forms | `access-path` emitted, `path-values` internal | ✅ `path-values` formalized as the string seam (`decisions/0002`) |
|
||||
|
||||
The residual ad-hoc path handling (the "re-derive their representations ad hoc" [#487] names):
|
||||
|
||||
- Per-tool path-bearing gate: `ToolCallGatePipeline` emitted `kind: "tool"` → `normalizeInput` → `normalizePathSurfaceValues` → `getPathPolicyValues` (lexical only) — closed by Steps 1–3 ([#502], [#504]): Step 1 migrated the gate to emit `access-path`; Step 3 removed `normalizePathSurfaceValues` and the path branches from `normalizeInput`.
|
||||
- Service/RPC queries: `permissions-service.ts` / `permission-event-rpc.ts` — closed by Step 2 ([#503]): both build an `AccessPath` via `buildAccessIntentForSurface` and route an `access-path` intent through the resolver (was a lexical `tool` intent for `path` / `external_directory`).
|
||||
- `path-utils.ts`: the loose `getPathPolicyValues` / `normalizePathForComparison` / `normalizePathPolicyLiteral` derivations that `AccessPath` should own — ✅ closed by Step 4 ([#505]): relocated into `access-intent/path-normalization.ts` and the grab-bag dissolved into focused modules.
|
||||
- ✅ `path-values`: formalized as the manager's deliberate string boundary by Step 5 ([#506]; `docs/decisions/0002-path-values-string-boundary.md`) — the manager stays string-based and never imports `AccessPath`, now guarded by a `no-restricted-imports` lint rule on `permission-manager.ts`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. ✅ **Migrate the per-tool path-bearing tool gate onto `AccessPath` (canonical parity).**
|
||||
([#502]) Target: `src/handlers/gates/tool-call-gate-pipeline.ts` (build `AccessPath.forPath` and emit `kind: "access-path"` with `surface: toolName` for path-bearing tools, keeping non-path tools on the `tool` intent), `src/handlers/gates/tool.ts` (derive the session-approval value from `accessPath.value()`).
|
||||
The resolver already unwraps `access-path` → `path-values` and the manager's path-value branch already routes `PATH_BEARING_TOOLS` through `evaluateAnyValue`, so the only behavior change is the canonical alias joining the match set — mechanically parallel to [#486].
|
||||
Smell: Category C (coupling / match asymmetry).
|
||||
Outcome: `read`/`write`/`edit`/`grep`/`find`/`ls` per-tool rules match lexical ∪ canonical (symlink-resistant); **breaking**.
|
||||
Release: batch "symlink-resistant-path-matching"
|
||||
|
||||
2. ✅ **Migrate the service/RPC path queries onto `AccessPath` (canonical parity).**
|
||||
([#503]) Target: `src/permissions-service.ts`, `src/permission-event-rpc.ts`, `src/input-normalizer.ts` (`buildAccessIntentForSurface`).
|
||||
For `path` / `external_directory` / path-bearing surface queries, build an `AccessPath` and route an `access-path` intent through the resolver instead of a lexical `tool` intent to the manager; non-path surfaces keep the existing path.
|
||||
Routing through the resolver (not a second `path-values` producer) keeps it the sole `matchValues()` unwrap site, the premise Step 5 ([#506]) decides against.
|
||||
Also fixed a latent gap: the `path` and path-bearing service/RPC queries dropped their value (collapsing to `["*"]`) and now evaluate the supplied path.
|
||||
Smell: Category C (coupling / match asymmetry).
|
||||
Outcome: external policy queries match the same lexical ∪ canonical set the gates do; **breaking** for external consumers.
|
||||
Release: batch "symlink-resistant-path-matching"
|
||||
|
||||
3. ✅ **Retire `input-normalizer`'s path normalization.**
|
||||
([#504]) Removed `normalizePathSurfaceValues`, the special-surface (`path` / `external_directory`) branch, and the `PATH_BEARING_TOOLS` branch from `normalizeInput`; dropped the `platform` / `cwd` parameters; removed the `currentCwd` field from `PermissionManager`.
|
||||
After Steps 1 and 2, these branches had no callers; the missing-path case falls through to the generic `["*"]` branch.
|
||||
Smell: Category A (dead / redundant code).
|
||||
Outcome: `normalizeInput` handles only bash / skill / mcp / extension surfaces; a single `AccessPath` path-derivation entry remains.
|
||||
Release: batch "symlink-resistant-path-matching"
|
||||
|
||||
4. ✅ **Consolidate path derivation behind `AccessPath`: dissolve the `path-utils.ts` grab-bag.**
|
||||
([#505]) Relocated the lexical/canonical/policy-value derivation (`normalizePathForComparison`, `canonicalNormalizePathForComparison`, `getPathPolicyValues`, `normalizePathPolicyLiteral`, and the two private absolute/relative helpers) into `src/access-intent/path-normalization.ts` as `AccessPath`'s backing; kept containment (`isPathWithinDirectory`, `isPathOutsideWorkingDirectory`) together in `src/path-containment.ts`, and split infra-read (`pi-infrastructure-read.ts`), tool-input extraction (`tool-input-path.ts`), safe-system paths (`safe-system-paths.ts`), and the surface/tool sets (`path-surfaces.ts`) into focused modules.
|
||||
A "tidy first" prep refactor made `isPathOutsideWorkingDirectory` pure geometry over prepared operands (canonicalization moved up to `PathNormalizer`), which dissolved the apparent representation↔containment cycle so the literal grouping held.
|
||||
Smell: Category B / E (god module, accelerating churn hotspot).
|
||||
Outcome: `path-utils.ts` dissolved into cohesive modules; path derivation owned by the access-intent domain; non-breaking.
|
||||
Release: independent
|
||||
|
||||
5. ✅ **Decide and formalize the `path-values` boundary.**
|
||||
([#506]) Target: `src/access-intent/access-intent.ts`, `src/permission-resolver.ts`, `src/permission-manager.ts`.
|
||||
With the resolver the sole `path-values` producer after Steps 1 and 2, decide between formalizing `path-values` as the manager's intentional string seam (document why the manager stays string-based) and moving the `matchValues()` unwrap into the manager (the manager imports `AccessPath`, dropping the string-boundary invariant).
|
||||
This is the [#487] "collapse the `path-values` variant" item, resolved as an explicit decision rather than a pre-committed mechanical change.
|
||||
Smell: Category C (clarify boundary).
|
||||
Decided: **formalize** — kept `path-values` as the string seam, recorded in `docs/decisions/0002-path-values-string-boundary.md`, and guarded the invariant with a `no-restricted-imports` lint rule on `permission-manager.ts`; non-breaking.
|
||||
Release: independent
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["✅ Step 1 (#502)<br/>Per-tool gate to AccessPath<br/>(breaking)"]
|
||||
S2["✅ Step 2 (#503)<br/>Service/RPC to AccessPath<br/>(breaking)"]
|
||||
S3["✅ Step 3 (#504)<br/>Retire input-normalizer path normalization"]
|
||||
S4["✅ Step 4 (#505)<br/>Dissolve path-utils grab-bag"]
|
||||
S5["✅ Step 5 (#506)<br/>Decide path-values boundary"]
|
||||
|
||||
S1 --> S3
|
||||
S2 --> S3
|
||||
S3 --> S4
|
||||
S1 --> S5
|
||||
S2 --> S5
|
||||
```
|
||||
|
||||
## Parallel tracks
|
||||
|
||||
- **Track A — access-side canonical parity:** Steps 1 and 2 proceed in parallel (different consumers), both feed Step 3 (dead-code removal), and both unblock Step 5 (the boundary decision).
|
||||
- **Track B — structural consolidation:** Step 4 follows Step 3 (fewer loose `path-utils.ts` consumers makes the relocation mechanical) and is otherwise independent.
|
||||
|
||||
## Release batches
|
||||
|
||||
- **Batch "symlink-resistant-path-matching":** Steps 1, 2, 3 (ship together; tail = Step 3).
|
||||
Steps 1 and 2 are breaking parity changes and Step 3 is their cleanup — they form one coherent "paths now match symlink-resistantly on every surface" major-bump release.
|
||||
- Independently releasable: Step 4 (a refactor that auto-batches into the next release), Step 5 (a decision / docs change).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Config patterns onto `AccessPath`.**
|
||||
Patterns are matched as pure regex (`*` compiles to `.*` with the dotall flag and crosses path segments — `wildcard-matcher.ts`), so a glob is a matching *mode*, not a *value* with a canonical form.
|
||||
The symlink protection [#487] wants is delivered on the access side: an accessed path's `matchValues()` carries both its lexical "source" and canonical "target", and a rule fires on either — so a rule on the symlink path or on the real file both match.
|
||||
The only uncovered case is a glob pattern whose directory *prefix* is a symlink (e.g. `~/linkdir/*` accessed via the real target): not closable on the pattern side, because `*` crossing segments leaves no reliable resolvable-prefix decomposition.
|
||||
Documented guidance: key glob rules on the real location, not a symlink-dir alias.
|
||||
- **Canonicalizing concrete symlink patterns at rule-load.**
|
||||
Feasible only for fully-concrete (non-glob) patterns and a narrow case; evaluated and dropped (the high-value protective patterns are globs, which this cannot help).
|
||||
- **Principal identity and cross-session path portability.**
|
||||
Still deferred (the broader access-intent design work), out of Phase 7 scope.
|
||||
|
||||
## Related: PathNormalizer platform seam ([#510])
|
||||
|
||||
A precursor refactor (not one of the five steps above) threaded a single injected `PathNormalizer` collaborator through the bash path pipeline, completing the half-built platform seam behind the recurring Windows-path bugs ([#382], [#345], [#418], [#508]).
|
||||
The host `platform` is read once at the composition root (`index.ts`) and injected: into `PermissionManager` (rule-matching case-fold), into `PermissionSession` (which builds the `PathNormalizer` from `platform` + the session `cwd` and exposes it via `getPathNormalizer()`), and into the subagent-context detection.
|
||||
No interior `src/` module reads `process.platform` — an ESLint `no-restricted-syntax` guard scoped to `pi-permission-system/src` (exempting `index.ts`) enforces this, so every `path-containment` / `path-normalization` / canonicalize / rule / subagent-context leaf takes an injected `platform` rather than a `= process.platform` default.
|
||||
`PathNormalizer` is a facade *over* the platform-parameterized `path-containment` / `path-normalization` / `AccessPath` primitives: Phase 7 Step 4 ([#505]) dissolved `path-utils.ts` into those cohesive modules (the seam was untouched — the facade kept the same leaf calls under new module names).
|
||||
The change is behavior-preserving on POSIX (every converted op already used the host `node:path`); the `win32` flavor is newly exercised by injected-platform unit tests, and [#508] then lands the drive-letter routing fix on the seam.
|
||||
|
||||
### Residual `getPlatform()` threading (follow-up [#511])
|
||||
|
||||
The seam left five call sites threading `platform` *directly* rather than through `PathNormalizer`, because they call raw path-leaf functions that are not `AccessPath` operations.
|
||||
`PermissionSession.getPlatform()` (and the `ToolCallGateInputs.getPlatform()` it backed) existed only to feed them; it has been retired now that every consumer is folded, while the leaf `platform` parameters in the relocated path modules (`path-containment.ts`, `path-normalization.ts`, `pi-infrastructure-read.ts`) persist.
|
||||
How each relates to the Phase 7 steps above:
|
||||
|
||||
- **Per-tool gate suggestion value** (`handlers/gates/tool.ts` `deriveSuggestionValue` → `normalizePathForComparison`) — ✅ retired by **Step 1 ([#502])**: `deriveSuggestionValue` now derives the session-approval value from `accessPath.value()`, dropping the `platform` thread into `describeToolGate`.
|
||||
- **`input-normalizer` path-policy values** (`normalizePathSurfaceValues` → `getPathPolicyValues`) — ✅ retired by **Steps 2–3 ([#503], [#504])**: Step 2 migrated the service/RPC path queries onto `AccessPath`; Step 3 removed the path-bearing/special-surface branches from `normalizeInput` entirely ([#504]).
|
||||
- **Infra-read containment** (`handlers/gates/external-directory.ts`) — ✅ routed through `PathNormalizer.isInfrastructureRead` ([#511]): the gate already holds the normalizer, which now answers the containment question over the already-built `AccessPath`.
|
||||
Step 4 ([#505]) still keeps `isPiInfrastructureRead` (`pi-infrastructure-read.ts`) and `isPathWithinDirectory` / `isPathOutsideWorkingDirectory` (`path-containment.ts`) as platform-taking leaf predicates that the normalizer delegates to.
|
||||
- **Skill-prompt sanitization** (`skill-prompt-sanitizer.ts` `createResolvedSkillEntry` / `findSkillPathMatch`; reached from `before-agent-start.ts` and `handlers/gates/skill-read.ts`) — ✅ routed through `PathNormalizer.comparableValue` / `isWithinDirectory` ([#511]).
|
||||
Skill entries still cache `normalizedLocation` / `normalizedBaseDir` as lexical strings (matching stays lexical, no new filesystem access), but they are now computed by the normalizer rather than by direct `normalizePathForComparison` calls.
|
||||
|
||||
✅ `getPlatform()` has been removed: with both [#511] and Step 1 ([#502]) landed, `ToolCallGatePipeline.evaluate` no longer reads it, so `PermissionSession.getPlatform()` and `ToolCallGateInputs.getPlatform()` were dropped ([#513] resolved).
|
||||
The leaf `platform` parameters in `path-containment.ts` / `pi-infrastructure-read.ts` persist (the containment / infra-read predicates still take it).
|
||||
|
||||
[#345]: https://github.com/gotgenes/pi-packages/issues/345
|
||||
[#382]: https://github.com/gotgenes/pi-packages/issues/382
|
||||
[#418]: https://github.com/gotgenes/pi-packages/issues/418
|
||||
[#486]: https://github.com/gotgenes/pi-packages/issues/486
|
||||
[#487]: https://github.com/gotgenes/pi-packages/issues/487
|
||||
[#502]: https://github.com/gotgenes/pi-packages/issues/502
|
||||
[#503]: https://github.com/gotgenes/pi-packages/issues/503
|
||||
[#504]: https://github.com/gotgenes/pi-packages/issues/504
|
||||
[#505]: https://github.com/gotgenes/pi-packages/issues/505
|
||||
[#506]: https://github.com/gotgenes/pi-packages/issues/506
|
||||
[#508]: https://github.com/gotgenes/pi-packages/issues/508
|
||||
[#510]: https://github.com/gotgenes/pi-packages/issues/510
|
||||
[#511]: https://github.com/gotgenes/pi-packages/issues/511
|
||||
[#513]: https://github.com/gotgenes/pi-packages/issues/513
|
||||
@@ -0,0 +1,166 @@
|
||||
# Phase 8: Tidy first for the authority spine
|
||||
|
||||
The [authority model](../architecture.md#the-authority-model) is the declared target: an `Authorizer` role selected once per session, yolo as recorded authority, and `PermissionForwarder` split by direction of authority flow.
|
||||
Phase 8 does not build the spine.
|
||||
It makes the spine change easy — Kent Beck's "make the change that makes the change easy, then make the easy change" — by landing the preparatory refactorings the discovery trace found between `GateRunner` and the UI/file transport.
|
||||
The spine itself (the `Authorizer` interface and its three implementations, `canConfirm()` dissolution, serving-as-resolution, grant-scope selection) is Phase 9, and the case-by-case model judge requested in [#472] rides on that spine as the `ModelTriageAuthorizer`, not on this phase.
|
||||
|
||||
## Findings
|
||||
|
||||
Health score 76 (B); no dead code; average cyclomatic complexity 1.4; maintainability 91.1.
|
||||
The score deductions are large *test* arrow functions and test-tree duplication — production functions are small, so the remaining debt is structural, not syntactic.
|
||||
The trace from `GateRunner` down to the UI dialog and forwarding files confirmed the elicitation thicket exactly as the target section describes it, plus the friction that would make the spine diff large:
|
||||
|
||||
- **yolo is smeared across the ask path.**
|
||||
`shouldAutoApprovePermissionState` is checked in `PermissionPrompter.prompt` and again in the forwarded-inbox serve arm; `canResolveAskPermissionRequest`'s yolo arm sits in `PromptingGateway.canConfirm()`.
|
||||
Three modules know about yolo on the decision path; the target says the ruleset should be the only one.
|
||||
- **The three `Authorizer`s already exist as anonymous branches.**
|
||||
`PermissionForwarder.requestApproval` dispatches hasUI → direct dialog (the future `LocalUserAuthorizer`), not-a-subagent → deny (`DenyingAuthorizer`), else → forward (`ParentAuthorizer`) — inside a 591-LOC class that also owns the opposite-direction serving role (`processInbox`).
|
||||
- **Subagent detection is threaded as a dep triple.**
|
||||
(`subagentSessionsDir`, `platform`, `registry`) is threaded into three constructors (`PromptingGateway`, `PermissionForwarder`, `ForwardingManager`), and `isSubagentExecutionContext` is re-evaluated up to three times per ask; the spine's "selected once per session" needs one owner for this predicate.
|
||||
- **A third elicitation path.**
|
||||
The deprecated `permissions:rpc:prompt` event-bus handler is a parallel prompt path (own hasUI check, own review-log entry, own UI-prompt event) the spine would otherwise have to adapt.
|
||||
- **The test scaffolding the spine will rewrite is duplicated.**
|
||||
`permission-manager-unified.test.ts` carries 24 clone groups (305 lines, accelerating churn); `permission-forwarder.test.ts` carries 6 groups including a 43-line clone ×2.
|
||||
|
||||
| Metric | Phase 7 close | Target after Phase 8 |
|
||||
| ------------------------------------------ | -------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| Health score | 76 (B) | ≥ 76 (B) |
|
||||
| yolo checks on the ask path | 3 (prompter, gateway, serve arm) | ✅ 1 (composition-stage rewrite) + serve arm (dissolves with the spine) |
|
||||
| `canConfirm()` predicates | hasUI ∨ isSubagent ∨ yolo | ✅ hasUI ∨ isSubagent (selection-ready) |
|
||||
| Elicitation paths the spine must adapt | 3 (gate prompt, forwarded inbox, RPC prompt) | ✅ 2 (gate prompt, forwarded inbox) |
|
||||
| `PermissionForwarder` roles per class | 2 (escalation + serving, 591 LOC) | ✅ 1 each (two classes under `src/authority/`) |
|
||||
| Subagent-detection dep-triple constructors | 3 | ✅ 1 (`SubagentDetection`) |
|
||||
| fallow refactoring targets | 1 (`value-guards.ts`) | 1 (`value-guards.ts`; fan-in from `toRecord`/`getNonEmptyString` persists) |
|
||||
| Duplication | 6.7% (3,129 lines) | ≤ 5.5% |
|
||||
|
||||
## Steps
|
||||
|
||||
1. ✅ **Extract shared fixtures from `permission-manager-unified.test.ts`.**
|
||||
([#525]) Target: `test/permission-manager-unified.test.ts` (3,714 LOC, 24 clone groups / 305 duplicated lines, accelerating churn) — extract the repeated config-harness blocks into `test/helpers/manager-harness.ts` (or a sibling fixture module).
|
||||
No production change; tidies the ground Step 2's manager tests land on.
|
||||
Smell: Category D (test duplication).
|
||||
Outcome: the file's clone groups drop to near zero; test-tree duplication falls measurably.
|
||||
Landed: the seven config-harness factories plus the `sessionRule` builder now live in `test/helpers/manager-harness.ts`; the test file drops from 3,745 to 3,481 LOC with one intentional act/assert clone remaining (agent-frontmatter, kept per the plan's Non-Goals).
|
||||
Release: independent
|
||||
|
||||
2. ✅ **Move yolo into recorded authority: composition-stage `ask` → `allow` rewrite.**
|
||||
([#526]) Target: `src/permission-manager.ts` (apply the rewrite over the composed ruleset at check time, keyed off an injected yolo reader; yolo state must join the `resolvedPermissionsCache` key or be applied post-cache), `src/rule.ts` (`RuleOrigin` gains `"yolo"`; update this doc's inline `Rule` listing), `src/handlers/gates/helpers.ts` + `runner.ts` (a yolo-origin `allow` derives resolution `auto_approved`, and the runner writes the `permission_request.auto_approved` review entry so review-log parity holds).
|
||||
Display must not change: `getComposedConfigRules` / `/permission-system show` keep showing the configured actions, not the rewrite.
|
||||
Faithful to current behavior: explicit `deny` is not `ask`, so yolo suppresses prompts but preserves hard denies (see [yolo is recorded authority](../architecture.md#yolo-is-recorded-authority)).
|
||||
Smell: Category C (policy smeared across the prompt path).
|
||||
Outcome: `evaluate()` is the only yolo decision point; the prompter and gateway yolo arms become unreachable; review log and decision events keep reporting `auto_approved`.
|
||||
Landed: `rewriteAsksToYolo` (pure `Ruleset` transform in `rule.ts`) is applied post-cache in `PermissionManager.check` behind an injected `isYoloEnabled` reader, wired in `index.ts` to `isYoloModeEnabled(configStore.current())`; `deriveResolution` maps a yolo-origin `allow` to `auto_approved` and `GateRunner` gained a yolo fast-path that writes the `permission_request.auto_approved` review entry (runner `logContext` shape, `toolCallId` not `requestId`).
|
||||
Skill-reads under yolo resolve to `allow` via the yolo-aware sanitizer and log `policy_allow`/`origin: "builtin"` — an accepted parity nuance (the prompter arm still auto-approves nothing new).
|
||||
The `yolo checks on the ask path` metric is not yet flipped; the prompter/gateway arms are removed in Step 3.
|
||||
Release: batch "yolo-recorded-authority"
|
||||
|
||||
3. ✅ **Delete the dead yolo arms from the prompt path; dissolve `yolo-mode.ts`.**
|
||||
([#527]) Target: `src/permission-prompter.ts` (drop the auto-approve arm), `src/prompting-gateway.ts` (`canConfirm()` = hasUI ∨ isSubagent; `canResolveAskPermissionRequest` deleted), `src/yolo-mode.ts` (dissolved — `isYoloModeEnabled` and the serve arm's check move next to their config in `extension-config.ts`).
|
||||
The forwarded-inbox serve arm keeps its yolo check for now — it dissolves when serving becomes resolution (Phase 9), and is documented as such.
|
||||
Smell: Category A (dead code after Step 2).
|
||||
Outcome: no yolo knowledge on the prompt path; `canConfirm()` is reduced to the two Authorizer-selection predicates.
|
||||
Landed: `PermissionPrompter.prompt()` dropped the auto-approve arm and its `config` dependency; `PromptingGateway.canConfirm()` is now `hasUI ∨ isSubagentExecutionContext(...)`, and `canResolveAskPermissionRequest` / `AskPermissionResolutionOptions` are deleted; `isYoloModeEnabled` moved into `extension-config.ts` and `yolo-mode.ts` is deleted; the forwarded-inbox serve arm re-points at `isYoloModeEnabled` with a comment noting it dissolves in the Phase 9 spine work.
|
||||
Release: batch "yolo-recorded-authority"
|
||||
|
||||
4. ✅ **Extract a shared forwarded-permission test harness.**
|
||||
([#528]) Target: `test/permission-forwarder.test.ts` (43-line clone ×2 plus 6 groups / 110 lines), `test/forwarding-manager.test.ts`, `test/permission-forwarding.test.ts` — extract request/response builders, temp forwarding-dir setup, and a fake `ForwarderContext` into `test/helpers/forwarding-fixtures.ts`.
|
||||
Smell: Category D (test duplication).
|
||||
Outcome: forwarder-family clone groups drop to near zero; Step 6 migrates its per-class tests onto the harness instead of copying scaffolding again.
|
||||
Landed: `test/helpers/forwarding-fixtures.ts` holds `createForwardingTempDir` (handle + `cleanup`, with a `writeRequest` writer), `makeForwarderDeps`, `makeForwarderContext`, `makeUiDecision`, and `makeSubagentRegistry`; `permission-forwarder.test.ts` migrated fully (every `try/finally` temp-dir block gone, `makeEvents` reused from `handler-fixtures`) and `permission-forwarding.test.ts` onto `makeSubagentRegistry`.
|
||||
`forwarding-manager.test.ts` was left unchanged — its `ExtensionContext`-cast ctx, mocked `subagent-context`, and fake-timer polling do not overlap the harness (per the plan's Non-Goals).
|
||||
Release: independent
|
||||
|
||||
5. ✅ **Extract a `SubagentDetection` collaborator; seed `src/authority/`.**
|
||||
([#529]) Target: new `src/authority/subagent-detection.ts` — a class constructed once in `index.ts` with (`subagentSessionsDir`, `platform`, `registry`), exposing `isSubagent(ctx)`; move `src/subagent-context.ts` → `src/authority/subagent-context.ts` (its consumers are all rewired by this step anyway).
|
||||
`PromptingGateway`, `ForwardingManager`, and `PermissionForwarder` drop the threaded dep triple and take the collaborator.
|
||||
Smell: Category C (dep triple threaded through three constructors) + Category E (seeds the authority domain directory).
|
||||
Outcome: one construction site for subagent detection — the input the Phase 9 Authorizer selection consumes; `src/authority/` exists.
|
||||
Landed: `src/authority/subagent-detection.ts` holds `SubagentDetection` (implements `SubagentDetector` + `RegisteredChildDetector`), constructed once in `index.ts` and shared; it delegates to the pure functions in the moved `src/authority/subagent-context.ts`, which keep their test file intact.
|
||||
`PromptingGateway`, `ForwardingManager`, and `PermissionForwarder` took the `SubagentDetector` seam (the forwarder keeps `registry` for target resolution only), and the scope widened to `PermissionServiceLifecycle`, which took the `RegisteredChildDetector` seam and dropped its raw registry field — so all subagent-detection predicates now have one owner.
|
||||
Release: independent
|
||||
|
||||
6. ✅ **Split `PermissionForwarder` by direction of authority flow.**
|
||||
([#530]) Target: `src/forwarded-permissions/permission-forwarder.ts` (591 LOC, both roles) → `src/authority/approval-escalator.ts` (`ApprovalEscalator implements ApprovalRequester` — keeps the three-way dispatch with each branch a named method, plus the request-write/poll machinery) and `src/authority/forwarded-request-server.ts` (`ForwardedRequestServer implements InboxProcessor` — `processInbox` and the per-request serve flow); `src/forwarded-permissions/io.ts` → `src/authority/forwarding-io.ts`; the `forwarded-permissions/` directory dissolves.
|
||||
Callers are unchanged: `PermissionPrompter` keeps depending on `ApprovalRequester`, `ForwardingManager` on `InboxProcessor`.
|
||||
Smell: Category B/C (dual-role class; the target's declared split).
|
||||
Outcome: each class constructs with only its own dependencies; Phase 9 turns the escalator's three named branches into the three `Authorizer`s branch-by-branch instead of dissecting a god class.
|
||||
Landed: `src/authority/forwarder-context.ts` holds the shared `ForwarderContext` read-interface and `getSessionId`; `ApprovalEscalator` (escalation-up, `ApprovalRequester`) and `ForwardedRequestServer` (serving-down, `InboxProcessor`) each construct with only their own dependencies — the escalator dropped `config`/`events`, the server dropped `detection`/`registry`; `src/forwarded-permissions/` and `test/forwarded-permissions/` both dissolved.
|
||||
Release: independent
|
||||
|
||||
7. ✅ **Remove the deprecated `permissions:rpc:check` / `permissions:rpc:prompt` event-bus channel.**
|
||||
([#531]) Target: delete `src/permission-event-rpc.ts` and `test/permission-event-rpc.test.ts`; remove the deprecated request/reply payload types and channel constants from `src/permission-events.ts`; unwire from `index.ts` / `PermissionServiceLifecycle`; update the cross-extension docs to point exclusively at the `Symbol.for()` service accessor.
|
||||
Before writing the migration note, verify the named replacement methods on the real `PermissionsService` type.
|
||||
Narrows [#309] to the service path only — leave a comment on that issue.
|
||||
Smell: Category A (deprecated subsystem) / Category F (duplicate cross-extension surface).
|
||||
Outcome: one cross-extension policy/prompt surface; the spine adapts two elicitation paths instead of three; **breaking** for event-bus RPC consumers.
|
||||
Landed: deleted `src/permission-event-rpc.ts` and its test; removed the RPC channel constants, request/reply payload types, the shared `PermissionsRpcReply` envelope, and `PERMISSIONS_PROTOCOL_VERSION` from `src/permission-events.ts`; removed the dead `rpc_prompt` UI-prompt source, `buildRpcUiPrompt`, and the `UI_PROMPT_SOURCES` whitelist entry; unwired registration and the two unsub handles from `src/index.ts`; repointed `docs/cross-extension-api.md` exclusively at the `Symbol.for()` service accessor; commented on [#309] narrowing its scope to the service path.
|
||||
Release: independent
|
||||
|
||||
8. ✅ **Split `value-guards.ts` by cohesion.**
|
||||
([#532]) Target: `src/value-guards.ts`: keep the generic parsing guards (`toRecord`, `getNonEmptyString`); move the domain guards (`isPermissionState`, `isDenyWithReason`) next to the types they guard (`src/types.ts`).
|
||||
Note: [#547] already removed the config-only guards (`normalizeOptionalStringArray`, `normalizeOptionalPositiveInt`) when zod took over config validation, shrinking this target.
|
||||
Smell: Category B (high-impact file) / Category E (mixed cohesion).
|
||||
Outcome: domain guards co-located with their types; **fallow refactoring targets did not clear to 0** — `value-guards.ts` still reports as a target (19 dependents) because the fan-in came from the retained generic guards (`toRecord`/`getNonEmptyString`), not the relocated domain guards.
|
||||
Landed: moved `isPermissionState` and `isDenyWithReason` from `src/value-guards.ts` to `src/types.ts`, beside `PermissionState`/`DenyWithReason`; repointed the three domain-guard consumers (`permission-manager.ts`, `normalize.ts`, `config-loader.ts`) to import from `./types`; moved the guard tests from `test/value-guards.test.ts` into a new `test/types.test.ts`.
|
||||
Release: independent
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["✅ Step 1 (#525)<br/>Manager-unified test fixtures"]
|
||||
S2["✅ Step 2 (#526)<br/>yolo into the composed ruleset"]
|
||||
S3["✅ Step 3 (#527)<br/>Delete dead yolo arms"]
|
||||
S4["✅ Step 4 (#528)<br/>Forwarding test harness"]
|
||||
S5["✅ Step 5 (#529)<br/>SubagentDetection + seed authority/"]
|
||||
S6["✅ Step 6 (#530)<br/>Split PermissionForwarder by direction"]
|
||||
S7["✅ Step 7 (#531)<br/>Remove deprecated event-bus RPC<br/>(breaking)"]
|
||||
S8["✅ Step 8 (#532)<br/>Split value-guards.ts"]
|
||||
|
||||
S1 --> S2
|
||||
S2 --> S3
|
||||
S4 --> S6
|
||||
S5 --> S6
|
||||
```
|
||||
|
||||
## Parallel tracks
|
||||
|
||||
- **Track A — yolo becomes recorded authority:** Steps 1 → 2 → 3.
|
||||
- **Track B — escalation machinery:** Steps 4 and 5 in parallel, then Step 6.
|
||||
- **Track C — cross-extension surface reduction:** Step 7, independent.
|
||||
- **Track D — health:** Step 8, independent.
|
||||
|
||||
## Release batches
|
||||
|
||||
- **Batch "yolo-recorded-authority":** Steps 2, 3 (ship together; tail = Step 3).
|
||||
Step 2 relocates the yolo decision with observable review-log/decision-event field changes and Step 3 is its cleanup.
|
||||
- Independently releasable: Steps 1, 4 (test-only; hidden changelog type), Steps 5, 6, 8 (refactors; auto-batch into the next release), Step 7 (**breaking** — ships as its own major-bump release).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **The spine itself.**
|
||||
The `Authorizer` interface and its three implementations, `canConfirm()` dissolution, serving-as-resolution, the one-hop canary, grant-scope selection, and yolo inheritance are Phase 9 — this phase only removes the friction in their way.
|
||||
- **The serve-arm yolo check.**
|
||||
It survives Phase 8 (one isolated `if`) and dissolves when `processInbox` is refactored onto `evaluate()` plus Authorizer selection.
|
||||
- **Principal identity and cross-session path portability.**
|
||||
Still the deferred access-intent design work; the forwarded request keeps carrying display fields, not a re-evaluable intent, until then.
|
||||
- **A big-bang `src/` reorganization.**
|
||||
Only the files Phase 8 already rewrites move into `src/authority/`; see the directory sketch below.
|
||||
|
||||
## Directory sketch (forward-looking)
|
||||
|
||||
Phase 8 seeds `src/authority/` with the modules it rewrites: `subagent-detection.ts` and `subagent-context.ts` (Step 5), then `approval-escalator.ts`, `forwarded-request-server.ts`, and `forwarding-io.ts` (Step 6).
|
||||
The remaining elicitation modules (`gate-prompter.ts`, `prompting-gateway.ts`, `permission-prompter.ts`, `permission-dialog.ts`, `permission-forwarding.ts`, `subagent-registry.ts`) migrate in Phase 9 as the spine rewrites them into the `Authorizer` interface and its implementations — there is no peer `subagent/` domain, because the subagent machinery is the cross-session edge of `authority/`.
|
||||
|
||||
[#309]: https://github.com/gotgenes/pi-packages/issues/309
|
||||
[#472]: https://github.com/gotgenes/pi-packages/issues/472
|
||||
[#525]: https://github.com/gotgenes/pi-packages/issues/525
|
||||
[#526]: https://github.com/gotgenes/pi-packages/issues/526
|
||||
[#527]: https://github.com/gotgenes/pi-packages/issues/527
|
||||
[#528]: https://github.com/gotgenes/pi-packages/issues/528
|
||||
[#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
|
||||
[#532]: https://github.com/gotgenes/pi-packages/issues/532
|
||||
[#547]: https://github.com/gotgenes/pi-packages/issues/547
|
||||
@@ -0,0 +1,144 @@
|
||||
# Phase 9: The Authorizer spine
|
||||
|
||||
Phase 9 builds the [authority model](../architecture.md#the-authority-model) spine that Phase 8 tidied for: the `Authorizer` interface and its three implementations, `canConfirm()` dissolution, serving-as-resolution, human-selectable grant-scope, and the mechanical completion of the `authority/` directory migration.
|
||||
|
||||
## Findings
|
||||
|
||||
The cause is first-principles, not tool-sourced: the live-authority path — what happens on `ask` — has no single owner.
|
||||
The deontic question "who may decide, and how do we reach them" is answered by an accretion of collaborators: `GateRunner` asks `GatePrompter.canConfirm()`, `PromptingGateway` computes it from `hasUI || isSubagent`, `ApprovalEscalator.requestApproval` re-branches on the same predicates per prompt, and `PermissionPrompter.prompt` reads `ctx.hasUI` a third time for event emission.
|
||||
"No authority reachable" is represented twice with different logging (`applyPermissionGate`'s `ask` + `!canConfirm` arm and `requestApproval`'s not-a-subagent arm).
|
||||
The serving side (`ForwardedRequestServer.processSingleForwardedRequest`) answers escalations with bespoke logic — its own yolo check (the last one outside the composed ruleset) and no `evaluate()` — so a parent `allow`/`deny` rule does not govern a child's escalation.
|
||||
Fallow corroborates the symptoms: the three largest non-test functions after the composition root are exactly the ask-path modules (`runDescriptor` 130 lines, `processSingleForwardedRequest` 117, `waitForForwardedApproval` 77); dead code is 0 and duplication is 0.4%.
|
||||
|
||||
| Metric | Phase 8 exit | Phase 9 target |
|
||||
| --------------------------------------------------------------------------------------- | ---------------------------- | ---------------- |
|
||||
| Health score | 78 B | ≥ 78 |
|
||||
| Dead exports / files | 0 | 0 |
|
||||
| Ask-path role interfaces (`GatePrompter`, `PermissionPrompterApi`, `ApprovalRequester`) | 3 | 1 (`Authorizer`) |
|
||||
| `canConfirm` occurrences in `src/` | 15 across 5 modules | 0 |
|
||||
| `hasUI` / `isSubagent` evaluations per ask | 3+ per prompt | once per session |
|
||||
| Yolo checks outside the composed ruleset | 1 (`ForwardedRequestServer`) | 0 |
|
||||
| `processSingleForwardedRequest` | 117 lines | < 60 lines |
|
||||
| Flat `src/` root modules | ~67 | ~62 |
|
||||
|
||||
Scope decisions from planning: grant-scope selection ([resolved direction](../architecture.md#resolved-direction) 4) is included as the tail step; the `ModelTriageAuthorizer` ([#472]) is deferred to a later phase with its own decision record — the Step 1 seam is its extension point.
|
||||
The two production clone groups (58 lines total, unrelated to the spine) score polish-tier (Priority ≤ 10) and are deferred.
|
||||
Open issues swept and out of scope: [#309] (advisory bash-path fidelity), [#490] (indirection-wrapper flooring), [#520] (win32 backslash-relative bug), [#521] (read-only command allowlisting), [#519] (SDK UIContext clarification), [#23] (upstream-fork per-agent override evaluation).
|
||||
|
||||
## Steps
|
||||
|
||||
1. **✅ Introduce the `Authorizer` spine: interface, three implementations, once-per-session selection.**
|
||||
([#555]) Cause: the three-way "who decides" dispatch is buried inside `ApprovalEscalator.requestApproval` and re-derived per prompt; the fallow signal (`waitForForwardedApproval` at 77 lines inside a class that also owns dispatch) is a symptom.
|
||||
Target: new `src/authority/authorizer.ts` (`Authorizer` interface — `authorize(details): Promise<PermissionPromptDecision>` — plus `selectAuthorizer(ctx, detection)`), new `src/authority/local-user-authorizer.ts` (owns `ctx.ui` + `requestPermissionDecisionFromUi` + direct UI-prompt event emission), new `src/authority/denying-authorizer.ts` (least-privilege deny), `src/authority/approval-escalator.ts` (sheds its `hasUI` and not-a-subagent arms; its forwarding machinery becomes the `ParentAuthorizer`), `src/prompting-gateway.ts` rewritten as the selection owner at `src/authority/authorizer-selection.ts` (context stored at `activate`, authorizer selected once per session), `src/permission-prompter.ts` → `src/authority/permission-prompter.ts` (keeps review-log bracketing, delegates to the selected `Authorizer`, drops per-call `ctx` threading).
|
||||
Smell: Category C (missing domain concept; relay chain of 4 role interfaces to reach one dialog).
|
||||
Outcome: the `hasUI`/`isSubagent`/deny dispatch exists in exactly one place (`selectAuthorizer`); predicates evaluated once per session activation; behavior-neutral — existing review-log and decision-event tests pass unchanged.
|
||||
Landed: `src/authority/authorizer.ts` (`Authorizer` interface, `AuthorizerSelectionDeps`, `selectAuthorizer`), `local-user-authorizer.ts`, `denying-authorizer.ts`, and `authorizer-selection.ts` (`AuthorizerSelection`, the `PromptingGateway` rewrite) landed in one commit alongside the moved `authority/permission-prompter.ts` and the wired `index.ts`; a second commit folded `ApprovalEscalator` directly into `ParentAuthorizer` (`approval-escalator.ts`), removing the transitional wrapper, the dead `hasUI`/`!isSubagent` arms, and the now-unused `ApprovalRequester` interface and `detection` dependency.
|
||||
`GatePrompter.canConfirm()` survives unchanged, as planned — dissolved next in Step 2.
|
||||
Impact 5 / Risk 3 / Priority 15.
|
||||
Release: independent
|
||||
|
||||
2. ✅ **Dissolve `canConfirm()`: the ask path always escalates.**
|
||||
([#556]) Cause: "can anyone answer" is a pre-check duplicating the selection knowledge; with `DenyingAuthorizer`, absent authority is an authorizer that answers, not a boolean smeared across the gateway, gate, and runner.
|
||||
Target: delete `src/gate-prompter.ts`; `src/permission-gate.ts` drops the `canConfirm` param (`ask` always awaits `promptForApproval`); `src/handlers/gates/runner.ts` drops the pre-check; `src/handlers/gates/helpers.ts` derives `confirmation_unavailable` from a marker on the `DenyingAuthorizer`'s decision (mirroring the existing `autoApproved` marker).
|
||||
Landed: `GatePrompter` deleted and replaced by the single-method `AskEscalator` seam (`escalate(details)`, `authorizer-selection.ts`); `permission-gate.ts`/`runner.ts`/`helpers.ts` shed the `canConfirm` plumbing; `DenyingAuthorizer` denies with a `confirmationUnavailable` marker and `PermissionPrompter` surfaces it as the denied entry's `resolution`.
|
||||
Smell: Category C (scattered boolean policy) / Category A (parameter dead after Step 1).
|
||||
Outcome: `canConfirm` occurrences in `src/` drop 15 → 0; `runDescriptor` sheds the pre-check plumbing.
|
||||
The ask path now escalates uniformly — the `DenyingAuthorizer` is bracketed like any authorizer — so the unavailable path is recorded as the prompter's `waiting`/`denied` entries (`resolution: confirmation_unavailable`, preserved via the marker) rather than a standalone gate-written `blocked` entry; the `confirmation_unavailable` decision event is unchanged.
|
||||
This is a deliberate design decision (uniform escalation over byte-identical review-log shape), so the review log differs from Step 1's target wording.
|
||||
Impact 4 / Risk 2 / Priority 16.
|
||||
Release: independent
|
||||
|
||||
3. **✅ Serving is resolution: rebuild `processInbox` on `evaluate()` + the serving session's `Authorizer`.**
|
||||
([#557]) Cause: the serving node answers escalations without consulting its own recorded authority ([resolved direction](../architecture.md#resolved-direction) 1), so parent policy cannot govern a child's escalation and yolo needs the bespoke serve-time check.
|
||||
Target: `src/authority/forwarded-request-server.ts` — inject a policy view + the `AskEscalator` seam; a request carrying `(surface, value)` resolves against the serving node's composed base ruleset (`agentName` undefined — the child applied its own per-agent overrides before forwarding; `allow`, including yolo-rewritten, auto-approves — yolo inheritance for free; `deny` auto-denies; `ask` or missing fields escalates through the seam); the escalated ask carries its forwarded provenance (requester agent/session, original `source`/`surface`/`value`) as data on `PromptPermissionDetails`, so `LocalUserAuthorizer` emits the non-degraded forwarded `permissions:ui_prompt` broadcast and the server sheds its bespoke emit + dialog path; remove `isYoloModeEnabled` + the `ConfigReader` dep; add the one-hop canary (loud warning when a request arrives from a requester whose registered parent is not the serving session).
|
||||
Smell: Category C (duplicate policy enforcement; single source of truth) / Category A (bespoke yolo arm).
|
||||
Outcome: zero yolo checks outside the composed ruleset; `processSingleForwardedRequest` < 60 lines; one `permissions:ui_prompt` emit site (`LocalUserAuthorizer`); behavior change (ships as `feat:`): parent `allow`/`deny` rules now govern children's escalations.
|
||||
Invariant (pinned by test): the forwarded `permissions:ui_prompt` broadcast stays non-degraded — original `source` and `surface`/`value` projection preserved, `forwarding` context populated — per the [#292] contract hardening documented in `docs/cross-extension-api.md`; rerouting the prompt through the `Authorizer` must not regress it.
|
||||
Landed: `ForwardedRequestServer` resolves each request on the injected `ServingPolicy` (recorded authority) and escalates `ask`/field-less requests through the `AskEscalator` seam; `LocalUserAuthorizer` became the single `permissions:ui_prompt` emit site rendering forwarded provenance from `PromptPermissionDetails` (the `buildDirectUiPrompt`/`buildForwardedUiPrompt` split folded into `buildUiPrompt`); the bespoke yolo check + `ConfigReader` dep are gone and the one-hop canary warns on a multi-hop/misrouted requester.
|
||||
Design recorded in `docs/decisions/0005-serving-authorizer-provenance.md`; post-ship validation in [#565].
|
||||
Impact 5 / Risk 3 / Priority 15.
|
||||
Release: independent
|
||||
|
||||
4. **✅ Grant-scope selection on forwarded approvals.**
|
||||
([#558]) Cause: [resolved direction](../architecture.md#resolved-direction) 4 — a forwarded "for this session" grant can today land only on the requesting subagent; the human cannot choose the serving scope.
|
||||
Target: `src/permission-forwarding.ts` (request carries the child's suggested session pattern), `src/authority/approval-escalator.ts` (rides the existing `sessionApproval` suggestion along), `src/authority/forwarded-request-server.ts` (threads the scope choice into the escalated ask's details — after Step 3 the forwarded dialog is shown by `LocalUserAuthorizer` via the threaded provenance, not server-local prompting), `src/authority/local-user-authorizer.ts` + `src/permission-dialog.ts` (scope-aware dialog options — requesting subagent pre-selected as the least-privilege default); a whole-session grant records into the serving node's own `SessionRules`.
|
||||
Smell: completes the Category C authority model (feature riding the spine).
|
||||
Outcome: the forwarded dialog offers "this subagent only" (default) vs "whole session"; a whole-session grant suppresses future prompts for the parent and all children (verified by a composition-root round-trip test).
|
||||
Landed: the child rides its `SessionApproval` on `PromptPermissionDetails.sessionApproval` → `ForwardedPermissionRequest.sessionApproval` (tolerant read); `LocalUserAuthorizer` offers a two-step scope select (`buildForwardedScopeLabels`) for a forwarded ask carrying a suggestion; a whole-session choice returns the serving-node-internal `approved_for_serving_session` state, which `ForwardedRequestServer.applyGrantScope` records into the serving `SessionRules` and translates to a plain `approved` (child records nothing, re-forwards, auto-approves).
|
||||
Design recorded in `docs/decisions/0006-forwarded-grant-scope-selection.md`.
|
||||
Impact 3 / Risk 3 / Priority 9.
|
||||
Release: independent
|
||||
|
||||
5. ✅ **Complete the `authority/` migration.**
|
||||
([#559]) Cause: Phase 8's forward-looking directory sketch names the elicitation and subagent modules as `authority/` residents; Steps 1–4 rewrite most of them into place, and this step moves the mechanical remainder so the domain is closed and files move once.
|
||||
Target: `src/permission-dialog.ts`, `src/permission-forwarding.ts`, `src/subagent-registry.ts`, `src/subagent-lifecycle-events.ts`, `src/forwarding-manager.ts` → `src/authority/`; imports rewritten via the `#src/` aliases.
|
||||
Smell: Category E (flat directory).
|
||||
Outcome: all escalation/forwarding/subagent modules live under `src/authority/`; flat `src/` root drops ~67 → ~62 modules; no behavior change.
|
||||
Landed: all five modules relocated via `git mv`; parent-relative imports rewritten to `#src/authority/…` aliases (mechanically verified by `tsc` + eslint's `no-parent-relative-imports` rule); five test files moved into `test/authority/` to match the established layout; no logic changes.
|
||||
Impact 2 / Risk 1 / Priority 10.
|
||||
Release: independent
|
||||
|
||||
## Step dependency diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["✅ Step 1 (#555)<br/>Authorizer interface + selection"]
|
||||
S2["✅ Step 2 (#556)<br/>Dissolve canConfirm"]
|
||||
S3["✅ Step 3 (#557)<br/>Serving is resolution"]
|
||||
S4["✅ Step 4 (#558)<br/>Grant-scope selection"]
|
||||
S5["✅ Step 5 (#559)<br/>Complete authority/ migration"]
|
||||
|
||||
S1 --> S2
|
||||
S1 --> S3
|
||||
S3 --> S4
|
||||
S2 --> S5
|
||||
S4 --> S5
|
||||
```
|
||||
|
||||
## Parallel tracks
|
||||
|
||||
- **Track A — spine:** Step 1 → Step 2.
|
||||
- **Track B — serving:** Step 1 → Step 3 → Step 4 (parallel to Track A after Step 1; disjoint files).
|
||||
- **Track C — organization:** Step 5, after both tracks land.
|
||||
|
||||
## Release batches
|
||||
|
||||
- No multi-step batch: every step leaves the package consistent on its own.
|
||||
- Independently releasable: Steps 1, 2, 5 (refactors; hidden changelog type, auto-batch into the next release), Steps 3, 4 (`feat:` — each cuts a release on landing).
|
||||
|
||||
## Completion
|
||||
|
||||
All 5 steps are closed: [#555], [#556], [#557], [#558], [#559].
|
||||
Follow-on issue [#565] (validate serving-is-resolution decisions post-ship) was opened alongside Step 3 to track live validation of the new parent-governs-child-escalation behavior; it is non-gating and remains open for that follow-up observation.
|
||||
Open issues swept and confirmed out of scope during planning: [#309], [#490], [#520], [#521], [#519], [#23].
|
||||
The `ModelTriageAuthorizer` ([#472]) remains deferred to a later phase with its own decision record.
|
||||
|
||||
### Delivered vs. predicted metrics
|
||||
|
||||
Recomputed at archive time (`pnpm fallow:health` / `pnpm fallow:dupes --workspace @gotgenes/pi-permission-system`):
|
||||
|
||||
| Metric | Phase 9 target | Delivered |
|
||||
| ---------------------------------------- | ------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Health score | ≥ 78 | 78 (B) — met |
|
||||
| Dead exports / files | 0 | 0.0% / 0.0% — met |
|
||||
| Ask-path role interfaces | 1 (`Authorizer`) | 1 (`Authorizer`, three implementations) — met |
|
||||
| `canConfirm` occurrences in `src/` | 0 | 0 functional occurrences (one explanatory comment) — met |
|
||||
| Yolo checks outside the composed ruleset | 0 | 0 — met |
|
||||
| `processSingleForwardedRequest` | < 60 lines | 39 lines — met |
|
||||
| Flat `src/` root modules | ~62 | 62 — met |
|
||||
| Duplication | (not separately targeted) | 0.2% (58 lines, 2 clone groups, unrelated to the spine; deferred as planned) |
|
||||
|
||||
[#23]: https://github.com/gotgenes/pi-packages/issues/23
|
||||
[#292]: https://github.com/gotgenes/pi-packages/issues/292
|
||||
[#309]: https://github.com/gotgenes/pi-packages/issues/309
|
||||
[#472]: https://github.com/gotgenes/pi-packages/issues/472
|
||||
[#490]: https://github.com/gotgenes/pi-packages/issues/490
|
||||
[#519]: https://github.com/gotgenes/pi-packages/issues/519
|
||||
[#520]: https://github.com/gotgenes/pi-packages/issues/520
|
||||
[#521]: https://github.com/gotgenes/pi-packages/issues/521
|
||||
[#555]: https://github.com/gotgenes/pi-packages/issues/555
|
||||
[#556]: https://github.com/gotgenes/pi-packages/issues/556
|
||||
[#557]: https://github.com/gotgenes/pi-packages/issues/557
|
||||
[#558]: https://github.com/gotgenes/pi-packages/issues/558
|
||||
[#559]: https://github.com/gotgenes/pi-packages/issues/559
|
||||
[#565]: https://github.com/gotgenes/pi-packages/issues/565
|
||||
@@ -0,0 +1,85 @@
|
||||
# PermissionPrompter
|
||||
|
||||
`src/authority/permission-prompter.ts`
|
||||
|
||||
## Responsibility
|
||||
|
||||
`PermissionPrompter` brackets the ask-path flow with review-log entries and delegates the live decision to the selected `Authorizer` ([#555]):
|
||||
|
||||
1. **Review log — waiting** — write `permission_request.waiting` before the authorizer is consulted.
|
||||
2. **`authorizer.authorize(details)`** — the selected `Authorizer` (`LocalUserAuthorizer`, `ParentAuthorizer`, or `DenyingAuthorizer`) resolves the decision.
|
||||
The UI-prompt broadcast and the UI/forwarding branching this class previously owned now live on the individual `Authorizer` implementations — see [architecture.md's authority model](architecture.md#the-authority-model).
|
||||
3. **Review log — outcome** — write `permission_request.approved` or `permission_request.denied` with the final decision state, any denial reason, and the decision's `decidedBy` provenance ([#726]).
|
||||
The denied entry's `resolution` is the decision state, or `confirmation_unavailable` when the decision carries that marker — a `DenyingAuthorizer` denial, i.e. no live authority was reachable (a no-UI, non-subagent session) ([#556]).
|
||||
|
||||
Only the outcome entries carry `decidedBy`; the waiting entry does not, because nothing has decided yet and a `null` there would read as decided-by-nobody.
|
||||
The prompter records what the decision states rather than deriving it — which is what lets one entry distinguish a human at the dialog, a chain link, an unreachable authority, and another session's answer, where the shape alone cannot.
|
||||
|
||||
Yolo-mode auto-approval is resolved upstream: at the composition stage (`PermissionManager.check`'s `rewriteAsksToYolo`) for a rule-driven ask, and at `GateRunner`'s auto-approve fast path (`resolveYoloGrant`) for an ask synthesized after resolution, which no rule rewrite can reach ([#712]).
|
||||
An `ask` never reaches this class under yolo, so `PermissionPrompter` has no yolo-mode knowledge.
|
||||
|
||||
## Why a class instead of a free function
|
||||
|
||||
The previous implementation was `promptPermission(runtime, forwardingDeps, ctx, details)` in `runtime.ts`.
|
||||
Adding a new field to `PromptPermissionDetails` (e.g. `sessionLabel` in #51) required touching four files: `types.ts` → `runtime.ts` → `polling.ts` → `index.ts`.
|
||||
|
||||
With `PermissionPrompter`, adding a new field touches two files:
|
||||
|
||||
- `src/authority/permission-prompter.ts` — add the field to `PromptPermissionDetails`.
|
||||
- The `Authorizer` implementation(s) that read the new field — currently `local-user-authorizer.ts` and `approval-escalator.ts` (`ParentAuthorizer`).
|
||||
|
||||
Handler code and wiring in `index.ts` are unaffected.
|
||||
|
||||
## Interfaces
|
||||
|
||||
```typescript
|
||||
interface PermissionPrompterApi {
|
||||
prompt(authorizer: Authorizer, details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
|
||||
}
|
||||
|
||||
interface PermissionPrompterDeps {
|
||||
logger: ReviewLogger; // review-log bracketing only
|
||||
}
|
||||
```
|
||||
|
||||
`PermissionPrompterApi` is the narrow seam `AuthorizerSelection` depends on (not the concrete class) — a private field on the concrete class would create a nominal brand a structural test mock (`{ prompt: vi.fn() }`) cannot satisfy without a cast.
|
||||
|
||||
`Authorizer` is the single live-authority role, defined in `src/authority/authorizer.ts`:
|
||||
|
||||
```typescript
|
||||
interface Authorizer {
|
||||
authorize(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
|
||||
}
|
||||
```
|
||||
|
||||
## Relationship to the Authorizer spine
|
||||
|
||||
`PermissionPrompter` no longer assembles or holds any UI/forwarding dependency — it receives the already-selected `Authorizer` as a call-time argument from `AuthorizerSelection.prompt(details)`, rather than threading `ExtensionContext` through a `forwarder.requestApproval(ctx, …)` call.
|
||||
`AuthorizerSelection` (the rewrite of the former `PromptingGateway`) owns the selection: `selectAuthorizer(ctx, deps)` runs once per session activation and returns a `SelectedAuthority` for that context — the terminal (`LocalUserAuthorizer` when `ctx.hasUI`, `ParentAuthorizer` when the context is a no-UI subagent, `DenyingAuthorizer` otherwise) plus `adjudicatesLocally`, which is false for the relaying `ParentAuthorizer` arm so that node resolves no chain links (one chain per node, ADR 0007 §7).
|
||||
|
||||
## Wiring
|
||||
|
||||
`PermissionPrompter` is instantiated once in `piPermissionSystemExtension()` (`src/index.ts`) and injected into `AuthorizerSelection`:
|
||||
|
||||
```typescript
|
||||
const prompter = new PermissionPrompter({ logger });
|
||||
|
||||
const authorizerSelection = new AuthorizerSelection({
|
||||
detection: subagentDetection,
|
||||
events: pi.events,
|
||||
requestPermissionDecisionFromUi,
|
||||
forwardingDir: paths.forwardingDir,
|
||||
registry: subagentRegistry,
|
||||
logger,
|
||||
prompter,
|
||||
});
|
||||
```
|
||||
|
||||
`authorizerSelection` implements `AskEscalator` and is passed to both `PermissionSession` (as the `activate`/`deactivate` lifecycle) and `GateRunner` (as the `escalate(details)` ask-escalation role).
|
||||
`GateRunner` calls `this.prompter.escalate(details)` for every `ask` — there is no `canConfirm()` pre-check ([#556] dissolved it); the selected `Authorizer` always answers, the `DenyingAuthorizer` by denying with the `confirmationUnavailable` marker.
|
||||
The Authorizer spine is entirely behind that seam.
|
||||
|
||||
[#555]: https://github.com/gotgenes/pi-packages/issues/555
|
||||
[#556]: https://github.com/gotgenes/pi-packages/issues/556
|
||||
[#726]: https://github.com/gotgenes/pi-packages/issues/726
|
||||
[#712]: https://github.com/gotgenes/pi-packages/issues/712
|
||||
@@ -0,0 +1,232 @@
|
||||
# Current Architecture
|
||||
|
||||
This document describes the permission system's as-is design, identifies structural strengths worth preserving, and names the debt that motivates the target architecture.
|
||||
|
||||
## Overview
|
||||
|
||||
The extension intercepts Pi's extension lifecycle events and applies policy-driven permission gates before tool execution.
|
||||
Policy is loaded from JSON config files (global, project, per-agent frontmatter), merged by precedence, and checked against tool call inputs at runtime.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Config["Config loading"]
|
||||
G["Global config<br/>~/.pi/agent/extensions/…/config.json"]
|
||||
P["Project config<br/><cwd>/.pi/extensions/…/config.json"]
|
||||
A["Agent frontmatter<br/>agents/<name>.md YAML"]
|
||||
end
|
||||
|
||||
G --> Merge["mergePermissions()"]
|
||||
P --> Merge
|
||||
A --> Merge
|
||||
Merge --> Resolved["ResolvedPermissions"]
|
||||
|
||||
subgraph Compiled["Compiled pattern caches"]
|
||||
CB["compiledBash<br/>(BashFilter)"]
|
||||
CM["compiledMcp"]
|
||||
CS["compiledSkills"]
|
||||
CSP["compiledSpecial"]
|
||||
end
|
||||
|
||||
Resolved --> CB
|
||||
Resolved --> CM
|
||||
Resolved --> CS
|
||||
Resolved --> CSP
|
||||
|
||||
subgraph Events["Pi extension events"]
|
||||
BAS["before_agent_start"]
|
||||
TC["tool_call"]
|
||||
INP["input"]
|
||||
end
|
||||
|
||||
BAS --> TF["Tool filtering<br/>+ prompt sanitization"]
|
||||
TC --> Gate["Permission gate pipeline"]
|
||||
INP --> SIG["Skill input gate"]
|
||||
|
||||
TF --> GTP["getToolPermission()"]
|
||||
Gate --> CP["checkPermission()"]
|
||||
SIG --> CP
|
||||
|
||||
GTP --> Resolved
|
||||
CP --> Resolved
|
||||
```
|
||||
|
||||
## Module map
|
||||
|
||||
```text
|
||||
src/
|
||||
├── index.ts Main extension factory — event wiring, ~1050 lines
|
||||
├── permission-manager.ts Config loading + merge + checkPermission(), ~940 lines
|
||||
├── permission-gate.ts Pure deny/ask/allow gate (injected IO)
|
||||
├── permission-dialog.ts Dialog options: Yes / Yes for session / No / No with reason
|
||||
├── session-rules.ts Ephemeral session approvals — Ruleset-based, external_directory only
|
||||
├── bash-filter.ts Wildcard matching for bash commands
|
||||
├── wildcard-matcher.ts Compiled glob → RegExp engine
|
||||
├── external-directory.ts Path-outside-cwd detection and prompt formatting (tree-sitter-bash AST for bash commands)
|
||||
├── skill-prompt-sanitizer.ts Skill prompt filtering by policy
|
||||
├── system-prompt-sanitizer.ts Remove denied tools from system prompt text
|
||||
├── tool-input-preview.ts Pure tool-input text utilities (truncation, line counting, count formatting) + serialization
|
||||
├── tool-input-prompt-formatters.ts Per-tool prompt formatters (edit/write/read) + getPromptPath helper
|
||||
├── tool-registry.ts Validate tool names against registered tools
|
||||
├── config-loader.ts JSON/JSONC parsing, legacy path detection
|
||||
├── config-paths.ts Canonical path derivation for all config scopes
|
||||
├── extension-config.ts Runtime knobs (debugLog, yoloMode, etc.)
|
||||
├── config-reporter.ts Build structured log entries for resolved config
|
||||
├── config-modal.ts /permission-system slash command UI
|
||||
├── permission-prompts.ts User-facing message formatting per surface
|
||||
├── active-agent.ts Detect current agent name from session/system prompt
|
||||
├── subagent-context.ts Detect subagent execution for forwarding
|
||||
├── permission-forwarding.ts Constants for cross-session approval forwarding
|
||||
├── forwarded-permissions/ Poll-based approval forwarding for subagents
|
||||
├── logging.ts JSONL review/debug log writer
|
||||
├── status.ts Footer status bar integration
|
||||
├── yolo-mode.ts Auto-approve logic
|
||||
├── common.ts Shared parsing utilities
|
||||
├── types.ts Core type definitions
|
||||
└── before-agent-start-cache.ts Memoization for prompt sanitization
|
||||
```
|
||||
|
||||
## Data model
|
||||
|
||||
### Config shape (on disk)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"defaultPolicy": { "tools": "ask", "bash": "ask", "mcp": "ask", "skills": "ask", "special": "ask" },
|
||||
"tools": { "read": "allow", "write": "deny" },
|
||||
"bash": { "git status": "allow", "git *": "ask" },
|
||||
"mcp": { "exa:*": "allow", "mcp_status": "allow" },
|
||||
"skills": { "*": "ask" },
|
||||
"special": { "external_directory": "ask" }
|
||||
}
|
||||
```
|
||||
|
||||
### Runtime types
|
||||
|
||||
```typescript
|
||||
type PermissionState = "allow" | "deny" | "ask";
|
||||
|
||||
// Per-surface maps — all the same underlying shape
|
||||
type ToolPermissions = Record<string, PermissionState>;
|
||||
type BashPermissions = Record<string, PermissionState>;
|
||||
type SkillPermissions = Record<string, PermissionState>;
|
||||
type SpecialPermissions = Record<string, PermissionState>;
|
||||
|
||||
interface PermissionDefaultPolicy {
|
||||
tools: PermissionState;
|
||||
bash: PermissionState;
|
||||
mcp: PermissionState;
|
||||
skills: PermissionState;
|
||||
special: PermissionState;
|
||||
}
|
||||
|
||||
interface GlobalPermissionConfig {
|
||||
defaultPolicy: PermissionDefaultPolicy;
|
||||
tools: ToolPermissions;
|
||||
bash: BashPermissions;
|
||||
mcp: ToolPermissions;
|
||||
skills: SkillPermissions;
|
||||
special: SpecialPermissions;
|
||||
}
|
||||
```
|
||||
|
||||
### Permission check flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Input["toolName + input + agentName"] --> Resolve["resolvePermissions(agentName)"]
|
||||
Resolve --> Branch{{"Surface?"}}
|
||||
|
||||
Branch -->|special| S["findCompiledWildcardMatch(compiledSpecial, name)"]
|
||||
Branch -->|skill| SK["findCompiledWildcardMatch(compiledSkills, skillName)"]
|
||||
Branch -->|bash| B["BashFilter.check(command)"]
|
||||
Branch -->|mcp| M["createMcpPermissionTargets(input)<br/>→ findCompiledWildcardMatchForNames(compiledMcp, targets)"]
|
||||
Branch -->|built-in tool| T["merged.tools[name]"]
|
||||
Branch -->|other| D["merged.tools[name] ?? defaultPolicy.tools"]
|
||||
|
||||
S --> Result["PermissionCheckResult"]
|
||||
SK --> Result
|
||||
B --> Result
|
||||
M --> Result
|
||||
T --> Result
|
||||
D --> Result
|
||||
```
|
||||
|
||||
## Strengths to preserve
|
||||
|
||||
### 1. MCP multi-name target derivation
|
||||
|
||||
Pi's MCP integration surfaces tools with munged names like `search_exa` (tool_server) with no reliable delimiter.
|
||||
`createMcpPermissionTargets()` generates a priority-ordered candidate list:
|
||||
|
||||
```text
|
||||
MCP call to tool "search" on server "exa":
|
||||
→ exa_search (server_tool)
|
||||
→ exa:search (qualified)
|
||||
→ exa (server-level)
|
||||
→ search (bare tool)
|
||||
→ mcp_call (operation-level)
|
||||
```
|
||||
|
||||
`findCompiledWildcardMatchForNames()` returns the first match across candidates — so users can write `exa: allow` or `exa:search: deny` at different specificity levels.
|
||||
This multi-name lookup with priority ordering is unique to our platform and cannot be reduced to a single-pattern evaluation.
|
||||
|
||||
### 2. Per-surface default policy
|
||||
|
||||
```jsonc
|
||||
{ "defaultPolicy": { "tools": "allow", "bash": "ask", "mcp": "deny", "skills": "allow" } }
|
||||
```
|
||||
|
||||
One declaration sets different baselines per surface.
|
||||
A flat catch-all (`"*": "ask"`) requires explicit rules per surface to achieve the same effect.
|
||||
|
||||
### 3. Two-phase checking: tool exposure vs invocation
|
||||
|
||||
- `getToolPermission(toolName)` — used in `before_agent_start` to filter tools from the LLM entirely.
|
||||
Checks tool-level policy without inspecting command/input patterns.
|
||||
- `checkPermission(toolName, input)` — used in `tool_call` to gate specific invocations.
|
||||
|
||||
This separation prevents the agent from seeing tools it can never use — a stronger posture than letting it try and fail.
|
||||
|
||||
### 4. Compiled regex caching
|
||||
|
||||
Wildcard patterns are compiled to `RegExp` once at config-load time, keyed by file mtime.
|
||||
Re-evaluation skips regex construction entirely when config files haven't changed.
|
||||
|
||||
### 5. Deterministic last-match-wins semantics
|
||||
|
||||
Both our `findCompiledWildcardMatch()` (reverse iteration) and OpenCode's `findLast()` use last-match-wins.
|
||||
Our semantics are already aligned with the target model.
|
||||
|
||||
## Structural debt
|
||||
|
||||
### 1. Surface-specific branching in `checkPermission()`
|
||||
|
||||
The method is a ~120-line `if/else if` chain dispatching on `toolName`.
|
||||
Every branch does the same thing: match input against compiled patterns, fall back to default.
|
||||
Only MCP has genuinely different logic (multi-name lookup + baseline auto-allow).
|
||||
|
||||
### 2. Redundant type aliases
|
||||
|
||||
`ToolPermissions`, `BashPermissions`, `SkillPermissions`, `SpecialPermissions` are all `Record<string, PermissionState>`.
|
||||
Four aliases for the same shape.
|
||||
The compiler cannot distinguish them, so they add cognitive overhead without type safety.
|
||||
|
||||
### 3. ~~Two separate matching mechanisms for session approvals~~ *(resolved by #57)*
|
||||
|
||||
`SessionRules` now stores approvals as a plain `Ruleset` and evaluates them via `evaluate()` / `wildcardMatch()`.
|
||||
The former `SessionApprovalCache` prefix-matching engine (`isPathWithinDirectory()`) has been removed.
|
||||
|
||||
### 4. Monolithic `index.ts`
|
||||
|
||||
~1050 lines with six inline event handler closures sharing mutable state via closure variables.
|
||||
Covered by existing issues #42 (extract handlers) and #43 (eliminate module-scope state).
|
||||
|
||||
### 5. Config loading mixed into `PermissionManager`
|
||||
|
||||
`PermissionManager` handles file I/O, YAML frontmatter parsing, mtime-based caching, MCP server name discovery, config issue accumulation, **and** permission evaluation — all in one 940-line class.
|
||||
Permission evaluation is not independently testable without a filesystem.
|
||||
|
||||
### 6. `external_directory` gating lives in `index.ts`, not in `checkPermission()`
|
||||
|
||||
The external-directory and bash-external-directory gates are ~150 lines of inline logic in the `tool_call` handler, separate from `checkPermission()`.
|
||||
Session approval cache lookup, prompt formatting, and gate application are interleaved with the main permission flow.
|
||||
Reference in New Issue
Block a user