@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 +2 -2
- package/CHANGELOG.md +25 -0
- package/README.md +5 -4
- package/docs/acp-backend-rail.md +32 -15
- package/docs/external-mcp-host.md +4 -2
- package/docs/fresh-cut-policy.md +3 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +6 -0
- package/package.json +2 -2
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +3 -3
- package/pi-extensions/lib/acp/event-mapper.ts +4 -4
- package/pi-extensions/lib/acp/tool-surface.ts +6 -0
- package/run.sh +33 -7
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-gate-qualification.ts +1 -1
- package/scripts/check-install-surface.ts +23 -7
- package/scripts/mutants/pi-package-ownership.json +26 -0
- package/scripts/pi_settings_io.py +3 -1
- package/scripts/register-pi-package.py +38 -30
- package/scripts/register-pi-provider.py +3 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +199 -71
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
98
|
-
them non-green. Linux's certified rail status is
|
|
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
|
package/docs/acp-backend-rail.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
282
|
-
0.
|
|
283
|
-
`src/acp-agent.ts` are **2 at
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/docs/fresh-cut-policy.md
CHANGED
|
@@ -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`,
|
|
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.
|
|
127
|
-
// `dist/acp-agent.js:
|
|
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.
|
|
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.
|
|
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.
|
|
50
|
-
* `dist/acp-agent.js:
|
|
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.
|
|
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.
|
|
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.
|
|
346
|
-
// `dist/acp-agent.js:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
64
|
-
* `dist/acp-agent.js:
|
|
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.
|
|
350
|
-
// `dist/acp-agent.js:
|
|
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
|
-
|
|
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
|
|
711
|
-
#
|
|
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
|
-
#
|
|
5779
|
-
#
|
|
5780
|
-
#
|
|
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
|
+
* 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.
|
|
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.
|
|
397
|
-
// `dist/acp-agent.js:
|
|
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.
|
|
612
|
-
// `dist/acp-agent.js:
|
|
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.
|
|
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 `:
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|
|
43
|
-
*
|
|
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
|
|
483
|
-
settingsText === null ||
|
|
484
|
-
|
|
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,
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
path
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
47
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
163
|
-
|
|
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
|
|
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
|
-
#
|
|
256
|
-
# same breath. Otherwise the canonical absolute path is what install
|
|
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
|
|
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'} —
|
|
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
|
|
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
|
|
29
|
-
|
|
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.
|
|
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:
|
|
4
|
-
#
|
|
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
|
|
14
|
-
# settings file with ABSOLUTE entries, so the portable, settings-relative form
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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:
|
|
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
|
|
199
|
-
#
|
|
200
|
-
# against. `setup`
|
|
201
|
-
#
|
|
202
|
-
#
|
|
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
|
|
205
|
-
# `".."
|
|
206
|
-
#
|
|
207
|
-
#
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 [
|
|
217
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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 —
|
|
299
|
-
#
|
|
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')
|
|
321
|
-
bad "14 SETUP MISS: the seed ($ck_seed) is not the committed
|
|
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
|
-
#
|
|
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 "
|
|
876
|
+
ok "35 $w routes through the shared pi_settings_io serializer"
|
|
749
877
|
else
|
|
750
|
-
bad "
|
|
878
|
+
bad "35 $w serializes settings on its own again (copied rule = the #53 B shape)"
|
|
751
879
|
fi
|
|
752
880
|
done
|
|
753
881
|
|