@junghanacs/entwurf 0.12.5 → 0.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to this project will be documented here. Format follows [Kee
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.12.6 — 2026-07-03
8
+
9
+ ### Fixed
10
+
11
+ - **Claude Code meta-bridge live marketplace sources now live outside the checkout for dev and npm installs alike.** Development clones and installed packages both assemble the plugin bundle under `$XDG_DATA_HOME/entwurf/meta-bridge/.assembled`; the checkout or `node_modules` tree is now only the source origin. This removes the class where `pnpm check`, `git clean -xfd`, or a repo-local uninstall smoke could delete a user-scope Claude marketplace source and leave new Claude Code sessions at statusline `?`. The installer still chooses `.ts` vs compiled `.js` hook artifacts by source origin, but the live registration path is the same XDG artifact lane.
12
+ - **Meta-bridge uninstall now uses the recorded live-artifact path and fails before side effects when the state is corrupt.** The install state's `assembledMarketplacePath` is the SSOT for uninstall, doctor, and state checks. Uninstall validates the full `…/entwurf/meta-bridge/.assembled` suffix before removing Claude plugin/MCP registrations or deleting state, so malformed paths such as `/` or `…/entwurf/meta-bridge/not-assembled` fail loud with zero Claude side effects and leave state/artifacts intact. `state.py check` also rejects missing, empty, or malformed recorded paths even when settings agree with the corruption.
13
+ - **User-scope pi package registration is now part of setup.** `./run.sh setup` registers `entwurf` in user-scope pi settings so `--entwurf-control` and the provider surface load from foreign cwd sessions, not only from a project-local `.pi/settings.json`. A hermetic `smoke-user-scope-citizen` covers idempotency, stale normalization, remove symmetry, unrelated-key preservation, and corrupt-shape fail-loud behavior.
14
+
15
+ ### Changed
16
+
17
+ - **The install toolchain is now aligned to one pnpm 11 lane and one setup surface.** The repo dropped the obsolete `packageManager` pin and `.npmrc`, moved pnpm policy to `pnpm-workspace.yaml`, updated CI to pnpm 11.9, and treats `./run.sh setup` as the single local install path instead of a split `pi install` flow.
18
+ - **The next delivery lane is pinned before implementation.** `NEXT.md` now records the agy/Antigravity delivery adapter constraints: direct-inject/native-push is separate from pi's control-socket liveness domain, registration is explicit, receiver markers are not reused for native-push replyability, and install/doctor/uninstall must inherit the source-origin/live-artifact boundary proven by this release.
19
+ - **The future fresh-spawn lane stays mux-visible by default.** The mux driver plan now keeps minting and mux transport separate, treats tmux/zmx as drivers behind a leaf interface, and keeps fresh pi-native GPT launch visible instead of reviving hidden background one-shots as the default.
20
+
21
+ ### Verification
22
+
23
+ - `pnpm check` passed on 2026-07-03 after the XDG live-artifact move, recorded-path uninstall hardening, user-scope citizen smoke, pnpm 11 setup cleanup, and NEXT lane updates.
24
+ - `./run.sh smoke-meta-install-state` passed with the new direct install→XDG proof, recorded-path mismatch case, corrupt basename/missing-field fail-loud cases, and checkout-internal `.assembled` boundary fingerprint.
25
+ - `./run.sh doctor-meta-bridge` passed on 2026-07-03 with source/assembled/installed writer parity all v2 and the assembled bundle under `$XDG_DATA_HOME/entwurf/meta-bridge/.assembled`.
26
+ - `./run.sh check-pack-install` passed on 2026-07-03, proving the npm consumer path keeps the stable XDG marketplace source and user-scope pi citizen registration.
27
+ - `LIVE=1 ./run.sh release-gate /tmp/psa-release-gate-0.12.6.ddCcmz` passed on 2026-07-03; log `/tmp/pi-tmux-entwurf-release-gate-0126.log`; summary `MUST: PASS=17 FAIL=0 SKIP=0`, advisory `BEHAVIOR: PASS=0 FAIL=1` (`smoke-resident-garden-guard` positive `/gnew T3` model-in-loop identity turn; non-blocking).
28
+
7
29
  ## 0.12.5 — 2026-07-01
8
30
 
9
31
  ### Fixed
package/VERIFY.md CHANGED
@@ -88,46 +88,36 @@ The goal is not merely "invoke Claude Code." We want:
88
88
 
89
89
  ## 1. Setup
90
90
 
91
- entwurf supports two install paths; both end in the same runtime state (a valid `.pi/settings.json` with `entwurfProvider.mcpServers` wired) and differ only in who owns the checkout.
91
+ **One install command to remember: `./run.sh setup <project>`.** It is idempotent — re-run the exact same command whenever anything looks wrong. There is no second install surface to juggle: from a clone `setup` runs the whole floor in order.
92
92
 
93
- | Path | Who | Shape |
94
- |------|-----|-------|
95
- | **AConsumer** | end-user of pi | `pi install git:…` + one `run.sh install .` |
96
- | **B — Developer** | contributor / first user | `git clone …` + `pi install ./` + `run.sh install …` |
97
-
98
- ### 1.1 Path A — consumer install
93
+ 1. `pnpm install` bundles pi (a dev/peer dependency; no separate `pi install` step) and builds the bridge
94
+ 2. project wiring → `<project>/.pi/settings.json` `entwurfProvider.mcpServers.entwurf-bridge`
95
+ 3. meta-bridge global plugin only when a native harness (`claude`) is on PATH; a pi-only host skips it cleanly
96
+ 4. `entwurf-bridge` install smoke (`validate_entwurf_bridge`)
99
97
 
100
98
  ```bash
101
- pi install git:github.com/junghan0611/entwurf # pi auto-clones + installs deps
102
- cd /path/to/consumer-project
103
- ~/.pi/agent/git/github.com/junghan0611/entwurf/run.sh install . # wire bundled mcpServers
104
- pi --list-models entwurf # curated model surface
105
- pi --provider entwurf --model claude-sonnet-5 -p "reply with ok only" # one-turn smoke
99
+ git clone https://github.com/junghan0611/entwurf /path/to/entwurf && cd $_
100
+ ./run.sh setup /path/to/consumer-project
101
+ # re-run the SAME command any time to repair a broken install
106
102
  ```
107
103
 
108
- Expected: the package appears under pi's `User packages`; `install .` logs `added entwurfProvider.mcpServers.entwurf-bridge` + `updated <project>/.pi/settings.json`; the smoke returns a one-word reply (full bootstrap ACP session bridge → clean shutdown). Note: `~/.pi/agent/git/.../entwurf` is pi-managed — do not edit it (a `pi update` overwrites). Step 2 is still required after `pi install git:…`.
104
+ Expected tail: `DONE: entwurf setup (pi package + meta-bridge + v2 install smoke) green.` On a host with `claude`, verify the native wiring with `./run.sh doctor-meta-bridge`.
105
+
106
+ The wiring / meta-bridge / smoke steps are internal building blocks of `setup` (`install_local_package`, `scripts/meta-bridge-install.sh`, `validate_entwurf_bridge`) — call `setup`, never the parts. Consumers who `npm install @junghanacs/entwurf` get the obvious npm surface; that path is not the developer concern here.
109
107
 
110
- ### 1.2 Path B — developer install
108
+ ### 1.1 Variables (optional)
111
109
 
112
110
  ```bash
113
- git clone https://github.com/junghan0611/entwurf /path/to/entwurf && cd $_
114
- pnpm install # pnpm is the pinned packageManager
115
- pi install ./
116
- ./run.sh install /path/to/consumer-project
117
- pnpm check # full deterministic floor
118
- pi --provider entwurf --model claude-sonnet-5 -p "reply with ok only"
119
- LIVE=1 ./run.sh release-gate /path/to/consumer-project
111
+ export REPO_DIR=/path/to/entwurf
112
+ export PROJECT_DIR=/path/to/consumer-project
113
+ cd "$REPO_DIR" && ./run.sh setup "$PROJECT_DIR"
120
114
  ```
121
115
 
122
- `install` is idempotent; user-authored `mcpServers.<name>` overrides survive a re-run (`preserved (user override: …)`). `run.sh remove <dir>` deletes only entries whose command matches the repo-authored launcher.
123
-
124
- ### 1.3 Setup shortcut + variables
116
+ ### 1.2 Live acceptance (optional)
125
117
 
126
118
  ```bash
127
- export REPO_DIR=... # Path A: $HOME/.pi/agent/git/github.com/junghan0611/entwurf ; Path B: your clone
128
- export PROJECT_DIR=/path/to/consumer-project
129
- export CACHE_DIR=$HOME/.pi/agent/cache/entwurf/sessions
130
- cd "$REPO_DIR" && ./run.sh setup "$PROJECT_DIR"
119
+ LIVE=1 ./run.sh release-gate /path/to/consumer-project
120
+ pi --provider entwurf --model claude-sonnet-5 -p "reply with ok only" # one-turn smoke
131
121
  ```
132
122
 
133
123
  `setup` runs `pnpm install` + `install` + meta-bridge (native harness) + the v2 install smoke; a green `setup` implies the settings.json wiring and install surface are healthy. The full live floor is still `LIVE=1 ./run.sh release-gate`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.12.5",
3
+ "version": "0.12.6",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for Claude Code, Codex, Antigravity, and pi harnesses.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -65,7 +65,6 @@
65
65
  "engines": {
66
66
  "node": ">=22.6.0"
67
67
  },
68
- "packageManager": "pnpm@10.33.0",
69
68
  "dependencies": {
70
69
  "@agentclientprotocol/claude-agent-acp": "0.54.1",
71
70
  "@agentclientprotocol/sdk": "1.1.0",
@@ -120,7 +119,7 @@
120
119
  "format": "biome check --write .",
121
120
  "check-meta-manifest-schema": "./run.sh check-meta-manifest-schema",
122
121
  "check-package-source-routing": "./run.sh check-package-source-routing",
123
- "check": "pnpm lint && pnpm typecheck && ./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-record-v2 && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-meta-dual-read && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-migration && ./run.sh check-meta-dual-consumers && ./run.sh check-meta-capability-source && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh smoke-meta-install-state && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-package-source-routing && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-install-preflight && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-boot && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-entwurf-v2-spawn && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-spawn-production && ./run.sh check-entwurf-facts && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-entwurf-mailbox-guard && ./run.sh check-auth-boundary && ./run.sh check-acp-provider-surface && ./run.sh check-acp-sdk-surface && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-acp-carrier-augment && ./run.sh check-pack"
122
+ "check": "pnpm lint && pnpm typecheck && ./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-record-v2 && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-meta-dual-read && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-migration && ./run.sh check-meta-dual-consumers && ./run.sh check-meta-capability-source && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh smoke-meta-install-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-package-source-routing && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-install-preflight && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-boot && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-entwurf-v2-spawn && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-spawn-production && ./run.sh check-entwurf-facts && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-entwurf-mailbox-guard && ./run.sh check-auth-boundary && ./run.sh check-acp-provider-surface && ./run.sh check-acp-sdk-surface && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-acp-carrier-augment && ./run.sh check-pack"
124
123
  },
125
124
  "pi": {
126
125
  "extensions": [
package/run.sh CHANGED
@@ -91,12 +91,13 @@ Usage:
91
91
  ./run.sh smoke-meta-async-drift # 1.0.0 meta-bridge step 1: drift sentinel — version pins + Claude binary undocumented-behavior markers (LIVE=1 adds plugin watch-arm probe)
92
92
  ./run.sh smoke-meta-honesty # 1.0.0 meta-bridge: honesty regression gate (#30 blockers) — doorbell counts ALL msgs honestly + hook logs failures as ERROR (best-effort, no scream). Offline/deterministic (deps: bash+node+python3)
93
93
  ./run.sh smoke-meta-install-state # 1.0.0 meta-bridge Phase 2: stateful install/uninstall + store-doctor regression gate. Offline/deterministic (deps: bash+node+python3)
94
+ ./run.sh smoke-user-scope-citizen # 0.12.6 install-boundary: pi packages[] registration SSOT (register-pi-package.py) — idempotent + preserves unrelated + normalizes stale + remove symmetry + fails loud. Offline/hermetic (deps: bash+python3)
94
95
  ./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)
95
96
  ./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)
96
97
  ./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)
97
98
  ./run.sh smoke-claude-native-resume-live # LIVE-only: Claude Code native fresh→--resume continuity + meta-record uniqueness; proves meta-bridge records identity without touching the backend resume path
98
99
 
99
- ./run.sh install-meta-bridge # 1.0.0 meta-bridge Phase 2: stateful GLOBAL install (plugin + USER MCP + settings keyset, honest uninstall state)
100
+ ./run.sh install-meta-bridge # INTERNAL part of `setup` (native-harness plugin) + doctor recovery path — prefer `setup`; stateful GLOBAL install (plugin + USER MCP + settings keyset, honest uninstall state)
100
101
  ./run.sh uninstall-meta-bridge # 1.0.0 meta-bridge Phase 2: stateful GLOBAL uninstall (restore only keys/items captured in install-state)
101
102
  ./run.sh doctor-meta-bridge # 1.0.0 meta-bridge Phase 2: fail-loud doctor — toolchain + state + plugin/MCP + store scan + hook errors + SessionStart evidence + writer-version parity (source↔assembled↔installed: FAIL on a stale deployed meta-record writer)
102
103
  ./run.sh meta-bridge-prune # 1.0.0 meta-bridge Phase 4: LISTING-ONLY store hygiene — classify orphan/stale/ambiguous/keep, print manual rm commands, delete NOTHING ([dir] [--ttl-days N])
@@ -106,7 +107,7 @@ Usage:
106
107
  ./run.sh check-pack # publish gate (dry-run): npm pack --dry-run + tarball invariants (runtime-critical present, dev residue absent)
107
108
  ./run.sh check-pack-install # heavy publish gate (prepublishOnly): actual npm pack + tar -tf + fresh-temp install smoke with 0.80.x peers
108
109
  ./run.sh sync-auth # copy ~/.pi/agent/auth.json anthropic OAuth credentials to entwurf alias
109
- ./run.sh install [project-dir] # install this local package into project .pi/settings.json
110
+ ./run.sh install [project-dir] # INTERNAL part of `setup` (project .pi/settings.json wiring) + npm-consumer entry — prefer `setup`, don't call directly for dev
110
111
  ./run.sh setup:links [--force] # repair ~/.pi/agent/entwurf-targets.json link (use --force to replace a stale operator file or wrong symlink; a .bak is taken)
111
112
  ./run.sh remove [project-dir] # remove entwurf entries from project .pi/settings.json
112
113
 
@@ -239,11 +240,67 @@ preflight_dep_integrity() {
239
240
  fi
240
241
  }
241
242
 
243
+ preflight_pi_settings_shapes() {
244
+ local project_settings="$1"
245
+ local user_settings="$2"
246
+ python3 - "$project_settings" "$user_settings" <<'PY'
247
+ import json, sys
248
+ from pathlib import Path
249
+
250
+ project = Path(sys.argv[1])
251
+ user = Path(sys.argv[2])
252
+
253
+ def load_object(path: Path, label: str):
254
+ if not path.exists():
255
+ return None
256
+ data = json.loads(path.read_text())
257
+ if not isinstance(data, dict):
258
+ raise SystemExit(f"{label} settings is not a JSON object: {path}")
259
+ return data
260
+
261
+ def check_packages(data, path: Path, label: str):
262
+ if data is None:
263
+ return
264
+ packages = data.get("packages")
265
+ if packages is not None and not isinstance(packages, list):
266
+ raise SystemExit(f"{label} settings packages is not a JSON array: {path}")
267
+
268
+ def check_project_provider(data, path: Path):
269
+ if data is None:
270
+ return
271
+ provider = data.get("entwurfProvider")
272
+ if provider is None:
273
+ return
274
+ if not isinstance(provider, dict):
275
+ raise SystemExit(f"project settings entwurfProvider is not an object: {path}")
276
+ servers = provider.get("mcpServers")
277
+ if servers is not None and not isinstance(servers, dict):
278
+ raise SystemExit(f"project settings entwurfProvider.mcpServers is not an object: {path}")
279
+
280
+ project_data = load_object(project, "project")
281
+ user_data = load_object(user, "user")
282
+ check_packages(project_data, project, "project")
283
+ check_packages(user_data, user, "user")
284
+ check_project_provider(project_data, project)
285
+ PY
286
+ }
287
+
242
288
  install_local_package() {
243
- local project_dir
289
+ local project_dir agent_dir
244
290
  project_dir=$(normalize_project_dir "$1")
291
+ agent_dir="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
245
292
  preflight_dep_integrity
293
+ # Fail BEFORE any settings write if either target config already has a corrupt
294
+ # shape. The packages[] SSOT and provider writer run in two separate steps, so
295
+ # without this preflight a bad entwurfProvider could leave a half-installed
296
+ # packages[] entry behind (2026-07-03 install-boundary hardening).
297
+ preflight_pi_settings_shapes "$project_dir/.pi/settings.json" "$agent_dir/settings.json"
246
298
  mkdir -p "$project_dir/.pi"
299
+ # packages[] registration via the shared SSOT — same is_entwurf_source
300
+ # predicate + idempotency as user-scope and remove (not a substring match).
301
+ python3 "$REPO_DIR/scripts/register-pi-package.py" "$project_dir/.pi/settings.json" "$REPO_DIR"
302
+ # entwurfProvider.mcpServers + legacy prune (project-scope only; packages[] was
303
+ # already written above by register-pi-package.py).
247
304
  python3 - "$project_dir/.pi/settings.json" "$REPO_DIR" <<'PY'
248
305
  import json, sys
249
306
  from pathlib import Path
@@ -258,23 +315,6 @@ if settings_path.exists():
258
315
  else:
259
316
  data = {}
260
317
 
261
- # --- packages[] registration --------------------------------------------------
262
- packages = data.get("packages")
263
- if not isinstance(packages, list):
264
- packages = []
265
-
266
- filtered = []
267
- for item in packages:
268
- source = item.get("source") if isinstance(item, dict) else item
269
- if isinstance(source, str) and ("entwurf" in source) and source != repo_dir:
270
- continue
271
- filtered.append(item)
272
-
273
- if repo_dir not in filtered:
274
- filtered.append(repo_dir)
275
-
276
- data["packages"] = filtered
277
-
278
318
  # --- entwurfProvider.mcpServers bundled entries ---------------------------
279
319
  # Ship the two in-repo MCP adapters pre-wired so `pi install` produces a
280
320
  # working setup without the consumer hand-editing settings.json. User-authored
@@ -334,9 +374,25 @@ for name, reason in LEGACY_BUNDLED.items():
334
374
 
335
375
  settings_path.write_text(json.dumps(data, indent=2) + "\n")
336
376
  print(f"install: updated {settings_path}")
337
- print(f"install: package source -> {repo_dir}")
338
377
  PY
339
378
  ensure_agent_dir_symlinks
379
+ register_user_scope_citizen
380
+ }
381
+
382
+ # Register entwurf as a pi USER-SCOPE citizen so its extensions
383
+ # (entwurf-control.ts → --entwurf-control / --emacs-agent-socket) load from ANY
384
+ # cwd, not only inside the entwurf checkout. project-scope `.pi/settings.json`
385
+ # only applies when pi runs inside the repo; the `pit`/`pia`/`pihome` global
386
+ # launchers and the npm consumer's "installs → just works" both need the entry
387
+ # in ~/.pi/agent/settings.json's packages[]. This is the wiring that dropped when
388
+ # `pi install` was removed from setup (2026-07-03: `--entwurf-control` unknown in
389
+ # a foreign cwd). Idempotent: absent → append, present → no-op; a stale entwurf
390
+ # entry at a different path is normalized to REPO_DIR. Every other package and key
391
+ # in the operator's user settings is preserved untouched.
392
+ register_user_scope_citizen() {
393
+ local agent_dir="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
394
+ # Shared idempotent implementation (also driven by smoke-user-scope-citizen).
395
+ python3 "$REPO_DIR/scripts/register-pi-package.py" "$agent_dir/settings.json" "$REPO_DIR"
340
396
  }
341
397
 
342
398
  # Ensure agent-level resources that entwurf code reads from
@@ -424,6 +480,14 @@ ensure_agent_dir_symlinks() {
424
480
  remove_local_package() {
425
481
  local project_dir
426
482
  project_dir=$(normalize_project_dir "$1")
483
+ # Same fail-before-write rule as install: remove has two writers too
484
+ # (packages[] SSOT, then entwurfProvider cleanup), so a malformed provider must
485
+ # not leave a half-removed packages[] entry behind.
486
+ preflight_pi_settings_shapes "$project_dir/.pi/settings.json" "$(mktemp -u)"
487
+ # packages[] cleanup via the shared SSOT — same is_entwurf_source predicate as
488
+ # install, so remove never over-deletes a look-alike repo (entwurf-notes, …)
489
+ # that install would never have registered.
490
+ python3 "$REPO_DIR/scripts/register-pi-package.py" "$project_dir/.pi/settings.json" "$REPO_DIR" --remove
427
491
  python3 - "$project_dir/.pi/settings.json" "$REPO_DIR" <<'PY'
428
492
  import json, sys
429
493
  from pathlib import Path
@@ -438,19 +502,6 @@ data = json.loads(settings_path.read_text())
438
502
  if not isinstance(data, dict):
439
503
  raise SystemExit("settings.json is not an object")
440
504
 
441
- # --- packages[] cleanup -------------------------------------------------------
442
- packages = data.get("packages")
443
- pkg_removed = 0
444
- if isinstance(packages, list):
445
- filtered = []
446
- for item in packages:
447
- source = item.get("source") if isinstance(item, dict) else item
448
- if isinstance(source, str) and ("entwurf" in source):
449
- pkg_removed += 1
450
- continue
451
- filtered.append(item)
452
- data["packages"] = filtered
453
-
454
505
  # --- entwurfProvider.mcpServers cleanup ------------------------------------
455
506
  # Only remove entries that look like they came from ./run.sh install: either
456
507
  # the command matches the current $REPO_DIR anchor exactly, or it ends with
@@ -484,7 +535,7 @@ if isinstance(provider, dict):
484
535
  data.pop("entwurfProvider", None)
485
536
 
486
537
  settings_path.write_text(json.dumps(data, indent=2) + "\n")
487
- print(f"remove: removed {pkg_removed} packages[] entries, {mcp_removed} mcpServers entries from {settings_path}")
538
+ print(f"remove: removed {mcp_removed} mcpServers entries from {settings_path}")
488
539
  PY
489
540
  }
490
541
 
@@ -1469,6 +1520,87 @@ check_install_preflight() {
1469
1520
  fi
1470
1521
  rm -rf "$fake" "$proj"
1471
1522
  ok "[check-install-preflight] representative dangling symlink (test -d blind spot) → fails before writing settings, names the dep"
1523
+
1524
+ # negative 3 — corrupt project provider shape. install_local_package now has a
1525
+ # two-step writer (packages[] SSOT, then entwurfProvider.mcpServers), so a
1526
+ # malformed provider MUST fail before the packages[] step writes anything.
1527
+ fake=$(mktemp -d); proj=$(mktemp -d)
1528
+ mkdir -p "$proj/.pi" "$fake/home"
1529
+ printf '{"entwurfProvider": []}\n' > "$proj/.pi/settings.json"
1530
+ local before after
1531
+ before=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
1532
+ rc=0; out=$(HOME="$fake/home" PI_CODING_AGENT_DIR="$fake/home/.pi/agent" "$REPO_DIR/run.sh" install "$proj" 2>&1) || rc=$?
1533
+ after=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
1534
+ if [ "$rc" -eq 0 ]; then
1535
+ fail "[check-install-preflight] corrupt entwurfProvider did NOT fail install"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
1536
+ fi
1537
+ if [ "$before" != "$after" ]; then
1538
+ fail "[check-install-preflight] corrupt entwurfProvider was partially rewritten (packages[] leak)"; echo "$out"; cat "$proj/.pi/settings.json"; rm -rf "$fake" "$proj"; exit 1
1539
+ fi
1540
+ if ! printf '%s' "$out" | grep -q "entwurfProvider is not an object"; then
1541
+ fail "[check-install-preflight] corrupt entwurfProvider failed for the WRONG reason:"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
1542
+ fi
1543
+ if [ -f "$fake/home/.pi/agent/settings.json" ]; then
1544
+ fail "[check-install-preflight] corrupt project provider still wrote user-scope settings"; cat "$fake/home/.pi/agent/settings.json"; rm -rf "$fake" "$proj"; exit 1
1545
+ fi
1546
+ rm -rf "$fake" "$proj"
1547
+ ok "[check-install-preflight] corrupt project entwurfProvider → fails before any project/user settings write"
1548
+
1549
+ # negative 4 — corrupt project mcpServers shape, same no-partial-write contract.
1550
+ fake=$(mktemp -d); proj=$(mktemp -d)
1551
+ mkdir -p "$proj/.pi" "$fake/home"
1552
+ printf '{"entwurfProvider": {"mcpServers": []}}\n' > "$proj/.pi/settings.json"
1553
+ before=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
1554
+ rc=0; out=$(HOME="$fake/home" PI_CODING_AGENT_DIR="$fake/home/.pi/agent" "$REPO_DIR/run.sh" install "$proj" 2>&1) || rc=$?
1555
+ after=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
1556
+ if [ "$rc" -eq 0 ]; then
1557
+ fail "[check-install-preflight] corrupt entwurfProvider.mcpServers did NOT fail install"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
1558
+ fi
1559
+ if [ "$before" != "$after" ]; then
1560
+ fail "[check-install-preflight] corrupt mcpServers was partially rewritten (packages[] leak)"; echo "$out"; cat "$proj/.pi/settings.json"; rm -rf "$fake" "$proj"; exit 1
1561
+ fi
1562
+ if ! printf '%s' "$out" | grep -q "entwurfProvider.mcpServers is not an object"; then
1563
+ fail "[check-install-preflight] corrupt mcpServers failed for the WRONG reason:"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
1564
+ fi
1565
+ rm -rf "$fake" "$proj"
1566
+ ok "[check-install-preflight] corrupt project entwurfProvider.mcpServers → fails before any project settings write"
1567
+
1568
+ # negative 5 — corrupt user-scope packages shape. Since install writes project
1569
+ # settings before user registration, this preflight must catch the user file up
1570
+ # front so a bad ~/.pi/agent/settings.json cannot leave the project half-wired.
1571
+ fake=$(mktemp -d); proj=$(mktemp -d)
1572
+ mkdir -p "$fake/home/.pi/agent"
1573
+ printf '{"packages": {"broken": true}}\n' > "$fake/home/.pi/agent/settings.json"
1574
+ rc=0; out=$(HOME="$fake/home" PI_CODING_AGENT_DIR="$fake/home/.pi/agent" "$REPO_DIR/run.sh" install "$proj" 2>&1) || rc=$?
1575
+ if [ "$rc" -eq 0 ]; then
1576
+ fail "[check-install-preflight] corrupt user packages did NOT fail install"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
1577
+ fi
1578
+ if [ -f "$proj/.pi/settings.json" ]; then
1579
+ fail "[check-install-preflight] corrupt user packages still wrote project settings (partial install)"; echo "$out"; cat "$proj/.pi/settings.json"; rm -rf "$fake" "$proj"; exit 1
1580
+ fi
1581
+ if ! printf '%s' "$out" | grep -q "user settings packages is not a JSON array"; then
1582
+ fail "[check-install-preflight] corrupt user packages failed for the WRONG reason:"; echo "$out"; rm -rf "$fake" "$proj"; exit 1
1583
+ fi
1584
+ rm -rf "$fake" "$proj"
1585
+ ok "[check-install-preflight] corrupt user-scope packages → fails before project settings write"
1586
+
1587
+ # negative 6 — remove has the same two-step write risk (packages[] remove,
1588
+ # then provider cleanup). A corrupt provider must fail before packages[] is
1589
+ # altered, otherwise uninstall becomes a partial destructive write.
1590
+ proj=$(mktemp -d)
1591
+ mkdir -p "$proj/.pi"
1592
+ printf '{"entwurfProvider": [], "packages": ["%s"]}\n' "$REPO_DIR" > "$proj/.pi/settings.json"
1593
+ before=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
1594
+ rc=0; out=$("$REPO_DIR/run.sh" remove "$proj" 2>&1) || rc=$?
1595
+ after=$(sha256sum "$proj/.pi/settings.json" | cut -d' ' -f1)
1596
+ if [ "$rc" -eq 0 ]; then
1597
+ fail "[check-install-preflight] corrupt provider did NOT fail remove"; echo "$out"; rm -rf "$proj"; exit 1
1598
+ fi
1599
+ if [ "$before" != "$after" ]; then
1600
+ fail "[check-install-preflight] corrupt provider remove partially rewrote packages[]"; echo "$out"; cat "$proj/.pi/settings.json"; rm -rf "$proj"; exit 1
1601
+ fi
1602
+ rm -rf "$proj"
1603
+ ok "[check-install-preflight] remove with corrupt provider → fails before packages[] removal"
1472
1604
  }
1473
1605
 
1474
1606
  check_pi_preflight() {
@@ -2002,8 +2134,14 @@ check_pack_install() {
2002
2134
  return 1
2003
2135
  fi
2004
2136
 
2005
- local loader_out
2006
- loader_out=$(cd "$tmp" && pi -e "$tmp/node_modules/@junghanacs/entwurf" --list-models entwurf 2>&1) || {
2137
+ # HOME/PI_CODING_AGENT_DIR redirected to a throwaway dir: this loader smoke must
2138
+ # depend ONLY on the -e package path, never on the operator's real
2139
+ # ~/.pi/agent/settings.json — otherwise a live-config change could silently
2140
+ # pass/fail the gate (the exact "check must not depend on live wiring" impurity
2141
+ # this whole lane is about).
2142
+ local loader_out loader_home="$tmp/loader-home"
2143
+ mkdir -p "$loader_home/.pi/agent"
2144
+ loader_out=$(cd "$tmp" && HOME="$loader_home" PI_CODING_AGENT_DIR="$loader_home/.pi/agent" pi -e "$tmp/node_modules/@junghanacs/entwurf" --list-models entwurf 2>&1) || {
2007
2145
  fail "[check-pack-install] pi loader smoke failed (exit non-zero):"
2008
2146
  echo "$loader_out" | tail -10 | sed 's/^/ /' >&2
2009
2147
  return 1
@@ -2075,6 +2213,43 @@ check_pack_install() {
2075
2213
  fi
2076
2214
  echo "[check-pack-install] npm-managed install regression pass (hoisted-dep run.sh install wrote settings)"
2077
2215
 
2216
+ # THE 2026-07-03 regression gate: removing `pi install` from setup dropped
2217
+ # user-scope citizen registration, so `--entwurf-control` was Unknown in a
2218
+ # foreign cwd. Prove the npm consumer path closes it end-to-end: run.sh install
2219
+ # (run above under HOME="$npmhome") must have registered the package in the USER
2220
+ # settings, and pi must then load the entwurf extension + its flags from a cwd
2221
+ # OUTSIDE the project. Fully isolated in the temp HOME — never touches ~/.pi.
2222
+ local npm_user_settings="$npmhome/.pi/agent/settings.json"
2223
+ if [ ! -f "$npm_user_settings" ]; then
2224
+ fail "[check-pack-install] npm-managed install did not register a user-scope citizen at $npm_user_settings"
2225
+ return 1
2226
+ fi
2227
+ if ! python3 -c "
2228
+ import json,sys
2229
+ p=json.load(open('$npm_user_settings')).get('packages',[])
2230
+ srcs=[(x if isinstance(x,str) else x.get('source')) for x in p]
2231
+ sys.exit(0 if any(isinstance(s,str) and s.endswith('/node_modules/@junghanacs/entwurf') for s in srcs) else 1)
2232
+ "; then
2233
+ fail "[check-pack-install] user-scope settings.json lacks the npm entwurf package in packages[]:"
2234
+ sed 's/^/ /' "$npm_user_settings" >&2
2235
+ return 1
2236
+ fi
2237
+ # Foreign cwd ($tmp — NOT the project, no project .pi): --entwurf-control must be
2238
+ # a KNOWN flag and the entwurf provider must load, sourced only from user scope.
2239
+ # Before the fix this printed "Unknown options: --entwurf-control".
2240
+ local foreign_out
2241
+ foreign_out=$(cd "$tmp" && HOME="$npmhome" PI_CODING_AGENT_DIR="$npmhome/.pi/agent" pi --entwurf-control --list-models entwurf 2>&1) || {
2242
+ fail "[check-pack-install] foreign-cwd --entwurf-control smoke failed (user-scope citizen not loading?):"
2243
+ echo "$foreign_out" | tail -10 | sed 's/^/ /' >&2
2244
+ return 1
2245
+ }
2246
+ if grep -qi "Unknown option" <<<"$foreign_out" || ! grep -q "claude-opus-4-8" <<<"$foreign_out"; then
2247
+ fail "[check-pack-install] foreign-cwd --entwurf-control did not load the entwurf extension from user scope:"
2248
+ echo "$foreign_out" | tail -10 | sed 's/^/ /' >&2
2249
+ return 1
2250
+ fi
2251
+ echo "[check-pack-install] user-scope citizen regression pass (npm consumer: --entwurf-control loads from a foreign cwd)"
2252
+
2078
2253
  # Installed meta-bridge ownership regression (0.12.5): package upgrades must not
2079
2254
  # bake versioned pnpm-store paths into Claude settings. MCP already uses the
2080
2255
  # stable `entwurf-bridge` bin; statusLine must now use `entwurf-statusline`, and
@@ -2998,6 +3173,14 @@ case "$cmd" in
2998
3173
  # duplicate/drift records. Offline + deterministic (deps bash+node+python3).
2999
3174
  (cd "$REPO_DIR" && bash scripts/smoke-meta-install-state.sh)
3000
3175
  ;;
3176
+ smoke-user-scope-citizen)
3177
+ # 0.12.6 install-boundary gate: register-pi-package.py is the shared
3178
+ # packages[] SSOT for project/user install and remove; user scope makes
3179
+ # --entwurf-control load from any cwd. Idempotent, preserves unrelated
3180
+ # packages/keys, normalizes stale entries, remove is symmetric, and corrupt
3181
+ # settings fail loud. The tripwire the 2026-07-03 `pi install` removal lacked.
3182
+ (cd "$REPO_DIR" && bash scripts/smoke-user-scope-citizen.sh)
3183
+ ;;
3001
3184
  smoke-claude-native-resume-live)
3002
3185
  # LIVE-only Detour A probe: two real Claude Code native turns (fresh +
3003
3186
  # --resume) in a scratch cwd. Verifies native resume works while the
@@ -3007,9 +3190,10 @@ case "$cmd" in
3007
3190
  ;;
3008
3191
  install-meta-bridge)
3009
3192
  # 1.0.0 meta-bridge step 5: operator-grade GLOBAL install of the garden-native
3010
- # receive plugin. Assembles a self-contained, node-path-baked copy under
3011
- # pi/meta-bridge/.assembled and runs marketplace add + install --scope user, so
3012
- # every native Claude Code session auto-loads it (no manual --plugin-dir).
3193
+ # receive plugin. Assembles a self-contained, node-path-baked copy under the
3194
+ # XDG data dir ($XDG_DATA_HOME/entwurf/meta-bridge/.assembled dev clone and
3195
+ # installed package alike, never the checkout) and runs marketplace add +
3196
+ # install --scope user, so every native Claude Code session auto-loads it.
3013
3197
  # Idempotent; Linux/macOS only (Windows fail-fast).
3014
3198
  (cd "$REPO_DIR" && bash scripts/meta-bridge-install.sh "$@")
3015
3199
  ;;
@@ -17,18 +17,22 @@ MKT_NAME="meta-bridge-local"
17
17
  PLUGIN="entwurf-meta-receive"
18
18
  CLAUDE_CFG="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
19
19
  REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
20
- # LIB_EXT tracks the hook artifact mode (0.12.5): installed packages ship the
20
+ # The live artifact lives under the XDG data dir (dev clone and installed package
21
+ # alike — never the checkout). Prefer the path RECORDED in install-state so a doctor
22
+ # run under a different XDG_DATA_HOME than install still hashes the REAL assembled
23
+ # bundle (and state.py check compares the same recorded marketplace path); fall back
24
+ # to the current-XDG computation only when no state exists yet (fresh host).
25
+ # LIB_EXT still tracks the hook artifact mode (0.12.5): installed packages ship the
21
26
  # tsc-emitted `.js` closure (node_modules-safe), dev clones run the `.ts` source.
22
27
  # The writer-version parity below hashes meta-session.<LIB_EXT> so an installed
23
28
  # `.js` bundle is compared against the SAME-pipeline dist `.js`, never against the
24
29
  # `.ts` source (which would hash-mismatch and false-STALE).
30
+ ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge/.assembled"
31
+ RECORDED_ASM="$(python3 "$REPO/scripts/meta-bridge-state.py" assembled-path --repo "$REPO" 2>/dev/null || true)"
32
+ [ -n "$RECORDED_ASM" ] && ASM="$RECORDED_ASM"
25
33
  case "$REPO" in
26
- */node_modules/@junghanacs/entwurf)
27
- ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge/.assembled"
28
- LIB_EXT="js" ;;
29
- *)
30
- ASM="$REPO/pi/meta-bridge/.assembled"
31
- LIB_EXT="ts" ;;
34
+ */node_modules/@junghanacs/entwurf) LIB_EXT="js" ;;
35
+ *) LIB_EXT="ts" ;;
32
36
  esac
33
37
  # shellcheck source=scripts/meta-bridge-hook-log.sh
34
38
  source "$REPO/scripts/meta-bridge-hook-log.sh"
@@ -4,7 +4,8 @@
4
4
  # needs, so a user never hand-edits hook/plugin settings or passes --plugin-dir.
5
5
  #
6
6
  # Mechanism (all proven on 2026-06-05):
7
- # 1. ASSEMBLE a self-contained plugin under pi/meta-bridge/.assembled/ (gitignored):
7
+ # 1. ASSEMBLE a self-contained plugin under the version-stable XDG data dir
8
+ # ($XDG_DATA_HOME/entwurf/meta-bridge/.assembled) — NEVER inside the checkout:
8
9
  # copy the committed skeleton, copy the entry shell + its lib (so
9
10
  # ${CLAUDE_PLUGIN_ROOT} self-locates them), and BAKE the node abspath into
10
11
  # hooks.json. The node path is the ONLY templated surface — the mailbox /
@@ -13,9 +14,10 @@
13
14
  # entwurf_inbox_read tool comes from USER-scope entwurf-bridge MCP wiring
14
15
  # (`claude mcp add -s user ...`). Project-scoped .mcp.json is deliberately
15
16
  # not enough: a /tmp native session would wake without a receipt tool.
16
- # 2. marketplace add <stable .assembled> (dev: repo-local; installed package:
17
- # version-stable XDG data dir; NOT /tmp — ephemeral source would break
18
- # `claude plugin marketplace update`).
17
+ # 2. marketplace add <stable XDG .assembled> (both dev clone and installed
18
+ # package assemble into the same version-stable XDG data dir; NOT /tmp —
19
+ # ephemeral source would break `claude plugin marketplace update`, and NOT
20
+ # the checkout — repo housekeeping must never cut the live user-scope wiring).
19
21
  # 3. install entwurf-meta-receive@meta-bridge-local --scope user (= global:
20
22
  # every native session auto-loads it; no manual --plugin-dir).
21
23
  # 4. install/update USER-scope entwurf-bridge MCP, so every native session has
@@ -31,26 +33,27 @@ REPO="$(cd "$HERE/.." && pwd)"
31
33
  MKT_NAME="meta-bridge-local"
32
34
  PLUGIN="entwurf-meta-receive"
33
35
  SRC="$REPO/pi/meta-bridge"
34
- # Dev clone: keep the marketplace source inside the checkout for transparent
35
- # inspection. Installed package: assemble into a version-stable operator path;
36
- # Claude settings store this directory path and package-manager upgrades do not
37
- # rewrite it, so a pnpm-store path would go stale on version/peer churn.
38
- #
39
- # The SAME installed-vs-dev split also picks the hook artifact (0.12.5): an
40
- # installed marketplace source lives below node_modules, where Node REFUSES
41
- # `--experimental-strip-types` on `.ts` (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING),
42
- # so installed packages run the tsc-emitted `meta-bridge-hook.js` closure (mirrors
43
- # start.sh/store-doctor). Dev clones live outside node_modules and run the `.ts`
44
- # source directly for transparent editing. HOOK_ENTRY is baked into hooks.json.
36
+ # The live marketplace artifact ALWAYS assembles under the version-stable XDG data
37
+ # dir dev clone and installed package alike. Claude settings store this directory
38
+ # path and package-manager upgrades do not rewrite it (a pnpm-store path would go
39
+ # stale on version/peer churn). Critically it lives OUTSIDE the checkout, so repo
40
+ # housekeeping (git clean -xfd, check/smoke) can never cut the global user-scope
41
+ # wiring: dev vs installed is a difference of SOURCE ORIGIN (repo tree vs npm
42
+ # package we assemble FROM), never of where the live artifact lands.
43
+ ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge/.assembled"
44
+ # The hook ARTIFACT form still splits by install shape (0.12.5): an installed
45
+ # package lives below node_modules, where Node REFUSES `--experimental-strip-types`
46
+ # on `.ts` (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so it runs the tsc-emitted
47
+ # `meta-bridge-hook.js` closure (mirrors start.sh/store-doctor). A dev clone lives
48
+ # outside node_modules — and so does the XDG artifact — so it runs the `.ts` source
49
+ # directly. HOOK_ENTRY is baked into hooks.json.
45
50
  case "$REPO" in
46
51
  */node_modules/@junghanacs/entwurf)
47
- ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge/.assembled"
48
52
  HOOK_ENTRY="meta-bridge-hook.js"
49
53
  HOOK_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js"
50
54
  LIB_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
51
55
  LIB_EXT="js" ;;
52
56
  *)
53
- ASM="$SRC/.assembled"
54
57
  HOOK_ENTRY="meta-bridge-hook.ts"
55
58
  HOOK_SRC="$REPO/pi-extensions/meta-bridge-hook.ts"
56
59
  LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
@@ -513,9 +513,33 @@ def check(repo: Path, asm: Path) -> None:
513
513
  failures.append(f"{claude_root_config_path()} root is not an object")
514
514
  root = {}
515
515
 
516
+ # The marketplace source path in settings was written by apply() from the
517
+ # RECORDED assembledMarketplacePath. Compare against that recorded value, not the
518
+ # --asm passed here: a doctor/check run under a different XDG_DATA_HOME than
519
+ # install must not false-FAIL a correctly-wired marketplace. Fall back to --asm
520
+ # only if the recorded field is somehow absent.
521
+ recorded_asm = state.get("assembledMarketplacePath")
522
+ # Shape-validate the recorded path (basename included). Every state our code
523
+ # writes carries this field (init_state/prepare/apply), so a missing/empty/
524
+ # non-string value is itself corruption — NOT a reason to fall back to --asm and
525
+ # PASS. And a malformed value that matches a same-malformed settings entry (both
526
+ # hand-corrupted) would otherwise slip the comparison below and greenlight a
527
+ # bogus marketplace source. This mirrors the uninstall.sh honest-inverse guard:
528
+ # only the exact install suffix …/entwurf/meta-bridge/.assembled is valid.
529
+ ASM_SUFFIX = "/entwurf/meta-bridge/.assembled"
530
+ if not isinstance(recorded_asm, str) or not recorded_asm.endswith(ASM_SUFFIX):
531
+ failures.append(
532
+ f"install-state assembledMarketplacePath is missing/malformed ('{recorded_asm}'); "
533
+ f"must end in {ASM_SUFFIX} — repair state or re-run install-meta-bridge"
534
+ )
535
+ marketplace_expected = (
536
+ {"source": {"source": "directory", "path": recorded_asm}}
537
+ if recorded_asm
538
+ else desired_marketplace(asm)
539
+ )
516
540
  checks = [
517
541
  (["enabledPlugins", PLUGIN_REF], True, "enabled plugin"),
518
- (["extraKnownMarketplaces", MARKETPLACE], desired_marketplace(asm), "known marketplace"),
542
+ (["extraKnownMarketplaces", MARKETPLACE], marketplace_expected, "known marketplace"),
519
543
  (["statusLine"], desired_statusline(repo), "statusLine"),
520
544
  ] + [(path_, desired, name) for name, path_, desired in MANAGED_SETTINGS_SCALARS]
521
545
  for path_, expected, label in checks:
@@ -554,6 +578,7 @@ def main() -> int:
554
578
  "apply",
555
579
  "preflight-uninstall",
556
580
  "uninstall",
581
+ "assembled-path",
557
582
  "check",
558
583
  "managed-keys",
559
584
  "desired-mcp",
@@ -564,7 +589,13 @@ def main() -> int:
564
589
  parser.add_argument("--asm", default=None, type=Path)
565
590
  args = parser.parse_args()
566
591
  repo = args.repo.resolve()
567
- asm = (args.asm or (repo / "pi" / "meta-bridge" / ".assembled")).resolve()
592
+ # The live artifact always lives under the XDG data dir — dev clone and
593
+ # installed package alike (mirror meta-bridge-install.sh's ASM resolution).
594
+ # install/doctor pass --asm explicitly; this default is the same XDG path so a
595
+ # bare invocation never falls back to a repo-internal marketplace source.
596
+ xdg_data = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
597
+ default_asm = xdg_data / "entwurf" / "meta-bridge" / ".assembled"
598
+ asm = (args.asm or default_asm).resolve()
568
599
  try:
569
600
  if args.command == "prepare":
570
601
  prepare(repo, asm)
@@ -574,6 +605,13 @@ def main() -> int:
574
605
  preflight_uninstall()
575
606
  elif args.command == "uninstall":
576
607
  uninstall()
608
+ elif args.command == "assembled-path":
609
+ # Print the RECORDED assembled marketplace path so the honest inverse
610
+ # (uninstall) removes exactly what install created — not a path
611
+ # recomputed from a possibly-changed XDG_DATA_HOME. Requires state.
612
+ state = load_state(required=True)
613
+ assert state is not None # load_state(required=True) dies otherwise
614
+ print(state.get("assembledMarketplacePath", ""))
577
615
  elif args.command == "check":
578
616
  check(repo, asm)
579
617
  elif args.command == "managed-keys":
@@ -26,6 +26,23 @@ command -v python3 >/dev/null || die "'python3' not on PATH. It is required for
26
26
  # delete live plugin/MCP entries before failing — guessing by side effect.
27
27
  python3 "$REPO/scripts/meta-bridge-state.py" preflight-uninstall --repo "$REPO"
28
28
 
29
+ # Read + shape-validate the RECORDED assembled path BEFORE any side effect. The
30
+ # honest inverse must remove exactly what install created; recomputing from
31
+ # ${XDG_DATA_HOME} would orphan the real artifact if it changed since install. If
32
+ # the recorded path is missing/corrupt we CANNOT safely remove the artifact, so we
33
+ # fail loud HERE — before touching any Claude registration or the state file (no
34
+ # guessing, no partial uninstall, no side-effect-then-WARN).
35
+ ASM_RECORDED="$(python3 "$REPO/scripts/meta-bridge-state.py" assembled-path --repo "$REPO")"
36
+ # Validate the FULL recorded path — basename included, not just the parent dir. The
37
+ # rm below targets MB_DIR (the parent meta-bridge dir), so a parent-only check would
38
+ # let a corrupt basename (…/entwurf/meta-bridge/not-assembled) pass as "well-formed"
39
+ # and still nuke the real .assembled + Claude registrations. Only the exact install
40
+ # suffix …/entwurf/meta-bridge/.assembled is a safe honest-inverse target.
41
+ case "$ASM_RECORDED" in
42
+ */entwurf/meta-bridge/.assembled) MB_DIR="$(dirname "$ASM_RECORDED")" ;; # …/entwurf/meta-bridge
43
+ *) die "install-state assembledMarketplacePath is missing/corrupt ('$ASM_RECORDED'); refusing to uninstall so the live artifact is not orphaned. Repair the state file or re-run install-meta-bridge, then uninstall." ;;
44
+ esac
45
+
29
46
  if command -v claude >/dev/null; then
30
47
  claude plugin uninstall "$PLUGIN@$MKT_NAME" >/dev/null 2>&1 || true
31
48
  claude plugin marketplace remove "$MKT_NAME" >/dev/null 2>&1 || true
@@ -37,16 +54,10 @@ fi
37
54
 
38
55
  python3 "$REPO/scripts/meta-bridge-state.py" uninstall --repo "$REPO"
39
56
 
40
- # Remove the assembled marketplace source too an honest inverse must not orphan
41
- # it. Installed packages assemble into a version-stable operator data dir
42
- # (~/.local/share/entwurf/meta-bridge/.assembled); leaving that tree behind is the
43
- # only meta-bridge footprint uninstall would otherwise leak. Dev clones drop their
44
- # gitignored in-checkout .assembled. Mirror install-meta-bridge's ASM resolution.
45
- case "$REPO" in
46
- */node_modules/@junghanacs/entwurf)
47
- rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge"
48
- rmdir "${XDG_DATA_HOME:-$HOME/.local/share}/entwurf" 2>/dev/null || true ;;
49
- *)
50
- rm -rf "$REPO/pi/meta-bridge/.assembled" ;;
51
- esac
57
+ # Remove the assembled marketplace source (validated above): the parent meta-bridge
58
+ # dir of the RECORDED .assembled, then the now-empty entwurf dir. This rm can NEVER
59
+ # reach inside the checkout, so repo housekeeping and this uninstall are structurally
60
+ # disjoint (the 0.12.x statusline-`?` impurity class is extinct, not guarded).
61
+ rm -rf "$MB_DIR"
62
+ rmdir "$(dirname "$MB_DIR")" 2>/dev/null || true # …/entwurf if empty
52
63
  echo "[meta-bridge-uninstall] DONE"
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ """Register (or --remove) entwurf in a pi settings.json packages[].
3
+
4
+ The SINGLE predicate / idempotency / fail-loud SSOT shared by BOTH scopes and by
5
+ remove, so install and uninstall can never drift to different meanings:
6
+ - project <repo>/.pi/settings.json (run.sh install_local_package / remove_local_package)
7
+ - user ~/.pi/agent/settings.json (run.sh register_user_scope_citizen)
8
+
9
+ Register is idempotent: absent → append REPO_DIR; already the sole canonical
10
+ entry → no-op (file not rewritten, mtime stable); any other entwurf entry (object
11
+ form, stale path, duplicate) collapses into one canonical string form. Remove
12
+ drops every entwurf entry. Both use is_entwurf_source(), so a look-alike repo
13
+ (entwurf-notes, openclaw-entwurf) is neither wrongly registered-over nor wrongly
14
+ removed. Every non-entwurf package and every other settings key is preserved.
15
+
16
+ This wiring (user scope) dropped when `pi install` was removed from setup
17
+ (2026-07-03: `--entwurf-control` unknown in a foreign cwd). Extracting it here
18
+ lets run.sh (both scopes + remove) and smoke-user-scope-citizen share ONE
19
+ implementation — mirrors the meta-bridge-state.py split.
20
+
21
+ Usage: register-pi-package.py <settings.json> <repo_dir> [--remove]
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import sys
28
+ from pathlib import Path
29
+
30
+
31
+ def source_of(item: object) -> object:
32
+ """The package spec of a packages[] entry — string form or {"source": …}."""
33
+ return item.get("source") if isinstance(item, dict) else item
34
+
35
+
36
+ def is_entwurf_source(source: str, repo_dir: str) -> bool:
37
+ """True iff this package entry points at THIS entwurf — the only entries
38
+ register/remove may touch. Strict on purpose: user-scope settings are GLOBAL,
39
+ so a substring "entwurf" match would wrongly eat unrelated repos like
40
+ entwurf-notes, openclaw-entwurf, or somebody else's git repo named entwurf.
41
+
42
+ Managed shapes:
43
+ - the exact resolved repo dir;
44
+ - an npm install path ending in node_modules/@junghanacs/entwurf;
45
+ - an explicit npm package source for @junghanacs/entwurf;
46
+ - a local filesystem path whose final directory is literally "entwurf"
47
+ (dev clone / stale move). Remote URL/git-like strings are NOT treated as
48
+ local paths merely because their last segment is "entwurf".
49
+ """
50
+ p = source.rstrip("/")
51
+ if p == repo_dir or p.endswith("/node_modules/@junghanacs/entwurf"):
52
+ return True
53
+ if p == "npm:@junghanacs/entwurf" or p.startswith("npm:@junghanacs/entwurf@"):
54
+ return True
55
+ local_like = p.startswith(("/", "./", "../", "~"))
56
+ return local_like and Path(p).name == "entwurf"
57
+
58
+
59
+ def _load(settings_path: Path) -> dict:
60
+ if settings_path.exists():
61
+ data = json.loads(settings_path.read_text())
62
+ if not isinstance(data, dict):
63
+ raise SystemExit(f"{settings_path} is not a JSON object")
64
+ return data
65
+ return {}
66
+
67
+
68
+ def _packages(settings_path: Path, data: dict) -> list:
69
+ packages = data.get("packages")
70
+ if packages is None:
71
+ return []
72
+ if not isinstance(packages, list):
73
+ # A settings file with a corrupt packages shape must NOT be silently
74
+ # coerced to [] — that would drop the operator's real packages.
75
+ raise SystemExit(f"{settings_path}: packages is not a JSON array")
76
+ return packages
77
+
78
+
79
+ def _entwurf_matches(packages: list, repo_dir: str) -> list:
80
+ return [
81
+ item for item in packages
82
+ if isinstance(source_of(item), str) and is_entwurf_source(source_of(item), repo_dir) # type: ignore[arg-type]
83
+ ]
84
+
85
+
86
+ def register(settings_path: Path, repo_dir_arg: str) -> str:
87
+ """"noop" if entwurf is already the sole canonical entry (file untouched),
88
+ else "registered" (rewritten with a single canonical entry)."""
89
+ repo_dir = str(Path(repo_dir_arg).resolve())
90
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
91
+ data = _load(settings_path)
92
+ packages = _packages(settings_path, data)
93
+
94
+ entwurf_entries = _entwurf_matches(packages, repo_dir)
95
+ # Already correct iff exactly ONE entwurf entry and it is the canonical string
96
+ # form at repo_dir. Order-insensitive; no rewrite → mtime stable.
97
+ if len(entwurf_entries) == 1 and entwurf_entries[0] == repo_dir:
98
+ return "noop"
99
+
100
+ filtered = [item for item in packages if item not in entwurf_entries]
101
+ data["packages"] = filtered + [repo_dir]
102
+ settings_path.write_text(json.dumps(data, indent=2) + "\n")
103
+ return "registered"
104
+
105
+
106
+ def remove(settings_path: Path, repo_dir_arg: str) -> int:
107
+ """Drop every entwurf entry (any shape/path). Returns the count removed."""
108
+ repo_dir = str(Path(repo_dir_arg).resolve())
109
+ if not settings_path.exists():
110
+ return 0
111
+ data = _load(settings_path)
112
+ packages = _packages(settings_path, data)
113
+
114
+ entwurf_entries = _entwurf_matches(packages, repo_dir)
115
+ if not entwurf_entries:
116
+ return 0
117
+ data["packages"] = [item for item in packages if item not in entwurf_entries]
118
+ settings_path.write_text(json.dumps(data, indent=2) + "\n")
119
+ return len(entwurf_entries)
120
+
121
+
122
+ def main(argv: list[str]) -> int:
123
+ args = [a for a in argv[1:] if a != "--remove"]
124
+ do_remove = "--remove" in argv[1:]
125
+ if len(args) != 2:
126
+ raise SystemExit("usage: register-pi-package.py <settings.json> <repo_dir> [--remove]")
127
+ settings_path = Path(args[0])
128
+ repo_dir_arg = args[1]
129
+ resolved = str(Path(repo_dir_arg).resolve())
130
+
131
+ if do_remove:
132
+ n = remove(settings_path, repo_dir_arg)
133
+ if n:
134
+ print(f"remove: removed {n} entwurf packages[] entr{'y' if n == 1 else 'ies'} from {settings_path}")
135
+ else:
136
+ print(f"remove: no entwurf packages[] entry to remove ({settings_path})")
137
+ return 0
138
+
139
+ result = register(settings_path, repo_dir_arg)
140
+ if result == "noop":
141
+ print(f"install: entwurf package already registered (no-op) -> {resolved}")
142
+ else:
143
+ print(f"install: registered entwurf package -> {settings_path}")
144
+ print(f"install: package source -> {resolved}")
145
+ return 0
146
+
147
+
148
+ if __name__ == "__main__":
149
+ raise SystemExit(main(sys.argv))
@@ -22,10 +22,58 @@ TMP="$(mktemp -d -t psa-meta-install-state.XXXXXX)"
22
22
  cleanup() { rm -rf "$TMP"; }
23
23
  trap cleanup EXIT
24
24
 
25
+ # ⓪ 설치 경계 봉쇄 (2026-07-03 `?` 사건의 구조적 소멸): source origin(repo/npm)과
26
+ # live artifact(XDG)를 분리한다. dev clone과 installed 소비자는 같은 user-scope
27
+ # install이고, 둘 다 marketplace source를 XDG data dir에 조립한다 — 절대 checkout
28
+ # 내부가 아니다. 이 smoke의 어떤 단계도 repo 안에 live source를 만들지 않음을
29
+ # top/bottom fingerprint로 감싸 증명하고, 아래 fake-install로 "install → XDG"를 직접 건다.
30
+ # present/absent만 보면 present→present 덮어쓰기를 놓치므로 내용 해시까지 비교한다.
31
+ asm_fingerprint() {
32
+ if [ -e "$REPO/pi/meta-bridge/.assembled" ]; then (cd "$REPO/pi/meta-bridge/.assembled" && find . -type f -exec sha256sum {} + 2>/dev/null | sort); else echo ABSENT; fi
33
+ }
34
+ REPO_ASM_FP_START="$(asm_fingerprint)"
35
+
36
+ # 실제 meta-bridge-install.sh를 격리된 HOME + XDG_DATA_HOME + fake claude로 돌려
37
+ # live marketplace source가 XDG 아래에 조립되고 `plugin marketplace add`가 그 XDG
38
+ # 경로를 받는지 직접 증명한다(오프라인: 실제 claude 없음).
39
+ INS_HOME="$TMP/ins-home"; INS_XDG="$TMP/ins-xdg"; INS_BIN="$TMP/ins-bin"
40
+ mkdir -p "$INS_HOME/.claude" "$INS_BIN"
41
+ echo '{}' > "$INS_HOME/.claude/settings.json"
42
+ echo '{}' > "$INS_HOME/.claude.json"
43
+ INS_LOG="$TMP/ins-claude.log"
44
+ cat > "$INS_BIN/claude" <<'SH'
45
+ #!/usr/bin/env bash
46
+ printf '%s\n' "$*" >> "$FAKE_CLAUDE_LOG"
47
+ case "$1${2:+ $2}" in
48
+ "plugin list") printf '%s\n' "entwurf-meta-receive@meta-bridge-local" " Status: enabled" ;;
49
+ "mcp get") printf '%s\n' "Scope: User config" "Status: Connected" ;;
50
+ *) : ;;
51
+ esac
52
+ exit 0
53
+ SH
54
+ chmod +x "$INS_BIN/claude"
55
+ if env HOME="$INS_HOME" CLAUDE_CONFIG_DIR="$INS_HOME/.claude" XDG_DATA_HOME="$INS_XDG" \
56
+ FAKE_CLAUDE_LOG="$INS_LOG" PATH="$INS_BIN:$PATH" \
57
+ bash "$REPO/scripts/meta-bridge-install.sh" >/dev/null 2>&1; then
58
+ if [ -e "$INS_XDG/entwurf/meta-bridge/.assembled/entwurf-meta-receive" ]; then ok "dev install assembles the live marketplace source under XDG (not the checkout)"; else bad "dev install did not assemble under XDG_DATA_HOME"; fi
59
+ MKT_ADD="$(grep '^plugin marketplace add ' "$INS_LOG" | head -1 || true)"
60
+ if printf '%s' "$MKT_ADD" | grep -Fq "$INS_XDG/entwurf/meta-bridge/.assembled"; then ok "claude plugin marketplace add received the XDG artifact path"; else bad "marketplace add did not point at XDG: $MKT_ADD"; fi
61
+ else
62
+ bad "real install-meta-bridge failed under isolated HOME/XDG/fake-claude:"$'\n'"$(cat "$INS_LOG" 2>/dev/null)"
63
+ fi
64
+
25
65
  export HOME="$TMP/home"
66
+ # The wrapper-uninstall below removes ${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/
67
+ # meta-bridge. Pin XDG_DATA_HOME into the sandbox too, so a developer who runs this
68
+ # smoke with XDG_DATA_HOME set in their shell can never have their real live
69
+ # artifact removed — the boundary that keeps check/smoke off global user wiring.
70
+ export XDG_DATA_HOME="$TMP/xdg"
26
71
  export CLAUDE_CONFIG_DIR="$HOME/.claude"
27
72
  mkdir -p "$CLAUDE_CONFIG_DIR"
28
- ASM="$TMP/assembled-marketplace"
73
+ # Canonical-suffix asm: production install ALWAYS records a path ending in
74
+ # …/entwurf/meta-bridge/.assembled, and state.py check now shape-validates that
75
+ # suffix — so the survival harness must use a realistic path, not a bare stub.
76
+ ASM="$TMP/asm-xdg/entwurf/meta-bridge/.assembled"
29
77
  export ASM
30
78
  mkdir -p "$ASM"
31
79
 
@@ -213,8 +261,10 @@ then ok "uninstall restores scalars/maps and removes only managed array addition
213
261
 
214
262
  if py uninstall >/dev/null 2>&1; then bad "state manager uninstall without state should not guess"; else ok "state manager uninstall without state fails instead of guessing"; fi
215
263
 
216
- # The wrapper must ALSO refuse before side effects. A fake claude records whether
217
- # plugin/MCP removals were attempted; no state means the log must stay empty.
264
+ # 봉쇄: wrapper-uninstall을 실제 $REPO 스크립트로 직접 돌린다. 이제 uninstall이
265
+ # 지우는 live artifact는 ${XDG_DATA_HOME}/entwurf/meta-bridge 하나뿐이고, 위에서
266
+ # HOME + XDG_DATA_HOME를 샌드박스로 고정했으므로 실제 개발자 배선/체크아웃에는
267
+ # 닿을 수 없다(2026-07-03 `?` 사건의 구조적 소멸 — 사본 repo 우회가 더는 불필요).
218
268
  FAKE_BIN="$TMP/fake-bin"; mkdir -p "$FAKE_BIN"
219
269
  cat > "$FAKE_BIN/claude" <<'SH'
220
270
  #!/usr/bin/env bash
@@ -222,6 +272,9 @@ printf '%s\n' "$*" >> "$FAKE_CLAUDE_LOG"
222
272
  exit 0
223
273
  SH
224
274
  chmod +x "$FAKE_BIN/claude"
275
+
276
+ # The wrapper must refuse before side effects. A fake claude records whether
277
+ # plugin/MCP removals were attempted; no state means the log must stay empty.
225
278
  FAKE_CLAUDE_LOG="$TMP/fake-claude-nostate.log"
226
279
  if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
227
280
  bad "wrapper uninstall without state should fail"
@@ -229,10 +282,14 @@ else
229
282
  if [ ! -s "$FAKE_CLAUDE_LOG" ]; then ok "wrapper uninstall without state has zero Claude side effects"; else bad "wrapper uninstall without state touched Claude: $(cat "$FAKE_CLAUDE_LOG")"; fi
230
283
  fi
231
284
 
232
- # With valid state, the wrapper may remove Claude registrations and then restore
233
- # JSON state. This proves the preflight gate is ordered before side effects.
234
- py prepare >/dev/null
235
- py apply >/dev/null
285
+ # With valid state, the wrapper removes Claude registrations, restores JSON state,
286
+ # AND removes the XDG live artifact RECORDED in state never the checkout, never a
287
+ # path recomputed from the env. Record an XDG-structured assembled path and seed the
288
+ # sentinel artifact so we can prove uninstall drops exactly the recorded tree.
289
+ WASM="$XDG_DATA_HOME/entwurf/meta-bridge/.assembled"
290
+ python3 "$STATE" prepare --repo "$REPO" --asm "$WASM" >/dev/null
291
+ python3 "$STATE" apply --repo "$REPO" --asm "$WASM" >/dev/null
292
+ mkdir -p "$WASM"; : > "$WASM/.sentinel"
236
293
  FAKE_CLAUDE_LOG="$TMP/fake-claude-state.log"
237
294
  if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
238
295
  if grep -q 'plugin uninstall entwurf-meta-receive@meta-bridge-local' "$FAKE_CLAUDE_LOG" && grep -q 'mcp remove entwurf-bridge -s user' "$FAKE_CLAUDE_LOG" && [ ! -f "$STATE_FILE" ]; then
@@ -240,10 +297,97 @@ if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts
240
297
  else
241
298
  bad "wrapper uninstall with state missed expected side effects/state removal"
242
299
  fi
300
+ # 봉쇄가 회피가 아니라 구조임을 증명: uninstall이 지우는 건 recorded XDG artifact뿐.
301
+ if [ ! -e "$XDG_DATA_HOME/entwurf/meta-bridge" ]; then ok "wrapper removes the recorded XDG live artifact tree, not the checkout"; else bad "wrapper did not remove the recorded XDG live artifact"; fi
243
302
  else
244
303
  bad "wrapper uninstall with valid state failed"
245
304
  fi
246
305
 
306
+ # Honest inverse under a CHANGED XDG_DATA_HOME (GPT hardening): uninstall must remove
307
+ # the RECORDED artifact path, never one recomputed from the current env. Record A,
308
+ # run uninstall with the env pointing at B, and prove A is gone while B is untouched.
309
+ XDGA="$TMP/xdgA"; XDGB="$TMP/xdgB"
310
+ ASM_A="$XDGA/entwurf/meta-bridge/.assembled"
311
+ python3 "$STATE" prepare --repo "$REPO" --asm "$ASM_A" >/dev/null
312
+ python3 "$STATE" apply --repo "$REPO" --asm "$ASM_A" >/dev/null
313
+ mkdir -p "$ASM_A"; : > "$ASM_A/.sentinel"
314
+ mkdir -p "$XDGB/entwurf/meta-bridge/.assembled"; : > "$XDGB/entwurf/meta-bridge/.assembled/.sentinel"
315
+ if XDG_DATA_HOME="$XDGB" PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$TMP/fake-claude-mismatch.log" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
316
+ if [ ! -e "$XDGA/entwurf/meta-bridge" ]; then ok "uninstall removes the RECORDED artifact (XDG A) even when env XDG_DATA_HOME differs"; else bad "uninstall did not remove the recorded artifact A — recomputed from env instead"; fi
317
+ if [ -e "$XDGB/entwurf/meta-bridge/.assembled/.sentinel" ]; then ok "uninstall leaves the current-env XDG (B) untouched (no env recompute)"; else bad "uninstall wrongly removed the current-env XDG B"; fi
318
+ else
319
+ bad "wrapper uninstall (recorded-path mismatch case) failed"
320
+ fi
321
+
322
+ # GPT hardening: a corrupt recorded assembledMarketplacePath must fail the wrapper
323
+ # uninstall LOUD and BEFORE any side effect — no Claude removal, state file intact
324
+ # (honest inverse never guesses / never partially uninstalls / never WARN-then-DONE).
325
+ # Two corrupt shapes: a trivially-bad "/" AND the basename-corrupt case that a
326
+ # PARENT-ONLY guard would silently pass (…/entwurf/meta-bridge/not-assembled) — the
327
+ # rm targets the parent meta-bridge dir, so that shape must be refused by the FULL
328
+ # suffix guard or it nukes the real artifact + Claude registrations (2026-07-03).
329
+ for CORRUPT in "/" "$XDG_DATA_HOME/entwurf/meta-bridge/not-assembled"; do
330
+ python3 "$STATE" prepare --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
331
+ python3 "$STATE" apply --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
332
+ # seed the real artifact so an over-broad rm would be observable as its removal
333
+ mkdir -p "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled"; : > "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled/.sentinel"
334
+ CORRUPT="$CORRUPT" python3 - <<'PY'
335
+ import json, os
336
+ p = os.environ['CLAUDE_CONFIG_DIR'] + '/entwurf.install-state.json'
337
+ s = json.load(open(p)); s['assembledMarketplacePath'] = os.environ['CORRUPT']
338
+ json.dump(s, open(p, 'w'), indent=2)
339
+ PY
340
+ FAKE_CLAUDE_LOG="$TMP/fake-claude-corrupt.log"; : > "$FAKE_CLAUDE_LOG"
341
+ if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
342
+ bad "wrapper uninstall with corrupt recorded path '$CORRUPT' should fail loud"
343
+ elif [ -s "$FAKE_CLAUDE_LOG" ] || [ ! -f "$STATE_FILE" ] || [ ! -e "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled/.sentinel" ]; then
344
+ bad "corrupt-path uninstall '$CORRUPT' leaked side effects/removed artifact: claude_log=[$(cat "$FAKE_CLAUDE_LOG")] state_exists=$([ -f "$STATE_FILE" ] && echo yes || echo no) artifact=$([ -e "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled/.sentinel" ] && echo intact || echo REMOVED)"
345
+ else
346
+ ok "corrupt recorded path '$CORRUPT' → uninstall fails BEFORE side effects (no Claude removal, state + artifact intact)"
347
+ fi
348
+ rm -f "$STATE_FILE"; rm -rf "$XDG_DATA_HOME/entwurf/meta-bridge"
349
+ done
350
+
351
+ # GPT hardening: state.py check must compare the RECORDED marketplace path, not the
352
+ # --asm handed in. Install/apply with A, then check with a DIFFERENT --asm B: it must
353
+ # still PASS (Claude settings + state both hold A; only the caller's XDG differs).
354
+ CKA="$TMP/ck-xdgA/entwurf/meta-bridge/.assembled"
355
+ CKB="$TMP/ck-xdgB/entwurf/meta-bridge/.assembled"
356
+ python3 "$STATE" prepare --repo "$REPO" --asm "$CKA" >/dev/null
357
+ python3 "$STATE" apply --repo "$REPO" --asm "$CKA" >/dev/null
358
+ if python3 "$STATE" check --repo "$REPO" --asm "$CKB" >/dev/null 2>&1; then ok "state.py check passes when --asm differs from the recorded path (compares recorded, no XDG false-fail)"; else bad "state.py check false-FAILed on a recorded≠--asm mismatch"; fi
359
+ rm -f "$STATE_FILE"
360
+
361
+ # GPT hardening: check must shape-validate the recorded path, not just compare it to
362
+ # settings. Corrupt BOTH state + settings to the same basename-bad value: a pure
363
+ # consistency compare would PASS (both agree) and greenlight a bogus marketplace
364
+ # source. The suffix guard must FAIL it instead.
365
+ python3 "$STATE" prepare --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
366
+ python3 "$STATE" apply --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
367
+ python3 - <<'PY'
368
+ import json, os
369
+ xdg = os.environ['XDG_DATA_HOME']; corrupt = xdg + '/entwurf/meta-bridge/not-assembled'
370
+ sp = os.environ['CLAUDE_CONFIG_DIR'] + '/entwurf.install-state.json'
371
+ s = json.load(open(sp)); s['assembledMarketplacePath'] = corrupt; json.dump(s, open(sp, 'w'), indent=2)
372
+ cp = os.environ['CLAUDE_CONFIG_DIR'] + '/settings.json'
373
+ c = json.load(open(cp)); c['extraKnownMarketplaces']['meta-bridge-local'] = {'source': {'source': 'directory', 'path': corrupt}}; json.dump(c, open(cp, 'w'), indent=2)
374
+ PY
375
+ if python3 "$STATE" check --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null 2>&1; then bad "state.py check greenlit a both-corrupt (state+settings) malformed marketplace path"; else ok "state.py check fails a malformed recorded path even when settings agree (shape guard, not just consistency)"; fi
376
+ rm -f "$STATE_FILE"
377
+
378
+ # GPT hardening: a MISSING/empty recorded path is corruption too — every state our
379
+ # code writes carries the field, so check must NOT fall back to --asm and PASS.
380
+ # Delete the field with settings still valid; the shape guard must FAIL, not greenlight.
381
+ python3 "$STATE" prepare --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
382
+ python3 "$STATE" apply --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null
383
+ python3 - <<'PY'
384
+ import json, os
385
+ sp = os.environ['CLAUDE_CONFIG_DIR'] + '/entwurf.install-state.json'
386
+ s = json.load(open(sp)); s.pop('assembledMarketplacePath', None); json.dump(s, open(sp, 'w'), indent=2)
387
+ PY
388
+ if python3 "$STATE" check --repo "$REPO" --asm "$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" >/dev/null 2>&1; then bad "state.py check PASSed with a MISSING recorded assembledMarketplacePath (fell back to --asm)"; else ok "state.py check fails a missing/empty recorded path (no --asm fallback greenlight)"; fi
389
+ rm -f "$STATE_FILE"
390
+
247
391
  # Doctor hook-log recovery predicate: only a later `INFO armed watch` clears an
248
392
  # ERROR. A UserPromptSubmit `INFO attach record` is degraded backfill, not wake
249
393
  # recovery.
@@ -365,7 +509,7 @@ if node --experimental-strip-types "$STORE_DOCTOR" "$STORE" >/dev/null 2>&1; the
365
509
  # chain to its final summary line — i.e. no early set -e death anywhere.
366
510
  DOC_HOME="$TMP/doctor-home"; DOC_CFG="$DOC_HOME/.claude"
367
511
  DOC_AGENT="$TMP/doctor-agent"; DOC_STORE="$DOC_AGENT/meta-sessions"
368
- DOC_BIN="$TMP/doctor-bin"; DOC_ASM="$REPO/pi/meta-bridge/.assembled" # SAME asm the doctor's check uses, so only the MCP drifts
512
+ DOC_BIN="$TMP/doctor-bin"; DOC_ASM="$XDG_DATA_HOME/entwurf/meta-bridge/.assembled" # SAME asm the doctor computes (XDG), so only the MCP drifts
369
513
  mkdir -p "$DOC_CFG" "$DOC_STORE" "$DOC_BIN"
370
514
  echo '{}' > "$DOC_CFG/settings.json"
371
515
  echo '{}' > "$DOC_HOME/.claude.json"
@@ -405,5 +549,11 @@ if printf '%s\n' "$DOC_OUT" | grep -q 'user MCP entwurf-bridge'; then ok "doctor
405
549
  if printf '%s\n' "$DOC_OUT" | grep -q '\[plugin install'; then ok "doctor continues to a later section after the drift (no early set -e death)"; else bad "doctor did not reach a later section header after the drift:"$'\n'"$DOC_OUT"; fi
406
550
  if printf '%s\n' "$DOC_OUT" | grep -q 'meta-bridge doctor: FAIL'; then ok "doctor runs the whole chain to its final summary line"; else bad "doctor did not reach its final summary line (mid-run death):"$'\n'"$DOC_OUT"; fi
407
551
 
552
+ # ⓪ 경계 종료 단언: 이 smoke 전체(install 조립 + state + wrapper-uninstall + doctor)가
553
+ # 끝난 뒤에도 checkout 안에는 live marketplace source가 생기지 않았다 — source
554
+ # origin(repo)과 live artifact(XDG)가 구조적으로 분리됐다는 최종 증명. 미래 회귀가
555
+ # repo 내부 ASM을 되살리거나 덮어쓰면(내용 해시 변화 포함) 여기서 잡힌다.
556
+ if [ "$(asm_fingerprint)" = "$REPO_ASM_FP_START" ]; then ok "checkout-internal marketplace source is byte-identical before/after — no op created or mutated \$REPO/.assembled (source origin ≠ live artifact)"; else bad "a meta-bridge operation created/mutated \$REPO/pi/meta-bridge/.assembled — repo boundary breached"; fi
557
+
408
558
  echo
409
559
  if [ "$fail" -eq 0 ]; then echo "smoke-meta-install-state: PASS"; else echo "smoke-meta-install-state: FAIL (see above)"; exit 1; fi
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env bash
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.
5
+ #
6
+ # Guards the wiring that dropped when `pi install` was removed from setup
7
+ # (2026-07-03: `--entwurf-control` / `--emacs-agent-socket` unknown in a foreign
8
+ # cwd because entwurf was absent from ~/.pi/agent/settings.json packages[]).
9
+ # statusline `?` had a tripwire; THIS omission had none and hid until GLG hit it
10
+ # in another repo. This gate is that missing tripwire: if the registration ever
11
+ # regresses, `pnpm check` goes red.
12
+ set -euo pipefail
13
+
14
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ REPO="$(cd "$HERE/.." && pwd)"
16
+ REG="$REPO/scripts/register-pi-package.py"
17
+
18
+ fail=0
19
+ ok() { echo " ok $*"; }
20
+ bad() { echo " FAIL $*"; fail=1; }
21
+
22
+ command -v python3 >/dev/null || { echo "FAIL: python3 not on PATH"; exit 1; }
23
+
24
+ TMP="$(mktemp -d -t psa-user-citizen.XXXXXX)"
25
+ trap 'rm -rf "$TMP"' EXIT
26
+ S="$TMP/settings.json"
27
+ FAKE_REPO="$TMP/fake/entwurf"; mkdir -p "$FAKE_REPO"
28
+ RESOLVED="$(cd "$FAKE_REPO" && pwd)"
29
+
30
+ has_pkg() { python3 -c "import json,sys;p=json.load(open('$S'))['packages'];sys.exit(0 if '$1' in p else 1)"; }
31
+ count_entwurf() { python3 -c "import json;p=json.load(open('$S'))['packages'];print(sum(1 for x in p if isinstance((x if isinstance(x,str) else x.get('source')),str) and (x if isinstance(x,str) else x.get('source')).rstrip('/').split('/')[-1]=='entwurf' or (x if isinstance(x,str) else x.get('source'))=='$RESOLVED'))"; }
32
+
33
+ # 1. absent settings file → created with entwurf registered
34
+ python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
35
+ if [ -f "$S" ] && has_pkg "$RESOLVED"; then ok "absent settings → entwurf registered (file created)"; else bad "entwurf not registered into a fresh settings file"; fi
36
+
37
+ # 2. idempotent: second run is a no-op AND does not even rewrite the file (mtime stable)
38
+ MT1="$(stat -c %Y "$S")"
39
+ sleep 1
40
+ OUT2="$(python3 "$REG" "$S" "$FAKE_REPO")"
41
+ MT2="$(stat -c %Y "$S")"
42
+ if printf '%s' "$OUT2" | grep -q 'no-op'; then ok "second run is a no-op (idempotent)"; else bad "second run was not a no-op: $OUT2"; fi
43
+ if [ "$MT1" = "$MT2" ]; then ok "no-op does NOT rewrite the file (mtime stable — strongest idempotence)"; else bad "no-op rewrote the file (mtime changed $MT1 -> $MT2)"; fi
44
+ if [ "$(count_entwurf)" = "1" ]; then ok "no duplicate entwurf entry after idempotent re-run"; else bad "duplicate entwurf entries: $(count_entwurf)"; fi
45
+
46
+ # 3. preserves unrelated packages AND unrelated keys — including look-alike repos
47
+ # whose path merely CONTAINS 'entwurf' and remote repos whose last segment is
48
+ # 'entwurf' but are not THIS package.
49
+ cat > "$S" <<'JSON'
50
+ {
51
+ "defaultProvider": "openai-codex",
52
+ "theme": "glg-dark",
53
+ "packages": ["../../repos/gh/andenken", "git:github.com/badlogic/pi-telegram",
54
+ "/home/me/entwurf-notes", "/x/openclaw-entwurf",
55
+ "git:github.com/someone/entwurf", "https://github.com/someone/entwurf"]
56
+ }
57
+ JSON
58
+ python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
59
+ if python3 -c "
60
+ import json
61
+ d=json.load(open('$S')); p=d['packages']
62
+ assert '../../repos/gh/andenken' in p, 'andenken dropped'
63
+ assert 'git:github.com/badlogic/pi-telegram' in p, 'pi-telegram dropped'
64
+ assert '/home/me/entwurf-notes' in p, 'entwurf-notes wrongly filtered (substring false-positive)'
65
+ assert '/x/openclaw-entwurf' in p, 'openclaw-entwurf wrongly filtered (substring false-positive)'
66
+ assert 'git:github.com/someone/entwurf' in p, 'foreign git repo named entwurf wrongly filtered'
67
+ assert 'https://github.com/someone/entwurf' in p, 'foreign URL repo named entwurf wrongly filtered'
68
+ assert '$RESOLVED' in p, 'entwurf missing'
69
+ assert d['defaultProvider']=='openai-codex' and d['theme']=='glg-dark', 'unrelated key dropped'
70
+ " 2>/dev/null; then ok "preserves unrelated packages incl. look-alikes/foreign entwurf repos and keys"; else bad "clobbered unrelated packages/keys or a look-alike repo"; fi
71
+
72
+ # 4. stale entwurf paths (bare local 'entwurf' dir, npm install path, explicit npm
73
+ # package source) normalize to one canonical REPO_DIR.
74
+ cat > "$S" <<'JSON'
75
+ {"packages": ["/old/moved/entwurf", "/some/root/node_modules/@junghanacs/entwurf",
76
+ "npm:@junghanacs/entwurf@0.12.4", "../../repos/gh/andenken"]}
77
+ JSON
78
+ python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
79
+ if python3 -c "
80
+ import json
81
+ p=json.load(open('$S'))['packages']
82
+ assert '/old/moved/entwurf' not in p, 'stale bare-entwurf path not dropped'
83
+ assert '/some/root/node_modules/@junghanacs/entwurf' not in p, 'stale npm path not dropped'
84
+ assert 'npm:@junghanacs/entwurf@0.12.4' not in p, 'stale explicit npm source not dropped'
85
+ assert '$RESOLVED' in p, 'entwurf not normalized to REPO_DIR'
86
+ assert '../../repos/gh/andenken' in p, 'andenken dropped'
87
+ ent=[x for x in p if isinstance((x if isinstance(x,str) else x.get('source')),str) and ((x if isinstance(x,str) else x.get('source')).rstrip('/').split('/')[-1]=='entwurf' or (x if isinstance(x,str) else x.get('source')).endswith('/node_modules/@junghanacs/entwurf') or (x if isinstance(x,str) else x.get('source'))=='$RESOLVED')]
88
+ assert len(ent)==1, f'expected exactly 1 entwurf entry, got {ent}'
89
+ " 2>/dev/null; then ok "stale entwurf paths/sources (bare + npm) normalized to a single REPO_DIR"; else bad "stale entwurf normalization failed"; fi
90
+
91
+ # 5. object-form entwurf entries collapse to one canonical string (no duplicate)
92
+ cat > "$S" <<JSON
93
+ {"packages": [{"source": "$RESOLVED"}, "../../repos/gh/andenken"]}
94
+ JSON
95
+ python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
96
+ python3 "$REG" "$S" "$FAKE_REPO" >/dev/null # twice → still one
97
+ if python3 -c "
98
+ import json
99
+ p=json.load(open('$S'))['packages']
100
+ srcs=[(x if isinstance(x,str) else x.get('source')) for x in p]
101
+ assert srcs.count('$RESOLVED')==1, f'object-form produced duplicate/none: {p}'
102
+ assert all(not isinstance(x,dict) or x.get('source')!='$RESOLVED' for x in p), 'object-form entwurf survived (should collapse to string)'
103
+ assert '../../repos/gh/andenken' in p, 'andenken dropped'
104
+ " 2>/dev/null; then ok "object-form entwurf collapses to a single canonical string (no dup across re-runs)"; else bad "object-form entwurf caused a duplicate or survived as object"; fi
105
+
106
+ # 6. a non-object settings file fails loud (never silently wipes operator config)
107
+ echo '[]' > "$S"
108
+ if python3 "$REG" "$S" "$FAKE_REPO" >/dev/null 2>&1; then bad "non-object settings should fail, not overwrite"; else ok "non-object settings fails loud (no silent clobber)"; fi
109
+
110
+ # 7. a non-array packages value fails loud (never silently coerced to [])
111
+ echo '{"packages": {"broken": true}}' > "$S"
112
+ if python3 "$REG" "$S" "$FAKE_REPO" >/dev/null 2>&1; then bad "non-array packages should fail, not coerce to []"; else ok "non-array packages fails loud (no silent drop of operator packages)"; fi
113
+
114
+ # 8. --remove drops every entwurf entry but preserves unrelated + look-alikes
115
+ # (symmetric with install: same is_entwurf_source predicate, no over-delete).
116
+ cat > "$S" <<JSON
117
+ {"packages": ["$RESOLVED", "../../repos/gh/andenken", "/home/me/entwurf-notes"]}
118
+ JSON
119
+ python3 "$REG" "$S" "$FAKE_REPO" --remove >/dev/null
120
+ if python3 -c "
121
+ import json
122
+ p=json.load(open('$S'))['packages']
123
+ assert '$RESOLVED' not in p, 'entwurf not removed'
124
+ assert '../../repos/gh/andenken' in p, 'andenken over-removed'
125
+ assert '/home/me/entwurf-notes' in p, 'entwurf-notes over-removed (substring over-delete)'
126
+ " 2>/dev/null; then ok "--remove drops entwurf, preserves unrelated + look-alikes (symmetric with install)"; else bad "--remove over-deleted a look-alike or missed entwurf"; fi
127
+
128
+ echo
129
+ if [ "$fail" -eq 0 ]; then echo "smoke-user-scope-citizen: PASS"; else echo "smoke-user-scope-citizen: FAIL (see above)"; exit 1; fi