@junghanacs/entwurf 0.12.0

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 (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env python3
2
+ """Stateful install/uninstall config manager for entwurf meta-bridge.
3
+
4
+ This is the Phase-2 honesty core: install records exactly what it touched before
5
+ writing, and uninstall restores/removes only those keys/items. No blind jq merge.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import copy
12
+ import json
13
+ import os
14
+ import sys
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ PLUGIN = "entwurf-meta-receive"
20
+ MARKETPLACE = "meta-bridge-local"
21
+ PLUGIN_REF = f"{PLUGIN}@{MARKETPLACE}"
22
+ STATE_VERSION = 1
23
+ OWNER = "entwurf meta-bridge"
24
+
25
+ PERMISSION_ALLOW = [
26
+ "Bash",
27
+ "Read",
28
+ "Write",
29
+ "Edit",
30
+ "Grep",
31
+ "Glob",
32
+ "WebFetch",
33
+ "WebSearch",
34
+ "Skill",
35
+ "mcp__entwurf-bridge__*",
36
+ ]
37
+
38
+ PERMISSION_DENY = [
39
+ "Agent",
40
+ "AskUserQuestion",
41
+ "CronCreate",
42
+ "CronDelete",
43
+ "CronList",
44
+ "EnterPlanMode",
45
+ "ExitPlanMode",
46
+ "EnterWorktree",
47
+ "ExitWorktree",
48
+ "Monitor",
49
+ "NotebookEdit",
50
+ "PushNotification",
51
+ "TaskCreate",
52
+ "TaskGet",
53
+ "TaskList",
54
+ "TaskOutput",
55
+ "TaskStop",
56
+ "TaskUpdate",
57
+ ]
58
+
59
+ # Legacy permission-allow items from the pre-rename bridge name (pi-tools-bridge
60
+ # → entwurf-bridge). apply() prunes these so an old install's stale allow does
61
+ # not linger forever (append_unique only adds; it never removes). Parallel to
62
+ # install.sh's one-shot `claude mcp remove pi-tools-bridge`. Uninstall does not
63
+ # restore them — the tool no longer exists under the old name, and they were
64
+ # never user-authored items.
65
+ LEGACY_PERMISSION_ALLOW = ["mcp__pi-tools-bridge__*"]
66
+
67
+ # Claude Code single-driver policy scalars owned by entwurf for the native
68
+ # meta-bridge install. These are not theming/personal hooks; they close background
69
+ # autonomy/suggestion/compaction surfaces so Claude Code behaves like the same
70
+ # single forged screwdriver that entwurf already enforces for ACP backends.
71
+ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
72
+ ("cleanupPeriodDays", ["cleanupPeriodDays"], 365),
73
+ ("env.DISABLE_AUTOCOMPACT", ["env", "DISABLE_AUTOCOMPACT"], "1"),
74
+ ("promptSuggestionEnabled", ["promptSuggestionEnabled"], False),
75
+ ("awaySummaryEnabled", ["awaySummaryEnabled"], False),
76
+ ("autoMemoryEnabled", ["autoMemoryEnabled"], False),
77
+ ("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
78
+ ("verbose", ["verbose"], False),
79
+ ("autoCompactEnabled", ["autoCompactEnabled"], False),
80
+ ("showTurnDuration", ["showTurnDuration"], False),
81
+ ("terminalProgressBarEnabled", ["terminalProgressBarEnabled"], False),
82
+ ("useAutoModeDuringPlan", ["useAutoModeDuringPlan"], False),
83
+ ]
84
+
85
+
86
+ class StateError(RuntimeError):
87
+ pass
88
+
89
+
90
+ def die(msg: str) -> None:
91
+ raise StateError(msg)
92
+
93
+
94
+ def iso_now() -> str:
95
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
96
+
97
+
98
+ def claude_config_dir() -> Path:
99
+ raw = os.environ.get("CLAUDE_CONFIG_DIR")
100
+ return Path(raw).expanduser().resolve() if raw else (Path.home() / ".claude")
101
+
102
+
103
+ def settings_path() -> Path:
104
+ return claude_config_dir() / "settings.json"
105
+
106
+
107
+ def state_path() -> Path:
108
+ return claude_config_dir() / "entwurf.install-state.json"
109
+
110
+
111
+ def claude_root_config_path() -> Path:
112
+ # Claude Code user-scope MCP is stored in ~/.claude.json (not in
113
+ # ~/.claude/settings.json). In tests HOME is isolated, so this remains safe.
114
+ return Path.home() / ".claude.json"
115
+
116
+
117
+ def read_json(path: Path, default: Any) -> Any:
118
+ if not path.exists():
119
+ return copy.deepcopy(default)
120
+ try:
121
+ with path.open("r", encoding="utf-8") as f:
122
+ return json.load(f)
123
+ except json.JSONDecodeError as exc:
124
+ die(f"{path} is not valid JSON: {exc}")
125
+
126
+
127
+ def write_json(path: Path, value: Any, mode: int | None = None) -> None:
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ tmp = path.with_name(f".{path.name}.tmp-{os.getpid()}")
130
+ with tmp.open("w", encoding="utf-8") as f:
131
+ json.dump(value, f, ensure_ascii=False, indent=2)
132
+ f.write("\n")
133
+ if mode is not None:
134
+ os.chmod(tmp, mode)
135
+ tmp.replace(path)
136
+
137
+
138
+ def load_state(required: bool) -> dict[str, Any] | None:
139
+ path = state_path()
140
+ if not path.exists():
141
+ if required:
142
+ die(f"install state missing: {path}. Honest uninstall/check requires the state file.")
143
+ return None
144
+ state = read_json(path, {})
145
+ if not isinstance(state, dict) or state.get("schemaVersion") != STATE_VERSION or state.get("owner") != OWNER:
146
+ die(f"invalid install state schema: {path}")
147
+ return state
148
+
149
+
150
+ def init_state(repo: Path, asm: Path) -> dict[str, Any]:
151
+ return {
152
+ "schemaVersion": STATE_VERSION,
153
+ "owner": OWNER,
154
+ "createdAt": iso_now(),
155
+ "updatedAt": iso_now(),
156
+ "repo": str(repo.resolve()),
157
+ "assembledMarketplacePath": str(asm.resolve()),
158
+ "files": {
159
+ "settings": {"path": str(settings_path()), "keys": {}},
160
+ "claudeRoot": {"path": str(claude_root_config_path()), "keys": {}},
161
+ },
162
+ }
163
+
164
+
165
+ def ensure_object(parent: dict[str, Any], key: str, label: str) -> dict[str, Any]:
166
+ value = parent.get(key)
167
+ if value is None:
168
+ value = {}
169
+ parent[key] = value
170
+ if not isinstance(value, dict):
171
+ die(f"{label} must be an object before entwurf can manage a child key")
172
+ return value
173
+
174
+
175
+ def get_nested(obj: dict[str, Any], path: list[str]) -> tuple[bool, Any]:
176
+ cur: Any = obj
177
+ for key in path[:-1]:
178
+ if not isinstance(cur, dict) or key not in cur:
179
+ return False, None
180
+ cur = cur[key]
181
+ if not isinstance(cur, dict) or path[-1] not in cur:
182
+ return False, None
183
+ return True, cur[path[-1]]
184
+
185
+
186
+ def set_nested(obj: dict[str, Any], path: list[str], value: Any) -> None:
187
+ cur = obj
188
+ for key in path[:-1]:
189
+ cur = ensure_object(cur, key, ".".join(path[:-1]))
190
+ cur[path[-1]] = value
191
+
192
+
193
+ def delete_nested(obj: dict[str, Any], path: list[str]) -> None:
194
+ cur: Any = obj
195
+ parents: list[tuple[dict[str, Any], str]] = []
196
+ for key in path[:-1]:
197
+ if not isinstance(cur, dict) or key not in cur:
198
+ return
199
+ parents.append((cur, key))
200
+ cur = cur[key]
201
+ if isinstance(cur, dict):
202
+ cur.pop(path[-1], None)
203
+ # Prune empty objects created only for our key path. Stop before deleting the
204
+ # root document.
205
+ for parent, key in reversed(parents):
206
+ child = parent.get(key)
207
+ if isinstance(child, dict) and not child:
208
+ parent.pop(key, None)
209
+ else:
210
+ break
211
+
212
+
213
+ def snapshot_value(
214
+ state: dict[str, Any],
215
+ file_key: str,
216
+ name: str,
217
+ obj: dict[str, Any],
218
+ path: list[str],
219
+ kind: str,
220
+ legacy_absent_if_equal: Any = None,
221
+ ) -> None:
222
+ keys = state["files"][file_key]["keys"]
223
+ if name in keys:
224
+ return
225
+ existed, value = get_nested(obj, path)
226
+ # Phase-2 migration path: GLG's machine already had the Phase-0/1 tribal
227
+ # installer live before the state file existed. If an exact entwurf
228
+ # managed value is present with no state, treating it as "original" would make
229
+ # uninstall restore the legacy install instead of removing it. Exact-match
230
+ # migration is limited to pi-owned identity keys (plugin/marketplace/MCP), not
231
+ # user-like policy keys such as permissions/env.
232
+ if existed and legacy_absent_if_equal is not None and value == legacy_absent_if_equal:
233
+ existed = False
234
+ value = None
235
+ keys[name] = {"kind": kind, "path": path, "original": {"existed": existed, "value": copy.deepcopy(value)}}
236
+
237
+
238
+ def snapshot_array_items(
239
+ state: dict[str, Any], file_key: str, name: str, obj: dict[str, Any], path: list[str], desired: list[str]
240
+ ) -> None:
241
+ keys = state["files"][file_key]["keys"]
242
+ existed, value = get_nested(obj, path)
243
+ if existed and not isinstance(value, list):
244
+ die(f"{'.'.join(path)} exists but is not an array; refusing to merge managed permission items")
245
+ current = value if isinstance(value, list) else []
246
+ if name in keys:
247
+ # Preserve the first-install additions, but if the managed baseline grew
248
+ # since then, add only the truly new managed entries.
249
+ entry = keys[name]
250
+ if entry.get("kind") != "array-items":
251
+ die(f"state entry {name} kind mismatch")
252
+ added = list(entry.get("added", []))
253
+ original_values = set(current) - set(added)
254
+ for item in desired:
255
+ if item not in original_values and item not in added:
256
+ added.append(item)
257
+ entry["added"] = added
258
+ return
259
+ added = [item for item in desired if item not in current]
260
+ keys[name] = {"kind": "array-items", "path": path, "originalExisted": existed, "added": added}
261
+
262
+
263
+ def desired_marketplace(asm: Path) -> dict[str, Any]:
264
+ return {"source": {"source": "directory", "path": str(asm.resolve())}}
265
+
266
+
267
+ def desired_mcp(repo: Path) -> dict[str, Any]:
268
+ return {
269
+ "type": "stdio",
270
+ "command": "bash",
271
+ "args": [str((repo / "mcp" / "entwurf-bridge" / "start.sh").resolve())],
272
+ "env": {
273
+ "ENTWURF_BRIDGE_EXTERNAL_AGENT_ID": "external-mcp/claude-code",
274
+ # Anonymous sends are forbidden on the Claude Code install path: a send
275
+ # with no pi-session identity AND no meta-sender marker is refused, not
276
+ # delivered as an unidentified external. The SessionStart hook writes
277
+ # the marker (parent-pid keyed), so a normally-opened session always has
278
+ # an authoritative garden-id sender.
279
+ "ENTWURF_BRIDGE_REQUIRE_META_SENDER": "1",
280
+ },
281
+ }
282
+
283
+
284
+ def desired_statusline(repo: Path) -> dict[str, Any]:
285
+ return {"type": "command", "command": str((repo / "scripts" / "meta-bridge-statusline.sh").resolve())}
286
+
287
+
288
+ def prepare(repo: Path, asm: Path) -> None:
289
+ existing = load_state(required=False)
290
+ state = existing if existing is not None else init_state(repo, asm)
291
+ state["updatedAt"] = iso_now()
292
+ state["repo"] = str(repo.resolve())
293
+ state["assembledMarketplacePath"] = str(asm.resolve())
294
+
295
+ settings = read_json(settings_path(), {})
296
+ root = read_json(claude_root_config_path(), {})
297
+ if not isinstance(settings, dict):
298
+ die(f"{settings_path()} root must be a JSON object")
299
+ if not isinstance(root, dict):
300
+ die(f"{claude_root_config_path()} root must be a JSON object")
301
+
302
+ snapshot_value(
303
+ state,
304
+ "settings",
305
+ f"enabledPlugins.{PLUGIN_REF}",
306
+ settings,
307
+ ["enabledPlugins", PLUGIN_REF],
308
+ "map-entry",
309
+ legacy_absent_if_equal=True,
310
+ )
311
+ snapshot_value(
312
+ state,
313
+ "settings",
314
+ f"extraKnownMarketplaces.{MARKETPLACE}",
315
+ settings,
316
+ ["extraKnownMarketplaces", MARKETPLACE],
317
+ "map-entry",
318
+ legacy_absent_if_equal=desired_marketplace(asm),
319
+ )
320
+ snapshot_array_items(state, "settings", "permissions.allow", settings, ["permissions", "allow"], PERMISSION_ALLOW)
321
+ snapshot_array_items(state, "settings", "permissions.deny", settings, ["permissions", "deny"], PERMISSION_DENY)
322
+ for name, path_, _desired in MANAGED_SETTINGS_SCALARS:
323
+ snapshot_value(state, "settings", name, settings, path_, "scalar")
324
+ snapshot_value(
325
+ state,
326
+ "settings",
327
+ "statusLine",
328
+ settings,
329
+ ["statusLine"],
330
+ "map-entry",
331
+ legacy_absent_if_equal=desired_statusline(repo),
332
+ )
333
+ snapshot_value(
334
+ state,
335
+ "claudeRoot",
336
+ "mcpServers.entwurf-bridge",
337
+ root,
338
+ ["mcpServers", "entwurf-bridge"],
339
+ "map-entry",
340
+ legacy_absent_if_equal=desired_mcp(repo),
341
+ )
342
+
343
+ write_json(state_path(), state, mode=0o600)
344
+ print(f"[meta-bridge-state] prepared {state_path()}")
345
+
346
+
347
+ def append_unique(current: list[Any], additions: list[str]) -> list[Any]:
348
+ out = list(current)
349
+ for item in additions:
350
+ if item not in out:
351
+ out.append(item)
352
+ return out
353
+
354
+
355
+ def apply(repo: Path, asm: Path) -> None:
356
+ state = load_state(required=True)
357
+ assert state is not None
358
+ settings = read_json(settings_path(), {})
359
+ root = read_json(claude_root_config_path(), {})
360
+ if not isinstance(settings, dict):
361
+ die(f"{settings_path()} root must be a JSON object")
362
+ if not isinstance(root, dict):
363
+ die(f"{claude_root_config_path()} root must be a JSON object")
364
+
365
+ set_nested(settings, ["enabledPlugins", PLUGIN_REF], True)
366
+ set_nested(settings, ["extraKnownMarketplaces", MARKETPLACE], desired_marketplace(asm))
367
+ for path_, desired in [(["permissions", "allow"], PERMISSION_ALLOW), (["permissions", "deny"], PERMISSION_DENY)]:
368
+ existed, value = get_nested(settings, path_)
369
+ if existed and not isinstance(value, list):
370
+ die(f"{'.'.join(path_)} exists but is not an array; refusing to merge managed permission items")
371
+ merged = append_unique(value if isinstance(value, list) else [], desired)
372
+ if path_ == ["permissions", "allow"]:
373
+ merged = [item for item in merged if item not in LEGACY_PERMISSION_ALLOW]
374
+ set_nested(settings, path_, merged)
375
+ for _name, path_, desired in MANAGED_SETTINGS_SCALARS:
376
+ set_nested(settings, path_, desired)
377
+ set_nested(settings, ["statusLine"], desired_statusline(repo))
378
+ set_nested(root, ["mcpServers", "entwurf-bridge"], desired_mcp(repo))
379
+
380
+ state["updatedAt"] = iso_now()
381
+ state["repo"] = str(repo.resolve())
382
+ state["assembledMarketplacePath"] = str(asm.resolve())
383
+ write_json(settings_path(), settings)
384
+ write_json(claude_root_config_path(), root)
385
+ write_json(state_path(), state, mode=0o600)
386
+ print("[meta-bridge-state] applied managed keyset (settings.json + user MCP)")
387
+
388
+
389
+ def restore_entry(obj: dict[str, Any], entry: dict[str, Any]) -> None:
390
+ path = entry.get("path")
391
+ if not isinstance(path, list) or not all(isinstance(p, str) for p in path):
392
+ die("bad state entry path")
393
+ kind = entry.get("kind")
394
+ if kind in ("map-entry", "scalar"):
395
+ original = entry.get("original")
396
+ if not isinstance(original, dict):
397
+ die("bad scalar/map original in state")
398
+ if original.get("existed"):
399
+ set_nested(obj, path, copy.deepcopy(original.get("value")))
400
+ else:
401
+ delete_nested(obj, path)
402
+ return
403
+ if kind == "array-items":
404
+ existed, value = get_nested(obj, path)
405
+ if existed and not isinstance(value, list):
406
+ die(f"{'.'.join(path)} exists but is not an array; refusing to uninstall managed permission items")
407
+ added = entry.get("added", [])
408
+ if not isinstance(added, list):
409
+ die("bad array added list in state")
410
+ current = value if isinstance(value, list) else []
411
+ remaining = [item for item in current if item not in added]
412
+ if remaining or entry.get("originalExisted"):
413
+ set_nested(obj, path, remaining)
414
+ else:
415
+ delete_nested(obj, path)
416
+ return
417
+ die(f"unknown state entry kind: {kind}")
418
+
419
+
420
+ def preflight_uninstall() -> None:
421
+ load_state(required=True)
422
+ print(f"[meta-bridge-state] uninstall preflight ok ({state_path()})")
423
+
424
+
425
+ def uninstall() -> None:
426
+ state = load_state(required=True)
427
+ assert state is not None
428
+ settings = read_json(settings_path(), {})
429
+ root = read_json(claude_root_config_path(), {})
430
+ if not isinstance(settings, dict):
431
+ die(f"{settings_path()} root must be a JSON object")
432
+ if not isinstance(root, dict):
433
+ die(f"{claude_root_config_path()} root must be a JSON object")
434
+
435
+ for entry in state["files"]["settings"]["keys"].values():
436
+ restore_entry(settings, entry)
437
+ for entry in state["files"]["claudeRoot"]["keys"].values():
438
+ restore_entry(root, entry)
439
+
440
+ write_json(settings_path(), settings)
441
+ write_json(claude_root_config_path(), root)
442
+ state_path().unlink(missing_ok=True)
443
+ print("[meta-bridge-state] restored managed keyset and removed install state")
444
+
445
+
446
+ def managed_keys() -> dict[str, Any]:
447
+ """The SSOT of settings.json / ~/.claude.json keys entwurf OWNS.
448
+
449
+ Derived from the same constants install/apply/check use, so there is one
450
+ source of truth for "which keys are ours". Cross-repo consumers (the keyset
451
+ overlap guard, agent-config's fragment) read THIS to know which keys they
452
+ must NOT also set — the keyset-owner invariant ("each side sets only its own
453
+ keys, never breaks the other's"). Paths are dotted; a parent path (e.g.
454
+ `statusLine`) owns the whole subtree below it.
455
+ """
456
+ return {
457
+ "owner": OWNER,
458
+ "settings": {
459
+ "scalar": [name for name, _path, _desired in MANAGED_SETTINGS_SCALARS],
460
+ "array-items": ["permissions.allow", "permissions.deny"],
461
+ "map-entry": [
462
+ f"enabledPlugins.{PLUGIN_REF}",
463
+ f"extraKnownMarketplaces.{MARKETPLACE}",
464
+ "statusLine",
465
+ ],
466
+ },
467
+ "claudeRoot": {
468
+ "map-entry": ["mcpServers.entwurf-bridge"],
469
+ },
470
+ }
471
+
472
+
473
+ def check(repo: Path, asm: Path) -> None:
474
+ state = load_state(required=True)
475
+ assert state is not None
476
+ settings = read_json(settings_path(), {})
477
+ root = read_json(claude_root_config_path(), {})
478
+ failures: list[str] = []
479
+ if not isinstance(settings, dict):
480
+ failures.append(f"{settings_path()} root is not an object")
481
+ settings = {}
482
+ if not isinstance(root, dict):
483
+ failures.append(f"{claude_root_config_path()} root is not an object")
484
+ root = {}
485
+
486
+ checks = [
487
+ (["enabledPlugins", PLUGIN_REF], True, "enabled plugin"),
488
+ (["extraKnownMarketplaces", MARKETPLACE], desired_marketplace(asm), "known marketplace"),
489
+ (["statusLine"], desired_statusline(repo), "statusLine"),
490
+ ] + [(path_, desired, name) for name, path_, desired in MANAGED_SETTINGS_SCALARS]
491
+ for path_, expected, label in checks:
492
+ existed, value = get_nested(settings, path_)
493
+ if not existed or value != expected:
494
+ failures.append(f"settings {label} missing/drifted at {'.'.join(path_)}")
495
+ for path_, desired, label in [(["permissions", "allow"], PERMISSION_ALLOW, "allow"), (["permissions", "deny"], PERMISSION_DENY, "deny")]:
496
+ existed, value = get_nested(settings, path_)
497
+ if not existed or not isinstance(value, list):
498
+ failures.append(f"settings permissions.{label} missing/not array")
499
+ else:
500
+ missing = [item for item in desired if item not in value]
501
+ if missing:
502
+ failures.append(f"settings permissions.{label} missing managed item(s): {', '.join(missing)}")
503
+ if path_ == ["permissions", "allow"]:
504
+ legacy = [item for item in LEGACY_PERMISSION_ALLOW if item in value]
505
+ if legacy:
506
+ failures.append(f"settings permissions.allow carries pruned legacy item(s): {', '.join(legacy)} (re-inject — re-run install-meta-bridge to prune)")
507
+ existed, value = get_nested(root, ["mcpServers", "entwurf-bridge"])
508
+ if not existed or value != desired_mcp(repo):
509
+ failures.append("user MCP entwurf-bridge missing/drifted in ~/.claude.json")
510
+
511
+ if failures:
512
+ for f in failures:
513
+ print(f"FAIL: {f}", file=sys.stderr)
514
+ raise SystemExit(1)
515
+ print(f"[meta-bridge-state] check ok ({state_path()})")
516
+
517
+
518
+ def main() -> int:
519
+ parser = argparse.ArgumentParser(description="entwurf meta-bridge state manager")
520
+ parser.add_argument(
521
+ "command",
522
+ choices=["prepare", "apply", "preflight-uninstall", "uninstall", "check", "managed-keys"],
523
+ )
524
+ parser.add_argument("--repo", default=Path(__file__).resolve().parents[1], type=Path)
525
+ parser.add_argument("--asm", default=None, type=Path)
526
+ args = parser.parse_args()
527
+ repo = args.repo.resolve()
528
+ asm = (args.asm or (repo / "pi" / "meta-bridge" / ".assembled")).resolve()
529
+ try:
530
+ if args.command == "prepare":
531
+ prepare(repo, asm)
532
+ elif args.command == "apply":
533
+ apply(repo, asm)
534
+ elif args.command == "preflight-uninstall":
535
+ preflight_uninstall()
536
+ elif args.command == "uninstall":
537
+ uninstall()
538
+ elif args.command == "check":
539
+ check(repo, asm)
540
+ elif args.command == "managed-keys":
541
+ print(json.dumps(managed_keys(), indent=2))
542
+ except StateError as exc:
543
+ print(f"meta-bridge-state: {exc}", file=sys.stderr)
544
+ return 1
545
+ return 0
546
+
547
+
548
+ if __name__ == "__main__":
549
+ raise SystemExit(main())