@junghanacs/entwurf 0.12.4 → 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.
@@ -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,8 +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 <repo-stable .assembled> (NOT /tmp ephemeral source
17
- # would break `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).
18
21
  # 3. install entwurf-meta-receive@meta-bridge-local --scope user (= global:
19
22
  # every native session auto-loads it; no manual --plugin-dir).
20
23
  # 4. install/update USER-scope entwurf-bridge MCP, so every native session has
@@ -30,7 +33,32 @@ REPO="$(cd "$HERE/.." && pwd)"
30
33
  MKT_NAME="meta-bridge-local"
31
34
  PLUGIN="entwurf-meta-receive"
32
35
  SRC="$REPO/pi/meta-bridge"
33
- ASM="$SRC/.assembled"
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.
50
+ case "$REPO" in
51
+ */node_modules/@junghanacs/entwurf)
52
+ HOOK_ENTRY="meta-bridge-hook.js"
53
+ HOOK_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js"
54
+ LIB_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
55
+ LIB_EXT="js" ;;
56
+ *)
57
+ HOOK_ENTRY="meta-bridge-hook.ts"
58
+ HOOK_SRC="$REPO/pi-extensions/meta-bridge-hook.ts"
59
+ LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
60
+ LIB_EXT="ts" ;;
61
+ esac
34
62
 
35
63
  die() { echo "meta-bridge-install: $*" >&2; exit 1; }
36
64
 
@@ -65,9 +93,12 @@ mkdir -p "$ASM"
65
93
  cp -r "$SRC/.claude-plugin" "$ASM/.claude-plugin"
66
94
  cp -r "$SRC/$PLUGIN" "$ASM/$PLUGIN"
67
95
  # entry shell + its lib travel WITH the plugin so the install copy is self-contained.
68
- cp "$REPO/pi-extensions/meta-bridge-hook.ts" "$ASM/$PLUGIN/meta-bridge-hook.ts"
96
+ # Installed → tsc-emitted JS (dist, node_modules-safe); dev clone → strip-types .ts.
97
+ [ -f "$HOOK_SRC" ] || die "hook artifact missing: $HOOK_SRC (installed package ships it via prepack build-bridge → dist; reinstall @junghanacs/entwurf, or run 'pnpm run build-bridge' in a dev clone)."
98
+ [ -f "$LIB_SRC" ] || die "hook lib artifact missing: $LIB_SRC (same build-bridge dist closure)."
99
+ cp "$HOOK_SRC" "$ASM/$PLUGIN/$HOOK_ENTRY"
69
100
  mkdir -p "$ASM/$PLUGIN/lib"
70
- cp "$REPO/pi-extensions/lib/meta-session.ts" "$ASM/$PLUGIN/lib/meta-session.ts"
101
+ cp "$LIB_SRC" "$ASM/$PLUGIN/lib/meta-session.$LIB_EXT"
71
102
  cp "$REPO/pi-extensions/lib/session-id.js" "$ASM/$PLUGIN/lib/session-id.js"
72
103
  # v2 writer (3D-3+) reads the capability registry at runtime
73
104
  # (loadMetaCapabilityRegistry). It MUST travel at the plugin ROOT — meta-session's
@@ -76,23 +107,29 @@ cp "$REPO/pi-extensions/lib/session-id.js" "$ASM/$PLUGIN/lib/session-id.js"
76
107
  # Without this, a v2 writer throws on every mint/parse. doctor-meta-bridge asserts it.
77
108
  cp "$REPO/pi/entwurf-capabilities.json" "$ASM/$PLUGIN/entwurf-capabilities.json"
78
109
  chmod +x "$ASM/$PLUGIN/scripts/doorbell.sh"
79
- # Bake the node abspath into hooks.json — the ONLY templated surface. mailbox /
80
- # meta-record dirs resolve at runtime inside entry.ts (<pi-agent-dir>, fixed ~/).
110
+ # Bake the node abspath AND the hook entry filename into hooks.json — the two
111
+ # templated surfaces (0.12.5: HOOK_ENTRY is meta-bridge-hook.js when installed,
112
+ # .ts in a dev clone). mailbox / meta-record dirs resolve at runtime inside the
113
+ # hook itself (<pi-agent-dir>, fixed ~/).
81
114
  # The plugin owns ONLY the wake/record hooks; the receiver-side entwurf_inbox_read
82
115
  # tool is NOT the plugin's job. It comes from USER-scope entwurf-bridge MCP
83
116
  # wiring (`claude mcp add -s user ...`), never a plugin .mcp.json duplicate.
84
117
  HOOKS="$ASM/$PLUGIN/hooks/hooks.json"
85
- HOOKS_PATH="$HOOKS" NODE_PATH_TO_BAKE="$NODE_BIN" python3 - <<'PY'
118
+ HOOKS_PATH="$HOOKS" NODE_PATH_TO_BAKE="$NODE_BIN" HOOK_ENTRY_TO_BAKE="$HOOK_ENTRY" python3 - <<'PY'
86
119
  from pathlib import Path
87
120
  import os
88
121
  hooks = Path(os.environ["HOOKS_PATH"])
89
122
  node = os.environ["NODE_PATH_TO_BAKE"]
123
+ hook_entry = os.environ["HOOK_ENTRY_TO_BAKE"]
90
124
  text = hooks.read_text(encoding="utf-8")
91
- if "__NODE_BIN__" not in text:
92
- raise SystemExit(f"node-path bake failed before replacement (placeholder absent in {hooks})")
93
- hooks.write_text(text.replace("__NODE_BIN__", node), encoding="utf-8")
125
+ for placeholder in ("__NODE_BIN__", "__HOOK_ENTRY__"):
126
+ if placeholder not in text:
127
+ raise SystemExit(f"hooks bake failed before replacement ({placeholder} absent in {hooks})")
128
+ hooks.write_text(text.replace("__NODE_BIN__", node).replace("__HOOK_ENTRY__", hook_entry), encoding="utf-8")
94
129
  PY
95
- grep -q "__NODE_BIN__" "$HOOKS" && die "node-path bake failed (placeholder still present in $HOOKS)."
130
+ for placeholder in "__NODE_BIN__" "__HOOK_ENTRY__"; do
131
+ grep -q "$placeholder" "$HOOKS" && die "hooks bake failed ($placeholder still present in $HOOKS)."
132
+ done
96
133
  echo "[meta-bridge-install] assembled $ASM (node baked, entry+lib bundled; MCP wiring is NOT plugin-owned)"
97
134
 
98
135
  # --- validate the manifests before touching user config ---------------------
@@ -306,6 +306,12 @@ def desired_mcp(repo: Path) -> dict[str, Any]:
306
306
 
307
307
 
308
308
  def desired_statusline(repo: Path) -> dict[str, Any]:
309
+ if is_installed_package(repo):
310
+ # Installed package: mirror the MCP stable-bin pattern. The statusLine is
311
+ # executed at render time, so a bare bin shim lets npm/pnpm update the
312
+ # target on package upgrade without rewriting Claude settings. Dev clones
313
+ # stay pinned to their checkout below.
314
+ return {"type": "command", "command": "entwurf-statusline"}
309
315
  return {"type": "command", "command": str((repo / "scripts" / "meta-bridge-statusline.sh").resolve())}
310
316
 
311
317
 
@@ -507,9 +513,33 @@ def check(repo: Path, asm: Path) -> None:
507
513
  failures.append(f"{claude_root_config_path()} root is not an object")
508
514
  root = {}
509
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
+ )
510
540
  checks = [
511
541
  (["enabledPlugins", PLUGIN_REF], True, "enabled plugin"),
512
- (["extraKnownMarketplaces", MARKETPLACE], desired_marketplace(asm), "known marketplace"),
542
+ (["extraKnownMarketplaces", MARKETPLACE], marketplace_expected, "known marketplace"),
513
543
  (["statusLine"], desired_statusline(repo), "statusLine"),
514
544
  ] + [(path_, desired, name) for name, path_, desired in MANAGED_SETTINGS_SCALARS]
515
545
  for path_, expected, label in checks:
@@ -543,13 +573,29 @@ def main() -> int:
543
573
  parser = argparse.ArgumentParser(description="entwurf meta-bridge state manager")
544
574
  parser.add_argument(
545
575
  "command",
546
- choices=["prepare", "apply", "preflight-uninstall", "uninstall", "check", "managed-keys", "desired-mcp"],
576
+ choices=[
577
+ "prepare",
578
+ "apply",
579
+ "preflight-uninstall",
580
+ "uninstall",
581
+ "assembled-path",
582
+ "check",
583
+ "managed-keys",
584
+ "desired-mcp",
585
+ "desired-statusline",
586
+ ],
547
587
  )
548
588
  parser.add_argument("--repo", default=Path(__file__).resolve().parents[1], type=Path)
549
589
  parser.add_argument("--asm", default=None, type=Path)
550
590
  args = parser.parse_args()
551
591
  repo = args.repo.resolve()
552
- 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()
553
599
  try:
554
600
  if args.command == "prepare":
555
601
  prepare(repo, asm)
@@ -559,6 +605,13 @@ def main() -> int:
559
605
  preflight_uninstall()
560
606
  elif args.command == "uninstall":
561
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", ""))
562
615
  elif args.command == "check":
563
616
  check(repo, asm)
564
617
  elif args.command == "managed-keys":
@@ -568,6 +621,10 @@ def main() -> int:
568
621
  # write for --repo. Lets a deterministic guard assert the installed-vs-clone
569
622
  # dual-mode without spinning up a real `claude` CLI. --repo need not exist.
570
623
  print(json.dumps(desired_mcp(repo), indent=2))
624
+ elif args.command == "desired-statusline":
625
+ # Same guard surface for the statusLine dual-mode: dev clones pin the
626
+ # checkout path; installed packages use the stable bin shim.
627
+ print(json.dumps(desired_statusline(repo), indent=2))
571
628
  except StateError as exc:
572
629
  print(f"meta-bridge-state: {exc}", file=sys.stderr)
573
630
  return 1
@@ -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
@@ -36,4 +53,11 @@ else
36
53
  fi
37
54
 
38
55
  python3 "$REPO/scripts/meta-bridge-state.py" uninstall --repo "$REPO"
56
+
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
39
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
 
@@ -71,6 +119,12 @@ JSON
71
119
 
72
120
  py() { python3 "$STATE" "$@" --repo "$REPO" --asm "$ASM"; }
73
121
 
122
+ DEV_STATUSLINE="$(python3 "$STATE" desired-statusline --repo "$REPO" | python3 -c 'import json,sys; print(json.load(sys.stdin)["command"])')"
123
+ if [ "$DEV_STATUSLINE" = "$REPO/scripts/meta-bridge-statusline.sh" ]; then ok "dev statusLine pins the checkout script"; else bad "dev statusLine command drifted: $DEV_STATUSLINE"; fi
124
+ FAKE_INSTALLED_REPO="$TMP/npmroot/node_modules/@junghanacs/entwurf"
125
+ INSTALLED_STATUSLINE="$(python3 "$STATE" desired-statusline --repo "$FAKE_INSTALLED_REPO" | python3 -c 'import json,sys; print(json.load(sys.stdin)["command"])')"
126
+ if [ "$INSTALLED_STATUSLINE" = "entwurf-statusline" ]; then ok "installed statusLine uses the stable bin shim"; else bad "installed statusLine command drifted: $INSTALLED_STATUSLINE"; fi
127
+
74
128
  py prepare >/dev/null
75
129
  STATE_FILE="$CLAUDE_CONFIG_DIR/entwurf.install-state.json"
76
130
  [ -f "$STATE_FILE" ] && ok "prepare writes install-state before any merge" || bad "state file missing after prepare"
@@ -207,8 +261,10 @@ then ok "uninstall restores scalars/maps and removes only managed array addition
207
261
 
208
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
209
263
 
210
- # The wrapper must ALSO refuse before side effects. A fake claude records whether
211
- # 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 우회가 더는 불필요).
212
268
  FAKE_BIN="$TMP/fake-bin"; mkdir -p "$FAKE_BIN"
213
269
  cat > "$FAKE_BIN/claude" <<'SH'
214
270
  #!/usr/bin/env bash
@@ -216,6 +272,9 @@ printf '%s\n' "$*" >> "$FAKE_CLAUDE_LOG"
216
272
  exit 0
217
273
  SH
218
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.
219
278
  FAKE_CLAUDE_LOG="$TMP/fake-claude-nostate.log"
220
279
  if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
221
280
  bad "wrapper uninstall without state should fail"
@@ -223,10 +282,14 @@ else
223
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
224
283
  fi
225
284
 
226
- # With valid state, the wrapper may remove Claude registrations and then restore
227
- # JSON state. This proves the preflight gate is ordered before side effects.
228
- py prepare >/dev/null
229
- 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"
230
293
  FAKE_CLAUDE_LOG="$TMP/fake-claude-state.log"
231
294
  if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts/meta-bridge-uninstall.sh" >/dev/null 2>&1; then
232
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
@@ -234,10 +297,97 @@ if PATH="$FAKE_BIN:$PATH" FAKE_CLAUDE_LOG="$FAKE_CLAUDE_LOG" bash "$REPO/scripts
234
297
  else
235
298
  bad "wrapper uninstall with state missed expected side effects/state removal"
236
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
237
302
  else
238
303
  bad "wrapper uninstall with valid state failed"
239
304
  fi
240
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
+
241
391
  # Doctor hook-log recovery predicate: only a later `INFO armed watch` clears an
242
392
  # ERROR. A UserPromptSubmit `INFO attach record` is degraded backfill, not wake
243
393
  # recovery.
@@ -359,7 +509,7 @@ if node --experimental-strip-types "$STORE_DOCTOR" "$STORE" >/dev/null 2>&1; the
359
509
  # chain to its final summary line — i.e. no early set -e death anywhere.
360
510
  DOC_HOME="$TMP/doctor-home"; DOC_CFG="$DOC_HOME/.claude"
361
511
  DOC_AGENT="$TMP/doctor-agent"; DOC_STORE="$DOC_AGENT/meta-sessions"
362
- 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
363
513
  mkdir -p "$DOC_CFG" "$DOC_STORE" "$DOC_BIN"
364
514
  echo '{}' > "$DOC_CFG/settings.json"
365
515
  echo '{}' > "$DOC_HOME/.claude.json"
@@ -399,5 +549,11 @@ if printf '%s\n' "$DOC_OUT" | grep -q 'user MCP entwurf-bridge'; then ok "doctor
399
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
400
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
401
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
+
402
558
  echo
403
559
  if [ "$fail" -eq 0 ]; then echo "smoke-meta-install-state: PASS"; else echo "smoke-meta-install-state: FAIL (see above)"; exit 1; fi