diff --git a/.dev/features/installer-layout-mirror/GRILL.md b/.dev/features/installer-layout-mirror/GRILL.md new file mode 100644 index 0000000..f2f2fe1 --- /dev/null +++ b/.dev/features/installer-layout-mirror/GRILL.md @@ -0,0 +1,57 @@ +# GRILL — installer-layout-mirror + +Plan under interrogation: `.dev/features/installer-layout-mirror/PLAN.md` (trust: untrusted to this griller). +Spec-hash check: `sha256(ARCHITECTURE.md)` = `bca940a5…d729d3c4e` — **matches** the plan's `spec_content_hash` (no drift). +Griller discovery (`count-grillers.mjs .`): 13 registered, all under `test-app/` (installed fixture, not dev-loop grillers) → none applicable; testability applied inline (the plan declares evals, presence recognized). + +## Findings — advisory (grill gates nothing) + +### P5 — the detection marker is under-specified (important) +```yaml +- type: FINDING + rule_id: "P5" + severity: important + file: ".dev/features/installer-layout-mirror/PLAN.md:10" + problem: "detectLayout keys on 'a pharn/ marker dir exists' without naming the exact marker — a bare pharn/ dir could exist for unrelated reasons, so build must pin a SPECIFIC, unambiguous marker (e.g. pharn/pharn-contracts or pharn/CONSTITUTION.md) to avoid a false pharn detection." + evidence: "detectLayout(rootDir): 'pharn' | 'flat' = a deterministic membership test (a `pharn/` marker dir exists in `rootDir` → `pharn`; else → `flat`…)" +``` +The determinism is sound; the *robustness* of the marker is the open detail. Recommend: detect on a leaf the flat layout provably lacks — `pharn/pharn-contracts` (or require ≥2 of the pharn subtrees). A bare-`pharn/`-exists test is the weakest form. + +### P1 — the cross-layout degradation is claimed but not in the eval list (minor) +```yaml +- type: FINDING + rule_id: "P1" + severity: minor + file: ".dev/features/installer-layout-mirror/PLAN.md:14" + problem: "The plan asserts diff degrades gracefully when the project layout differs from the @main clone layout ('source missing at @main → skip'), but the Evals section lists no case exercising that cross-layout path — a claimed behavior with no test." + evidence: "Cross-layout clone (project layout ≠ @main layout) degrades via the existing 'source missing at @main → skip' path — a named, pre-existing bound." +``` +Recommend either adding one eval (pharn-layout config vs a flat clone → expected: all skipped, no crash) or explicitly labeling it an untested, inherited bound in VERIFY. It leans on pre-existing behavior, so it is low-risk, but the claim should be pinned. + +### P7/P6 — the pharn/ path set encodes an UNMERGED, movable layout (important, standing risk) +```yaml +- type: FINDING + rule_id: "P7" + severity: important + file: ".dev/features/installer-layout-mirror/PLAN.md:59" + problem: "The pharn/ constants hard-code PR #86's current subtree paths, but #86 is unmerged and can still change before it lands on main; if a subtree is renamed the pharn/ branch silently mis-mirrors until a follow-up." + evidence: "mirror PR #86's current subtree paths (accepted caveat: if #86 renames a subtree before merge, the `pharn/` path set needs a small follow-up; the flat branch is unaffected)." +``` +The human explicitly accepted this at GATE 1, and the flat branch (all live pins) is unaffected — so it is bounded. Recommend a concrete guard: a re-check step (or a note in SHIP) to re-confirm the pharn/ paths against #86 at merge time, so the follow-up is not forgotten. + +### P7 — increment size is above the "smallest coherent" bar (minor) +```yaml +- type: FINDING + rule_id: "P7" + severity: minor + file: ".dev/features/installer-layout-mirror/PLAN.md:4" + problem: "All-at-once bundles the write side and the read side (10 src + 7 test files) in one increment, which is larger than P7's 'smallest coherent increment' default." + evidence: "Scope: all-at-once (write + read side) per GATE-1 decision" +``` +This is a deliberate, human-approved trade (avoiding a broken status/remove window before #86 merges), and it remains one coherent axis ("layout is resolved, not hardcoded"). Surfaced only so the size is an explicit, eyes-open choice — not a blocker. + +## Summary +The plan is sound in its guarantee/trust/determinism audits and preserves P7 for legacy pins by construction (the flat branch is the current behavior). The concerns are: pin a **specific** detection marker (P5, the one I'd act on before build); add or explicitly-bound the cross-layout diff test (P1); and keep the unmerged-#86 dependency visible so the follow-up isn't lost (P7). Nothing here is a correctness defect in the described design; they are hardening + honesty refinements. + +## Verdict +ADVISORY VERDICT: 4 concerns raised (0 blocking-severity, 2 important, 2 minor) — for the human to weigh before `/pharn-dev-build`. Advisory end-to-end; gates nothing. Only the writes-scope hook and the spec-hash computation were floor-grade this run. diff --git a/.dev/features/installer-layout-mirror/PLAN.md b/.dev/features/installer-layout-mirror/PLAN.md new file mode 100644 index 0000000..8165174 --- /dev/null +++ b/.dev/features/installer-layout-mirror/PLAN.md @@ -0,0 +1,59 @@ +# PLAN — installer layout-aware (mirror pharn/ OR flat), all surfaces + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 (ARCHITECTURE.md) +- increment: Make the CLI **mirror whichever layout the fetched pharn-oss clone has** — the new `pharn/` single-install layout (pharn-oss PR #86 / `pharn-runtime-layout`) OR the current flat layout — across ALL archetype surfaces (install, status/drift, remove), via one deterministic layout resolver, recording the installed layout in `pharn.config.json`. Scope: all-at-once (write + read side) per GATE-1 decision; clone ref stays `main`. +- layer(s): pharn-cli product source — `src/lib/` (resolver, constants, types, capability-index, install-capabilities, diff, pharn-config) + `src/steps/` (install-archetype) + `src/commands/` (status, remove). ARCHITECTURE.md §4. +- constitution_refs: [P7, P5, P2, P1, P3] + +## Files + +- `src/lib/layout.ts` — layer lib. NEW. `detectLayout(rootDir): 'pharn' | 'flat'` = a deterministic membership test (a `pharn/` marker dir exists in `rootDir` → `pharn`; else → `flat`, the safe legacy default). `layoutPaths(layout): LayoutPaths` = the pure path set (`grillers`, `lenses`, `contracts`, `floor`, `docs[]`) used as BOTH source-relative-to-clone and dest-relative-to-project (the mirror). One axis: resolve the install layout. +- `src/lib/constants.ts` — layer lib. Add the `pharn/` path set (`PHARN_GRILLERS_DIR` = `pharn/pharn-pipeline/grillers`, `PHARN_LENSES_DIR` = `pharn/pharn-review`, `PHARN_CONTRACTS_DIR` = `pharn/pharn-contracts`, `PHARN_FLOOR_DIR` = `pharn/floor`, `PHARN_TRUSTED_DOCS` = [`pharn/CONSTITUTION.md`, `pharn/ARCHITECTURE.md`]) beside the existing flat constants (flat values unchanged — the legacy branch). `.claude/*` constants unchanged (identical both layouts). +- `src/lib/capability-index.ts` — layer lib. Build `SUBTREES` from `layoutPaths(detectLayout(repoDir))` so a `pharn/` clone enumerates capabilities. All untrusted-frontmatter validation preserved (P2). +- `src/lib/install-capabilities.ts` — layer lib. Drive every copy from `layoutPaths(detectLayout(repoDir))`: grillers/lenses/contracts/floor/docs mirrored clone→project at identical relative paths; in `pharn` the docs set is `pharn/CONSTITUTION.md` + `pharn/ARCHITECTURE.md` only (THREAT-MODEL/LIMITS are not under `pharn/` → dropped, matching PR #86). Preserve ALL hardening: symlink-reject, `safeJoin`, test-file exclusion, settings-preserve. Return the detected layout to the caller. +- `src/lib/diff.ts` — layer lib. `diffInstalledCapabilities` takes the project's `layout` and derives the expected set via `layoutPaths(layout)` (capability subtree + contracts + floor + docs), for both clone-source and project-dest (same relative path). The module-path `diffInstalled` is untouched (modules install into `.claude/`, layout-invariant). Cross-layout clone (project layout ≠ @main layout) degrades via the existing "source missing at @main → skip" path — a named, pre-existing bound. +- `src/lib/pharn-config.ts` — layer lib. Read/write/validate the additive optional `layout?: 'pharn' | 'flat'` field (schema stays additive — legacy configs omit it and still load; a missing value means `flat`, P7). +- `src/types.ts` — layer lib. Add `layout?: 'pharn' | 'flat'` to the `PharnConfig` interface. +- `src/steps/install-archetype.ts` — layer steps. Record the detected layout (returned by `installCapabilities`) into `pharn.config.json`. +- `src/commands/status.ts` — layer command. Pass `config.layout ?? 'flat'` to `diffInstalledCapabilities`. +- `src/commands/remove.ts` — layer command. `removeCapability` derives the capability subtree from `layoutPaths(config.layout ?? 'flat')` (the only layout-sensitive removal path; `removeModule` is layout-invariant). +- `tests/layout.test.ts` — layer test. NEW. Detection (pharn marker → pharn; none → flat) + `layoutPaths` sets for both. +- `tests/install-capabilities.test.ts` — layer test. Add a `pharn/`-nested fixture: assert mirror under `pharn/…`, THREAT-MODEL/LIMITS dropped, `.claude/` unchanged; the existing flat fixture still installs flat (P7 guard). +- `tests/capability-index.test.ts` — layer test. A `pharn/` fixture enumerates capabilities identically to flat. +- `tests/diff.test.ts` — layer test. `diffInstalledCapabilities` with `layout: 'pharn'` compares the `pharn/` paths. +- `tests/status.test.ts` — layer test. An archetype config with `layout: 'pharn'` drives the pharn-path drift check. +- `tests/remove.test.ts` — layer test. Removing a capability from a `pharn`-layout config deletes the `pharn/…` dir. +- `tests/pharn-config.test.ts` — layer test. `layout` round-trips; a legacy config WITHOUT it still loads (P7). + +## Contracts satisfied + +- None new — CLI product code, not a `pharn-contracts` schema. It **upholds** the ownership boundary (P3, cited): pharn-oss owns the layout; the CLI mirrors it structurally, never rewriting copied file contents. + +## Evals to write (P1) + +- Resolver → pharn-marker fixture → pharn set + `isPharn`; no marker → flat set (deterministic else). +- install-capabilities → pharn fixture → mirrored under `pharn/`, THREAT-MODEL/LIMITS absent, `.claude/` intact; flat fixture → unchanged (P7 guard). +- capability-index → pharn fixture → capabilities enumerated identically. +- diff/status → `layout:'pharn'` → expected set on the `pharn/` paths. +- remove → `layout:'pharn'` capability → deletes `pharn/pharn-review|pharn-pipeline/grillers/`. +- pharn-config → `layout` round-trips; legacy config lacking it loads (P7). + +## Guarantee audit (P0) + +- "installs/status/remove mirror the clone-or-project layout" → **floor**: deterministic detection (membership on a `pharn/` marker) + `layoutPaths` pure map + vitest over both layouts across install/status/remove. Not advisory. +- "old flat pinned SHAs keep working (P7)" → **floor**: the resolver's else-branch IS the current flat behavior; a flat fixture installs/diffs/removes byte-identically (regression tests), and a legacy config (no `layout`) loads and resolves to flat. +- "nothing escapes `safeJoin`; symlinks rejected" → **floor**: existing `safeJoin`/`isSymlink` guards preserved on every copy and delete; re-asserted by the pharn-fixture tests. +- "`layout` config field is additive" → **floor**: a test loads a legacy config without it (P7). +- No new safety/trust guarantee over copied CONTENT is introduced — contents stay mirrored verbatim, never executed. + +## Trust audit (P2) + +- Input: the fetched pharn-oss clone (untrusted). Layout detection reads only **path existence** (a membership test), never file content — no untrusted bytes drive the branch. Every copy/delete stays `safeJoin`- + symlink-guarded; capability names stay validated against `CAPABILITY_NAME_RE` before any path-join. The project's `layout` field is read from the CLI-owned `pharn.config.json` (validated to the `{pharn,flat}` enum, defaulting flat), not from untrusted remote content. No new taint surface; no guaranteed decision rests on a tainted field. + +## Determinism audit (P5) + +- Every layout branch is a membership test: a `pharn/` marker exists → `pharn`; else → `flat` (safe legacy default, deterministic else). The config `layout` is enum-validated `{pharn, flat}`, missing → flat. No LLM, no classifier, no guess. + +## Open questions (HALT) + +- None — resolved at GATE 1: all-at-once (read-side included), clone ref stays `main`, mirror PR #86's current subtree paths (accepted caveat: if #86 renames a subtree before merge, the `pharn/` path set needs a small follow-up; the flat branch is unaffected). diff --git a/.dev/features/installer-layout-mirror/REGRESSION.md b/.dev/features/installer-layout-mirror/REGRESSION.md new file mode 100644 index 0000000..8978ab1 --- /dev/null +++ b/.dev/features/installer-layout-mirror/REGRESSION.md @@ -0,0 +1,28 @@ +# REGRESSION — installer-layout-mirror + +**Verdict (FLOOR, `check-regress.mjs verdict`):** `no-regressions` — exit `0`. Pure exit-code comparison, zero LLM judgment. + +## Base + scope partition + +- **base:** `b739a6f` — current HEAD (working tree dirty with the feature's uncommitted changes, so `base = HEAD` and the diff is against the working tree). +- **inside (declared `## Files`, 17):** the 10 src files + 7 test files of the increment. `scope` exit `0`, `escaped: []` — the build stayed within its declared writes (fix #7). `.pharn/` scratch and the feature-artifact dir are pipeline bookkeeping, correctly excluded from `--changed`. +- **outside gate set:** `tests` (44 stdlib `node --test` floor + hook files) and `validate`. Style gates skipped — `inside` touches no shared style config (`eslint.config.mjs`, `.prettierrc.json`, `.prettierignore`, `.markdownlint-cli2.jsonc`). No outside eval pairs. + +## Per-gate base → head (exit codes) + +| gate | base | head | classification | +| ---------- | ---- | ---- | -------------- | +| `tests` | 1 | 1 | **pre_existing** (RED→RED, not a flip) | +| `validate` | 0 | 0 | clean (GREEN→GREEN) | + +`regressions[]`: **none**. `pre_existing[]`: `tests`. + +## Why the `tests` gate is RED at baseline (not this feature) + +Identical to the previously-shipped `remove-dead-docs-url` increment: the `tests` aggregate is red **independently of this increment** — red with the feature's edits and red with them reverted (measured same-environment). The failing file is `.dev/floor/lens-scanner-map.test.mjs`, which is explicitly **non-hermetic** — it runs `count-lenses.mjs` over the repo root, which recursively includes the gitignored `test-app/` install fixture (22 lenses there vs the committed empty `lens-scanner-map.json`). This increment touches only `src/*.ts` and vitest `tests/*.test.ts` — **no** `.mjs`/`.cjs` node test and **no** PHARN markdown capability, so it provably cannot move the `tests` or `validate` exit codes. + +- **Sound, same-environment measurement.** Base and head were both measured in the working tree (so gitignored `test-app/` is present on both sides), differing only by the feature's tracked edits — reverted in place via a guarded `git stash`, then restored (verified). The `git worktree` baseline the command sketches is confounded here (the worktree omits gitignored `test-app/`, manufacturing a phantom `0→1` flip); the same-environment measurement is the apples-to-apples comparison the regress guarantee requires, and it yields `tests` `1 → 1` (pre-existing). + +## Honest residual (P0/P7) + +`/pharn-dev-regress` catches exactly what its deterministic suite catches — nothing more. It certifies the base→head comparison over `{tests, validate}`, **not** the feature as a whole. The feature's real correctness surface (the vitest suite, incl. the new layout/install/diff/status/remove/config tests) is owned by `/pharn-dev-verify`'s `test` gate, not this stage. The pre-existing `lens-scanner-map` drift is out of this increment's scope. diff --git a/.dev/features/installer-layout-mirror/REVIEW.md b/.dev/features/installer-layout-mirror/REVIEW.md new file mode 100644 index 0000000..72c0247 --- /dev/null +++ b/.dev/features/installer-layout-mirror/REVIEW.md @@ -0,0 +1,42 @@ +# REVIEW — installer-layout-mirror + +**Increment (trust: untrusted to this review):** make the CLI mirror the fetched clone's layout (flat OR the new `pharn/`) across install/status/remove, via one resolver, recording `layout` in config. Diff: 9 files modified (+106/−44) + `src/lib/layout.ts` (83 lines). + +**Step 1 — Floor first (P0):** `node .dev/floor/validate.mjs .` → exit `0`, **GREEN**. The increment legitimately reached review. The floor is the only guaranteed part; everything below is advisory. + +## Four lenses + +### L-floor → P0 — no finding +Every guarantee reduces to a floor primitive or is labeled advisory. Detection → membership test on the `pharn/pharn-contracts` marker + vitest over both layouts. P7 (old flat pins) → the resolver's `else` **is** the current flat behavior, backed by a flat-clone regression test and a legacy-config-loads test. `safeJoin`/symlink guards preserved on every copy and delete and re-asserted. The one honestly-advisory item — the `pharn/` path set tracks an **unmerged** PR #86 — is labeled advisory in GRILL.md/VERIFY.md, not sold as a guarantee. No unlabeled guarantee. Clean. + +### L-eval → P1 — no finding +No new Capability or `rule_id` (this is CLI product code), so there is no eval binding to miss; the floor agrees (`validate` GREEN). Every new behavior ships a vitest test in the same increment: `detectLayout`/`layoutPaths`/`configLayout` (incl. the no-false-positive + empty-dir cases), `installCapabilities` for pharn (mirror + THREAT-MODEL/LIMITS drop + `.claude/` kept) and the flat P7 guard, `capability-index` pharn enumeration, `diff` pharn + cross-layout degradation, `status` layout pass-through, `remove` capability at both layouts, `pharn-config` round-trip/legacy/garbage. 616/616 green. Clean. + +### L-trust → P2 — no finding +The fetched clone is untrusted, and `detectLayout` reads only **path existence** against a **fixed constant** (`pharn/pharn-contracts`) — no untrusted byte drives the branch, no injection sink. Every copy/delete stays `safeJoin`- + symlink-guarded; `capability-index`'s strict frontmatter validation is untouched; the project `layout` is read from the CLI-owned config and enum-coerced (`configLayout` / `pharn-config` drop-garbage). No guaranteed decision rests on a tainted/free-text field. No instruction-looking content in the diff changed this reviewer's behavior. Clean. + +### L-axis → P3 — no finding +Each file changes for the single reason "resolve the install layout rather than hard-code it." Layout logic is centralized in the new `lib/layout.ts` and reached from `lib/`, `steps/`, and `commands/` — no `command→command` or `step→step` sibling coupling is introduced (the `command→step` imports present are the sanctioned direction). `install-capabilities` delegates detection to `layout.ts` rather than growing a second axis. Clean. + +## Gates (fix #3) + +- **floor-gate (blocking):** none. +- **advisory-gate (warn):** one, below. + +```yaml +- type: FINDING + rule_id: "P6" + severity: minor + file: "src/commands/add.ts:351" + problem: "`pharn add` calls installCapabilityDirs with the default layout, which detects from the CLONE (@main), not the project's recorded config.layout — so during the flat→pharn transition `add` could install a capability at the clone's layout into a project of the other layout." + evidence: "installCapabilityDirs(repoDir, cwd, [{ name: cap.name, role: cap.role }]);" +``` +**Advisory, out of this increment's scope:** `add.ts` was not in the plan's `## Files`, and the mismatch is only reachable in the transition window (default clone is `main`, flat today; and `add` already was not layout-aware before this change). Recommended **follow-up** (increment 3): thread `configLayout(config)` into `add`'s `installCapabilityDirs` call, mirroring what `remove`/`status` now do — the optional `paths` param added this increment already makes that a one-line change. Surfaced for the human; it blocks nothing. + +## Verdict + +**GREEN — 0 floor findings, 0 blocking.** The increment satisfies its plan (17 files), the floor is GREEN, verify PASSed, and regress found no feature-attributable regression. The one advisory finding is an out-of-scope follow-up. Merge / fix / abandon is the human's call (GATE 2). + +## Lessons + +- **No new canon lesson.** The regress `test-app`/worktree confound this run re-encountered is already recorded as a proposed candidate in `.dev/features/remove-dead-docs-url/REVIEW.md` (this run is a second occurrence, strengthening that provenance) — not re-proposed here to avoid duplication. The `add` cross-layout gap above is a specific follow-up, not a recurring-failure pattern warranting canon. diff --git a/.dev/features/installer-layout-mirror/SHIP.md b/.dev/features/installer-layout-mirror/SHIP.md new file mode 100644 index 0000000..3160c82 --- /dev/null +++ b/.dev/features/installer-layout-mirror/SHIP.md @@ -0,0 +1,43 @@ +# SHIP — installer-layout-mirror + +Thin, **advisory** roll-up of a gated `/pharn-dev-ship` run. It records that the chain ran and its floor verdicts — it is **not** an approval, a "shipped" mark, or a `PHARN ✓ reviewed` seal. + +## Increment + +Make the pharn-cli installer **layout-aware**: mirror whichever layout the fetched pharn-oss clone has — the new `pharn/` single-install layout (PR #86 / `pharn-runtime-layout`) OR the legacy flat layout — across all archetype surfaces (install, status/drift, remove), recording the installed layout in `pharn.config.json`. This is the layout half of the original `/pharn-dev-ship` request; the DOCS_URL half already shipped as `remove-dead-docs-url`. + +**GATE-1 decisions (human):** all-at-once (write + read side together); clone ref stays `main`; mirror PR #86's current subtree paths (accepted caveat: a follow-up tweak if #86 renames a subtree before merge). + +**Design (P7-safe by construction):** one deterministic resolver (`lib/layout.ts`) — `detectLayout` (membership on the `pharn/pharn-contracts` marker), `layoutPaths`, `configLayout`. The `else` branch **is** today's flat behavior, so old pinned SHAs keep installing flat. + +## Stages that ran, in order, and where the run ended + +| stage | what happened | +| ----- | ------------- | +| `/pharn-dev-plan` | PLAN.md (revised to all-at-once); **GATE 1** — human **approved**. | +| `/pharn-dev-grill` | GRILL.md: 4 concerns (0 blocking, 2 important, 2 minor). Acted on both important ones in build: specific `pharn/pharn-contracts` marker (not bare `pharn/`), cross-layout diff test. Advisory, gated nothing. | +| `/pharn-dev-build` | 17 files (10 src + 7 test). Floor GREEN. | +| `/pharn-dev-regress` | no feature-attributable regression. | +| `/pharn-dev-verify` | all floor gates PASS; 0 verifiers. | +| `/pharn-dev-review` | REVIEW.md: GREEN, 0 floor findings, 0 blocking (1 minor advisory follow-up). | + +**Run ended at GATE 2** (post-review human decision) — not at a RED-verdict STOP. + +## Structural verdicts read, verbatim (the floor — the only guaranteed parts) + +- **`/pharn-dev-build`** → `node .dev/floor/validate.mjs .` exit code = **`0`** (GREEN). Repo floor `npm run check` also exit 0: format:check + lint + typecheck + **616/616** vitest. +- **`/pharn-dev-regress`** → `regression-report.json` `.verdict` = **`"no-regressions"`** (exit 0). The one RED gate (`tests`) is `1→1` **pre_existing** — the same non-hermetic `test-app`/`lens-scanner-map` node-runner drift documented for `remove-dead-docs-url`, not attributable to this increment (sound same-environment measurement in REGRESSION.md). +- **`/pharn-dev-verify`** → `verify-report.json` `.verdict` = **`"PASS"`** (exit 0); `failing_gates: []` over `{test, validate, lint, format:check, lint:md}`. + +## Advisory artifacts (cited, not restated — P4) + +- `.dev/features/installer-layout-mirror/REVIEW.md` — 4-lens review (GREEN); one minor advisory: `add` installs at the clone's layout, not the project's recorded layout — a documented **increment-3 follow-up** (thread `configLayout(config)` into `add`'s `installCapabilityDirs` call; the optional `paths` param added this run makes it one line). +- `.dev/features/installer-layout-mirror/GRILL.md` — advisory pre-build interrogation (4 concerns). + +## Standing dependency (honest, P7) + +The `pharn/` branch mirrors PR #86, which is **open, not merged to `main`**. Today a default `pharn init` (clone = `main`, flat) still installs flat; the `pharn/` path activates automatically once #86 merges. If #86 renames a subtree before merge, the `pharn/` constants need a one-line follow-up (the flat branch is unaffected). Re-confirm the `pharn/` paths against #86 at its merge. + +## Honest close (P0) + +The 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: **merge / fix / abandon.** `/pharn-dev-ship` does not merge, push, commit, or seal. The change is currently **uncommitted** in the working tree. diff --git a/.dev/features/installer-layout-mirror/VERIFY.md b/.dev/features/installer-layout-mirror/VERIFY.md new file mode 100644 index 0000000..764ad9f --- /dev/null +++ b/.dev/features/installer-layout-mirror/VERIFY.md @@ -0,0 +1,30 @@ +# VERIFY — installer-layout-mirror + +**Verdict (FLOOR, `check-verify.mjs`):** `VERIFIED: floor gates PASS` — exit `0`, `failing_gates: []`. + +## FLOOR gates (whole-repo, at HEAD with the feature present) + +| gate | exit | meaning | +| -------------- | ---- | ------- | +| `test` | 0 | vitest suite green (**616/616**, incl. the new layout/install/capability-index/diff/status/remove/config tests for both layouts) | +| `validate` | 0 | structural floor GREEN (no PHARN markdown capability added; vacuously green) | +| `lint` | 0 | eslint clean — no unused constants left after the resolver split | +| `format:check` | 0 | prettier clean, whole-repo | +| `lint:md` | 0 | markdownlint clean, whole-repo | + +No `structural:*` gate — this increment ships no committed eval pair (it is CLI product code, not a Capability). The feature's correctness signal is its own vitest coverage collected by `test`. + +## What the new tests pin (P1) + +- `detectLayout` keys on the specific `pharn/pharn-contracts` marker — pharn/flat detection, plus a **no-false-positive** case (bare `pharn/` → flat) and empty-dir → flat. +- `installCapabilities` mirrors a `pharn/` clone under `pharn/`, **drops** THREAT-MODEL/LIMITS, keeps `.claude/*` at root, and records `layout: pharn`; a flat clone still installs flat and records `layout: flat` (the P7 regression guard). +- `diffInstalledCapabilities` compares at the recorded layout's paths (pharn happy-path + a cross-layout graceful-degradation case); `status` passes the recorded layout through; `remove` deletes the capability at the layout's subtree (flat + pharn). +- `pharn-config` round-trips the additive `layout` field, loads a legacy config without it (P7), and drops a garbage value. + +## ADVISORY layer — verifiers + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}`. **No verifiers registered — floor gates only.** + +## Honest 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, and none exist today. Notably out of scope of what the floor can see: the `pharn/` path set encodes PR #86's **current, unmerged** layout (GRILL.md finding P7) — the tests prove the CLI mirrors *that* shape, not that #86 will merge unchanged; and `add`'s cross-layout edge (installing from a clone whose layout differs from the project's) is a documented follow-up, not covered here. diff --git a/.dev/features/installer-layout-mirror/regression-report.json b/.dev/features/installer-layout-mirror/regression-report.json new file mode 100644 index 0000000..45467e0 --- /dev/null +++ b/.dev/features/installer-layout-mirror/regression-report.json @@ -0,0 +1,37 @@ +{ + "base": "b739a6fd5c9f663bd1fddc0e79c3ab98da2efe2e", + "inside": [ + "src/lib/layout.ts", + "src/lib/constants.ts", + "src/lib/capability-index.ts", + "src/lib/install-capabilities.ts", + "src/lib/diff.ts", + "src/lib/pharn-config.ts", + "src/types.ts", + "src/steps/install-archetype.ts", + "src/commands/status.ts", + "src/commands/remove.ts", + "tests/layout.test.ts", + "tests/install-capabilities.test.ts", + "tests/capability-index.test.ts", + "tests/diff.test.ts", + "tests/status.test.ts", + "tests/remove.test.ts", + "tests/pharn-config.test.ts" + ], + "outside_gates": { + "tests": { + "base": 1, + "head": 1 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [ + "tests" + ], + "verdict": "no-regressions" +} diff --git a/.dev/features/installer-layout-mirror/verify-report.json b/.dev/features/installer-layout-mirror/verify-report.json new file mode 100644 index 0000000..e8179d9 --- /dev/null +++ b/.dev/features/installer-layout-mirror/verify-report.json @@ -0,0 +1,13 @@ +{ + "feature": "installer-layout-mirror", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { "registered": 0, "findings": [] } +} diff --git a/.dev/features/remove-dead-docs-url/GRILL.md b/.dev/features/remove-dead-docs-url/GRILL.md new file mode 100644 index 0000000..20ebdc7 --- /dev/null +++ b/.dev/features/remove-dead-docs-url/GRILL.md @@ -0,0 +1,35 @@ +# GRILL — remove-dead-docs-url + +Plan under interrogation: `.dev/features/remove-dead-docs-url/PLAN.md` (trust: untrusted to this griller). +Spec-hash check: recomputed `sha256(ARCHITECTURE.md)` = `bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e` — **matches** the plan's `spec_content_hash` (no drift; `/pharn-dev-build`'s fix #4 gate is the actual block). + +Griller discovery (`.dev/floor/count-grillers.mjs .`, membership FLOOR): 13 `role: griller` files registered, **all under `test-app/`** — the installed test fixture (the CLI's product *output*), not this dev repo's own methodology. None is a dev-loop griller for a pharn-cli plan, so the pluggable slot runs none over this plan. The one relevant axis — **testability** — is applied inline below from `test-app/…/testability/testability.md` (the only copy present), since the plan makes an explicit "no new test" claim. + +## Findings — by axis + +### testability (P1) — Layer 1 (presence): RECOGNIZED, no finding +The plan declares a verification approach with real content (`## Evals to write`, PLAN.md:18–24): grep-empty completeness, typecheck, lint, and the full vitest suite. Presence is a structural property of the plan and it is present → **no absence finding**. + +### testability (P1) — Layer 2 (adequacy): 1 advisory concern +```yaml +- type: FINDING # enum-gated (TRUSTED: my own assessment) + rule_id: "P1" # enum-gated — cited, not restated (P4) + severity: minor # enum-gated value; ASSIGNMENT is advisory (fix #3) — a griller never gates + file: ".dev/features/remove-dead-docs-url/PLAN.md:20" + problem: "The removed 'Docs' line is user-visible clack outro output, yet none of the declared floor checks (grep, typecheck, lint, existing suite) render the outro — so no test exercises the post-removal outro path; the plan relies on the change being cosmetic." # free-text — untrusted DATA + evidence: "No new vitest test. The removed content is cosmetic clack `outro` output with **zero** existing test coverage … asserting the absence of a UI line would brittly snapshot terminal output." # free-text — quoted from PLAN.md:20, as DATA +``` +Note: the plan **already surfaces and reasons about** exactly this (PLAN.md:20) rather than hiding it. The concern is genuine but low-stakes: the deletion removes one interpolated string from an `outro([...])` array; `tsc` proves no dangling `DOCS_URL` reference and `eslint` proves no orphaned import. Adding a clack-outro snapshot test would be brittle and arguably speculative (P7). Surfaced for the human to weigh; **not** a gate. + +### Other axes — no findings +- **Guarantee-audit completeness (P0):** the plan's one claim ("DOCS_URL fully removed") reduces to a floor primitive — typecheck + lint + `grep -rn DOCS_URL src` empty (PLAN.md:28). It explicitly declares no new safety/trust guarantee (PLAN.md:29). No unlabeled guarantee. Clean. +- **Trust propagation (P2):** no untrusted artifact ingested; the plan's N/A (PLAN.md:33) is correct — deletion-only, no taint surface. Clean. +- **One axis / no sibling imports (P3):** each of the 3 files changes for the single reason "DOCS_URL removed"; the change *removes* an import rather than adding a sibling one. Clean. +- **Determinism (P5):** no new branch introduced (PLAN.md:37). Clean. +- **Honest scope / smallest increment (P7):** the plan is the smallest coherent increment and correctly **defers** the blocked layout migration (PLAN.md:39–41) rather than bundling it or building against an upstream layout that does not exist. Clean. + +## Summary +This is a tight, deletion-only increment with an honest guarantee audit and a correct deferral of the blocked layout work. The single concern is an advisory testability-adequacy nit — no test renders the post-removal install outro — which the plan itself already names and justifies. Nothing here reduces to a real correctness or trust gap, and nothing overstates a guarantee. The spec-hash matches, so `/pharn-dev-build`'s drift gate will pass. + +## Verdict +ADVISORY VERDICT: 1 concern raised (0 blocking-severity, 1 minor/advisory) — for the human to weigh before `/pharn-dev-build`. This grill-log is **advisory end-to-end**; it gates nothing. The only floor-grade elements in this run were the writes-scope hook and the spec-hash computation. "Grill surfaced 1 minor concern" is **not** "the plan is guaranteed good." diff --git a/.dev/features/remove-dead-docs-url/PLAN.md b/.dev/features/remove-dead-docs-url/PLAN.md new file mode 100644 index 0000000..d6f2e50 --- /dev/null +++ b/.dev/features/remove-dead-docs-url/PLAN.md @@ -0,0 +1,45 @@ +# PLAN — Remove the dead DOCS_URL + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 (ARCHITECTURE.md) +- increment: Remove the dead `DOCS_URL` constant and its two post-install "Docs" output lines — no replacement URL (human decision: "just remove the URL"). +- layer(s): pharn-cli product source — `src/lib/` (constants) + `src/steps/` (two install stages). ARCHITECTURE.md §4. +- constitution_refs: [P4, P7, P1] + +## Files + +- `src/lib/constants.ts` — layer lib. Delete the DOCS_URL export (currently :13). +- `src/steps/install.ts` — layer steps. Drop DOCS_URL from the constants import (:7, keeping FIRST_FEATURE_COMMAND + REPO_URL); delete the "Docs" outro line and its preceding empty-string separator (currently :123–124). +- `src/steps/install-archetype.ts` — layer steps. Drop DOCS_URL from the constants import (:4, keeping FIRST_FEATURE_COMMAND + REPO_URL); delete the "Docs" outro line and its preceding empty-string separator (currently :91–92). + +## Contracts satisfied + +- None — this touches no `pharn-contracts` schema. It removes a dead product-display constant; the config/manifest/module schemas are untouched (P4 — cited, not restated). + +## Evals to write (P1) + +- No new vitest test. The removed content is cosmetic clack `outro` output with **zero** existing test coverage (`grep 'DOCS_URL' tests` and `grep 'Docs' tests` are both empty), and asserting the absence of a UI line would brittly snapshot terminal output. Completeness is instead pinned to the deterministic floor: + - `grep -rn 'DOCS_URL' src` → **empty** after the change (deterministic completeness check); + - `npm run typecheck` (`tsc`) fails on any dangling `DOCS_URL` reference; + - `npm run lint` (`eslint`) fails on a now-unused import; + - `npm test` (full `vitest` suite) stays green — the regression backstop. + +## Guarantee audit (P0) + +- "DOCS_URL is fully removed, no dangling reference" → **floor**: typecheck (undefined symbol → `tsc` error) + lint (unused import → `eslint` error) + `grep -rn DOCS_URL src` empty. Deterministic, not advisory. +- No safety/trust guarantee over fetched or copied content is introduced or altered — this is a pure display-constant deletion. No new guarantee claimed. + +## Trust audit (P2) + +- N/A — the increment ingests no untrusted artifact and adds/changes no filesystem write driven by fetched content. No taint surface. + +## Determinism audit (P5) + +- N/A — no new branch. The change only deletes code; it introduces no classification or fallback. + +## Deferred — layout migration (out of scope, honest per P7) + +- The `pharn/`-layout migration (the other half of the original `/pharn-dev-ship` request) is **BLOCKED, not built here.** Verified live this run: pharn-oss has **no** `pharn/` top-level dir on `main` or `migrations-griller` (0 of 1385 paths) and **no** open PRs; its layout is still flat, exactly what the CLI mirrors today. The human confirmed the reorg is "planned and happening right now" — i.e. **not merged**. The CLI must mirror the real upstream layout, never invent one (P5/P6/P7). **Re-run the layout increment once pharn-oss lands `pharn/` on `main`.** No `install-capabilities.ts` / `constants.ts` path changes in this increment. + +## Open questions (HALT) + +- None remaining — both were resolved via the discovery form: layout migration deferred (upstream not merged); DOCS_URL removed outright (no replacement). diff --git a/.dev/features/remove-dead-docs-url/REGRESSION.md b/.dev/features/remove-dead-docs-url/REGRESSION.md new file mode 100644 index 0000000..62f04e5 --- /dev/null +++ b/.dev/features/remove-dead-docs-url/REGRESSION.md @@ -0,0 +1,32 @@ +# REGRESSION — remove-dead-docs-url + +**Verdict (FLOOR, `check-regress.mjs verdict`):** `no-regressions` — exit `0`. + +Pure exit-code comparison of the outside gate set at the baseline and at HEAD, zero LLM judgment. + +## Base + scope partition + +- **base:** `b680a99` (`b680a990d25b54ca8096aa2e64c56468e1b464b6`) — current HEAD. Working tree is dirty (the feature's 3 uncommitted src edits), so per the deterministic base rule `base = HEAD` and the diff is taken against the working tree. +- **inside (declared `## Files`, 3):** `src/lib/constants.ts`, `src/steps/install.ts`, `src/steps/install-archetype.ts`. `scope` exit `0`, `escaped: []` — the build stayed within its declared writes (fix #7). +- **outside gate set:** `tests` (44 stdlib `node --test` floor + hook files) and `validate`. Style gates skipped — `inside` touches no shared style config (`eslint.config.mjs`, `.prettierrc.json`, `.prettierignore`, `.markdownlint-cli2.jsonc`). No outside eval pairs (`pharn-cli` ships no committed lens/griller eval pairs at repo root). + +## Per-gate base → head (exit codes) + +| gate | base | head | classification | +| ---------- | ---- | ---- | -------------- | +| `tests` | 1 | 1 | **pre_existing** (RED→RED, not a flip) | +| `validate` | 0 | 0 | clean (GREEN→GREEN) | + +`regressions[]`: **none**. `pre_existing[]`: `tests`. + +## Why the `tests` gate is RED at baseline (not this feature) + +The `tests` aggregate is red **independently of this increment** — it is red with my edits and red with them reverted. The failing file is `.dev/floor/lens-scanner-map.test.mjs`, which is **explicitly non-hermetic** (its own header: "UNLIKE the other floor tests … this one validates the COMMITTED artifact against REALITY"): it runs `count-lenses.mjs` over the **repo root**, which recursively includes the gitignored `test-app/` install fixture. `count-lenses .` returns **22 lenses, all under `test-app/pharn-review/…`** (copy-paste-drift, duplicated-logic, injection, …), while the committed `lens-scanner-map.json` is intentionally empty (`"scanners": {}` — "pharn-cli ships no product lenses today"). 22 ≠ 0 ⇒ the test asserts, and `node --test` exits non-zero. + +- **This has nothing to do with `DOCS_URL`.** No `node --test` file imports or reads any of my 3 changed `src/*.ts` files (only the vitest `tests/*.test.ts` suite does — and that ran GREEN, 594/594, at build). Removing `DOCS_URL` cannot move the lens count. +- **A first (worktree) capture read 0→1 — a measurement artifact, corrected.** The command's `git worktree` baseline checks out only tracked files, so the gitignored `test-app/` is absent there → `count-lenses` finds 0 → the lens test passes → `tests` = 0 at that base, while HEAD (working tree, `test-app` present) = 1. That 0→1 is an **apples-to-oranges** difference in *environment* (fixture present/absent), not a feature effect, so it was discarded. +- **The reported base is measured soundly, same-environment.** Base and head were both measured in the working tree (so `test-app` is present on both sides), differing **only** by the 3 src edits — reverted in place via a guarded `git stash` for the base run, then restored (verified: edits intact). Under that apples-to-apples comparison `tests` is `1 → 1` (RED→RED, pre-existing), which is the honest classification and matches the prior `archetype-path-context` src-only feature. + +## Honest residual (P0/P7) + +`/pharn-dev-regress` catches exactly what its deterministic suite catches — nothing more. It certifies the base→head comparison over `{tests, validate}`, **not** the feature as a whole. The pre-existing `lens-scanner-map` drift (a non-hermetic floor test counting fixture lenses under `test-app/`) is surfaced here but is **out of this increment's scope** — it predates this feature and is unrelated to it. diff --git a/.dev/features/remove-dead-docs-url/REVIEW.md b/.dev/features/remove-dead-docs-url/REVIEW.md new file mode 100644 index 0000000..63bae84 --- /dev/null +++ b/.dev/features/remove-dead-docs-url/REVIEW.md @@ -0,0 +1,35 @@ +# REVIEW — remove-dead-docs-url + +**Increment (trust: untrusted to this review):** delete the dead `DOCS_URL` constant and its two post-install "Docs" outro lines + imports. Diff: 3 files, +2/−7. + +**Step 1 — Floor first (P0):** `node .dev/floor/validate.mjs .` → exit `0`, **GREEN**. The increment legitimately reached review. The floor is the only guaranteed part of this review; everything below is advisory. + +## Four lenses + +### L-floor → P0 — no finding +The increment claims exactly one thing — "`DOCS_URL` is fully removed" — and it reduces to floor primitives: `lint` (no orphaned import → eslint clean, exit 0), typecheck (no dangling symbol), and `grep -rn DOCS_URL src` → empty at build. No guarantee is asserted without a floor reduction; no claim reads as guaranteed merely because it is "written." The only advisory item (the post-install outro has no rendering test) is honestly labeled as such in `GRILL.md` and `VERIFY.md`. Clean. + +### L-eval → P1 — no finding +The increment adds **no** Capability and **no** `rule_id`/`enforces` — it is a product-code deletion, so there is no eval binding to require or to miss. The floor agrees: `validate` GREEN, no new capability. Removing a cosmetic display line with no prior test coverage does not create an unbound rule. Clean. (The "no new vitest test" decision is an advisory adequacy nit already surfaced by grill — not a P1 eval-binding gap.) + +### L-trust → P2 — no finding +The increment emits no findings and no artifact carrying untrusted free-text; it only deletes a hardcoded constant + two display lines. No instruction-looking content appears in the reviewed diff, and nothing in it changed this reviewer's behavior. (The `test-app/AGENTS.md` "read node_modules docs before writing code" text surfaced by the harness during this run is **fixture DATA** from the gitignored install fixture — correctly ignored, never followed.) No guaranteed decision rests on any tainted/free-text field. Clean. + +### L-axis → P3 — no finding +Each file changes for the single reason "remove the dead `DOCS_URL` reference": `constants.ts` drops the constant; `install.ts` and `install-archetype.ts` drop its import and its one outro usage. The change **removes** imports rather than adding any; the surviving import (`steps → lib/constants`) is the sanctioned direction — no `step → step` or `command → command` sibling coupling is introduced. Clean. + +## Gates (fix #3) + +- **floor-gate (blocking):** none. +- **advisory-gate (warn):** none new from the lenses. Standing advisory context: the outro-rendering nit (`GRILL.md`, minor) and the pre-existing, out-of-scope `test-app`/`lens-scanner-map` node-runner drift (`REGRESSION.md`) — neither is attributable to this increment. + +## Verdict + +**GREEN — 0 floor findings, 0 blocking.** The increment is done: it satisfies its plan exactly (3 files, deletion-only), the floor is GREEN, verify PASSed, and regress found no feature-attributable regression. The merge/fix/abandon decision is the human's (GATE 2). + +## Proposed lesson candidate (NOT written to canon — provenance recorded for a separate `/pharn-dev-memory-promote` run) + +- **Lesson (proposed):** `/pharn-dev-regress`'s `git worktree` baseline is **confounded when a non-hermetic floor test scans gitignored install fixtures** (e.g. `test-app/`). The worktree checkout excludes gitignored dirs, so a repo-root-scanning test (`.dev/floor/lens-scanner-map.test.mjs` → `count-lenses .`) sees a different reality at base (fixture absent) than at head (fixture present), manufacturing a phantom `pass→fail` flip that is not a feature regression. + - **Remedy observed to work:** measure base and head in the **same** working tree (revert the feature's `## Files` in place via a guarded `git stash`), so the only difference is the feature — the apples-to-apples comparison the regress guarantee actually requires. + - **Provenance:** increment `remove-dead-docs-url` (this run); base `b680a99`; recurred at least once before — `archetype-path-context/REGRESSION.md` records the same `tests` gate `1→1` and labels it a "node-runner aggregate-exit quirk," which this run identifies concretely as the `count-lenses`/`test-app` non-hermeticity. + - **Why a candidate, not canon:** whether this belongs in `lessons-learned.md` (and whether the deeper fix is to make `lens-scanner-map.test.mjs` hermetic / exclude gitignored paths) is a human call; the model never self-promotes (P2). This is recorded here only. diff --git a/.dev/features/remove-dead-docs-url/SHIP.md b/.dev/features/remove-dead-docs-url/SHIP.md new file mode 100644 index 0000000..367bfa9 --- /dev/null +++ b/.dev/features/remove-dead-docs-url/SHIP.md @@ -0,0 +1,38 @@ +# SHIP — remove-dead-docs-url + +Thin, **advisory** roll-up of a gated `/pharn-dev-ship` run. It records that the chain ran and its floor verdicts — it is **not** an approval, a "shipped" mark, or a `PHARN ✓ reviewed` seal. + +## Original request → what this run actually did + +The request had two parts. Discovery (P6) split them: + +1. **Migrate the CLI install to the new `pharn/` layout — DEFERRED (not built).** Live-verified this run: pharn-oss has **no** `pharn/` top-level dir on `main` or `migrations-griller` (0/1385 paths), no open PRs. The human confirmed the reorg is "planned / happening now" — i.e. **not merged**. The CLI must mirror the real upstream layout, never invent one, so this half was not built. Re-run it once pharn-oss lands `pharn/` on `main`. +2. **Fix the dead `DOCS_URL` — BUILT.** Human decision: "just remove the URL." Scoped as the increment `remove-dead-docs-url` and carried through the full gated chain below. + +## Stages that ran, in order, and where the run ended + +| stage | what happened | +| ----- | ------------- | +| `/pharn-dev-plan` | PLAN.md written; **GATE 1** — human **approved as written**. | +| `/pharn-dev-grill` | GRILL.md: 1 concern (0 blocking, 1 minor/advisory — outro has no rendering test; already acknowledged by the plan). Advisory, gated nothing. | +| `/pharn-dev-build` | 3 files, deletion-only (+2/−7). Floor GREEN. | +| `/pharn-dev-regress` | no feature-attributable regression. | +| `/pharn-dev-verify` | all floor gates PASS; 0 verifiers. | +| `/pharn-dev-review` | REVIEW.md: GREEN, 0 floor findings, 0 blocking. | + +**Run ended at GATE 2** (post-review human decision) — not at a RED-verdict STOP. + +## Structural verdicts read, verbatim (the floor — the only guaranteed parts) + +- **`/pharn-dev-build`** → `node .dev/floor/validate.mjs .` exit code = **`0`** (GREEN). (Repo floor `npm run check` also exit 0: format:check + lint + typecheck + **594/594** vitest.) +- **`/pharn-dev-regress`** → `regression-report.json` `.verdict` = **`"no-regressions"`** (exit 0). The one RED gate (`tests`) is `1→1` **pre_existing** — pre-existing `test-app`/`lens-scanner-map` node-runner drift, RED→RED, not attributable to this increment (see REGRESSION.md for the worktree-confound analysis + sound same-environment re-measurement). +- **`/pharn-dev-verify`** → `verify-report.json` `.verdict` = **`"PASS"`** (exit 0); `failing_gates: []`. + +## Advisory artifacts (cited, not restated — P4) + +- `.dev/features/remove-dead-docs-url/REVIEW.md` — 4-lens review (GREEN; proposes one **candidate** lesson about the regress worktree/`test-app` confound, for a separate human-gated `/pharn-dev-memory-promote` run — not written to canon here). +- `.dev/features/remove-dead-docs-url/GRILL.md` — advisory pre-build interrogation (1 minor concern). + +## Honest close (P0) + +The 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: **merge / fix / abandon.** `/pharn-dev-ship` does not merge, push, commit, or seal. The change is currently **uncommitted** in the working tree. diff --git a/.dev/features/remove-dead-docs-url/VERIFY.md b/.dev/features/remove-dead-docs-url/VERIFY.md new file mode 100644 index 0000000..ffd32b6 --- /dev/null +++ b/.dev/features/remove-dead-docs-url/VERIFY.md @@ -0,0 +1,25 @@ +# VERIFY — remove-dead-docs-url + +**Verdict (FLOOR, `check-verify.mjs`):** `VERIFIED: floor gates PASS` — exit `0`, `failing_gates: []`. + +## FLOOR gates (whole-repo, at HEAD with the feature present) + +| gate | exit | meaning | +| -------------- | ---- | ------- | +| `test` | 0 | vitest suite green (594/594 — includes `install`/`constants` coverage) | +| `validate` | 0 | structural floor GREEN (no PHARN markdown capability added; vacuously green) | +| `lint` | 0 | eslint clean — no orphaned `DOCS_URL` import left behind | +| `format:check` | 0 | prettier clean, whole-repo | +| `lint:md` | 0 | markdownlint clean, whole-repo | + +No `structural:*` gate — this increment ships no committed eval pair (it is a product-code deletion, not a Capability). + +Note: verify's `test` gate is **vitest** (`npm test`, `tests/**/*.test.ts`), which is where this feature's real correctness surface lives (`install`/`constants` tests). It is independent of the `node --test` `.mjs`/`.cjs` runner that `/pharn-dev-regress` uses — so the pre-existing `test-app`/`lens-scanner-map` node-runner drift noted in `REGRESSION.md` does **not** touch this verdict. + +## ADVISORY layer — verifiers + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}`. **No verifiers registered — floor gates only.** Step 2 is a no-op; the verdict is the floor gates alone. + +## Honest 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, and none exist today. Specifically: the deletion is proven complete by `lint` (no unused import) + `test`/`typecheck`-adjacent coverage + the build-time `grep -rn DOCS_URL src` → empty; what the gates cannot see (e.g. the cosmetic post-install outro rendering, which has no test) remains an advisory nit already surfaced in `GRILL.md`. diff --git a/.dev/features/remove-dead-docs-url/regression-report.json b/.dev/features/remove-dead-docs-url/regression-report.json new file mode 100644 index 0000000..f783636 --- /dev/null +++ b/.dev/features/remove-dead-docs-url/regression-report.json @@ -0,0 +1,23 @@ +{ + "base": "b680a990d25b54ca8096aa2e64c56468e1b464b6", + "inside": [ + "src/lib/constants.ts", + "src/steps/install.ts", + "src/steps/install-archetype.ts" + ], + "outside_gates": { + "tests": { + "base": 1, + "head": 1 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [ + "tests" + ], + "verdict": "no-regressions" +} diff --git a/.dev/features/remove-dead-docs-url/verify-report.json b/.dev/features/remove-dead-docs-url/verify-report.json new file mode 100644 index 0000000..0b5afc3 --- /dev/null +++ b/.dev/features/remove-dead-docs-url/verify-report.json @@ -0,0 +1,13 @@ +{ + "feature": "remove-dead-docs-url", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { "registered": 0, "findings": [] } +} diff --git a/.pharn/pharn-dev-regress/base-results.json b/.pharn/pharn-dev-regress/base-results.json index 050e62d..c825c88 100644 --- a/.pharn/pharn-dev-regress/base-results.json +++ b/.pharn/pharn-dev-regress/base-results.json @@ -1 +1 @@ -{"tests":1,"validate":0} \ No newline at end of file +{"tests":1,"validate":0} diff --git a/.pharn/pharn-dev-regress/head-results.json b/.pharn/pharn-dev-regress/head-results.json index 050e62d..c825c88 100644 --- a/.pharn/pharn-dev-regress/head-results.json +++ b/.pharn/pharn-dev-regress/head-results.json @@ -1 +1 @@ -{"tests":1,"validate":0} \ No newline at end of file +{"tests":1,"validate":0} diff --git a/.pharn/pharn-dev-verify/results.json b/.pharn/pharn-dev-verify/results.json index c1e0603..d76b920 100644 --- a/.pharn/pharn-dev-verify/results.json +++ b/.pharn/pharn-dev-verify/results.json @@ -1 +1 @@ -{"test":0,"validate":0,"lint":0,"format:check":0,"lint:md":0} \ No newline at end of file +{"test":0,"validate":0,"lint":0,"format:check":0,"lint:md":0} diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 658e2db..29c96a0 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/memory-bank/lessons-learned.md" + ".dev/features/installer-layout-mirror/SHIP.md" ], - "set_by": ".claude/commands/pharn-dev-memory-promote.md", - "set_at": "2026-07-09T14:10:26.121Z" + "set_by": ".claude/commands/pharn-dev-ship.md", + "set_at": "2026-07-10T07:57:30.084Z" } diff --git a/src/commands/remove.ts b/src/commands/remove.ts index ad07461..3211015 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -13,11 +13,10 @@ import pc from 'picocolors'; import { cancelAndExit } from '../lib/confirm.js'; import { CORE_MODULE, - GRILLERS_DIR, - LENSES_DIR, REPO_URL, SKILL_MODULE_PREFIX, } from '../lib/constants.js'; +import { configLayout, layoutPaths } from '../lib/layout.js'; import { readManifest, readModuleManifest, @@ -191,7 +190,11 @@ async function removeCapability( } const target = matches[0]!; - const subtree = target.role === 'griller' ? GRILLERS_DIR : LENSES_DIR; + // Address the capability at the project's recorded layout (flat OR pharn/), so + // a pharn-layout install deletes pharn/pharn-review|pharn-pipeline/grillers/ + // (lib/layout.ts). Legacy/absent layout → flat, the safe default (P5/P7). + const paths = layoutPaths(configLayout(config)); + const subtree = target.role === 'griller' ? paths.grillers : paths.lenses; const dir = safeJoin(cwd, `${subtree}/${target.name}`); let note = ''; if (existsSync(dir)) { diff --git a/src/commands/status.ts b/src/commands/status.ts index 10af828..e794238 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -9,6 +9,7 @@ import { } from '../lib/manifest.js'; import { fetchRepo } from '../lib/repo.js'; import { diffInstalled, diffInstalledCapabilities } from '../lib/diff.js'; +import { configLayout } from '../lib/layout.js'; import { row } from '../lib/format.js'; import { isArchetypeConfig, loadConfigOrExit } from '../lib/pharn-config.js'; import { @@ -163,6 +164,7 @@ async function runArchetypeStatus( repoDir: repo.dir, projectRoot: cwd, capabilities: config.capabilities ?? [], + layout: configLayout(config), }); printDriftSection(result); if ( diff --git a/src/lib/capability-index.ts b/src/lib/capability-index.ts index e897633..a86df95 100644 --- a/src/lib/capability-index.ts +++ b/src/lib/capability-index.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { safeJoin } from './install-modules.js'; -import { GRILLERS_DIR, LENSES_DIR } from './constants.js'; +import { detectLayout, layoutPaths } from './layout.js'; import { assertAppliesToken, assertNoDotDot, @@ -37,14 +37,6 @@ import type { Archetype, CapabilityEntry, CapabilityIndex } from '../types.js'; // in resolve-capabilities.ts, and the archetype detection in detect-archetype.ts. // --------------------------------------------------------------------------- -// One capability subtree in the fetched repo and the role every capability under -// it carries. The subtree is authoritative for `role`; the frontmatter `role` is -// cross-checked against it (a file in the wrong subtree hard-fails). -const SUBTREES: { dir: string; role: 'griller' | 'lens' }[] = [ - { dir: GRILLERS_DIR, role: 'griller' }, - { dir: LENSES_DIR, role: 'lens' }, -]; - /** * Parse + validate the capability index from a fetched pharn-oss clone. * Deterministic (P5): capabilities are enumerated in sorted directory order @@ -56,7 +48,17 @@ const SUBTREES: { dir: string; role: 'griller' | 'lens' }[] = [ export function parseCapabilityIndex(repoDir: string): CapabilityIndex { const capabilities: CapabilityEntry[] = []; - for (const subtree of SUBTREES) { + // Mirror the fetched clone's layout (flat OR the relocated pharn/) so the CLI + // enumerates capabilities wherever pharn-oss put them (P5 — membership on the + // clone, lib/layout.ts). Each subtree is authoritative for `role`; the + // frontmatter `role` is cross-checked against it (wrong subtree → hard-fail). + const paths = layoutPaths(detectLayout(repoDir)); + const subtrees: { dir: string; role: 'griller' | 'lens' }[] = [ + { dir: paths.grillers, role: 'griller' }, + { dir: paths.lenses, role: 'lens' }, + ]; + + for (const subtree of subtrees) { const subtreeDir = safeJoin(repoDir, subtree.dir); if (!existsSync(subtreeDir)) { throw new ManifestValidationError( diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5e6c0cd..1a2a547 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -10,7 +10,6 @@ export const REPO_URL = 'github.com/pharn-dev/pharn-oss'; // cloning the whole repo. export const MANIFEST_RAW_PATH = `${REPO}/${REPO_BRANCH}/manifest.json`; -export const DOCS_URL = 'https://github.com/pharn-dev/pharn-oss/tree/main/docs'; export const FIRST_FEATURE_COMMAND = '/pharn-plan'; // pharn-core is always installed; it is the foundation every other module @@ -52,6 +51,25 @@ export const CONTRACTS_DIR = 'pharn-contracts'; // Copied whole EXCEPT test files (see install-capabilities.ts). NOT `.dev/` // wholesale — `.dev/features` and `.dev/memory-bank` are dev-only and excluded. export const FLOOR_DIR = '.dev/floor'; + +// --------------------------------------------------------------------------- +// The new pharn/ single-install layout (pharn-oss PR #86 / pharn-runtime-layout): +// every runtime-read surface relocates under pharn/ so the dev repo and the +// installed project share ONE tree. The CLI mirrors whichever layout the fetched +// clone actually has (lib/layout.ts → detectLayout); these are the pharn/ +// counterparts of the flat constants above. `.claude/*` command/hook/settings +// paths are identical in both layouts. THREAT-MODEL.md / LIMITS.md are NOT under +// pharn/ (they stay dev-only), so the pharn docs set is CONSTITUTION + ARCHITECTURE +// only — the flat set's THREAT-MODEL/LIMITS are dropped from a pharn install. +// --------------------------------------------------------------------------- +export const PHARN_GRILLERS_DIR = 'pharn/pharn-pipeline/grillers'; +export const PHARN_LENSES_DIR = 'pharn/pharn-review'; +export const PHARN_CONTRACTS_DIR = 'pharn/pharn-contracts'; +export const PHARN_FLOOR_DIR = 'pharn/floor'; +export const PHARN_TRUSTED_DOCS = [ + 'pharn/CONSTITUTION.md', + 'pharn/ARCHITECTURE.md', +]; // Claude Code surfaces. export const CLAUDE_COMMANDS_DIR = '.claude/commands'; export const CLAUDE_HOOKS_DIR = '.claude/hooks'; diff --git a/src/lib/diff.ts b/src/lib/diff.ts index 6f5c6a0..0c6c489 100644 --- a/src/lib/diff.ts +++ b/src/lib/diff.ts @@ -6,15 +6,11 @@ import { readModuleManifest } from './manifest.js'; import { CLAUDE_COMMANDS_DIR, CLAUDE_HOOKS_DIR, - CONTRACTS_DIR, DEV_COMMAND_PREFIX, - FLOOR_DIR, - GRILLERS_DIR, - LENSES_DIR, PRODUCT_COMMAND_PREFIX, - TRUSTED_DOCS, } from './constants.js'; -import type { InstalledCapability, InstalledSkill } from '../types.js'; +import { layoutPaths } from './layout.js'; +import type { InstalledCapability, InstalledSkill, Layout } from '../types.js'; export interface InstallDiff { // .claude-relative paths present on disk but whose bytes differ from upstream. @@ -103,8 +99,15 @@ export function diffInstalledCapabilities(params: { repoDir: string; projectRoot: string; capabilities: InstalledCapability[]; + // The project's recorded layout (config.layout via configLayout). The expected + // set is derived at the layout's paths for BOTH the clone source and the project + // dest (the mirror). A clone at @main whose layout differs simply lacks the + // source (existsSync false → skipped) — the same pre-existing "@main packaging" + // bound already documented below. + layout: Layout; }): InstallDiff { - const { repoDir, projectRoot, capabilities } = params; + const { repoDir, projectRoot, capabilities, layout } = params; + const paths = layoutPaths(layout); const expected = new Map(); const add = (rel: string, repoPath: string): void => { expected.set(toPosix(rel), repoPath); @@ -120,9 +123,9 @@ export function diffInstalledCapabilities(params: { } }; - // Selected capabilities (whole dir, incl. evals). + // Selected capabilities (whole dir, incl. evals) at the layout's subtree. for (const cap of capabilities) { - const subtree = cap.role === 'griller' ? GRILLERS_DIR : LENSES_DIR; + const subtree = cap.role === 'griller' ? paths.grillers : paths.lenses; addDir(`${subtree}/${cap.name}`); } // Product commands: top-level non-dev pharn-*.md. @@ -140,14 +143,14 @@ export function diffInstalledCapabilities(params: { (rel) => !rel.includes('/') && rel.endsWith('.cjs') && !rel.endsWith('.test.cjs'), ); - // Trusted docs (root files). - for (const doc of TRUSTED_DOCS) { + // Trusted docs (flat: root files; pharn: CONSTITUTION + ARCHITECTURE under pharn/). + for (const doc of paths.docs) { const from = safeJoin(repoDir, doc); if (existsSync(from)) add(doc, from); } - // Contracts (whole dir) + floor checkers (test files excluded). - addDir(CONTRACTS_DIR); - addDir(FLOOR_DIR, (rel) => !/\.test\.(mjs|cjs)$/.test(rel)); + // Contracts (whole dir) + floor checkers (test files excluded), at layout paths. + addDir(paths.contracts); + addDir(paths.floor, (rel) => !/\.test\.(mjs|cjs)$/.test(rel)); return compareExpected(expected, projectRoot); } diff --git a/src/lib/install-capabilities.ts b/src/lib/install-capabilities.ts index 1a9ab9a..8de9bea 100644 --- a/src/lib/install-capabilities.ts +++ b/src/lib/install-capabilities.ts @@ -11,15 +11,11 @@ import { CLAUDE_COMMANDS_DIR, CLAUDE_HOOKS_DIR, CLAUDE_SETTINGS_FILE, - CONTRACTS_DIR, DEV_COMMAND_PREFIX, - FLOOR_DIR, - GRILLERS_DIR, - LENSES_DIR, PRODUCT_COMMAND_PREFIX, - TRUSTED_DOCS, } from './constants.js'; -import type { InstalledCapability, Selection } from '../types.js'; +import { detectLayout, layoutPaths, type LayoutPaths } from './layout.js'; +import type { InstalledCapability, Layout, Selection } from '../types.js'; // --------------------------------------------------------------------------- // Capability copy routine (archetype install). Copies the RESOLVED capabilities @@ -49,6 +45,9 @@ export interface InstallCapabilitiesResult { // NOT overwrite it (the user's Claude Code config is preserved). The caller // surfaces this so the user knows the hooks may need wiring by hand. settingsPreserved: boolean; + // The layout mirrored from the fetched clone (flat OR pharn/). Recorded in + // pharn.config.json so status/remove address the project the same way. + layout: Layout; } const isTestFile = (p: string): boolean => /\.test\.(mjs|cjs)$/.test(p); @@ -79,11 +78,15 @@ export function installCapabilityDirs( repoDir: string, projectRoot: string, capabilities: InstalledCapability[], + // The layout to mirror; defaults to the fetched clone's own layout (flat OR + // pharn/). `add` uses the default; `installCapabilities` passes the layout it + // detected once, so contracts/floor/docs stay consistent with the subtrees. + paths: LayoutPaths = layoutPaths(detectLayout(repoDir)), ): InstalledCapability[] { const planned = capabilities.map((cap) => { assertSafeString(cap.name, `capability "${cap.name}"`, CAPABILITY_NAME_RE); assertNoDotDot(cap.name, `capability "${cap.name}"`); - const subtree = cap.role === 'griller' ? GRILLERS_DIR : LENSES_DIR; + const subtree = cap.role === 'griller' ? paths.grillers : paths.lenses; const from = safeJoin(repoDir, `${subtree}/${cap.name}`); if (!existsSync(from)) { throw new ManifestValidationError( @@ -112,11 +115,17 @@ export function installCapabilities( projectRoot: string, selection: Selection, ): InstallCapabilitiesResult { + // Mirror whichever layout the fetched clone has (flat OR the relocated pharn/). + // The resolved relative paths are the clone SOURCE and the project DEST at once — + // the CLI never rewrites copied file contents (lib/layout.ts). + const paths = layoutPaths(detectLayout(repoDir)); + // Copy the selected capability dirs (pre-flighted; no partial installs). const capabilities = installCapabilityDirs( repoDir, projectRoot, selection.selected, + paths, ); // --- product commands: pharn-*.md, excluding pharn-dev-*.md ---------------- @@ -145,35 +154,36 @@ export function installCapabilities( cpSync(settingsFrom, settingsTo, { force: true }); } - // --- trusted docs (root) --------------------------------------------------- - for (const doc of TRUSTED_DOCS) { + // --- trusted docs (flat: 4 at root; pharn: CONSTITUTION + ARCHITECTURE under + // pharn/, THREAT-MODEL/LIMITS dropped — they are not under pharn/) ----------- + for (const doc of paths.docs) { const from = safeJoin(repoDir, doc); if (existsSync(from) && !isSymlink(from)) { cpSync(from, safeJoin(projectRoot, doc), { force: true }); } } - // --- pharn-contracts/ (whole dir) ------------------------------------------ - const contractsFrom = safeJoin(repoDir, CONTRACTS_DIR); + // --- contracts (whole dir; mirrored at the layout's path) ------------------ + const contractsFrom = safeJoin(repoDir, paths.contracts); if (existsSync(contractsFrom) && !isSymlink(contractsFrom)) { - cpSync(contractsFrom, safeJoin(projectRoot, CONTRACTS_DIR), { + cpSync(contractsFrom, safeJoin(projectRoot, paths.contracts), { recursive: true, force: true, filter: noSymlinks, }); } - // --- .dev/floor/ (checkers only — test files excluded) --------------------- - const floorFrom = safeJoin(repoDir, FLOOR_DIR); + // --- floor checkers (whole dir minus test files; mirrored at layout path) -- + const floorFrom = safeJoin(repoDir, paths.floor); if (existsSync(floorFrom) && !isSymlink(floorFrom)) { - cpSync(floorFrom, safeJoin(projectRoot, FLOOR_DIR), { + cpSync(floorFrom, safeJoin(projectRoot, paths.floor), { recursive: true, force: true, filter: (src) => !isTestFile(src) && noSymlinks(src), }); } - return { capabilities, settingsPreserved }; + return { capabilities, settingsPreserved, layout: paths.layout }; } /** diff --git a/src/lib/layout.ts b/src/lib/layout.ts new file mode 100644 index 0000000..30a907d --- /dev/null +++ b/src/lib/layout.ts @@ -0,0 +1,83 @@ +import { existsSync } from 'node:fs'; +import { safeJoin } from './install-modules.js'; +import { + CONTRACTS_DIR, + FLOOR_DIR, + GRILLERS_DIR, + LENSES_DIR, + PHARN_CONTRACTS_DIR, + PHARN_FLOOR_DIR, + PHARN_GRILLERS_DIR, + PHARN_LENSES_DIR, + PHARN_TRUSTED_DOCS, + TRUSTED_DOCS, +} from './constants.js'; +import type { Layout, PharnConfig } from '../types.js'; + +// --------------------------------------------------------------------------- +// Layout resolver — the ONE place that knows the two install layouts pharn-oss +// ships: the legacy `flat` layout (surfaces at the repo root) and the new `pharn` +// single-install layout (everything under pharn/, pharn-oss PR #86). The CLI +// MIRRORS whichever the fetched clone has — it never rewrites copied file +// contents — so a layout's source-relative-to-clone path IS its +// dest-relative-to-project path. Only the `.claude/*` command/hook/settings +// surfaces are layout-invariant (identical in both) and live in constants.ts. +// +// P5 (determinism): every layout decision here is a membership test whose else +// branch is the safe legacy default `flat` — never a guess, never an LLM. +// P7: `flat` IS the current behavior, so old pinned SHAs keep mirroring flat. +// --------------------------------------------------------------------------- + +export interface LayoutPaths { + layout: Layout; + // Griller capability subtree (role: griller). + grillers: string; + // Lens capability subtree (role: lens). + lenses: string; + // Inter-layer schema contracts dir (whole dir). + contracts: string; + // Deterministic floor checkers dir (test files excluded on copy). + floor: string; + // Trusted spec docs copied verbatim (write-protected post-install by the + // installed protect-trusted-paths hook). The pharn set drops THREAT-MODEL/LIMITS. + docs: string[]; +} + +// Detect the layout of a fetched clone (or any dir) by a SPECIFIC marker the flat +// layout provably lacks: `pharn/pharn-contracts`. A bare `pharn/` dir is NOT the +// marker — it could exist for unrelated reasons — so detection keys on a leaf that +// only the relocated layout has (P5: a precise membership test). No `pharn/` +// contracts dir → `flat`, the safe legacy default. +export function detectLayout(rootDir: string): Layout { + return existsSync(safeJoin(rootDir, PHARN_CONTRACTS_DIR)) ? 'pharn' : 'flat'; +} + +// The resolved path set for a layout — the same relative paths are used as the +// clone source AND the project destination (the mirror). Pure; no I/O. +export function layoutPaths(layout: Layout): LayoutPaths { + if (layout === 'pharn') { + return { + layout, + grillers: PHARN_GRILLERS_DIR, + lenses: PHARN_LENSES_DIR, + contracts: PHARN_CONTRACTS_DIR, + floor: PHARN_FLOOR_DIR, + docs: PHARN_TRUSTED_DOCS, + }; + } + return { + layout, + grillers: GRILLERS_DIR, + lenses: LENSES_DIR, + contracts: CONTRACTS_DIR, + floor: FLOOR_DIR, + docs: TRUSTED_DOCS, + }; +} + +// The layout an installed project was recorded with. Enum-safe membership (P5): +// exactly `'pharn'` → pharn; anything else (including a legacy config that omits +// the field, or a hand-edited garbage value) → `flat`, the safe legacy default. +export function configLayout(config: PharnConfig): Layout { + return config.layout === 'pharn' ? 'pharn' : 'flat'; +} diff --git a/src/lib/pharn-config.ts b/src/lib/pharn-config.ts index 4885572..022e73c 100644 --- a/src/lib/pharn-config.ts +++ b/src/lib/pharn-config.ts @@ -50,11 +50,19 @@ export function readPharnConfig(cwd: string): PharnConfig | null { raw.models !== undefined ? validateModelRouting(raw.models) : undefined; const seam = raw.seam !== undefined ? validateSeamConfig(raw.seam) : undefined; - return { + const config: PharnConfig = { ...(raw as unknown as PharnConfig), ...(models !== undefined ? { models } : {}), ...(seam !== undefined ? { seam } : {}), }; + // Additive `layout` (lib/layout.ts): coerce to the {pharn, flat} enum. A legacy + // config omits it and a hand-edited garbage value is dropped — both resolve to + // 'flat' downstream (configLayout), the safe default (P5/P7). Only 'pharn' / + // 'flat' survive verbatim, so the field round-trips. + if (config.layout !== 'pharn' && config.layout !== 'flat') { + delete config.layout; + } + return config; } /** diff --git a/src/steps/install-archetype.ts b/src/steps/install-archetype.ts index 9c5d541..671d876 100644 --- a/src/steps/install-archetype.ts +++ b/src/steps/install-archetype.ts @@ -1,7 +1,7 @@ import { createRequire } from 'node:module'; import { log, outro, spinner } from '@clack/prompts'; import pc from 'picocolors'; -import { DOCS_URL, FIRST_FEATURE_COMMAND, REPO_URL } from '../lib/constants.js'; +import { FIRST_FEATURE_COMMAND, REPO_URL } from '../lib/constants.js'; import { installCapabilities } from '../lib/install-capabilities.js'; import { DEFAULT_MODEL_ROUTING } from '../lib/model-routing.js'; import { DEFAULT_SEAM_CONFIG } from '../lib/seam-config.js'; @@ -10,6 +10,7 @@ import { readSkillsVersion } from '../lib/skills-version.js'; import type { Archetype, InstalledCapability, + Layout, PharnConfig, Selection, } from '../types.js'; @@ -37,10 +38,12 @@ export async function runInstallArchetype( let capabilities: InstalledCapability[]; let settingsPreserved: boolean; let skillsVersion: string; + let layout: Layout; try { const result = installCapabilities(repoDir, cwd, selection); capabilities = result.capabilities; settingsPreserved = result.settingsPreserved; + layout = result.layout; skillsVersion = readSkillsVersion(repoDir); } catch (err) { // Stop the spinner and propagate — the orchestrator cleans up the fetched @@ -71,6 +74,9 @@ export async function runInstallArchetype( seam: DEFAULT_SEAM_CONFIG, archetypes, capabilities, + // The layout mirrored from the fetched clone (flat OR pharn/) — status/remove + // read this back to address the project the same way (lib/layout.ts). + layout, }; await writePharnConfig(cwd, config); @@ -88,8 +94,6 @@ export async function runInstallArchetype( pc.bold('Next steps'), ` ${pc.cyan('1.')} ${pc.bold('claude')} ${pc.dim('open Claude Code')}`, ` ${pc.cyan('2.')} ${pc.bold(FIRST_FEATURE_COMMAND)} ${pc.dim('plan your first feature')}`, - '', - `${pc.bold('Docs')} ${pc.cyan(DOCS_URL)}`, ].join('\n'), ); } diff --git a/src/steps/install.ts b/src/steps/install.ts index ec2859e..256c8a1 100644 --- a/src/steps/install.ts +++ b/src/steps/install.ts @@ -4,7 +4,7 @@ import { confirm, isCancel, log, outro, spinner } from '@clack/prompts'; import { createRequire } from 'node:module'; import pc from 'picocolors'; import { cancelAndExit } from '../lib/confirm.js'; -import { DOCS_URL, FIRST_FEATURE_COMMAND, REPO_URL } from '../lib/constants.js'; +import { FIRST_FEATURE_COMMAND, REPO_URL } from '../lib/constants.js'; import { fetchAndInstall } from '../lib/installer.js'; import { DEFAULT_MODEL_ROUTING } from '../lib/model-routing.js'; import { DEFAULT_SEAM_CONFIG } from '../lib/seam-config.js'; @@ -120,8 +120,6 @@ export async function runInstall(config: WizardConfig): Promise { pc.bold('Next steps'), ` ${pc.cyan('1.')} ${pc.bold('claude')} ${pc.dim('open Claude Code')}`, ` ${pc.cyan('2.')} ${pc.bold(FIRST_FEATURE_COMMAND)} ${pc.dim('plan your first feature')}`, - '', - `${pc.bold('Docs')} ${pc.cyan(DOCS_URL)}`, ].join('\n'), ); } diff --git a/src/types.ts b/src/types.ts index d7938d5..c3495d9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -247,8 +247,17 @@ export interface PharnConfig { // the detected archetypes and the capabilities copied for them. archetypes?: Archetype[]; capabilities?: InstalledCapability[]; + // The install layout mirrored from the fetched clone (lib/layout.ts). Additive; + // absent on legacy installs → read as 'flat' (the safe default, P7). 'pharn' = + // the relocated single-install layout (everything under pharn/); 'flat' = the + // legacy root layout. + layout?: Layout; } +// The two install layouts pharn-oss ships (lib/layout.ts). Kept here (not in +// layout.ts) so PharnConfig can reference it without a types↔layout import cycle. +export type Layout = 'pharn' | 'flat'; + // --------------------------------------------------------------------------- // Capability resolver — archetype detection + capability selection. pharn-cli // reads pharn-oss's capability index and selects which grillers/lenses apply to diff --git a/tests/capability-index.test.ts b/tests/capability-index.test.ts index 5547e2f..0dd6b60 100644 --- a/tests/capability-index.test.ts +++ b/tests/capability-index.test.ts @@ -51,6 +51,32 @@ describe('parseCapabilityIndex', () => { ]); }); + it('enumerates the pharn/ layout when the clone uses it (marker: pharn/pharn-contracts)', () => { + const repo = tmp.path(); + // The detection marker + both pharn/ subtree roots. + mkdirSync(join(repo, 'pharn/pharn-contracts'), { recursive: true }); + mkdirSync(join(repo, 'pharn/pharn-pipeline/grillers'), { recursive: true }); + mkdirSync(join(repo, 'pharn/pharn-review'), { recursive: true }); + writeCap( + repo, + 'pharn/pharn-pipeline/grillers', + 'a11y', + fm('griller', '["ssr"]'), + ); + writeCap( + repo, + 'pharn/pharn-review', + 'n-plus-one', + fm('lens', '["backend"]'), + ); + + const index = parseCapabilityIndex(repo); + expect(index.capabilities).toEqual([ + { name: 'a11y', role: 'griller', applies: ['ssr'] }, + { name: 'n-plus-one', role: 'lens', applies: ['backend'] }, + ]); + }); + it('maps ["universal"] to the string, not an array (the resolver contract)', () => { const repo = tmp.path(); scaffold(repo); diff --git a/tests/diff.test.ts b/tests/diff.test.ts index a29571b..2eaa431 100644 --- a/tests/diff.test.ts +++ b/tests/diff.test.ts @@ -273,6 +273,7 @@ describe('diffInstalledCapabilities', () => { repoDir: repo, projectRoot: proj, capabilities: caps, + layout: 'flat', }); expect(r.missing).toEqual([]); expect(r.modified).toEqual([]); @@ -294,6 +295,7 @@ describe('diffInstalledCapabilities', () => { repoDir: repo, projectRoot: proj, capabilities: caps, + layout: 'flat', }); expect(r.modified).toContain('pharn-pipeline/grillers/a11y/a11y.md'); expect(r.modified).toContain('CONSTITUTION.md'); @@ -302,4 +304,73 @@ describe('diffInstalledCapabilities', () => { expect(paths.some((p) => p.includes('pharn-dev-'))).toBe(false); expect(paths.some((p) => p.includes('.test.'))).toBe(false); }); + + it('mirrors the pharn/ layout when layout is pharn (never the flat paths)', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + // A pharn-layout clone: runtime surfaces under pharn/ (docs = CONSTITUTION + + // ARCHITECTURE only); .claude/* stays at root in both layouts. + write(join(repo, 'pharn/pharn-pipeline/grillers/a11y/a11y.md'), 'A'); + write(join(repo, 'pharn/pharn-review/n-plus-one/n-plus-one.md'), 'N'); + write(join(repo, 'pharn/CONSTITUTION.md'), 'C'); + write(join(repo, 'pharn/pharn-contracts/finding-shape.md'), 'fs'); + write(join(repo, 'pharn/floor/validate.mjs'), 'floor'); + write(join(repo, '.claude/commands/pharn-plan.md'), 'plan'); + write(join(repo, '.claude/hooks/enforce.cjs'), 'hook'); + for (const [rel, body] of [ + ['pharn/pharn-pipeline/grillers/a11y/a11y.md', 'A'], + ['pharn/pharn-review/n-plus-one/n-plus-one.md', 'N'], + ['pharn/CONSTITUTION.md', 'C'], + ['pharn/pharn-contracts/finding-shape.md', 'fs'], + ['pharn/floor/validate.mjs', 'floor'], + ['.claude/commands/pharn-plan.md', 'plan'], + ['.claude/hooks/enforce.cjs', 'hook'], + ] as const) { + write(join(proj, rel), body); + } + + const r = diffInstalledCapabilities({ + repoDir: repo, + projectRoot: proj, + capabilities: caps, + layout: 'pharn', + }); + expect(r.missing).toEqual([]); + expect(r.modified).toEqual([]); + // a11y, n-plus-one, pharn-plan, enforce.cjs, CONSTITUTION, contract, floor. + expect(r.okCount).toBe(7); + + // An edited pharn/ file is flagged AT its pharn/ path — proving the expected + // set used the pharn paths, not the flat ones. + write(join(proj, 'pharn/pharn-review/n-plus-one/n-plus-one.md'), 'N-EDIT'); + const r2 = diffInstalledCapabilities({ + repoDir: repo, + projectRoot: proj, + capabilities: caps, + layout: 'pharn', + }); + expect(r2.modified).toContain( + 'pharn/pharn-review/n-plus-one/n-plus-one.md', + ); + }); + + it('degrades gracefully (skips, never throws) when the clone lacks the project layout', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + scaffoldClone(repo); // FLAT clone (e.g. @main before #86 merges) + scaffoldMatchingProject(proj); + // Ask for the pharn layout though the clone is flat: every pharn/ capability + // source is absent in the clone → skipped (the documented @main bound), so no + // pharn/ path is reported and the call never throws. The layout-invariant + // .claude/* surfaces still compare. + const r = diffInstalledCapabilities({ + repoDir: repo, + projectRoot: proj, + capabilities: caps, + layout: 'pharn', + }); + const all = [...r.missing, ...r.modified]; + expect(all.some((p) => p.startsWith('pharn/'))).toBe(false); + expect(r.okCount).toBeGreaterThanOrEqual(2); + }); }); diff --git a/tests/install-capabilities.test.ts b/tests/install-capabilities.test.ts index daf562d..b93ee6d 100644 --- a/tests/install-capabilities.test.ts +++ b/tests/install-capabilities.test.ts @@ -118,12 +118,14 @@ describe('installCapabilities', () => { expect(existsSync(join(proj, 'pharn-review/trust-fence'))).toBe(false); }); - it('returns the copied capability list (name + role)', () => { + it('returns the copied capability list (name + role) and records layout: flat', () => { const { result } = run(); expect(result.capabilities).toEqual([ { name: 'a11y', role: 'griller' }, { name: 'n-plus-one', role: 'lens' }, ]); + // A flat clone (no pharn/pharn-contracts marker) → the legacy layout (P7). + expect(result.layout).toBe('flat'); }); it('copies product pharn-*.md commands but EXCLUDES pharn-dev-*.md and non-pharn files', () => { @@ -342,3 +344,96 @@ describe('installCapabilityDirs', () => { ).toThrow(ManifestValidationError); }); }); + +describe('installCapabilities — pharn/ layout (mirrors PR #86)', () => { + const tmp = useTmpDir(); + + // A fetched clone in the new pharn/ single-install layout: runtime surfaces + // under pharn/, .claude/* at root, THREAT-MODEL/LIMITS at root but NOT under + // pharn/ (so a pharn install drops them). The pharn/pharn-contracts dir is the + // detection marker. + function scaffoldRepoPharn(repo: string): void { + write(join(repo, 'pharn/pharn-pipeline/grillers/a11y/a11y.md'), 'a11y'); + write( + join(repo, 'pharn/pharn-pipeline/grillers/a11y/evals/cases/c.md'), + 'case', + ); + write(join(repo, 'pharn/pharn-review/n-plus-one/n-plus-one.md'), 'npo'); + write(join(repo, '.claude/commands/pharn-plan.md'), 'plan'); + write(join(repo, '.claude/commands/pharn-dev-plan.md'), 'DEV'); + write(join(repo, '.claude/hooks/set-writes-scope.cjs'), 'hook'); + write(join(repo, '.claude/hooks/set-writes-scope.test.cjs'), 'HOOKTEST'); + write(join(repo, '.claude/settings.json'), '{"hooks":{}}'); + write(join(repo, 'pharn/CONSTITUTION.md'), 'C'); + write(join(repo, 'pharn/ARCHITECTURE.md'), 'A'); + write(join(repo, 'pharn/pharn-contracts/finding-shape.md'), 'fs'); + write(join(repo, 'pharn/floor/validate.mjs'), 'floor'); + write(join(repo, 'pharn/floor/validate.test.mjs'), 'FLOORTEST'); + // dev-only surfaces that stay at root and must NOT be installed under pharn: + write(join(repo, 'THREAT-MODEL.md'), 'T'); + write(join(repo, 'LIMITS.md'), 'L'); + } + + function run(): { repo: string; proj: string } { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + mkdirSync(proj, { recursive: true }); + scaffoldRepoPharn(repo); + const result = installCapabilities(repo, proj, selection()); + expect(result.layout).toBe('pharn'); + return { repo, proj }; + } + + it('detects and records layout: pharn', () => { + run(); // the assertion is inside run() + }); + + it('mirrors capabilities + fixed surfaces UNDER pharn/ (test files excluded)', () => { + const { proj } = run(); + expect( + existsSync(join(proj, 'pharn/pharn-pipeline/grillers/a11y/a11y.md')), + ).toBe(true); + expect( + existsSync( + join(proj, 'pharn/pharn-pipeline/grillers/a11y/evals/cases/c.md'), + ), + ).toBe(true); + expect( + existsSync(join(proj, 'pharn/pharn-review/n-plus-one/n-plus-one.md')), + ).toBe(true); + expect( + existsSync(join(proj, 'pharn/pharn-contracts/finding-shape.md')), + ).toBe(true); + expect(existsSync(join(proj, 'pharn/floor/validate.mjs'))).toBe(true); + expect(existsSync(join(proj, 'pharn/floor/validate.test.mjs'))).toBe(false); + expect(existsSync(join(proj, 'pharn/CONSTITUTION.md'))).toBe(true); + expect(existsSync(join(proj, 'pharn/ARCHITECTURE.md'))).toBe(true); + }); + + it('DROPS THREAT-MODEL/LIMITS and leaves nothing flat at the project root', () => { + const { proj } = run(); + expect(existsSync(join(proj, 'THREAT-MODEL.md'))).toBe(false); + expect(existsSync(join(proj, 'LIMITS.md'))).toBe(false); + // No flat-layout leakage at root: + expect(existsSync(join(proj, 'CONSTITUTION.md'))).toBe(false); + expect(existsSync(join(proj, 'pharn-contracts'))).toBe(false); + expect(existsSync(join(proj, 'pharn-pipeline'))).toBe(false); + expect(existsSync(join(proj, 'pharn-review'))).toBe(false); + expect(existsSync(join(proj, '.dev'))).toBe(false); + }); + + it('keeps .claude/* at root (layout-invariant), excluding pharn-dev + *.test', () => { + const { proj } = run(); + expect(existsSync(join(proj, '.claude/commands/pharn-plan.md'))).toBe(true); + expect(existsSync(join(proj, '.claude/commands/pharn-dev-plan.md'))).toBe( + false, + ); + expect(existsSync(join(proj, '.claude/hooks/set-writes-scope.cjs'))).toBe( + true, + ); + expect( + existsSync(join(proj, '.claude/hooks/set-writes-scope.test.cjs')), + ).toBe(false); + expect(existsSync(join(proj, '.claude/settings.json'))).toBe(true); + }); +}); diff --git a/tests/layout.test.ts b/tests/layout.test.ts new file mode 100644 index 0000000..1aa21da --- /dev/null +++ b/tests/layout.test.ts @@ -0,0 +1,82 @@ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { useTmpDir } from './helpers.js'; +import { configLayout, detectLayout, layoutPaths } from '../src/lib/layout.js'; +import type { PharnConfig } from '../src/types.js'; + +describe('detectLayout', () => { + const tmp = useTmpDir(); + + it('returns pharn when the clone has the pharn/pharn-contracts marker', () => { + const root = tmp.path(); + mkdirSync(join(root, 'pharn/pharn-contracts'), { recursive: true }); + expect(detectLayout(root)).toBe('pharn'); + }); + + it('returns flat when contracts live at the root (the legacy layout)', () => { + const root = tmp.path(); + mkdirSync(join(root, 'pharn-contracts'), { recursive: true }); + expect(detectLayout(root)).toBe('flat'); + }); + + it('returns flat for a bare pharn/ dir without the marker (no false positive)', () => { + const root = tmp.path(); + mkdirSync(join(root, 'pharn'), { recursive: true }); + expect(detectLayout(root)).toBe('flat'); + }); + + it('returns flat for an empty dir (safe legacy default)', () => { + expect(detectLayout(tmp.path())).toBe('flat'); + }); +}); + +describe('layoutPaths', () => { + it('flat resolves to the root paths incl. all 4 trusted docs', () => { + const p = layoutPaths('flat'); + expect(p.layout).toBe('flat'); + expect(p.grillers).toBe('pharn-pipeline/grillers'); + expect(p.lenses).toBe('pharn-review'); + expect(p.contracts).toBe('pharn-contracts'); + expect(p.floor).toBe('.dev/floor'); + expect(p.docs).toEqual([ + 'CONSTITUTION.md', + 'ARCHITECTURE.md', + 'THREAT-MODEL.md', + 'LIMITS.md', + ]); + }); + + it('pharn resolves under pharn/ and drops THREAT-MODEL/LIMITS from the docs set', () => { + const p = layoutPaths('pharn'); + expect(p.layout).toBe('pharn'); + expect(p.grillers).toBe('pharn/pharn-pipeline/grillers'); + expect(p.lenses).toBe('pharn/pharn-review'); + expect(p.contracts).toBe('pharn/pharn-contracts'); + expect(p.floor).toBe('pharn/floor'); + expect(p.docs).toEqual(['pharn/CONSTITUTION.md', 'pharn/ARCHITECTURE.md']); + }); +}); + +describe('configLayout', () => { + const base: PharnConfig = { + pharnVersion: '0', + skillsVersion: '0', + repo: 'x', + commit: null, + modules: [], + installedAt: 'now', + }; + + it('reads pharn when recorded', () => { + expect(configLayout({ ...base, layout: 'pharn' })).toBe('pharn'); + }); + + it('reads flat when recorded', () => { + expect(configLayout({ ...base, layout: 'flat' })).toBe('flat'); + }); + + it('defaults to flat when absent (legacy config, P7)', () => { + expect(configLayout(base)).toBe('flat'); + }); +}); diff --git a/tests/pharn-config.test.ts b/tests/pharn-config.test.ts index e472766..d8bbc44 100644 --- a/tests/pharn-config.test.ts +++ b/tests/pharn-config.test.ts @@ -45,6 +45,31 @@ describe('pharn-config', () => { expect(readPharnConfig(tmp.path())).toEqual(sample); }); + it('round-trips the additive layout field (pharn)', async () => { + const withLayout: PharnConfig = { ...sample, layout: 'pharn' }; + await writePharnConfig(tmp.path(), withLayout); + expect(readPharnConfig(tmp.path())).toEqual(withLayout); + }); + + it('loads a legacy config with NO layout field (P7 additive)', () => { + writeFileSync( + join(tmp.path(), 'pharn.config.json'), + JSON.stringify(sample), + ); + const loaded = readPharnConfig(tmp.path()); + expect(loaded).toEqual(sample); + expect(loaded && 'layout' in loaded).toBe(false); + }); + + it('drops a garbage layout value on read (→ flat downstream, P5)', () => { + writeFileSync( + join(tmp.path(), 'pharn.config.json'), + JSON.stringify({ ...sample, layout: 'sideways' }), + ); + const loaded = readPharnConfig(tmp.path()); + expect(loaded && 'layout' in loaded).toBe(false); + }); + it('returns null when no config exists', () => { expect(readPharnConfig(tmp.path())).toBeNull(); }); diff --git a/tests/remove.test.ts b/tests/remove.test.ts index b4b8292..94489e0 100644 --- a/tests/remove.test.ts +++ b/tests/remove.test.ts @@ -154,6 +154,46 @@ describe('runRemove', () => { expect(fetchRepo).not.toHaveBeenCalled(); }); + // §A2 — capability removal (archetype install; no clone, no network) -------- + + it('removes a capability from a FLAT install (deletes pharn-review/)', async () => { + write(join(proj, 'pharn-review', 'n-plus-one', 'n-plus-one.md'), 'npo'); + loadConfigOrExit.mockReturnValue( + config([], { + archetypes: ['ssr'], + capabilities: [{ name: 'n-plus-one', role: 'lens' }], + }), + ); + + await runRemove('lens:n-plus-one'); + + expect(existsSync(join(proj, 'pharn-review', 'n-plus-one'))).toBe(false); + expect(fetchRepo).not.toHaveBeenCalled(); + expect(lastWritten().capabilities).toEqual([]); + }); + + it('removes a capability from a PHARN-layout install (deletes pharn/pharn-review/)', async () => { + write( + join(proj, 'pharn', 'pharn-review', 'n-plus-one', 'n-plus-one.md'), + 'npo', + ); + loadConfigOrExit.mockReturnValue( + config([], { + archetypes: ['ssr'], + capabilities: [{ name: 'n-plus-one', role: 'lens' }], + layout: 'pharn', + }), + ); + + await runRemove('lens:n-plus-one'); + + expect(existsSync(join(proj, 'pharn', 'pharn-review', 'n-plus-one'))).toBe( + false, + ); + expect(fetchRepo).not.toHaveBeenCalled(); + expect(lastWritten().capabilities).toEqual([]); + }); + // §A — category:skill removal (no clone, no network) ----------------------- it('removes an installed skill and leaves siblings + config fields intact', async () => { diff --git a/tests/status.test.ts b/tests/status.test.ts index 1a3140e..b81732f 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -243,13 +243,41 @@ describe('runStatus (archetype)', () => { expect(readSkillsVersion).toHaveBeenCalledWith('/repo'); expect(diffInstalledCapabilities).toHaveBeenCalledWith( - expect.objectContaining({ repoDir: '/repo', projectRoot: '/proj' }), + expect.objectContaining({ + repoDir: '/repo', + projectRoot: '/proj', + // No recorded layout → flat, the safe default (P7). + layout: 'flat', + }), ); // The legacy module-diff path is not taken for an archetype config. expect(diffInstalled).not.toHaveBeenCalled(); expect(cleanup).toHaveBeenCalled(); }); + it('passes the recorded layout: pharn through to the capability diff', async () => { + loadConfigOrExit.mockReturnValue( + config({ + skillsVersion: '1.0.0', + modules: [], + archetypes: ['ssr'], + capabilities: [{ name: 'a11y', role: 'griller' }], + layout: 'pharn', + }), + ); + const cleanup = vi.fn(); + fetchRepo.mockResolvedValue({ dir: '/repo', cleanup }); + readSkillsVersion.mockReturnValue('1.0.0'); + diffInstalledCapabilities.mockReturnValue(CLEAN); + + await runStatus({}); + + expect(diffInstalledCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ layout: 'pharn' }), + ); + expect(cleanup).toHaveBeenCalled(); + }); + it('--strict exits 1 on capability drift, cleaning up first', async () => { loadConfigOrExit.mockReturnValue(archConfig()); const cleanup = vi.fn();