@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.
- package/AGENTS.md +4 -4
- package/CHANGELOG.md +20 -1
- package/DELIVERY.md +1 -1
- package/README.md +122 -10
- package/demo/README.md +2 -2
- package/docs/setup-clean-host.md +14 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +9 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +8 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +483 -34
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
- package/mcp/entwurf-bridge/src/index.ts +10 -12
- package/package.json +7 -7
- package/pi-extensions/entwurf-control.ts +12 -12
- package/pi-extensions/lib/acp/models.ts +12 -12
- package/pi-extensions/lib/entwurf-fact-provider.ts +9 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +8 -2
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +8 -2
- package/pi-extensions/lib/meta-sender-identity.ts +15 -5
- package/pi-extensions/lib/meta-session.ts +526 -38
- package/pi-extensions/meta-bridge-hook.ts +8 -2
- package/run.sh +30 -26
- package/scripts/agy-imprint.ts +15 -1
- package/scripts/check-acp-session-reuse.ts +1 -1
- package/scripts/check-acp-session-store.ts +3 -3
- package/scripts/check-agy-sender-identity.ts +83 -0
- package/scripts/check-entwurf-control-rpc.ts +2 -2
- package/scripts/check-entwurf-fact-provider.ts +9 -1
- package/scripts/check-entwurf-facts.ts +1 -1
- package/scripts/check-entwurf-mailbox-guard.ts +6 -2
- package/scripts/check-entwurf-resume-args.ts +6 -6
- package/scripts/check-entwurf-session-identity.ts +7 -6
- package/scripts/check-entwurf-v2-production.ts +4 -2
- package/scripts/check-entwurf-v2-spawn-production.ts +2 -2
- package/scripts/check-fresh-cut-gate.sh +305 -4
- package/scripts/check-meta-identity-consumers.ts +501 -1
- package/scripts/check-meta-listing.ts +91 -9
- package/scripts/check-meta-receiver-marker.ts +54 -0
- package/scripts/check-model-lock.ts +1 -1
- package/scripts/meta-bridge-fresh-cut.ts +164 -28
- package/scripts/pi_settings_io.py +65 -0
- package/scripts/register-pi-package.py +183 -37
- package/scripts/register-pi-provider.py +68 -10
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-meta-install-state.sh +1 -1
- package/scripts/smoke-pi-attach.ts +7 -2
- 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
|
|
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 another — swap 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.
|
|
262
|
-
- **The re-evaluation
|
|
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
|
-
-
|
|
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-
|
|
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.
|
|
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
|
|
265
|
-
> not re-scan the whole store per message. What every
|
|
266
|
-
> the per-entry half of the same contract:
|
|
267
|
-
> record that is not a regular file (a symlink is
|
|
268
|
-
> direction) and one whose body disagrees with its name,
|
|
269
|
-
>
|
|
270
|
-
>
|
|
271
|
-
>
|
|
272
|
-
>
|
|
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-
|
|
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.
|
|
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)
|
package/docs/setup-clean-host.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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:
|
|
393
|
-
//
|
|
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: (
|
|
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
|
|
21
|
-
// `/compat`,
|
|
22
|
-
//
|
|
23
|
-
// and appends the remainder, yielding the
|
|
24
|
-
// `…/dist/compat.js/providers/anthropic` (verified live: extension
|
|
25
|
-
// invisible to static typecheck which resolves against node_modules
|
|
26
|
-
// So `/compat`
|
|
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-
|
|
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-
|
|
52
|
-
// Anthropic's registry reports 1M for both Sonnet 5 and Opus
|
|
53
|
-
// entwurf surface
|
|
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,
|
|
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 ??
|
|
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 {
|
|
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 =
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
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 });
|