---
issue: 511
issue_title: "pi-permission-system: retire the residual getPlatform() threading (infra-read + skill-prompt sanitization)"
---

# Retro: #511 — Retire the residual `getPlatform()` threading (infra-read + skill-prompt sanitization)

## Stage: Planning (2026-06-28T23:10:40Z)

### Session summary

Produced a numbered plan to route the two residual `platform`-threading sites (external-directory infra-read and skill-prompt sanitization) through the session's `PathNormalizer`, adding two methods (`comparableValue`, `isInfrastructureRead`) and dropping the `platform` parameters from `describeExternalDirectoryGate`, `describeSkillReadGate`, and the skill-sanitizer functions.
Filed follow-up #513 for the final `getPlatform()` removal, which neither #511 nor #502 can complete alone (the pipeline reads `getPlatform()` once and threads it to three gates; the tool gate keeps it until #502 lands).

### Observations

- The issue raises a genuine fork for skill sanitization (carry `AccessPath`s vs. resolve through the normalizer).
  Resolved decisively against carrying `AccessPath`s: `AccessPath.forPath` eagerly computes the canonical alias via `realpathSync` (`canonicalize-path.ts`), so it would add per-entry, per-turn filesystem access and shift lexical matching toward canonical — both behavior changes against a behavior-preserving refactor.
  Chose a lexical `comparableValue` method instead.
  Did not invoke `ask_user` because the behavior-preservation invariant removes the real choice (one option violates it).
- Confirmed `tcc.cwd === ctx.cwd === normalizer.cwd` (set in `permission-gate-handler.ts` and rebuilt on `session.activate`), which is what makes moving the `cwd` argument onto the baked normalizer behavior-preserving.
- `isInfrastructureRead` takes the already-built `AccessPath` (not a raw path) so the gate does not re-run `forPath`/`realpathSync` — Tell-Don't-Ask plus no double FS.
- Steps 1–4 of Phase 7 (#502–#505) are all still OPEN, so this plan deliberately leaves `getPlatform()` in place rather than removing it; the dependency, not ambiguity, forces that.
- `getPlatform()` removal depends only on #511 + #502 (the only two `getPlatform()` readers are `before-agent-start.ts` and the pipeline; the pipeline's read survives for the tool gate until #502).
  It does not depend on #503/#504, which touch `input-normalizer.ts` (that file gets `platform` elsewhere, not via `getPlatform()`).
- Doc surface to update: `architecture.md` (normalizer method list + the residual-threading subsection) and the package `SKILL.md` (normalizer method list in Debugging).
  Plans/retros under `docs/` are historical and left untouched.

## Stage: Implementation — TDD (2026-06-28T19:32:00Z)

### Session summary

Completed all four planned TDD cycles: added `PathNormalizer.comparableValue` + `isInfrastructureRead`, routed the external-directory infra-read through the normalizer, routed skill-prompt sanitization through the normalizer, and updated the architecture doc + package `SKILL.md`.
Test count went 2189 → 2195 (+6 new `PathNormalizer` tests); full suite, `check`, `lint`, and `fallow dead-code` all green.
Pre-completion reviewer returned PASS.

### Observations

- Pre-completion reviewer: PASS.
  No WARN findings.
- Deviation: also updated `test/handlers/external-directory-symlink-acceptance.test.ts` (not in the plan's listed test files) — it threaded the same `platform` arg into `describeExternalDirectoryGate`; `pnpm run check` caught it after the Step 2 production edit.
  Noted in the Step 2 commit body.
- The plan's lexical-no-FS decision held up: `comparableValue` delegates to `normalizePathForComparison` (no `realpathSync`), and `forPath` is intentionally absent from all skill-entry code paths.
- Self-inflicted friction: a blind `sed 's/"linux"/normalizer/g'` over the two skill test files also rewrote the `"linux"` inside the `new PathNormalizer("linux", cwd)` const declarations themselves (→ `new PathNormalizer(normalizer, cwd)`), which surfaced as a `ReferenceError: Cannot access 'normalizer' before initialization`.
  Fixed with a follow-up targeted `sed`.
  Lesson: exclude the constructor lines (or add the const after the bulk replacement) when mass-replacing a platform literal.
- `getPlatform()` left in place as planned (still feeds `describeToolGate` until #502); the `ToolCallGatePipeline.evaluate` `const platform` read remains.
  Final removal tracked in #513.
- biome's pre-commit hook reflowed the Step 3 sanitizer test (collapsed now-short multi-line calls); re-staged and committed cleanly.

## Stage: Final Retrospective (2026-06-28T23:53:50Z)

### Session summary

Shipped #511 end-to-end in one continuous session (plan → TDD → ship): a behavior-preserving refactor routing the two residual `platform`-threading sites through `PathNormalizer`, landing 4 commits, +6 tests (2189 → 2195), pre-completion PASS, CI green, issue closed.
No release was cut — every commit is `refactor:` (hidden changelog type) or `docs:` on an `exclude-paths` directory, so the work auto-batches into the next releasing commit, exactly as the plan's `**Release:** ship independently` rationale predicted.
The session was clean: the two friction points were minor, self-inflicted, and each caught immediately by a deterministic check (`tsc` / a test run).

### Observations

#### What went well

- **Hard-invariant resolution of an issue-flagged "decision."**
  The issue explicitly forked skill sanitization (carry `AccessPath`s vs. route through the normalizer).
  The behavior-preservation invariant (`AccessPath.forPath` eagerly calls `realpathSync` via `canonicalize-path.ts`) made one branch a behavior change, collapsing the fork to a single correct answer (a lexical `comparableValue` method) without spending an `ask_user` round.
  Recognizing when a constraint — not preference — settles a flagged decision kept planning tight.
- **Proactive cross-issue follow-up (#513).**
  The final `getPlatform()` removal cannot be completed by #511 or #502 alone (the pipeline reads it once and threads it to three gates; the tool gate keeps it until #502).
  Filing #513 during planning gave that order-independent cleanup an explicit home rather than leaving an untracked dangling accessor.
- **Incremental feedback loop did its job.** `pnpm run check` ran after each signature-changing step and caught the one un-planned test file (`external-directory-symlink-acceptance.test.ts`) the moment Step 2's production edit landed — no end-of-session surprise.
- **Pre-completion reviewer PASS on first dispatch** (ran on `anthropic/claude-sonnet-4-6`, model well-matched to judgment-heavy review).

#### What caused friction (agent side)

- `missing-context` (self-identified) — the plan's Module-Level Changes listed only `external-directory.test.ts` for the external-directory gate, but the planning-stage `grep -rln describeExternalDirectoryGate test/` had already surfaced `external-directory-symlink-acceptance.test.ts` too.
  The grep result was in hand; it just wasn't carried into the plan's test-file list.
  Impact: one extra `sed` fix during TDD Step 2, caught instantly by `tsc`.
  No rework beyond that; noted in the Step 2 commit body.
- `other` (self-identified) — a blind `sed 's/"linux"/normalizer/g'` over the two skill test files also rewrote the literal inside the `new PathNormalizer("linux", cwd)` const declarations I had just added (→ `new PathNormalizer(normalizer, cwd)`), producing `ReferenceError: Cannot access 'normalizer' before initialization`.
  Impact: ~2 extra tool calls (one failed test run + one targeted `sed`).
  Self-caught on the next test run.
  Lesson: when mass-replacing a literal, guard the new symbol's own declaration (replace before introducing the const, or exclude the constructor line).

#### What caused friction (user side)

- None.
  The session ran autonomously across all three stages with no user correction or redirect needed.

### Diagnostic details

- **Model-performance correlation** — one subagent dispatched (pre-completion-reviewer) on `anthropic/claude-sonnet-4-6`; appropriate for judgment-heavy review (acceptance criteria, design, invariants).
  No mismatch.
- **Feedback-loop gap analysis** — no gap: `pnpm run check` ran after each shared-signature step, the affected test file ran per Red/Green cycle, and the full suite + `lint` + `fallow` ran once before the pre-completion dispatch.
  Verification was incremental, not deferred.
- Escalation-delay and unused-tool lenses found nothing notable (no rabbit-hole exceeded 1 follow-up tool call; no missing-context warranted a subagent).

### Changes made

1. Added this Final Retrospective stage entry to `packages/pi-permission-system/docs/retro/0511-retire-residual-getplatform-threading.md`.
   No `AGENTS.md` or prompt changes — the session's two friction points were minor, self-caught by deterministic checks, and already covered by existing guidance (confirmed with the operator).
