@junghanacs/entwurf 0.20.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BASELINE.md CHANGED
@@ -33,7 +33,7 @@ not **UNSUPPORTED** — the deliberate never, which today is native Windows only
33
33
  | Claude Code 2.1.138 | **unsupported** | Launcher refuses empty argv; no shell-form fallback |
34
34
  | Maintainer NixOS installed package | **certified** for `0.12.8-repair.1` | 2026-07-25 registry install → doctor exit 0 (HISTORY) |
35
35
  | Secondary Ubuntu installed package | **certified** for `0.12.8-repair.1` | 2026-07-25 same artifact, isolated agent dir → doctor exit 0 (HISTORY) |
36
- | macOS Claude meta-bridge | NOT CERTIFIED — pending physical host | No physical-Mac doctor yet; a CI runner has no Claude login. Physical-host rental is postponed past this 0.20.0 cut. |
36
+ | macOS Claude meta-bridge | NOT CERTIFIED — pending physical host | No physical-Mac doctor yet; a CI runner has no Claude login. Physical-host rental remains unscheduled; no cut has carried it. |
37
37
  | Darwin install fence (meta/copilot/omp-bridge + omp-receive) | portable (Linux or Darwin) | Four installers accept Darwin; python3/node/harness presence holds the seat, not the platform name. A Darwin install is not a rail receipt. |
38
38
  | Doctor fail-closed on unreadable environ | Linux measured; Darwin unmeasured | Copilot launch-flag and omp identity-carrier: missing `/proc/<pid>/environ` as predicate INPUT is `UNVERIFIABLE` (non-green), not a benign note. omp distinguishes `pgrep` exit 1 (absent) from exit 2 (enumeration failed); copilot distinguishes ENOENT/ESRCH (gone) from other errno (read denied). |
39
39
  | setup on uncertified platform with harness present | named non-green | All 13 harness units ask `harness_rail_certified_platform()`; uncertified → FAIL whose wording is not "install failed". Harness-absent Darwin setup (the CI cell) can still be computed green. |
@@ -59,7 +59,7 @@ Notes the table cannot carry without becoming prose again:
59
59
  bridge, and now runs dev wiring again.
60
60
  - **macOS evidence is split, not a support claim.** The Entwurf-only install surface is
61
61
  CERTIFIED (CI) (row above); the harness rails are NOT CERTIFIED — pending
62
- physical host (rental postponed past this cut). The package-level `os` field stays
62
+ physical host (rental remains unscheduled). The package-level `os` field stays
63
63
  unrestricted, and native Windows is UNSUPPORTED.
64
64
 
65
65
  **Operator acceptance rule:** on a claimed Claude host, reinstall from the released
package/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ All notable changes to this project will be documented here. Format follows [Kee
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.20.1 - 2026-09-10
8
+
9
+ ### Fixed
10
+
11
+ - **A second checkout of this repo no longer hijacks `--entwurf-control` from the installed one (#110).** The tracked `.pi/settings.json` shipped `packages: [".."]`, which registers "whichever checkout pi is running in"; `run.sh install` separately registers this root in user scope by absolute path. pi's package identity is `local:<resolved path>` and its dedupe collapses only EQUAL identities, so in the installed checkout the two entries are one package — and in any OTHER checkout (worktree, release scratch) they are two. Both then load `pi-extensions/entwurf-control.ts`, the loader awards `--entwurf-control` and `--emacs-agent-socket` to whoever registered first (project scope, i.e. the branch copy), and the INSTALLED extension fails to load with an error naming the installed path as the loser. Not silent — designed to be misread, while all seven `entwurf` model rows still printed and the pi session looked healthy. entwurf's own checkout now carries no project-scope self-registration in tracked bytes, and `install_local_package` skips writing one when the project it is installing into IS this root; a foreign project is unaffected, which is what `entwurf install <project>` is for. No realpath rule could have fixed this: two checkouts are genuinely two directories.
12
+ - **The gates moved with the contract.** `check-install-surface` S7c stops pinning the SHAPE (`packages == [".."]`) and pins the invariant it was there for — no host-absolute path in the tracked, biome-governed file — which "no packages key" satisfies strictly more. `smoke-user-scope-citizen` cells 11/11b–11e and 14 no longer seed themselves from this repo's own committed bytes: a witness that depends on us shipping the shape it tests dies with the shape, while the predicate it guards (`is_settings_relative_self`, still the thing that keeps an unrelated relative package like `../../repos/gh/andenken` from reading as ours) does not. Cell 14 now demands the stronger end-to-end property — installing into entwurf's own checkout writes no packages entry and leaves the file byte-identical — and new cell 15 spawns the pinned devDep pi against two real stand-in checkouts, planting the retired registration first and requiring the collision to APPEAR before asserting it is gone, so a sandbox that silently loads nothing cannot report a green. Both halves are replant-qualified (`[QK:SECOND-CHECKOUT-EXTENSION-COLLISION]`, `[QK:SELF-CHECKOUT-PROJECT-REGISTRATION]`). That new probe also taught the gate a fence it did not have: `pi --print` reads a non-tty stdin as piped prompt input, so the probe inherited the caller's stdin and hung for 1h56m under a supervised runner while passing instantly under every shell it had been tried in. It now reads `/dev/null` under a bounded `timeout`, so a probe that cannot finish goes red with its own name instead of hanging.
13
+
14
+ ### Changed
15
+
16
+ - **The bundled Claude ACP adapter moves to `@agentclientprotocol/claude-agent-acp 0.76.0`.** One refactor plus one opt-in extension; nothing reaches our surface. Declared runtime deps are unchanged (`@agentclientprotocol/sdk 1.4.0`, `@anthropic-ai/claude-agent-sdk 0.3.257`), so the anthropic peer floor does not move either. The one feature — upstream #1111's `recommendedValue` on AIR config options — is enabled only when `initialize` advertises it, and entwurf sends `clientCapabilities: {}`; the label normalization, `default` row and effort selector riding it are out of reach for a second, independent reason as well (`configOptions` has zero call sites here, and `EFFORT_CONFIG_ID` is never set). `setSessionConfigOption`, our only model-forcing wire call, is byte-identical across the bump. One change has a shape that WOULD reach us and does not: a string-path `configuredSettings` now does its `readFile`/`JSON.parse` on the unconditional `session/new` path, and entwurf passes `settings` as an object — so "entwurf never hands the adapter a settings PATH" is now stated as a contract at that call site rather than left as an accident. #96's readiness argument was re-measured rather than inherited: `mcpServerStatus` still has exactly two call sites, re-read at the new upstream coordinates, both still an auth handshake and not a readiness fence.
17
+ - **Documentation matched to shipped behavior.** A repo-wide stale-prose sweep corrected live claims that had drifted: OMP described as three installer units when it ships four, a fresh-cut quiescence instruction that named four of six identity writers, a gate docstring promising byte-pinning that its gate never performed, README speaking of macOS as arriving rather than shipped in 0.20.0, and ROADMAP carrying #72 as an open failure after it was closed as an external-reaper cause. Historical records — the dep-bump ledger, measurement receipts, archived handoffs, and every occurrence that names a retired symbol AS retired — were deliberately left intact.
18
+
19
+ ### Upgrade note
20
+
21
+ **Nothing to run.** A host that has already registered entwurf in user scope (any host that ran `./run.sh setup` or installed the npm package) is unaffected: that entry is what loaded the extensions from a foreign cwd all along, and it is untouched. What changes is a checkout that has NEVER been set up — it used to pick up the extensions from the tracked `packages: [".."]` and now does not. That is a narrower loss than it reads: the same tracked file names `entwurf-bridge` as a bare bin, which is only on PATH after setup, so a never-set-up clone never had the MCP hand either. Setup was already the prerequisite; this makes it the only one. To run a branch checkout's extensions deliberately, use `pi -ne -e pi-extensions/entwurf-control.ts`, or move ownership explicitly with `./run.sh takeover-user-scope`.
22
+
23
+ ### Verification
24
+
25
+ Lane receipts (oracle, 2026-09-10) plus prepare P4/P5 on this host.
26
+
27
+ - **`pnpm run check:full`** exit 0, 479 s (inherited, omp handoff on this tree) / again as the release-gate static MUST step (this run).
28
+ - **Prepare P5** `LIVE=1 ./run.sh release-gate --cut`:
29
+ scratch `/tmp/entwurf-release-gate-0.20.1.uuZCm8`, pane exit 0, **MUST: PASS=23 FAIL=0 SKIP=0**, BEHAVIOR: PASS=1 FAIL=0 SKIP=0, `cut: OK`. Qualification **393/393 KILLED**, origin HEAD `d72225e`, purity held. stdin closed (`exec </dev/null`); PWD in scratch; `PI_SESSION_ID` / `PI_AGENT_ID` / `CLAUDE_CONFIG_DIR` unset. Wall ~55 m (16:49:37→17:44:48 KST).
30
+ - **P9 at verdict:** prefix-blind census top was 28 `entwurf-chain` roots (Sep 2–4; not an owned `mkdtempSync` prefix, so not in the delete set). 5 reparented `/tmp` processes: 4 `entwurf-copilot-receive` Node stubs (ppid=1, ages 6h54m / 6h09m / 5h18m / 29m) named and not killed, plus the operator emacs daemon. Reclaimed 4 owned `check-probe-ordering-*` roots (64 K). Scratch preserved.
31
+
7
32
  ## 0.20.0 - 2026-09-09
8
33
 
9
34
  This range carries three lanes that are not one cause: the macOS
package/README.md CHANGED
@@ -88,14 +88,15 @@ CERTIFIED (CI) is weaker than a physical-host doctor green.
88
88
  The npm package has no `os` restriction; that is installability, not a
89
89
  support claim.
90
90
 
91
- **macOS in this release.** This release adds macOS support for Entwurf's
91
+ **macOS landed in 0.20.0.** That release added macOS support for Entwurf's
92
92
  install surface: the npm package installs, `entwurf --help` and `entwurf
93
93
  check-bridge` boot, and `entwurf setup` composes the integrations it finds.
94
94
  The four installers that previously rejected Darwin by platform name no longer
95
- do. We validated that surface only in macOS CI because the developers did not
95
+ do. That surface was validated only in macOS CI because the developers did not
96
96
  have a physical Mac. So macOS native-harness rails—marker join, an ACP turn,
97
- and mux included—are not yet certified; setup writes their wiring but reports
98
- them non-green. Linux's certified rail status is unchanged.
97
+ and mux included—are **NOT CERTIFIED pending physical host**; setup writes
98
+ their wiring but reports them non-green. Linux's certified rail status is
99
+ unchanged.
99
100
 
100
101
  On Darwin, `entwurf setup` that detects a harness is intentionally
101
102
  non-green: the wiring WAS written and nothing failed to install, but the
@@ -92,10 +92,10 @@ undifferentiated "supported" column is what let a Claude PASS read as if it also
92
92
 
93
93
  | Surface | Declaration | Class | What a green actually says |
94
94
  |---|---|---|---|
95
- | Entwurf package | `0.17.0` | shipped baseline | the package contract these rows belong to |
95
+ | Entwurf package | `0.20.0` | shipped baseline | the package contract these rows belong to |
96
96
  | pi runtime | devDep exact `0.85.1`, peer `>=0.85.1 <0.86` | **exact** oracle + **closed range** | built and certified against 0.85.1; hosts inside the range are accepted, and the ceiling moves only on measurement |
97
97
  | ACP wire SDK | `@agentclientprotocol/sdk 1.4.0` | **exact** | the shared wire oracle both adapters speak |
98
- | Claude ACP adapter | `@agentclientprotocol/claude-agent-acp 0.75.1` | **exact**, bundled | the adapter we ship and certify; resolved before any PATH fallback |
98
+ | Claude ACP adapter | `@agentclientprotocol/claude-agent-acp 0.76.0` | **exact**, bundled | the adapter we ship and certify; resolved before any PATH fallback |
99
99
  | Claude Agent SDK | `0.3.257` (transitive) | **exact** oracle | the runtime risk surface behind the adapter |
100
100
  | Anthropic SDK | `0.100.1` | **exact**, peer-resolution only | satisfies the Agent SDK peer floor (0.93.0+); never an API client here (gate L4) |
101
101
  | Claude Code runtime | `>=2.1.217` (`entwurf.claudeCodeFloor`) | **floor** | below it, hook args are silently dropped; entwurf enforces this itself |
@@ -151,6 +151,23 @@ different reasons, and collapsing them would hide a real risk**:
151
151
  entwurf passes that flag nowhere (repo grep, 0 hits). They are
152
152
  unreachable only because the common loop never invokes them (nor `logout`). Nothing upstream
153
153
  enforces that; it is our own call-site discipline, and it stops holding the moment we use one.
154
+ - **Advertised but never called — 0.76.0 adds exactly one.** Upstream #1111 puts a
155
+ `recommendedValue` on AIR config options, and it is **opt-in**: the adapter turns it on only
156
+ when `initialize`'s `clientCapabilities._meta.jetbrains.air.capabilities` names
157
+ `recommendedValue`. entwurf sends `clientCapabilities: {}` (`backend.ts:1758`), so
158
+ `useRecommendedValue` is false on every branch. The label normalization, the `default` row and
159
+ the effort selector that ride it are unreachable for a second, independent reason as well:
160
+ `[측정 2026-09-10]` `git grep -c configOptions pi-extensions/lib/acp/` is **0**, and entwurf
161
+ has never set `EFFORT_CONFIG_ID`, which is the other gate on the SDK-side `applyFlagSettings`.
162
+ The rest of 0.76.0 is one refactor: `resolveModelPreference` moved into a new
163
+ `dist/session-model.*` (diff is comments and formatting), `dist/session-effort.*` is new, and
164
+ `setSessionConfigOption` — our only model-forcing wire call — is byte-identical across
165
+ `v0.75.1..v0.76.0`.
166
+ - **One 0.76.0 change has a reachable SHAPE and is still inert for us.** When
167
+ `configuredSettings` is a STRING PATH, its `readFile` + `JSON.parse` moved out of
168
+ `resolvedProvider` and up onto the unconditional `session/new` path. entwurf passes `settings`
169
+ as an OBJECT (`tool-surface.ts:153`), so the branch is never taken. That makes "entwurf never
170
+ hands `settings` as a path" a contract rather than an accident; it is stated at that call site.
154
171
  - **The one 0.73.0 → 0.75.1 change that DOES reach us:** context compaction is now surfaced as a
155
172
  synthetic ACP tool lifecycle (0.75.0, #991) — a `tool_call` with `kind: "think"`, title
156
173
  `Compact conversation`, and `_meta.contextCompaction` schema v1 — where it used to arrive as
@@ -275,23 +292,23 @@ caller-session `_meta`, and cross-machine certification.
275
292
 
276
293
  A backend can return `newSession` before its declared MCP server is callable. This was
277
294
  observed intermittently on the Claude rail and directly on Cortex's private `mcp.json`
278
- path. Neither `claude-agent-acp` 0.75.1 nor the Cortex landing adds a client-side
295
+ path. Neither `claude-agent-acp` 0.76.0 nor the Cortex landing adds a client-side
279
296
  readiness fence over a session's declared MCP servers, and entwurf's common loop
280
297
  calls `mcpServerStatus()` nowhere.
281
- (Re-measured at the 0.73.0 → 0.75.1 bump, not inherited from the previous one and the
282
- 0.70.0 → 0.73.0 argument is not reused either. `mcpServerStatus` call sites in
283
- `src/acp-agent.ts` are **2 at both v0.73.0 and v0.75.1** `[측정 2026-09-06, git grep -c]`;
284
- they first appeared in 0.71.0 via `0cbbaf3` (MCP OAuth, LLM-25012), so the ADAPTER calls it
285
- where it once did not. Both were re-read at `v0.75.1 src/acp-agent.ts:1736` and `:1829`
286
- (the 0.73.0 coordinates were `:1618` and `:1711`): the first sits inside
298
+ (Re-measured at the 0.75.1 → 0.76.0 bump, not inherited the previous bump's argument is
299
+ not reused, the way the 0.73.0 → 0.75.1 entry did not reuse 0.70.0 0.73.0's.
300
+ `mcpServerStatus` call sites in `src/acp-agent.ts` are **2 at v0.75.1 and 2 at v0.76.0**
301
+ `[측정 2026-09-10, upstream v0.76.0/src/acp-agent.ts read directly, grep -n]`; they first
302
+ appeared in 0.71.0 via `0cbbaf3` (MCP OAuth, LLM-25012), so the ADAPTER calls it where it
303
+ once did not. Both were re-read at `v0.76.0 src/acp-agent.ts:1762` and `:1855`
304
+ (v0.75.1: `:1736` / `:1829`; v0.73.0: `:1618` / `:1711`): the first sits inside
287
305
  `authenticateMcpServers` behind `supportsMcpOAuth(query)` and skips every status that is not
288
306
  `needs-auth`; the second polls a SINGLE named server to `connected` under an OAuth deadline.
289
307
  Neither waits on every declared server before `newSession` returns. That is an auth
290
- handshake, not a readiness fence, so the boundary below is unchanged. The other reachable-surface findings also re-measured:
291
- AIR typed failures and the AIR file-change report stay capability-gated and
292
- unadvertised by entwurf; `providers/set` / `providers/disable` stay advertised
293
- unconditionally and uncalled; native subagents, async tasks, session forks, session
294
- titles, permission-mode kinds and clear-context planning are all new-but-uncalled.
308
+ handshake, not a readiness fence, so the boundary below is unchanged. The surrounding 200
309
+ lines are byte-identical and the region moved +26; the whole 0.76.0 delta is one refactor
310
+ plus one opt-in AIR extension that `clientCapabilities: {}` never enables, so the other
311
+ reachable-surface findings stand as re-measured at the previous bump.
295
312
  This bump changes no readiness behavior and closes no part of #72.)
296
313
 
297
314
  ### 11-7-a/b. Instrument and first measurement
@@ -384,5 +401,5 @@ Receipt, limits and the `completed`-branch gap: `scripts/raw-acp-compaction-meas
384
401
 
385
402
  - causal MCP-readiness diagnosis and, only with proof, a backend-invariant fence;
386
403
  - broader installed-host and cross-machine Cortex evidence;
387
- - any future Codex managed native-citizen lane—separate from ACP; 0.14.0 does not ship one;
404
+ - any future Codex managed native-citizen lane—separate from ACP; the lane is open as #95 and no release ships one;
388
405
  - persisted ACP resume/load, which is not implemented by today's in-memory reuse.
@@ -169,6 +169,7 @@ Use the managed install surface rather than editing omp's files by hand:
169
169
  ```bash
170
170
  entwurf install-omp-bridge # the BIRTH extension (a garden id per visible TUI session)
171
171
  entwurf install-omp-mcp # the MCP hand (this section)
172
+ entwurf install-omp-config # the operator setting (tools.xdev: false)
172
173
  entwurf install-omp-receive # the RECEIVER extension (mailbox watch + announce-only doorbell)
173
174
 
174
175
  entwurf doctor-omp-bridge
@@ -176,7 +177,7 @@ entwurf doctor-omp-mcp
176
177
  entwurf doctor-omp-receive
177
178
  ```
178
179
 
179
- Three units, not two. The receiver is what makes the citizen answerable at all — without it omp
180
+ Four units, not two. The receiver is what makes the citizen answerable at all — without it omp
180
181
  sends under its own garden id and every reply is refused as `mailbox-undeliverable` — and it is
181
182
  also a prerequisite of visible fresh: the fresh preflight refuses this host before touching tmux
182
183
  when the receive unit is missing, rather than opening a window that can never be reached.
@@ -244,7 +245,8 @@ bridge registered, **11 devices**: omp's own `ast_edit`, `debug`, `lsp`, `browse
244
245
  seven `entwurf_*`. So the default does not merely wrap entwurf — it wraps omp's own LSP and
245
246
  debugger too.
246
247
 
247
- **Set this on any omp host that is meant to work as a citizen:**
248
+ **`entwurf setup` writes this for a detected omp host, and `entwurf install-omp-config` is
249
+ the repair leaf. The value they own:**
248
250
 
249
251
  ```yaml
250
252
  # ~/.omp/agent/config.yml
@@ -22,7 +22,8 @@ the new generation.
22
22
  ## What certification checks
23
23
 
24
24
  `certifyActiveStore` is shared by the doctor and all identity writers: pi birth,
25
- Claude's `SessionStart`, agy's imprint, and `entwurf_register_native`. Every active
25
+ Claude's `SessionStart`, Copilot's first-prompt birth hook, OMP's birth extension,
26
+ agy's imprint, and `entwurf_register_native`. Every active
26
27
  record must be:
27
28
 
28
29
  - a regular, non-symlink `.meta.json` file;
@@ -43,7 +44,7 @@ are opened without following symlinks; implementation and race proofs live besid
43
44
 
44
45
  ## Running a cut
45
46
 
46
- First close every pi, Claude Code, and agy session that could own a citizen or
47
+ First close every pi, Claude Code, Copilot, OMP, and agy session that could own a citizen or
47
48
  transport artifact. Then run:
48
49
 
49
50
  ```bash
@@ -123,8 +123,8 @@ export const claudeAdapter = {
123
123
  },
124
124
  // MEASURED (2026-09-02, #93): claude-agent-acp's `usage_update` carries a
125
125
  // session-cumulative ESTIMATED cost (`cost.amount = message.total_cost_usd`)
126
- // and a context-occupancy scalar (`used`; both read at 0.75.1
127
- // `dist/acp-agent.js:3467-3482`). Both are measured, so backend.ts seals them.
126
+ // and a context-occupancy scalar (`used`; both read at 0.76.0
127
+ // `dist/acp-agent.js:3471-3486`). Both are measured, so backend.ts seals them.
128
128
  // The token partition is NOT declared here: ACP's only token carrier is a
129
129
  // per-turn round-trip aggregate, which is not what pi's four fields mean.
130
130
  sealsTurnAccounting: true,
@@ -120,6 +120,12 @@ export function buildClaudeSessionMeta(params, normalizedSystemPrompt) {
120
120
  ...(params.modelId ? { model: params.modelId } : {}),
121
121
  tools: [...params.tools],
122
122
  settingSources: [...params.settingSources],
123
+ // `settings` is an OBJECT here, and that is a contract, not a convenience.
124
+ // claude-agent-acp also accepts a STRING PATH, and at 0.76.0 the readFile +
125
+ // JSON.parse for that form moved out of `resolvedProvider` up onto the
126
+ // unconditional `session/new` path — a shape that WOULD reach us, and does not,
127
+ // only because we never hand it a path. Keep it inline: a path would put file
128
+ // IO, and its failure modes, inside every session creation.
123
129
  settings: {
124
130
  permissions: {
125
131
  allow: [...params.permissionAllow],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for pi, Claude Code, Copilot CLI, OMP, and Antigravity; Claude and Snowflake Cortex Code ACP backends. Codex has verified delivery-probe evidence, not a managed native-citizen lane.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -82,7 +82,7 @@
82
82
  "claudeCodeFloor": ">=2.1.217"
83
83
  },
84
84
  "dependencies": {
85
- "@agentclientprotocol/claude-agent-acp": "0.75.1",
85
+ "@agentclientprotocol/claude-agent-acp": "0.76.0",
86
86
  "@agentclientprotocol/sdk": "1.4.0",
87
87
  "@anthropic-ai/sdk": "0.100.1",
88
88
  "@modelcontextprotocol/sdk": "^1.12.1",
@@ -46,8 +46,8 @@ import type { AcpTextBlock } from "./context.js";
46
46
  * shape) and never onto those four. A field rename upstream fails the typecheck
47
47
  * here rather than silently zeroing the operator's cache-effect badge.
48
48
  */
49
- /** One `_meta.quota.token_count` row (claude-agent-acp 0.75.1
50
- * `dist/acp-agent.js:6493-6502`). `cachedInputTokens` is cache READS — the name
49
+ /** One `_meta.quota.token_count` row (claude-agent-acp 0.76.0
50
+ * `dist/acp-agent.js:6566-6575`). `cachedInputTokens` is cache READS — the name
51
51
  * differs from `usage.cachedReadTokens` because the shape is shared with
52
52
  * codex-acp; `cachedWriteTokens` is Claude's extra sibling. */
53
53
  export type AcpQuotaTokenCount = {
@@ -64,7 +64,7 @@ export type AcpPromptResponse = {
64
64
  /** Vendor-private, version-pinned. `_meta` is a standard ACP extension slot
65
65
  * whose values a client may not assume, and `quota` is NOT in
66
66
  * claude-agent-acp's exported types — it is produced by the private
67
- * `turnQuotaMeta()` (read at 0.75.1 `dist/acp-agent.js:6476-6485`). Read
67
+ * `turnQuotaMeta()` (read at 0.76.0 `dist/acp-agent.js:6549-6558`). Read
68
68
  * defensively, never structurally required, and re-measure on a pin move. */
69
69
  _meta?: {
70
70
  quota?: {
@@ -150,7 +150,7 @@ export interface AcpAdapterSettingsParams {
150
150
  *
151
151
  * During stream handling the vendor keeps a per-message snapshot in
152
152
  * `lastAssistantUsage`, but its `usage_update` publishes only the scalar `used`
153
- * value (read at claude-agent-acp 0.75.1 `dist/acp-agent.js:3853-3878`). Until
153
+ * value (read at claude-agent-acp 0.76.0 `dist/acp-agent.js:3857-3882`). Until
154
154
  * that partition is carried on the wire, NOTHING honest can go in pi's four
155
155
  * fields, so entwurf writes none of them.
156
156
  */
@@ -342,8 +342,8 @@ export const claudeAdapter: AcpBackendAdapter = {
342
342
 
343
343
  // MEASURED (2026-09-02, #93): claude-agent-acp's `usage_update` carries a
344
344
  // session-cumulative ESTIMATED cost (`cost.amount = message.total_cost_usd`)
345
- // and a context-occupancy scalar (`used`; both read at 0.75.1
346
- // `dist/acp-agent.js:3467-3482`). Both are measured, so backend.ts seals them.
345
+ // and a context-occupancy scalar (`used`; both read at 0.76.0
346
+ // `dist/acp-agent.js:3471-3486`). Both are measured, so backend.ts seals them.
347
347
  // The token partition is NOT declared here: ACP's only token carrier is a
348
348
  // per-turn round-trip aggregate, which is not what pi's four fields mean.
349
349
  sealsTurnAccounting: true,
@@ -137,7 +137,7 @@ function finiteOrZero(value: unknown): number {
137
137
  * "the accounting-grade figure per the SDK" — it also counts Task subagents,
138
138
  * sidechains, and INTERNAL CALLS SUCH AS COMPACTION, so its rows "can total more
139
139
  * than `token_count`" and are "the fuller picture, not a decomposition of it"
140
- * (read at claude-agent-acp 0.75.1 `dist/acp-agent.js:6465-6485`). The narrower
140
+ * (read at claude-agent-acp 0.76.0 `dist/acp-agent.js:6538-6558`). The narrower
141
141
  * `PromptResponse.usage` (== `quota.token_count`) is the MAIN AGENT LOOP only.
142
142
  *
143
143
  * The wider one is the right numerator because the denominator already has that
@@ -1237,7 +1237,7 @@ export function streamAcpTurn(
1237
1237
  // Writing zeros is not a placeholder for a better number we could compute:
1238
1238
  // the per-request partition is genuinely absent from the wire. The vendor
1239
1239
  // builds it in `lastAssistantUsage` and sends only its scalar sum (read at
1240
- // claude-agent-acp 0.75.1 `dist/acp-agent.js:3853-3878`) — #96.
1240
+ // claude-agent-acp 0.76.0 `dist/acp-agent.js:3857-3882`) — #96.
1241
1241
  //
1242
1242
  // But silence is NOT the resting state. A cache miss the operator never sees
1243
1243
  // is a false reading, not a modest one: a session can run for hours believing
@@ -1356,7 +1356,7 @@ export function streamAcpTurn(
1356
1356
  // between them is exactly why the notice below names a MECHANISM and
1357
1357
  // never a cause: claude-agent-acp's `conversation_reset` handler only
1358
1358
  // switches the SDK to a fresh conversation and touches no cost at all
1359
- // (read at 0.75.1 `dist/acp-agent.js:4282-4289`), while claude-agent-sdk
1359
+ // (read at 0.76.0 `dist/acp-agent.js:4286-4293`), while claude-agent-sdk
1360
1360
  // separately documents that "a mid-session /clear resets the running
1361
1361
  // total" (read at 0.3.257 `sdk.d.ts:4884`). A reset therefore PLAUSIBLY
1362
1362
  // explains a backwards total, but nothing here has MEASURED that it did,
@@ -60,8 +60,8 @@ export type AcpPiStreamState = {
60
60
  *
61
61
  * Last write wins, never a sum: both are latest session-level observations, and
62
62
  * one turn can legitimately see several (claude emits one per `result` message,
63
- * including a sub-agent's own — read at claude-agent-acp 0.75.1
64
- * `dist/acp-agent.js:3467-3482`), each carrying that result's current values.
63
+ * including a sub-agent's own — read at claude-agent-acp 0.76.0
64
+ * `dist/acp-agent.js:3471-3486`), each carrying that result's current values.
65
65
  */
66
66
  observedSessionCostUsd?: number;
67
67
  observedContextOccupancyTokens?: number;
@@ -346,8 +346,8 @@ export function applyAcpSessionUpdate(
346
346
  case "usage_update": {
347
347
  // `used` is OCCUPANCY-shaped — the backend's post-turn context size, not
348
348
  // the prompt response's turn aggregate. Claude sends `lastAssistantTotalUsage`
349
- // as `used` (read at claude-agent-acp 0.75.1
350
- // `dist/acp-agent.js:3867-3878`), after constructing that scalar from the
349
+ // as `used` (read at claude-agent-acp 0.76.0
350
+ // `dist/acp-agent.js:3871-3882`), after constructing that scalar from the
351
351
  // latest assistant snapshot (`:3853-3866`). pi reads `usage.totalTokens` as
352
352
  // exactly that occupancy (`calculateContextTokens(usage) = usage.totalTokens
353
353
  // || input + output + cacheRead + cacheWrite`, read at pi-coding-agent
@@ -150,6 +150,12 @@ export function buildClaudeSessionMeta(
150
150
  ...(params.modelId ? { model: params.modelId } : {}),
151
151
  tools: [...params.tools],
152
152
  settingSources: [...params.settingSources],
153
+ // `settings` is an OBJECT here, and that is a contract, not a convenience.
154
+ // claude-agent-acp also accepts a STRING PATH, and at 0.76.0 the readFile +
155
+ // JSON.parse for that form moved out of `resolvedProvider` up onto the
156
+ // unconditional `session/new` path — a shape that WOULD reach us, and does not,
157
+ // only because we never hand it a path. Keep it inline: a path would put file
158
+ // IO, and its failure modes, inside every session creation.
153
159
  settings: {
154
160
  permissions: {
155
161
  allow: [...params.permissionAllow],
package/run.sh CHANGED
@@ -201,7 +201,7 @@ Usage:
201
201
  ./run.sh smoke-copilot-mcp-state # Copilot MCP install/doctor/inverse regression. Offline/deterministic
202
202
  ./run.sh smoke-omp-bridge-state # OMP birth-extension install/doctor/inverse regression: placement, stale-writer detection, honest inverse, no-state refusal (structurally VALID as well as foreign), symlink refusal, ambiguous-agent-dir refusal (ledger M6), and a poisoned PI_CODING_AGENT_DIR that attracts no artifact. Fully sandboxed HOME/PI/XDG. Offline/deterministic
203
203
  ./run.sh smoke-agy-hooks-state # agy PreInvocation birth/sender hook install/doctor/inverse + direct stdin→meta-record regression. Offline/deterministic
204
- ./run.sh smoke-user-scope-citizen # 0.12.6 install-boundary: pi packages[] registration SSOT (register-pi-package.py) — idempotent + preserves unrelated + remove symmetry + fails loud, and the #86 C2 explicit ownership cells (project scope still normalizes ITS OWN stale entries; user scope refuses other owners and only takeover-user-scope moves the shared entry). Offline/hermetic (deps: bash+python3)
204
+ ./run.sh smoke-user-scope-citizen # 0.12.6 install-boundary: pi packages[] registration SSOT (register-pi-package.py) — idempotent + preserves unrelated + remove symmetry + fails loud, and the #86 C2 explicit ownership cells (project scope still normalizes ITS OWN stale entries; user scope refuses other owners and only takeover-user-scope moves the shared entry), plus the #110 self-checkout cells: the real `run.sh install <checkout>` drive leaves the tracked .pi/settings.json byte-identical, writes NO project-scope packages[] self-registration, and REPORTS the skip; cell 15 boots the pinned devDep pi against two stand-in checkouts and requires the --entwurf-control collision to APPEAR before asserting its absence. Offline/hermetic (deps: bash+python3+pinned pi)
205
205
  ./run.sh smoke-meta-prune # 1.0.0 meta-bridge Phase 4: listing-only store janitor regression gate — classify keep/orphan/stale/ambiguous, delete nothing. Offline/deterministic (deps: bash+node)
206
206
  ./run.sh smoke-meta-keyset-guard # 0.10.0 meta-bridge: keyset-owner guard regression — check-keyset-overlap + managed-keys SSOT (disjoint passes, collisions fail). Offline/hermetic (deps: bash+python3)
207
207
  ./run.sh check-meta-manifest-schema # 0.12.2 meta-bridge: CLI-version-INDEPENDENT static guard — plugin manifests pinned to the minimal keyset that validates on the lowest supported Claude (closed-schema regression that broke 0.12.1 install on floor) + desired_mcp installed-vs-clone dual-mode. Offline (deps: python3)
@@ -537,7 +537,28 @@ install_local_package() {
537
537
  mkdir -p "$project_dir/.pi"
538
538
  # packages[] registration via the shared SSOT — same is_entwurf_source
539
539
  # predicate + idempotency as user-scope and remove (not a substring match).
540
- python3 "$REPO_DIR/scripts/register-pi-package.py" "$project_dir/.pi/settings.json" "$REPO_DIR"
540
+ #
541
+ # #110: entwurf's OWN checkout is the one project that never gets this entry.
542
+ # pi's package identity is `local:<resolved path>` and dedupePackages collapses
543
+ # only EQUAL identities (package-manager.js getPackageIdentity/dedupePackages),
544
+ # so a project entry naming this checkout and the user-scope entry naming the
545
+ # installed root are the same package only while they are the same directory.
546
+ # In a SECOND checkout (worktree, release scratch) they differ, pi keeps both,
547
+ # and both copies of pi-extensions/entwurf-control.ts declare
548
+ # `--entwurf-control`: detectExtensionConflicts gives the flag to whoever
549
+ # registered first — project scope — and fails the INSTALLED extension while
550
+ # printing the installed path as the loser, which reads exactly backwards.
551
+ # user-scope already owns self-registration from any cwd, so the project entry
552
+ # was only ever a duplicate of it; dropping it removes the second identity in
553
+ # written bytes, the way the tracked `.pi/settings.json` no longer carries one.
554
+ # A FOREIGN project still gets its entry — that is what `entwurf install
555
+ # <project>` is for, and an npm consumer's REPO_DIR is inside node_modules, so
556
+ # it can never take this branch.
557
+ if [ "$(cd -P -- "$project_dir" 2>/dev/null && pwd)" = "$REPO_DIR" ]; then
558
+ echo "install: project-scope packages[] skipped — entwurf's own checkout self-registers through user scope (#110)"
559
+ else
560
+ python3 "$REPO_DIR/scripts/register-pi-package.py" "$project_dir/.pi/settings.json" "$REPO_DIR"
561
+ fi
541
562
  # entwurfProvider.mcpServers.entwurf-bridge (project scope — checkout-local, NO state; #46
542
563
  # Task 2) via the shared register-pi-provider SSOT: normalize the command to the bare stable
543
564
  # bin `entwurf-bridge` (ownership-classified: absent/managed-current/managed-legacy adopt, a
@@ -707,8 +728,11 @@ remove_local_package() {
707
728
  # install, so remove never over-deletes a look-alike repo (entwurf-notes, …)
708
729
  # that install would never have registered. Same rule, one shape further (#53 B):
709
730
  # register only ever WRITES the absolute path, so a settings-RELATIVE entry naming
710
- # this repo (the committed `".."` in <repo>/.pi/settings.json) is source, not
711
- # install state — remove leaves it and says so instead of editing tracked bytes.
731
+ # this repo is source an operator hand-wrote, not install state — remove leaves it
732
+ # and says so instead of editing bytes it never authored. entwurf's own checkout
733
+ # no longer ships such an entry (#110: user scope owns self-registration), so for
734
+ # THIS repo the call is a reported no-op; the predicate stays because the shape is
735
+ # still writable by hand and still must not be over-deleted.
712
736
  python3 "$REPO_DIR/scripts/register-pi-package.py" "$project_dir/.pi/settings.json" "$REPO_DIR" --remove
713
737
  # entwurfProvider.mcpServers.entwurf-bridge cleanup (project scope) via the shared SSOT: strip
714
738
  # our-managed shapes (the bare stable bin AND the legacy repo start.sh path — a true user
@@ -5775,9 +5799,11 @@ case "$cmd" in
5775
5799
  check_shell_quote
5776
5800
  ;;
5777
5801
  check-install-surface)
5778
- # 0.12.7 — structural half of the node_modules strip-types fence: run_ts is the only
5779
- # crossing, every operator subcommand has a compiled twin, bin wrappers branch, dev
5780
- # gates stay out of the tarball, and offline smokes never touch the real $HOME.
5802
+ # structural half of the node_modules strip-types fence: run_ts is the only crossing,
5803
+ # every operator subcommand has a compiled twin, bin wrappers branch, dev gates stay out
5804
+ # of the tarball, offline smokes carry no OBVIOUS write to the real $HOME (a static
5805
+ # tripwire, never a sandbox proof), and S7 pins the candidate .pi/settings.json — the
5806
+ # shared release skill, the stable bridge bin, and NO packages key (#110).
5781
5807
  # check-pack-install owns the dynamic half (it drives the installed commands).
5782
5808
  run_ts scripts/check-install-surface.ts
5783
5809
  ;;
@@ -72,7 +72,7 @@ interface WireUsage {
72
72
  * One `_meta.quota.token_count` row. The field NAMES deliberately differ from
73
73
  * `PromptResponse.usage`: cache reads are `cachedInputTokens` here because the
74
74
  * shape is shared with codex-acp, and `cachedWriteTokens` is Claude's extra
75
- * sibling (read at claude-agent-acp 0.75.1 `dist/acp-agent.js:6493-6502`).
75
+ * sibling (read at claude-agent-acp 0.76.0 `dist/acp-agent.js:6566-6575`).
76
76
  * Reading a quota row with the `usage` field names silently yields zeros, so the
77
77
  * fixture below spells the vendor's names out rather than reusing `WireUsage`.
78
78
  */
@@ -137,7 +137,7 @@ function makeFakeChild() {
137
137
  *
138
138
  * Each `prompt` call consumes the next TurnScript: it first pushes that turn's
139
139
  * `usage_update` notification (the wire the running cost total actually arrives
140
- * on — read at claude-agent-acp 0.75.1 `dist/acp-agent.js:3467-3482`), then answers the
140
+ * on — read at claude-agent-acp 0.76.0 `dist/acp-agent.js:3471-3486`), then answers the
141
141
  * prompt with that turn's `PromptResponse.usage` (the wire the turn aggregate
142
142
  * arrives on). Both orderings are the real one: the notification precedes the
143
143
  * response, because the SDK emits it from the `result` message that ENDS the turn.
@@ -393,8 +393,8 @@ try {
393
393
  // AGENT LOOP only. `_meta.quota.model_usage` comes from `result.modelUsage` and
394
394
  // also counts Task subagents, sidechains and INTERNAL CALLS SUCH AS COMPACTION;
395
395
  // the vendor states its rows "can total more than `token_count`" and are "the
396
- // fuller picture, not a decomposition of it" (read at claude-agent-acp 0.75.1
397
- // `dist/acp-agent.js:6468-6474`).
396
+ // fuller picture, not a decomposition of it" (read at claude-agent-acp 0.76.0
397
+ // `dist/acp-agent.js:6541-6547`).
398
398
  //
399
399
  // The wide one is required, not merely nicer, because the DENOMINATOR already
400
400
  // has that scope: turn cost is the adjacent diff of the backend's running total,
@@ -608,8 +608,8 @@ try {
608
608
  // identity that produces the bound is a property of the MAIN AGENT LOOP's
609
609
  // cache breakpoints. `_meta.quota.model_usage` is a WIDER scope: the vendor
610
610
  // states those rows also count Task subagents, sidechains, and INTERNAL
611
- // CALLS SUCH AS COMPACTION (read at claude-agent-acp 0.75.1
612
- // `dist/acp-agent.js:6465-6485`). Mixing the two scopes inflates the bound
611
+ // CALLS SUCH AS COMPACTION (read at claude-agent-acp 0.76.0
612
+ // `dist/acp-agent.js:6538-6558`). Mixing the two scopes inflates the bound
613
613
  // through both remaining terms that mention cacheWrite:
614
614
  // max(0, occupancy − cacheWrite) shrinks as wide cacheWrite grows, so
615
615
  // less is subtracted; min(rawBound, cacheWrite) rises with it.
@@ -749,9 +749,9 @@ try {
749
749
  // CELL 3 — a turn with NO cost notification holds the baseline.
750
750
  //
751
751
  // Measured upstream: the result-path `usage_update` carries cost (read at
752
- // claude-agent-acp 0.75.1 `dist/acp-agent.js:3467-3482`), while other
752
+ // claude-agent-acp 0.76.0 `dist/acp-agent.js:3471-3486`), while other
753
753
  // `usage_update` paths can carry `used` without cost (for example the
754
- // rate-limit path at `:4269-4277`). A live thinkpad ledger shows such turns
754
+ // rate-limit path at `:4273-4281`). A live thinkpad ledger shows such turns
755
755
  // really occur. The honest handling is to HOLD the baseline so the amount lands
756
756
  // in the NEXT diff: misattributed by turn, exact by session. Rebaselining to 0
757
757
  // there would double-count the whole prefix.
@@ -831,7 +831,7 @@ try {
831
831
  // CELL 4 — a DECREASING session total is never silently absorbed.
832
832
  //
833
833
  // `conversation_reset` switches the session to a fresh transcript (read at
834
- // claude-agent-acp 0.75.1 `dist/acp-agent.js:4282-4289`), but whether that
834
+ // claude-agent-acp 0.76.0 `dist/acp-agent.js:4286-4293`), but whether that
835
835
  // changes `total_cost_usd` is an SDK-internal value we cannot observe here.
836
836
  // A diff can therefore go negative in a session we are still holding.
837
837
  // Absorbing it quietly would both misreport the turn and destroy the only
@@ -846,7 +846,7 @@ let manifestCount: number;
846
846
  "mux-launcher-fence": 7,
847
847
  "mux-parent-artifact": 3,
848
848
  "pack-install": 2,
849
- "pi-package-ownership": 6,
849
+ "pi-package-ownership": 8,
850
850
  "mux-resume-call": 12,
851
851
  "omp-birth": 13,
852
852
  "omp-fresh": 24,
@@ -22,6 +22,10 @@
22
22
  * smoke that swaps HOME into a sandbox swaps XDG_DATA_HOME with it — moving HOME
23
23
  * alone still writes real install-state below the inherited XDG root, which is
24
24
  * exactly how a verification sweep polluted a live host's provenance (2026-07-14).
25
+ * S5b every offline smoke that swaps HOME also swaps XDG_DATA_HOME (HOME alone still
26
+ * writes real install-state below the inherited XDG root).
27
+ * S5c every MUTATING run.sh drive in the offline floor is sandboxed at every root it
28
+ * writes (agent dir + XDG + HOME), not only by an enclosing export.
25
29
  * S5d a gate may isolate by PROCESS BOUNDARY instead of by HOME swap: lines inside a
26
30
  * `CONTAINER_RUNNER_EOF` heredoc run in a container, so their $HOME is not this
27
31
  * host's and S5 must not read them as host writes. The exemption covers the BLOCK,
@@ -31,7 +35,10 @@
31
35
  * contains it while executing right here).
32
36
  * S7 the release operator surface is one repo-local Agent Skill shared by Claude Code and
33
37
  * pi: project settings point pi at `.claude/skills`, land/prepare/make/publish coexist in
34
- * one SKILL.md, the exact-SHA CI oracle ships beside it, and retired prompt copies stay absent.
38
+ * one SKILL.md, the exact-SHA CI oracle ships beside it, and retired prompt copies stay
39
+ * absent. S7c also pins that the tracked settings carry NO packages key — entwurf's own
40
+ * checkout self-registers through user scope alone (#110).
41
+ * S6 tracked first-party text sources are NUL-free (a stray NUL makes git show nothing).
35
42
  *
36
43
  * HONEST SCOPE — what a green run does and does not mean. S1-S4 are structural: they read the
37
44
  * dispatch graph and the build manifest, so they hold for any entrypoint written in this repo's
@@ -39,8 +46,8 @@
39
46
  * of variable aliasing, and it does not see a path assembled across several variables, built
40
47
  * inside an embedded python/node heredoc, or reached through a helper in another file. A green
41
48
  * S5 therefore means "no obvious destructive line", NOT "verification is sandboxed". The real
42
- * guarantee is running the whole offline floor under a swapped HOME; that is an open item in
43
- * NEXT.md, not a claim made here. Every S was mutation-checked, including bypasses found in
49
+ * guarantee would be running the whole offline floor under a swapped HOME; that is not done,
50
+ * and not claimed here. Every S was mutation-checked, including bypasses found in
44
51
  * review — do not add an S without proving it fails on the bug it names.
45
52
  *
46
53
  * Read-only: parses sources, spawns nothing.
@@ -478,11 +485,20 @@ const operatorCmds = [...targets].filter(([cmd, ts]) => !isDevGate(cmd) && ts.le
478
485
  (settingsParseError === "" && Array.isArray(settings.skills) && settings.skills.includes("../.claude/skills")),
479
486
  settingsParseError || `${settingsRel}: expected skills to include ../.claude/skills`,
480
487
  );
488
+ // S7c pins an INVARIANT, not a shape. #53 B: a host-absolute path must never reach
489
+ // this tracked, biome-governed file. It used to be pinned as "the entry is exactly
490
+ // `..`", which satisfied that invariant and carried a second, unintended one — the
491
+ // checkout registers ITSELF. #110 showed what that second one buys: user scope
492
+ // already registers this root absolutely, so in any OTHER checkout the two entries
493
+ // name different directories, pi's dedupe (identity = `local:<resolved path>`) keeps
494
+ // both, and two copies of pi-extensions/entwurf-control.ts fight over
495
+ // `--entwurf-control` — the branch copy wins and the INSTALLED one fails, printing
496
+ // the installed path as the loser. No packages key at all satisfies #53 B strictly
497
+ // more than `..` did, and leaves self-registration where it is owned.
481
498
  ok(
482
- "S7c: candidate project settings keep the local package source portable",
483
- settingsText === null ||
484
- (Array.isArray(settings.packages) && settings.packages.length === 1 && settings.packages[0] === ".."),
485
- `${settingsRel}: expected packages to be the settings-relative repo root '..'`,
499
+ "S7c: candidate project settings never bind entwurf to a checkout path",
500
+ settingsText === null || settings.packages === undefined,
501
+ `${settingsRel}: expected NO packages key entwurf's own checkout self-registers through user scope (#110)`,
486
502
  );
487
503
  const bridge = settings.entwurfProvider?.mcpServers?.["entwurf-bridge"];
488
504
  ok(
@@ -87,6 +87,32 @@
87
87
  "timeoutSeconds": 300,
88
88
  "signature": "[QK:MISSING-OWNER-AUTO-TAKEOVER]",
89
89
  "signatureSource": "scripts/smoke-user-scope-citizen.sh"
90
+ },
91
+ {
92
+ "claim": "SECOND-CHECKOUT-EXTENSION-COLLISION",
93
+ "title": "entwurf's own checkout ships a project-scope self-registration again, so any SECOND checkout registers a rival copy of pi-extensions beside the installed one and wins --entwurf-control while the installed extension fails, printing the installed path as the loser (#110)",
94
+ "subject": ".pi/settings.json",
95
+ "find": ["\"skills\": [\"../.claude/skills\"],\n\t\"entwurfProvider\": {"],
96
+ "replace": ["\"skills\": [\"../.claude/skills\"],\n\t\"packages\": [\"..\"],\n\t\"entwurfProvider\": {"],
97
+ "gate": ["bash", "run.sh", "smoke-user-scope-citizen"],
98
+ "timeoutSeconds": 300,
99
+ "signature": "[QK:SECOND-CHECKOUT-EXTENSION-COLLISION]",
100
+ "signatureSource": "scripts/smoke-user-scope-citizen.sh"
101
+ },
102
+ {
103
+ "claim": "SELF-CHECKOUT-PROJECT-REGISTRATION",
104
+ "title": "install registers entwurf project-scope into entwurf's OWN checkout, so `./run.sh setup` writes back the self-registration the tracked file no longer carries — the same #110 collision, arriving through the writer instead of through committed bytes",
105
+ "subject": "run.sh",
106
+ "find": [
107
+ " if [ \"$(cd -P -- \"$project_dir\" 2>/dev/null && pwd)\" = \"$REPO_DIR\" ]; then\n echo \"install: project-scope packages[] skipped — entwurf's own checkout self-registers through user scope (#110)\"\n else\n python3 \"$REPO_DIR/scripts/register-pi-package.py\" \"$project_dir/.pi/settings.json\" \"$REPO_DIR\"\n fi"
108
+ ],
109
+ "replace": [
110
+ " python3 \"$REPO_DIR/scripts/register-pi-package.py\" \"$project_dir/.pi/settings.json\" \"$REPO_DIR\""
111
+ ],
112
+ "gate": ["bash", "run.sh", "smoke-user-scope-citizen"],
113
+ "timeoutSeconds": 300,
114
+ "signature": "[QK:SELF-CHECKOUT-PROJECT-REGISTRATION]",
115
+ "signatureSource": "scripts/smoke-user-scope-citizen.sh"
90
116
  }
91
117
  ]
92
118
  }
@@ -7,7 +7,9 @@ Both Python writers touch the SAME file (`<repo>/.pi/settings.json` at project s
7
7
  - register-pi-provider.py → entwurfProvider.mcpServers.entwurf-bridge
8
8
 
9
9
  A settings file can be tracked and owned by a formatter (this repo's own is: tab
10
- indented, biome-governed, pinned byte-for-byte by check-install-surface S7b–S7d), so a
10
+ indented, biome-governed, its skills/packages/provider-bin invariants pinned by
11
+ check-install-surface S7b-S7d and its bytes pinned end to end by smoke-user-scope-citizen
12
+ cell 14b), so a
11
13
  writer that re-serializes it in its own house style turns `install` into a source edit.
12
14
  #53 B closed that for the packages writer and left the provider writer open — the same
13
15
  file, the same defect, one function call away. The rules therefore live HERE rather than
@@ -22,29 +22,36 @@ settings-RELATIVE entry that resolves to repo_dir (see below). A look-alike repo
22
22
  removed. Every non-entwurf package and every other settings key is preserved.
23
23
 
24
24
  CANONICAL IS NOT ONLY THE ABSOLUTE PATH. A packages[] entry is resolved by pi
25
- against the SETTINGS FILE'S OWN DIRECTORY, so this repo's committed
26
- `.pi/settings.json` names itself portably as `".."` the exact form
27
- check-install-surface S7c pins. Comparing entries against the resolved absolute
28
- path ALONE did not recognize that as entwurf, so `setup` appended the absolute
29
- path beside it and rewrote the tracked, biome-governed file in a foreign style:
30
- a dev clone went RED at `pnpm check` step 1, diagnosed as a "formatting" error
31
- (#53 B). Two rules follow, and they are the same rule read forwards and backwards:
25
+ against the SETTINGS FILE'S OWN DIRECTORY, so a settings-relative entry such as
26
+ `".."` can name the very repo being registered. Comparing entries against the
27
+ resolved absolute path ALONE did not recognize that as entwurf, so `setup` appended
28
+ the absolute path beside it and rewrote a tracked, biome-governed file in a foreign
29
+ style: a dev clone went RED at `pnpm check` step 1, diagnosed as a "formatting"
30
+ error (#53 B). Two rules follow, and they are the same rule read forwards and
31
+ backwards:
32
32
  - register: an entry that RESOLVES to repo_dir is already canonical → no-op, and
33
33
  when a rewrite is genuinely needed a settings-relative self-reference is kept
34
34
  as the survivor, so the portable form is never silently absolutized;
35
35
  - remove: register only ever WRITES the absolute form, so a settings-relative
36
- self-reference cannot be install's own output — it was authored by the repo
37
- (this one commits `".."`) or by the operator. Uninstall is install's inverse,
38
- not a settings editor: it leaves that ONE class in place and SAYS SO on stdout,
39
- rather than deleting source bytes install never wrote. The cost is stated
40
- rather than hidden: on a settings file whose only entwurf entry is relative,
41
- `--remove` is a no-op and the package stays registered until a human edits it.
42
- would_remove() asks the same split, so `--dry-run` can never disagree with what
43
- `--remove` does.
36
+ self-reference cannot be install's own output — it was authored by hand.
37
+ Uninstall is install's inverse, not a settings editor: it leaves that ONE class
38
+ in place and SAYS SO on stdout, rather than deleting source bytes install never
39
+ wrote. The cost is stated rather than hidden: on a settings file whose only
40
+ entwurf entry is relative, `--remove` is a no-op and the package stays
41
+ registered until a human edits it. would_remove() asks the same split, so
42
+ `--dry-run` can never disagree with what `--remove` does.
43
+ This repo's own `.pi/settings.json` USED to be the load-bearing instance of that
44
+ shape. It no longer carries a packages[] entry at all: entwurf's checkout
45
+ self-registers through USER scope only, because a project entry naming "whichever
46
+ checkout I am in" plus a user entry naming the installed one are two package
47
+ identities to pi, and a second checkout then loads a rival copy of pi-extensions
48
+ and wins `--entwurf-control` (#110). The predicate stays because the shape is still
49
+ writable by hand, and because resolving-then-comparing is also what keeps an
50
+ unrelated relative package like `../../repos/gh/andenken` from being read as ours.
44
51
  A rewrite also preserves the file's existing indentation instead of forcing 2
45
52
  spaces. That is narrower than it sounds — it keeps the indent UNIT, not a
46
- formatter's line-collapsing decisions — so the byte-identity guarantee this repo's
47
- own settings depend on comes from the no-op path, never from the writer's style.
53
+ formatter's line-collapsing decisions — so a byte-identity guarantee always comes
54
+ from the no-op path, never from the writer's style.
48
55
 
49
56
  This wiring (user scope) dropped when `pi install` was removed from setup
50
57
  (2026-07-03: `--entwurf-control` unknown in a foreign cwd). Extracting it here
@@ -129,11 +136,11 @@ def source_of(item: object) -> object:
129
136
  def is_settings_relative_self(source: str, settings_dir: str, repo_dir: str) -> bool:
130
137
  """True iff this entry is a SETTINGS-RELATIVE path naming repo_dir itself.
131
138
 
132
- This is the repo's own committed portable form (`".."` in
133
- <repo>/.pi/settings.json) functionally identical to the absolute entry,
134
- because pi resolves a relative package source against the settings file's own
135
- directory. It is also the one shape register never writes, which is what lets
136
- remove treat it as source rather than as install state.
139
+ The portable form (`".."` in <repo>/.pi/settings.json) — functionally identical
140
+ to the absolute entry, because pi resolves a relative package source against the
141
+ settings file's own directory. It is also the one shape register never writes,
142
+ which is what lets remove treat it as source rather than as install state. This
143
+ repo stopped shipping it at #110; an operator may still write it by hand.
137
144
 
138
145
  Anything carrying a scheme (npm:/git:/https:) is a spec, not a path; anything
139
146
  absolute (`/`, `~`) is not settings-relative. Everything else is resolved and
@@ -159,8 +166,8 @@ def is_entwurf_source(source: str, repo_dir: str, settings_dir: str | None = Non
159
166
  - the exact resolved repo dir;
160
167
  - an npm install path ending in node_modules/@junghanacs/entwurf;
161
168
  - an explicit npm package source for @junghanacs/entwurf;
162
- - a settings-relative path that RESOLVES to the repo dir (the committed
163
- portable `".."`), when the caller supplies settings_dir;
169
+ - a settings-relative path that RESOLVES to the repo dir (the portable
170
+ `".."` form), when the caller supplies settings_dir;
164
171
  - a local filesystem path whose final directory is literally "entwurf"
165
172
  (dev clone / stale move). Remote URL/git-like strings are NOT treated as
166
173
  local paths merely because their last segment is "entwurf".
@@ -242,7 +249,7 @@ def register(settings_path: Path, repo_dir_arg: str) -> str:
242
249
  # the canonical absolute string or as a settings-relative path resolving there.
243
250
  # Both are the same registration to pi, so both are a no-op: order-insensitive, no
244
251
  # rewrite, mtime stable. Recognizing only the absolute form is what made `setup`
245
- # duplicate this repo's own committed `".."` and restyle the tracked file (#53 B).
252
+ # duplicate a settings-relative `".."` and restyle the tracked file (#53 B).
246
253
  # Both arms require the STRING form, so an object-form entry still collapses to a
247
254
  # canonical string exactly as before.
248
255
  if len(entwurf_entries) == 1 and isinstance(entwurf_entries[0], str) and (
@@ -252,8 +259,9 @@ def register(settings_path: Path, repo_dir_arg: str) -> str:
252
259
  return "noop"
253
260
 
254
261
  # A rewrite keeps the PORTABLE form when the file already had one: absolutizing a
255
- # committed `".."` would repair the duplicate and dirty the tracked bytes in the
256
- # same breath. Otherwise the canonical absolute path is what install writes.
262
+ # hand-written `".."` would repair the duplicate and dirty bytes install never
263
+ # wrote in the same breath. Otherwise the canonical absolute path is what install
264
+ # writes.
257
265
  survivor = next(
258
266
  (
259
267
  source_of(e) for e in entwurf_entries
@@ -277,7 +285,7 @@ def _removable(packages: list, repo_dir: str, settings_dir: str) -> tuple[list,
277
285
 
278
286
  PRESERVED = settings-relative self-references, the one shape register CANNOT
279
287
  have produced (it always writes the resolved absolute path). Such an entry is
280
- the repo's committed portable registration or the operator's own hand edit.
288
+ the operator's own hand edit.
281
289
  Deleting it would make uninstall a source editor, which is the defect #53 B is
282
290
  about, pointed the other way. main() prints what was kept so the incompleteness
283
291
  is loud rather than silent.
@@ -665,7 +673,7 @@ def remove_user(settings_path: Path, repo_dir_arg: str, state_path: Path, orphan
665
673
  else:
666
674
  print(f"remove: no entwurf packages[] entry naming this root ({settings_path})")
667
675
  if preserved_rel:
668
- print(f"remove: kept {len(preserved_rel)} settings-relative entwurf entr{'y' if len(preserved_rel) == 1 else 'ies'} — committed/operator source, not install state")
676
+ print(f"remove: kept {len(preserved_rel)} settings-relative entwurf entr{'y' if len(preserved_rel) == 1 else 'ies'} — operator source, not install state")
669
677
  if foreign_like:
670
678
  print(f"remove: kept {len(foreign_like)} entwurf-shaped entr{'y' if len(foreign_like) == 1 else 'ies'} with no recorded owner "
671
679
  "(not provably this root's) — use './run.sh doctor-pi-package' / takeover-user-scope to resolve ownership")
@@ -829,7 +837,7 @@ def main(argv: list[str]) -> int:
829
837
  if kept:
830
838
  print(
831
839
  f"remove: kept {kept} settings-relative entwurf entr{'y' if kept == 1 else 'ies'} "
832
- f"({settings_path}) — install never writes that form, so it is committed/operator "
840
+ f"({settings_path}) — install never writes that form, so it is operator "
833
841
  "source, not install state; edit the file by hand to drop it"
834
842
  )
835
843
  return 0
@@ -25,8 +25,9 @@ Scope asymmetry (봉인계약 4·6, REASONED — not the unfounded asymmetry dev
25
25
  of our key is tidied; siblings kept.
26
26
  project <repo>/.pi/settings.json — checkout-LOCAL, disposable, re-creatable, and `run.sh
27
27
  remove` already covers it → NO state. install normalizes the command; remove strips
28
- our-managed shapes (the bare bin AND the legacy repo path). project-scope state is a
29
- NAMED FOLLOW-UP (NEXT), deliberately out of this lane.
28
+ our-managed shapes (the bare bin AND the legacy repo path). project scope takes NO
29
+ state, deliberately: it is checkout-local, disposable, and `run.sh remove` is its
30
+ whole inverse.
30
31
 
31
32
  USER-SCOPE OWNER BINDING (#86 C2): the user install-state additionally records
32
33
  `installerRoot` (the repo/package root that installed the key), so an old root's
@@ -3,7 +3,7 @@
3
3
  // LIVE=1 ./run.sh smoke-acp-raw-turn-live
4
4
  //
5
5
  // What this proves (and ONLY this): the pinned Claude ACP adapter
6
- // (@agentclientprotocol/claude-agent-acp@0.75.1) spawns, speaks the ACP wire
6
+ // (@agentclientprotocol/claude-agent-acp@0.76.0) spawns, speaks the ACP wire
7
7
  // protocol over stdio NDJSON, and returns one real model turn. It is the
8
8
  // bytes-flow proof that the S2a dep surface is not just installable but
9
9
  // actually drivable — before any provider/overlay/streamSimple code (S2b+).
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # smoke-user-scope-citizen — deterministic gate for pi packages[] citizen
3
- # registration/removal (register-pi-package.py). Offline/hermetic: a fake
4
- # settings file + a fake REPO_DIR under $TMP, no pi, no network, no ~/.pi touched.
3
+ # registration/removal (register-pi-package.py). Offline/hermetic: fake settings
4
+ # files and fake REPO_DIRs under $TMP, sandboxed HOME/PI_CODING_AGENT_DIR/XDG,
5
+ # no network, nothing of the operator's ~/.pi read or written.
5
6
  #
6
7
  # Guards the wiring that dropped when `pi install` was removed from setup
7
8
  # (2026-07-03: `--entwurf-control` / `--emacs-agent-socket` unknown in a foreign
@@ -10,10 +11,14 @@
10
11
  # in another repo. This gate is that missing tripwire: if the registration ever
11
12
  # regresses, `pnpm run check:full` goes red.
12
13
  #
13
- # Cells 11–13 close the second omission (#53 B): every case here drove a fake
14
- # settings file with ABSOLUTE entries, so the portable, settings-relative form this
15
- # repo actually commits was never registered against — and `setup` duplicated and
16
- # restyled the tracked file for four cuts without a single gate seeing it.
14
+ # Cells 11–13 close the second omission (#53 B): every case before them drove a fake
15
+ # settings file with ABSOLUTE entries, so the portable, settings-relative form was
16
+ # never registered against — and `setup` duplicated and restyled a tracked file for
17
+ # four cuts without a single gate seeing it.
18
+ #
19
+ # Cell 15 closes the third (#110), and it is the one cell here that SPAWNS pi: the
20
+ # defect is not a byte, it is which of two checkouts wins `--entwurf-control`, and
21
+ # only the real loader can say. It uses the pinned devDep pi, never PATH.
17
22
  set -euo pipefail
18
23
  export PYTHONDONTWRITEBYTECODE=1 # snapshot purity: no ignored scripts/__pycache__ writes under qualification
19
24
 
@@ -193,69 +198,64 @@ assert d['defaultProvider']=='openai-codex', 'run.sh remove-user-scope dropped a
193
198
  # idempotent: a second remove-user-scope is a clean no-op (no crash on absent entry)
194
199
  if XDG_DATA_HOME="$TMP/xdg" PI_CODING_AGENT_DIR="$AGENT_DIR" bash "$RUN" remove-user-scope >/dev/null 2>&1; then ok "run.sh remove-user-scope is idempotent (no-op second run)"; else bad "run.sh remove-user-scope second run crashed"; fi
195
200
 
196
- # ── 11–13. #53 B: install must not dirty a tracked, formatter-governed settings file
201
+ # ── 11–13. #53 B: a settings-relative self entry is source, never install state
197
202
  # The cell whose ABSENCE let this ship. Every case above drove a fake settings file
198
- # with absolute entries, so the one shape this repo actually commits — the portable,
199
- # settings-relative `".."` that check-install-surface S7c pins was never registered
200
- # against. `setup` therefore appended the absolute path BESIDE it and rewrote the
201
- # tracked, biome-owned bytes at indent=2, and `pnpm check` died at step 1 reading
202
- # "format error" instead of "install wrote this".
203
+ # with ABSOLUTE entries, so the portable, settings-relative form — the one shape
204
+ # register never WRITES, and must therefore never duplicate, absolutize or restyle —
205
+ # was never registered against. `setup` appended the absolute path BESIDE it and
206
+ # rewrote the file at indent=2, and `pnpm check` died at step 1 reading "format
207
+ # error" instead of "install wrote this".
203
208
  #
204
- # The seed is the repo's OWN committed settings, copied into a stand-in checkout so
205
- # `".."` resolves to that clone rather than to the real repo (nothing here touches
206
- # $REPO). Setup landing is PROVEN before any product verdict: if the seed is not the
207
- # portable form, this says SETUP MISS in its own name instead of blaming the code.
209
+ # The seed is a FIXTURE. It used to be this repo's OWN committed `.pi/settings.json`,
210
+ # because the repo shipped `packages: [".."]`; #110 retired that entry (entwurf's own
211
+ # checkout self-registers through user scope alone, so a second checkout can no
212
+ # longer register a rival copy of pi-extensions). A witness that depends on us still
213
+ # SHIPPING the shape it tests dies with the shape — and the shape it tests did not
214
+ # die. An operator may hand-write a settings-relative entry, and
215
+ # `is_settings_relative_self` is also what stops an unrelated relative package
216
+ # (`../../repos/gh/andenken`) from being read as ours. Dropping the fixture to a
217
+ # literal here makes the reason honest: the predicate is the subject, not our bytes.
208
218
  CLONE="$TMP/selfclone"; mkdir -p "$CLONE/.pi"
209
219
  CS="$CLONE/.pi/settings.json"
210
- seed_src=""
211
- if (cd "$REPO" && git show :.pi/settings.json) > "$CS" 2>/dev/null && [ -s "$CS" ]; then
212
- seed_src="candidate index"
213
- elif cp "$REPO/.pi/settings.json" "$CS" 2>/dev/null; then
214
- seed_src="worktree"
220
+ # Tab-indented with a compact array on purpose: cell 13 pins that a genuine rewrite
221
+ # preserves the file's own indent unit, and 11b below pins that this file is not
222
+ # rewritten at all. A seed written in the writer's default style could not tell the
223
+ # two apart.
224
+ printf '{\n\t"skills": ["../.claude/skills"],\n\t"packages": [".."]\n}\n' > "$CS"
225
+ BEFORE="$(sha256sum "$CS" | cut -d' ' -f1)"; MT_B="$(stat -c %Y "$CS")"; sleep 1
226
+ OUT_SELF="$(python3 "$REG" "$CS" "$CLONE")"
227
+ AFTER="$(sha256sum "$CS" | cut -d' ' -f1)"; MT_A="$(stat -c %Y "$CS")"
228
+ if printf '%s' "$OUT_SELF" | grep -q 'no-op'; then
229
+ ok "11 register against a settings-relative self entry is a no-op"
230
+ else
231
+ bad "11 register duplicated/absolutized the portable '..' entry" "$OUT_SELF"
215
232
  fi
216
- if [ ! -s "$CS" ]; then
217
- bad "11 SETUP MISS: could not seed this repo's own .pi/settings.json (no git object, no worktree file)"
218
- elif ! python3 -c "
219
- import json,sys
220
- p=json.load(open(sys.argv[1])).get('packages')
221
- sys.exit(0 if p==['..'] else 1)" "$CS"; then
222
- bad "11 SETUP MISS: the seed ($seed_src) is not the committed portable form (packages != ['..']) — product verdict withheld"
233
+ if [ "$BEFORE" = "$AFTER" ] && [ "$MT_B" = "$MT_A" ]; then
234
+ ok "11b the formatter-governed bytes are UNCHANGED (sha256 + mtime)"
223
235
  else
224
- BEFORE="$(sha256sum "$CS" | cut -d' ' -f1)"; MT_B="$(stat -c %Y "$CS")"; sleep 1
225
- OUT_SELF="$(python3 "$REG" "$CS" "$CLONE")"
226
- AFTER="$(sha256sum "$CS" | cut -d' ' -f1)"; MT_A="$(stat -c %Y "$CS")"
227
- if printf '%s' "$OUT_SELF" | grep -q 'no-op'; then
228
- ok "11 register against this repo's OWN committed settings is a no-op (seed: $seed_src)"
229
- else
230
- bad "11 register duplicated/absolutized the portable '..' entry" "$OUT_SELF"
231
- fi
232
- if [ "$BEFORE" = "$AFTER" ] && [ "$MT_B" = "$MT_A" ]; then
233
- ok "11b the tracked, formatter-governed bytes are UNCHANGED (sha256 + mtime)"
234
- else
235
- bad "11b install rewrote the tracked settings file (sha $BEFORE -> $AFTER, mtime $MT_B -> $MT_A)"
236
- fi
237
- # The inverse direction of the same asymmetry: the shared matcher now RECOGNIZES
238
- # `".."`, so an uninstall that deleted it would edit committed source — the same
239
- # defect pointed the other way. remove leaves it and says so; dry-run agrees,
240
- # because both ask one predicate.
241
- BEFORE_R="$(sha256sum "$CS" | cut -d' ' -f1)"
242
- OUT_RM="$(python3 "$REG" "$CS" "$CLONE" --remove)"
243
- OUT_DRY="$(python3 "$REG" "$CS" "$CLONE" --remove --dry-run)"
244
- if [ "$BEFORE_R" = "$(sha256sum "$CS" | cut -d' ' -f1)" ]; then
245
- ok "11c --remove does NOT delete the committed portable entry (bytes unchanged)"
246
- else
247
- bad "11c --remove edited the repo's committed settings source" "$OUT_RM"
248
- fi
249
- if printf '%s' "$OUT_RM" | grep -q 'kept 1 settings-relative'; then
250
- ok "11d the inverse REPORTS what it deliberately left behind (never a silent partial uninstall)"
251
- else
252
- bad "11d --remove left the entry without saying so" "$OUT_RM"
253
- fi
254
- if printf '%s' "$OUT_DRY" | grep -q 'no entwurf packages\[\] entry to remove'; then
255
- ok "11e --dry-run agrees with remove (one predicate, no over-report)"
256
- else
257
- bad "11e --dry-run disagreed with what remove actually does" "$OUT_DRY"
258
- fi
236
+ bad "11b register rewrote a settings file it only recognizes (sha $BEFORE -> $AFTER, mtime $MT_B -> $MT_A)"
237
+ fi
238
+ # The inverse direction of the same asymmetry: the shared matcher RECOGNIZES `".."`,
239
+ # so an uninstall that deleted it would edit source it never wrote — the same defect
240
+ # pointed the other way. remove leaves it and says so; dry-run agrees, because both
241
+ # ask one predicate.
242
+ BEFORE_R="$(sha256sum "$CS" | cut -d' ' -f1)"
243
+ OUT_RM="$(python3 "$REG" "$CS" "$CLONE" --remove)"
244
+ OUT_DRY="$(python3 "$REG" "$CS" "$CLONE" --remove --dry-run)"
245
+ if [ "$BEFORE_R" = "$(sha256sum "$CS" | cut -d' ' -f1)" ]; then
246
+ ok "11c --remove does NOT delete a settings-relative self entry (bytes unchanged)"
247
+ else
248
+ bad "11c --remove edited settings source it never authored" "$OUT_RM"
249
+ fi
250
+ if printf '%s' "$OUT_RM" | grep -q 'kept 1 settings-relative'; then
251
+ ok "11d the inverse REPORTS what it deliberately left behind (never a silent partial uninstall)"
252
+ else
253
+ bad "11d --remove left the entry without saying so" "$OUT_RM"
254
+ fi
255
+ if printf '%s' "$OUT_DRY" | grep -q 'no entwurf packages\[\] entry to remove'; then
256
+ ok "11e --dry-run agrees with remove (one predicate, no over-report)"
257
+ else
258
+ bad "11e --dry-run disagreed with what remove actually does" "$OUT_DRY"
259
259
  fi
260
260
 
261
261
  # 12. the state a pre-fix `setup` already left on real hosts: the portable entry AND
@@ -291,12 +291,20 @@ if grep -q $'^\t"packages"' "$S"; then ok "13 a rewrite preserves the file's tab
291
291
  # indent=2 — semantically a no-op, byte-wise a RED `pnpm check` diagnosed as "formatting".
292
292
  # A per-writer cell can never see that; only the real drive can. So this one runs
293
293
  # `run.sh install <checkout>` for real and demands sha256 + mtime invariance.
294
- #
295
294
  # The checkout is a stand-in: run.sh resolves its OWN symlinks to find REPO_DIR, so
296
295
  # run.sh is COPIED (a link would point the drive back at the operator's real repo) while
297
296
  # the trees it only reads are linked. That makes REPO_DIR == the stand-in, which is the
298
- # shape that matters — `".."` in <checkout>/.pi/settings.json resolves to the very repo
299
- # being registered, exactly as it does in a dev clone.
297
+ # shape that matters — the drive installs entwurf INTO entwurf's own checkout, exactly
298
+ # as `./run.sh setup` does from a dev clone (PROJECT_DIR_DEFAULT=$(pwd)).
299
+ #
300
+ # #110 raised what this cell demands. "install is a no-op here" used to hold because
301
+ # the committed `packages: [".."]` already satisfied the writer. That entry is gone,
302
+ # so the writer must instead RECOGNIZE that the project it is installing into IS this
303
+ # root and skip project scope entirely — user scope already registers it, and a second
304
+ # entry naming a different checkout is what made pi load two rival copies of
305
+ # pi-extensions/entwurf-control.ts. The invariant, tested end to end below: entwurf's
306
+ # own checkout carries no project-scope self-registration, in tracked bytes OR in
307
+ # bytes a writer produced.
300
308
  CK="$TMP/checkout"
301
309
  mkdir -p "$CK/.pi"
302
310
  cp "$REPO/run.sh" "$CK/run.sh"
@@ -317,8 +325,8 @@ if [ ! -s "$CKS" ]; then
317
325
  elif ! python3 -c "
318
326
  import json,sys
319
327
  d=json.load(open(sys.argv[1]))
320
- sys.exit(0 if d.get('packages')==['..'] and isinstance(d.get('entwurfProvider'),dict) else 1)" "$CKS"; then
321
- bad "14 SETUP MISS: the seed ($ck_seed) is not the committed portable form (packages ['..'] + entwurfProvider)"
328
+ sys.exit(0 if d.get('packages') is None and isinstance(d.get('entwurfProvider'),dict) else 1)" "$CKS"; then
329
+ bad "14 SETUP MISS: the seed ($ck_seed) is not the committed form (no packages key + entwurfProvider)"
322
330
  else
323
331
  CK_BEFORE="$(sha256sum "$CKS" | cut -d' ' -f1)"; CK_MT="$(stat -c %Y "$CKS")"
324
332
  sleep 1
@@ -359,6 +367,124 @@ else
359
367
  else
360
368
  bad "14d the user-scope citizen was not written under the sandboxed PI_CODING_AGENT_DIR" "$ck_out"
361
369
  fi
370
+ # The invariant, read off the file the drive just wrote through: no writer put a
371
+ # project-scope self-registration back. 14b already pins the bytes, but a writer
372
+ # that ADDED `packages` to a file with no `packages` key would fail 14b for a
373
+ # reason a reader could mistake for formatting; this says the thing #110 is about,
374
+ # in its own words.
375
+ L14E="14e install into entwurf's OWN checkout wrote no project-scope packages[] self-registration [QK:SELF-CHECKOUT-PROJECT-REGISTRATION]"
376
+ if python3 -c "
377
+ import json,sys
378
+ sys.exit(0 if json.load(open(sys.argv[1])).get('packages') is None else 1)" "$CKS"; then
379
+ ok "$L14E"
380
+ else
381
+ bad "$L14E — violated: install self-registered this checkout, so a second checkout will load a rival copy of pi-extensions" "$(cat "$CKS")"
382
+ fi
383
+ case "$ck_out" in
384
+ *"project-scope packages[] skipped"*) ok "14f the drive REPORTS the skip (a silent skip and a silent write read alike)" ;;
385
+ *) bad "14f install skipped project scope without saying so — 14e cannot tell that apart from a writer that never ran" "$ck_out" ;;
386
+ esac
387
+ fi
388
+ fi
389
+
390
+ # ── 15. #110: the operator-observable this whole contract exists for ───────────
391
+ # Cells 11–14 read settings bytes. Bytes are the mechanism; the SYMPTOM is what pi
392
+ # does with them, and that is what nobody could see: a second checkout booted pi,
393
+ # printed all seven `entwurf` model rows, and looked fine, while `--entwurf-control`
394
+ # — the flag that makes a pi session a garden citizen — was being served by the
395
+ # branch copy, and the INSTALLED extension failed to load with an error naming the
396
+ # installed path as the loser. Not silent. Designed to be misread.
397
+ #
398
+ # pi's package identity is `local:<resolved path>` and dedupePackages collapses only
399
+ # EQUAL identities (package-manager.js getPackageIdentity/dedupePackages), so two
400
+ # real checkouts can never merge — no realpath rule, ours or pi's, would fix this.
401
+ # The only lever is not registering the second one.
402
+ #
403
+ # The cell is control-then-assert on purpose. A sandbox where pi loads no project
404
+ # settings at all (untrusted cwd, missing extension deps, a probe that dies early)
405
+ # would report "no conflicts" for reasons that have nothing to do with the contract,
406
+ # and that green would be a lie. So it first PLANTS the retired entry and demands the
407
+ # conflict APPEAR; only a cell that has just watched the defect happen is allowed to
408
+ # assert its absence.
409
+ # Two different absences, and only one of them is allowed to be quiet. A tree with no
410
+ # `node_modules` at all is a genuinely reduced checkout and the property is untestable
411
+ # there — a named skip. A tree that HAS dependencies but no pinned pi is a broken dev
412
+ # checkout, and skipping it would hide the breakage behind an exit-0 gate, so that one
413
+ # fails loud. (The qualification snapshot is the first case only in principle: it
414
+ # symlinks the origin's node_modules in after its baseline commit — mutation-qualify.ts
415
+ # createRepoSnapshot — so cell 15 really runs there, which is what lets the two mutants
416
+ # below cover this skip. A skip nobody can reach with a planted defect would be exactly
417
+ # the quiet green this cell is built to refuse.)
418
+ PI_BIN_PINNED="$REPO/node_modules/.bin/pi"
419
+ if [ ! -d "$REPO/node_modules" ]; then
420
+ echo " skip 15 second-checkout probe skipped: this tree has no node_modules (reduced checkout); the property is untestable here, not violated"
421
+ elif [ ! -x "$PI_BIN_PINNED" ]; then
422
+ bad "15 the pinned pi is missing from a checkout that HAS dependencies ($PI_BIN_PINNED) — a broken dev checkout, not a reduced one; re-run pnpm install"
423
+ else
424
+ A15="$TMP/dual/installed"; B15="$TMP/dual/second"
425
+ AG15="$TMP/agent15"
426
+ mkdir -p "$A15/.pi" "$B15/.pi" "$AG15"
427
+ # pi-extensions is COPIED into each root, not linked: the defect is two DISTINCT
428
+ # real paths declaring the same flag, and a shared symlink target would be one path
429
+ # wearing two names — a fixture that cannot reproduce what two checkouts do.
430
+ for root in "$A15" "$B15"; do
431
+ cp -R "$REPO/pi-extensions" "$root/pi-extensions"
432
+ for entry in node_modules package.json protocol.js; do
433
+ [ -e "$REPO/$entry" ] && ln -s "$REPO/$entry" "$root/$entry"
434
+ done
435
+ done
436
+ # user scope registers the INSTALLED root, absolutely — what `register_user_scope_citizen`
437
+ # writes on a real host. Trust is seeded for the second checkout because `.pi/` is a
438
+ # trust-gated input: an untrusted cwd loads no project settings, which would silence
439
+ # the control below and make the assertion vacuous.
440
+ printf '{"packages": ["%s"]}\n' "$A15" > "$AG15/settings.json"
441
+ printf '{"%s": true}\n' "$B15" > "$AG15/trust.json"
442
+ # Two fences on the probe child, and BOTH are load-bearing.
443
+ #
444
+ # `< /dev/null`: `pi --print` reads a non-tty stdin as piped prompt input, so it
445
+ # inherits whatever the CALLER handed this gate. Under an interactive shell or a
446
+ # /dev/null stdin that ends immediately and the probe returns in ~2s; under a
447
+ # supervised runner — which hands the process an open socket — it blocks forever.
448
+ # Measured 2026-09-10: a release-gate run sat 1h56m on this line with `pi` alive
449
+ # and no output, and the gate has no timeout of its own to catch it. A gate must
450
+ # read the loader, never the stdin of whoever started it.
451
+ #
452
+ # `timeout 60`: even so, a probe that cannot finish must go RED rather than hang.
453
+ # 124 lands in the captured output, the conflict grep misses, and the cell fails
454
+ # with its own name — the honest outcome for "the loader never answered".
455
+ #
456
+ # Ambient identity carriers are stripped for the same reason: a live pi/ACP session
457
+ # exports PI_SESSION_ID/PI_AGENT_ID and CLAUDE_CONFIG_DIR into children.
458
+ probe15() {
459
+ (cd "$B15" && env -u PI_SESSION_ID -u PI_AGENT_ID -u CLAUDE_CONFIG_DIR \
460
+ HOME="$TMP/home" PI_CODING_AGENT_DIR="$AG15" \
461
+ XDG_DATA_HOME="$TMP/xdg" XDG_STATE_HOME="$TMP/state" XDG_CACHE_HOME="$TMP/cache" \
462
+ timeout 60 "$PI_BIN_PINNED" --model zzz-no-such-provider/zzz-no-such-model --print x \
463
+ </dev/null 2>&1 || true)
464
+ }
465
+ # CONTROL: the retired shape, re-planted. `".."` resolves against <B>/.pi, so the
466
+ # project entry names B while user scope names A — two identities, both loaded.
467
+ printf '{"packages": [".."]}\n' > "$B15/.pi/settings.json"
468
+ OUT15_CTL="$(probe15)"
469
+ if printf '%s' "$OUT15_CTL" | grep -q 'conflicts with'; then
470
+ ok "15 control: a second checkout that registers itself DOES collide on the extension flags"
471
+ else
472
+ bad "15 SETUP MISS: the planted second registration produced no conflict — this sandbox cannot observe the defect, so 15b would be vacuous" "$OUT15_CTL"
473
+ fi
474
+ # ASSERT: this repo's real `.pi/settings.json`, read from the WORKING TREE — not
475
+ # from the candidate index the way cells 11/14 read theirs. The subject here is
476
+ # what pi's loader does with the file that exists ON DISK when a session starts;
477
+ # the index is a different question and check-install-surface S7c already owns it.
478
+ # Reading the index here also made this cell blind to its own replant: a mutation
479
+ # writes the file, git show reads the staged bytes, and the gate stayed green over
480
+ # a planted defect (measured — qualification reported SURVIVED, 2026-09-10).
481
+ cp "$REPO/.pi/settings.json" "$B15/.pi/settings.json"
482
+ OUT15="$(probe15)"
483
+ L15B="15b a second checkout of this repo boots pi with zero extension conflicts [QK:SECOND-CHECKOUT-EXTENSION-COLLISION]"
484
+ if printf '%s' "$OUT15" | grep -q 'conflicts with'; then
485
+ bad "$L15B — violated: the branch copy wins --entwurf-control and the INSTALLED extension fails, printing the installed path as the loser" "$OUT15"
486
+ else
487
+ ok "$L15B"
362
488
  fi
363
489
  fi
364
490
 
@@ -740,14 +866,16 @@ if [ "$RC34B" -ne 0 ] && printf '%s' "$OUT34B" | grep -q 'CORRUPT'; then
740
866
  ok "34b doctor coupling FAILs on a wrong-type provider installerRoot (named CORRUPT) $QK34"
741
867
  else bad "34b doctor stayed green/silent over a corrupt installerRoot (rc=$RC34B): $OUT34B $QK34"; fi
742
868
 
743
- # 15. WIRING: both writers of this one file must share the serializer, not copy it.
869
+ # 35. WIRING: both writers of this one file must share the serializer, not copy it.
744
870
  # A duplicated indent-detector is how the provider writer stayed open after the
745
871
  # package writer was closed; a parity check is cheaper than a third round.
872
+ # (Numbered 35, after the ownership block: it used to be a second "15", which
873
+ # stopped being merely untidy once #110 added the real cell 15 above.)
746
874
  for w in register-pi-package.py register-pi-provider.py; do
747
875
  if grep -q "from pi_settings_io import" "$REPO/scripts/$w"; then
748
- ok "15 $w routes through the shared pi_settings_io serializer"
876
+ ok "35 $w routes through the shared pi_settings_io serializer"
749
877
  else
750
- bad "15 $w serializes settings on its own again (copied rule = the #53 B shape)"
878
+ bad "35 $w serializes settings on its own again (copied rule = the #53 B shape)"
751
879
  fi
752
880
  done
753
881