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
39 changes: 39 additions & 0 deletions .dev/features/update-frozen-recheck/GRILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# GRILL — update-frozen-recheck

Plan: `.dev/features/update-frozen-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: minor
file: '.dev/features/update-frozen-recheck/PLAN.md:38'
problem: 'Say which list the re-check keys are drawn from: it must be the NEXT (merged) capabilities, `configCapabilities` = `merged.capabilities`, so an entry the merge drops (e.g. an auto entry no longer selected once it parses) cannot hold the same-version gate open with nothing behind it.'
evidence: 'next `frozenCapabilities` = config entries still unparseable ∪ config entries listed in the PREVIOUS `frozenCapabilities`'
- type: FINDING
rule_id: 'P4'
severity: minor
file: '.dev/features/update-frozen-recheck/PLAN.md:47'
problem: 'The field''s meaning broadens from "kept because it could not be parsed" to "kept, or parsed again but its files not yet brought up to date", and init now writes it too (init-manual-carry). The docs are in Files, but the field''s own comment in `src/types.ts:124-127` is not, and would go stale.'
evidence: '`docs/reference/pharn-config.md` — the `frozenCapabilities` paragraph, same rule'
- type: FINDING
rule_id: 'P1'
severity: minor
file: '.dev/features/update-frozen-recheck/PLAN.md:42'
problem: 'Add the negative case the rule implies: a formerly-frozen entry that the merge DROPS on the re-check run leaves the list, even if files under its directory were skipped.'
evidence: 'a skip under ANOTHER capability does not keep the key'
```

## Summary

The plan targets a reproduced defect with a minimal rule (a formerly-frozen key stays while any of its own
files is skipped), and its prefix test reuses the prefix `recordsUnderCapabilities` already uses. Skips
outside the capability's directory correctly do not keep the key: on a re-check run those files are
already at the recorded version, so the normal same-version behavior applies. The concerns are
precision, not direction: draw the keys from the merged list, keep the field's type comment true, and
pin the merge-drop case.

ADVISORY VERDICT: 3 concerns raised (0 blocking-severity, 3 advisory) — for the human to weigh before
/pharn-dev-build. Finding 2 needs `src/types.ts` (comment only) added to the plan's `## Files`.
84 changes: 84 additions & 0 deletions .dev/features/update-frozen-recheck/PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# PLAN — update-frozen-recheck (a frozen capability stays re-checked until its files actually land)

- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4
- increment: `update` keeps a capability's key in `frozenCapabilities` after it parses again for as
long as any of ITS files was skipped in the run, so the same-version gate stays open and a later
run re-checks those files; it also stops writing a `pendingSkillsVersion` equal to the
`skillsVersion` it withheld.
- layer(s): the CLI (`src/commands/update.ts`) + user docs
- constitution_refs: [P1, P4, P5, P6]

## Discovery — verified this run (P6)

- `update.ts:207-208`: the same-version early return is skipped only while
`config.frozenCapabilities` is non-empty (PHARN-13, aec6d3e).
- `update.ts:586-589`: the next `frozenCapabilities` is exactly the config entries that are
unparseable THIS run — a capability that parses again is dropped from the list unconditionally.
- `update.ts:506-510`: a skip withholds the bump — but on a frozen re-check run the bump already
happened on the run that froze it (that run left the capability's files out of the plan entirely),
so `nextSkillsVersion = config.skillsVersion` withholds nothing.
- Net effect, reproduced by review with vitest on fake clones: installed 1.0.0, user edits a file
under lens:x; upstream 2.0.0 makes x unparseable → `update` keeps x, records 2.0.0,
`frozenCapabilities: [lens:x]`; x parses again at 2.0.0 → `update` reports MODIFIED and CLEARS the
field → every later `update` prints "Already up to date" with zero fetches; after the user reverts
the edit as advised, the file stays at v1 for good (only `--force` recovers). Same with an absent
store after a `--force` freeze run (files skipped as UNRECORDED). Control (never frozen): the bump
is held and the next run re-reports the skip.
- `update.ts:516-519`: on that same-version re-check with only user-edit skips,
`pendingSkillsVersion = installedVersion` equals the withheld `skillsVersion` — a no-op field.
- `plan.skipped` is `{ label, rels[] }[]` with project-relative rels at the clone's layout
(`update-decision.ts:174`); a capability's rels share the prefix
`layoutPaths(layout).{grillers|lenses}/<name>/` — the same prefix `recordsUnderCapabilities`
(`install-records.ts:363-378`) already uses.
- Docs: `docs/commands/update.md:101-105` ("that run updates its files as usual and clears the
field") and `docs/reference/pharn-config.md:77-81` ("the field is removed once none are left").

## Files

- `src/commands/update.ts` — next `frozenCapabilities` = config entries still unparseable ∪ config
entries listed in the PREVIOUS `frozenCapabilities` that parse now but have ≥1 rel under their
capability dir in `plan.skipped` (sorted, de-duplicated); `pendingSkillsVersion` omitted when it
equals `nextSkillsVersion` — layer CLI/commands
- `tests/update.test.ts` — in the frozen block: a user-edited file under a formerly-frozen capability
→ re-check run skips it AND keeps the key (FAILS on base); the next run fetches again (FAILS on
base); after reverting the edit the file upgrades and the key clears; a skip under ANOTHER
capability does not keep the key; the same-version re-check writes no `pendingSkillsVersion`
(FAILS on base); existing frozen tests unchanged
- `docs/commands/update.md` — the frozen paragraph: the field clears once the capability parses AND
none of its files had to be skipped
- `docs/reference/pharn-config.md` — the `frozenCapabilities` paragraph, same rule
- `src/types.ts` — the `frozenCapabilities` field comment only (no type change): it also lists a
capability that parses again while one of its files is still skipped, and a re-run `init` writes it
too (init-manual-carry) — added at grill (finding 2), comment-only, intent unchanged
- `CHANGELOG.md` — `[Unreleased]` → `### Fixed`

## Contracts satisfied

- CLAUDE.md's `update` invariant "a run that skipped anything withholds the … bump so … the next run
still has work" — restored for the frozen path (cited, not restated, P4).

## Evals to write (P1)

- listed under Files.

## Guarantee audit (P0)

- "a formerly-frozen capability with a skipped file keeps the gate open" → floor: prefix membership
of skipped rels + set membership of the previous `frozenCapabilities` keys (P5).
- Unchanged residual (named, not closed here): a NEW upstream capability this CLI could not parse
never reaches `frozenCapabilities` (it is not in the config), so after upgrading pharn it installs
only on the next `SKILLS_VERSION` bump or via `--force` / `pharn add` — pre-existing, outside
PHARN-13's scope.

## Trust audit (P2)

- No new input. `frozenCapabilities` is already validated at ingest (a non-`role:name` list is
ignored, `docs/reference/pharn-config.md:81`); only keys that match a config entry are re-emitted.

## Determinism audit (P5)

- Set membership and string-prefix tests only.

## Open questions (HALT)

- none
36 changes: 36 additions & 0 deletions .dev/features/update-frozen-recheck/REGRESSION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# REGRESSION — update-frozen-recheck

The verdict below is computed by `.dev/floor/check-regress.mjs`, not by this stage's judgment.

## Base and partition

- **base:** `3581c867bc5ae6e809b20ef880e984487a84e240` (`HEAD` — `origin/main` after #216; the build is an
uncommitted working tree on top of it).
- **inside** (each declared in `PLAN.md` `## Files`): `src/commands/update.ts`, `src/types.ts`,
`tests/update.test.ts`, `docs/commands/update.md`, `docs/reference/pharn-config.md`, `CHANGELOG.md`.
- **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 46 stdlib `*.test.mjs` / `*.test.cjs` files `scope` returned (754 tests) +
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.
- **environment:** both sides ran with no proxy variables and as root **without** `CAP_DAC_OVERRIDE` /
`CAP_DAC_READ_SEARCH` / `CAP_FOWNER` (`setpriv`), the CI-equivalent of this root sandbox.

## 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.
57 changes: 57 additions & 0 deletions .dev/features/update-frozen-recheck/REVIEW.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# REVIEW — update-frozen-recheck

Increment: `src/commands/update.ts` (the next `frozenCapabilities` also keeps a key from the previous
list whose capability parses again but had a file skipped this run; no `pendingSkillsVersion` equal to
the withheld version), `src/types.ts` (the field comment only), `tests/update.test.ts` (three cases),
`docs/commands/update.md`, `docs/reference/pharn-config.md`, `CHANGELOG.md`. Treated as
`trust: untrusted`; nothing in it read as an instruction.

## Floor first (P0)

`node .dev/floor/validate.mjs .` → `FLOOR: GREEN` (exit 0). `/pharn-dev-build`'s `npm run check` exit 0
(1498 tests), `/pharn-dev-regress` `no-regressions`, `/pharn-dev-verify` `PASS`.

## Floor-gate findings (blocking)

None.

- L-floor (P0): "a formerly-frozen capability with a skipped file keeps the same-version gate open" →
set membership of the previous list's keys + a string-prefix test of the skipped rels, drawn from the
MERGED capabilities (grill finding 1). "No pending version equal to the recorded one" → a string
equality. Nothing is claimed beyond those two.
- L-eval (P1): the three-run case (skip → re-fetch → revert → upgrade and clear) fails on the base
`update.ts` (checked by stashing it); the two regression guards (a skip under another path; the
merge dropping the entry) pass on both, as intended.
- L-trust (P2): `frozenCapabilities` is validated at ingest (`isFrozenKeyList`); only keys matching a
merged config entry are re-emitted.
- L-axis (P3): no sibling reference; the change stays in the command that owns the config write.

## Advisory findings (warn — severity is this reviewer's judgment, fix #3)

```yaml
- type: FINDING
rule_id: 'P3'
severity: minor
file: 'src/commands/update.ts:601'
problem: 'This is the fifth copy of the role → subtree ternary (install-records, install-manifest twice, install-capabilities, and now update); a `capabilitySubtree(paths, role)` beside `layoutPaths` would single-source it. Kept inline here to match the existing pattern.'
evidence: 'const subtree = cap.role === ''griller'' ? paths.grillers : paths.lenses;'
- type: FINDING
rule_id: 'P7'
severity: minor
file: 'src/commands/update.ts:612'
problem: 'A user who deliberately keeps an edit in a formerly-frozen capability''s file now sees every `update` fetch again and never "Already up to date". That matches the non-frozen path (a user edit withholds the bump, so every run re-fetches) and is documented in update.md; noted, not a defect.'
evidence: '(previouslyFrozen.has(key) && hasSkippedFile(cap))'
```

## Proposed lesson for canon (NOT written — for a human-gated `/pharn-dev-memory-promote`)

- **Candidate:** "Withholding a version bump only preserves pending work if the bump has not already
happened. When an earlier run advanced the version and deferred some files, that deferral needs its
own carrier into the next run (here `frozenCapabilities`), and the carrier may only be cleared once
the deferred files actually land."
- **Provenance:** increment `update-frozen-recheck`; the defect entered with PHARN-13 (aec6d3e, #207);
found by the 18-commit review on 2026-09-24; fixed by this diff (`src/commands/update.ts:596-617`).

## Verdict

**GREEN — 0 floor-gate findings, 2 advisory (minor).** The standing decision is the human's (GATE 2).
19 changes: 19 additions & 0 deletions .dev/features/update-frozen-recheck/SHIP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# SHIP — update-frozen-recheck

Stages run, in order: `/pharn-dev-plan` → GATE 1 (human: all four plans of this batch accepted — "deliver
all of them one by one using pharn-dev-ship") → `/pharn-dev-grill` → plan `## Files` amended with
`src/types.ts` (the field comment only, grill finding 2; intent unchanged) → `/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: after each increment, open a
pull request and merge it once its checks are green, then start the next plan.

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.
31 changes: 31 additions & 0 deletions .dev/features/update-frozen-recheck/VERIFY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# VERIFY — update-frozen-recheck

## FLOOR layer (owns the verdict)

Gates run over the whole repo with the feature present, on node 22 with the session proxy variables unset
and as root **without** `CAP_DAC_OVERRIDE` / `CAP_DAC_READ_SEARCH` / `CAP_FOWNER` (`setpriv`) — the
CI-equivalent of this root sandbox.

| gate | exit |
| -------------- | ---- |
| `format:check` | 0 |
| `lint` | 0 |
| `lint:md` | 0 |
| `test` | 0 |
| `test:floor` | 0 |
| `typecheck` | 0 |
| `validate` | 0 |

`test` is vitest (1498 tests), which collects this increment's own tests (`tests/update.test.ts`, the
"a formerly-frozen capability that parses again" block). `test:floor` is floor.yml's `node --test` run
(754 tests). 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":[]}` — no verifiers registered, floor
gates only.

Residual (P0/P7): verified = the named gates passed; this is NOT a guarantee of correctness beyond what those
gates check — verifier concerns are advisory help, not assurance.
24 changes: 24 additions & 0 deletions .dev/features/update-frozen-recheck/regression-report.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"base": "3581c867bc5ae6e809b20ef880e984487a84e240",
"inside": [
"CHANGELOG.md",
"docs/commands/update.md",
"docs/reference/pharn-config.md",
"src/commands/update.ts",
"src/types.ts",
"tests/update.test.ts"
],
"outside_gates": {
"tests": {
"base": 0,
"head": 0
},
"validate": {
"base": 0,
"head": 0
}
},
"regressions": [],
"pre_existing": [],
"verdict": "no-regressions"
}
18 changes: 18 additions & 0 deletions .dev/features/update-frozen-recheck/verify-report.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"feature": "update-frozen-recheck",
"gates": {
"format:check": 0,
"lint": 0,
"lint:md": 0,
"test": 0,
"test:floor": 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/init-manual-carry/SHIP.md"
".dev/features/update-frozen-recheck/SHIP.md"
],
"set_by": ".claude/commands/pharn-dev-ship.md",
"set_at": "2026-09-24T19:16:48.445Z"
"set_at": "2026-09-24T19:24:04.227Z"
}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Carried entries show as "added by hand" in the install summary, not also under SKIPPED.
- **`pharn init` no longer overwrites a config another `pharn` command wrote while its prompts were open.** It now re-checks `pharn.config.json` under its lock, like `add`, `update` and `remove`. If the file changed after `init` read it, `init` refuses and writes nothing.
- **A re-run `pharn init` dropped the `pharn.config.json` keys you added by hand.** Upstream PHARN reads top-level keys that users add themselves: `testResults` (without it `/pharn-loop` stops with `blocked: no-test-runner`) and `ship.requireAttestation`. `add`, `update` and `remove` kept them, but `init` rebuilt the config from its own fields alone. It now copies every top-level key pharn does not own across from the config it replaces, unchanged. It does so from any file that parses as a JSON object, including one the other commands refuse. Keys pharn owns are still written fresh.
- **`pharn update` could stop re-checking a capability whose files were still out of date.** When pharn cannot read a capability upstream, `update` keeps it, skips its files, and moves the skills version on. Once pharn could read it again, the next run removed it from `frozenCapabilities` even if one of its files had to be skipped, for example because you edited it. Every later run then said "Already up to date", and that file stayed at the old version even after you resolved your edit. The capability now stays listed until none of its files are skipped, so each run checks it again. `update` also no longer writes a `pendingSkillsVersion` equal to the recorded `skillsVersion`.

## [0.5.0] - 2026-09-10

Expand Down
8 changes: 5 additions & 3 deletions docs/commands/update.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ upstream this run`. A capability you have is not dropped because one fetch could
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).
finishes the change, or you upgrade pharn with `npm install -g @pharn-dev/pharn@latest`), `update`
brings its files up to date as usual and clears the field — but only once none of that capability's
files had to be skipped. A file of it you edited is skipped as usual, and the capability stays listed,
so every later run checks it again until you resolve the edit or pass `--force`. 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: 4 additions & 2 deletions docs/reference/pharn-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,10 @@ value that is not a `x.y.z` version is ignored.

`frozenCapabilities` lists (`role:name`, sorted) the installed capabilities the last `pharn update`
(or re-run `pharn init`) 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
at the same skills version, so they are re-checked on every run. A capability that can be read again
stays listed while any of its files had to be skipped (one you edited, say), so the next run checks it
again; 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
Expand Down
Loading
Loading