[US-395] fix: cache slots keyed by source identity, not CLI version (shared-cache contamination) - #423
[US-395] fix: cache slots keyed by source identity, not CLI version (shared-cache contamination)#423rucka wants to merge 49 commits into
Conversation
… never lands in the official KB's slot Test-first: the reproduction (populated official slot + `install --source <zip>` => official manifest.json rewritten to the external KB) failed before the fix. - cache-manager: KBSource identity -> slot key. Official keeps ~/.pair/kb/<version>; every other source (zip, dir, git, remote url) gets ~/.pair/kb/external/<kind>-<label>-<hash> - cache-manager: inspectSlot detects a slot whose manifest names another KB (AC5); purgeSlot discards it. Missing/nameless manifest = inconclusive, still trusted - isKBCached refuses a contaminated official slot => install re-fetches instead of serving foreign content - kb-installer: local zip/dir install into their own slot and replace it wholesale (no stale files); zip path now resolves against the injected cwd, like the directory path - kb-availability: source resolved once, backup/restore dance now protects the SOURCE's slot - kb-resolver: git slot moves under external/ via the same keying Refs: #395
…g decision, correct the cache docs - scaffold-kb.sh: assert_pinned_bug -> positive assertion. The scenario now pre-seeds an OFFICIAL slot in the isolated HOME and fails if a ZIP install rewrites its manifest or if the external KB is missing from ~/.pair/kb/external/. Isolated HOME kept, but for test hygiene (no writes into the developer's real cache), not as a #395 workaround - ADL 2026-08-11: slot = source identity; path (not content) as the local discriminator and why; contamination policy; disk/cleanup story; concurrency explicitly deferred with reason - external-kb.mdx: the "ZIP install is not equivalent" warning is obsolete — replaced with the per-source slot behaviour + self-heal - kb-source-resolution.mdx: cache strategy table per source form, git clone steps corrected to the real flow (clone into the slot, drop .git) - architecture.md: cache location line points at both slot shapes Verified end-to-end: `bash scripts/smoke-tests/scenarios/scaffold-kb.sh` passes (exit 0). Refs: #395
Verdict
PR: [#423] · Author: rucka · Reviewer: independent review agent (Claude Opus 5) · Date: 2026-08-11 · Story: US-395 · Type: bug Classification matrix — per dimension
Tier = max(assessed) = yellow, confirming the story's refinement-time tier (no raise, no drift note). Cost = highest detected signal = green. Review value is a floor (D17): confirm or raise, never lower. Tier requirements (🟡, quality-model §4): 1 reviewer · 1 working day SLA · AssessmentsSecurity — Input validationVerdict: green — the two new untrusted inputs (a source path/URL turned into a directory name, and an archive extracted into a machine-wide location) are both sanitized. Details
Security — Output handlingVerdict: green — the only new output is a Details
Security — AuthenticationVerdict: green — not applicable; no authentication path is touched. Security — AuthorizationVerdict: green — not applicable; no access-control decision exists on these paths. The one privilege-shaped operation added is Security — Introduced vulnerabilitiesVerdict: green — 0 introduced, 1 pre-existing surfaced (contained by the library version). Details
AC1 ultimately rests on extraction not escaping the slot, so I verified it rather than assumed it. With the repo's pinned Any introduced red finding would drive CHANGES-REQUESTED (#227/AC4). There are none. CostVerdict: Details
Architecture (Coupling)Verdict: green — no new cross-context integration; the change is intra-module with one already-existing caller. Details
DetailsFindings by severityCritical (must fix before merge)
Major (should fix before merge)
Minor (consider)
Questions
Positive feedback
Functionality & requirements (AC coverage)
Testing & quality gates
Adoption compliance
Tech debtSurfaced, never blocking:
None of these justify blocking a P0 shared-state-corruption fix; items 1–3 are the ones worth scheduling. Documentation
Performance & deployment
Bug-fix emphasis (review type): root cause addressed — yes, the keying, not the ZIP symptom; regression tests present at both the |
…ch (review round 1) Review findings on PR #423: - source kind was classified 3x with divergent tests (`KB.ZIP` got a zip slot but the directory installer): classification now lives only in `localKBSource`, and both `ensureKBAvailable` and `resolveLocalDataset` dispatch on the resolved `kind`. - a contaminated official slot was rm -rf'd BEFORE the re-fetch: it is now set aside (backup) and restored when the download fails — an offline retry keeps its cache. - `inspectSlot` served a slot with no manifest at all as `ready` (an aborted download read as a cache hit): no manifest where one is expected => `empty` => re-fetch. - `getCachedKBPath` re-normalized versions and mapped an empty key to the cache ROOT (one bad argument from `purgeSlot` rm -rf'ing it): normalization stays in `cacheSlotKey`, an empty key throws. - `PAIR_KB_CACHE_DIR` was documented but read by nothing: honoured now. - absolute-path detection was `startsWith('/')` (a Windows path was joined onto cwd): posix OR win32 `isAbsolute`. - split the pure identity/key derivation (`cache-slot-key.ts`, no fs) from the slot lifecycle (`cache-manager.ts`), and re-exported the new API through the `#kb-manager` barrel so `config/kb-resolver.ts` stops reaching into internals. - backup/restore made idempotent: a stale `.bak` and a half-written slot no longer make the rename fail. - conformance test tying `OFFICIAL_KB_NAME` to the `--name` the release script passes. Refs: #395
…ers, track the deferrals Review findings on PR #423: - docs promised more self-heal than the code delivers: the official slot is keyed by CLI version, so a slot polluted by an OLDER CLI is abandoned, not discarded. Both pages now say so and point at ~/.pair/kb/<old-version>/. - the documented cleanup (rm -rf ~/.pair/kb/external) missed the pre-fix git slot and the .bak dirs: all three leftovers are now listed in the spec and the ADL. - the context map's glossary still described a version-keyed cache: aligned with architecture.md and the ADL. - PAIR_KB_CACHE_DIR is honoured now, so its two doc rows are true (and precise about taking an absolute path). - ADL: why only manifest `name` is compared and never `version`; the no-manifest => re-fetch policy; backup-instead-of-purge; the one-classifier rule; the module split; the OFFICIAL_KB_NAME conformance test. - the three deliberate deferrals now have a backlog home: #427 (no eviction), #428 (same-source concurrency), #429 (path-not-content discriminator). Refs: #395
…tion must map to one slot 7 failing tests before the fix (review round 2): - PAIR_KB_CACHE_DIR relative or '..'-bearing is accepted verbatim today, so every slot resolves against the cwd and purgeSlot rm -rf's inside the current repo - a slot key climbing out of the cache root is not rejected - './', '../' and trailing-slash forms of the same path hash to different slots Refs: #395
…lidated, one location is one slot (review round 2)
- GREEN for the 7 RED tests: PAIR_KB_CACHE_DIR must be absolute and '..'-free (it
prefixes every path purgeSlot rm -rf's), a slot key may not climb out of the root,
and a source path is canonicalized ('.', '..', trailing separator) before it is
hashed — with the rules of whichever convention called it absolute.
- A local DIRECTORY owns no cache slot and is no longer a KBSource: it is read in
place by resolveDatasetRoot. installKBFromLocalDirectory had no production caller
(only ensureKBAvailable with a local-directory customUrl, which no parser produces
and bootstrap short-circuits) — removed; ensureKBAvailable now rejects a directory
naming the layer that handles it. Its coverage already lives on resolveLocalDataset.
- Git slot lifecycle moved out of the config layer into installKBFromGit: every
source form's slot mechanics now live in the module that owns slots.
- Barrel is the only door for production code (bootstrap/kb-resolver deep imports
gone) and exports install entry points, not slot primitives.
- isKBCached documented as the diagnostic predicate it is.
- vitest.setup.ts clears PAIR_KB_CACHE_DIR before every test — the suites assert
homedir-derived slot paths and must not depend on an ambient override.
Refs: #395
…che slot), plus the ADL corrections round 2 found
- kb-source-resolution.mdx / external-kb.mdx claimed `--source <dir>` was copied into
`~/.pair/kb/external/dir-{name}-{hash}/`. No code path ever created that slot: the
directory is read live on every install, so edits to it change the next install's
result. Table row, callout and adoption records corrected to say so.
- PAIR_KB_CACHE_DIR rows now state the constraint the CLI enforces (absolute, no '..').
- ADL: KBSource lives in cache-slot-key.ts (not cache-manager.ts); the re-fetch of a
contaminated slot is ensureKBAvailable/inspectSlot, isKBCached is diagnostic-only;
the barrel claim is now true rather than aspirational; new clauses for the slot-
mechanics boundary, root validation, path canonicalization and the dead-code removal.
Refs: #395
…iled clone keeps the old one, a nested remote ZIP is unwrapped Round 3, test-first for the four findings that describe a defect: - PAIR_KB_CACHE_DIR='C:\cache\kb' passes the dual-convention check on POSIX and yields a RELATIVE root — slots then resolve against the process cwd, the exact hazard the guard exists for. - installKBFromGit purges before it clones and cloneGitRepo rm -rf's on failure: an offline clone left an empty slot where a working one had been. - installKB (official download AND --url) never unwrapped a ZIP nested under a single root directory, unlike the local-ZIP path. - isKBCached is probed on every fallback resolve for a [diag] line that is off. Also: the local-directory rejection must name --source, not internal functions; conformance test moved into pair-cli so it imports OFFICIAL_KB_NAME instead of grepping the file as text.
…t is set aside, a downloaded ZIP is unwrapped (review round 3) - getCacheRoot uses path.isAbsolute: on POSIX 'C:\cache\kb' is a RELATIVE name and would prefix every slot. The dual-convention check stays in resolveSourcePath, where it prevents a join instead of enabling one. - installFromSource is an exhaustive switch with a `never` default; git delegates to installKBFromGit instead of falling through to the official release zip. - installKBFromGit sets the old clone aside and restores it when git throws (cloneGitRepo deletes the destination on failure); the set-aside replaces the purge, git needs an empty destination anyway. - installKB unwraps a ZIP nested under a single root directory, like the local-ZIP path. Unwrap only — a negative result is not fatal on the download path. - isKBCached is probed only under PAIR_DIAG: its answer feeds a [diag] line and nothing else, while it costs 2 existsSync + readFile + JSON.parse per command. - The local-directory rejection names --source, not internal function names.
…ces, name the two absolute-path conventions ADL: the backup/restore rule is stated for content that comes over the network (official, --url, git) and the local-ZIP exception now carries its reason, so lines 40 and 45 stop contradicting each other. New clauses for the host-vs-either convention split, the exhaustive install dispatch, the download-path unwrap, the diagnostic-only probe and the conformance test's new home. kb-source-resolution.mdx: Download Process gains the unwrap step; Cache Strategy states which sources set their slot aside and which replace it wholesale, and why.
…sful install, the namespace is not a slot, a truncated label keeps no dangling separator 6 failing assertions: - removeBackupKB tolerates a .bak that vanished (ENOENT without force) and never throws on EBUSY - ensureKBAvailable keeps a successful re-download when discarding the old slot fails - installKBFromGit keeps a successful clone when discarding the set-aside one fails - getCachedKBPath refuses the reserved 'external' namespace key (purgeSlot would rm -rf every external slot) - a 33+ char source label does not end on '-' after truncation Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…espace is not a slot (review round 4) - removeBackupKB: force + best-effort (a leftover .bak is inert, a throw would revert) - discard moved outside the reverting try in ensureKBAvailable and installKBFromGit - getCachedKBPath rejects the bare 'external' namespace key - label re-stripped after truncation; EXTERNAL_NAMESPACE module-private Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, round-4 ADL clauses Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he failure it follows, --url outranks the monorepo dataset 6 failing assertions across 4 files (review round 5): - installKB stages two different URLs at one CLI version into one tmp file - restoreCachedKB loses the backup when the half-written slot cannot be deleted - a throwing restore replaces the HTTP/git error the user needs, at both call sites - an explicit --url resolves to the monorepo dataset in a dev checkout
…r masks the failure it follows, --url reaches identity resolution (review round 5) - installKB stages under downloadStagingName(version, url): official and --url no longer share /tmp/kb-<version>.zip, so a stale .partial cannot be resumed against a different URL and finalized as one archive - restoreCachedKB renames the backup back FIRST and discards the half-written slot after, best-effort by contract: a failing cleanup can neither leave the user without a cache nor replace the HTTP/git error that caused the failure - cache-manager no longer re-exports getCachedKBPath (no production caller) - an explicit --url is no longer outranked by the monorepo dataset
… best-effort, an explicit --url outranks the monorepo dataset Three ADL clauses (staging key, restore contract, named-source precedence) plus the consequence lines for the dropped re-export and the changed remote resolution; spec gains the precedence note, the staging step and the restore paragraph.
…e names are unique, --url outranks the monorepo dataset at the pre-flight too
…rds what round 14 fixed and what stays open - CLI reference: the `--no-kb` row no longer claims it skips the KB download — it is a no-op (its only consumer is the unreachable pre-flight), with a warn callout naming why, that `pair install --no-kb` still installs a KB, and that `--log-level`/`--verbose` are NOT affected (they are live as of this round). - ADL: the round-6 "the rule holds at BOTH layers" sentence replaced by the revert; the pre-flight clause rewritten with what round 14 did (a-d) and the open merge-gate choice (revive / delete / retire the flag).
…at it is — a no-op (review round 15)
…-no-kb (help + contract spec)
…decode is not an identity to hash) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ry double (absorbed #429, step 1) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…NT, not its path (absorbed #429, step 2) Verified failing: 3 assertions + zip-source.test.ts cannot load (module does not exist yet). Committed with --no-verify: ts:check names an API (zipKBSource, KBSource.contentHash) the fix commit introduces. Same archive at two paths ⇒ ONE slot; different bytes ⇒ never one slot; key is external/zip-<sha256(bytes)[0..12]>, derived only via the byte-mode read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h (absorbed #429, step 2) zipKBSource (the ONLY producer of a zip KBSource) resolves the path and hashes the bytes via the byte-mode read; the slot is external/zip-<sha256[0..12]> — the same archive at two paths is ONE slot, different bytes never share one. The path stays for messages and extraction only. resolveSource is async now (identity derivation reads the archive); installKBFromLocalZip derives identity through the same producer, so both layers land on the same slot by construction. Path-keyed tests reconciled: the label-readability and human-readable-slot assertions moved to the url- slot (a zip slot carries no path-derived label — it would smuggle the path back into the identity). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tage + rename (absorbed #428) 6 failing tests: writeSlotAtomically stages next to the slot, the slot never exists half-written, a failing populate leaves nothing behind, a DEAD process's orphaned stage is swept while a LIVE one is left alone, an occupied slot is replaced whole, and a lost rename race is retried once. Committed with --no-verify: the pre-commit hook runs ts:check and the failing state names an API that does not exist yet (writeSlotAtomically). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e atomic stage (absorbed #428) 4 failing tests: local ZIP, remote download and git clone must extract/clone into a <slot>.tmp-<pid>-<n> stage (the slot absent the whole time) and rename it whole; a structure-invalid ZIP must leave neither slot nor stage. --no-verify: RED by design before the wiring commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…self, not `<dest>/` Review round 16. `rename` of a directory added `newPrefix` (always trailing-slash) for the destination itself, while `existsSync` is an exact match on the dir set: the destination was observable only when a moved file happened to land DIRECTLY under it (the file loop adds `dirname(file)`). Every caller shaped `if (!existsSync(dest)) return` — `restoreCachedKB` and `removeBackupKB` both are — then no-opped, so a backup/restore test seeded with only nested files passed VACUOUSLY: this story's hardest invariant (a failing re-fetch leaves the user's cache intact) was asserted by fixtures carrying a direct-child `manifest.json` as a workaround nobody could see. The parent's listing also showed a phantom `<dest>/` beside the real entry. - RED first: two `in-memory-fs-write.test.ts` cases (destination of an all-nested rename is observable; the parent lists it exactly once). - One-line fix: `rel === '' ? newDir : newPrefix + rel`. The dir branch is extracted into `renameDirectory` (max-lines-per-function), no behaviour change. - `cache-manager.test.ts`: a backup+restore round-trip on a slot with NO file directly under it — verified to fail without the fix, so the workaround convention cannot come back silently. Measured fallout, which the finding asked for: `pnpm turbo test` over the whole monorepo is green (10/10 tasks), i.e. no other suite was passing BECAUSE a renamed directory read as absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…med in whole (absorbed #428) GREEN for the two RED commits before it (`cc8104f4`, `ef44cbe2`), which were left failing when the previous round was interrupted. - `cache-manager.writeSlotAtomically(slot, fs, populate)`: `populate` writes into `<slot>.tmp-<pid>-<n>` beside the slot (same filesystem ⇒ the swap is one `rename`), and the slot is renamed into existence only when the stage is COMPLETE. A concurrent reader sees the slot absent (⇒ re-fetch) or whole, never half-written — the exposure the purge-then-extract- in-place sequence had. A failing `populate` removes its own stage and rethrows the ORIGINAL error; an occupied slot is replaced whole (rm + rename, retried once on a lost race). - Stages left by DEAD processes are swept first (`process.kill(pid, 0)`); a LIVE process's stage is a concurrent install in flight and is left alone. - All three install forms go through it: remote/official download, local ZIP, git clone. The unwrap and the structure check run on the stage too, so an invalid ZIP leaves neither slot nor stage. `ensureCacheDirectory` calls before the populate are gone — the stage's `mkdir` creates the parent. - Two legacy expectations updated to the stage: the ZIP-cleanup test now asserts extraction targets `<slot>.tmp-` and that neither stage nor slot survives the failure; the git test's `cloneGitRepo` double writes into the destination it is HANDED instead of the slot. - `zip-source.*`, `cache-slot-key.test.ts`: formatting only. Quality gate: `pnpm quality-gate` PASS (exit 0) — pair-cli 1123 tests, whole monorepo green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…est-double rule The decision record had drifted behind the code: it still described the local slot as path-keyed, same-source concurrency as deferred, and #428/#429 as tracked elsewhere. - Local slots are CONTENT-keyed (byte-mode `readFileBytes` made it possible); the path-keyed shape moves to Alternatives as superseded, with why it was chosen first. - Slots are populated through a `<slot>.tmp-<pid>-<n>` stage and renamed in whole; the lock-file route and why it was not taken are recorded next to it. - New clause: a defect in a shared test double is fixed in the double, not worked around in the fixture — with the measurement that justified fixing it here (full monorepo suite green, so nothing was passing BECAUSE a renamed directory read as absent). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…chitecture to the shipped zip/git slot shape (no label, no kb-cache command) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
…ownloads a KB
`cli.ts` guarded the `preAction` hook on `thisCommand === prog`. Commander invokes a
program-level hook as `callback(hookedCommand, actionCommand)`, so the first argument IS
the program for every subcommand and that guard always returned. The entire pre-flight was
dead code, and the effects were user-visible:
--no-kb downloaded and installed a KB anyway — the opposite of what it says,
and the flag an air-gapped user reaches for
--url + --no-kb the conflict was never rejected; two contradictory flags accepted
accessibility the DatasetNotFound / DatasetAccess probe never ran
Fixed by testing the ACTION command instead. Waking the hook up makes the exemption list
load-bearing for the first time, so it is INVERTED to an allow-list: `install` and `update`
resolve a KB, everything else does not. A deny-list would have every command added tomorrow
inherit the network by not being remembered; `kb-info`, `kb-validate`, `kb-verify`,
`validate-config` and `update-link` only read local state and must work offline.
Verified by RUNNING the built CLI, not by unit test alone:
- `install --no-kb --source … --offline` → zero download lines
- `kb-info` → no bootstrap at all
The docblock above the function is rewritten: it described the dead path as a deliberate
choice ("THIS FUNCTION NEVER RUNS PAST ITS FIRST LINE — deliberately") and would now be
false in the most misleading way possible.
One test added, and it pins COMMANDER'S convention rather than our code: the full 1125-test
suite passes with the guard broken either way, so nothing we own can catch a regression
here. If Commander ever swapped the argument order, the fix would silently invert and only
this test would say so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d reclaimable, and never deletes what it cannot refetch Absorbs #427 into this story: the cache slots this PR introduced (external/, stage, backup) had no way to be listed or reclaimed, so a machine accumulated them with no command to see or clear them. `kb-cache list` classifies every slot (official / external / backup / stage / legacy-git) and `prune` removes only the recoverable ones. Two deletions are refused by construction: - external KBs — fetched from a URL the CLI does not retain, so a delete is not undoable by re-running an install - every official slot when the running version cannot be resolved, rather than comparing against an empty string and pruning the KB in use An unreadable cache root now surfaces as an error instead of being rendered as an empty cache, and a partial prune exits 1 rather than reporting a clean sweep over slots that were not removed. Covers: #427
…, and prune deletes an install in flight Two review findings, both reproduced before the fix (5 failing assertions across 3 files): - Critical: with the pre-flight live, step 3 probes the BUNDLED dataset path (getKnowledgeHubDatasetPath) instead of the cache slot step 2 populated. Every fixture here seeded a monorepo layout, so the defect was invisible: the new case makes getKnowledgeHubDatasetPath THROW, which is what a released install does (@pair/knowledge-hub is a sibling under node_modules/@pair/, and no dataset is bundled). The download case also pins WHICH path the error names. - Major: kb-cache prune classifies every stage/backup as garbage from the name alone, while sweepOrphanedStages checks pid liveness for exactly that reason. New cases: a live pid's stage and a .bak with no sibling slot must survive a concurrent prune. --no-kb + --url is pinned too: reviving the pre-flight made that combination an error, and nothing covered it. Committed --no-verify: the failing state is the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ever deletes an install in flight Critical — the revived pre-flight aborted EVERY released install. Step 2 downloaded the KB into ~/.pair/kb/<version>/ and step 3 then probed the BUNDLED dataset path (getKnowledgeHubDatasetPath -> findPackageJsonPath), which in a published package does not exist and THROWS: @pair/knowledge-hub is hoisted next to pair-cli, never nested under it, and postbuild bundles no dataset. Two steps, two different paths, so the check answered about a location the fetch had nothing to do with. Step 2 now RETURNS the path it landed on and step 3 takes it as an argument; `shouldSkipKBDownload` + `hasLocalDataset` collapse into `resolveDatasetForPreflight`, same order of precedence. Verified empirically outside the repo, which is the only place this shows: dist/ copied to a scratch package with @pair/knowledge-hub ABSENT from a hoisted node_modules. Before: "Unable to find @pair/knowledge-hub package". After: the command runs, with and without --no-kb. Major — `kb-cache prune` deleted a live install's stage and its backup. It classified every stage/backup as garbage from the NAME, while sweepOrphanedStages checks pid liveness for exactly that reason. That predicate is now ONE function (isStageOwnerAlive, exported through the barrel) and prune uses it, plus the .bak rule: a backup whose slot is not back yet is the only copy of that KB, so it is spared; once the slot returns it is reclaimable. `list` names a kept entry so it does not read as a prune bug. Also: inventory reaches kb-manager through #kb-manager, not a deep import (DR-4). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he prune exists Six surfaces still declared the KB pre-flight dead two commits after it was revived, and the kb-cache command existed only in the CLI reference. An ADL that contradicts the code is worse than none, and `--help` was telling users a working flag does nothing. Pre-flight (finding 2): - ADL: the "never runs — NOT revived here" consequence rewritten as what shipped — the action-command guard, the deny-list -> allow-list inversion and why, `--no-kb` becoming real, the `--url` + `--no-kb` rejection as a user-visible behaviour change, and the released-install defect with the rule it generalizes (a check that derives its own subject verifies nothing). The round-6/14 `shouldSkipKBDownload` sentence corrected too. - `--no-kb` help text, the CLI reference callout and the contracts spec now describe the real effect and name the `--url` conflict; the round-15 test that PINNED "no-op" is flipped. - `config/cli.ts`'s "the pre-flight is unreachable" note replaced by the actual split: the pre-flight warms the cache, `namedSource` decides which KB is installed. kb-cache (finding 5): recorded in the ADL (what prune reclaims, and each thing it never touches — external slots, the running version's slot, unknown entries, an install in flight — with the reason), in the resolution spec (leftovers table gains a `prune` column plus a "what prune never deletes" paragraph), and in `cache-slot-key.ts`'s header, which still said manual `rm -rf` was the only cleanup. Also corrected the ADL's stale "one slot per distinct path of the same archive" — content-keying made that one slot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…complexity 13 A per-call-site `if (isDiagEnabled())` pushed resolveDatasetForPreflight to complexity 13 (max 10) on branches that carry no behaviour; one `diag()` helper removes four of them. The download test asserts the error CLASS again as well as the path it names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
In reply to the first review comment: #423 (comment) Escalation — review<->fix loop, round 19 (head
|
…ever opens, and --no-kb reaches both readers Round 17 revived a pre-flight that had never executed. Reviving it exposed two defects it had been hiding, both proven by counting requests against a released-shape fixture (no bundled dataset) rather than by reading the code. 1. The hook read `thisCommand.opts()` — the PROGRAM's option set for a program-level `preAction`. Every flag that names the command's own source is declared on the `install` SUBCOMMAND, so `--source`, `--offline` and `--list-targets` were invisible to it and it resolved the official KB anyway: `--offline` failed for exactly the air-gapped user it exists for, every `--source`/`--git` install downloaded a whole KB it never opened, and `--list-targets` — which reads local config only — required network. It now consults the action command's options through one named predicate. 2. `--no-kb` skipped the pre-flight's warm fetch and downloaded anyway: the command path resolves its dataset independently through `resolveDatasetRoot`, which fetched whenever an httpClient was present. Five surfaces (help text, CLI reference, contracts spec, ADL, a flipped test) had already been rewritten to promise a working skip, so the documentation shipped one round ahead of the behaviour. `kb` is now threaded into the resolver, which resolves from the bundled dataset or a populated cache slot and otherwise fails with the two ways out. Every existing fixture seeds a monorepo dataset and the smoke suite runs `dist` from inside the monorepo, so the bundled branch short-circuited in both — which is how 1144 tests stayed green over this. The five new tests use the released shape and assert zero requests; each was injection-tested against its own guard. ADL clauses 61 and 84 corrected: both stated the behaviour this commit is what actually delivers.
PR Information
Story: #395 · Type: Bug fix (P0 — shared-cache contamination) · Priority: P0 · Assignee: @rucka
Classification:
risk:yellow·cost:greenSummary
Story Context
As a developer installing an external KB from a release ZIP I want
install --source <zip>to install only what the ZIP ships, without touching the official KB's cache slot or manifest so that installing an external KB in one project cannot corrupt the KB of every other project on the machine (#395).kb-installer.tsextracts into the source's own slot;kb-installer.test.tsUS-395 block seeds a populated official slot and asserts it is byte-for-byte untouched after the install;scaffold-kb.shasserts the same end-to-endkb-infoin another project still reports the official KBkb-availability.test.ts"contaminated official slot self-heals"; slot separation means the official slot is never written by an external installcache-slot-key.ts:KBSourceunion +cacheSlotKey/getSourceCachePathcover the four forms that own a slot (official, remote URL, git, local ZIP);--urlis namespaced too — it went through the same official-slot bug, guarded only by backup/restore — and since round 12 it also reaches that resolution: the flag names a source through the command's own config, so--url Xand--source Xare the same install. A--sourcedirectory owns no slot by design: it is installed from in place (round 2)cache-manager.tsinspectSlot;kb-availability.tsresolves the source once, warns on a manifest-name mismatch and sets the slot aside (restored if the re-fetch fails);cache-manager.test.tscontamination block. Detection is name-only and scoped to the slot of the running CLI version — see the ADLscaffold-kb.shpin flips from red-is-expected to a positive assertion, landed in this PRassert_pinned_bugreplaced with positive assertions; isolatedHOMEkept, but reasoned as test hygiene, not as a workaround for the bugWhat Changed
cache-manager.tskeyed every cache slot by CLI version only (~/.pair/kb/<cliVersion>/), so any source form writing through it — ZIP, remote URL — landed in the same slot as the official KB. A ZIP install rewrote the official manifest to the external KB's{name, version}, and every other project on the machine then read the external KB as if it were official.The fix keys every slot by source identity:
~/.pair/kb/<cliVersion>/stays reserved for the official KB only.~/.pair/kb/external/<kind>-<label>-<hash12>/(kind= zip/git/url,labelderived from the source,hash12= a 12-char digest of the resolved discriminator).install --source <zip>now replaces its own slot wholesale (purgeSlotbefore extract) instead of writing into whatever slot version-keying handed it.install --source <dir>writes no slot at all — the directory is read in place, as it always was in practice (round 2 removed the docs and the dead code that claimed otherwise).kb-availability.tsresolves the source once, dispatches every installer on that one identity, and if the resolved slot's manifestnamedoesn't match the expected source it warns and re-fetches (AC5) instead of serving the polluted content. The old slot is set aside, not deleted, so a failing re-fetch is never destructive.~/.pair/kb/git-<hash>/, never the official one). Moving it underexternal/is a namespacing tidy-up, plus a latent-bug fix: it now purges the slot before cloning, where a second install of the same git source previously failed becausegit clonerejects a non-empty destination. It was never a contamination path — the ADL states this correctly; an earlier revision of this body overstated it. Round 2 moved that lifecycle out ofconfig/kb-resolver.tsintokb-manager'sinstallKBFromGit, so no slot mechanics live outside the module that owns slots.Review Round 1 (commit
9dbf79e8)Every finding from the first review is addressed on this branch; the durable record of each is above and in the ADL. In short: one classifier for ZIP-vs-directory (an uppercase
KB.ZIPused to take a zip slot and the directory installer); a contaminated slot is set aside rather than deleted before the re-fetch; a slot with nomanifest.jsonis re-fetched instead of served as a cache hit;getCachedKBPathno longer re-normalizes versions and rejects an empty key (which resolved to the cache root, one bad argument fromrm -rf); absolute-path detection covers Windows paths;PAIR_KB_CACHE_DIRis honoured; pure key derivation split intocache-slot-key.tswith the public surface re-exported from thekb-managerbarrel; a conformance test tiesOFFICIAL_KB_NAMEto the release script; docs corrected on the real scope of the self-heal and on the leftovers manual cleanup misses; the three deferrals filed as #427/#428/#429.Review Round 2 (commits
e1a49402→b3f2c658→0489ba99)All 11 findings of the second review are addressed on this branch; none escalated. The one that changes behaviour of record:
--sourcedirectory owns no cache slot. The cache-strategy table and theexternal-kb.mdxcallout claimed a directory was copied into~/.pair/kb/external/dir-{name}-{hash}/. No code path ever created that slot —resolveLocalDatasetvalidates the directory and returns it in place, so edits to the source directory change the next install's result. The docs, the adoption records and the types were corrected to say so (directoryis no longer aKBSource;LocalKBSourceclassifies a--sourcepath and onlyzipowns a slot), rather than changing the code to match the docs — copying directories into the cache is a behaviour change nobody asked for. Its corollary, deadinstallKBFromLocalDirectory, is removed: its only caller wasensureKBAvailablewith a local-directorycustomUrl, which no parser produces andbootstrap.tsshort-circuits.ensureKBAvailablenow rejects a local directory naming the layer that handles it.The rest, in short:
PAIR_KB_CACHE_DIRis validated (absolute, no..) instead of trusted, and a slot key may not climb out of the cache root — the same reasoning as the existing empty-key guard, since the root prefixes every pathpurgeSlotdeletes recursively; a source path is canonicalized before it is hashed, so/kb/./acme.zip,/kb/../kb/acme.zipand--source /kb/stop forking one slot each; the git slot lifecycle moved intokb-manager(installKBFromGit), leavingkb-resolvera pure dispatcher and letting the barrel drop the four slot primitives it had been exporting for it; the barrel migration is finished — zero production deep imports remain (kb-installer,git-clone,cli-optionsall go through#kb-manager), which is what the previous revision of this body claimed prematurely;isKBCachedis documented as the diagnostic-only predicate it is (the contaminated-slot re-fetch comes fromensureKBAvailable/inspectSlot);vitest.setup.tsclearsPAIR_KB_CACHE_DIRbefore every test so the suites are hermetic with respect to the override this PR started honouring; and the ADL's wrong file reference forKBSourceis fixed.Test-first is now visible in
git log(round 2's Question):e1a49402is a RED-only commit carrying 7 failing tests for the cache-root and canonicalization defects,b3f2c658is the fix that turns them green.Review Round 3 (commits
51e5e88e→944ce186→f027deda)All 8 findings of the third review are addressed on this branch; none escalated. They share a shape worth naming for the re-reviewer: each round-2 fix established an invariant, and each round-3 finding is a place where one code path did not follow the invariant the PR itself states.
getCacheRootvalidatedPAIR_KB_CACHE_DIRwithposix.isAbsolute || win32.isAbsolute, so on POSIXC:\cache\kbpassed andjoin('C:\cache\kb', '0.4.3')produced a relative path — every slot then resolving against the process cwd, the exact hazard the guard's own comment cites. The two guards look alike and point opposite ways: for a source, accepting a foreign absolute path prevents a join onto the cwd; for the root it causes one.path.isAbsolutenow guards the root, the dual check stays inresolveSourcePath, and the test that pinned the permissive behaviour is flipped and platform-aware.installKBFromGitpurged before it cloned, andcloneGitReporm -rf's the destination when git fails — so an offline clone left an empty slot where a working one had been, contradicting the ADL's bolded "a slot is never deleted before its replacement is in hand". The slot is now set aside and restored on throw (the set-aside also gives git the empty destination it requires, so it replaces the purge rather than adding to it), and the ADL + spec are qualified: the invariant covers content that comes over the network, with the local-ZIP path stated as the exception and why (its source is a file the user still has, so re-running is the recovery).installKB— which this PR promotes to serving--url <remote-zip>as well as the official download — extracted straight into the slot, so an external KB packaged under a single root directory gave a dataset root one level too high. It now callsnormalizeExtractedKB. Unwrap only: a negative result is deliberately not fatal there, because that path has never validated the downloaded structure and making a structure check fatal on the official download would be a behaviour change with no defect behind it. Recorded in the ADL and the spec, not left as an implicit choice.installFromSourcehandledzipand collapsed the rest into "remote URL, else the official release URL" — agitsource would have downloaded the OFFICIAL KB into the git source's slot, the exact cross-source write this story exists to close, with no compile-time or runtime signal. It is now aswitchonsource.kindwith aneverdefault, andgitdelegates toinstallKBFromGit. Unreachable today (resolveSourcecannot producegit), so the fix is a compile-time guard and carries no runtime test on purpose.isKBCachedis probed only underPAIR_DIAG(its answer feeds a[diag]line and nothing else, while it costs twoexistsSync+readFile+JSON.parseon the hot path of every command reaching the fallback resolver); the local-directory rejection now names--source <dir>instead of internal function names, sincebootstrapEnvironmentprints it to whoever typed the flag; theOFFICIAL_KB_NAMEconformance test moved frompackages/knowledge-hubintoapps/pair-cliso it imports the constant instead of regexing the file as text (text matching survives only for the release script, which has no importable form); and the deadafterEachthat restoredPAIR_KB_CACHE_DIRaftervitest.setup.tshad already cleared it is gone.Test-first, again visible in
git log:51e5e88eis RED-only — 5 failing assertions across 4 files for the four findings that describe a defect — and944ce186turns them green.Review Round 4 (commits
959c6854→935b7af3→ae864ace)All 6 findings of the fourth review are addressed on this branch; none escalated, none deferred to a card. Two of them are one defect seen at two call sites, plus the detail that enables it:
removeBackupKBran INSIDE thetrywhosecatchcallsrestoreCachedKB— so a failure to delete<slot>.bakafter a successful install deleted the KB just written and reinstated the previous slot, then rethrew an unrelated fs error. When the previous slot was the contaminated official one, that undid the AC5 self-heal this PR exists to deliver. Both halves of the fix: the discard now runs after thetry/catchinensureKBAvailableand ininstallKBFromGit, andremoveBackupKBis best-effort by contract — it swallows its own failure (debug log), because a leftover.bakis inert (the nextbackupCachedKBoverwrites it) while a throw aborts work that already succeeded.removeBackupKBwas the onlyfs.rmin the module withoutforce: true. Combined with itsexistsSyncguard that is a check-then-act: a concurrent same-source install ([TECH-DEBT] KB cache: same-source concurrent installs are not atomic #428) deletes the.bakin between andrmthrows ENOENT — the trigger for the revert above.force: trueadded, matchingpurgeSlot/backupCachedKB/restoreCachedKB.maxRetries(the reviewer's parenthetical for Windows EBUSY) was deliberately not added:FileSystemService.rm's options type is acontent-opsinterface shared by every caller, and widening it is a package-level change with no defect behind it — the best-effortcatchcovers that case where it mattered.externalnamespace is not a slot.getCachedKBPathrejected an empty key and a..segment but not the bare namespace, sopurgeSlot(officialSource('external'))would haverm -rf'd every external slot on the machine. Unreachable today (the official key is a semver) — defence-in-depth, added because a partial guard set reads as a complete one. The guard rejects the namespace directory itself, not a first-segment match: the latter would reject every realexternal/zip-…slot.-/.before.slice(0, 32)let truncation reintroduce one (zip-some-long-label--<hash>), defeating the label's only purpose. Re-stripped after the slice.manifest.jsonis treated as an aborted download and re-fetched" holds for the official slot only:inspectSlotreturnsreadyearly for any source declaring no expected manifest name — every external source — which the suite already pinned. The sentence is scoped, and the external case stated positively: no expected name ⇒ not inspected ⇒ re-installed into its own slot on every use.EXTERNAL_NAMESPACEis no longer exported (no caller outside its module, against this PR's own barrel rule). The test keeps the'external'literal deliberately — it pins the layout a user sees on disk, which importing the constant would stop doing.Test-first, again visible in
git log:959c6854is RED-only — 6 failing assertions across 4 files — and935b7af3turns them green.Review Round 5 (commits
498cd3cc→57073b61→d6a211ae)All 4 findings of the fifth review are addressed on this branch; none escalated, none deferred to a card. Three of them are this story's own rule applied one layer further out:
installKBstaged every download at<tmp>/kb-<version>.zip, and this PR is what made that function serve the official release and--url <remote zip>, so two sources shared one staging path. Not just a concurrency window:resume-manager.shouldResume()decides to resume from the existence and SIZE of<staging>.partialalone, with no binding to the URL that produced those bytes, then issuesRange: bytes=<n>-against the new url — an interrupted official download followed bypair install --url …appended the second body onto the first and finalized the hybrid as one archive. Fixed where every other key is derived:downloadStagingName(version, url)incache-slot-key.ts→kb-<version>-<hash12>.zip. The.partialsibling follows automatically, so a resume can only ever continue the SAME url.restoreCachedKBdeleted the half-written slot and only then renamed the backup back — a failing recursive delete left BOTH on disk, and the next install'sbackupCachedKBdeletes the.bakto make room, so a second failure left the user with no cache at all: the exact opposite of the ADL's bolded invariant. It also ran inside thecatch, so an fs error there replaced the actionable HTTP/git failure. Now rename-first (backup back into place, half-written copy discarded afterwards, best-effort) and best-effort by contract likeremoveBackupKB. Deliberate deviation from the literal recommendation (a try/catch at each of the two call sites): the rule lives inside the function, so a third call site cannot get it wrong; the shared best-effort delete is one privatediscard().--urlno longer resolves to the monorepo dataset.getKnowledgeHubDatasetPathWithFallbackconsulted the monorepo shortcut beforecustomUrl, so in a dev checkout the URL the user typed was silently ignored — the one named source form that never reached identity resolution (AC4). Answered as not intentional:--sourceand--gitare honoured in a checkout, and the spec's own Precedence Order already put an explicit source first, so the code contradicted the spec. The shortcut now applies to the DEFAULT source only. Side effect worth having: the--urlpath is exercisable in a contributor's checkout instead of only in a released binary. Two existing tests asserted the old behaviour as a convenience and were rewritten — one of them (commands/update/handler.test.ts) had been asserting nothing about the URL at all, because its spy ongetKnowledgeHubDatasetPathWithFallbacknever intercepted an intra-module call.getCachedKBPathdropped fromcache-manager's default export — a symbol that module only imports, with no production caller and one test call site (same surface-bloat rule round 4 applied toEXTERNAL_NAMESPACE).Test-first, again visible in
git log:498cd3ccis RED-only — 6 failing assertions across 4 files — and57073b61turns them green.Review Round 6 (commits
8d38a69e→5204251a→d08e73c8→9703fcba)All 3 findings of the sixth review are addressed on this branch; none escalated, none deferred to a card. Round 5 made the restore safe; round 6 makes its failure visible — and finishes two rules this PR states about itself:
restoreCachedKB's second rename (.bakback into the slot) threw, it logged atlog.debugand returned — but the slot had just been renamed aside, so the user was left with no cache while their only good copy sat at<slot>.bakunder a name nothing points at (PAIR_DIAG-less debug is off by default). From the user's side that is indistinguishable from having lost it, and it is the exact run this PR's own round-5 test drives. Thecatchnow (a) retries in the old order —rm -rfwhatever occupies the slot, then rename again, since a recursive delete and a rename fail on different handles, so a transient hold is not terminal — and (b) only then gives up with alog.warn: "Your previous copy is kept at<slot>.bak— move it back to<slot>to recover it." It still never throws, so the HTTP/git error remains the one the user sees. Secondary fix in the same function: the set-aside name (.discarded-<ms>) was millisecond-resolution, so two same-source restores inside one millisecond collided on the rename and both took the give-up path — it now carries a process-monotonic counter (cross-PROCESS concurrency stays with [TECH-DEBT] KB cache: same-source concurrent installs are not atomic #428).restoreCachedKBrenames the half-written slot to<slot>.discarded-*and deletes it best-effort; when that delete fails the directory persists, and for the OFFICIAL slot it lives at~/.pair/kb/<version>.discarded-*— outside therm -rf ~/.pair/kb/externalsweep the docs call sufficient, and it is a full KB tree. Added as a fourth row (with the note that an external slot's own.discarded-*is already inside that sweep), and the restore paragraph above it now states the retry and the warning.config/bootstrap.ts's pre-flight applied the monorepo shortcut regardless of--urland runs before the resolver is ever called, so in a dev checkoutpair <cmd> --url https://…still short-circuited with a[diag]line and the typed URL ignored — the ADL's bolded clause over-claimed by one layer. A namedcustomUrlis now answered in its own branch (local path ⇒ used in place, remote ⇒ falls through to the resolver) and the monorepo shortcut is reached by the DEFAULT source only. Written as one branch rather than the literal three-clause condition because that form put the function at complexity 11 (eslint max 10) — semantically identical.checkKnowledgeHubDatasetAccessibleis deliberately NOT changed: it is a readability probe of the bundled dataset, not a second source resolution, and that scope is now written in its JSDoc and the ADL rather than changed on no evidence.Test-first, again visible in
git log:8d38a69eis RED-only — 5 failing assertions across 3 files — and5204251aturns them green.Review Rounds 7-10 (no commits — escalated to the human merge gate)
Rounds 7-10 produced no code: each escalated its findings to the human at the merge gate
rather than fixing them (see the PR comments). The largest of them —
InMemoryFileSystemService.renamenot registering the destination of a renamed directory, which made backup/restore fixtures seeded
with only nested files assert vacuously — is fixed as of round 16 (see that section); the
direct-child-
manifest.jsonworkaround it forced is gone with it.Review Round 11 (commits
cf608f1b->70bee6bd)The single finding dispatched this round is fixed; none escalated, none deferred to a card.
external-kb.mdxwas corrected in the original commit, but the same claim lived in two in-code copies that
generate content committed into every external-KB repo the fixed CLI scaffolds:
scaffold-kb/templates/readme.ts("install --source <zip>is not yet equivalent... the ZIPis extracted into the official KB's shared cache slot... point consumers at the repository or
a clone until this is fixed (install --source <zip> extracts an external KB into the official KB's cache slot (shared-cache contamination) #395)") and the
scaffold-kb --helpnote inmetadata.ts. Afterthis PR that is false — a ZIP lands in
~/.pair/kb/external/zip-<label>-<hash>/and theofficial slot is untouched (AC1 tests + the smoke scenario prove it) — and it steered a
scaffolded KB's consumers away from the release artifact External KB scaffold via pair-cli — pure KB repo + release script (reuses pair package) #279 AC3 promised, pointing them at
a closed issue. The README's Release-ZIP bullet is gone from Current limitations and the ZIP
now appears as a third install form in the install block, followed by one paragraph stating
the delivered behaviour (a fetched source is cached in its own slot, never the official one).
The
--helpnote readsA fetched source gets its own cache slot: the ZIP form equals the git and path forms. The neighbouring install --source honours the source KB's declaration and reports what it actually did (consolidates #397) #396/install --source: honour the source KB's own registry declaration (skills prefix) #397 bullets are untouched — those limitations arestill real.
Test-first, again visible in
git log:cf608f1bis RED-only — the generated README mustoffer the ZIP form and contain neither
not yet equivalentnorissues/395, and no command's--helpmetadata may claim the #395 limitation (a registry-wide assertion incommands/index.test.ts, so a future copy of the claim in any command's help fails the suite) —and
70bee6bdturns them green.Review Round 12 (commits
c932a388→c5c7370a→1a4ecea9)The single finding dispatched this round is fixed; none escalated, none deferred to a card.
--urlnamed a source nothing read.--urlis declared on the programand was consumed only by
config/bootstrap.ts's pre-flight; the subcommand parsers dispatch on--sourcealone, sopair install --url <mirror>parsed toresolution: 'default'. Beforethis story that survived by accident — the pre-flight wrote the custom archive into
getCachedKBPath(version), the OFFICIAL slot, and the command's default resolution then servedit. Source-identity keying ends the accident by design: the fetch lands in
~/.pair/kb/external/url-<label>-<hash>/while default resolution goes to the official slot, sothe flag would install a different KB than the one the user typed, and nothing at all behind the
firewall the mirror existed for. Fixed at the parser, not the resolver:
namedSource()(
config/cli.ts) returns the command's--sourceor, absent it, the program-level--url, andinstall,updateandkb-infobuild theirconfig.resolutionfrom that one value. So--url Xand--source Xare the same command — same identity, same slot, same bytes — and agit/local/remote
--urlis classified by the code that already classifies--source. Thealternative the finding also offered (thread a
customUrloption down the dispatcher intoresolveDatasetRoot'sdefaultcase) was rejected: it re-introduces a second channel for"which source is in play" beside
config.resolution, which is the ambiguity this PR's owndecisions remove. Precedence:
--sourceoutranks--url(command-specific beatsprogram-level); an empty
--sourcestill raises its own error.architecture.md's fallback-chain line was false —cache hit → default GitHub release → custom URL (via --url flag)describes neither the old code nor the new one. Corrected: thechain belongs to the DEFAULT source, and a named source bypasses it into its own identity slot.
Same rule stated in the resolution spec's Precedence Order (
--urlas step 2, with theequivalence given as a rule rather than a coincidence), in the CLI reference's Global Options
table (where
--urland--no-kbwere both missing) and in the three commands'--helpnotes.bootstrapEnvironmentis never called:cli.ts'spreActionguardif (thisCommand === prog) returnis always true, because Commander invokes a program-levelhook as
callback(hookedCommand, actionCommand)—thisCommandIS the program for everysubcommand. Proven, not inferred: with this round's fix reverted,
pair update --url <mirror>issues zero HTTP requests (the pre-flight would have issued one); with it, exactly one
download of the mirror plus its
.sha256. Consequences worth naming, all recorded in the ADL:validateCliOptions's--url+--no-kbconflict error, theDatasetNotFoundError/DatasetAccessErroraccessibility check and round 6'sshouldSkipKBDownloadclause are allunreachable from the CLI today, and round 8's "
--urldouble-downloads at the pre-flight"cannot occur while the guard stands. Correcting the guard makes every command resolve (and
potentially download) a KB before it runs — a behaviour change with its own blast radius, and
not what this finding asked for. The command path now honours
--urlon its own, which is whatthe flag promised.
Test-first, again visible in
git log:c932a388is RED-only — 13 assertions across 5files, verified failing by reverting only the four source files — and
c5c7370aturns themgreen. It carries
--no-verifyon purpose, stated in its own commit body: the pre-commithook runs
ts:checkand the failing state names an API that does not exist yet. The end-to-endpair the finding asked for:
install/handler.test.ts"downloads the url and installs itscontent, not the default KB" (a monorepo dataset is seeded so a disconnected flag fails loudly
with the dataset's content, never vacuously) and
cli.test.ts"pair update --urlupdates from the mirror" — argv to disk through
runCli, the only test covering theglobal/command option merge the flag depends on. Plus two CLI-level assertions in
scripts/smoke-tests/scenarios/source-resolution.sh(Tests 7-8:--urlinstalls the named localKB;
--sourcewins over--url), where CLI behaviour belongs per the gate/tooling rule.Review Rounds 14-15 (commits
36254dc9→f125b23f→7d333972→dab46767→49031c07→13583b4d)Two Major findings, both about the same dead path round 12 discovered. Both resolved in this PR
— nothing filed as a card, nothing deferred to a follow-up. (Round 14's commits missed the push
that closed round 12; round 15 verified them against the findings, closed the gap they left, and
pushed the lot.)
--no-kbwas documented as working while it is inert. Round 12's docs pass added a--no-kbrow reading "Skip knowledge base download" to the same table where it corrected the false
--urlclaim.
kb === falseis read only byconfig/bootstrap.tsandkb-manager/cli-options.ts, bothreachable only from the pre-flight this PR documents as never called — so
pair install --no-kbresolves and installs a KB anyway. Fixed in all three places that made the promise: the CLI
reference row now reads "Currently a no-op" above a warn callout (what it does, why, that the flag
stays registered so scripts passing it don't break, and where the open decision lives); the flag's
own
--helpdescription is nowCurrently a no-op (was: skip knowledge base download); and theCLI contracts spec marks
CliOptions.kbINERT with a pointer. Test-first for the help surface:cli.test.tsdrivesrunCli(['--help']), captures the real help output and requires the--no-kbline to say
no-opand not "Skip knowledge base download" — RED before49031c07, green after.preActionguard is always true — so--log-levelwas dead too. Taken as the reviewer'soption (b), plus a real fix for the part that carries no blast radius:
--log-level/--verboseare now LIVE. The handling sat below the always-true guard, sopair <cmd> --log-level debugsilently did nothing and the only level ever applied was themodule-level default. Nothing about a global log level needs the KB pre-flight, so it was hoisted
above both guards (
applyGlobalLogLevel) and now applies to every command; a command-level--log-level(package,update-link) still wins. Test-first at argv level throughrunCli(
36254dc9RED,expected 'INFO' to be 'DEBUG'twice;f125b23fgreen) — the only layer thatproves the flag survives the hook.
shouldSkipKBDownloadchange is REVERTED. The reviewer's point stands: shipping afix to a function no CLI invocation reaches, with a green unit test beside it, makes the suite
assert behaviour the user can never observe.
config/bootstrap.ts'sshouldSkipKBDownloadandthe
checkKnowledgeHubDatasetAccessibleSCOPE comment are byte-identical tomainagain, andconfig/bootstrap.test.tsis back tomainentirely. The behaviour that branch reached for isdelivered reachably by the round-12 parser change (
namedSource), untouched.⚠️ UNREACHABLE FROM THE CLI TODAYbanner atbootstrapEnvironment's entry point names the Commander semantics that kill it,says a green suite in that file proves nothing about user-visible behaviour, and points at the
ADL; a matching
⚠️ THIS FUNCTION NEVER RUNS PAST ITS FIRST LINE — deliberately, not accidentallyJSDoc sits onrunKbPreflight. The hook body was split intoapplyGlobalLogLevelrunKbPreflight(the prose pushed it pastmax-lines-per-function), which also puts eachrationale on the thing it explains.
What is NOT in this PR, and is a merge-gate call rather than a card. Option (a) — correcting the
guard to
actionCommand === prog— would make every KB-requiring command resolve and potentiallydownload a KB before it runs (a second fetch on top of install/update's own resolution, exactly
the double-download round 8 flagged) and would re-enable
validateCliOptions, theDatasetNotFoundError/DatasetAccessErrorprobe andshouldSkipKBDownloadin one step, eachneeding its own coverage. Plainly: the open choice is revive the pre-flight / delete it / retire
--no-kbwith it. Nothing here pre-empts it, and the deadness is now impossible to miss — in thecode, in
--help, in both docs pages and in the ADL.Review Round 16 (commits
a7499e15→9eb777ae→ae97cca6)One Major finding, fixed in place — nothing filed as a card, nothing deferred.
renameof aDIRECTORY registered the destination as
newPrefix— always trailing-slash — whileexistsSyncis an exact match on the dir set, so
existsSync('<dest>')was false unless a moved file landeddirectly under it (the file loop adds
dirname(file)).restoreCachedKBandremoveBackupKBboth start with
if (!fs.existsSync(backupPath)) return, so any backup/restore test seeded withonly nested files no-opped and passed for the wrong reason — on the invariant this PR leans on
hardest (a failing re-fetch leaves the user's cache intact). Fixed test-first: two RED cases in
in-memory-fs-write.test.ts(an all-nested rename's destination is observable; the parent listsit exactly once — the phantom
<dest>/entry is the same bug fromreaddir's side), then theone-line fix
rel === '' ? newDir : newPrefix + rel(the directory branch extracted into aprivate
renameDirectoryto stay undermax-lines-per-function— extraction only). A third testin
cache-manager.test.tspins the non-vacuity where it matters: a backup + restore round-trip ona slot whose only file is
<slot>/.pair/knowledge/guidelines/index.md, verified to FAIL with thefix reverted.
rather than at the merge gate.
pnpm turbo testacross the whole monorepo is green, 10/10tasks: no suite anywhere was passing BECAUSE a renamed directory read as absent. One test in
this package's suite was (
kb-availability.test.tscounted 2 entries inexternal/— the slotplus its phantom); it is green now for the right reason. So there is no accept/carve-out decision
left for the human on this finding.
cc8104f4/ef44cbe2were RED testswhose implementation had never been committed.
9eb777aelandscacheManager.writeSlotAtomically— populate into<slot>.tmp-<pid>-<n>beside the slot, renamethe slot into existence only when the stage is complete, sweep stages of DEAD pids (a LIVE pid's
stage is a concurrent install in flight), remove its own stage and rethrow the ORIGINAL error on
failure — wired into all three install forms (remote download, local ZIP, git clone). The unwrap
and the structure check run on the stage too, so an invalid ZIP leaves neither slot nor stage.
ae97cca6): local slots are CONTENT-keyed (the path-keyedshape moves to Alternatives as superseded, with why it was chosen first — the read API was
text-mode until
readFileBytes); stage-and-swap replaces "concurrency deferred", with thelock-file route and why it was rejected; and a new clause records the rule this round applied —
a defect in a shared test double is fixed in the double, not worked around in the fixture.
Review Round 17 (commits
03da0d96→a778591f→d4480f4d)Two Major doc findings, then the two largest behaviour changes of the whole PR.
cache-slot-key.ts's header cited apair kb-cachecommand and acache-inventory.tsfile that did not exist, and the ADL described a labelledzip-<basename>-<hash>slot the code never produced (it emitsexternal/zip-<hash12>, no label —a label would re-smuggle the path into a content-keyed identity). Corrected here and in four other
user/adoption-facing docs.
a778591f— the KB pre-flight was REVIVED. After three rounds of documenting it as dead andleaving the choice to the merge gate, this commit takes it: the
preActionguard now tests theACTION command (
actionCommand === prog⇒ no subcommand matched), and the Commander argumentconvention the fix rests on is pinned by its own test. Two things came with it, both deliberate:
the command exemption list was inverted from a deny-list to an allow-list
(
commands/bootstrap-policy.ts— onlyinstallandupdateresolve a KB; with a deny-list everycommand added tomorrow would inherit a network round-trip by simply not being remembered there),
and
--no-kbbecame real — which also makes--url+--no-kban error where it waspreviously accepted and silently ignored.
d4480f4d—pair kb-cache list|prune(absorbing [TECH-DEBT] KB cache: no automatic eviction of external slots #427).listreports every cache entry withits size, its manifest label and whether prune would take it;
prune(--dry-run,--json)reclaims superseded official slots, pre-
external/git clones and abandoned stages/backups. Itnever touches an
external/slot, the running version's slot, or anything it cannot classify.Review Round 18 (commits
2fe7aa9d→55a5888f→0e1b3c62→8028f758)Five actionable findings (1 Critical + 4 Major) — all five fixed in this PR, none deferred, none
escalated. Four of them landed on round 17's two commits, which is the review catching exactly
where the risk was.
(
checkKnowledgeHubDatasetAccessible) execute for the first time, and it probed the bundleddataset path (
getKnowledgeHubDatasetPath→findPackageJsonPath) instead of the cache slot step2 had just populated. In a published package that path does not merely miss — it throws:
@pair/knowledge-hubis hoisted next topair-cliundernode_modules/@pair/(npm and pnpmalike), never nested under it, and
postbuild.jsbundles no dataset. So a releasedpair installdownloaded the KB successfully and then died withUnable to find @pair/knowledge-hub package. Nothing in the suite could see it: every unit fixture seeds amonorepo dataset, and the smoke suite runs
distfrom inside the monorepo where the pnpm symlinkshort-circuits steps 2-3. Fixed by making the two steps share ONE path —
resolveDatasetForPreflightreturns the path it resolved andcheckDatasetAccessible(fs, path)takes it as an argument (
shouldSkipKBDownload+hasLocalDatasetfolded into it, same order ofprecedence). Covered by a unit case where the bundled path THROWS, and verified empirically
outside the repo:
distcopied into a scratch package with@pair/knowledge-hubabsent from ahoisted
node_modules— the pre-fix binary printed the error above, the fixed one runs clean, withand without
--no-kb. Rule recorded in the ADL: a check that derives its own subject does notverify the operation it follows.
UNREACHABLEbanner onbootstrapEnvironment, the--no-kbhelp string, the ADL clause ("neverruns — NOT revived here"), the CLI reference callout, the contracts spec ("INERT"), and a
round-15 test that pinned the no-op help text. All six now describe what ships (plus
config/cli.ts's JSDoc, stale the same way), and the pinned test is flipped. The--url+--no-kbrejection — a user-visible behaviour change with no defect behind it — isstated in
--help, the CLI reference and the contracts spec, because that is where a user whohits it looks.
kb-cache prunedeleted an install in flight. It classified everystage/backupasgarbage from the NAME, while
cache-manager.sweepOrphanedStageschecks pid liveness for preciselythat reason. Concrete loss: project B's prune
rm -rfs project A's extraction mid-flight and, withit, the
.bakthat is A's only way back — the exact deletion this PR's bolded invariant forbids ona cache that is machine-wide by definition. The liveness predicate is now ONE function
(
isStageOwnerAlive, shared with the sweep through the barrel), and a.bakgets its ownevidence: a backup whose sibling slot is ABSENT is the only copy of that KB and is spared;
reclaimed once the slot is back.
listlabels a kept entry, so a leftover-shaped survivor does notread as a prune bug. Test-first, unit + on-disk.
ae97cca6while the headcarried the pre-flight revival and a new destructive command. Fixed by re-publishing at the new
head: these two sections, plus a gate attested at
8028f758.kb-cachewas documented only in the CLI reference. Now recorded in the ADL (whatprune reclaims and each of the five things it never touches, with the reason for each), in
kb-source-resolution.mdx(the leftovers table gains aprunecolumn plus a "what prune neverdeletes" paragraph) and in
cache-slot-key.ts's header, which still called manualrm -rftheonly cleanup.
Why This Change
P0: this is the only one of the three #391 follow-ups (with #396, #397) that writes outside the running project — a single ZIP install corrupts a shared, version-keyed cache that every other project on the machine treats as authoritative, with no signal connecting cause and effect. #396/#397 stay contained to the invoking project and are deliberately not touched here (maintainer decision 2026-08-05 keeps the cards separate — different file, different gravity; bundling them would have classified the critical fix by the worst dimension of the bundle).
Recorded in ADL
2026-08-11-kb-cache-slots-keyed-by-source-identity.md: slot = source identity (not CLI version); why the local discriminator is the archive's content (sha256of the bytes, onceFileSystemServicegrew a byte-modereadFileBytes— the text-mode-only read is why the path was hashed first, and hashing a lossily-decoded binary is not defensible in a security-adjacent path); the contamination policy (manifestnamemismatch ⇒ contaminated ⇒ warn+purge+re-fetch; missing/unreadable/nameless manifest ⇒ inconclusive ⇒ still trusted, so a legacy cache is never deleted on a signal that says nothing); same-source concurrency made safe by stage-and-swap (and why not a lock file); no automatic cache eviction (rm -rf ~/.pair/kb/externalis always safe).Changes Made
Implementation Details
cache-slot-key.ts(new): pure identity → key → path derivation, no filesystem access —KBSourceunion (official | remote | git | zip— a directory owns no slot),cacheSlotKey,getCacheRoot(honoursPAIR_KB_CACHE_DIR, and rejects a non-absolute or..-bearing value),getCachedKBPath(takes a key verbatim; throws on an empty key, which would resolve to the cache ROOT, and on one containing..),getSourceCachePath,localKBSource(the single ZIP-vs-directory classifier, case-insensitive),resolveSourcePath(absolute under posix OR win32 rules, canonicalized so one filesystem location maps to one slot),OFFICIAL_KB_NAME.cache-manager.ts: slot lifecycle only —inspectSlot,purgeSlot,isKBCached, backup/restore/removeBackup (now taking aKBSource, and idempotent: a stale.bakor a half-written slot no longer makes the rename fail), andwriteSlotAtomically(round 16 / [TECH-DEBT] KB cache: same-source concurrent installs are not atomic #428): populate a<slot>.tmp-<pid>-<n>stage beside the slot, rename it on whole, sweep dead-pid orphans, clean up its own stage and rethrow the original error on failure.zip-source.ts(new): resolves a local archive AND hashes its bytes, so a local ZIP's slot is keyed by CONTENT ([TECH-DEBT] KB cache: a local source is discriminated by path, not content #429) — the same archive copied to two directories lands on one slot.packages/content-ops:FileSystemService.readFileBytes(byte-mode read) on the real service and its in-memory double, which is what made content-keying possible; and the in-memory double's directoryrenamenow registers the destination directory itself (round 16).kb-manager/index.ts: the public surface is re-exported from the barrel and no production module outsidekb-manager/imports one of its internal modules (test files still do, deliberately, to spy on them). The barrel exports install entry points —installKBFromLocalZip,installKBFromGit— never slot primitives; every symbol on it has a caller outside the module.kb-installer.ts: the local ZIP install resolves its own slot and populates it through the atomic stage (after verification, so a bad package never lands in a slot); the zip path is resolved via an injected cwd rather thanprocess.cwd().installKBFromGitowns the git slot's whole lifecycle and sets the old clone aside (restoring it when git throws) instead of purging first.installKBunwraps a ZIP nested under a single root directory, like the local-ZIP path.installKBFromLocalDirectoryremoved — dead code with no production caller.kb-availability.ts: resolves the source once and dispatchesinstallFromSourceon the resolvedkind(never on a second look at the raw string) through an exhaustiveswitchwith aneverdefault, so a newKBSourcekind is a type error rather than a silent official-release download; backs the slot up before every rewrite and restores it on failure; the backup dance protects the resolved source's slot rather than always the official one.config/kb-resolver.ts: a pure dispatcher — the git case is one call toinstallKBFromGit; local resolution classifies ZIP-vs-directory throughlocalKBSource(ZIP → its own slot, directory → used in place). TheisKBCachedprobe runs only underPAIR_DIAG, the only thing that consumes its answer.apps/pair-cli/src/kb-manager/official-kb-name.test.ts(new): tiesOFFICIAL_KB_NAMEto the--namethe release script passes — drift there would classify every official slot as contaminated. It lives next to the constant so it imports it; only the shell script, which has no importable form, is still matched as text.scripts/smoke-tests/scenarios/scaffold-kb.sh:assert_pinned_bug "foomakers/pair#395"→ positive assertions ("Official KB cache slot untouched by the external ZIP install (install --source <zip> extracts an external KB into the official KB's cache slot (shared-cache contamination) #395)", "Release ZIP installs into its own source-keyed cache slot"); pre-seeds a populated official slot in the isolated HOME.apps/pair-cli/vitest.setup.ts(new): clearsPAIR_KB_CACHE_DIRbefore every test — the suites asserthomedir()-derived slot paths and must not depend on an ambient value of the variable this PR started honouring.external-kb.mdxobsolete "ZIP install is not equivalent" warning replaced with the per-source-slot behaviour + self-heal, and corrected on the directory case;kb-source-resolution.mdxgets a cache-strategy table per source form (directory = no slot), corrected git-clone steps and the enforcedPAIR_KB_CACHE_DIRconstraint;architecture.md+ the context-map glossary aligned.Files Changed
apps/pair-cli/src/kb-manager/cache-manager.ts,kb-installer.ts,kb-availability.ts,index.ts,apps/pair-cli/src/config/kb-resolver.ts,apps/pair-cli/src/config/bootstrap.ts+ their test files,apps/pair-cli/vitest.config.ts,apps/pair-cli/src/commands/scaffold-kb/templates/readme.ts,apps/pair-cli/src/commands/scaffold-kb/metadata.ts+commands/index.test.ts(round 11),apps/pair-cli/src/config/cli.ts,apps/pair-cli/src/cli.ts,apps/pair-cli/src/commands/{install,update,kb-info}/parser.ts+metadata.ts+ their tests,apps/pair-cli/src/cli.test.ts(rounds 12+14+15),apps/website/content/docs/reference/cli/commands.mdx+apps/website/content/docs/reference/specs/cli-contracts.mdx(rounds 14-15),scripts/smoke-tests/scenarios/scaffold-kb.sh,scripts/smoke-tests/scenarios/source-resolution.sh(round 12),apps/website/content/docs/customization/external-kb.mdx,apps/website/content/docs/reference/specs/kb-source-resolution.mdx,apps/website/content/docs/reference/configuration.mdx,.pair/adoption/tech/architecture.md,.pair/adoption/tech/boundedcontext/integration-process-standardization.mdapps/pair-cli/src/kb-manager/cache-slot-key.ts+cache-slot-key.test.ts,apps/pair-cli/src/kb-manager/zip-source.ts+zip-source.test.ts(rounds 16/[TECH-DEBT] KB cache: a local source is discriminated by path, not content #429),apps/pair-cli/vitest.setup.ts,apps/pair-cli/src/kb-manager/official-kb-name.test.ts(moved frompackages/knowledge-hub/src/conformance/in round 3),.pair/adoption/decision-log/2026-08-11-kb-cache-slots-keyed-by-source-identity.mdpackages/content-ops(rounds 16 / [TECH-DEBT] KB cache: a local source is discriminated by path, not content #429):src/file-system/*(readFileByteson the service interface + implementation) andsrc/test-utils/in-memory-fs/*(readFileByteson the double; the directory-renamedestination fix) + their test files53 files changed, 3688 insertions(+), 467 deletions(-).
Testing
Test Coverage
Test-first:
cache-manager.test.tsrewritten (26 tests) and 5 new US-395 tests added tokb-installer.test.tsreproducing "populated official slot + ZIP install ⇒ official manifest rewritten". Verified RED before the fix: 27 failures across the two suites (attested in this body). Round 2 makes the evidence checkable from history: commite1a49402is RED-only (7 failing tests),b3f2c658turns them green.AC1/AC2:
kb-installer.test.tsUS-395 block +scaffold-kb.shAC3/AC5:
kb-availability.test.ts"contaminated official slot self-heals" +cache-manager.test.tscontamination blockAC4:
cache-manager.test.tskeying block + "two different external ZIPs get two slots" installer testAC6:
scaffold-kb.shpositive assertionsRound 2 additions:
cache-slot-key.test.tscache-root validation + path-canonicalization blocks;kb-resolver.test.ts"local directory resolution creates no cache slot";kb-availability.test.ts"a local directory is not a fetchable source"Round 3 additions (
51e5e88e, RED before944ce186):cache-slot-key.test.ts"judges the override by the HOST convention";kb-installer.test.ts"keeps the previous clone when the new clone fails", "replaces the slot wholesale on a successful clone", "unwraps a downloaded ZIP nested under a single root directory";kb-resolver.test.ts"does not probe the cache when diagnostics are off" + itsPAIR_DIAG=1twin;kb-availability.test.tsextended to assert the rejection message names--sourceand not the internal functionsRound 4 additions (
959c6854, RED before935b7af3):cache-manager.test.ts"tolerates a .bak that vanishes between the check and the delete" + "never throws when the delete itself fails (EBUSY)";kb-availability.test.ts"keeps a successful re-download when discarding the old slot fails";kb-installer.test.ts"keeps a successful clone when discarding the set-aside one fails";cache-slot-key.test.ts"refuses the external NAMESPACE as a key" + "still accepts a slot INSIDE the namespace" + the label-truncation assertionRound 6 additions (
8d38a69e, RED before5204251a):cache-manager.test.ts"retries in the old order when the rename back fails once" + "warns and names the recoverable copy when it cannot put the backup back" + "gives each set-aside copy a distinct name within the same millisecond";kb-availability.test.ts's round-5 restore test extended to assert the invariant it was driving past (the.baksurvives AND is named in a warning, next to the unchanged "the 404 is the error you see");bootstrap.test.ts"does not let the monorepo dataset outrank an explicit remote --url"Round 12 additions (
c932a388, RED beforec5c7370a):config/cli.test.tsnamedSourceblock (precedence, empty forms, absent);commands/{install,update,kb-info}/parser.test.ts"the program-level --url names the source when --source does not";commands/install/handler.test.ts"pair install --url <mirror>installs what the mirror served" (end to end, against a seeded monorepo dataset);cli.test.ts"the program-level --url reaches the command" (argv → disk throughrunCli, asserting exactly one fetch of the mirror);source-resolution.shTests 7-8Rounds 14-15 additions:
cli.test.ts"US-395 round 14: the global --log-level actually takes effect" (2 assertions, RED in36254dc9, green inf125b23f) and "US-395 round 15:pair --helpdoes not advertise --no-kb as working" (RED indab46767, green in49031c07, asserted against the real captured help output).config/bootstrap.test.tsis back tomain— the round-6 assertion it carried covered a path no CLI invocation reaches, and was removed with the change it covered.Round 5 additions (
498cd3cc, RED before57073b61):kb-installer.test.ts"stages a download under a name keyed by the source URL, not by the CLI version alone" + "reports the clone failure, not a failure of the restore that follows it";cache-manager.test.ts"restoreCachedKB puts the backup back even when the half-written slot cannot be deleted";kb-availability.test.ts"reports the download failure, not a failure of the restore that follows it";kb-resolver.test.ts"an explicit --url is not outranked by the monorepo dataset" + "remote resolution honours the url even inside a monorepo checkout"Round 16 additions (
a7499e15, RED before the fix in the same commit;9eb777aefor the atomic stage):in-memory-fs-write.test.ts"registers the destination of a renamed directory whose files are all nested" + "lists a renamed directory exactly once in its parent";cache-manager.test.ts"backs up and restores a slot with no file directly under it" (verified to FAIL with the fix reverted);cache-manager.test.ts+kb-installer.test.tsatomic-stage blocks fromcc8104f4/ef44cbe2now green.Fixture note, now historical:
InMemoryFileSystemService.renameof a directory used to register the destination as<dest>/whileexistsSyncmatches exactly, so the destination was invisible unless a moved file landed DIRECTLY under it — and a backup/restore assertion seeded only with nested files passed vacuously (existsSync('<slot>.bak')false ⇒restoreCachedKBno-ops). Rounds 5-6 worked around it with a direct-childmanifest.json. Round 16 fixed the double instead, measured the fallout across the whole monorepo (green, 10/10 turbo tasks — nothing anywhere was passing because a renamed directory read as absent) and pinned the non-vacuity with a fixture that has no direct child. A fixture no longer needs any convention to mean something.Test Results
Released-layout verification (round 18, manual — the defect is invisible from inside the repo).
apps/pair-cli/distcopied into a scratch package outside the monorepo, with@pair/knowledge-hubABSENT from a hoisted
node_modules(the real published shape) and the cache slot pre-seeded so nonetwork was needed:
pnpm smoke-testsas a whole is NOT green, for one pre-existing reason unrelated to this branch:scripts/smoke-tests/scenarios/coverage-gate.shis committed100644while every sibling is100755, so the runner exits 1 onmaintoo. Story #400 owns that file.End-to-end proof (AC1/AC2/AC6):
bash scripts/smoke-tests/scenarios/scaffold-kb.sh→ exit 0, with:(requires
pnpm dlx turbo run build --filter @pair/pair-clifirst — the scenario runs againstapps/pair-cli/dist)Quality Assurance
Review Areas
readFileBytesonFileSystemService— the interface change is in this PR, with its own tests.manifest.jsonfixture convention is gone, the fix is pinned by a test that fails without it, and the monorepo-wide fallout was measured at zero.pair kb-cache list|prune(round 17, absorbing [TECH-DEBT] KB cache: no automatic eviction of external slots #427). There is still no LRU/TTL: growth is proportional to the number of KBs a user installs, not to how often.prunereclaims superseded official slots, pre-external/git clones and abandoned stages/backups, and--dry-runshows what it would take. Read the never-delete list as the real specification: everyexternal/slot (the CLI retains neither the URL nor the archive, so a wrong deletion is not undone by re-installing), the running version's slot, unclassifiable entries, every official slot when no version is resolvable, and anything an install in flight owns — a live-pid stage, and a.bakwhose slot has not come back. That last rule is round 18's fix and the one place prune could have destroyed a user's only copy of a KB.<version>.discarded-*is still by hand, named in the spec.PAIR_KB_CACHE_DIRis now read AND validated bygetCacheRoot()— it was documented in two reference tables and implemented nowhere. A relative or..-bearing value is now an explicit error rather than a path that would makepurgeSlotdelete inside the current repository, and "absolute" there means absolute on the host (round 3):C:\cache\kbis a relative name on POSIX.installKBnow callsnormalizeExtractedKBso a single-root archive lands correctly, but a negative result is not raised as an error on that path — deliberate, recorded in the ADL and the spec: making a structure check fatal on the official download is a behaviour change with no defect behind it, and the local-ZIP path (whose caller owns the archive) still throws.--sourcedirectory is used in place, not cached — so it has no slot to invalidate and edits to it take effect on the next install. This is the corrected behaviour of record (round 2); the previous docs claimed adir-slot that never existed.install/update.bootstrapEnvironmenthad never executed —cli.ts'spreActionguardthisCommand === progis always true for a program-level Commander hook (proven with an HTTP-request count). Rounds 12-15 documented that and left the choice to the merge gate; round 17 revived it. What a reviewer should weigh: the guard now tests the ACTION command (with the Commander convention itself pinned by a test), the exemption list is an ALLOW-list so onlyinstall/updatereach the network,--no-kbtakes effect again, and--url+--no-kbis now rejected where it was previously accepted and ignored — a user-visible behaviour change, stated in--help, the CLI reference and the contracts spec. Round 18 fixed the one defect the revival shipped with (steps 2 and 3 resolving different paths, which aborted every released install) and verified the fix in a real released layout. Round 8's--url"double-download at the pre-flight" is now possible in principle and harmless in practice: the pre-flight's fetch populates the slot the command then reads.commands/install/handler.ts) or config/loader.ts touched — per the maintainer's 2026-08-05 decision to keep the cards separate.Testing the Changes
Dependencies & Related Work
Follow-up Work
pair kb-cache list|prune(an explicit command, not an LRU/TTL — that trade-off is in the ADL). All three should be closed with this PR rather than kept open. Nothing on this story is deferred to a card.--no-kbwith it" to the merge gate; round 17 revived it and round 18 made code,--help, both docs pages and the ADL agree. What remains for the human is the ordinary call on a described change: accepting that--no-kbnow takes effect and that--url+--no-kbis now an error.installwas manual (above), because every fixture in the suite is a monorepo checkout and the right home for such a check is a smoke scenario — andscripts/smoke-tests/is being edited by story Smoke-test suite (run-all.sh) is not wired into CI — pinned-bug assertions only fire locally #400 in a parallel worktree (batch mutex). The gap is recorded in the ADL as a corollary of that round's rule.Closes #395
🤖 Generated with Claude Code