From 440585b9c9d13a859da580fe5f3a8aec2f5a824a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 09:52:17 +0000 Subject: [PATCH] fix(update): keep re-checking a KEPT (frozen) capability on every run (PHARN-13) After an update kept an unparseable capability and bumped skillsVersion, the next run at the same version returned "Already up to date" before fetching, so the KEPT report never repeated and the capability's bytes stayed stale even after a pharn upgrade that could parse it. `update` now records the KEPT `role:name` keys in an additive `frozenCapabilities` config field (sorted, omitted when empty) and skips the same-version early return while it is non-empty. Ingest drops a malformed value whole, which restores the previous behavior. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TvcuVhk8hTeDskp5pAJhnc --- .../frozen-capability-recheck/GRILL.md | 35 +++++++ .../frozen-capability-recheck/PLAN.md | 60 ++++++++++++ .../frozen-capability-recheck/REGRESSION.md | 30 ++++++ .../frozen-capability-recheck/REVIEW.md | 43 ++++++++ .../frozen-capability-recheck/SHIP.md | 17 ++++ .../frozen-capability-recheck/VERIFY.md | 27 ++++++ .../regression-report.json | 25 +++++ .../verify-report.json | 17 ++++ .pharn/writes-scope.json | 4 +- docs/commands/update.md | 8 +- docs/reference/pharn-config.md | 6 ++ src/commands/update.ts | 19 +++- src/lib/pharn-config.ts | 27 ++++++ src/types.ts | 5 + tests/pharn-config.test.ts | 47 +++++++++ tests/update.test.ts | 97 +++++++++++++++++++ 16 files changed, 460 insertions(+), 7 deletions(-) create mode 100644 .dev/features/frozen-capability-recheck/GRILL.md create mode 100644 .dev/features/frozen-capability-recheck/PLAN.md create mode 100644 .dev/features/frozen-capability-recheck/REGRESSION.md create mode 100644 .dev/features/frozen-capability-recheck/REVIEW.md create mode 100644 .dev/features/frozen-capability-recheck/SHIP.md create mode 100644 .dev/features/frozen-capability-recheck/VERIFY.md create mode 100644 .dev/features/frozen-capability-recheck/regression-report.json create mode 100644 .dev/features/frozen-capability-recheck/verify-report.json diff --git a/.dev/features/frozen-capability-recheck/GRILL.md b/.dev/features/frozen-capability-recheck/GRILL.md new file mode 100644 index 0000000..003b3a9 --- /dev/null +++ b/.dev/features/frozen-capability-recheck/GRILL.md @@ -0,0 +1,35 @@ +# GRILL — frozen-capability-recheck + +Plan: `.dev/features/frozen-capability-recheck/PLAN.md` · spec-hash `bca940a5…d729d3c4e` matches live +`ARCHITECTURE.md`. Registered grillers: `{"registered":0,"grillers":[]}` → inline axes only. + +## Findings + +```yaml +- type: FINDING + rule_id: 'P5' + severity: important + file: '.dev/features/frozen-capability-recheck/PLAN.md:30' + problem: 'Write only the frozen keys that are ALSO config capabilities (KEPT), not every index.unknown entry — an unparseable capability this project never had must not pin every future run to a re-fetch.' + evidence: '`write the sorted frozen keys of the config''s capabilities`' +- type: FINDING + rule_id: 'P5' + severity: minor + file: '.dev/features/frozen-capability-recheck/PLAN.md:27' + problem: 'Ingest must drop the WHOLE field on any malformed element (not filter), and reject control characters via the same CAPABILITY_NAME_RE; partial acceptance of a hand edit is harder to reason about.' + evidence: '`kept only when an array of role:name strings`' +- type: FINDING + rule_id: 'P1' + severity: minor + file: '.dev/features/frozen-capability-recheck/PLAN.md:35' + problem: 'The run-2 eval must assert the fetch actually happens (fetchRepo called), not just the absence of "Already up to date", or it can pass on a different early exit.' + evidence: '`run 2 at the same version fetches again and re-reports KEPT`' +- type: FINDING + rule_id: 'P7' + severity: minor + file: '.dev/features/frozen-capability-recheck/PLAN.md:27' + problem: 'A stale field left by `remove` of a frozen capability is self-healing (the next update recomputes from config ∩ unknown and clears it) — acceptable, but worth one sentence in the reference doc.' + evidence: '`cleared once none are frozen`' +``` + +ADVISORY VERDICT: 4 concerns raised (0 blocking-severity, 4 advisory) — all folded into the build. diff --git a/.dev/features/frozen-capability-recheck/PLAN.md b/.dev/features/frozen-capability-recheck/PLAN.md new file mode 100644 index 0000000..9c3b808 --- /dev/null +++ b/.dev/features/frozen-capability-recheck/PLAN.md @@ -0,0 +1,60 @@ +# PLAN — frozen-capability-recheck (PHARN-13: a KEPT (frozen) capability must be re-checked on every update) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: when `update` keeps a capability it could not parse upstream ("frozen"), it records its + `role:name` in an additive `frozenCapabilities` config field (cleared once none are frozen); while that + field is non-empty the same-version early return ("Already up to date") is skipped, so every run + re-fetches, re-reports KEPT, and refreshes the capability's files once the parse succeeds (e.g. after a + pharn upgrade). The version bump itself stays as designed, so `add` is not blocked. +- layer(s): the CLI itself (`src/types.ts`, `src/lib/pharn-config.ts`, `src/commands/update.ts`) +- constitution_refs: [P1, P4, P5, P7] + +## Discovery — verified this run (P6) + +Review (agent test with mocked repo): after a run with a frozen capability, `update` bumps +`skillsVersion` (`versionWithheld` counts only file skips); the next `update` at the same version exits +"Already up to date" without fetching (`fetchRepo` calls = 0), so the KEPT report does not repeat, and +after a CLI upgrade the capability's bytes stay old until upstream bumps its version or `--force`. +`docs/commands/update.md:101-103` claims the message "repeats on every run while the situation lasts" +and "clears … when you upgrade pharn". `update.ts` computes `frozen` from `index.unknown` +(`:423`) and keeps their records (`frozenRecords`); the early return is at the version check after +`fetchRemoteSkillsVersion`. + +## Files + +- `src/types.ts` — `PharnConfig.frozenCapabilities?: string[]` (additive, P7) — layer CLI +- `src/lib/pharn-config.ts` — ingest: kept only when an array of `role:name` strings (role enum + + `CAPABILITY_NAME_RE`); anything else dropped (fail-safe: the early return then applies as today) — layer CLI/lib +- `src/commands/update.ts` — write the sorted frozen keys of the config's capabilities (field omitted when + empty); skip the same-version early return while the loaded config has any — layer CLI/command +- `tests/update.test.ts` — run 1 with a frozen capability records the key; run 2 at the same version + fetches again and re-reports KEPT; once the capability parses, its files are refreshed and the field is + cleared +- `tests/pharn-config.test.ts` — valid field round-trips; garbage dropped +- `docs/commands/update.md` — the KEPT paragraph matches the behavior (P4) +- `docs/reference/pharn-config.md` — document the field (P4) + +## Contracts satisfied + +- `docs/commands/update.md` "repeats on every run … clears when you upgrade pharn" — now true. + +## Evals to write (P1) + +- listed above; the "run 2 re-fetches" case fails on the base source. + +## Guarantee audit (P0) + +- "a frozen capability is re-checked on every update" → floor: early return skipped iff the validated + field is non-empty (membership). + +## Trust audit (P2) + +- The new field is local, hand-editable, validated at ingest; it only gates whether a fetch happens. + +## Determinism audit (P5) + +- Set membership; sorted output. + +## Open questions (HALT) + +- none diff --git a/.dev/features/frozen-capability-recheck/REGRESSION.md b/.dev/features/frozen-capability-recheck/REGRESSION.md new file mode 100644 index 0000000..b44f66c --- /dev/null +++ b/.dev/features/frozen-capability-recheck/REGRESSION.md @@ -0,0 +1,30 @@ +# REGRESSION — frozen-capability-recheck + +The verdict below is computed by `.dev/floor/check-regress.mjs`, not by this stage's judgment. + +## Base and partition + +- **base:** `b49f41f029def06de471ce931cc8266b99fe7675` (`origin/main` at build time; the build is an uncommitted working tree on top of it). +- **inside** (each declared in `PLAN.md` `## Files`): `docs/commands/update.md`, `docs/reference/pharn-config.md`, `src/commands/update.ts`, `src/lib/pharn-config.ts`, `src/types.ts`, `tests/pharn-config.test.ts`, `tests/update.test.ts`. +- **scope partition:** `check-regress.mjs scope` exited **0**, `escaped: []`. `.pharn/` (hook scratch) and this + feature's own stage artifacts are not build output. +- **outside gates:** the stdlib `*.test.mjs` / `*.test.cjs` files + whole-repo `validate`; 0 committed eval pairs. +- **style-gate skip:** `inside` touches no shared style config, so `lint` / `format:check` / `lint:md` are absent from both maps. + +## Per-gate exit codes + +| gate | base | head | flipped? | +| ---------- | ---- | ---- | -------- | +| `tests` | 0 | 0 | no | +| `validate` | 0 | 0 | no | + +- `regressions[]`: **empty** +- `pre_existing[]`: **empty** + +## Verdict + +**REGRESSIONS: none — no deterministically-detectable breakage outside the feature.** +(`regression-report.json` `.verdict` = `no-regressions`.) + +Residual (P0/P7): this catches exactly what its suite catches. The vitest suite exercising `src/**` is +owned by `/pharn-dev-build`'s floor and `/pharn-dev-verify`. This certifies the comparison, never the increment. diff --git a/.dev/features/frozen-capability-recheck/REVIEW.md b/.dev/features/frozen-capability-recheck/REVIEW.md new file mode 100644 index 0000000..8faec42 --- /dev/null +++ b/.dev/features/frozen-capability-recheck/REVIEW.md @@ -0,0 +1,43 @@ +# REVIEW — frozen-capability-recheck + +Floor first: `node .dev/floor/validate.mjs .` → exit 0 (GREEN). Everything below is **advisory**. + +## Floor-gate findings (blocking) + +None. + +- **L-floor (P0):** the early return is skipped iff the ingest-validated `frozenCapabilities` is + non-empty (membership); the written value is `config capabilities ∩ index.unknown`, sorted (grill #1). +- **L-eval (P1):** 11 new cases fail on the base source: 8 ingest drops (whole-field, grill #2), the + KEPT-only write, the run-2 re-fetch (asserts `fetchRepo` was called — grill #3), and the refresh + + clear once the capability parses. A fourth case pins that the early return still fires with no field. +- **L-trust (P2):** the field is local and hand-editable; a malformed value is dropped, which restores + today's behavior (fail-safe). It only decides whether a fetch happens; it never names a path. +- **L-axis (P3):** ingest in `pharn-config.ts`, decision + write in `update.ts` — the existing split. + +## Advisory findings + +```yaml +- type: FINDING + rule_id: 'P4' + severity: minor + file: 'CLAUDE.md' + problem: "CLAUDE.md's `pharn update` paragraph does not mention `frozenCapabilities` (not in the plan's `## Files`)." + evidence: '**`pharn update` (`commands/update.ts`) is drift-safe by default.**' +- type: FINDING + rule_id: 'P5' + severity: minor + file: 'src/commands/update.ts:203' + problem: 'While upstream stays unparseable, every same-version run fetches and asks to re-apply. Intended (the doc says the message repeats), but it costs a clone per run until the capability parses or is removed.' + evidence: 'const recheckFrozen = (config.frozenCapabilities ?? []).length > 0;' +- type: FINDING + rule_id: 'P4' + severity: minor + file: 'CHANGELOG.md:8' + problem: "No CHANGELOG `[Unreleased]` entry (not in the plan's `## Files`)." + evidence: '## [Unreleased]' +``` + +## Verdict + +**GREEN** — 0 floor-gate findings, 3 advisory findings. No lesson proposed for canon. diff --git a/.dev/features/frozen-capability-recheck/SHIP.md b/.dev/features/frozen-capability-recheck/SHIP.md new file mode 100644 index 0000000..e780c06 --- /dev/null +++ b/.dev/features/frozen-capability-recheck/SHIP.md @@ -0,0 +1,17 @@ +# SHIP — frozen-capability-recheck + +Stages run, in order: `/pharn-dev-plan` → GATE 1 (human: **Approve as written**) → `/pharn-dev-grill` → +`/pharn-dev-build` → `/pharn-dev-regress` → `/pharn-dev-verify` → `/pharn-dev-review` → GATE 2. + +| stage | structural verdict (verbatim) | +| -------------------- | ------------------------------------------------------ | +| `/pharn-dev-build` | `node .dev/floor/validate.mjs .` exit `0` | +| `/pharn-dev-regress` | `regression-report.json` `.verdict` = `no-regressions` | +| `/pharn-dev-verify` | `verify-report.json` `.verdict` = `PASS` | + +- Review: [`REVIEW.md`](REVIEW.md) · Grill (advisory): [`GRILL.md`](GRILL.md) +- Run ended at **GATE 2**. The human's standing instruction for this batch: open a PR and merge it only if + its CI checks are green. + +chain ran; the named floor verdicts are as shown — this is NOT a judgment that the increment is good or +wise; that is the human's call at the post-review gate. diff --git a/.dev/features/frozen-capability-recheck/VERIFY.md b/.dev/features/frozen-capability-recheck/VERIFY.md new file mode 100644 index 0000000..3613483 --- /dev/null +++ b/.dev/features/frozen-capability-recheck/VERIFY.md @@ -0,0 +1,27 @@ +# VERIFY — frozen-capability-recheck + +## FLOOR layer (owns the verdict) + +Gates run over the whole repo with the feature present, as a non-root user on node 22 with the session +proxy variables unset (as root with the proxy set, 5 pre-existing tests in `init.test.ts` / +`update.test.ts` fail for environmental reasons, identically at the baseline). + +| gate | exit | +| -------------- | ---- | +| `format:check` | 0 | +| `lint` | 0 | +| `lint:md` | 0 | +| `test` | 0 | +| `typecheck` | 0 | +| `validate` | 0 | + +No `structural:*` gate — the increment ships no eval-actual pair. + +**VERDICT: PASS** (`.dev/floor/check-verify.mjs`, `failing_gates: []`). + +## ADVISORY layer + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}` — floor gates only. + +Residual (P0/P7): "verified" means the named gates passed — not that the feature is correct in any sense +the suite does not encode. diff --git a/.dev/features/frozen-capability-recheck/regression-report.json b/.dev/features/frozen-capability-recheck/regression-report.json new file mode 100644 index 0000000..7d97206 --- /dev/null +++ b/.dev/features/frozen-capability-recheck/regression-report.json @@ -0,0 +1,25 @@ +{ + "base": "b49f41f029def06de471ce931cc8266b99fe7675", + "inside": [ + "docs/commands/update.md", + "docs/reference/pharn-config.md", + "src/commands/update.ts", + "src/lib/pharn-config.ts", + "src/types.ts", + "tests/pharn-config.test.ts", + "tests/update.test.ts" + ], + "outside_gates": { + "tests": { + "base": 0, + "head": 0 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [], + "verdict": "no-regressions" +} diff --git a/.dev/features/frozen-capability-recheck/verify-report.json b/.dev/features/frozen-capability-recheck/verify-report.json new file mode 100644 index 0000000..2b4754a --- /dev/null +++ b/.dev/features/frozen-capability-recheck/verify-report.json @@ -0,0 +1,17 @@ +{ + "feature": "frozen-capability-recheck", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "typecheck": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { + "registered": 0, + "findings": [] + } +} diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 3bd882a..e7162a7 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/proxy-env-opt-in/SHIP.md" + ".dev/features/frozen-capability-recheck/SHIP.md" ], "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-09-24T09:34:10.397Z" + "set_at": "2026-09-24T09:52:10.070Z" } diff --git a/docs/commands/update.md b/docs/commands/update.md index 9afefd6..7d2b2c7 100644 --- a/docs/commands/update.md +++ b/docs/commands/update.md @@ -98,9 +98,11 @@ upstream this run`. A capability you have is not dropped because one fetch could - **Everything else updates normally**, including the skills version — so the next [`pharn add`](add.md) is not blocked. -The message repeats on every run while the situation lasts, because the situation lasts. It usually -clears by itself when upstream finishes the change, or when you upgrade pharn -(`npm install -g @pharn-dev/pharn@latest`). If you no longer want the capability at all, remove it +pharn records the kept capability in `frozenCapabilities` in `pharn.config.json`. While that +field is set, `update` does not stop at "Already up to date": every run fetches again and re-checks +it, so the message repeats while the situation lasts. Once the capability can be read (upstream +finishes the change, or you upgrade pharn with `npm install -g @pharn-dev/pharn@latest`), that run +updates its files as usual and clears the field. If you no longer want the capability at all, remove it with [`pharn remove`](remove.md). ## `pharn is too old for the current pharn-oss` diff --git a/docs/reference/pharn-config.md b/docs/reference/pharn-config.md index cd8bdb4..15690d8 100644 --- a/docs/reference/pharn-config.md +++ b/docs/reference/pharn-config.md @@ -74,6 +74,12 @@ When the only skipped files were ones you edited (`modified` / `unrecorded`), it [`pharn add`](../commands/add.md) run at that version; the next complete `pharn update` removes it. A value that is not a `x.y.z` version is ignored. +`frozenCapabilities` lists (`role:name`, sorted) the installed capabilities the last `pharn update` +kept because it could not read them upstream. While it is non-empty, `pharn update` re-fetches even +at the same skills version, so they are re-checked on every run; the field is removed once none are +left. If you `pharn remove` one, the next update drops it from the list. A value that is not a list of +`role:name` keys is ignored. + ## Example ```json diff --git a/src/commands/update.ts b/src/commands/update.ts index afe230d..bb4cb62 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -200,8 +200,12 @@ async function runArchetypeUpdate( // my tree match upstream, overwriting my edits", which is a request the user // can legitimately make at the current version — and it is what `pharn status` // tells them to do about locally-changed files. + // A capability the last run KEPT unparsed (`frozenCapabilities`) re-opens the + // gate: without the fetch it is never re-checked, so the KEPT report would not + // repeat and its bytes would stay stale after a pharn upgrade that can parse it. const current = config.skillsVersion === latest; - if (current && !force) { + const recheckFrozen = (config.frozenCapabilities ?? []).length > 0; + if (current && !force && !recheckFrozen) { outro(`Already up to date (skills v${config.skillsVersion}).`); return; } @@ -577,12 +581,23 @@ async function applyUpdate( ...buildRecords(cwd, written), }, }); - const { pendingSkillsVersion: _previousPending, ...rest } = config; + // Only KEPT entries count: an unparseable capability this project never had + // must not pin every future run to a re-fetch. + const frozenCapabilities = configCapabilities + .map((cap) => `${cap.role}:${cap.name}`) + .filter((key) => frozen.has(key)) + .sort(); + const { + pendingSkillsVersion: _previousPending, + frozenCapabilities: _previousFrozen, + ...rest + } = config; await writePharnConfig(cwd, { ...rest, skillsVersion: nextSkillsVersion, commit: nextCommit, ...(pendingSkillsVersion !== undefined ? { pendingSkillsVersion } : {}), + ...(frozenCapabilities.length > 0 ? { frozenCapabilities } : {}), capabilities: configCapabilities, layout, installedAt: new Date().toISOString(), diff --git a/src/lib/pharn-config.ts b/src/lib/pharn-config.ts index 2c956a6..a3d12fc 100644 --- a/src/lib/pharn-config.ts +++ b/src/lib/pharn-config.ts @@ -291,9 +291,36 @@ export function readPharnConfig(cwd: string): PharnConfig | null { ) { delete config.pendingSkillsVersion; } + // Additive `frozenCapabilities` (types.ts): kept only when EVERY element is a + // `role:name` key (role enum + the anchored CAPABILITY_NAME_RE, which admits no control + // character); any malformed element drops + // the whole field, so a garbage hand-edit fails safe — `update`'s same-version + // early return then applies as it would without the field. + if ( + config.frozenCapabilities !== undefined && + !isFrozenKeyList(config.frozenCapabilities) + ) { + delete config.frozenCapabilities; + } return config; } +function isFrozenKeyList(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.every((key) => { + if (typeof key !== 'string') return false; + const sep = key.indexOf(':'); + if (sep === -1) return false; + const role = key.slice(0, sep); + const name = key.slice(sep + 1); + return ( + ROLE_VALUES.some((r) => r === role) && CAPABILITY_NAME_RE.test(name) + ); + }) + ); +} + /** * Load pharn.config.json for a command, or exit(1) with a clear message — the * shared load surface for `add`/`status`/`update`/`remove` (mirrors diff --git a/src/types.ts b/src/types.ts index e97d035..e655815 100644 --- a/src/types.ts +++ b/src/types.ts @@ -121,6 +121,11 @@ export interface PharnConfig { // edits (`modified` / `unrecorded` skips): every other file is at this // version. `add` accepts a clone at it; the next complete update clears it. pendingSkillsVersion?: string; + // Additive (P7). `role:name` of every installed capability the last + // `pharn update` KEPT because it could not parse it upstream ("frozen"), + // sorted; omitted when none. While non-empty, `update` skips its same-version + // early return, so each run re-fetches and re-checks them. + frozenCapabilities?: string[]; repo: string; commit: string | null; // Legacy (module/wizard) installs record the chosen constitution variant. diff --git a/tests/pharn-config.test.ts b/tests/pharn-config.test.ts index f26f590..fdac175 100644 --- a/tests/pharn-config.test.ts +++ b/tests/pharn-config.test.ts @@ -824,3 +824,50 @@ describe('pendingSkillsVersion ingest', () => { ); }); }); + +// PHARN-13: the additive frozenCapabilities round-trips when every element is a +// `role:name` key and is dropped WHOLE otherwise, so a hand-edit fails safe. +describe('frozenCapabilities ingest', () => { + const tmp = useTmpDir(); + const base = { + pharnVersion: '0.5.0', + skillsVersion: '1.0.0', + repo: 'pharn-dev/pharn-oss', + commit: null, + modules: [], + installedAt: '2026-09-24T00:00:00.000Z', + archetypes: ['ssr'], + capabilities: [], + }; + const put = (v: unknown): void => + writeFileSync(join(tmp.path(), 'pharn.config.json'), JSON.stringify(v)); + + it('round-trips role:name keys', () => { + const keys = ['griller:backwards-compat', 'lens:n-plus-one']; + put({ ...base, frozenCapabilities: keys }); + expect(readPharnConfig(tmp.path())!.frozenCapabilities).toEqual(keys); + }); + + it.each([ + ['not-an-array'], + [[7]], + [['no-colon']], + [['auditor:x']], + [['griller:Bad_Name']], + [['griller:../..']], + [['griller:a\u0007b']], + [['griller:ok', 'broken']], + ])('drops %j whole', (v) => { + put({ ...base, frozenCapabilities: v }); + expect(readPharnConfig(tmp.path())).not.toHaveProperty( + 'frozenCapabilities', + ); + }); + + it('absent is legal (P7)', () => { + put(base); + expect(readPharnConfig(tmp.path())).not.toHaveProperty( + 'frozenCapabilities', + ); + }); +}); diff --git a/tests/update.test.ts b/tests/update.test.ts index 69b42b3..1c6add7 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -1449,6 +1449,103 @@ describe('runUpdate (drift-safe)', () => { .join('\n'); expect(warned).not.toContain('still recorded as skills'); }); + + // PHARN-13: the bump above must not END the re-checking. Without a record of + // what was kept, the next run at the same version returns "Already up to + // date" before fetching, so the KEPT report never repeats and the bytes stay + // stale even once a newer pharn can parse the capability. + it('records the KEPT key — and only KEPT ones — in frozenCapabilities', async () => { + await installed({ capabilities: [CAP, { ...FROZEN, source: 'auto' }] }); + frozenUpstream(); + // A second unparseable capability this project never had. + parseCapabilityIndex.mockReturnValue({ + capabilities: [], + unknown: [ + ...parseCapabilityIndex().unknown, + { + name: 'never-installed', + role: 'lens', + subtree: 'pharn-pipeline/lenses', + reason: 'bad frontmatter', + }, + ], + }); + + await runUpdate(); + + expect(readPharnConfig(proj)!.frozenCapabilities).toEqual([ + 'griller:backwards-compat', + ]); + }); + + it('re-fetches and re-reports KEPT at the same version while one is frozen', async () => { + await installed({ + skillsVersion: '1.1.0', + capabilities: [CAP, { ...FROZEN, source: 'auto' }], + frozenCapabilities: ['griller:backwards-compat'], + }); + frozenUpstream(); + + await runUpdate(); + + expect(fetchRepo).toHaveBeenCalledTimes(1); + const outros = vi + .mocked(prompts.outro) + .mock.calls.map((c) => String(c[0])) + .join('\n'); + expect(outros).not.toContain('Already up to date'); + expect(capNote()).toContain('KEPT'); + // Still frozen → still recorded. + expect(readPharnConfig(proj)!.frozenCapabilities).toEqual([ + 'griller:backwards-compat', + ]); + }); + + it('refreshes its files and clears the field once it parses again', async () => { + await installed({ + skillsVersion: '1.1.0', + capabilities: [CAP, { ...FROZEN, source: 'auto' }], + frozenCapabilities: ['griller:backwards-compat'], + }); + // Installed at v1 and recorded, so a clean upstream copy is an upgrade. + write(join(proj, FROZEN_FILE), 'installed at v1'); + const read = readRecords(proj); + if (read.kind !== 'ok') throw new Error('fixture: records unreadable'); + await writeRecords(proj, { + skillsVersion: '1.1.0', + commit: null, + files: { + ...read.store.files, + [FROZEN_FILE]: sha256File(join(proj, FROZEN_FILE)), + }, + }); + // Upstream now parses (e.g. after a pharn upgrade). + write(join(repo, FROZEN_FILE), 'parsed upstream content'); + resolveCapabilities.mockReturnValue({ + selected: [ + { ...CAP, matched: ['ssr'] }, + { ...FROZEN, matched: ['ssr'] }, + ], + skipped: [], + }); + + await runUpdate(); + + expect(fetchRepo).toHaveBeenCalledTimes(1); + expect(body(FROZEN_FILE)).toBe('parsed upstream content'); + expect(readPharnConfig(proj)!.frozenCapabilities).toBeUndefined(); + }); + + it('still returns early at the same version when nothing is frozen', async () => { + await installed({ skillsVersion: '1.1.0' }); + + await runUpdate(); + + expect(fetchRepo).not.toHaveBeenCalled(); + expect(vi.mocked(prompts.outro)).toHaveBeenCalledWith( + expect.stringContaining('Already up to date'), + ); + }); }); // -------------------------------------------------------------------------