@junghanacs/entwurf 0.12.8 → 0.12.9

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.
Files changed (52) hide show
  1. package/AGENTS.md +4 -4
  2. package/CHANGELOG.md +20 -1
  3. package/DELIVERY.md +1 -1
  4. package/README.md +122 -10
  5. package/demo/README.md +2 -2
  6. package/docs/setup-clean-host.md +14 -3
  7. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +9 -15
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +1 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +8 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +483 -34
  14. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
  15. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
  16. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
  17. package/mcp/entwurf-bridge/src/index.ts +10 -12
  18. package/package.json +7 -7
  19. package/pi-extensions/entwurf-control.ts +12 -12
  20. package/pi-extensions/lib/acp/models.ts +12 -12
  21. package/pi-extensions/lib/entwurf-fact-provider.ts +9 -2
  22. package/pi-extensions/lib/entwurf-v2-production.ts +8 -2
  23. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +8 -2
  24. package/pi-extensions/lib/meta-sender-identity.ts +15 -5
  25. package/pi-extensions/lib/meta-session.ts +526 -38
  26. package/pi-extensions/meta-bridge-hook.ts +8 -2
  27. package/run.sh +30 -26
  28. package/scripts/agy-imprint.ts +15 -1
  29. package/scripts/check-acp-session-reuse.ts +1 -1
  30. package/scripts/check-acp-session-store.ts +3 -3
  31. package/scripts/check-agy-sender-identity.ts +83 -0
  32. package/scripts/check-entwurf-control-rpc.ts +2 -2
  33. package/scripts/check-entwurf-fact-provider.ts +9 -1
  34. package/scripts/check-entwurf-facts.ts +1 -1
  35. package/scripts/check-entwurf-mailbox-guard.ts +6 -2
  36. package/scripts/check-entwurf-resume-args.ts +6 -6
  37. package/scripts/check-entwurf-session-identity.ts +7 -6
  38. package/scripts/check-entwurf-v2-production.ts +4 -2
  39. package/scripts/check-entwurf-v2-spawn-production.ts +2 -2
  40. package/scripts/check-fresh-cut-gate.sh +305 -4
  41. package/scripts/check-meta-identity-consumers.ts +501 -1
  42. package/scripts/check-meta-listing.ts +91 -9
  43. package/scripts/check-meta-receiver-marker.ts +54 -0
  44. package/scripts/check-model-lock.ts +1 -1
  45. package/scripts/meta-bridge-fresh-cut.ts +164 -28
  46. package/scripts/pi_settings_io.py +65 -0
  47. package/scripts/register-pi-package.py +183 -37
  48. package/scripts/register-pi-provider.py +68 -10
  49. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  50. package/scripts/smoke-meta-install-state.sh +1 -1
  51. package/scripts/smoke-pi-attach.ts +7 -2
  52. package/scripts/smoke-user-scope-citizen.sh +177 -0
package/AGENTS.md CHANGED
@@ -83,7 +83,7 @@ Warnings make agents blame themselves and flail. Broken tool state must surface
83
83
  2. **Dispatch is a function of liveness, not session type.** `entwurf_v2` never asks "is this a resume or a send" up front — it probes the target on its own rail and routes: live pi→control-socket, dormant pi→spawn-bg resume, active self-fetch→meta-mailbox, live native conversation→native-push. State is computed, never stored (a stored liveness bit is a lie).
84
84
  3. **A reject is honest, never cosmetic.** When a target cannot receive (dead, drifted identity, wrong state×intent), the decider returns a reject — no `✓ delivered`, no `.msg` written, no signal poke. Silent degraded "delivery" is forbidden.
85
85
  4. **MCP injection**: only via explicit `mcpServers` wiring. No ambient `~/.mcp.json` scanning, no automatic retrieval.
86
- 5. **Meta-record authority is the record body, never the filename — and the ACTIVE store is certified as a whole, by one contract.** Identity comes from the `.meta.json` body; no filename is ever parsed for authority. `certifyActiveStore` is the single contract the install doctor and all four identity writers (pi birth, Claude hook, agy imprint, `entwurf_register_native`) share: every `.meta.json` entry is a **regular file** (a symlink is refused, never followed — its bytes live where this store has no ownership), **readable by the live schema**, **named by its own body**, and the **unique holder of its `nativeSessionId`**. Body-is-authority and name-must-agree are not in tension: the body decides *what a record says*, while a record parked under the wrong name is unreachable by garden id and so corrupts the *active store*. Any defect fails the whole write BEFORE it happens, naming the fresh-cut verb — including a defect involving neither of the writer's own ids, because a narrow "is there a record for MY native id" scan is how a store ends up certified by the doctor and written to by the runtime under two different contracts (the weaker one always wins at runtime). **The contract binds both directions, at the granularity each surface can afford:** the store-WIDE scan runs on identity writes and in the doctor, while every TARGETED read (`readMetaIdentityByGardenId`, and so v2 dispatch / `entwurf_self` / sender-marker trust) holds the per-entry half — a non-regular-file record is refused and never followed, and body/filename drift fails loud, both naming the verb. Certifying only on write while the read followed a symlink made the rule true exactly where nobody was addressed. Store-wide *uniqueness* is still not proven per read, so a store that would fail certification can still surface a duplicate `nativeSessionId` to `entwurf_peers` and to dispatch — a known open gap, tracked separately, never described as guarded. A meta-record is nullable-at-birth (`model`/`transcriptPath` null until known). Production reads schemaVersion-3 records only, and the repo carries NO legacy reader or migrator: a record the live schema cannot read fails loud naming the fresh-cut verb (`entwurf meta-bridge-fresh-cut` — archive the whole generation, open an empty one). The active store provides no cross-generation address or resume continuity; sessions flow, and memory lives in the native transcripts and the embedding axes, never in the bridge.
86
+ 5. **Meta-record authority is the record body, never the filename — and the ACTIVE store is certified as a whole, by one contract.** Identity comes from the `.meta.json` body; no filename is ever parsed for authority. `certifyActiveStore` is the single contract the install doctor and all four identity writers (pi birth, Claude hook, agy imprint, `entwurf_register_native`) share: every `.meta.json` entry is a **regular file** (a symlink is refused, never followed — its bytes live where this store has no ownership), **readable by the live schema**, **named by its own body**, and the **unique holder of its `nativeSessionId`**. Body-is-authority and name-must-agree are not in tension: the body decides *what a record says*, while a record parked under the wrong name is unreachable by garden id and so corrupts the *active store*. Any defect fails the whole write BEFORE it happens, naming the fresh-cut verb — including a defect involving neither of the writer's own ids, because a narrow "is there a record for MY native id" scan is how a store ends up certified by the doctor and written to by the runtime under two different contracts (the weaker one always wins at runtime). **The contract binds both directions, at the granularity each surface can afford:** the store-WIDE scan runs on identity writes and in the doctor, while the plain TARGETED read (`readMetaIdentityByGardenId`, used by `entwurf_self`, mailbox primitives and sender-marker trust) holds the per-entry half — a non-regular-file record is refused and never followed, and body/filename drift fails loud, both naming the verb. V2 dispatch and pi resume use the stronger `readAddressableMetaIdentity` described next. Certifying only on write while the read followed a symlink made the rule true exactly where nobody was addressed. Store-wide *uniqueness* is checked on the read snapshot wherever it is both affordable and load-bearing (#52, 0.12.9): the **discovery** scan (`listAllMetaIdentities` → `entwurf_peers`) already holds every record, so a duplicated `nativeSessionId` costs nothing to see and neither rival is listed as a citizen — a facts surface may not mint the ownership the certification refuses; and the **dispatch** read (`readAddressableMetaIdentity`, used by v2 `resolveTarget` and the pi resume) fails loud, because that is the moment a record becomes an ADDRESS and a duplicate there injects one live conversation twice or resumes one transcript under two garden-id locks. It is deliberately NOT held on the per-message relay path (mailbox poke, sender-marker trust, `entwurf_self`) — those keep the per-entry half, and a call-relay that re-scans the store per message is a design this repo does not have. A RIVAL is a record that could be ADDRESSED INSTEAD, which is narrower than a file whose bytes carry the same id: a symlinked entry is not a candidate and is NEVER READ (rule 1 — following it to judge it would break the rule while enforcing it), and a drifted or unparseable neighbour is not a candidate because no garden id can reach it; all three stay certification defects and listing diagnostics, they just may not blind a healthy citizen. The inverse is loud: a REGULAR `.meta.json` this process cannot read might BE the duplicate, so it refuses rather than skipping (ENOENT alone is absence — a file that vanished mid-scan is not in the store). Both store-wide read scans take entries WITH THEIR KIND from one shared `readActiveStoreEntries`; the former name-only bindings read each path without first carrying its kind, which is exactly how both `entwurf_peers` surfaces followed a symlinked record the doctor refused. A kind attached to a NAME is still only half the rule, because `lstat`-then-`readFileSync(path)` classifies one entry and reads anotherswap the final component in between and the read follows the symlink while every settled-store test stays green. Record bytes therefore come from exactly one reader, `readStoreRecordFile`, shared by the store-wide reader and the targeted read: `O_NOFOLLOW` (a symlink fails the OPEN, before a byte is read), `O_NONBLOCK` (deciding on the fd would otherwise let a fifo block the open forever, a hazard classify-then-open never had), kind decided by `fstat` on THAT file description, and `closeSync` in a `finally`. It does not flatten errno — raced-away (`ENOENT`, skipped), unreadable (`EACCES`, loud) and swapped (`ELOOP`, refused) must stay distinguishable, because the rival scan's raced-away skip is built on that distinction and a wrapper that erased it would turn every concurrent birth into a refused dispatch. **That reader does not replace the `lstat` classification in front of the targeted read: the two layers hold different things, and merging them into "one enforcement point" is a regression, not a cleanup** (caught in cross-review before shipping). The classification decides POLICY on a settled store WITHOUT OPENING anything, which is what lets a socket, a device or a mode-000 directory earn the certification's own sentence — `open` answers `ENXIO`/`EACCES` there, errnos about availability and permission rather than regularity, so an fd-only read starts calling the host unreadable where the doctor calls the entry non-regular, and one store again has two contracts. The fd layer decides the RACE after a regular snapshot, and its errno verdicts collapse back onto the settled sentences through one PURE classifier (`classifyRecordReadFailure`: `ENOENT`→absent; `ELOOP`/`ENXIO`/not-regular-on-fstat→irregular; everything else→unreadable) so a race never teaches a second vocabulary for one state of the world. Those branches are unreachable from any settled store precisely because the classification answers first — so they are pinned with synthetic errnos on the pure function, never by a store on disk. A gate cell that claims to pin a branch nothing can reach is worse than no cell: it passed with the whole mapping deleted (2026-07-26 cross-review). The threat is not only foreign corruption: `upsertMetaSession` certifies and then writes, which is not a transaction, so two concurrent births can mint two garden ids for one native session on a host where nothing was ever corrupted. A meta-record is nullable-at-birth (`model`/`transcriptPath` null until known). Production reads schemaVersion-3 records only, and the repo carries NO legacy reader or migrator: a record the live schema cannot read fails loud naming the fresh-cut verb (`entwurf meta-bridge-fresh-cut` — archive the whole generation, open an empty one). The active store provides no cross-generation address or resume continuity; sessions flow, and memory lives in the native transcripts and the embedding axes, never in the bridge.
87
87
  6. **GC reclaims process resources only — never data.** meta-records and transcripts (the denote-id memory layer) are preserved; dormant/stale entries are archived/TTL'd, not deleted.
88
88
  7. **This is not a second harness**: no prompt reconstruction, no transcript hydration, no tool result ledger, no harness emulation. Native bridges front only a garden id plus their narrow delivery rail (Claude mailbox or agy native-push); they do not scrape transcripts or run a replacement control daemon.
89
89
  8. **Auth boundary is deployment-surface-agnostic**. This repo does not provide, copy, proxy, decrypt, or mediate any backend's credentials. Native-harness sessions read whatever auth state is visible in their own process filesystem; nothing here moves that.
@@ -91,7 +91,7 @@ Warnings make agents blame themselves and flail. Broken tool state must surface
91
91
  10. **A green dev clone is not a working package — and a green package on the maintainer's host is not a working consumer.** Node refuses `--experimental-strip-types` below `node_modules`, so any surface an operator can invoke must reach compiled JS when installed. This class has shipped four times (start.sh 0.12.1, store-doctor 0.12.4, plugin hook 0.12.5, agy imprint + three operator commands 0.12.7) because the fence was crossed by hand, per surface, and the source-tree floor cannot see it. There is now exactly one crossing — `run_ts` in `run.sh` — and two gates that hold it: `check-install-surface` (structural) and `check-pack-install` (drives the real tarball, in CI). A new `.ts` entrypoint routes through `run_ts` or it does not ship. Dev-only gates have no compiled twin by design and must be REFUSED under an installed package, never silently skipped — a `.sh` dev gate refuses in its own body, since `scripts/` ships whole and run.sh's dispatch is not the only way in. **`check-pack-install` is still a maintainer-shaped proof**: the checkout is present, every tree is operator-owned, and the install is project-local, so a surface that writes beside the installed package or depends on the repo being nearby is green there and broken for a real consumer. `check-install-container` (#51 C, own required CI job) closes that: one candidate tarball, read-only, into a container that has never seen this repo — non-root `npm install -g`, resolution through the PATH shim, a frozen package root, and a regular-file path+sha256 manifest fence across `install-meta-bridge`; the evidence line records the canonical tarball path + sha256 and the Node image id/repository digest. Default CI packs once into a temp dir; release acceptance passes a caller-preserved tarball through `ENTWURF_CANDIDATE_TGZ` and the gate consumes that exact file without re-packing, so `npm publish <same.tgz> --tag repair` can publish the accepted bytes. The two are not redundant detectors of one defect: the **freeze is a permission-level consumer fact** (the cell actually refuses the write, EACCES, the way a real consumer's host would), while the **manifest fence is the detector** — and it is exactly a regular-file path+sha256 comparison, not a whole-tree guarantee: it reads no permissions, ownership or symlink targets. A freeze at the package root alone is demonstrably insufficient (a write one directory down sails past it and only the fence sees it). Model the consumer's world, never a stricter one: a blanket `chmod -R a-w` freeze produced false reds because `cp -r` propagates modes into the installer's own assembly target, which no `sudo npm i -g` consumer can reach.
92
92
  11. **Verification must not rewire the operator's own install.** An offline smoke that writes a live `~/.claude` / `~/.gemini` / `~/.pi` path uninstalls the operator as a side effect of "testing". Swap `HOME` **and every already-exported writable `XDG_*` root** (`XDG_DATA_HOME`: install-state · `XDG_STATE_HOME`: the imprint log · `XDG_CACHE_HOME`: the statusline gid cache): moving HOME alone still writes below the inherited roots. This class struck three times in two days — hard-verify 2026-07-13 (DATA, scratch scripts), `check-pack-install`'s own drives 2026-07-14 (DATA + STATE, inside run.sh), and `smoke-user-scope-citizen` 2026-07-14 (fake `PI_CODING_AGENT_DIR` paired with the real XDG ownership state, so its inverse followed the real `managedSettingsPath` and removed the live MCP key). `check-install-surface` S5 is a static **tripwire** over `scripts/*.sh` source only: it catches a literal live path, one hop of aliasing, (S5b) HOME-without-XDG swaps, and (S5c) a mutating `run.sh` drive left unsandboxed at any root that command writes — the agent dir, `XDG_DATA_HOME`, and, for `install`/`setup`, `HOME` itself, because `ensure_agent_dir_symlinks` hard-codes `$HOME/.pi/agent` and never reads the agent-dir override (so sandboxing `PI_CODING_AGENT_DIR` is not isolation for those commands) — but it cannot see a path assembled across variables, an embedded heredoc, or run.sh itself. **A tripwire keyed to one syntactic form is not a tripwire**: S5c first shipped matching only the inline-env drive, and a review mutation walked the identical leak straight past it by hoisting the same override into an `export` one line up. Match the drive, then demand the isolation — never the other way round. The dynamic complement is `check-pack-install`'s **outer self-fence**, which runs after every success or early-failure path: the operator's real `$XDG_DATA_HOME/entwurf` tree must be byte-identical, and the gate-specific fake agy marker count in the real `$XDG_STATE_HOME/entwurf/agy-imprint.log` must not increase (mutation-checked). Read a green S5 as "no obvious destructive line", never as "verification is sandboxed" — the real guarantee is running the offline floor under a swapped HOME+XDG, which is still open. LIVE gates are the only surfaces that may drive the real host, and they say so in their name.
93
93
  12. **A doctor reports runtime truth and ownership truth separately.** Read the target's own semantics before calling a host broken. agy matches `mcp(*)` and `mcp(<server>)` against our tool wherever those rules appear, so an operator's broad `allow` already grants `entwurf_v2` — reporting that host as "NOT granted, agy prompts on every call" was a false red about a working surface. Installers still take the narrowest rule they need; doctors distinguish **we own this** from **someone else's rule is carrying it** from **it is genuinely broken**. Install-state is evidence only when it parses, names its required managed-path field as an absolute path, and that normalized path equals the live target this host reads; corrupt or foreign-target state is a failure even when the live command itself resolves. Ownership beats coverage: an element the state records as ours that has since vanished stays a failure even while an operator's broader rule keeps the surface working (a whole-file settings relink produces exactly this shape). Conversely, broken ownership state does not justify saying a visibly configured runtime command is absent — report both axes honestly and keep the final verdict red.
94
- 13. **A native-hook owner is structural, not a topology guess — and the structure is the exec form.** Shell-form command hooks do not expose one portable process tree: under the same Claude Code version we observed both a direct hook→Claude join and a retained `/bin/bash -c` wrapper, and ordinary tail-exec tests never reproduced the trigger. That form is retired, not patched. The meta-bridge declares the **exec form** — `command` is the shipped `hook-launch.sh`, `args` is the real argv — so no shell exists on the launch path, the launcher `exec`s the payload and preserves the pid, and the hook's parent IS Claude on every host (#51 B2, measured at Claude Code 2.1.217). The hook therefore reads `process.ppid` directly; the `$PPID` carrier, the ancestry walk, and the missing-carrier contract are **gone**, and re-introducing any of them means the manifest stopped feeding the owner. **But `process.ppid` is only the owner when the launcher was actually on the path, so `hook-launch.sh` stamps a non-identity `ENTWURF_META_HOOK_LAUNCH` provenance token and the hook writes NO sender/receiver marker without it.** This is not the retired carrier wearing a new name: the carrier smuggled a *pid* that had to be ancestry-checked, while this token carries no identity at all and answers only "was the authorized launch path taken". It is what keeps the upgrade mismatch fail-closed — an already-open Claude session still holding the OLD cached command reaches the new hook with a shell wrapper as its parent, and without the token that wrapper would be minted as an owner. Deleting it is never a cleanup. **entwurf requires Claude Code `>=2.1.217` and enforces that floor itself, because upstream gives no fail-loud:** an older Claude passes `plugin validate` on the exec manifest (unknown-key passthrough), then at runtime drops `args`, runs `command` alone, and reports the hook as `exit_code: 0, outcome: success` — measured at 2.1.138. `hook-launch.sh` refusing an empty argv is that silence made loud; installer and doctor refuse the version outright; there is no shell-form fallback for older versions. `check-hook-launch-topology` drives the shipped argv for real — including a plugin path containing a space, `$`, a backtick, and `;&` — and `check-claude-floor-coherence` keeps the floor one number derived from `package.json` `entwurf.claudeCodeFloor`. Evidence stays tiered: B/B2 are direct-native observations from actual 2.1.138/2.1.217 sessions on one NixOS host; the Linux artifact-consumer's fake Claude, planted cache, stand-in owner and `/proc` bridge are fixtures that prove package/oracle behavior, never a second native-host acceptance. **The doctor is the release oracle, so its exit 0 must mean every required layer was measured, never that a layer was skipped.** It resolves the ONE artifact Claude loads (`claude plugin list --json`.installPath; an ambiguous multi-version cache is refused, never guessed), classifies the installed *launch form* by name across all three owner hooks — a shell-form or launcher-less exec manifest is refused by name, not reported as unreadable drift — and then requires the live MCP↔marker join. Missing live evidence is `NOT CERTIFIED`, a failure worded distinctly from a broken install. The #51 repair cut has **Linux as its only currently certified axis**: install refuses Darwin because `/proc`-based live bridge discovery cannot certify it yet, doctor stays `NOT CERTIFIED`/nonzero there, and uninstall alone retains Darwin support so legacy state is not stranded. This is an evidence boundary, not a permanent macOS impossibility; future native validation may reopen the lane. `check-meta-doctor-oracle` holds this: a healthy fixture must reach PASS and twenty-one planted defects must each turn it red *naming their own cause*. An oracle with an optional central evidence layer is not an oracle.
94
+ 13. **A native-hook owner is structural, not a topology guess — and the structure is the exec form.** Shell-form command hooks do not expose one portable process tree: under the same Claude Code version we observed both a direct hook→Claude join and a retained `/bin/bash -c` wrapper, and ordinary tail-exec tests never reproduced the trigger. That form is retired, not patched. The meta-bridge declares the **exec form** — `command` is the shipped `hook-launch.sh`, `args` is the real argv — so no shell exists on the launch path, the launcher `exec`s the payload and preserves the pid, and the hook's parent IS Claude on every host (#51 B2, measured at Claude Code 2.1.217). The hook therefore reads `process.ppid` directly; the `$PPID` carrier, the ancestry walk, and the missing-carrier contract are **gone**, and re-introducing any of them means the manifest stopped feeding the owner. **But `process.ppid` is only the owner when the launcher was actually on the path, so `hook-launch.sh` stamps a non-identity `ENTWURF_META_HOOK_LAUNCH` provenance token and the hook writes NO sender/receiver marker without it.** **The authorized launch path is necessary, not sufficient: `process.ppid` must also be a pid that CAN own a session, and that question belongs to one shared predicate.** `isPlausibleOwnerPid` (safe integer, `> 1`, in `meta-session.ts` beside `classifyMarkerOwner`) is asked by both writers — the Claude hook and the agy imprint — by the `writeMetaSender/ReceiverMarker` boundary itself, by all three readers (fresh-cut inspect, sender-marker read, receiver-marker read) and by the sender-identity candidate filter. It is deliberately NOT part of `classifyMarkerOwner`, whose pure rule stays correct: asked about pid 1 that rule answers `live`, and honestly so. The layer above is where the claim fails. **That layer is a POLICY bound to the currently certified axis, not a law of processes**: on a Linux desktop/workstation host init is the service manager and every native harness descends from a login session, so `ownerPid <= 1` is always residue — but a container that runs the harness AS pid 1 is a real shape where it would name a genuine owner. Such a host is **unsupported and fails closed**: the writers refuse the marker, so that session keeps its meta-record and never claims reply-addressability. Widening the axis takes new evidence and a new contract, never a quiet loosening of the predicate. Splitting the two is what let the Claude hook refuse `<= 1` while the agy imprint still wrote `> 0` and every reader accepted `> 0`, and a single leftover `ownerPid: 1` marker — observed as a shell-form hook reparented to init before it read its own parent — then blocked that host's `meta-bridge-fresh-cut`: **the one action the refusal prescribes, quiescing the sessions, cannot refute an owner that is init**, and `0.12.8` named that same cut as the only repair for its pre-v3 store (#53 A, measured 2026-07-25). So a marker failing the predicate is **refuted by construction, not proven dead** — legacy (any pre-fix writer that was reparented) or corrupt (a non-integer pid no writer here ever produced), never claimed to have one single origin: the cut clears it as residue and reports it under its own `refuted:` line, never folded into the dead count. A new marker consumer asks the predicate or it reopens this. This is not the retired carrier wearing a new name: the carrier smuggled a *pid* that had to be ancestry-checked, while this token carries no identity at all and answers only "was the authorized launch path taken". It is what keeps the upgrade mismatch fail-closed — an already-open Claude session still holding the OLD cached command reaches the new hook with a shell wrapper as its parent, and without the token that wrapper would be minted as an owner. Deleting it is never a cleanup. **entwurf requires Claude Code `>=2.1.217` and enforces that floor itself, because upstream gives no fail-loud:** an older Claude passes `plugin validate` on the exec manifest (unknown-key passthrough), then at runtime drops `args`, runs `command` alone, and reports the hook as `exit_code: 0, outcome: success` — measured at 2.1.138. `hook-launch.sh` refusing an empty argv is that silence made loud; installer and doctor refuse the version outright; there is no shell-form fallback for older versions. `check-hook-launch-topology` drives the shipped argv for real — including a plugin path containing a space, `$`, a backtick, and `;&` — and `check-claude-floor-coherence` keeps the floor one number derived from `package.json` `entwurf.claudeCodeFloor`. Evidence stays tiered: B/B2 are direct-native observations from actual 2.1.138/2.1.217 sessions on one NixOS host; the Linux artifact-consumer's fake Claude, planted cache, stand-in owner and `/proc` bridge are fixtures that prove package/oracle behavior, never a second native-host acceptance. **The doctor is the release oracle, so its exit 0 must mean every required layer was measured, never that a layer was skipped.** It resolves the ONE artifact Claude loads (`claude plugin list --json`.installPath; an ambiguous multi-version cache is refused, never guessed), classifies the installed *launch form* by name across all three owner hooks — a shell-form or launcher-less exec manifest is refused by name, not reported as unreadable drift — and then requires the live MCP↔marker join. Missing live evidence is `NOT CERTIFIED`, a failure worded distinctly from a broken install. The #51 repair cut has **Linux as its only currently certified axis**: install refuses Darwin because `/proc`-based live bridge discovery cannot certify it yet, doctor stays `NOT CERTIFIED`/nonzero there, and uninstall alone retains Darwin support so legacy state is not stranded. This is an evidence boundary, not a permanent macOS impossibility; future native validation may reopen the lane. `check-meta-doctor-oracle` holds this: a healthy fixture must reach PASS and twenty-one planted defects must each turn it red *naming their own cause*. An oracle with an optional central evidence layer is not an oracle.
95
95
 
96
96
  ## ACP Plugin Boundary
97
97
 
@@ -258,8 +258,8 @@ Code-level invariants pinned at the same time:
258
258
  ## Runtime Dependencies
259
259
 
260
260
  - `@modelcontextprotocol/sdk` and `zod` are the substrate runtime deps. With the Claude-first ACP plugin shipped, the Claude/ACP backend deps are pinned alongside them: `@agentclientprotocol/claude-agent-acp` (`0.61.0`), `@agentclientprotocol/sdk` (`1.3.0`), `@anthropic-ai/sdk` (`0.100.1` — kept: `claude-agent-sdk@0.3.217`'s peer floor is `>=0.93.0`, so 0.100.1 satisfies it and a mechanical rise to the newest SDK is NOT taken). Codex/Gemini ACP packages stay out of scope; Codex is native/probe, agy is the shipped native-push Google lane, and Gemini ACP remains compatibility history rather than a current target.
261
- - `pi` (`@earendil-works/pi-ai`) on PATH at the pinned range (`>= 0.82.0 < 0.83` — devDep exact `0.82.0` + next-minor ceiling). Mismatches are caught by `check-dep-versions` / `check-pi-runtime-version`. 0.80 moved the standalone root `getModels()` to the deprecated `@earendil-works/pi-ai/compat` entrypoint; the curated Claude surface (`pi-extensions/lib/acp/models.ts`) imports `getModels` from `/compat` — the single subpath allowlisted in `check-pi-import-surface`. NOT a provider-factory subpath such as `providers/anthropic`: although it typechecks, pi's extension loader (jiti alias map in pi-coding-agent `core/extensions/loader.ts`) resolves only FOUR pi-ai specifiers for extensions — the bare root, `/compat`, `/oauth`, and (added in 0.81) `/providers/all` — so any other `providers/*` import resolves to the unresolvable `dist/compat.js/providers/…` and crashes extension load (caught live by `smoke-resident-garden-guard`, not by static typecheck). This `/compat` use is an **extension-loader compatibility shim** chosen by loader constraint, not a preference for a deprecated API — the `<0.83` ceiling guards it.
262
- - **The re-evaluation this line demanded has been done (2026-07-24, pi `v0.80.7..v0.82.0` source-diffed + both versions installed and `getModels("anthropic")` compared).** The shim holds: root / `/compat` / `/oauth` are untouched in the alias map, `getModels` is still exported from `/compat` (as `getBuiltinModels`, deprecation text now pointing at `/providers/all`), and the curated anchors are byte-identical across the bump (14 models, `claude-opus-4-8` + `claude-sonnet-5`, same `cost` / `contextWindow` / `maxTokens` / `reasoning`). What 0.81 ADDED the `/providers/all` alias and native `registerProvider(provider)` registration is a new surface, not a replacement forced on us; migrating the curated read off deprecated `/compat` onto it is a **separate cut**, deliberately kept out of the version bump. Re-evaluate again at the `<0.83` ceiling.
261
+ - `pi` (`@earendil-works/pi-ai`) on PATH at the pinned range (`>= 0.82.1 < 0.83` — devDep exact `0.82.1` + next-minor ceiling). Mismatches are caught by `check-dep-versions` / `check-pi-runtime-version`. 0.80 moved the standalone root `getModels()` to the deprecated `@earendil-works/pi-ai/compat` entrypoint; the curated Claude surface (`pi-extensions/lib/acp/models.ts`) imports `getModels` from `/compat` — the single subpath allowlisted in `check-pi-import-surface`. NOT a provider-factory subpath such as `providers/anthropic`: although it typechecks, pi's extension loader (jiti alias map in pi-coding-agent `core/extensions/loader.ts`) resolves only FOUR pi-ai specifiers for extensions — the bare root, `/compat`, `/oauth`, and (added in 0.81) `/providers/all` — so any other `providers/*` import resolves to the unresolvable `dist/compat.js/providers/…` and crashes extension load (caught live by `smoke-resident-garden-guard`, not by static typecheck). This `/compat` use is an **extension-loader compatibility shim** chosen by loader constraint, not a preference for a deprecated API — the `<0.83` ceiling guards it.
262
+ - **The minor-boundary re-evaluation was done 2026-07-24 against pi `v0.80.7..v0.82.0`.** The shim held: root / `/compat` / `/oauth` stayed in the alias map, `getModels` remained a `/compat` re-export of `getBuiltinModels`, and 0.81 added `/providers/all` without forcing migration. **The 2026-07-26 `v0.82.0..v0.82.1` patch was source-diffed and installed again:** loader aliases and `/compat` are unchanged; the builtin Anthropic catalog grows 14→15 only by adding `claude-opus-5`; `claude-sonnet-5` retains identical cost/context/max-token/reasoning metadata. The explicit model cut replaces the curated Opus 4.8 row with Opus 5 (1M context, 128K max output, reasoning enabled) rather than widening the two-model surface. Re-evaluate again at the `<0.83` ceiling.
263
263
 
264
264
  ## Working Style
265
265
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable changes to this project will be documented here. Format follows [Kee
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.12.9 — 2026-07-26
8
+
9
+ ### Changed
10
+
11
+ - **The curated ACP surface adopts Claude Opus 5 on pi 0.82.1.** The deliberately narrow two-model surface keeps Sonnet 5 and replaces the Opus 4.8 anchor with Opus 5 (1M context, 128K max output, reasoning enabled). The pi runtime pin, closed peer range, lockfile, install gates, and current-version docs move together; source review of pi 0.82.1 confirmed the extension-loader aliases and `/compat` catalog seam are unchanged.
12
+ - **`meta-bridge-fresh-cut` now reports the transition that actually occurred.** Exit 0 means the cut completed, 1 means nothing moved, 2 is usage, 3 means the cut transition is incomplete, and 4 means the generation was archived and the fresh v3 store opened but residue cleanup is incomplete. The command help, `run.sh` usage, operator docs, and deterministic decision/source-fence cells carry the same contract; exit 4 may proceed to `setup`, while rerunning after a new citizen is born would archive the new generation too.
13
+
14
+ ### Fixed
15
+
16
+ - **A marker that cannot plausibly own a session no longer blocks the only repair verb or grants identity indefinitely.** The Claude hook, agy imprint, marker write boundary, all sender/receiver readers, fresh-cut inspection, and sender-candidate filtering share one `isPlausibleOwnerPid` policy. On the currently certified Linux desktop/workstation axis, pid 1 is refuted by construction and reported separately from a proven-dead owner; the cut clears that residue instead of waiting forever on init. A container running the harness as pid 1 remains unsupported and fails closed rather than quietly widening the evidence boundary.
17
+ - **`setup` preserves the repository's tracked pi settings across the full install path.** Both writers of `.pi/settings.json` now share one serializer, recognize the portable relative package entry as the same checkout, and leave already-satisfied bytes untouched. The end-to-end gate plants the candidate-index settings in a stand-in checkout, drives the real `run.sh install`, and requires byte and mtime identity, covering both writers and user-scope side effects rather than only a helper.
18
+ - **Duplicate `nativeSessionId` claims are quarantined on discovery and refused wherever a record becomes an address.** `entwurf_peers` lists neither rival while preserving unrelated citizens and naming diagnostics; canonical `entwurf_v2` dispatch checks the store before choosing any transport, and dormant pi resume uses the same addressable read. A regular unreadable rival fails loud because it might be the duplicate, while symlinked, drifted, or unparseable neighbours remain certification/listing defects without being promoted into addressable rivals. The deliberately narrower raw mailbox and marker paths retain the per-entry half of the contract; that boundary and its promotion rule remain tracked in #55.
19
+ - **Record bytes now come from the same opened file description that proves their kind.** `readStoreRecordFile` opens with `O_NOFOLLOW` so a final-component regular-to-symlink swap fails before a byte is read, uses `fstat` on that fd, reads through the fd, and closes in a `finally`. `O_NONBLOCK` prevents the fd-classification repair from introducing a FIFO hang. The preceding `lstat` classification remains a separate policy layer so settled sockets, devices, and mode-000 directories receive the certification contract's non-regular verdict instead of leaking `ENXIO`/`EACCES` as a second vocabulary.
20
+
21
+ ### Verification
22
+
23
+ - Pre-version landing HEAD `aaab00fbd293300d25c294580bb19bd3a88ad757` passed exact-SHA GitHub Actions run [30186472796](https://github.com/junghan0611/entwurf/actions/runs/30186472796): `check`, `install-surface`, and `artifact-consumer` all success.
24
+ - The prepared tree passed an independent `pnpm check`, then `LIVE=1 ./run.sh release-gate /tmp/entwurf-release-gate-0.12.9.8NSzz7` completed all green: **MUST PASS=17 FAIL=0 SKIP=0** and **BEHAVIOR PASS=1 FAIL=0**, EXIT=0. Full log: `/tmp/entwurf-release-gate-0.12.9.8NSzz7/release-gate.log`; per-step artifact paths are preserved inside it. Prepared-HEAD exact-SHA CI and preserved-candidate acceptance remain deliberately deferred to `make`.
25
+
7
26
  ## 0.12.8 — 2026-07-25
8
27
 
9
28
  ### Changed
@@ -30,7 +49,7 @@ All notable changes to this project will be documented here. Format follows [Kee
30
49
  - Pre-version landing HEAD `1345688001ed6629bd0f58996a36134e7b7874bc` passed exact-SHA GitHub Actions run [30150824225](https://github.com/junghan0611/entwurf/actions/runs/30150824225): `check`, `install-surface`, and `artifact-consumer` all success.
31
50
  - Published `0.12.8-repair.1` passed installed `doctor-meta-bridge` on maintainer + secondary Linux hosts after a new real Claude session opened with a live MCP child; both runs included physical `entwurf_v2` delivery and live owner join (BASELINE HISTORY).
32
51
  - On the stable prepared tree, `LIVE=1 ./run.sh release-gate` is all green — **MUST PASS=17 FAIL=0 SKIP=0** (its first step is the full `pnpm check`; the run includes `smoke-acp-bundled-mcp-live` and `smoke-acp-v2-send-live`) and **BEHAVIOR PASS=1 FAIL=0**, EXIT=0 — at scratch `/tmp/entwurf-release-gate-0.12.8.u9y9IX`, complete log `/tmp/entwurf-release-gate-0.12.8.u9y9IX/release-gate.log`, per-step artifact paths printed inside it. The static gate judges the **candidate index**, so the release-prep bytes were staged before the run, not merely present in the working tree.
33
- - The prepared-HEAD exact-SHA CI, the preserved candidate and its container acceptance, the tag, the GitHub release, and `latest` publication remain deliberately deferred to `make` / `publish`.
52
+ - Prepared HEAD `e31c28f` passed exact-SHA GitHub Actions run [30152323861](https://github.com/junghan0611/entwurf/actions/runs/30152323861) with `check`, `install-surface`, and `artifact-consumer` all successful. The preserved candidate `junghanacs-entwurf-0.12.8.tgz` (sha256 `7c7e8985823391ec6dfae918e08c4aed1fafd06b7415806b2d591b0cb95891f3`) passed the checkout-invisible container consumer, was tagged as `v0.12.8`, released on GitHub, and published as `latest=0.12.8` while `repair=0.12.8-repair.1` remained unchanged.
34
53
 
35
54
  ## 0.12.8-repair.1 — 2026-07-22
36
55
 
package/DELIVERY.md CHANGED
@@ -86,7 +86,7 @@ D2 receiver_armed: pass
86
86
  D3 addressed_enqueue: pass
87
87
  D4 idle_active_wake: pass
88
88
  D5 context_injection: pass token=AGY-PARITY-3399
89
- D6 continuity: pass session_id=<native-id> model=claude-opus-4-8
89
+ D6 continuity: pass session_id=<native-id> model=claude-opus-5
90
90
  D7 completion_reply: partial reason="no garden outbox yet"
91
91
  D8 robustness: partial reason="loop guard present; crash recovery not tested"
92
92
  ```
package/README.md CHANGED
@@ -159,7 +159,7 @@ because Node refuses to strip `.ts` files under `node_modules`.
159
159
  ### Pi adapter / ACP plugin lane
160
160
 
161
161
  To use the `entwurf` provider inside pi, install a compatible pi binary
162
- separately (`@earendil-works/pi-coding-agent >=0.82.0 <0.83`). Then point pi at
162
+ separately (`@earendil-works/pi-coding-agent >=0.82.1 <0.83`). Then point pi at
163
163
  the npm-installed package or development clone:
164
164
 
165
165
  ```bash
@@ -261,15 +261,82 @@ Only that accepted file may be published under the explicitly authorized lane:
261
261
  > there is nothing to diagnose or branch on.
262
262
  >
263
263
  > Note the deliberate scope, stated as it actually is. A **store-wide** scan runs on
264
- > **identity writes** and in the doctor not on every mailbox poke; a call-relay does
265
- > not re-scan the whole store per message. What every **targeted read** does hold is
266
- > the per-entry half of the same contract: `readMetaIdentityByGardenId` refuses a
267
- > record that is not a regular file (a symlink is never followed, in *either*
268
- > direction) and one whose body disagrees with its name, naming the verb. What it
269
- > does **not** do is prove store-wide uniqueness on the read path, so on a store that
270
- > would fail certification two records can still claim one `nativeSessionId` and
271
- > `entwurf_peers` will list both as citizens. That is a **known open gap**, not a
272
- > promise: closing it on the dispatch path is [#52](https://github.com/junghan0611/entwurf/issues/52).
264
+ > **identity writes**, in the doctor, and on the two read surfaces below — not on every
265
+ > mailbox poke; a call-relay does not re-scan the whole store per message. What every
266
+ > **targeted read** holds is the per-entry half of the same contract:
267
+ > `readMetaIdentityByGardenId` refuses a record that is not a regular file (a symlink is
268
+ > never followed, in *either* direction) and one whose body disagrees with its name,
269
+ > naming the verb. That is what the mailbox poke, the sender-marker trust and
270
+ > `entwurf_self` use, and it is all they need.
271
+ >
272
+ > Store-wide **uniqueness** is checked on the read snapshot at the two places where it
273
+ > is both affordable and load-bearing ([#52](https://github.com/junghan0611/entwurf/issues/52),
274
+ > 0.12.9):
275
+ >
276
+ > - **Discovery** — `listAllMetaIdentities`, and so `entwurf_peers`, already reads the
277
+ > whole store, so the check is free. Two records claiming one `nativeSessionId` are
278
+ > **not** two citizens: *neither* is listed (the store cannot say which one owns that
279
+ > session, and a facts surface may not mint an authority the certification refuses),
280
+ > both become diagnostics naming each other, and every unrelated citizen keeps listing.
281
+ > - **Dispatch** — `readAddressableMetaIdentity`, used by v2 `resolveTarget` and by the
282
+ > pi resume path. Those are the moments a record stops being data and becomes an
283
+ > **address**, they happen once per dispatch next to a socket connect and a spawn, and
284
+ > a duplicate there means direct-injecting one live conversation under two garden ids,
285
+ > or resuming one transcript twice under two per-garden-id locks. It fails **loud**; a
286
+ > soft `bad-target` is reserved for a record that is genuinely absent.
287
+ >
288
+ > **A rival is a record that could be addressed instead** — narrower than "a file whose
289
+ > bytes mention the same id". A **symlinked** entry is not a candidate and is *never
290
+ > read* (rule 1 again: following it to see whether it counts would break the rule in the
291
+ > act of enforcing it, and let planted foreign bytes quarantine a healthy citizen); a
292
+ > **drifted** or **unparseable** neighbour is not a candidate either, because no garden
293
+ > id can reach it. All three remain certification defects and the listing reports them as
294
+ > diagnostics — they just may not blind a healthy record. The opposite case is a
295
+ > **regular `.meta.json` this process cannot read**: that one might BE the duplicate, so
296
+ > it fails loud rather than being skipped, because "holds it alone" from a scan that
297
+ > never asked is the same vacuous pass in miniature. (`ENOENT` alone is the exception —
298
+ > a file that vanished mid-scan is not in the store.)
299
+ >
300
+ > Both store-wide read scans take entries **with their kind** from one shared
301
+ > `readActiveStoreEntries`, rather than each binding doing its own name-only `readdir`.
302
+ > That is what makes rule 1 structural: a scan handed bare names has no choice but to
303
+ > read the path, which is how both `entwurf_peers` bindings came to follow a symlinked
304
+ > record while the doctor refused the very same entry.
305
+ >
306
+ > A kind carried alongside a **name** is still only half of it, because a name can stop
307
+ > meaning what it meant. `lstat`-then-`readFileSync(path)` classifies one entry and reads
308
+ > another: replace the final path component with a symlink in between, and the read
309
+ > follows it into foreign bytes while every test on a settled store stays green. So the
310
+ > bytes of a record come from exactly one place — `readStoreRecordFile`, shared by the
311
+ > store-wide reader and by `readMetaIdentityByGardenId` — which opens with `O_NOFOLLOW`
312
+ > (a symlink fails the **open**, before a byte is read), decides the kind by `fstat` on
313
+ > **that file description** rather than on a name, and closes it in a `finally`. It also
314
+ > opens `O_NONBLOCK`, because classify-then-open never had to care that `open(fifo,
315
+ > O_RDONLY)` blocks until a writer appears, and deciding on the fd does. The reader does
316
+ > not flatten errno: callers still separate a record that raced away (`ENOENT`, skipped)
317
+ > from one that cannot be read (`EACCES`, loud) from one that was swapped (`ELOOP`,
318
+ > refused) — and the rival scan's raced-away skip depends on exactly that.
319
+ >
320
+ > That reader does not replace the `lstat` classification in front of the targeted read;
321
+ > the two hold **different** things, and collapsing them into "one enforcement point"
322
+ > was itself a regression (caught in review before shipping). The classification decides
323
+ > POLICY on a settled store **without opening anything**, which is what lets a socket, a
324
+ > device or a mode-000 directory earn the certification's own sentence — an `open` would
325
+ > answer `ENXIO` or `EACCES` there, errnos that say nothing about regularity, and the
326
+ > targeted read would start calling the host unreadable where the doctor calls the entry
327
+ > non-regular. Two contracts for one store is precisely the defect rule 1 exists to
328
+ > prevent. The fd layer decides the RACE: after a regular snapshot, its errno verdicts
329
+ > (`ELOOP`, `ENXIO`, a non-regular `fstat`) collapse back onto the settled sentences
330
+ > through one pure classifier, so a race never teaches the operator a second vocabulary
331
+ > for one state of the world. Because the classification answers first, those branches
332
+ > are unreachable from any settled store — which is why the classifier is pure and pinned
333
+ > with synthetic errnos rather than by a store on disk.
334
+ >
335
+ > This is not only a defence against external corruption. `upsertMetaSession` certifies
336
+ > and then writes, which is **not a transaction**, so two concurrent births — two
337
+ > `SessionStart` hooks, an `entwurf_register_native` racing an agy imprint — can both
338
+ > observe one clean store and mint different garden ids for one native session. A
339
+ > duplicate can therefore appear on a host where nothing was ever corrupted.
273
340
  >
274
341
  > There is **no migrator and no legacy reader anywhere in this repo** — carrying
275
342
  > old records forward would serve a continuity the system deliberately does not
@@ -290,6 +357,23 @@ Only that accepted file may be published under the explicitly authorized lane:
290
357
  > entwurf meta-bridge-fresh-cut # quiesce-checked: archive the old generation, open an empty one
291
358
  > ```
292
359
  >
360
+ > **Read its exit status, don't just chain it.** The cut answers with a contract
361
+ > ([#54](https://github.com/junghan0611/entwurf/issues/54), `--help` prints it), because
362
+ > "it failed" is not one world-state:
363
+ >
364
+ > | exit | what already moved | what to do |
365
+ > |---|---|---|
366
+ > | `0` | the cut is complete | run `setup` |
367
+ > | `1` | **nothing** — a live/unprovable surface, an occupied archive destination, an unreadable surface | fix the named cause, re-run. **Do not** run `setup`: the store it refused is still there |
368
+ > | `2` | nothing — usage error | fix the command |
369
+ > | `3` | the cut transition is **incomplete** after at least one archive move; the fresh generation is not confirmed open | inspect, or re-run to finish under a new stamp |
370
+ > | `4` | the cut is **complete**; marker/socket residue could not be unlinked | `setup` may run. Prefer repairing the named residue before `setup`; if new citizens have already been born, remove it manually — another fresh-cut would archive their generation too |
371
+ >
372
+ > Only `0` is success — a failed sweep never becomes a pass. `fresh-cut && setup` is
373
+ > still the right chain for the common path; the codes are there so a runbook, CI or an
374
+ > agent can tell a refusal that changed nothing from a cut that already unblocked the
375
+ > install. An exit-4 re-run is safe only before `setup` or any new citizen birth.
376
+ >
293
377
  > The refusal is a **preflight, not a lock**: it certifies the store as it stands
294
378
  > at that moment. On a host whose pi/Claude settings point straight at a checkout,
295
379
  > a `git pull` can put the new code in front of live sessions before you run
@@ -304,6 +388,34 @@ Only that accepted file may be published under the explicitly authorized lane:
304
388
  > refuses the cut before anything moves. Cutting needs proof of death, not absence
305
389
  > of proof of life.
306
390
  >
391
+ > **One marker is cleared without proving death, and it is the exception that keeps
392
+ > this path open.** A marker whose recorded `ownerPid` cannot own anything — `1`
393
+ > (init), `0`, a negative or non-integer — is *refuted by construction*: no writer in
394
+ > this tree can mint one any more, so on a current install it is **legacy or corrupt
395
+ > residue** — a pre-fix writer whose parent had been reparented to init (the retired
396
+ > shell-form Claude hook; the agy imprint, which asked only `> 0` until this repair),
397
+ > or a foreign/damaged marker, the only way a non-integer pid appears at all. The one
398
+ > file actually observed was a shell-form hook reparented to init. Honoring it was not
399
+ > merely wrong, it was a trap: init runs for the whole boot and its start-key does not
400
+ > change while it does, so the owner verdict is `live` and **the very action this
401
+ > refusal prescribes cannot change that** — you quiesce every session, exactly as
402
+ > told, and the cut refuses again. (Deleting the marker removes the claim rather than
403
+ > refuting the verdict; a reboot recomputes the key with no contract either way.)
404
+ > Meanwhile the one repair this page names could not run: on the affected host the cut
405
+ > stayed blocked until the marker file was removed by hand (#53 A, measured on a second
406
+ > Linux host 2026-07-25). Such a marker is now swept as residue and **reported apart
407
+ > from the dead ones** (`refuted:`), because a proof of invalidity is a different
408
+ > finding from a proof of death — and a stronger one.
409
+ >
410
+ > **Scope of that rule.** "A native session is never owned by init" is a property of the
411
+ > axis entwurf certifies — a Linux desktop/workstation host, where init is the service
412
+ > manager and every harness descends from a login session. A container that runs the
413
+ > harness **as pid 1** is a real shape, and there the marker would name a genuine owner.
414
+ > That host is **unsupported and fails closed**: the writers refuse the marker, so the
415
+ > session still gets its meta-record but never claims reply-addressability — a lost
416
+ > capability rather than a false identity. Reopening that lane needs new evidence and a
417
+ > new contract, not a looser predicate.
418
+ >
307
419
  > That agy row is not symmetry for its own sake: `entwurf_register_native` writes a
308
420
  > record and **no marker at all**, and `entwurf_v2` dispatches to such a citizen
309
421
  > straight off the record, so marker absence is the *normal* state of a live,
package/demo/README.md CHANGED
@@ -113,7 +113,7 @@ The demo launch lines correspond to your shell aliases:
113
113
 
114
114
  | Alias | Model id | Use as |
115
115
  |---|---|---|
116
- | `piao` | `entwurf/claude-opus-4-8` | `SENDER_MODEL` (precision scenes) |
116
+ | `piao` | `entwurf/claude-opus-5` | `SENDER_MODEL` (precision scenes) |
117
117
  | `pias` | `entwurf/claude-sonnet-5` | `SENDER_MODEL` (default) |
118
118
  | `piat` | `entwurf/gpt-5.4` | `PEER_MODEL` (default) |
119
119
  | `piat5` | `entwurf/gpt-5.5` | either |
@@ -137,7 +137,7 @@ SCENE_DELAY=30 FINAL_PAUSE=10 bash demo.sh
137
137
 
138
138
  ## Prerequisites
139
139
 
140
- - `pi` on PATH (current floor 0.82.0)
140
+ - `pi` on PATH (current floor 0.82.1)
141
141
  - `entwurf` provider configured + auth ready for the selected sender/peer models
142
142
  - `asciinema` installed
143
143
  - `agg` installed (optional — only for GIF conversion)
@@ -65,7 +65,7 @@ exit 0.
65
65
  | Claude Code | **`>=2.1.217`** — the exec-form hook floor; an older Claude drops the hook's `args` silently and still reports success, so there is no fallback lane | `entwurf.claudeCodeFloor` (bound by `check-claude-floor-coherence`) |
66
66
  | npm | bundled with Node 24 | public package install path |
67
67
  | entwurf | `@junghanacs/entwurf` | neutral npm package; exposes `entwurf`, `entwurf-bridge`, `entwurf-statusline`, `entwurf-agy-statusline`, and `entwurf-agy-imprint` bins |
68
- | pi binary | **optional**, `@earendil-works/pi-coding-agent >=0.82.0 <0.83` | needed only for the pi adapter / ACP provider / spawn-bg resume lane |
68
+ | pi binary | **optional**, `@earendil-works/pi-coding-agent >=0.82.1 <0.83` | needed only for the pi adapter / ACP provider / spawn-bg resume lane |
69
69
  | Antigravity `agy` | **optional**, operator-installed/authenticated native CLI | needed only for the shipped native-push citizen lane; entwurf never moves its auth |
70
70
 
71
71
  ## Stage 0 — Node 24 via nvm
@@ -155,7 +155,7 @@ If the host will run pi sessions or the Claude ACP provider through pi, install
155
155
  a compatible pi binary separately and wire the target project.
156
156
 
157
157
  ```bash
158
- npm install -g @earendil-works/pi-coding-agent@0.82.0
158
+ npm install -g @earendil-works/pi-coding-agent@0.82.1
159
159
  pi --version
160
160
 
161
161
  mkdir -p ~/entwurf-smoke
@@ -172,7 +172,7 @@ Drift points:
172
172
  `entwurf-bridge`. (The `entwurf-targets.json` link is gone — #50 C3 removed the
173
173
  target registry; a leftover operator link is inert.)
174
174
  - Older pi versions may silently miss the provider/extension surface. Use the
175
- pinned floor (`>=0.82.0 <0.83`) for release verification.
175
+ pinned floor (`>=0.82.1 <0.83`) for release verification.
176
176
  - A host that only uses the external MCP bridge can skip this stage until it
177
177
  needs `owned-outcome` spawn-bg resume or pi-native control sockets.
178
178
 
@@ -235,6 +235,17 @@ host has nothing to cut and can ignore this paragraph. The refusal is a
235
235
  preflight, not a lock: for checkout-backed installs, quiesce sessions before
236
236
  pull, then fresh-cut, setup, and reopen.
237
237
 
238
+ Read the cut's exit status rather than only chaining it (#54; `--help` prints the
239
+ contract): `0` complete → run `setup`; `1` NOTHING MOVED — a live/unprovable
240
+ surface, an occupied archive destination, an unreadable surface — fix the named
241
+ cause and re-run, and do not run `setup`, because the store it refused is still
242
+ there; `2` usage; `3` CUT TRANSITION INCOMPLETE — at least one archive move
243
+ happened but the fresh generation is not confirmed open — inspect, or re-run to
244
+ finish under a new stamp; `4` the cut is COMPLETE but marker/socket residue could
245
+ not be unlinked — `setup` may run. Prefer fixing the named residue and re-running
246
+ before `setup`; if new citizens have already been born, remove it manually rather
247
+ than fresh-cutting their new generation. Only `0` is success.
248
+
238
249
  Upgrade invariant: every global npm/pnpm package upgrade must be followed by
239
250
  `entwurf install-meta-bridge` from that same installed binary and then
240
251
  `entwurf doctor-meta-bridge`. Installed statusline/MCP entries use stable bin
@@ -39,8 +39,7 @@
39
39
  * - surface errors (isError:true); never silent empty results
40
40
  * - no user-specific paths baked in; env-configurable with safe defaults
41
41
  */
42
- import { existsSync, readFileSync } from "node:fs";
43
- import * as fs from "node:fs/promises";
42
+ import { existsSync } from "node:fs";
44
43
  import * as os from "node:os";
45
44
  import * as path from "node:path";
46
45
  import * as process from "node:process";
@@ -55,7 +54,7 @@ import { computeSelfAddressability } from "../../../pi-extensions/lib/entwurf-se
55
54
  import { nativePushSupported } from "../../../pi-extensions/lib/entwurf-v2-contract.js";
56
55
  import { runAndRenderEntwurfV2FromSurface } from "../../../pi-extensions/lib/entwurf-v2-surface.js";
57
56
  import { probeNativeSenderAlive, resolveTrustedMetaSenderIdentity, } from "../../../pi-extensions/lib/meta-sender-identity.js";
58
- import { defaultMetaMailboxDir, defaultMetaSessionsDir, readMetaInbox, readMetaReceiverMarker, } from "../../../pi-extensions/lib/meta-session.js";
57
+ import { defaultMetaMailboxDir, defaultMetaSessionsDir, makeStoreRecordReader, readActiveStoreEntries, readMetaInbox, readMetaReceiverMarker, } from "../../../pi-extensions/lib/meta-session.js";
59
58
  import { registerNativeConversation } from "../../../pi-extensions/lib/native-push/register.js";
60
59
  const HOME = os.homedir();
61
60
  // Directory SOURCE is this adapter's own policy — the bridge honours an explicit
@@ -389,20 +388,15 @@ server.tool("entwurf_peers", "List the entwurf fact surface: garden citizens (fr
389
388
  "Note: this is the *active* world. It is NOT a fresh-sibling creation surface; pass an " +
390
389
  "existing garden id to entwurf_v2.", {}, async () => {
391
390
  try {
392
- // Meta-store axis: list `.meta.json` entries (ENOENT = fresh install =
393
- // empty; any other readdir failure is a real error, not a silent empty).
391
+ // Meta-store axis: entries WITH their kind (ENOENT = fresh install = empty; any
392
+ // other readdir failure is a real error, not a silent empty). The name-only
393
+ // readdir that used to live here made rule 1 unenforceable on this surface —
394
+ // `readRecord` would follow a symlinked `.meta.json` to bytes the store does not
395
+ // own, while the doctor refused that same entry. One store, one contract.
394
396
  const sessionsDir = defaultMetaSessionsDir();
395
- let metaEntries = [];
396
- try {
397
- metaEntries = (await fs.readdir(sessionsDir)).filter((n) => n.endsWith(".meta.json"));
398
- }
399
- catch (err) {
400
- if (err?.code !== "ENOENT")
401
- throw err;
402
- }
403
397
  const result = await listEntwurfFacts({
404
- metaEntries,
405
- readRecord: (filename) => readFileSync(path.join(sessionsDir, filename), "utf8"),
398
+ metaEntries: readActiveStoreEntries(sessionsDir),
399
+ readRecord: makeStoreRecordReader(sessionsDir),
406
400
  // Socket axis: the same dir dispatch uses (grammar SSOT), scan-internal only.
407
401
  socket: { dir: ENTWURF_DIR },
408
402
  });
@@ -17,14 +17,14 @@
17
17
  // Why `/compat` and NOT the 0.80 provider-factory subpath (the pi-ai
18
18
  // providers/anthropic subpath): this file is loaded by pi's
19
19
  // EXTENSION loader (pi-coding-agent `core/extensions/loader.ts`), whose jiti
20
- // alias map resolves ONLY three pi-ai specifiers for extensions — the bare root,
21
- // `/compat`, and `/oauth` all to `ai/dist/compat.js`. A `providers/*` subpath
22
- // is NOT in that map: jiti prefix-matches the bare `@earendil-works/pi-ai` alias
23
- // and appends the remainder, yielding the unresolvable
24
- // `…/dist/compat.js/providers/anthropic` (verified live: extension load crash,
25
- // invisible to static typecheck which resolves against node_modules `exports`).
26
- // So `/compat` is the SINGLE sanctioned extension entrypoint for the old global
27
- // model-catalog API, and the SINGLE allowlisted exception in
20
+ // alias map resolves FOUR pi-ai specifiers for extensions — the bare root,
21
+ // `/compat`, `/oauth`, and (since pi 0.81) `/providers/all`. Other `providers/*`
22
+ // subpaths are NOT in that map: jiti prefix-matches the bare
23
+ // `@earendil-works/pi-ai` alias and appends the remainder, yielding the
24
+ // unresolvable `…/dist/compat.js/providers/anthropic` (verified live: extension
25
+ // load crash, invisible to static typecheck which resolves against node_modules
26
+ // `exports`). So `/compat` remains the SINGLE sanctioned extension entrypoint
27
+ // this repo uses for the old global model-catalog API, and the SINGLE allowlisted exception in
28
28
  // `run.sh check-pi-import-surface`. `getModels` here is compat's deprecated
29
29
  // re-export of `getBuiltinModels`. When pi removes compat we migrate to whatever
30
30
  // the loader then exposes.
@@ -46,11 +46,11 @@ export const ENTWURF_ACP_NO_AUTH_SENTINEL = "entwurf-no-auth";
46
46
  // both axes (protocol smoke + agent interview) — do not extend casually.
47
47
  // Exported so the claude backend adapter (backend-adapter.ts) can answer
48
48
  // `routeModel` without re-deriving the set from curatedClaudeModels().
49
- export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-4-8"];
49
+ export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-5"];
50
50
  /** The anchor model whose absence is a hard registry regression, not a soft skip. */
51
- export const CURATED_ANCHOR_MODEL_ID = "claude-opus-4-8";
52
- // Anthropic's registry reports 1M for both Sonnet 5 and Opus 4.8, and the
53
- // entwurf surface now exposes the full 1M for BOTH. Sonnet 5's 1M window is the
51
+ export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5";
52
+ // Anthropic's registry reports 1M for both Sonnet 5 and Opus 5, and the
53
+ // entwurf surface exposes the full 1M for BOTH. Sonnet 5's 1M window is the
54
54
  // whole point of the 0.12.3 bump — it is the compact-free long-context floor the
55
55
  // earlier 200K Sonnet cap could not provide. We still clamp to a 1M ceiling so a
56
56
  // future registry value can't silently inflate the surface past what we verify.
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import { isNonPiGardenIdSocketConflict, resolveFactList } from "./entwurf-facts.js";
30
30
  import { isLivenessSupported } from "./entwurf-v2-contract.js";
31
- import { FRESH_CUT_PRESCRIPTION, listAllMetaIdentities } from "./meta-session.js";
31
+ import { FRESH_CUT_PRESCRIPTION, listAllMetaIdentities, } from "./meta-session.js";
32
32
  import { scanSocketProbes } from "./socket-discovery.js";
33
33
  /** The #50 C4 demotion messages, one per probed liveness so the F8 aggregation
34
34
  * groups same-state sockets into one line. Each names the true cause (no record
@@ -21,6 +21,9 @@
21
21
  *
22
22
  * `resolveTarget` (QB1): a MISSING meta-record is a soft `bad-target` (identity:null); a
23
23
  * PRESENT-but-corrupt record fails LOUD (the read throws drift) — never matched by string.
24
+ * "Corrupt" includes a record that does not hold its `nativeSessionId` alone (#52): the read
25
+ * here is the ADDRESSABLE one, so the store-wide uniqueness half runs at this boundary and
26
+ * nowhere cheaper. Absent stays soft; ambiguous never does.
24
27
  * `preProbeAddressConflict` (QB2): a single record-side `lstat` (NO connect) of the target's
25
28
  * canonical socket path; `indeterminate` (EACCES/unknown) fails loud rather than silently
26
29
  * claiming "no conflict". The decider's later `inspectSocket` probe is a SEPARATE step.
@@ -37,7 +40,7 @@ import { executeControlSocketSend, } from "./entwurf-v2-send.js";
37
40
  import { resolveDeadControlSendFallback } from "./entwurf-v2-send-fallback.js";
38
41
  import { executeSpawnBgResume } from "./entwurf-v2-spawn.js";
39
42
  import { makeProductionSpawnBgResumeDeps } from "./entwurf-v2-spawn-production.js";
40
- import { defaultMetaMailboxDir, defaultMetaSessionsDir, enqueueMetaMessage, metaCapabilityFor, metaRecordExistsByGardenId, readMetaIdentityByGardenId, readMetaReceiverMarker, } from "./meta-session.js";
43
+ import { defaultMetaMailboxDir, defaultMetaSessionsDir, enqueueMetaMessage, metaCapabilityFor, metaRecordExistsByGardenId, readAddressableMetaIdentity, readMetaReceiverMarker, } from "./meta-session.js";
41
44
  import { resolveNativePushAdapter as realResolveNativePushAdapter, } from "./native-push/adapter.js";
42
45
  import { CONTROL_SOCKET_DIR, controlSocketPath, inspectControlSocketPath, inspectTargetControlSocket, isRecordLessSocketCandidate, } from "./socket-discovery.js";
43
46
  import { classifyConnectError, probeSocketLiveness } from "./socket-probe.js";
@@ -97,7 +100,7 @@ export function makeProductionEntwurfV2Deps(opts) {
97
100
  const s = opts.seams ?? {};
98
101
  const io = {
99
102
  metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
100
- readIdentity: s.readIdentity ?? readMetaIdentityByGardenId,
103
+ readIdentity: s.readIdentity ?? readAddressableMetaIdentity,
101
104
  readReceiverMarker: s.readReceiverMarker ?? ((gid) => readMetaReceiverMarker({ gardenId: gid })),
102
105
  inspectPath: s.inspectPath ?? inspectControlSocketPath,
103
106
  acquireLock: s.acquireLock ?? realAcquireLock,
@@ -32,7 +32,7 @@ import * as fs from "node:fs/promises";
32
32
  import { getEntwurfExplicitExtensions, mirrorChildStderr, readSessionIdentity } from "./entwurf-core.js";
33
33
  import { buildResumePiArgs } from "./entwurf-resume-args.js";
34
34
  import { releaseLock } from "./entwurf-v2-lock.js";
35
- import { readMetaIdentityByGardenId } from "./meta-session.js";
35
+ import { readAddressableMetaIdentity } from "./meta-session.js";
36
36
  import { inspectControlSocketPath, mapInspectionToLiveness } from "./socket-discovery.js";
37
37
  import { probeSocketLiveness } from "./socket-probe.js";
38
38
  /**
@@ -76,13 +76,19 @@ function requireProductionChild(child) {
76
76
  * record remembers whose it is — a mismatch means the transcriptPath is stale or
77
77
  * foreign, and resuming it would put a turn into a different being's session).
78
78
  *
79
+ * That check is per-record, and per-record is not enough: the v2 lock domain is keyed on
80
+ * GARDEN ID, so two records sharing one `nativeSessionId` would each pass their own
81
+ * integrity check and resume the SAME transcript concurrently under two different locks.
82
+ * Hence {@link readAddressableMetaIdentity} rather than the plain targeted read (#52) —
83
+ * a resume is exactly the moment a record stops being data and becomes an address.
84
+ *
79
85
  * Everything else is unchanged authority: readSessionIdentity (first model_change) for
80
86
  * provider/model/cwd, getEntwurfExplicitExtensions for bridge re-injection (#29 fail-fast).
81
87
  * Throws on anything that makes a resume impossible; each throw becomes the watcher's
82
88
  * `spawn-start-failed` (no child to watch → release), never a silent no-op.
83
89
  */
84
90
  export function resolveResumeLaunchIdentity(plan) {
85
- const record = readMetaIdentityByGardenId(plan.sessionId);
91
+ const record = readAddressableMetaIdentity(plan.sessionId);
86
92
  if (record.backend !== "pi") {
87
93
  throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} is a ${record.backend} citizen — spawn-bg resume is the pi rail.`);
88
94
  }
@@ -16,8 +16,13 @@
16
16
  * remains compatibility for an MCP host wrapper; the hook never writes a blind grandparent
17
17
  * marker because that may be the long-lived login shell.
18
18
  *
19
- * Two guards make a marker an IDENTITY rather than a hint, and a candidate is only trusted after
20
- * BOTH pass:
19
+ * Three guards make a marker an IDENTITY rather than a hint, and a candidate is only trusted
20
+ * after ALL of them pass:
21
+ * 0. a PLAUSIBLE owner (isPlausibleOwnerPid, shared with both writers and the generation cut):
22
+ * pid <= 1 cannot own a session, and guard 1 cannot catch it — for as long as the host
23
+ * is up, init IS still the very process the marker named. This is a rule about the
24
+ * marker's CLAIM, not about the owner's state, which is why it is asked first and is
25
+ * not opt-out (#53 A).
21
26
  * 1. pid + start-key (readMetaSenderMarker): the owner is still the very process that wrote it,
22
27
  * so a dead session's pid, reused by something else, cannot inherit its garden-id.
23
28
  * 2. the backing meta-record: the record store is the authority — a marker whose record was
@@ -30,7 +35,7 @@
30
35
  * the answer depend on which pid or backend happened to be read first; here lookup order carries
31
36
  * no meaning, and two live identities are a refusal rather than a race.
32
37
  */
33
- import { metaRecordExistsByGardenId, parentPid, readMetaIdentityByGardenId, readMetaSenderMarker, } from "./meta-session.js";
38
+ import { isPlausibleOwnerPid, metaRecordExistsByGardenId, parentPid, readMetaIdentityByGardenId, readMetaSenderMarker, } from "./meta-session.js";
34
39
  import { resolveNativePushAdapter } from "./native-push/adapter.js";
35
40
  /** Every native backend that mints a garden-id from its own hook and writes a sender marker. */
36
41
  export const META_SENDER_BACKENDS = ["claude-code", "antigravity"];
@@ -120,7 +125,13 @@ export function resolveTrustedMetaSenderIdentity(opts = {}) {
120
125
  markers.push(marker);
121
126
  }
122
127
  else {
123
- const ownerPids = (opts.ownerPids ?? [process.ppid, parentPid(process.ppid) ?? 0]).filter((p) => typeof p === "number" && p > 0);
128
+ // The candidate filter asks the same question the marker readers ask CAN this
129
+ // pid own a session — so it uses the same predicate rather than a second `> 0`
130
+ // literal. The default set is only the bridge's parent and grandparent, so init
131
+ // enters it just when the native host itself was reparented (a detached/daemonized
132
+ // Claude): narrow, but reachable, and a candidate that cannot be an owner has no
133
+ // business reaching the read at all (#53 A).
134
+ const ownerPids = (opts.ownerPids ?? [process.ppid, parentPid(process.ppid) ?? 0]).filter(isPlausibleOwnerPid);
124
135
  for (const ownerPid of [...new Set(ownerPids)]) {
125
136
  for (const backend of META_SENDER_BACKENDS) {
126
137
  const marker = readMetaSenderMarker({ backend, ownerPid, sendersDir: opts.sendersDir });