10 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 505 | pi-permission-system: dissolve the path-utils grab-bag behind AccessPath (Phase 7 Step 4) |
Retro: #505 — Dissolve the path-utils grab-bag behind AccessPath (Phase 7 Step 4)
Stage: Planning (2026-06-29T00:00:00Z)
Session summary
Produced a numbered plan to dissolve src/path-utils.ts (18 symbols, four jobs, accelerating churn hotspot) into six cohesive modules.
Representation derivation relocates into src/access-intent/path-normalization.ts as AccessPath's backing; both containment predicates stay together in a focused src/path-containment.ts; safe-system paths, infra-read, tool-input extraction, and the surface/tool lookup sets each get their own module.
The plan is Release: independent (a refactor:/docs: change that auto-batches into the next release).
Observations
- The "import cycle" the naïve split implies is not fundamental.
The operator pushed back on my "derivation vs containment" framing, observing that you should prepare the data, then ask questions about it — geometry should not depend on derivation.
Tracing it:
isPathWithinDirectoryis already pure geometry over prepared operands, andisPiInfrastructureReadalready receives an already-canonicalaccessPath.boundaryValue(). The lone offender isisPathOutsideWorkingDirectory, which canonicalizes both operands inline (the only geometry→representation edge). Fixing that mis-factoring — pure geometry over prepared operands, with canonicalization pushed up to its single callerPathNormalizer.isOutsideWorkingDirectory— collapses the tangle into a strict DAG and lets the issue's literal grouping stand (one representation module, one containment module). - Everything in
path-utils.tsis access-side, not config-side. Config rule patterns are never path-derived (a standing Phase 7 Non-goal), so the real seam is representation-for-matching vs geometry-for-boundaries, sharing theisPathWithinDirectoryprimitive. - Step 1 is a real behavior-contract change (TDD red→green); Steps 2–7 are pure relocations verified by the existing suite staying green after each move + importer update.
path-utils.test.ts(695 LOC) splits to mirror the new modules; theisPiInfrastructureReadblock duplicated withpi-infrastructure-read.test.tsconsolidates. - Doc surface to keep in sync (implementation doc commit, not deferred to ship):
architecture.mdmodule tree + Phase 7 Step 4 ✅ + MermaidS4node + findings metric + the "PathNormalizer platform seam" prose, andSKILL.md's twosrc/path-utils.tsreferences.history/phase-6-*.mdis a frozen snapshot — not edited. - Guardrails confirmed: no
import/no-cyclelint exists (so the cycle is a design smell, not a CI gate), but theno-restricted-syntaxprocess.platformguard does — every relocated leaf keeps its injectedplatformparameter.subagent-context.tshas its own private within-dir helper and is out of scope. - Next stage:
/tdd-plan(Step 1 is true TDD; the rest are refactor-relocation steps).
Stage: Implementation — TDD (2026-06-29T22:00:00Z)
Session summary
Executed all 8 TDD steps as planned, in one session, with no deviations.
src/path-utils.ts is fully dissolved into six cohesive modules (access-intent/path-normalization.ts, path-containment.ts, safe-system-paths.ts, pi-infrastructure-read.ts, tool-input-path.ts, path-surfaces.ts); the 695-LOC path-utils.test.ts split to mirror them.
Final state: 109 test files / 2194 tests green (net −8 from baseline 2202 — the isPiInfrastructureRead duplicate block consolidated into pi-infrastructure-read.test.ts, +3 unique win32 cases re-added, +2 from a new PATH_SURFACES describe).
Observations
- Step 1's red was nearly hollow — the
isPathOutsideWorkingDirectorysignature change kept the same arity (3 strings), so value-based tests pass against both old and new code. The genuine discriminator is behavioral: the pure function must not callrealpathSync(expect(realpathSync).not.toHaveBeenCalled()). That assertion failed on old code (2 realpath calls) and passed on new — a real red, per thetestingskill's "hollow red" warning. - Steps 2–7 are relocations, not red→green — the existing suite is the safety net; each step moved code + updated importers + carried the
describeblock to its new test file, staying green.pnpm run check(TS2305 on a mis-pointed import) was the real guard, run after each step. - The cycle stayed dissolved exactly as planned — because Step 1 made
isPathOutsideWorkingDirectorystop callingcanonicalNormalizePathForComparison, the residualpath-containment.tshas no representation import, andpath-normalization.tsimports only theisPathWithinDirectoryprimitive downward. Strict DAG; noimport/no-cyclelint needed to confirm (none exists). git mvfor Step 7 preserved history for bothpath-utils.ts→path-containment.tsand the test rename.pnpm fallow dead-codeclean (all moves keep their consumers).- One autoformat-reflow snag: a multi-edit
Editonpath-utils.test.tswas rejected because the formatter had reflowed anexpect(...)onto one line; fell back to a line-rangedseddeletion, then re-read to confirm. No content lost. - Pre-completion reviewer: PASS — all deterministic checks green (check / lint / 2194 tests / fallow), Mermaid validated via
mmdc, cross-step invariants (#502/#503/#382/#418/#510/#511) confirmed intact, no stalepath-utilsreferences insrc/test. No WARN/FAIL findings.
Stage: Final Retrospective (2026-06-30T00:00:00Z)
Session summary
Shipped #505 end-to-end across planning, TDD, and ship stages: src/path-utils.ts dissolved into six cohesive modules, 8 clean commits, pre-completion PASS, CI green, issue closed.
The standout of the whole arc was a planning-stage course-correction by the operator that turned an apparent unavoidable import cycle into a strictly-better DAG honoring the issue's literal module grouping.
The only friction was a ship-stage misprediction of whether release-please would cut a patch, rooted in an oversimplified line in the ship prompt.
Observations
What went well
- Bidirectional win (the standout) — during planning I surfaced the "how to break the derivation↔containment cycle" decision via
ask_userwith three module-layout options. The operator did not pick one; they asked a redirecting question ("why would the implementation of geometry rely on derivation? prepare your data, then ask questions about it"). That reframing revealed the cycle was an artifact of one mis-factored function (isPathOutsideWorkingDirectorycanonicalizing inline), not fundamental. The resulting design is strictly better — a clean DAG that also satisfies the issue's literal "one representation module, one containment module" grouping. Theask_usergate is what made the intervention possible; without surfacing the ambiguity, the operator had nothing to push on. - Exemplary incremental verification —
pnpm run checkplus the targetedvitest run <file>ran after each of the 8 TDD steps, not just at the end; full suite + root lint +fallow dead-codeat the close. A mis-pointed relocation import would have surfaced asTS2305within the same step. No feedback-loop gap. - "Hollow red" anticipated — Step 1's signature change kept the same arity (3 strings), so a value-based test would pass against both old and new code.
I added a behavioral discriminator (
expect(realpathSync).not.toHaveBeenCalled()) that genuinely failed on the old code — per thetestingskill's hollow-red warning.
What caused friction (agent side)
missing-context— At ship step 4b I predicted release-please would cut a patch because thedocs(pi-permission-system):commit touched.pi/skills/package-pi-permission-system/SKILL.md, a "non-excluded path." That was wrong: release-please attributes commits to a package only by thepackages/<pkg>/path prefix, and.pi/skills/lives outside the package tree, so it is attributed to no package. The commit's only in-package file (docs/architecture/architecture.md) is inexclude-paths. Release-please correctly reported "No user facing commits found — skipping," matching the plan's prediction but contradicting my step-4b reasoning. Impact: ~5 extra tool calls afterrelease_pr_findtimed out (gh pr list,ci_list, twogh run view --log | greppasses — the second a very large CI-log read) to rediscover what the plan already stated. No rework; wasted effort and context budget. Self-identified (worked it out from the log), but rooted in an oversimplified line in.pi/prompts/ship-issue.mdstep 4b ("adocs:commit on a non-excluded path does cut a patch").
Diagnostic details
- Feedback-loop gap analysis — no gap. Verification was incremental (check + targeted test after every step), the ideal pattern; flagged here only as a positive baseline.
- Escalation-delay tracking — the ship-stage release investigation ran ~5 consecutive read-only tool calls on the same question before concluding. Under the 5-call dispatch threshold and it was investigation, not error-chasing, but the answer was already in the plan and the ship prompt's step-4b intent — the dig was avoidable with a crisper prompt rule (below).
- Model-performance correlation — one subagent dispatch (the
pre-completion-reviewer) doing judgment-heavy work (acceptance criteria, design review, cross-step invariants); appropriate model for the task, no mismatch. Nothing else notable.
Changes made
.pi/prompts/ship-issue.md(step 4b) — replaced the line "adocs:commit on a non-excluded path does cut a patch" with a package-prefix-aware rule: adocs:commit cuts a patch only when it touches a non-exclude-pathsfile underpackages/<pkg>/; files outside the package tree (.pi/skills/, rootAGENTS.md/README.md) are attributed to no package and auto-batch. Prevents the ship-stage misprediction documented above.