@junghanacs/entwurf 0.12.5 → 0.12.7

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.
Files changed (93) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +44 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +33 -38
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -6
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +974 -248
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +47 -9
  79. package/scripts/meta-bridge-install.sh +20 -17
  80. package/scripts/meta-bridge-state.py +40 -2
  81. package/scripts/meta-bridge-uninstall.sh +23 -12
  82. package/scripts/register-pi-package.py +183 -0
  83. package/scripts/register-pi-provider.py +287 -0
  84. package/scripts/smoke-agy-hooks-state.sh +172 -0
  85. package/scripts/smoke-agy-install-state.sh +660 -0
  86. package/scripts/smoke-agy-native-push-live.ts +243 -0
  87. package/scripts/smoke-agy-statusline-state.sh +300 -0
  88. package/scripts/smoke-meta-async-drift.sh +9 -2
  89. package/scripts/smoke-meta-install-state.sh +178 -8
  90. package/scripts/smoke-pi-provider-state.sh +182 -0
  91. package/scripts/smoke-user-scope-citizen.sh +191 -0
  92. package/scripts/with-dist-lock.sh +81 -0
  93. package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ // doctor-pi-provider — fail-loud doctor for the pi provider (entwurfProvider.mcpServers.
3
+ // entwurf-bridge) ownership (#46 Task 2). Side-effect FREE (read-only). Uses the config.ts SSOT
4
+ // `readProviderSettingsFile` so the effective (shadow-resolved) view matches what pi actually
5
+ // loads — NOT a re-implemented merge (GPT D: a python re-impl drifts into "doctor green, runtime
6
+ // red"). Reports user / project / EFFECTIVE command (project shadows user per-name, the
7
+ // resolveProviderConfig rule), plus install-state ownership, and gates on stable-bin resolvability.
8
+ //
9
+ // Env overrides (for the hermetic smoke):
10
+ // PI_PROVIDER_GLOBAL_SETTINGS default: $PI_CODING_AGENT_DIR/settings.json or ~/.pi/agent/settings.json
11
+ // PI_PROVIDER_PROJECT_SETTINGS default: <cwd>/.pi/settings.json
12
+ // PI_PROVIDER_STATE default: $XDG_DATA_HOME/entwurf/pi-provider/install-state.json
13
+ //
14
+ // Exit: 0 ok (incl. honest "never installed / unowned" notes) · 1 hard fail (malformed settings /
15
+ // state-owned-but-drifted / stable bin dangling).
16
+ import { execSync } from "node:child_process";
17
+ import { existsSync, constants as FS, readFileSync, statSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { readProviderSettingsFile } from "../pi-extensions/lib/acp/config.ts";
21
+
22
+ const BARE = "entwurf-bridge";
23
+ const KEY = "entwurf-bridge";
24
+
25
+ const home = homedir();
26
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(home, ".pi", "agent");
27
+ const globalPath = process.env.PI_PROVIDER_GLOBAL_SETTINGS || join(agentDir, "settings.json");
28
+ const projectPath = process.env.PI_PROVIDER_PROJECT_SETTINGS || join(process.cwd(), ".pi", "settings.json");
29
+ const xdg = process.env.XDG_DATA_HOME || join(home, ".local", "share");
30
+ const statePath = process.env.PI_PROVIDER_STATE || join(xdg, "entwurf", "pi-provider", "install-state.json");
31
+
32
+ let hardFail = 0;
33
+ const log = (s: string) => process.stdout.write(s + "\n");
34
+
35
+ function commandOf(settings: { mcpServers?: Record<string, unknown> }): string | undefined {
36
+ const entry = settings.mcpServers?.[KEY];
37
+ if (entry && typeof entry === "object" && typeof (entry as { command?: unknown }).command === "string") {
38
+ return (entry as { command: string }).command;
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ // Does the command resolve in the environment (best local proxy for "where pi/agy runs")?
44
+ // A bare name is looked up on PATH; a path must be an executable file.
45
+ function resolvable(cmd: string): boolean {
46
+ if (cmd.includes("/")) {
47
+ try {
48
+ statSync(cmd);
49
+ // eslint-disable-next-line no-bitwise
50
+ return (statSync(cmd).mode & FS.S_IXUSR) !== 0;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+ try {
56
+ // `command -v` is a POSIX sh builtin; use the default /bin/sh (NixOS has no /bin/bash).
57
+ execSync(`command -v ${cmd}`, { stdio: "ignore" });
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ log("[pi-provider doctor]");
65
+
66
+ // Read via the SSOT — a malformed settings file THROWS here (fail-loud, named file).
67
+ let userCmd: string | undefined;
68
+ let projCmd: string | undefined;
69
+ try {
70
+ userCmd = commandOf(readProviderSettingsFile(globalPath).settings);
71
+ projCmd = commandOf(readProviderSettingsFile(projectPath).settings);
72
+ } catch (err) {
73
+ log(` FAIL: ${err instanceof Error ? err.message : String(err)}`);
74
+ process.exit(1);
75
+ }
76
+
77
+ // EFFECTIVE = project shadows user per-name (the resolveProviderConfig merge rule).
78
+ const effectiveCmd = projCmd ?? userCmd;
79
+ const effectiveScope = projCmd !== undefined ? "project" : userCmd !== undefined ? "user(global)" : "none";
80
+
81
+ log("── scopes (project shadows user per-name)");
82
+ log(` user(global) ${globalPath}: ${userCmd ? `'${userCmd}'` : "entwurf-bridge NOT configured"}`);
83
+ log(` project ${projectPath}: ${projCmd ? `'${projCmd}'` : "entwurf-bridge NOT configured"}`);
84
+ log(` EFFECTIVE (${effectiveScope}): ${effectiveCmd ? `'${effectiveCmd}'` : "none"}`);
85
+
86
+ // install-state ownership (user scope). absent state on a configured effective is either a
87
+ // pre-Task-2 install or a user-override we deliberately did not own.
88
+ let ownership: string | undefined;
89
+ if (existsSync(statePath)) {
90
+ try {
91
+ const st = JSON.parse(readFileSync(statePath, "utf8")) as { ownership?: string };
92
+ ownership = typeof st.ownership === "string" ? st.ownership : undefined;
93
+ log(` state: install-state present (ownership=${ownership}).`);
94
+ } catch {
95
+ log(` state: FAIL — install-state ${statePath} is unreadable/corrupt.`);
96
+ hardFail = 1;
97
+ }
98
+ } else {
99
+ log(" state: no user-scope install-state.");
100
+ }
101
+
102
+ log("── verdict");
103
+ if (effectiveCmd === undefined) {
104
+ log(
105
+ " note: no entwurfProvider.mcpServers.entwurf-bridge in any scope (never installed — this is the '?'; run ./run.sh setup).",
106
+ );
107
+ } else if (effectiveCmd === BARE) {
108
+ if (resolvable(effectiveCmd)) {
109
+ log(` ok: effective command is the bare stable bin '${BARE}' and it RESOLVES.`);
110
+ } else {
111
+ log(` FAIL: effective command is '${BARE}' but it does NOT resolve (run ./run.sh expose-dev-bin / npm bin-link).`);
112
+ hardFail = 1;
113
+ }
114
+ } else {
115
+ // effective is NOT the bare bin. If state says we own it → drift (FAIL). Otherwise classify
116
+ // the effective command honestly: our OWN legacy repo start.sh (not yet adopted) is NOT a
117
+ // user override — say so distinctly so "run setup" is the clear next step. A truly foreign
118
+ // command is an unowned override left as the operator's choice. Neither is a hard fail.
119
+ const isLegacyManaged = effectiveCmd.endsWith("/entwurf/mcp/entwurf-bridge/start.sh");
120
+ if (ownership && ownership !== "user-override") {
121
+ log(
122
+ ` FAIL: state owns entwurf-bridge (ownership=${ownership}) but the effective command drifted to '${effectiveCmd}'.`,
123
+ );
124
+ hardFail = 1;
125
+ } else if (isLegacyManaged) {
126
+ log(
127
+ ` note: effective is our LEGACY managed repo path ('${effectiveCmd}'), not yet adopted to the bare stable bin. Run ./run.sh setup to normalize (this is the pre-Task-2 '?').`,
128
+ );
129
+ } else {
130
+ log(
131
+ ` note: entwurf-bridge is an UNOWNED override ('${effectiveCmd}') — effective is not the stable bin. Left as the operator's choice (run ./run.sh setup to adopt the bare bin).`,
132
+ );
133
+ }
134
+ }
135
+
136
+ if (hardFail) {
137
+ log("pi-provider doctor: FAIL.");
138
+ process.exit(1);
139
+ }
140
+ log("pi-provider doctor: ok.");
@@ -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"
@@ -89,9 +93,43 @@ else
89
93
  fi
90
94
 
91
95
  echo "[plugin install (global / --scope user)]"
92
- if claude plugin list 2>/dev/null | grep -q "$PLUGIN@$MKT_NAME"; then
96
+ PLUGIN_LIST_JSON="$(claude plugin list --json 2>/dev/null || true)"
97
+ PLUGIN_FACT="$(printf '%s' "$PLUGIN_LIST_JSON" | python3 -c '
98
+ import json, sys
99
+ try:
100
+ rows = json.load(sys.stdin)
101
+ except Exception:
102
+ raise SystemExit(1)
103
+ target = sys.argv[1]
104
+ row = next((x for x in rows if isinstance(x, dict) and x.get("id") == target), None)
105
+ if row is None:
106
+ print("absent")
107
+ else:
108
+ print("present")
109
+ print("true" if row.get("enabled") is True else "false")
110
+ errors = row.get("errors")
111
+ print("; ".join(str(x) for x in errors) if isinstance(errors, list) else "")
112
+ ' "$PLUGIN@$MKT_NAME" 2>/dev/null || true)"
113
+ if [ -n "$PLUGIN_FACT" ]; then
114
+ PLUGIN_PRESENT="$(printf '%s\n' "$PLUGIN_FACT" | sed -n '1p')"
115
+ PLUGIN_ENABLED="$(printf '%s\n' "$PLUGIN_FACT" | sed -n '2p')"
116
+ PLUGIN_ERRORS="$(printf '%s\n' "$PLUGIN_FACT" | sed -n '3p')"
117
+ if [ "$PLUGIN_PRESENT" = "absent" ]; then
118
+ bad "$PLUGIN@$MKT_NAME not installed — run ./run.sh install-meta-bridge"
119
+ else
120
+ ok "$PLUGIN@$MKT_NAME present"
121
+ if [ -n "$PLUGIN_ERRORS" ]; then
122
+ bad "enabled=$PLUGIN_ENABLED but FAILED TO LOAD: $PLUGIN_ERRORS — the SessionStart hook is not running; re-run ./run.sh install-meta-bridge"
123
+ elif [ "$PLUGIN_ENABLED" = "true" ]; then
124
+ ok "enabled and loadable"
125
+ else
126
+ bad "installed but NOT enabled"
127
+ fi
128
+ fi
129
+ # Compatibility fallback for a Claude floor whose plugin list has no --json surface.
130
+ elif claude plugin list 2>/dev/null | grep -q "$PLUGIN@$MKT_NAME"; then
93
131
  ok "$PLUGIN@$MKT_NAME present"
94
- claude plugin list 2>/dev/null | grep -A3 "$PLUGIN@$MKT_NAME" | grep -qi "enabled" && ok "enabled" || bad "installed but NOT enabled"
132
+ claude plugin list 2>/dev/null | grep -A3 "$PLUGIN@$MKT_NAME" | grep -qi "enabled" && ok "enabled (text fallback; load errors unavailable)" || bad "installed but NOT enabled"
95
133
  else
96
134
  bad "$PLUGIN@$MKT_NAME not installed — run ./run.sh install-meta-bridge"
97
135
  fi
@@ -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,183 @@
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 would_remove(settings_path: Path, repo_dir_arg: str) -> int:
123
+ """Count the entwurf entries a --remove WOULD drop, writing NOTHING.
124
+
125
+ Read-only companion to remove() for --dry-run — lets a caller (e.g. run.sh's
126
+ project `remove` pointer note) decide whether the global user-scope inverse is
127
+ worth suggesting without mutating the operator's settings.
128
+ """
129
+ repo_dir = str(Path(repo_dir_arg).resolve())
130
+ if not settings_path.exists():
131
+ return 0
132
+ data = _load(settings_path)
133
+ packages = _packages(settings_path, data)
134
+ return len(_entwurf_matches(packages, repo_dir))
135
+
136
+
137
+ def main(argv: list[str]) -> int:
138
+ flags = {a for a in argv[1:] if a.startswith("--")}
139
+ args = [a for a in argv[1:] if not a.startswith("--")]
140
+ do_remove = "--remove" in flags
141
+ dry_run = "--dry-run" in flags
142
+ known = {"--remove", "--dry-run"}
143
+ unknown = flags - known
144
+ if unknown:
145
+ raise SystemExit(f"unknown flag(s): {', '.join(sorted(unknown))}")
146
+ # --dry-run is a REMOVE-only preview. Without --remove it would otherwise fall
147
+ # through to the register path and WRITE — a flag literally named "dry-run"
148
+ # mutating settings is an install-hygiene footgun, so reject it loud instead of
149
+ # silently registering.
150
+ if dry_run and not do_remove:
151
+ raise SystemExit("--dry-run is only supported with --remove")
152
+ if len(args) != 2:
153
+ raise SystemExit("usage: register-pi-package.py <settings.json> <repo_dir> [--remove] [--dry-run]")
154
+ settings_path = Path(args[0])
155
+ repo_dir_arg = args[1]
156
+ resolved = str(Path(repo_dir_arg).resolve())
157
+
158
+ if do_remove:
159
+ if dry_run:
160
+ n = would_remove(settings_path, repo_dir_arg)
161
+ if n:
162
+ print(f"remove: would remove {n} entwurf packages[] entr{'y' if n == 1 else 'ies'} from {settings_path}")
163
+ else:
164
+ print(f"remove: no entwurf packages[] entry to remove ({settings_path})")
165
+ return 0
166
+ n = remove(settings_path, repo_dir_arg)
167
+ if n:
168
+ print(f"remove: removed {n} entwurf packages[] entr{'y' if n == 1 else 'ies'} from {settings_path}")
169
+ else:
170
+ print(f"remove: no entwurf packages[] entry to remove ({settings_path})")
171
+ return 0
172
+
173
+ result = register(settings_path, repo_dir_arg)
174
+ if result == "noop":
175
+ print(f"install: entwurf package already registered (no-op) -> {resolved}")
176
+ else:
177
+ print(f"install: registered entwurf package -> {settings_path}")
178
+ print(f"install: package source -> {resolved}")
179
+ return 0
180
+
181
+
182
+ if __name__ == "__main__":
183
+ raise SystemExit(main(sys.argv))