Files
my-pi/pi-permission-system/docs/plans/0475-extract-command-enumeration-cwd-projection.md
T

30 KiB
Raw Blame History

issue, issue_title
issue issue_title
475 pi-permission-system: extract command enumeration and cwd projection; relocate the bash sub-domain (Phase 6 Step 3)

Extract command enumeration and cwd projection; relocate the bash sub-domain (born-ready BashProgram + cwd type fix)

Release Recommendation

Release: ship now — batch "bash-program-decomposition" tail (this issue completes the batch)

This is Step 3 of the Phase 6 access-intent roadmap and the tail of the bash-program-decomposition batch (Steps 1 #473, 2 #474, 3 [#475]). Steps 1 and 2 already landed on main with their releases deferred per the mid-batch marker. Landing Step 3 completes the batch, so the release-please PR should merge rather than stay open.

Caveat to confirm at ship time: every commit here is refactor: / docs: with no user-facing behavior change (the extension's permission decisions, config surface, and outputs are identical), so release-please will not derive a version bump from the batch alone. "Ship now" means "nothing is holding the batch back" — if no bumping commit has accumulated, no release is cut, which is correct for an internal-only refactor. Do not fabricate a fix:/feat: to force a bump.

Problem Statement

After Steps 1 and 2, src/handlers/gates/bash-program.ts is down to ~695 LOC but still mixes three distinct concerns:

  1. the BashProgram value-object API (parse once, expose typed slices),
  2. command enumeration — chain/substitution/subshell descent that emits each executed command unit, and
  3. the effective-working-directory cd-fold projection — the stateful AST walk that tags each path candidate with the working directory in force at its position, plus the per-candidate resolution that turns those tagged candidates into external paths and policy values.

The cd-fold projection is the subtlest region in the package (the home of the #307 and #454 fixes). It and the command enumeration are each independently testable concerns that do not belong in the value-object file. The file also still lives under handlers/gates/, which inverts the intended dependency direction: the gates should consume the access-intent engine, not host it.

Two design problems surfaced while planning this relocation, and both are folded into this issue:

  • BashProgram is not born-ready. parse(command) stores intermediate PathCandidate[] state, then externalPaths(cwd) / pathRuleCandidates(cwd) re-supply cwd on every call to finish the resolution lazily. But cwd is always available at parse time (it is tcc.cwd, threaded from ExtensionContext.cwd). An object should be constructed with all the state it needs; parse(command, cwd) can resolve eagerly and hand callers finished answers.
  • The cwd type is wrongly widened. ToolCallContext.cwd is typed string | undefined, but ExtensionContext.cwd is string in the SDK (non-optional — the same interface marks model and signal as | undefined, so cwd's presence is deliberate). The | undefined is a type-widening error introduced in this package. It spawned dead cwd-undefined branches in five gates that obscure the real invariant: if we are evaluating a tool call, tcc.cwd is a string.

Goals

  • Extract command enumeration into src/access-intent/bash/command-enumeration.ts.
  • Extract the cd-fold projection — the walk and the per-candidate resolution — into src/access-intent/bash/cwd-projection.ts (Option B: projection owns the whole lifecycle; EffectiveBase / PathCandidate never leave the module).
  • Make BashProgram born-ready: parse(command, cwd: string) resolves eagerly; commands() / externalPaths() / pathRuleCandidates() become parameter-free getters over stored resolved arrays.
  • Fix the cwd type widening: narrow ToolCallContext.cwd to string, and remove the now-dead cwd-undefined branches and obsolete tests across all five gates.
  • Relocate the slimmed BashProgram to src/access-intent/bash/program.ts and bash-token-classification.ts to src/access-intent/bash/token-classification.ts.
  • Repoint all bash gates and tests at #src/access-intent/bash/....
  • Sharpen the dependency direction: handlers/gates/ depends into access-intent/bash/, never the reverse.

This is not a breaking change to the extension's user-facing surface — no public command, config field, default, schema, or permission output changes. The changes are internal (module layout, in-package types, computation timing, dead-code removal). All commits are refactor: / docs:.

Non-Goals

  • No change to permission decisions, enumeration semantics, the cd-fold projection results, classification, or policy resolution — outputs are identical; only when they are computed (eager vs lazy) and the internal API shape change.
  • No collapse of BashProgram from a class to a function-returning-record. Under eager resolution the class is close to a data holder, but reshaping the value object belongs with Phase 6 Step 4 (#476), which already retypes BashProgram.externalPaths for the AccessPath value object.
  • No introduction of the AccessPath value object — Step 4.
  • No collapse of the two external-directory gates — Step 5.
  • No new index.ts barrel for access-intent/bash/ — consumers import the relocated modules directly (matching parser.ts / node-text.ts / token-collection.ts), so fallow does not flag speculative re-exports.
  • No migration of the package-level path helpers (path-utils, canonicalize-path) into access-intent/ — a later phase.

Background

Relevant existing modules (all under packages/pi-permission-system/src/):

  • handlers/gates/bash-program.ts — the file being decomposed. Exports BashProgram (class), BashCommand (interface), BashPathRuleCandidate (interface). Private: EffectiveBase, PathCandidate, the enumeration functions, the projection walk, and the per-candidate resolution helpers.
  • access-intent/bash/parser.ts — lazy tree-sitter-bash parser (getParser, TSNode). Seeded by Step 1.
  • access-intent/bash/node-text.tsresolveNodeText, SKIP_SUBTREE_TYPES, ARG_NODE_TYPES. Seeded by Steps 12.
  • access-intent/bash/token-collection.tscollectCommandTokens, collectPathCandidateTokens, collectRedirectTokens, extractCommandName. Seeded by Step 2.
  • handlers/gates/bash-token-classification.tsclassifyTokenAsPathCandidate (strict), classifyTokenAsRuleCandidate (broad), shared rejectNonPathToken.

The cwd invariant (verified during planning):

  • ExtensionContext.cwd: string (@earendil-works/pi-coding-agent) — non-optional.
  • permission-gate-handler.ts builds ToolCallContext with cwd: ctx.cwd, so tcc.cwd is always a string at runtime.
  • The widened ToolCallContext.cwd: string | undefined produced dead cwd-undefined handling in five gates: bash-external-directory.ts (|| !tcc.cwd guard), external-directory.ts (if (!tcc.cwd) return null), skill-read.ts (if (tcc.cwd === undefined) return null), path.ts (tcc.cwd ? … : filePath ternary), tool.ts (tcc.cwd ? … : path ternary). Two of these have dedicated "returns null when no CWD" tests (bash-external-directory.test.ts, external-directory.test.ts) exercising an input that cannot occur.

Consumers of BashProgram (all in handlers/gates/ unless noted):

  • tool-call-gate-pipeline.ts — calls BashProgram.parse, passes the program to the bash gates.
  • bash-command.ts — imports the BashCommand type; the handler decomposes via program.commands().
  • bash-path.tsdescribeBashPathGate calls program.pathRuleCandidates(tcc.cwd).
  • bash-external-directory.tsdescribeBashExternalDirectoryGate calls program.externalPaths(tcc.cwd).
  • bash-path-extractor.ts — thin facade extractExternalPathsFromBashCommand(command, cwd: string) over BashProgram (already types cwd: string).

AGENTS.md / skill constraints that apply:

  • package-pi-permission-system skill: the parser is module-scoped state that persists across same-cwd session switches (earendil-works/pi#5905); this change does not touch that. SKILL.md references the classifiers by name (classifyTokenAsPathCandidate / classifyTokenAsRuleCandidate) but not by file path, so no SKILL.md edit is needed.
  • docs/architecture/architecture.md carries a source-tree layout block and a Mermaid roadmap graph that reference bash-program.ts and bash-token-classification.ts by path — those must be updated when the files move.
  • Deferred-to-tail work from #474: its architecture Outcome: line still reads "drops below ~670 LOC" against the actual 695. Fold the correction into this plan's doc step.

Design Overview

The design has three parts: the cwd type fix (a self-contained correctness change that lands first), the born-ready eager-resolution model, and the Option-B module split. The facade-scope question (where the projection lives) is resolved to Option B because born-ready makes it the only coherent choice — once parse() resolves eagerly, there is no call-time orchestration left for the facade to retain, so the projection lives wholly in cwd-projection.ts.

Born-ready data flow — eager resolution at parse time

parse(command, cwd) does all the work; the instance stores only finished answers. PathCandidate[] and EffectiveBase are local to the projection call and never reach the instance.

flowchart TD
    IN["command string + cwd<br/>(cwd: string — always present)"]
    IN --> PARSE["BashProgram.parse(command, cwd)<br/>[program.ts]"]
    PARSE --> GP["getParser() then parser.parse()<br/>[parser.ts]"]
    GP --> TREE["AST tree (rootNode)"]

    TREE --> CE["collectCommands(rootNode)<br/>[command-enumeration.ts]"]
    TREE --> CP["collectPathCandidates(rootNode)<br/>[cwd-projection.ts walk]"]

    CP --> CAND["PathCandidate[] (local, internal to projection)<br/>{token, base: EffectiveBase}"]
    CAND --> PE["projectExternalPaths(candidates, cwd)<br/>[cwd-projection.ts]"]
    CAND --> PR["projectRuleCandidates(candidates, cwd)<br/>[cwd-projection.ts]"]

    CE --> CTOR["new BashProgram(commandUnits,<br/>resolvedExternalPaths, resolvedRuleCandidates)"]
    PE --> CTOR
    PR --> CTOR

    CTOR --> INST["born-ready instance<br/>(holds 3 resolved arrays only)"]
    INST --> G1["commands() returns BashCommand[]"]
    INST --> G2["pathRuleCandidates() returns BashPathRuleCandidate[]"]
    INST --> G3["externalPaths() returns string[]"]

The expensive tree-sitter parse still happens once; the projection (cheap, pure) now also runs once at parse time instead of on every slice call. Outputs are byte-for-byte identical to the lazy design — the slices were already pure functions of (candidates, cwd), and cwd is fixed for the call.

Module layout — Option B, born-ready

flowchart TD
    subgraph dom["src/access-intent/bash/ (the cohesive sub-domain)"]
        prog["program.ts — BashProgram (born-ready)<br/>parse(command, cwd) + 3 getters (~110 LOC)<br/>stores resolved arrays only; no classifier/path-utils imports"]
        proj["cwd-projection.ts<br/>walk + projectExternalPaths/projectRuleCandidates<br/>owns EffectiveBase, PathCandidate, BashPathRuleCandidate (~420 LOC)"]
        enm["command-enumeration.ts<br/>collectCommands + descent, owns BashCommand"]
        clsf["token-classification.ts (relocated, unchanged)"]
        base["parser.ts, node-text.ts, token-collection.ts (already here)"]
    end
    prog --> enm
    prog --> proj
    prog --> base
    proj --> clsf
    proj --> base
    proj --> putils["path-utils, canonicalize-path"]

    subgraph gates["src/handlers/gates/ (consumers — depend INTO the domain)"]
        pipe["tool-call-gate-pipeline.ts → parse(command, tcc.cwd)"]
        bcmd["bash-command.ts → program.commands()"]
        bpath["bash-path.ts → program.pathRuleCandidates()"]
        bext["bash-external-directory.ts → program.externalPaths()"]
        bextr["bash-path-extractor.ts (thin facade)"]
    end
    pipe --> prog
    bcmd --> enm
    bpath --> prog
    bext --> prog
    bextr --> prog

EffectiveBase / PathCandidate / BashPathRuleCandidate live in cwd-projection.ts; BashCommand lives in command-enumeration.ts (its producer); program.ts imports only what its three getters return.

cwd type fix — dead branches removed

Narrowing ToolCallContext.cwd to string makes the type checker enforce the invariant and turns five gate branches into compile errors-if-kept (they read a property that is now always present):

File Dead branch removed Replacement
bash-external-directory.ts || !tcc.cwd in the guard if (tcc.toolName !== "bash") return null;
external-directory.ts if (!tcc.cwd) return null; removed (guard above it still applies)
skill-read.ts if (tcc.cwd === undefined) return null; removed
path.ts tcc.cwd ? normalizePathForComparison(filePath, tcc.cwd) : filePath normalizePathForComparison(filePath, tcc.cwd)
tool.ts tcc.cwd ? normalizePathForComparison(path, tcc.cwd) : path normalizePathForComparison(path, tcc.cwd)

The getPolicyValuesForRuleCandidate if (!cwd) { literal-only } branch is likewise dead under born-ready (the projection always has cwd) and is removed with it.

Born-ready BashProgram sketch

export class BashProgram {
  private constructor(
    private readonly commandUnits: readonly BashCommand[],
    private readonly resolvedExternalPaths: readonly string[],
    private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
  ) {}

  static async parse(command: string, cwd: string): Promise<BashProgram> {
    const parser = await getParser();
    const tree = parser.parse(command);
    if (!tree) return new BashProgram([], [], []);
    try {
      const candidates = collectPathCandidates(tree.rootNode);
      return new BashProgram(
        collectCommands(tree.rootNode),
        projectExternalPaths(candidates, cwd),
        projectRuleCandidates(candidates, cwd),
      );
    } finally {
      tree.delete();
    }
  }

  commands(): BashCommand[] { return [...this.commandUnits]; }
  externalPaths(): string[] { return [...this.resolvedExternalPaths]; }
  pathRuleCandidates(): BashPathRuleCandidate[] { return [...this.resolvedRuleCandidates]; }
}

Consumer call sites get simpler (no cwd argument on the getters):

// tool-call-gate-pipeline.ts
const bashProgram =
  tcc.toolName === "bash" && command
    ? await BashProgram.parse(command, tcc.cwd) // tcc.cwd is now string
    : null;
// bash-external-directory.ts → program.externalPaths()
// bash-path.ts             → program.pathRuleCandidates()
// bash-path-extractor.ts   → (await BashProgram.parse(command, cwd)).externalPaths()

Alternatives considered

  • Facade scope A (facade retains the projection orchestration) and C (a resolveCandidateBase() helper) — both kept the per-candidate loop in the facade with the methods taking cwd. Born-ready eager resolution removes all call-time orchestration, so A/C no longer have anything to retain; Option B is the only coherent layout.
  • Collapse BashProgram to a function returning a record — under eager resolution the class is close to a data holder, so this is a legitimate direction. Deferred to Step 4 (#476), which already reshapes this value object (retyping externalPaths for AccessPath); doing it here would pre-empt that step.
  • Split the cwd type fix into its own prerequisite issue — considered; the operator chose to land it in #475 (all-in), since born-ready couples the ToolCallContext narrowing at the pipeline seam and the five-gate cleanup is small and mechanical.

Module-Level Changes

New files (all under src/access-intent/bash/):

  • command-enumeration.tscollectCommands, collectCommandsInto, makeUnit, descendCommandChildren, collectSubstitutionCommands, the COMMAND_ENUM_DESCEND / COMMAND_ENUM_SKIP / NESTED_EXECUTION_CONTEXTS tables, and the BashCommand interface (the type moves to its producer). Imports TSNode from parser.ts and BashCommandContext from #src/types. Exports collectCommands and BashCommand.
  • cwd-projection.ts — the projection walk (collectPathCandidates, walkForCandidates, walkCurrentShellSequence, walkPipeline, foldPipelineFirstStage, foldListExceptTerminal, isBackgrounded, tagTokens, foldCd, cdLiteralTarget, literalTextOf, CWD_BASE, UNKNOWN_BASE), the per-candidate helpers (getPolicyValuesForRuleCandidate, isRelativeCandidate, both taking cwd: string), and the two projection functions projectExternalPaths(candidates, cwd: string) / projectRuleCandidates(candidates, cwd: string). Owns the EffectiveBase, PathCandidate, and BashPathRuleCandidate types. Drops the dead if (!cwd) literal-only branch from getPolicyValuesForRuleCandidate. Imports TSNode from parser.ts, ARG_NODE_TYPES / SKIP_SUBTREE_TYPES from node-text.ts, the collectors + extractCommandName from token-collection.ts, the classifiers from token-classification.ts, plus path-utils and canonicalize-path. Exports collectPathCandidates, projectExternalPaths, projectRuleCandidates, and BashPathRuleCandidate.
  • program.ts — the born-ready BashProgram class only (see sketch). Private constructor takes the three resolved arrays; parse(command, cwd: string) resolves eagerly; the three getters are parameter-free. Imports getParser from parser.ts, collectCommands + BashCommand from command-enumeration.ts, and collectPathCandidates + projectExternalPaths + projectRuleCandidates + BashPathRuleCandidate from cwd-projection.ts.
  • token-classification.ts — relocated bash-token-classification.ts, content unchanged except the doc-comment phrase "consumed by bash-program.ts" → "consumed by cwd-projection.ts".

Removed files:

  • src/handlers/gates/bash-program.ts — content distributed across the three new files.
  • src/handlers/gates/bash-token-classification.ts — relocated to token-classification.ts.

Changed files — cwd type fix (lands first, Step 1):

  • src/handlers/gates/types.tsToolCallContext.cwd: string | undefinedstring.
  • src/handlers/gates/bash-external-directory.ts — drop || !tcc.cwd from the guard.
  • src/handlers/gates/external-directory.ts — remove if (!tcc.cwd) return null;.
  • src/handlers/gates/skill-read.ts — remove if (tcc.cwd === undefined) return null;.
  • src/handlers/gates/path.ts — collapse the tcc.cwd ? … : filePath ternary.
  • src/handlers/gates/tool.ts — collapse the tcc.cwd ? … : path ternary.
  • test/helpers/gate-fixtures.tsmakeTcc cwd override no longer accepts undefined (its default "/test/project" stands).
  • test/handlers/gates/bash-external-directory.test.ts — remove the "returns null when no CWD" test (makeTcc({ cwd: undefined })).
  • test/handlers/gates/external-directory.test.ts — remove the "returns null when no CWD" test.

Grep ToolCallContext object literals before the narrowing — only permission-gate-handler.ts (uses ctx.cwd: string) and makeTcc construct one; both already supply a string.

Changed files — born-ready signatures (Step 3, with the projection extraction):

  • src/handlers/gates/bash-external-directory.tsprogram.externalPaths() (drop tcc.cwd arg; tcc.cwd is still read for getExternalDirectoryPolicyValues and the descriptor cwd field).
  • src/handlers/gates/bash-path.tsprogram.pathRuleCandidates() (drop tcc.cwd arg).
  • src/handlers/gates/bash-path-extractor.ts(await BashProgram.parse(command, cwd)).externalPaths(); signature unchanged.
  • src/handlers/gates/tool-call-gate-pipeline.tsBashProgram.parse(command, tcc.cwd).

Changed files — import repoints (Step 4, relocation):

  • src/handlers/gates/bash-command.tsBashCommand import to #src/access-intent/bash/command-enumeration.
  • src/handlers/gates/bash-external-directory.ts, bash-path.ts, bash-path-extractor.ts, tool-call-gate-pipeline.tsBashProgram import to #src/access-intent/bash/program.

Test files — relocate and/or repoint (Step 4):

  • test/handlers/gates/bash-program.test.ts to test/access-intent/bash/program.test.ts; import to #src/access-intent/bash/program.
  • test/handlers/gates/bash-token-classification.test.ts to test/access-intent/bash/token-classification.test.ts; import to #src/access-intent/bash/token-classification.
  • test/handlers/gates/bash-external-directory.test.ts, bash-path.test.ts, bash-command-metamorphic.test.ts, test/handlers/external-directory-symlink-acceptance.test.tsBashProgram import repoints.
  • test/handlers/gates/tool-call-gate-pipeline.test.ts — the vi.mock("#src/handlers/gates/bash-program", …) factory path to #src/access-intent/bash/program; the mock's three methods become zero-arg (no signature change to the mock shape — they already return []).

Born-ready test updates (Step 3): program.test.ts (née bash-program.test.ts) call sites change from BashProgram.parse(cmd) + .externalPaths(cwd) / .pathRuleCandidates(cwd) to BashProgram.parse(cmd, cwd) + parameter-free getters; the "returns the literal token only when no cwd is provided" test is removed (the no-cwd path no longer exists). extractExternalPathsFromBashCommand(command, cwd) keeps its signature, so the ~90 call sites in test/bash-external-directory.test.ts are untouched.

Doc updates (docs/architecture/architecture.md, Step 5):

  • Source-tree layout block: under access-intent/bash/ add command-enumeration.ts, cwd-projection.ts, program.ts, token-classification.ts; remove bash-program.ts and bash-token-classification.ts from the handlers/gates/ block. Update the program.ts entry to describe the born-ready value object (parse-time resolution, parameter-free slices).
  • Inline ToolCallContext listing, if present in the doc's copied gate types, updated to cwd: string.
  • Mark Phase 6 Step 3 complete: on the Step 3 heading and the S3 Mermaid roadmap node.
  • Track A narrative note: update to reflect Step 3 landed.
  • Fold in the #474 deferred fix: correct the Step 2 Outcome: "drops below ~670 LOC" line.
  • Health metrics table: rename the bash-program.ts LOC / risk rows to program.ts with post-Step-3 actuals.

Test Impact Analysis

  1. New unit tests the extraction enables. Low value, as in Steps 12: command-enumeration.ts and cwd-projection.ts consume parse-derived TSNode trees and PathCandidate[], so isolated tests would mean hand-building tree/candidate fixtures. The parse-driven program.test.ts exercises the walk + projection end to end. No new isolated unit-test files are required.
  2. Tests that change shape. program.test.ts parse + slice call sites adopt the born-ready signatures (parse(cmd, cwd) + parameter-free getters). This is mechanical and touches only that file (plus the removed no-cwd case).
  3. Tests removed (dead inputs). Three tests assert behavior for cwd === undefined, which the narrowed type makes impossible: bash-external-directory.test.ts "returns null when no CWD", external-directory.test.ts "returns null when no CWD", and bash-program.test.ts "returns the literal token only when no cwd is provided". Removing them is correct — they document an input the SDK never produces.
  4. Tests that must stay as-is. Every projection / enumeration / classification assertion pins current behavior; the relocation and born-ready change must keep them green (only call-site shape and import paths change).

Invariants at risk

A behavior-preserving move must keep every documented invariant green:

  • The cd-fold projection invariants from #307 (conservative flagging after a non-literal cd) and #454 (folding a leading current-shell cd across a redirect-then-pipe) — pinned by the externalPaths cases in program.test.ts.
  • The never-weaker nested-command enumeration from #306 — pinned by the commands() cases.
  • The lexical-vs-canonical return contract from #418 and cd-aware policy values from #393 — pinned by the externalPaths / pathRuleCandidates cases.

These live in tests, not just prose; the eager-resolution change must produce identical arrays. The removed cwd-undefined branches were verified dead against ExtensionContext.cwd: string, so their removal does not change production behavior — only the (impossible) undefined-input tests go.

TDD Order

Each cycle keeps the suite green (behavior-preserving); there is no red phase. Because the born-ready signature change and the type narrowing each break consumers at the type level, those steps fold the source change, all consumer updates, and all consumer-test updates into one commit.

  1. Fix the cwd type widening. Narrow ToolCallContext.cwd to string; remove the dead cwd-undefined branches in bash-external-directory.ts, external-directory.ts, skill-read.ts, path.ts, tool.ts; update makeTcc; remove the two "returns null when no CWD" tests. Run pnpm run check + full suite. Commit refactor(pi-permission-system): narrow ToolCallContext.cwd to string and drop dead cwd-undefined gate branches.
  2. Extract command enumeration. Create command-enumeration.ts with the enumeration functions, tables, and BashCommand; remove them from bash-program.ts, importing collectCommands + BashCommand back; repoint bash-command.ts's BashCommand import. Run pnpm run check + full suite. Commit refactor(pi-permission-system): extract bash command enumeration to its own module.
  3. Extract the cwd projection and make BashProgram born-ready. Create cwd-projection.ts with the walk, the per-candidate helpers, projectExternalPaths / projectRuleCandidates (all taking cwd: string), and the EffectiveBase / PathCandidate / BashPathRuleCandidate types; drop the dead if (!cwd) literal branch. Rewrite BashProgram to born-ready: parse(command, cwd: string), three-array constructor, parameter-free getters. Update all callers — bash-external-directory.ts, bash-path.ts, bash-path-extractor.ts, tool-call-gate-pipeline.ts — and bash-program.test.ts (born-ready call sites; remove the no-cwd test). Run pnpm run check + full suite. Commit refactor(pi-permission-system): extract bash cwd projection and make BashProgram born-ready.
  4. Relocate the facade and classifiers into the sub-domain. Move bash-program.ts to access-intent/bash/program.ts and bash-token-classification.ts to access-intent/bash/token-classification.ts; repoint cwd-projection.ts's classifier import and all gate consumers; relocate bash-program.test.ts to test/access-intent/bash/program.test.ts and bash-token-classification.test.ts to test/access-intent/bash/token-classification.test.ts; repoint the remaining test imports and the tool-call-gate-pipeline.test.ts vi.mock path. Run pnpm run check + full suite + pnpm run lint + pnpm fallow dead-code. Commit refactor(pi-permission-system): relocate bash sub-domain under access-intent/bash.
  5. Update the architecture doc. Apply the layout-block, born-ready program.ts description, ToolCallContext type note, Step 3 , Track A, #474-deferred Outcome: fix, and health-metrics edits. Commit docs(pi-permission-system): record Phase 6 Step 3 bash sub-domain relocation.

Steps 24 may merge if a green intermediate state is awkward, but keep Step 1 first (it is independent and unblocks born-ready) and the doc update (Step 5) last.

Risks and Mitigations

  • Risk: the born-ready signature change ripples to many call sites. parse gains a parameter and the three getters lose one, breaking every consumer and test at the type level. Mitigation: fold the projection extraction, all gate-caller updates, and program.test.ts updates into Step 3; tsc after the step confirms none were missed.
  • Risk: removing a cwd-undefined branch that is actually reachable. Mitigation: the invariant is verified against the SDK type (ExtensionContext.cwd: string); the narrowed ToolCallContext.cwd makes any reachable use a compile error, so tsc proves the branches dead.
  • Risk: an import cycle between the new modules. Mitigation: every type lives with its producer (BashCommand → enumeration, EffectiveBase/PathCandidate/BashPathRuleCandidate → projection), so the graph is acyclic (program.ts → both; both → parser.ts). pnpm run check after each step confirms.
  • Risk: silently dropping a moved symbol during the large block moves. Mitigation: anchor edits on adjacent unique code lines (not decorative rules), re-read each moved region, rely on Biome noRedeclare / noUnusedImports + tsc.
  • Risk: a stale vi.mock path silently mocks nothing. Mitigation: repoint the tool-call-gate-pipeline.test.ts vi.mock factory path in Step 4 and confirm the pipeline tests still pass against the mock.

Open Questions

  • None blocking. Facade scope is resolved to Option B (born-ready forces it); the cwd-fix scope is resolved to all-in #475; the class→function reshape is deferred to Step 4 (#476).
  • No follow-up issues are filed by this plan: Step 4 (#476) covers the AccessPath value object and the BashProgram reshape; the external-directory gate collapse is Phase 6 Step 5.