Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .dev/features/frozen-capability-recheck/GRILL.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions .dev/features/frozen-capability-recheck/PLAN.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions .dev/features/frozen-capability-recheck/REGRESSION.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions .dev/features/frozen-capability-recheck/REVIEW.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions .dev/features/frozen-capability-recheck/SHIP.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions .dev/features/frozen-capability-recheck/VERIFY.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions .dev/features/frozen-capability-recheck/regression-report.json
Original file line number Diff line number Diff line change
@@ -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"
}
17 changes: 17 additions & 0 deletions .dev/features/frozen-capability-recheck/verify-report.json
Original file line number Diff line number Diff line change
@@ -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": []
}
}
4 changes: 2 additions & 2 deletions .pharn/writes-scope.json
Original file line number Diff line number Diff line change
@@ -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"
}
8 changes: 5 additions & 3 deletions docs/commands/update.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/pharn-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(),
Expand Down
27 changes: 27 additions & 0 deletions src/lib/pharn-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading