@junghanacs/entwurf 0.34.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,111 @@ All notable changes to this project will be documented here. Format follows [Kee
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.34.1 - 2026-10-09
8
+
9
+ ### Release coordination
10
+
11
+ - **A patch cut for #135.** Coordinated by the pi-durable citizen `20261008T113104-801621` and
12
+ implemented by Claude Code Opus as sole writer, under the maintainer's grant through prepare and
13
+ make; npm publication is not part of that grant. This section is written at prepare time, before
14
+ the release gates run: the `check:full`, package-consumer and `release-gate --cut` results are
15
+ recorded in the operator handoff and the GitHub release, not claimed here. Their raw logs stay
16
+ under the ignored `.tmp-verify/135-install-ownership/writer/release-0341/{p4,p6,p5}/`.
17
+ - **The published 0.34.0 does not carry these fixes.** Its registry bytes stay as accepted.
18
+
19
+ ### Herdr plugin
20
+
21
+ - **Component owners are asked before anything is fetched (#135).** The plugin build now runs the
22
+ selected components' read-only owner preflights — Pi package, Pi provider, and the Claude
23
+ meta-bridge plan — before runtime acquisition, on the same Pi state paths the activation reads. A
24
+ foreign Pi owner refuses with no runtime, journal, cache or ledger written, and the refusal says
25
+ to release the registration from the root that owns it (its own `remove-user-scope`): a takeover
26
+ from the plugin's root is not a route. The check is planned against the physical prospective
27
+ runtime root; a landed package whose name or root differs from that plan refuses as
28
+ `herdr-build-artifact-drifted`. The installed activation keeps its own preflight recheck.
29
+ - **`herdr-plugin-deactivate` without a ledger no longer reports success over leftovers.** With no
30
+ certified activation ledger it exits 0 only when nothing stands at the plugin's journal, runtime
31
+ and cache addresses; otherwise it names what it found and exits non-zero
32
+ (`deactivate-unattributed-leftovers`), removing nothing.
33
+ - **Only a regular file or nothing may stand at the activation ledger address.** A symlink,
34
+ dangling or not, or a directory at `activation.json` refuses as `activation-ledger-uncertified`.
35
+ - **Limits that stay.** The Claude install state records the last installer and is not root-bound.
36
+ Roots changed between runs, or a host changing between the early owner check and the installed
37
+ recheck, can still refuse after the download. The no-ledger deactivate reports leftovers and
38
+ neither reclaims nor reuses them. No wider Claude-root or ownership guarantee is added.
39
+ `docs/setup-clean-host.md` §1.1 names the four installation paths and these limits.
40
+ - **The runtime lock rides the `herdr-checkout` verification carrier for this window.**
41
+ `plugins/herdr/runtime-lock.json` returns from npm 0.34.0 to the closed `herdr-checkout` source,
42
+ so the lock stays coherent with the 0.34.1 package version without inventing a registry
43
+ integrity. The plugin manifest stays 0.34.0, and this is not a plugin pair: the npm re-pin to
44
+ 0.34.1 follows publication with its measured sha512 and its own source proof. Moving a host
45
+ between the published npm 0.34.0 plugin and this carrier, in either direction, needs
46
+ `herdr-plugin-deactivate` first.
47
+
48
+ ### Repository hooks
49
+
50
+ - **`prepare` no longer runs husky.** It only builds the bridge. The local
51
+ `core.hooksPath=.husky/_` override that shadowed the configured global guard was removed on this
52
+ checkout, and `.husky/pre-commit` (whitespace, lint, typecheck) is reached through a global guard
53
+ that chains to repository-local hooks. Another existing clone may keep its local value until it
54
+ is unset there (unmeasured). On this checkout a real `npm pack` inside `check-pack-install` left
55
+ `.git/config` and `.husky/_` unchanged. The `husky` devDependency is unchanged.
56
+
57
+ ## v2026.10.9-herdr.0.34.0 — Herdr Entwurf plugin 0.34.0
58
+
59
+ A Herdr plugin snapshot, not a core release: no package version, npm artifact or core tag moves.
60
+
61
+ ### Herdr plugin
62
+
63
+ - **Runtime re-pinned to the published 0.34.0 (#132 B).** `plugins/herdr/runtime-lock.json`
64
+ returns from the `herdr-checkout` verification carrier the 0.30.1 preparation rode to the npm
65
+ production source: `@junghanacs/entwurf@0.34.0` with the registry's measured sha512
66
+ (`sha512-rUpQ…/uJ/fw==`), which is the artifact accepted and published as 0.34.0. 0.31–0.33
67
+ were never pinned here. The manifest version moves 0.30.1 → 0.34.0; `min_herdr_version` stays
68
+ 0.9.0 and the measured Herdr supply stays 0.9.3.
69
+ - **Upgrading from a 0.30.1 plugin install needs an explicit deactivate.** That install recorded a
70
+ `herdr-checkout` runtime and ledger, and npm is a different acquisition source, which is refused
71
+ rather than inherited (`activation-artifact-source-drifted`). Run `entwurf
72
+ herdr-plugin-deactivate`, then `herdr plugin install` again. A host with no plugin installation
73
+ starts fresh.
74
+ - **The release skill's core baseline ignores Herdr snapshot tags.** `prepare` P1 took the newest
75
+ tag by version sort, and a `vYYYY.M.D-herdr.*` tag sorts above every `v0.x`, so it would have
76
+ become the next core audit baseline. P1 now excludes exactly that shape; a core `v20.1.0` is
77
+ still picked.
78
+ - **Plugin docs corrected before the tag (GLG's final review).**
79
+ - The direct route opens six fresh-call backends, `pi-durable` among them as the managed launcher.
80
+ The plugin's Herdr pair stays `{pi, claude-code}`, and the manifest now says it does not widen
81
+ with the core list.
82
+ - `smoke-herdr-plugin-build-live` is described as the checkout-carrier journey it is, not as
83
+ registry evidence.
84
+ - `herdr-plugin-deactivate` is given by its absolute runtime path, because the plugin puts nothing
85
+ on `PATH`.
86
+ - The plugin tag namespace is explained.
87
+ - A v1 record is now described as refused by the current inverse as well. The launch-rail doc
88
+ separates the `0.9.0` admission floor from the single Herdr version the gates certify (the
89
+ supply pin, 0.9.3).
90
+
91
+ ### Verification
92
+
93
+ - **Real-npm swap and torn-swap recovery, measured before commit.** This is the row the 0.30.0
94
+ pin left open. A one-shot driver ran the registry-shipped `scripts/herdr-runtime.mjs`, whose
95
+ sha256 equals this checkout's, against the real registry:
96
+ - A 0.33.0 → 0.34.0 swap.
97
+ - The same upgrade torn between its two renames (`torn-swap-with-candidate`), then resumed.
98
+ The last-good 0.33.0 tree was restored before 0.34.0 was acquired again.
99
+ - Both ended `runtime-ready` with observed digest
100
+ `sha256-e9d995ff3858ffca40fa12b4fce2a6db3802da3ee27c7b233142fc4c71f23450`, the accepted
101
+ artifact. Neither left a staging or backup directory behind.
102
+ - Each 0.34.0 runtime passed the installed-runtime check: three executable bins, the compiled
103
+ entry, and `check-bridge` with its exact eight verbs. The owned inverse removed cache,
104
+ runtime and journal.
105
+ - Two earlier runs stopped on defects in the driver's own verb parser, not on product behaviour.
106
+ - The focused Herdr gates (`check-herdr-plugin`, `-plugin-profile`, `-plugin-build`,
107
+ `-runtime-bootstrap`, `-activation`) and `check-gate-manifests` are green on this tree. No gate,
108
+ mutant or matrix changed, so no qualification PARTIAL is owed.
109
+ - The public-ref first-user path (`smoke-herdr-raw-install-live`) and the exact-SHA CI run on the
110
+ pushed commit. Their receipts are recorded in the GitHub release and #132, not claimed here.
111
+
7
112
  ## 0.34.0 - 2026-10-09
8
113
 
9
114
  ### Release coordination
package/CONTRIBUTING.md CHANGED
@@ -30,7 +30,7 @@ pnpm check # everyday core (prints wall time; <=60s on the referenc
30
30
  pnpm run check:full # full deterministic floor — the required PR gate
31
31
  ```
32
32
 
33
- The deterministic floor is tiered (#70). `pnpm check` is the everyday core (biome, tsc, the vitest lanes, and the fast contract gates); `pnpm run check:full` adds the hermetic-integration and package/install tiers — including `check-gate-manifests`, the qualification HEAD (runner self-test, manifest-set validation, declared lane inventory; zero COMMITTED mutants executed — the self-test runs only its synthetic fixture mutants in tmp fixture repos — ~20s), but not the separately scheduled mutant-EXECUTING body `check-gate-qualification`, which CI runs on a branch push whose diff touched the qualification surface (and unconditionally on dispatch or the weekly schedule) and a gate-changing PR must run once itself. Exact membership is the named `check:*` scripts in `package.json`. Run `check:full` once on your frozen commit candidate — the pre-commit hook (`.husky/pre-commit`) carries only fast static checks (whitespace, lint, typecheck), not the full floor, so a green `pnpm run check:full` before commit is the evidence that your change holds (scheduling contract: AGENTS.md "Verification scheduling").
33
+ The deterministic floor is tiered (#70). `pnpm check` is the everyday core (biome, tsc, the vitest lanes, and the fast contract gates); `pnpm run check:full` adds the hermetic-integration and package/install tiers — including `check-gate-manifests`, the qualification HEAD (runner self-test, manifest-set validation, declared lane inventory; zero COMMITTED mutants executed — the self-test runs only its synthetic fixture mutants in tmp fixture repos — ~20s), but not the separately scheduled mutant-EXECUTING body `check-gate-qualification`, which CI runs on a branch push whose diff touched the qualification surface (and unconditionally on dispatch or the weekly schedule) and a gate-changing PR must run once itself. Exact membership is the named `check:*` scripts in `package.json`. Run `check:full` once on your frozen commit candidate — the pre-commit hook (`.husky/pre-commit`) carries only fast static checks (whitespace, lint, typecheck), not the full floor, so a green `pnpm run check:full` before commit is the evidence that your change holds (scheduling contract: AGENTS.md "Verification scheduling"). No install or build lifecycle script installs git hooks: a global `core.hooksPath` guard that chains to repository-local hooks runs its own checks, then delegates — to `.husky/pre-commit` here, unless an executable `.git/hooks/pre-commit` takes precedence.
34
34
 
35
35
  For changes that touch backend launch, session lifecycle, or `_meta` shape, also run
36
36
  the live ACP smokes that cover the touched rail — at minimum:
package/VERIFY.md CHANGED
@@ -96,7 +96,7 @@ that moved out of `scripts/` and into a file beside its subject would otherwise
96
96
 
97
97
  > **The herdr rail is an on-demand axis until it has run.** `smoke-herdr-fresh-call-live` opens a real caller inside a real herdr pane, has it invoke the PUBLIC `entwurf_fresh_call`, and reads the child's nonce callback — the one axis no deterministic gate reaches, since the rail is selected from the caller's own process environment. **The release aggregate does not yet require the herdr rail**: herdr is optional (a cut on a host without it must not block), and first evidence precedes promotion — a rail whose first acceptance run is days old has not earned a place in the gate that blocks every cut. Run it directly with `LIVE=1 ./run.sh smoke-herdr-fresh-call-live` whenever a cut changes herdr rail code, and promote it to a MUST only after the axis has a run history to point at. Without `LIVE=1`, or without herdr/pi/claude on PATH, it reports protocol SKIP — never a pass.
98
98
 
99
- > **The plugin's FIRST USER PATH is its own on-demand axis, and an npm acquisition receipt holds only for the ref it ran on (#118 홉 1).** Every other herdr gate proves the plugin against a source this repository controls: `check-herdr-plugin-build` drives a stub, and `smoke-herdr-plugin-build-live` redirects the product remote to a local bare clone with git `insteadOf` and asserts `identity.kind === "herdr-checkout"`. npm has been the production authority class since `dd84ac0`; the 0.30.0 follow-up re-pinned `@junghanacs/entwurf@0.30.0` with its published sha512 after the candidate windows rode `herdr-checkout` (`8b92e09`). The **unpublished 0.30.1 preparation** now uses that closed verification carrier again, without inventing a registry integrity. The source re-proof has separate dated npm rows: 2026-09-23 public `main=113ca53`, plugin 0.5.0, runtime 0.25.0 (CHANGELOG 0.25.1), and the 0.30.0 re-pin accepted at public `506c46f` in the [#126 thread](https://github.com/junghan0611/entwurf/issues/126). Neither certifies a future 0.30.1 registry acquisition. After publication and a measured re-pin, that source owes its own public-ref run; checkout candidate evidence does not transfer. `LIVE=1 ./run.sh smoke-herdr-raw-install-live [--ref <REF>]` fills that row in a clean `node:24` container with no socket, config, cache or repo mounted, no `insteadOf`, the public remote spelled as a user would type it, and pi / herdr / Claude Code installed in the IMAGE BUILD as scaffolding (Hard Rule 17 — the product face begins at `herdr integration install pi`). herdr itself comes from `scripts/install-herdr-ci.sh` against `scripts/fixtures/herdr-supply.json`, so no version or digest lives here. `--ref` defaults to `main` rather than a pinned SHA because receipt (a) is defined as what a user gets today; the resolved commit is printed, so the receipt stays exact. It takes a REMOTE REF and not an arbitrary commit — measured on herdr 0.9.1, `--ref <sha>` fails as `couldn't find remote ref`, because the checkout fetches by name — so a candidate is addressed by its branch and pinned by the resolved commit the gate prints. **Its first question is a measurement, not an assumption:** herdr registers a plugin through a running server and falls back to `persist_plugin_offline` only on a connection error (`src/cli/plugin.rs:914-981,1016-1021` @ `c77af189`, 0.9.0 source), and a container has no server. The 2026-09-23 PASS installed with no server on the 0.9.1 binary `113ca53`'s `herdr-supply.json` pins, so that install path worked on 0.9.1 — inferred from the PASS, not a direct observation of the fallback branch; a later "no" on another herdr is #118 investigation output rather than a completion. `--yes` is always passed so a non-interactive exit 2 (`:189`) can never be misread as a missing server. **At a ref whose lock names `herdr-checkout` the acquisition axis reports a NAMED SKIP rather than a red, and the rest of the journey still runs.** That lock is the candidate carrier a cut legitimately sits on — 0.23.0 was tagged that way and `dd84ac0` pinned npm afterwards — so a red there would report a broken install when what is true is that the ref does not lock npm. What is asserted at BOTH lock kinds is that the runtime came from the source the ref locks, and the package name is read from the checkout manifest rather than the lock, because a `herdr-checkout` lock carries no `name` (`herdr-runtime.mjs:317-328`) and joining `undefined` throws instead of failing an assertion. **Cells [6]–[9] carry it past the install into the USE path (#118 H1-4/H1-5).** An install is not a use, so the same container then runs what those bytes became: the compiled bridge entry, the three `REQUIRED_BINS` as executables, and a real `entwurf check-bridge` printing its exact eight-verb set — the installed-runtime row below, measured rather than inferred from the bootstrap's refusal to install without them. Then the pi that this plugin WIRED is started with no `-e` and no `--no-extensions`, because the subject is the wiring and not this checkout: one `get_state` in `--mode rpc` costs zero tokens and needs no provider argument (a bogus `--provider` exits 1 before session_start; omitting the pair lets pi resolve the default the extension itself registers), and while that session is alive the cell reads its V3 record, its gardenId-keyed control socket, and the installed `entwurf peer-facts` reporting it `alive`. The plugin's own status fan is then driven with NEITHER side stubbed, in both states a user can be in: with no herdr server it must name `herdr-agent-list-failed` and go red rather than draw an empty table, and under a headless `herdr server` — scaffolding for that second read only, stopped again before the teardown — it exits 0 and COUNTS the citizen as `unobserved` instead of dropping it. Last, the shipped `entwurf herdr-plugin-deactivate` runs from the installed bin, deletes the runtime it is executing from, and the plugin reinstalls onto the host it fully left — the deactivation row, on the npm source. **What these cells still do not close:** swap / torn-swap recovery on the npm source, the package-consumer proof, and the fan drawing a citizen as a ROW — that needs `placement.kind === "herdr-pane"`, so the pi session must live in a herdr pane a session reference joins, and a headless container has no panes. **This smoke carries no test beside behavior and no mutant**, and that is a property of its class rather than an omission: it is a real-live container journey whose oracle is the container's own end state, so there is no pure seam to assert against and nothing a source mutation could kill deterministically. Like the other two herdr LIVE lanes it is **on-demand and not an aggregate MUST** — herdr is optional, a cut on a host without it must not block, and first evidence precedes promotion. Without `LIVE=1`, or without a usable Docker daemon, it reports a named SKIP; `ENTWURF_REQUIRE_DOCKER=1` turns the Docker absence red. Neither skip is a pass.
99
+ > **The plugin's FIRST USER PATH is its own on-demand axis, and an npm acquisition receipt holds only for the ref it ran on (#118 홉 1).** Every other herdr gate proves the plugin against a source this repository controls: `check-herdr-plugin-build` drives a stub, and `smoke-herdr-plugin-build-live` redirects the product remote to a local bare clone with git `insteadOf` and asserts `identity.kind === "herdr-checkout"`. npm has been the production authority class since `dd84ac0`; the 0.30.0 follow-up re-pinned `@junghanacs/entwurf@0.30.0` with its published sha512 after the candidate windows rode `herdr-checkout` (`8b92e09`), and the 0.30.1 preparation rode that closed verification carrier again without inventing a registry integrity. The last published plugin snapshot `v2026.10.9-herdr.0.34.0` pins the published `@junghanacs/entwurf@0.34.0` with its measured sha512 (#132); 0.31–0.33 were never pinned. This checkout's unpublished 0.34.1 preparation rides the closed `herdr-checkout` verification carrier again, without inventing a registry integrity: it has no published npm SRI and is not a plugin pair. The source re-proof has separate dated npm rows: 2026-09-23 public `main=113ca53`, plugin 0.5.0, runtime 0.25.0 (CHANGELOG 0.25.1), and the 0.30.0 re-pin accepted at public `506c46f` in the [#126 thread](https://github.com/junghan0611/entwurf/issues/126). Neither certifies the 0.34.0 registry acquisition. That pin's real-npm swap / torn-swap recovery was measured locally on 2026-10-09 by driving the registry-shipped `scripts/herdr-runtime.mjs` (byte-equal to this checkout's) with its own npm acquisition: a 0.33.0 → 0.34.0 swap, and the same upgrade torn between its two renames then resumed, where the last-good 0.33.0 tree was restored before 0.34.0 was acquired again. Both ended `runtime-ready` at observed digest `sha256-e9d995ff…`, the accepted 0.34.0 artifact. That local receipt is recorded in the [#132 thread](https://github.com/junghan0611/entwurf/issues/132); it covers runtime recovery, not this smoke's cells. The public-ref acceptance of this smoke is PENDING the prepared push, and its actual result will be recorded there; checkout candidate evidence does not transfer. `LIVE=1 ./run.sh smoke-herdr-raw-install-live [--ref <REF>]` fills that row in a clean `node:24` container with no socket, config, cache or repo mounted, no `insteadOf`, the public remote spelled as a user would type it, and pi / herdr / Claude Code installed in the IMAGE BUILD as scaffolding (Hard Rule 17 — the product face begins at `herdr integration install pi`). herdr itself comes from `scripts/install-herdr-ci.sh` against `scripts/fixtures/herdr-supply.json`, so no version or digest lives here. `--ref` defaults to `main` rather than a pinned SHA because receipt (a) is defined as what a user gets today; the resolved commit is printed, so the receipt stays exact. It takes a REMOTE REF and not an arbitrary commit — measured on herdr 0.9.1, `--ref <sha>` fails as `couldn't find remote ref`, because the checkout fetches by name — so a candidate is addressed by its branch and pinned by the resolved commit the gate prints. **Its first question is a measurement, not an assumption:** herdr registers a plugin through a running server and falls back to `persist_plugin_offline` only on a connection error (`src/cli/plugin.rs:914-981,1016-1021` @ `c77af189`, 0.9.0 source), and a container has no server. The 2026-09-23 PASS installed with no server on the 0.9.1 binary `113ca53`'s `herdr-supply.json` pins, so that install path worked on 0.9.1 — inferred from the PASS, not a direct observation of the fallback branch; a later "no" on another herdr is #118 investigation output rather than a completion. `--yes` is always passed so a non-interactive exit 2 (`:189`) can never be misread as a missing server. **At a ref whose lock names `herdr-checkout` the acquisition axis reports a NAMED SKIP rather than a red, and the rest of the journey still runs.** That lock is the candidate carrier a cut legitimately sits on — 0.23.0 was tagged that way and `dd84ac0` pinned npm afterwards — so a red there would report a broken install when what is true is that the ref does not lock npm. What is asserted at BOTH lock kinds is that the runtime came from the source the ref locks, and the package name is read from the checkout manifest rather than the lock, because a `herdr-checkout` lock carries no `name` (`herdr-runtime.mjs:317-328`) and joining `undefined` throws instead of failing an assertion. **Cells [6]–[9] carry it past the install into the USE path (#118 H1-4/H1-5).** An install is not a use, so the same container then runs what those bytes became: the compiled bridge entry, the three `REQUIRED_BINS` as executables, and a real `entwurf check-bridge` printing its exact eight-verb set — the installed-runtime row below, measured rather than inferred from the bootstrap's refusal to install without them. Then the pi that this plugin WIRED is started with no `-e` and no `--no-extensions`, because the subject is the wiring and not this checkout: one `get_state` in `--mode rpc` costs zero tokens and needs no provider argument (a bogus `--provider` exits 1 before session_start; omitting the pair lets pi resolve the default the extension itself registers), and while that session is alive the cell reads its V3 record, its gardenId-keyed control socket, and the installed `entwurf peer-facts` reporting it `alive`. The plugin's own status fan is then driven with NEITHER side stubbed, in both states a user can be in: with no herdr server it must name `herdr-agent-list-failed` and go red rather than draw an empty table, and under a headless `herdr server` — scaffolding for that second read only, stopped again before the teardown — it exits 0 and COUNTS the citizen as `unobserved` instead of dropping it. Last, the shipped `entwurf herdr-plugin-deactivate` runs from the installed bin, deletes the runtime it is executing from, and the plugin reinstalls onto the host it fully left — the deactivation row, on the npm source. **What these cells still do not close:** swap / torn-swap recovery on the npm source, the package-consumer proof, and the fan drawing a citizen as a ROW — that needs `placement.kind === "herdr-pane"`, so the pi session must live in a herdr pane a session reference joins, and a headless container has no panes. **This smoke carries no test beside behavior and no mutant**, and that is a property of its class rather than an omission: it is a real-live container journey whose oracle is the container's own end state, so there is no pure seam to assert against and nothing a source mutation could kill deterministically. Like the other two herdr LIVE lanes it is **on-demand and not an aggregate MUST** — herdr is optional, a cut on a host without it must not block, and first evidence precedes promotion. Without `LIVE=1`, or without a usable Docker daemon, it reports a named SKIP; `ENTWURF_REQUIRE_DOCKER=1` turns the Docker absence red. Neither skip is a pass.
100
100
 
101
101
  > **The Herdr plugin's install-time build has two axes, and one of them needs the network (#116 M3-b3).** `check-herdr-plugin-build` is deterministic and rides `check:hermetic`: the listing reaches the runner through its own spawn seam (there is no fake `herdr` binary in this repo), and the one cell that drives the REAL binary reports a named SKIP on a host without it — `ENTWURF_REQUIRE_HERDR=1` makes that absence red. What it cannot have is the real acquisition: `npm pack git+https://github.com/junghan0611/entwurf.git#<full sha>` builds the bridge through `prepare`, which installs devDependencies from the registry. So `LIVE=1 ./run.sh smoke-herdr-plugin-build-live` owns that axis, and its cells are **two kinds of evidence, labelled as such**: three are the real Herdr journey — a real `herdr plugin install` driving the real `[[build]]`, with the UNCHANGED product remote redirected to a local bare clone by git `insteadOf` (the one substitution, which is what makes an unpushed candidate testable) — covering remote-commit available, the `{pi}` → `{pi, claude-code}` reinstall with OpenCode as a zero-write negative control and exactly ONE counted vendor MCP owner entry, and the post-build Herdr-commit gap (where the ledger has already rebound to the gap commit, so the honest assertion is that transition and not unchanged bytes). The fourth cell — remote-commit **unavailable** — calls the shipped runtime leaf directly with the fixed product argv and is therefore **acquisition-leaf evidence, not a journey**; routing it through Herdr would add a second explanation for one red. Its oracle is the honest one too: active runtime, ledger and harness bytes preserved, while the journal is left as the certified `installing` retry authority the transaction deliberately writes before acquiring, and a normal install afterwards is green. Its Claude cell is where the vendor question the deterministic gates refuse to claim gets answered — whether Claude Code accepts an ABSOLUTE executable as an MCP command — and it reports a named skip on a host without `claude`, never a pass. Like the herdr fresh-call rail, this smoke is **on-demand and not an aggregate MUST**. The release aggregate does not require the plugin build smoke: herdr is optional, a cut on a host without it must not block, and first evidence precedes promotion.
102
102
 
@@ -179,6 +179,24 @@ packageRoot↔installerRoot coupling mismatch and a package/provider managed-pat
179
179
  | source checkout | the checkout dir | `dev-bin` symlinks (`setup`) | `<project>/.pi/settings.json` | shared entry + owner state | `takeover-user-scope` from the new checkout | `remove` (project) / `remove-user-scope` (global, same-owner-only) | `doctor-pi-package` → `missing-owner`; normal install still refuses |
180
180
  | global npm (`npm i -g`) | the global `node_modules/@junghanacs/entwurf` | npm bin linking | same | same shared entry | same explicit action | same; a LIVE foreign owner always refuses | same |
181
181
  | project-local npm | that project's `node_modules/@junghanacs/entwurf` | `node_modules/.bin` | same | same shared entry | same explicit action | same | same; a deleted root becomes the aligned `remove-user-scope` orphan cleanup (entry + package state + provider installerRoot must all name that missing root) |
182
+ | Herdr plugin | `$XDG_DATA_HOME/entwurf/herdr-plugin/runtime/active/node_modules/@junghanacs/entwurf` | none on PATH — Pi provider and Claude MCP/statusLine record ABSOLUTE commands under that runtime | none | same shared entry, plus the plugin's activation ledger (`$XDG_STATE_HOME/entwurf/herdr-plugin/activation.json`) | none — the plugin never takes a registration over; release it from its owner first | the runtime's own absolute `…/node_modules/.bin/entwurf herdr-plugin-deactivate` | — |
183
+
184
+ The four installation paths keep separate records: Herdr's plugin registry (changed only by `herdr
185
+ plugin install` / `unlink` / `uninstall`), the plugin's runtime journal and activation ledger, the shared Pi
186
+ package/provider ownership above, and the Claude meta-bridge install state
187
+ (`${CLAUDE_CONFIG_DIR:-~/.claude}/entwurf.install-state.json`). Moving between them is explicit: release the
188
+ shared registration from its owner (`remove-user-scope`, plus `uninstall-meta-bridge` for Claude) before
189
+ `herdr plugin install` — a foreign Pi owner refuses the plugin build before anything is fetched (#135) — and
190
+ run the plugin's `herdr-plugin-deactivate` before going back to `setup`. Do not `takeover-user-scope` a
191
+ registration the plugin owns, even though a refused `setup` suggests it: it splits ownership (the plugin's
192
+ provider key becomes an unowned override while its ledger still records pi) and the plugin's deactivate
193
+ then refuses. Limits that stay as they are: the Claude install state records the LAST installer and is not
194
+ root-bound — an install from another root rewrites it rather than refusing; nothing covers roots (XDG,
195
+ `PI_CODING_AGENT_DIR`, Claude config) changed between runs or a host that changes between the plugin's
196
+ early owner check and its installed recheck, which can still refuse after the download; and the plugin's
197
+ deactivate, finding no activation ledger, reports what is left at its addresses and neither reclaims nor
198
+ reuses it. The no-ledger diagnostic is not in the published 0.34.0 runtime; a plugin host receives it only
199
+ from a later published runtime the plugin pins.
182
200
 
183
201
  ## 2. Optional pi adapter / ACP plugin
184
202
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.34.0",
3
+ "version": "0.34.1",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for pi, Claude Code, Copilot CLI, OMP, Antigravity, and Codex CLI; Claude and Snowflake Cortex Code ACP backends.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -119,7 +119,7 @@
119
119
  "vitest": "4.1.9"
120
120
  },
121
121
  "scripts": {
122
- "prepare": "husky 1>&2 || true; npm run --silent build-bridge 1>&2",
122
+ "prepare": "npm run --silent build-bridge 1>&2",
123
123
  "postinstall": "node scripts/postinstall-chmod.cjs",
124
124
  "build-bridge": "bash scripts/with-dist-lock.sh bash scripts/build-bridge.sh",
125
125
  "prepack": "pnpm run build-bridge 1>&2",
package/run.sh CHANGED
@@ -294,7 +294,7 @@ Usage:
294
294
  ./run.sh remove [project-dir] # remove entwurf entries from project .pi/settings.json (project scope only; global user-scope citizen left intact)
295
295
  ./run.sh install-user-scope [--plugin-runtime <root>] # #116 M3-b2 PLUGIN-ONLY forward seam: compose ONLY the global pi citizen (user-scope packages[] + provider), never a project `.pi/settings.json` — a Herdr plugin activation has no project and must not invent one. With --plugin-runtime the provider's managed command becomes the absolute bridge under that certified stable runtime (a clean host has nothing entwurf on PATH); without it every byte is the historical bare-bin install. Inverse: remove-user-scope
296
296
  ./run.sh herdr-plugin-activate <backend...> # #116 M3-b2 public forward activation. Takes a subset of {pi, claude-code} and NOTHING else — the stable runtime root is DERIVED from XDG here and handed down, and both harness writers accept a named root only when it IS that derived one. Certifies the runtime-ready journal AND the installed tree, certifies the existing ledger and its recorded harness roots, plans ADD-ONLY (a backend absent from this request is retained, never removed), preflights every selected component before the first byte, then writes each component `pending` BEFORE mutating and checkpoints `active` the moment its writer returns — so a run that dies halfway leaves a record of how far it got. Which backends is M3-b3's question: this verb is handed the answer, it does not read Herdr's integration status
297
- ./run.sh herdr-plugin-deactivate # #116 M3-b2 public teardown of a Herdr-plugin activation. Herdr's `plugin uninstall` deletes its checkout and calls NO cleanup hook, so the harness wiring and the stable runtime are retained, not cleaned — this is the shipped verb that takes them back. Certifies the activation ledger, the resolved Pi/Claude roots and BOTH component inverses read-only first; mutates only when every preflight is green; then Claude -> Pi user scope -> runtime -> ledger LAST. A component failure stops with the runtime and ledger intact as retry authority. Static imports only: the process deletes the runtime it is running from, and a lazy import after that fails
297
+ ./run.sh herdr-plugin-deactivate # #116 M3-b2 public teardown of a Herdr-plugin activation. Herdr's `plugin uninstall` deletes its checkout and calls NO cleanup hook, so the harness wiring and the stable runtime are retained, not cleaned — this is the shipped verb that takes them back. Certifies the activation ledger, the resolved Pi/Claude roots and BOTH component inverses read-only first; mutates only when every preflight is green; then Claude -> Pi user scope -> runtime -> ledger LAST. A component failure stops with the runtime and ledger intact as retry authority. With NO ledger it removes nothing: a clean host exits 0, and anything found at the plugin's journal/runtime/cache addresses is named with a non-zero exit (#135). Static imports only: the process deletes the runtime it is running from, and a lazy import after that fails
298
298
  ./run.sh remove-user-scope # explicit GLOBAL inverse of install's user-scope citizen: drop entwurf from ~/.pi/agent/settings.json packages[] (affects ALL cwds — shared entry, not per-project). #86 C2: same-owner-only — a LIVE foreign owner refuses; a MISSING owner is removed only when package entry + package state + provider installerRoot all align (reported orphan cleanup)
299
299
  ./run.sh takeover-user-scope # #86 C2: operator-EXPLICIT ownership move of the shared user-scope registration to THIS root (old→new reported, packages[] entry + provider installerRoot together). The only writer over another owner — normal install/setup/remove refuse instead. No --force exists
300
300
  ./run.sh doctor-pi-package # #86 C2: package-side user-scope ownership verdict — unregistered / owned / owned-by-other(live) / legacy-no-state / mismatch / missing-owner (nonzero on defect verdicts). Provider runtime verdicts stay with doctor-pi-provider
@@ -4393,8 +4393,8 @@ _check_pack_install_impl() {
4393
4393
 
4394
4394
  # Fresh-temp install smoke. Uses pnpm because that is what this
4395
4395
  # repo packages with; --ignore-workspace stops it from re-attaching
4396
- # to our pnpm-workspace.yaml; --ignore-scripts blocks the husky
4397
- # prepare hook (and any future install scripts) from running inside
4396
+ # to our pnpm-workspace.yaml; --ignore-scripts blocks
4397
+ # prepare (and any future install scripts) from running inside
4398
4398
  # the consumer project. The pi SDK is pinned to the CURRENT pi release
4399
4399
  # baseline (the package.json exact production dependency — the pins below
4400
4400
  # are what check-dep-versions binds to it) so the smoke matches the same
@@ -3471,8 +3471,8 @@ let manifestCount: number;
3471
3471
  "herdr-placement": 13,
3472
3472
  "herdr-plugin": 25,
3473
3473
  "herdr-plugin-profile": 14,
3474
- "herdr-activation": 25,
3475
- "herdr-plugin-build": 14,
3474
+ "herdr-activation": 28,
3475
+ "herdr-plugin-build": 20,
3476
3476
  "herdr-runtime-bootstrap": 31,
3477
3477
  "herdr-fresh-call": 38,
3478
3478
  "herdr-supply": 11,
@@ -27,6 +27,7 @@ import os from "node:os";
27
27
  import path from "node:path";
28
28
  import { pathToFileURL } from "node:url";
29
29
  import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
30
+ import { treeDigest } from "./lib/tree-digest.ts";
30
31
 
31
32
  let passed = 0;
32
33
  function ok(label: string, cond: boolean): void {
@@ -737,21 +738,12 @@ function ledgerWith(
737
738
  const dataRoot = resolveEntwurfDataRoot(bare);
738
739
  const states = piStatePaths(bare);
739
740
  const piExpected = path.join(home, ".local", "share", "entwurf");
740
- // The python writers derive the SAME stable root from the SAME environment.
741
- const pyRoot = spawnSync(
742
- "python3",
743
- [
744
- "-c",
745
- "import sys; sys.path.insert(0, 'scripts'); from importlib import import_module; m = import_module('register-pi-provider'.replace('-', '_')) if False else None",
746
- ],
747
- { encoding: "utf8", env: bare },
748
- );
749
741
  ok(
750
742
  "[QK:HAC-XDG-RESOLUTION-SHARED] every root comes from one resolution — `XDG_DATA_HOME`, else " +
751
743
  "`$HOME/.local/share` — so a host with XDG unset is the same host to the teardown as it is to the writers. " +
752
744
  'The first cut built its Pi state paths from `env.XDG_DATA_HOME || ""`, which on such a host pointed at ' +
753
745
  "`/entwurf/...`: a preflight that looked at a file nobody writes and passed for the wrong reason " +
754
- `(dataRoot=${dataRoot === piExpected} packageState=${path.relative(home, states.packageState)} py=${pyRoot.status === 0})`,
746
+ `(dataRoot=${dataRoot === piExpected} packageState=${path.relative(home, states.packageState)})`,
755
747
  dataRoot === piExpected &&
756
748
  states.packageState === path.join(piExpected, "pi-package", "install-state.json") &&
757
749
  states.providerState === path.join(piExpected, "pi-provider", "install-state.json"),
@@ -1364,4 +1356,132 @@ function ledgerWith(
1364
1356
  );
1365
1357
  }
1366
1358
 
1359
+ // ── 26. both Pi owner records are asked where their writers keep them (#135 D1) ─
1360
+ {
1361
+ const env = world("pi-state-paths");
1362
+ const defaultData = path.join(env.HOME as string, ".local", "share");
1363
+ // An EMPTY XDG_DATA_HOME: run.sh and the runtime owner both fall back to $HOME/.local/share.
1364
+ const runEnv = { ...env, XDG_DATA_HOME: "" };
1365
+ seedRuntime({ ...env, XDG_DATA_HOME: defaultData });
1366
+ const agentSettings = path.join(env.PI_CODING_AGENT_DIR as string, "settings.json");
1367
+ fs.writeFileSync(agentSettings, '{"theme":"dark"}\n');
1368
+ const foreign = path.join(env.HOME as string, "dev", "entwurf");
1369
+ fs.mkdirSync(foreign, { recursive: true });
1370
+ const seeded = py(env, "register-pi-provider.py", [
1371
+ "install",
1372
+ agentSettings,
1373
+ foreign,
1374
+ "--scope",
1375
+ "user",
1376
+ "--state",
1377
+ path.join(defaultData, "entwurf", "pi-provider", "install-state.json"),
1378
+ ]);
1379
+ assert.equal(seeded.status, 0, `fixture: seeding the provider owner failed: ${seeded.stderr}`);
1380
+ const layout = resolveActivationLayout(runEnv);
1381
+ const run = spawnSync("node", [ACTIVATE, "pi"], { encoding: "utf8", env: runEnv, cwd: env.HOME as string });
1382
+ ok(
1383
+ "[QK:HAC-PI-STATE-PATHS-SHARED] both Pi ownership preflights read the record where its writer keeps it: with " +
1384
+ "XDG_DATA_HOME set but EMPTY, run.sh writes to $HOME/.local/share, so the provider preflight must look there " +
1385
+ "too — the first cut built a RELATIVE path for the provider half alone, saw no owner, and let the ledger land " +
1386
+ `on a host the provider writer then refused (exit=${run.status} ledger=${fs.existsSync(layout.ledgerPath)} refusal=${JSON.stringify((run.stderr || "").trim().split("\n").pop())})`,
1387
+ run.status !== 0 &&
1388
+ (run.stderr || "").includes("activation-preflight-refused") &&
1389
+ (run.stderr || "").includes("pi-provider") &&
1390
+ !fs.existsSync(layout.ledgerPath),
1391
+ );
1392
+ }
1393
+
1394
+ // ── 27. no ledger is not "nothing here" (#135) ─────────────────────────────────
1395
+ {
1396
+ const plugin = (env: NodeJS.ProcessEnv) => path.join(env.XDG_DATA_HOME as string, "entwurf", "herdr-plugin");
1397
+ const rows: [string, (env: NodeJS.ProcessEnv) => void][] = [
1398
+ [
1399
+ "installing-journal",
1400
+ (env) => {
1401
+ fs.mkdirSync(path.join(plugin(env), "runtime"), { recursive: true });
1402
+ fs.writeFileSync(
1403
+ path.join(plugin(env), "journal.json"),
1404
+ `${JSON.stringify({
1405
+ schemaVersion: 2,
1406
+ phase: "installing",
1407
+ runtimeRoot: runtimeRootOf(env),
1408
+ artifactIdentity: { kind: "npm", name: PACKAGE, version: "0.21.0", expectedIntegrity: "sha512-fixture" },
1409
+ previousRuntime: null,
1410
+ })}\n`,
1411
+ );
1412
+ },
1413
+ ],
1414
+ ["ready-runtime", (env) => void seedRuntime(env)],
1415
+ [
1416
+ "cache-only",
1417
+ (env) =>
1418
+ fs.mkdirSync(path.join(env.XDG_CACHE_HOME as string, "entwurf", "herdr-plugin", "npm"), { recursive: true }),
1419
+ ],
1420
+ ["journal-less-runtime", (env) => fs.mkdirSync(path.join(plugin(env), "runtime", "active"), { recursive: true })],
1421
+ ];
1422
+ const results = rows.map(([tag, make]) => {
1423
+ const env = world(`no-ledger-${tag}`);
1424
+ make(env);
1425
+ const before = treeDigest(env.HOME as string);
1426
+ const run = spawnSync("node", [DEACTIVATE], { encoding: "utf8", env });
1427
+ return {
1428
+ tag,
1429
+ status: run.status,
1430
+ named: (run.stderr || "").includes("deactivate-unattributed-leftovers"),
1431
+ unchanged: before === treeDigest(env.HOME as string),
1432
+ };
1433
+ });
1434
+ const clean = world("no-ledger-clean");
1435
+ const cleanRun = spawnSync("node", [DEACTIVATE], { encoding: "utf8", env: clean });
1436
+ ok(
1437
+ "[QK:HAC-NO-LEDGER-LEFTOVERS-REPORTED] without a ledger the teardown no longer says 'nothing to undo' over what " +
1438
+ "an aborted install left: an installing journal, a ready runtime, our npm cache, or a runtime with no journal " +
1439
+ "are each NAMED and the verb exits non-zero with every byte untouched — nothing proves what they belong to, so " +
1440
+ "nothing is removed. A truly clean host still exits 0 with the old line " +
1441
+ `(rows=${JSON.stringify(results)} clean=${cleanRun.status}/${JSON.stringify((cleanRun.stdout || "").trim())})`,
1442
+ results.every((r) => r.status !== 0 && r.named && r.unchanged) &&
1443
+ cleanRun.status === 0 &&
1444
+ (cleanRun.stdout || "").includes("no certified activation ledger — nothing to undo"),
1445
+ );
1446
+ }
1447
+
1448
+ // ── 28. only a regular file — or nothing — may stand at the ledger address (#135) ─
1449
+ {
1450
+ const env = world("ledger-kinds");
1451
+ seedRuntime(env);
1452
+ const layout = resolveActivationLayout(env);
1453
+ fs.mkdirSync(layout.stateRoot, { recursive: true });
1454
+ // a DANGLING symlink: readFileSync used to answer ENOENT, i.e. "no ledger".
1455
+ fs.symlinkSync(path.join(env.HOME as string, "nowhere.json"), layout.ledgerPath);
1456
+ const dangling = refusal(() => readCertifiedLedger(layout));
1457
+ const danglingRun = spawnSync("node", [DEACTIVATE], { encoding: "utf8", env });
1458
+ fs.unlinkSync(layout.ledgerPath);
1459
+ // a symlink to a perfectly valid ledger is still not OUR ledger file.
1460
+ const elsewhere = path.join(env.HOME as string, "ledger-elsewhere.json");
1461
+ fs.writeFileSync(elsewhere, `${JSON.stringify({ schemaVersion: 2, ...ledgerFor(env, ["pi"]) }, null, 2)}\n`);
1462
+ fs.symlinkSync(elsewhere, layout.ledgerPath);
1463
+ const linked = refusal(() => readCertifiedLedger(layout));
1464
+ fs.unlinkSync(layout.ledgerPath);
1465
+ fs.mkdirSync(layout.ledgerPath);
1466
+ const directory = refusal(() => readCertifiedLedger(layout));
1467
+ // a state root that is a FILE: lstat answers ENOTDIR, which must stay a named refusal, not a raw errno.
1468
+ const fileRoot = path.join(env.HOME as string, "state-root-is-a-file");
1469
+ fs.writeFileSync(fileRoot, "not a directory\n");
1470
+ const notDir = refusal(() =>
1471
+ readCertifiedLedger({ stateRoot: fileRoot, ledgerPath: path.join(fileRoot, "activation.json") }),
1472
+ );
1473
+ ok(
1474
+ "[QK:HAC-LEDGER-PATH-KIND] the ledger address is read by lstat first: a dangling symlink, a symlink to a valid " +
1475
+ "ledger elsewhere, a directory, and a state root that is a file are each refused by name — none of them is " +
1476
+ "'no ledger', which is what a dangling link used to read as, letting every verb act as if nothing had been " +
1477
+ `activated (dangling=${dangling}/teardown-exit=${danglingRun.status} linked=${linked} directory=${directory} not-dir=${notDir})`,
1478
+ dangling === "activation-ledger-uncertified" &&
1479
+ danglingRun.status !== 0 &&
1480
+ (danglingRun.stderr || "").includes("activation-ledger-uncertified") &&
1481
+ linked === "activation-ledger-uncertified" &&
1482
+ directory === "activation-ledger-uncertified" &&
1483
+ notDir === "activation-ledger-uncertified",
1484
+ );
1485
+ }
1486
+
1367
1487
  console.log(`\ncheck-herdr-activation: ${passed} assertions passed`);
@@ -187,12 +187,18 @@ function tree(root: string): string[] {
187
187
  * installed package's activation verb is — absent, incapable (exits 0 on an empty request), or a
188
188
  * faithful recorder that refuses an empty request by name and logs the argv it was given.
189
189
  */
190
- function fixtureAcquire(opts: { activate: "absent" | "incapable" | "recorder"; log?: string; fail?: boolean }) {
190
+ function fixtureAcquire(opts: {
191
+ activate: "absent" | "incapable" | "recorder";
192
+ log?: string;
193
+ fail?: boolean;
194
+ name?: string;
195
+ }) {
196
+ const name = opts.name ?? PACKAGE;
191
197
  return ({ identity, prefix }: AcquireArgs) => {
192
198
  const version = identity.version ?? FIXTURE_VERSION;
193
- const root = path.join(prefix, "node_modules", PACKAGE);
199
+ const root = path.join(prefix, "node_modules", name);
194
200
  fs.mkdirSync(path.join(root, path.dirname(COMPILED_ENTRY)), { recursive: true });
195
- fs.writeFileSync(path.join(root, "package.json"), `${JSON.stringify({ name: PACKAGE, version })}\n`);
201
+ fs.writeFileSync(path.join(root, "package.json"), `${JSON.stringify({ name, version })}\n`);
196
202
  fs.writeFileSync(path.join(root, COMPILED_ENTRY), "// compiled entry fixture\n");
197
203
  const bin = path.join(prefix, "node_modules", ".bin");
198
204
  fs.mkdirSync(bin, { recursive: true });
@@ -222,7 +228,7 @@ function fixtureAcquire(opts: { activate: "absent" | "incapable" | "recorder"; l
222
228
  observedDigest: `sha256-${createHash("sha256")
223
229
  .update(identity.commit ?? "x")
224
230
  .digest("hex")}`,
225
- packageName: PACKAGE,
231
+ packageName: name,
226
232
  packageVersion: version,
227
233
  };
228
234
  };
@@ -238,7 +244,7 @@ function drive(
238
244
  env: NodeJS.ProcessEnv,
239
245
  text: string | { error: string } | { status: number; stderr: string },
240
246
  deps: Record<string, unknown> = {},
241
- ): { code: number | null; refusal: string | null; out: string; progress: string[] } {
247
+ ): { code: number | null; refusal: string | null; detail: string; out: string; progress: string[] } {
242
248
  let out = "";
243
249
  // The operator's terminal is a SEAM here, never the real `/dev/tty`: a gate that narrated into
244
250
  // whatever terminal happened to be running it would be writing outside its own sandbox.
@@ -269,11 +275,12 @@ function drive(
269
275
  readRuntimeLock: () => FIXTURE_NPM_LOCK,
270
276
  ...deps,
271
277
  });
272
- return { code, refusal: null, out, progress: progressLines };
278
+ return { code, refusal: null, detail: "", out, progress: progressLines };
273
279
  } catch (err) {
274
280
  return {
275
281
  code: null,
276
282
  refusal: (err as { code?: string }).code ?? `not-a-HerdrBuildError:${String(err)}`,
283
+ detail: (err as { detail?: string }).detail ?? String(err),
277
284
  out,
278
285
  progress: progressLines,
279
286
  };
@@ -727,6 +734,228 @@ function drive(
727
734
  );
728
735
  }
729
736
 
737
+ // ── 6c. the selected components' owners are asked before the first byte (#135) ─
738
+ /** Run one of the shipped Pi ownership writers against a sandbox, exactly as run.sh does. */
739
+ function piOwner(env: NodeJS.ProcessEnv, script: string, args: string[]): SpawnResult {
740
+ return spawnSync("python3", [path.join(REPO, "scripts", script), ...args], {
741
+ encoding: "utf8",
742
+ env,
743
+ }) as unknown as SpawnResult;
744
+ }
745
+ function piSettingsOf(env: NodeJS.ProcessEnv): string {
746
+ return path.join(env.HOME as string, ".pi", "agent", "settings.json");
747
+ }
748
+ function piStateOf(env: NodeJS.ProcessEnv, half: "package" | "provider"): string {
749
+ return path.join(env.XDG_DATA_HOME as string, "entwurf", `pi-${half}`, "install-state.json");
750
+ }
751
+ /** A LIVE foreign owner: a real directory standing for a developer checkout, registered for real. */
752
+ function foreignPiOwner(env: NodeJS.ProcessEnv, halves: ("package" | "provider")[]): string {
753
+ const checkout = path.join(env.HOME as string, "dev", "entwurf");
754
+ fs.mkdirSync(checkout, { recursive: true });
755
+ fs.mkdirSync(path.dirname(piSettingsOf(env)), { recursive: true });
756
+ fs.writeFileSync(piSettingsOf(env), '{"theme":"dark"}\n');
757
+ if (halves.includes("package")) {
758
+ const r = piOwner(env, "register-pi-package.py", [
759
+ piSettingsOf(env),
760
+ checkout,
761
+ "--scope",
762
+ "user",
763
+ "--state",
764
+ piStateOf(env, "package"),
765
+ ]);
766
+ assert.equal(r.status, 0, `fixture: seeding the foreign package owner failed: ${r.stderr}`);
767
+ }
768
+ if (halves.includes("provider")) {
769
+ const r = piOwner(env, "register-pi-provider.py", [
770
+ "install",
771
+ piSettingsOf(env),
772
+ checkout,
773
+ "--scope",
774
+ "user",
775
+ "--state",
776
+ piStateOf(env, "provider"),
777
+ ]);
778
+ assert.equal(r.status, 0, `fixture: seeding the foreign provider owner failed: ${r.stderr}`);
779
+ }
780
+ return checkout;
781
+ }
782
+ /** An acquisition that must never be reached: it counts, and it throws a sentinel instead of fetching. */
783
+ function countingSentinel(): { acquire: () => never; calls: () => number } {
784
+ let calls = 0;
785
+ return {
786
+ acquire: () => {
787
+ calls++;
788
+ throw new Error("HPB_OWNERSHIP_SENTINEL_NO_NETWORK");
789
+ },
790
+ calls: () => calls,
791
+ };
792
+ }
793
+ {
794
+ const env = world("owner-package");
795
+ foreignPiOwner(env, ["package"]);
796
+ const before = treeDigest(env.HOME as string);
797
+ const sentinel = countingSentinel();
798
+ const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
799
+ acquire: sentinel.acquire,
800
+ resolveCommit: () => COMMIT_A,
801
+ });
802
+ const after = treeDigest(env.HOME as string);
803
+ ok(
804
+ "[QK:HPB-OWNERSHIP-BEFORE-ACQUISITION] a Pi user-scope registration owned by another LIVE root refuses the " +
805
+ "plugin install BEFORE anything is fetched or written: the SAME shipped package-owner preflight the installed " +
806
+ "activation runs is asked against the prospective plugin root, so the refusal names the component and the " +
807
+ "acquisition is never called — no installing journal, runtime, cache or ledger appears and every byte under " +
808
+ "the sandbox HOME is unchanged. The first cut asked only after bootstrap, so a known refusal still fetched and " +
809
+ `installed a runtime first (refusal=${r.refusal} acquired=${sentinel.calls()} home-unchanged=${before === after} ` +
810
+ `journal=${fs.existsSync(journalPathOf(env))} detail=${JSON.stringify(r.detail.slice(0, 160))})`,
811
+ r.refusal === "herdr-build-component-ownership-refused" &&
812
+ r.detail.includes("pi-package") &&
813
+ sentinel.calls() === 0 &&
814
+ before === after &&
815
+ !fs.existsSync(journalPathOf(env)),
816
+ );
817
+ }
818
+ {
819
+ // The provider key carries its OWN ownership record; a host whose package entry is free but whose
820
+ // provider key another root owns must refuse just as early.
821
+ const env = world("owner-provider");
822
+ foreignPiOwner(env, ["provider"]);
823
+ const before = treeDigest(env.HOME as string);
824
+ const sentinel = countingSentinel();
825
+ const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
826
+ acquire: sentinel.acquire,
827
+ resolveCommit: () => COMMIT_A,
828
+ });
829
+ ok(
830
+ "[QK:HPB-OWNERSHIP-PROVIDER-HALF] the provider half is asked on its own: a free package entry with a provider " +
831
+ "key another LIVE root owns still refuses before acquisition, names `pi-provider`, and leaves the sandbox " +
832
+ `HOME byte-identical (refusal=${r.refusal} acquired=${sentinel.calls()} home-unchanged=${before === treeDigest(env.HOME as string)} detail=${JSON.stringify(r.detail.slice(0, 120))})`,
833
+ r.refusal === "herdr-build-component-ownership-refused" &&
834
+ r.detail.includes("pi-provider") &&
835
+ !r.detail.includes("pi-package:") &&
836
+ sentinel.calls() === 0 &&
837
+ before === treeDigest(env.HOME as string),
838
+ );
839
+ }
840
+ {
841
+ // Claude's preflight is its PLAN check (documents and managed-key shapes), run against the
842
+ // prospective root in plugin mode. It is not a claim about which root owns Claude.
843
+ const env = world("owner-claude-plan");
844
+ fs.mkdirSync(path.join(env.HOME as string, ".claude"), { recursive: true });
845
+ fs.writeFileSync(path.join(env.HOME as string, ".claude", "settings.json"), "[]\n");
846
+ const before = treeDigest(env.HOME as string);
847
+ const sentinel = countingSentinel();
848
+ const r = drive(env, listing({ claude: "current (v2) (/home/u/.claude)" }), {
849
+ acquire: sentinel.acquire,
850
+ resolveCommit: () => COMMIT_A,
851
+ });
852
+ ok(
853
+ "[QK:HPB-OWNERSHIP-CLAUDE-PLAN] a selected Claude whose install plan its own state owner refuses (a settings " +
854
+ "file that is not a JSON object) refuses before acquisition, names `claude-code`, and writes nothing " +
855
+ `(refusal=${r.refusal} acquired=${sentinel.calls()} home-unchanged=${before === treeDigest(env.HOME as string)} detail=${JSON.stringify(r.detail.slice(0, 120))})`,
856
+ r.refusal === "herdr-build-component-ownership-refused" &&
857
+ r.detail.includes("claude-code") &&
858
+ sentinel.calls() === 0 &&
859
+ before === treeDigest(env.HOME as string),
860
+ );
861
+ }
862
+ {
863
+ // Only the SELECTED components are asked: a backend Herdr has not integrated cannot veto another's install.
864
+ const piOnly = world("owner-unselected-claude");
865
+ fs.mkdirSync(path.join(piOnly.HOME as string, ".claude"), { recursive: true });
866
+ fs.writeFileSync(path.join(piOnly.HOME as string, ".claude", "settings.json"), "[]\n");
867
+ const piLog = path.join(piOnly.HOME as string, "argv.log");
868
+ const piRun = drive(piOnly, listing({ pi: "current (v8) (/home/u/.pi)" }), {
869
+ acquire: fixtureAcquire({ activate: "recorder", log: piLog }),
870
+ resolveCommit: () => COMMIT_A,
871
+ });
872
+ const claudeOnly = world("owner-unselected-pi");
873
+ foreignPiOwner(claudeOnly, ["package", "provider"]);
874
+ const claudeLog = path.join(claudeOnly.HOME as string, "argv.log");
875
+ const claudeRun = drive(claudeOnly, listing({ claude: "current (v2) (/home/u/.claude)" }), {
876
+ acquire: fixtureAcquire({ activate: "recorder", log: claudeLog }),
877
+ resolveCommit: () => COMMIT_A,
878
+ });
879
+ const logged = (f: string) => (fs.existsSync(f) ? fs.readFileSync(f, "utf8").trim() : "");
880
+ ok(
881
+ "[QK:HPB-OWNERSHIP-SELECTED-ONLY] only the backends in `A` are asked: Pi alone installs over a Claude settings " +
882
+ "file its owner would refuse, and Claude alone installs over a Pi registration another root owns — an " +
883
+ "unselected backend's owner never vetoes the selected one " +
884
+ `(pi-only=${piRun.code}/${piRun.refusal}/${JSON.stringify(logged(piLog))} claude-only=${claudeRun.code}/${claudeRun.refusal}/${JSON.stringify(logged(claudeLog))})`,
885
+ piRun.code === 0 && logged(piLog) === "pi" && claudeRun.code === 0 && logged(claudeLog) === "claude-code",
886
+ );
887
+ }
888
+ {
889
+ // A SAME-owner reinstall under a symlinked XDG_DATA_HOME. The installed verb sees its own root in
890
+ // PHYSICAL form (Node realpaths the ESM entry) and the provider owner records that root without
891
+ // resolving it, so the early ask must use the physical form too, while the stable command stays the
892
+ // lexical runtime address.
893
+ const home = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-hpb-owner-symlinked-")));
894
+ const realData = path.join(home, "real-data");
895
+ fs.mkdirSync(realData, { recursive: true });
896
+ fs.symlinkSync(realData, path.join(home, "data"));
897
+ const env: NodeJS.ProcessEnv = {
898
+ HOME: home,
899
+ XDG_DATA_HOME: path.join(home, "data"),
900
+ XDG_CACHE_HOME: path.join(home, "cache"),
901
+ XDG_STATE_HOME: path.join(home, "state"),
902
+ PATH: process.env.PATH,
903
+ };
904
+ const physicalRoot = path.join(realData, "entwurf", "herdr-plugin", "runtime", "active", "node_modules", PACKAGE);
905
+ fs.mkdirSync(path.dirname(piSettingsOf(env)), { recursive: true });
906
+ fs.writeFileSync(piSettingsOf(env), '{"theme":"dark"}\n');
907
+ const pkg = piOwner(env, "register-pi-package.py", [
908
+ piSettingsOf(env),
909
+ physicalRoot,
910
+ "--scope",
911
+ "user",
912
+ "--state",
913
+ piStateOf(env, "package"),
914
+ ]);
915
+ const prov = piOwner(env, "register-pi-provider.py", [
916
+ "install",
917
+ piSettingsOf(env),
918
+ physicalRoot,
919
+ "--scope",
920
+ "user",
921
+ "--state",
922
+ piStateOf(env, "provider"),
923
+ "--plugin-runtime",
924
+ activeDirOf(env),
925
+ ]);
926
+ assert.equal(pkg.status, 0, `fixture: same-owner package seed failed: ${pkg.stderr}`);
927
+ assert.equal(prov.status, 0, `fixture: same-owner provider seed failed: ${prov.stderr}`);
928
+ const log = path.join(home, "argv.log");
929
+ const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
930
+ acquire: fixtureAcquire({ activate: "recorder", log }),
931
+ resolveCommit: () => COMMIT_A,
932
+ });
933
+ ok(
934
+ "[QK:HPB-OWNERSHIP-PHYSICAL-ROOT] a same-owner reinstall under a symlinked XDG_DATA_HOME is admitted, not " +
935
+ "mistaken for a foreign root: the early owner preflight asks with the PHYSICAL prospective package root " +
936
+ "(the form the installed verb records as its own) and passes the LEXICAL stable runtime as the plugin-mode " +
937
+ "runtime flag (the only form the provider owner accepts), so the build reaches activation " +
938
+ `(code=${r.code} refusal=${r.refusal} detail=${JSON.stringify(r.detail.slice(0, 160))})`,
939
+ r.code === 0 && fs.existsSync(log) && fs.readFileSync(log, "utf8").trim() === "pi",
940
+ );
941
+ }
942
+ {
943
+ // The owners were asked about one root; the installed verb must run from that same root.
944
+ const env = world("owner-root-tie");
945
+ const log = path.join(env.HOME as string, "argv.log");
946
+ const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
947
+ acquire: fixtureAcquire({ activate: "recorder", log, name: "@elsewhere/entwurf" }),
948
+ readRuntimeLock: () => FIXTURE_CHECKOUT_LOCK,
949
+ resolveCommit: () => COMMIT_A,
950
+ });
951
+ ok(
952
+ "[QK:HPB-PLANNED-ROOT-TIE] the root the component owners were asked about is the root the installed verb runs " +
953
+ "from: an artifact that lands under another package name is refused by name before activation, so the early " +
954
+ `answer can never have been about a different root (refusal=${r.refusal} activated=${fs.existsSync(log)} detail=${JSON.stringify(r.detail.slice(0, 160))})`,
955
+ r.refusal === "herdr-build-artifact-drifted" && !fs.existsSync(log),
956
+ );
957
+ }
958
+
730
959
  // ── 7. the runner claims nothing about Herdr's own commit ──────────────────────
731
960
  {
732
961
  const env = world("post-build-gap");
@@ -155,6 +155,22 @@ export function writeLedger(layout, entry) {
155
155
  * host it was never describing.
156
156
  */
157
157
  export function readCertifiedLedger(layout) {
158
+ // lstat FIRST (#135). `readFileSync` follows links, so a dangling symlink at the ledger address
159
+ // read as ENOENT — "no ledger" — and every verb then acted as if nothing had ever been activated.
160
+ // Only a regular file, or nothing at all, may stand there.
161
+ let kind;
162
+ try {
163
+ kind = fs.lstatSync(layout.ledgerPath, { throwIfNoEntry: false });
164
+ } catch (err) {
165
+ throw new ActivationError("activation-ledger-uncertified", `${layout.ledgerPath}: ${err.message}`);
166
+ }
167
+ if (kind === undefined) return null;
168
+ if (!kind.isFile()) {
169
+ throw new ActivationError(
170
+ "activation-ledger-uncertified",
171
+ `${layout.ledgerPath} is ${kind.isSymbolicLink() ? "a symlink" : kind.isDirectory() ? "a directory" : "not a regular file"}; only a regular file or nothing may stand at the ledger address`,
172
+ );
173
+ }
158
174
  let raw;
159
175
  try {
160
176
  raw = fs.readFileSync(layout.ledgerPath, "utf8");
@@ -33,11 +33,11 @@ import {
33
33
  certifyRootsAgainstLedger,
34
34
  componentStates,
35
35
  ledgerBody,
36
+ piStatePaths,
36
37
  planActivation,
37
38
  readCertifiedLedger,
38
39
  resolveActivationLayout,
39
40
  resolveComponentRoots,
40
- resolveEntwurfDataRoot,
41
41
  writeLedger,
42
42
  } from "./herdr-activation.mjs";
43
43
  import {
@@ -49,8 +49,109 @@ import {
49
49
 
50
50
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
51
51
 
52
- function run(command, args, env) {
53
- return spawnSync(command, args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
52
+ function run(command, args, env, spawn = spawnSync) {
53
+ return spawn(command, args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
54
+ }
55
+
56
+ /**
57
+ * Ask every selected component's OWN ownership authority, read-only, whether a write for
58
+ * `packageRoot` would be admitted. Returns the refused `[name, result]` pairs; writes nothing.
59
+ *
60
+ * TWO CALL POSITIONS, ONE LIST (#135). The installed verb below asks it about itself, after the
61
+ * runtime exists. The Herdr build asks it BEFORE any byte of runtime work, with the checkout's copy
62
+ * of the same owners and the PROSPECTIVE installed root — so an owner the late check would refuse
63
+ * is refused while nothing has been fetched. `scriptRoot` is whose owner scripts run; `packageRoot`
64
+ * is the root they judge. The prospective root must be in the form this verb will later see as its
65
+ * own PACKAGE_ROOT (physical: Node realpaths the ESM entry), because the provider owner records the
66
+ * root without resolving it; `runtimeRoot` stays the lexical stable address the commands are
67
+ * derived from.
68
+ *
69
+ * Claude's preflight is its document/plan-shape check. It does not judge which root owns Claude.
70
+ */
71
+ export function preflightSelectedComponents(
72
+ env,
73
+ { scriptRoot, packageRoot, runtimeRoot, selected, spawn = spawnSync },
74
+ ) {
75
+ const preflights = [];
76
+ if (selected.includes("claude-code")) {
77
+ preflights.push([
78
+ "claude-code",
79
+ // The mode rides the ARGV, because that is what the state owner reads. The env var is the
80
+ // installer script's channel; passing only that preflights the DEFAULT mode and then
81
+ // installs the plugin one — a green check of work nobody is going to do.
82
+ run(
83
+ "python3",
84
+ [
85
+ path.join(scriptRoot, "scripts", "meta-bridge-state.py"),
86
+ "preflight-install",
87
+ "--repo",
88
+ packageRoot,
89
+ "--plugin-runtime",
90
+ runtimeRoot,
91
+ ],
92
+ { ...env, ENTWURF_PLUGIN_RUNTIME: runtimeRoot },
93
+ spawn,
94
+ ),
95
+ ]);
96
+ }
97
+ if (selected.includes("pi")) {
98
+ const agentSettings = path.join(resolveComponentRoots(env).piAgentDir.path, "settings.json");
99
+ // ONE resolution for both owner records, the one their writers use (`run.sh` `${XDG_DATA_HOME:-…}`).
100
+ const piState = piStatePaths(env);
101
+ // BOTH halves. The user-scope citizen is a package registration and a provider key with
102
+ // SEPARATE ownership records, and a foreign or corrupt package state refuses independently —
103
+ // preflighting only the provider would let the first ledger write land on a host the package
104
+ // writer was always going to refuse.
105
+ preflights.push([
106
+ "pi-package",
107
+ run(
108
+ "python3",
109
+ [
110
+ path.join(scriptRoot, "scripts", "register-pi-package.py"),
111
+ agentSettings,
112
+ packageRoot,
113
+ "--scope",
114
+ "user",
115
+ "--state",
116
+ piState.packageState,
117
+ "--preflight",
118
+ ],
119
+ env,
120
+ spawn,
121
+ ),
122
+ ]);
123
+ preflights.push([
124
+ "pi-provider",
125
+ run(
126
+ "python3",
127
+ [
128
+ path.join(scriptRoot, "scripts", "register-pi-provider.py"),
129
+ "install",
130
+ agentSettings,
131
+ packageRoot,
132
+ "--scope",
133
+ "user",
134
+ "--state",
135
+ piState.providerState,
136
+ "--plugin-runtime",
137
+ runtimeRoot,
138
+ "--preflight",
139
+ ],
140
+ env,
141
+ spawn,
142
+ ),
143
+ ]);
144
+ }
145
+ return preflights.filter(([, r]) => r.status !== 0);
146
+ }
147
+
148
+ /** The owner's own last line for each refused component, for the operator to act on. */
149
+ export function describeRefusedComponents(refused) {
150
+ return refused
151
+ .map(
152
+ ([name, r]) => `${name}: ${(r.stderr || r.stdout || (r.error ? r.error.message : "")).trim().split("\n").pop()}`,
153
+ )
154
+ .join("; ");
54
155
  }
55
156
 
56
157
  /** The only input: a subset of P, parsed from argv and refused if it is anything else. */
@@ -113,79 +214,14 @@ export function activate(env, requested) {
113
214
  const plan = planActivation({ ledger, requested });
114
215
  const selected = [...plan.reconcile, ...plan.add];
115
216
 
116
- // 3. preflight EVERY selected component before the first byte.
117
- const preflights = [];
118
- if (selected.includes("claude-code")) {
119
- preflights.push([
120
- "claude-code",
121
- // The mode rides the ARGV, because that is what the state owner reads. The env var is the
122
- // installer script's channel; passing only that preflights the DEFAULT mode and then
123
- // installs the plugin one — a green check of work nobody is going to do.
124
- run(
125
- "python3",
126
- [
127
- path.join(PACKAGE_ROOT, "scripts", "meta-bridge-state.py"),
128
- "preflight-install",
129
- "--repo",
130
- PACKAGE_ROOT,
131
- "--plugin-runtime",
132
- runtime.activeDir,
133
- ],
134
- { ...env, ENTWURF_PLUGIN_RUNTIME: runtime.activeDir },
135
- ),
136
- ]);
137
- }
138
- if (selected.includes("pi")) {
139
- const agentSettings = path.join(roots.piAgentDir.path, "settings.json");
140
- const dataRoot = resolveEntwurfDataRoot(env);
141
- // BOTH halves. The user-scope citizen is a package registration and a provider key with
142
- // SEPARATE ownership records, and a foreign or corrupt package state refuses independently —
143
- // preflighting only the provider would let the first ledger write land on a host the package
144
- // writer was always going to refuse.
145
- preflights.push([
146
- "pi-package",
147
- run(
148
- "python3",
149
- [
150
- path.join(PACKAGE_ROOT, "scripts", "register-pi-package.py"),
151
- agentSettings,
152
- PACKAGE_ROOT,
153
- "--scope",
154
- "user",
155
- "--state",
156
- path.join(dataRoot, "pi-package", "install-state.json"),
157
- "--preflight",
158
- ],
159
- env,
160
- ),
161
- ]);
162
- preflights.push([
163
- "pi-provider",
164
- run(
165
- "python3",
166
- [
167
- path.join(PACKAGE_ROOT, "scripts", "register-pi-provider.py"),
168
- "install",
169
- agentSettings,
170
- PACKAGE_ROOT,
171
- "--scope",
172
- "user",
173
- "--state",
174
- path.join(
175
- env.XDG_DATA_HOME ?? path.join(env.HOME, ".local", "share"),
176
- "entwurf",
177
- "pi-provider",
178
- "install-state.json",
179
- ),
180
- "--plugin-runtime",
181
- runtime.activeDir,
182
- "--preflight",
183
- ],
184
- env,
185
- ),
186
- ]);
187
- }
188
- const refused = preflights.filter(([, r]) => r.status !== 0);
217
+ // 3. preflight EVERY selected component before the first byte — the same list the Herdr build
218
+ // already asked about the prospective root; this is the installed recheck.
219
+ const refused = preflightSelectedComponents(env, {
220
+ scriptRoot: PACKAGE_ROOT,
221
+ packageRoot: PACKAGE_ROOT,
222
+ runtimeRoot: runtime.activeDir,
223
+ selected,
224
+ });
189
225
  if (refused.length > 0) {
190
226
  for (const [name, r] of refused) {
191
227
  process.stderr.write(` ${name}: ${(r.stderr || r.stdout || "").trim().split("\n").pop()}\n`);
@@ -25,6 +25,7 @@ import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
27
  import {
28
+ ActivationError,
28
29
  certifyPhaseForInverse,
29
30
  certifyRootsAgainstLedger,
30
31
  componentStates,
@@ -49,6 +50,31 @@ function fail(code, detail) {
49
50
  process.exit(1);
50
51
  }
51
52
 
53
+ /**
54
+ * What stands at this host's fixed plugin addresses — journal, runtime root, our npm cache — by
55
+ * lstat, without reading or following anything. Only consulted when there is no ledger.
56
+ */
57
+ function leftoversAtPluginAddresses(runtime) {
58
+ const found = [];
59
+ for (const [label, target] of [
60
+ ["journal", runtime.journalPath],
61
+ ["runtime", runtime.runtimeRoot],
62
+ ["cache", runtime.cacheDir],
63
+ ]) {
64
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
65
+ if (stat === undefined) continue;
66
+ const kind = stat.isSymbolicLink()
67
+ ? "symlink"
68
+ : stat.isDirectory()
69
+ ? "directory"
70
+ : stat.isFile()
71
+ ? "file"
72
+ : "other";
73
+ found.push(`${label}=${kind} (${target})`);
74
+ }
75
+ return found;
76
+ }
77
+
52
78
  export function deactivate(env) {
53
79
  const activation = resolveActivationLayout(env);
54
80
  const runtime = resolveRuntimeLayout(env);
@@ -56,8 +82,21 @@ export function deactivate(env) {
56
82
 
57
83
  const ledger = readCertifiedLedger(activation);
58
84
  if (ledger === null) {
59
- process.stdout.write("[herdr-plugin-deactivate] no certified activation ledger — nothing to undo\n");
60
- return 0;
85
+ // NO LEDGER IS NOT "NOTHING HERE" (#135). An install that stopped before its activation was
86
+ // recorded leaves a journal, a runtime or a cache behind, and saying "nothing to undo" over them
87
+ // was a false success. Without a ledger nothing proves what they belong to or that no harness
88
+ // wiring names them, so this verb removes nothing: it names what it found and exits non-zero.
89
+ const leftovers = leftoversAtPluginAddresses(runtime);
90
+ if (leftovers.length === 0) {
91
+ process.stdout.write("[herdr-plugin-deactivate] no certified activation ledger — nothing to undo\n");
92
+ return 0;
93
+ }
94
+ throw new ActivationError(
95
+ "deactivate-unattributed-leftovers",
96
+ `no certified activation ledger, yet ${leftovers.join(", ")} — NOTHING was removed. Without an activation ` +
97
+ "ledger nothing proves what these belong to or that no harness wiring still names them; inspect them and " +
98
+ "remove them by hand only if they are yours",
99
+ );
61
100
  }
62
101
  certifyRootsAgainstLedger(ledger, roots, runtime.activeDir);
63
102
  certifyPhaseForInverse(ledger);
@@ -316,6 +316,41 @@
316
316
  "timeoutSeconds": 420,
317
317
  "signature": "[QK:HAC-REBIND-ATOMIC-CHECKPOINT]",
318
318
  "signatureSource": "scripts/check-herdr-activation.ts"
319
+ },
320
+ {
321
+ "claim": "HAC-PI-STATE-PATHS-SHARED",
322
+ "title": "resolving the provider owner record with `??` turns an empty XDG_DATA_HOME into a relative path no writer uses, so the preflight sees no owner and the ledger lands on a host the provider writer then refuses",
323
+ "subject": "scripts/herdr-plugin-activate.mjs",
324
+ "find": ["\t\tconst piState = piStatePaths(env);"],
325
+ "replace": [
326
+ "\t\tconst piState = { packageState: piStatePaths(env).packageState, providerState: path.join(env.XDG_DATA_HOME ?? path.join(env.HOME, \".local\", \"share\"), \"entwurf\", \"pi-provider\", \"install-state.json\") };"
327
+ ],
328
+ "gate": ["bash", "run.sh", "check-herdr-activation"],
329
+ "timeoutSeconds": 300,
330
+ "signature": "[QK:HAC-PI-STATE-PATHS-SHARED]",
331
+ "signatureSource": "scripts/check-herdr-activation.ts"
332
+ },
333
+ {
334
+ "claim": "HAC-NO-LEDGER-LEFTOVERS-REPORTED",
335
+ "title": "answering 'nothing to undo' whenever the ledger is absent is a false success over the journal, runtime or cache an aborted install left behind",
336
+ "subject": "scripts/herdr-plugin-deactivate.mjs",
337
+ "find": ["\t\tif (leftovers.length === 0) {"],
338
+ "replace": ["\t\tif (true) {"],
339
+ "gate": ["bash", "run.sh", "check-herdr-activation"],
340
+ "timeoutSeconds": 300,
341
+ "signature": "[QK:HAC-NO-LEDGER-LEFTOVERS-REPORTED]",
342
+ "signatureSource": "scripts/check-herdr-activation.ts"
343
+ },
344
+ {
345
+ "claim": "HAC-LEDGER-PATH-KIND",
346
+ "title": "reading the ledger without lstat lets a dangling symlink answer ENOENT, i.e. 'no ledger', and every verb then acts as if nothing had ever been activated",
347
+ "subject": "scripts/herdr-activation.mjs",
348
+ "find": ["\tif (!kind.isFile()) {"],
349
+ "replace": ["\tif (false) {"],
350
+ "gate": ["bash", "run.sh", "check-herdr-activation"],
351
+ "timeoutSeconds": 300,
352
+ "signature": "[QK:HAC-LEDGER-PATH-KIND]",
353
+ "signatureSource": "scripts/check-herdr-activation.ts"
319
354
  }
320
355
  ]
321
356
  }
@@ -162,6 +162,72 @@
162
162
  "timeoutSeconds": 300,
163
163
  "signature": "[QK:HPB-INSTALL-USAGE-NOTE]",
164
164
  "signatureSource": "scripts/check-herdr-plugin-build.ts"
165
+ },
166
+ {
167
+ "claim": "HPB-OWNERSHIP-BEFORE-ACQUISITION",
168
+ "title": "not asking the selected components' owners before bootstrap fetches and installs a runtime for a registration another root owns — the refusal only arrives after the bytes are on disk, which is the #135 ordering defect",
169
+ "subject": "plugins/herdr/lib/build.mjs",
170
+ "find": ["\tcertifyComponentOwners(env, {"],
171
+ "replace": ["\t((_env, _args) => {})(env, {"],
172
+ "gate": ["bash", "run.sh", "check-herdr-plugin-build"],
173
+ "timeoutSeconds": 300,
174
+ "signature": "[QK:HPB-OWNERSHIP-BEFORE-ACQUISITION]",
175
+ "signatureSource": "scripts/check-herdr-plugin-build.ts"
176
+ },
177
+ {
178
+ "claim": "HPB-OWNERSHIP-PROVIDER-HALF",
179
+ "title": "dropping the provider half lets a host whose provider key another root owns pass the early check, so the runtime is fetched for an install its own provider writer was always going to refuse",
180
+ "subject": "scripts/herdr-plugin-activate.mjs",
181
+ "find": ["\t\tpreflights.push([", "\t\t\t\"pi-provider\","],
182
+ "replace": ["\t\tif (false) preflights.push([", "\t\t\t\"pi-provider\","],
183
+ "gate": ["bash", "run.sh", "check-herdr-plugin-build"],
184
+ "timeoutSeconds": 300,
185
+ "signature": "[QK:HPB-OWNERSHIP-PROVIDER-HALF]",
186
+ "signatureSource": "scripts/check-herdr-plugin-build.ts"
187
+ },
188
+ {
189
+ "claim": "HPB-OWNERSHIP-CLAUDE-PLAN",
190
+ "title": "skipping Claude's plan preflight lets a selected Claude whose settings its own state owner cannot plan reach acquisition, and the refusal lands only after the runtime is installed",
191
+ "subject": "scripts/herdr-plugin-activate.mjs",
192
+ "find": ["\tif (selected.includes(\"claude-code\")) {"],
193
+ "replace": ["\tif (false) {"],
194
+ "gate": ["bash", "run.sh", "check-herdr-plugin-build"],
195
+ "timeoutSeconds": 300,
196
+ "signature": "[QK:HPB-OWNERSHIP-CLAUDE-PLAN]",
197
+ "signatureSource": "scripts/check-herdr-plugin-build.ts"
198
+ },
199
+ {
200
+ "claim": "HPB-OWNERSHIP-SELECTED-ONLY",
201
+ "title": "asking every backend instead of the selected ones lets a harness Herdr has not integrated veto the install of one it has",
202
+ "subject": "plugins/herdr/lib/build.mjs",
203
+ "find": ["\t\tselected: profile.activate,", "\t\tspawn,"],
204
+ "replace": ["\t\tselected: [\"pi\", \"claude-code\"],", "\t\tspawn,"],
205
+ "gate": ["bash", "run.sh", "check-herdr-plugin-build"],
206
+ "timeoutSeconds": 300,
207
+ "signature": "[QK:HPB-OWNERSHIP-SELECTED-ONLY]",
208
+ "signatureSource": "scripts/check-herdr-plugin-build.ts"
209
+ },
210
+ {
211
+ "claim": "HPB-OWNERSHIP-PHYSICAL-ROOT",
212
+ "title": "asking the owners with the lexical prospective root makes a same-owner reinstall under a symlinked XDG_DATA_HOME look like a foreign root, because the installed verb records its own root in physical form",
213
+ "subject": "plugins/herdr/lib/build.mjs",
214
+ "find": ["\t\t\treturn path.join(fs.realpathSync(probe), ...rest);"],
215
+ "replace": ["\t\t\treturn path.join(probe, ...rest);"],
216
+ "gate": ["bash", "run.sh", "check-herdr-plugin-build"],
217
+ "timeoutSeconds": 300,
218
+ "signature": "[QK:HPB-OWNERSHIP-PHYSICAL-ROOT]",
219
+ "signatureSource": "scripts/check-herdr-plugin-build.ts"
220
+ },
221
+ {
222
+ "claim": "HPB-PLANNED-ROOT-TIE",
223
+ "title": "without the tie the owners can have been asked about one root while the installed verb runs from another, so the early answer is about bytes nobody installed",
224
+ "subject": "plugins/herdr/lib/build.mjs",
225
+ "find": ["\tif (completeness.name !== plannedName || landedRoot !== plannedRoot) {"],
226
+ "replace": ["\tif (false) {"],
227
+ "gate": ["bash", "run.sh", "check-herdr-plugin-build"],
228
+ "timeoutSeconds": 300,
229
+ "signature": "[QK:HPB-PLANNED-ROOT-TIE]",
230
+ "signatureSource": "scripts/check-herdr-plugin-build.ts"
165
231
  }
166
232
  ]
167
233
  }
@@ -398,10 +398,10 @@
398
398
  },
399
399
  {
400
400
  "claim": "HRB-PREPARE-STDOUT-CLEAN",
401
- "title": "silencing stderr instead of stdout is the exact byte that broke the first clean-host install: husky writes `.git can't be found` to STDOUT when npm packs our GitHub git spec (no `.git` in a codeload tarball), npm merges that line into `npm pack --json`, and a perfectly good artifact is refused as `runtime-checkout-source-unavailable` — invisible to both the fake-npm cells here and to the LIVE gate, whose `insteadOf` mirror is cloned and therefore HAS a `.git`",
401
+ "title": "silencing stderr instead of routing stdout to it lets the `prepare` bridge build's stdout into the `npm pack --json` stream this rail parses — the class of byte that broke the first clean-host install, when husky (then part of `prepare`) wrote `.git can't be found` to STDOUT while npm packed our GitHub git spec (no `.git` in a codeload tarball) and a perfectly good artifact was refused as `runtime-checkout-source-unavailable`, invisible to both the fake-npm cells here and to the LIVE gate, whose `insteadOf` mirror is cloned and therefore HAS a `.git`",
402
402
  "subject": "package.json",
403
- "find": ["\t\t\"prepare\": \"husky 1>&2 || true; npm run --silent build-bridge 1>&2\","],
404
- "replace": ["\t\t\"prepare\": \"husky 2>/dev/null || true; npm run --silent build-bridge\","],
403
+ "find": ["\t\t\"prepare\": \"npm run --silent build-bridge 1>&2\","],
404
+ "replace": ["\t\t\"prepare\": \"npm run --silent build-bridge 2>/dev/null\","],
405
405
  "gate": ["bash", "run.sh", "check-herdr-runtime-bootstrap"],
406
406
  "timeoutSeconds": 300,
407
407
  "signature": "[QK:HRB-PREPARE-STDOUT-CLEAN]",