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'), + ); + }); }); // -------------------------------------------------------------------------