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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- **Releases could not publish: the `Pack` step wrote into a directory nothing had created.** Since the release workflow was split into an unprivileged `build` job and a `publish` job, `build` ran `npm pack --pack-destination "$RUNNER_TEMP/pkg"` without creating `pkg/`, and npm does not create it (`ENOENT` on npm 10 and 11). Every Release run would have failed at `Pack` and never reached `publish`. The step now runs `mkdir -p` first, and a live test pins that every workflow's pack destination is created earlier in the job that packs.
- **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.

## [0.5.0] - 2026-09-10

Expand Down
8 changes: 6 additions & 2 deletions docs/commands/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,12 @@ edits β€” are listed **first** and marked `(edited)`, and the prompt says how ma
continue, `init` copies each of them to `.pharn-backup/<timestamp>/` **before** the first write and prints
that directory as soon as it is created (byte-identical files are not edits and are not backed up).
Capabilities you added by hand with `pharn add` (`source: "manual"` in `pharn.config.json`) are **kept**:
`init` installs them again and records them as `manual`, as long as upstream still ships them. An
unreadable or invalid existing config never blocks `init` β€” nothing is carried over from it. The target set is derived from the fetched clone's layout + your resolved selection (`lib/install-manifest.ts`), so it is exact β€” not a git-history heuristic.
`init` installs them again and records them as `manual`, as long as upstream still ships them. Top-level
keys in `pharn.config.json` that pharn does not own, such as upstream's `testResults` and `ship`, are
copied across unchanged β€” see [Keys pharn does not own](../reference/pharn-config.md#keys-pharn-does-not-own).
An unreadable or invalid existing config never blocks `init`. Manual capabilities are carried over
only from a config the other commands would accept, and your own keys from any config that parses as
a JSON object; a config that is not valid JSON carries nothing over. The target set is derived from the fetched clone's layout + your resolved selection (`lib/install-manifest.ts`), so it is exact β€” not a git-history heuristic.

### 7. Install

Expand Down
57 changes: 52 additions & 5 deletions docs/reference/pharn-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ archetypes/capabilities and the pinned commit).

`isArchetypeConfig` treats the presence of a `capabilities` array as the marker of an archetype install.

Any top-level key **not** on this page is yours, and every command keeps it β€” see
[Keys pharn does not own](#keys-pharn-does-not-own).

`layout` is written only by `pharn init` and `pharn update`, each recording the layout of the clone it
actually copied from. `pharn add` never writes the field β€” it
[refuses](../commands/add.md#layout-mismatch) a clone whose layout disagrees with the recorded one,
Expand Down Expand Up @@ -55,10 +58,12 @@ A `source` present but outside `{auto, manual}` is a hand-edit error: `pharn` re
(`capabilities[2].source`) and exits, rather than falling back to "run `pharn init`". Deleting the
field is a valid fix β€” the next update sets it.

> Re-running `pharn init` on an existing project is an explicit **start-over**: it rewrites
> `capabilities` from scratch, so every entry becomes `auto` and previous `manual` tags are lost.
> `init` warns before overwriting `pharn.config.json` and defaults to **No**. Use `pharn update` to
> refresh an existing install; `init` is for installing one.
> Re-running `pharn init` on an existing project **rewrites this file** from its own fields. Two
> things survive: capabilities you added with `pharn add` are installed again and stay `manual` (as
> long as upstream still ships them), and [keys pharn does not own](#keys-pharn-does-not-own) are
> copied across. Everything else pharn owns is written fresh. `init` warns before overwriting
> `pharn.config.json` and defaults to **No**. Use `pharn update` to refresh an existing install;
> `init` is for installing one.

A sibling file, [`pharn.records.json`](pharn-records.md), holds a sha256 per installed file. It is
written by the same operations that write this config and is **stamped** with this file's
Expand Down Expand Up @@ -185,10 +190,52 @@ Five hand-edits are rejected by name: an unknown sibling key, an unknown step, a
`resolutionOrder` whose last entry is not `ask`, and a `modelConfidenceThreshold` set without a `model`
step in the order.

## Keys pharn does not own

Every field on this page is **pharn's**, including the [legacy fields](#legacy-fields-pre-archetype-configs-still-load)
it no longer writes. Any **other** top-level key is **yours**: `pharn` does not interpret or validate it,
and every command that writes this file keeps it. Upstream PHARN documents two that you add by hand:

| Key | Read by | What it sets |
| ------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
| `testResults` | `/pharn-test`, and `/pharn-verify`'s acceptance-criteria check | The JSON report format of each test gate |
| `ship` | `/pharn-ship` (`ship.requireAttestation`) | `true`: the ship stage asks for a named person's attestation |

```json
{
"testResults": { "test": "vitest-json", "test:e2e": "playwright-json" },
"ship": { "requireAttestation": false }
}
```

Without `testResults`, `/pharn-loop` stops with `blocked: no-test-runner`. The shape of both keys is
upstream's to define β€” see the pharn-oss README,
[Per-test results](https://github.com/pharn-dev/pharn-oss#per-test-results). Because `pharn` does not
own these keys it does not check them, so a typo in one is not reported by any `pharn` command.

How each command keeps them:

- `pharn add`, `pharn remove` and `pharn update` edit this file in place, so a key they do not write
stays where it is.
- `pharn init` writes the file afresh from its own fields, then **copies every key pharn does not own
across from the config it replaces**, unchanged, after its own. It does this for any file that
parses as a JSON object, including one the other commands refuse: a config missing its `modules`
array (the case where they tell you to run `pharn init`), or one with an invalid `models` or `seam`
block. A file that is **not valid JSON** carries nothing over. Move it aside first
([troubleshooting](../troubleshooting.md#the-config-is-not-valid-json)), then copy your keys back.

`init` **never** carries over a key pharn owns. It writes `pharnVersion`, `skillsVersion`, `repo`,
`commit`, `installedAt`, `archetypes`, `capabilities`, `layout` and `modules` fresh. `models` and
`seam` go back to their defaults, so a hand-edit there does not survive a re-run `init`.
`pendingSkillsVersion`, `frozenCapabilities` and the legacy fields below are dropped. (`capabilities`
is rewritten too, but the entries you added with `pharn add` are kept as `manual` β€” see the
[init command](../commands/init.md#6-summary).)

## Legacy fields (pre-archetype configs still load)

The schema is **additive** (P7): a `pharn.config.json` written by an older, module-based CLI still loads,
and its now-unused fields are preserved on read.
and its now-unused fields are preserved on read. They are still pharn's fields, though, so a re-run
`pharn init` drops them rather than [carrying them over](#keys-pharn-does-not-own).

Two fields are nonetheless **load-bearing**, and deleting either makes the file unreadable: a config
without a string `skillsVersion` or without a `modules` array is treated as absent, and every command
Expand Down
5 changes: 3 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,8 +419,9 @@ JSON parser supplies one, the **line and column** of the offending byte. Open th
position and fix it; nothing else is needed, and nothing has been written.

**Do not reach for `pharn init` here.** It rewrites `pharn.config.json` wholesale: hand-edited
`models` / `seam` blocks go back to defaults and every capability is re-stamped `source: "auto"`,
which discards the record of which capabilities you added by hand with `pharn add`. That record
`models` / `seam` blocks go back to defaults, keys of your own such as `testResults` are not carried
over, and every capability is re-stamped `source: "auto"`, which discards the record of which
capabilities you added by hand with `pharn add`. That record
lives nowhere else, and `pharn update` reads it to keep your manual additions across upgrades.

If the file is genuinely beyond repair, move it out of the way first so you can still read it, then
Expand Down
76 changes: 75 additions & 1 deletion src/lib/pharn-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,79 @@ export function configPath(cwd: string): string {
return resolve(cwd, CONFIG_FILENAME);
}

/**
* Every top-level key this CLI OWNS: exactly the keys `PharnConfig` declares β€”
* the ones it writes and validates, plus the module-era fields it no longer
* writes but that are still its own (`constitution`, `stackAnswers`, …).
*
* `satisfies Record<keyof PharnConfig, true>` makes the list exhaustive BY
* CONSTRUCTION: declaring a field on `PharnConfig` without listing it here (or
* listing one it does not declare) fails the typecheck. That is what keeps
* `userOwnedConfigEntries` honest β€” a new pharn-owned field can never be read
* as the user's and carried stale across a re-run init.
*
* A `Set` of the keys, never an `in` test against the literal: `in` walks the
* prototype chain, so a user key named `constructor` or `toString` would read
* as pharn's.
*/
const CLI_OWNED_KEYS: ReadonlySet<string> = new Set(
Object.keys({
pharnVersion: true,
skillsVersion: true,
pendingSkillsVersion: true,
frozenCapabilities: true,
repo: true,
commit: true,
constitution: true,
isMultiTenant: true,
modules: true,
installedAt: true,
models: true,
seam: true,
stackAnswers: true,
installedSkills: true,
archetypes: true,
capabilities: true,
layout: true,
} satisfies Record<keyof PharnConfig, true>),
);

/**
* The top-level entries of a parsed `pharn.config.json` that this CLI does NOT
* own (`CLI_OWNED_KEYS`): keys the user put there, most of them for upstream
* pharn-oss to read β€” `testResults` (the per-test results runners `/pharn-test`
* and `/pharn-verify` need) and `ship.requireAttestation`. Returned verbatim and
* never validated: pharn does not own their schema, so it has no business
* judging them. Pure. `pharn init` is the caller β€” it rebuilds the config from
* its own fields and carries these over (steps/install-archetype.ts).
*
* EVERY such key, not an allowlist of the two known today β€” deliberately:
* - It is the contract the other writers already keep. `readPharnConfig` passes
* unknown top-level keys through (P7, additive) and `add`/`update`/`remove`
* write that object back, so every other command already answers "does my key
* survive?" with yes. An allowlist would make `init` the one command whose
* answer depends on whether this CLI release has heard of the key.
* - Upstream outpaces CLI releases. A released CLI installs pharn-oss `main`
* HEAD, which added `testResults` in 6.15 and `ship` after it; an allowlist
* would re-arm this exact drop for the next key, in every deployed CLI, until
* a new release shipped.
* - The owned side is the closed, known set, so it is the side to enumerate:
* `CLI_OWNED_KEYS` is exhaustive by construction, while a list of foreign keys
* could only ever be complete by luck.
* The cost, accepted: a key nobody reads (a typo, another tool's leftover)
* survives a re-init too β€” exactly as it already survives `add`/`update`/`remove`.
*
* `Object.fromEntries` defines OWN data properties, so a `__proto__` key (an own
* key after `JSON.parse`) round-trips as a key and never becomes a prototype.
*/
export function userOwnedConfigEntries(
raw: Record<string, unknown>,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(raw).filter(([key]) => !CLI_OWNED_KEYS.has(key)),
);
}

/**
* Read + validate pharn.config.json.
*
Expand All @@ -220,7 +293,8 @@ export function configPath(cwd: string): string {
* On success the validated, typed `models`/`seam` (the validators' stripped
* return) replace the raw sub-blocks (BUG 3), while unknown TOP-LEVEL keys still
* pass through so a legacy config carrying a since-removed field still loads
* (P7, additive).
* (P7, additive) β€” and so a key the user owns (`userOwnedConfigEntries`) survives
* every command that writes this object back.
*/
export function readPharnConfig(cwd: string): PharnConfig | null {
const path = configPath(cwd);
Expand Down
45 changes: 43 additions & 2 deletions src/steps/install-archetype.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs';
import { log, outro, spinner } from '@clack/prompts';
import pc from 'picocolors';
import { FIRST_FEATURE_COMMAND, REPO_URL } from '../lib/constants.js';
Expand All @@ -10,8 +11,13 @@ import { buildRecords, writeRecords } from '../lib/install-records.js';
import { DEFAULT_MODEL_ROUTING } from '../lib/model-routing.js';
import { formatModelRoutingLines } from '../lib/model-routing-format.js';
import { DEFAULT_SEAM_CONFIG } from '../lib/seam-config.js';
import { writePharnConfig } from '../lib/pharn-config.js';
import {
configPath,
userOwnedConfigEntries,
writePharnConfig,
} from '../lib/pharn-config.js';
import { readSkillsVersion } from '../lib/skills-version.js';
import { isPlainObject } from '../lib/validate.js';
import { PHARN_VERSION } from '../version.js';
import type {
Archetype,
Expand Down Expand Up @@ -155,7 +161,16 @@ export async function runInstallArchetype(
collectExpectedInstallPaths({ repoDir, capabilities, layout }).keys(),
),
});
await writePharnConfig(cwd, config);
// The config this install replaces may hold keys pharn does not own β€”
// upstream's `testResults` / `ship`, which users add by hand β€” and the object
// above is built from pharn's own fields alone. Carry them over (why every
// such key: userOwnedConfigEntries). Read HERE, inside init's project lock and
// immediately before the write, for the reason the backup scan above is taken
// late: a key edited while a prompt was open must not be lost. Appended AFTER
// pharn's own keys: the two sets are disjoint by construction, and this keeps
// the user's keys where they most likely added them, so the committed file's
// diff is only what init actually changed.
await writePharnConfig(cwd, { ...config, ...readCarriedEntries(cwd) });

const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
const check = pc.green('βœ”');
Expand Down Expand Up @@ -198,3 +213,29 @@ export async function runInstallArchetype(
].join('\n'),
);
}

/**
* The user-owned top-level entries (`userOwnedConfigEntries`) of the
* pharn.config.json this install is about to replace β€” `{}` on ANY failure.
* Read TOLERANTLY, like init's carriedManualCapabilities: init is the command
* every other one points at for recovery, so an absent, unreadable or
* unparseable config means "nothing to carry over", never a refusal.
*
* Deliberately NOT readPharnConfig, whose verdict is about the keys pharn OWNS:
* it returns null for a config with no `modules` array β€” which every other
* command answers with "Run `pharn init` first" β€” and throws on a bad
* `models`/`seam` hand-edit. Neither says anything about the user's own keys,
* and reading through it would drop `testResults` on exactly the recovery path
* pharn prescribes. So the one requirement is a JSON object at top level β€” the
* same shape-only read steps/overwrite-check.ts makes for its one display
* scalar, and file-local for the same reason: a total-catch reader must not be
* importable from the module whose point is that it throws.
*/
function readCarriedEntries(cwd: string): Record<string, unknown> {
try {
const raw: unknown = JSON.parse(readFileSync(configPath(cwd), 'utf8'));
return isPlainObject(raw) ? userOwnedConfigEntries(raw) : {};
} catch {
return {};
}
}
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ export interface SeamConfig {
haltOnUnknown?: boolean;
}

// Every key declared here is pharn-OWNED: `pharn init` rewrites it from scratch
// (CLI_OWNED_KEYS in src/lib/pharn-config.ts must list it β€” the typecheck
// enforces that). Keys the user adds that are NOT declared β€” upstream pharn-oss's
// `testResults` and `ship` β€” are user-owned and survive every command, init
// included (userOwnedConfigEntries). So declaring an upstream key here is not a
// harmless typing convenience: it would make init start dropping it.
export interface PharnConfig {
pharnVersion: string;
skillsVersion: string;
Expand Down
Loading
Loading