@junghanacs/entwurf 0.15.0 → 0.16.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 (90) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +299 -0
  3. package/DELIVERY.md +4 -3
  4. package/README.md +99 -67
  5. package/VERIFY.md +13 -7
  6. package/docs/acp-backend-rail.md +1 -1
  7. package/docs/external-mcp-host.md +147 -9
  8. package/docs/setup-clean-host.md +221 -16
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +65 -20
  10. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +17 -6
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +1 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +172 -5
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +152 -17
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +271 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +988 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js +574 -0
  18. package/mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js +84 -0
  19. package/mcp/entwurf-bridge/src/index.ts +65 -19
  20. package/mcp/entwurf-bridge/tsconfig.build.json +19 -3
  21. package/mcp/tsconfig.json +10 -0
  22. package/package.json +14 -4
  23. package/pi/entwurf-capabilities.json +1 -0
  24. package/pi/meta-bridge-omp/entwurf-meta-omp/package.json +7 -0
  25. package/pi/omp-receive/entwurf-receive-omp/package.json +7 -0
  26. package/pi-extensions/entwurf-control.ts +12 -9
  27. package/pi-extensions/lib/entwurf-self-address.ts +18 -7
  28. package/pi-extensions/lib/meta-sender-identity.ts +1 -1
  29. package/pi-extensions/lib/meta-session.ts +219 -5
  30. package/pi-extensions/lib/mux-fresh-call.ts +171 -17
  31. package/pi-extensions/lib/omp-fresh-preflight.ts +275 -0
  32. package/pi-extensions/meta-bridge-omp.ts +1244 -0
  33. package/pi-extensions/meta-bridge-receive-omp.ts +666 -0
  34. package/run.sh +1041 -174
  35. package/scripts/agy-bridge.sh +4 -2
  36. package/scripts/check-copilot-birth-hook.ts +700 -23
  37. package/scripts/check-entwurf-self-address.ts +31 -0
  38. package/scripts/check-fresh-cut-gate.sh +12 -4
  39. package/scripts/check-gate-qualification.ts +8 -2
  40. package/scripts/check-harness-admission-parity.ts +143 -0
  41. package/scripts/check-meta-doctor-oracle.sh +20 -0
  42. package/scripts/check-omp-birth-hook.ts +1049 -0
  43. package/scripts/check-omp-fresh-preflight.ts +181 -0
  44. package/scripts/check-omp-receive-arm.ts +516 -0
  45. package/scripts/check-setup-qualification.sh +126 -0
  46. package/scripts/copilot-bridge-doctor.sh +104 -71
  47. package/scripts/copilot-bridge-install.sh +180 -20
  48. package/scripts/copilot-bridge-oracle.sh +223 -0
  49. package/scripts/copilot-bridge-uninstall.sh +137 -0
  50. package/scripts/dev-bin.sh +50 -9
  51. package/scripts/fake-copilot-vendor.sh +67 -0
  52. package/scripts/inventory-verification-surface.ts +1 -1
  53. package/scripts/meta-bridge-hook-log.sh +9 -1
  54. package/scripts/mutants/copilot-birth.json +96 -5
  55. package/scripts/mutants/mux-fresh-call.json +22 -22
  56. package/scripts/mutants/omp-birth.json +173 -0
  57. package/scripts/mutants/omp-fresh.json +300 -0
  58. package/scripts/mutants/omp-receive.json +135 -0
  59. package/scripts/mutants/pi-package-ownership.json +92 -0
  60. package/scripts/mutants/self-address.json +34 -0
  61. package/scripts/mutants/setup-verdict.json +148 -0
  62. package/scripts/mutants/source-install.json +32 -0
  63. package/scripts/omp-bridge-doctor.sh +315 -0
  64. package/scripts/omp-bridge-install.sh +221 -0
  65. package/scripts/omp-bridge-oracle.sh +154 -0
  66. package/scripts/omp-bridge-uninstall.sh +57 -0
  67. package/scripts/omp-mcp-bridge.sh +320 -0
  68. package/scripts/omp-mcp-config.py +392 -0
  69. package/scripts/omp-receive-doctor.sh +246 -0
  70. package/scripts/omp-receive-facts.ts +106 -0
  71. package/scripts/omp-receive-install.sh +228 -0
  72. package/scripts/omp-receive-uninstall.sh +60 -0
  73. package/scripts/omp-tool-surface.py +349 -0
  74. package/scripts/pi_settings_io.py +22 -0
  75. package/scripts/raw-omp-measure/README.md +420 -0
  76. package/scripts/raw-omp-measure/probe-extension.ts +76 -0
  77. package/scripts/raw-omp-measure/probe-receive-surface.ts +250 -0
  78. package/scripts/raw-omp-measure/source-audit.md +414 -0
  79. package/scripts/register-pi-package.py +525 -7
  80. package/scripts/register-pi-provider.py +162 -12
  81. package/scripts/smoke-agy-install-state.sh +86 -30
  82. package/scripts/smoke-agy-statusline-state.sh +9 -6
  83. package/scripts/smoke-omp-bridge-state.sh +221 -0
  84. package/scripts/smoke-omp-fresh-live.ts +497 -0
  85. package/scripts/smoke-omp-mcp-state.sh +327 -0
  86. package/scripts/smoke-omp-receive-live.ts +340 -0
  87. package/scripts/smoke-omp-receive-state.sh +196 -0
  88. package/scripts/smoke-setup-verdict.sh +291 -0
  89. package/scripts/smoke-user-scope-citizen.sh +388 -1
  90. package/scripts/tsconfig.json +2 -0
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env python3
2
+ """Stateful OMP-native MCP-config adapter; stdlib only (#87 step 5).
3
+
4
+ Owns ONE server key inside the omp-native user MCP file (``<agent dir>/mcp.json``).
5
+ The file wrapper is always ``mcpServers`` and the stdio entry follows omp's OWN writer
6
+ layer — ``{command, args?, env?}`` with ``type`` omitted (stdio is the default) —
7
+ measured at ``mcp/config-writer.ts:111-143`` + ``config/mcp-schema.json`` on v18.0.0.
8
+
9
+ THE SERVER KEY IS A PINNED LITERAL, AND THAT IS THE WHOLE MECHANISM. omp already
10
+ translates Claude Code's ``~/.claude.json`` as an IMPORT provider, so on a host that ever
11
+ used Claude Code there is already an ``entwurf-bridge`` server — carrying Claude Code's
12
+ external agent id. This writer does not remove it, hide it, or ask the operator to: it
13
+ shadows it, and same-key first-wins is how. Provider priorities are native=100 >
14
+ omp-plugins=90 > claude=80 (``capability/index.ts:84-91``), dedupe is first-wins on
15
+ ``key: server => server.name`` (``:183``), and on a key hit ``equivalent()`` is NEVER
16
+ consulted (``:203-207``) — so an entry whose env deliberately differs still suppresses the
17
+ import completely. A DIFFERENT key would load BOTH: two bridge tool families, one of them
18
+ still introducing this session as Claude Code.
19
+
20
+ ``disabledServers`` IS NOT THE HIDE-IMPORT TOOL. Suppression is by NAME
21
+ (``mcp/config.ts:123-127``) and a suppressed item still claims the dedupe key
22
+ (``capability/index.ts:191-196``), so denylisting ``entwurf-bridge`` kills the native entry
23
+ and the import together. This adapter never writes that key, and the doctor goes red when
24
+ somebody else has.
25
+ """
26
+
27
+ import datetime
28
+ import json
29
+ import os
30
+ import sys
31
+ import tempfile
32
+
33
+ STATE_SCHEMA_VERSION = 1
34
+
35
+ # PINNED. The literal `mcpServers` map key the Claude import also uses — measured LIVE on
36
+ # oracle 2026-08-27 (`/mcp list` → "Claude Code (~/.claude.json): entwurf-bridge") and in
37
+ # source (`discovery/claude.ts:88-92` assigns the plain object key with no prefix). Changing
38
+ # this string does not rename our entry; it un-shadows the import.
39
+ SERVER_KEY = "entwurf-bridge"
40
+
41
+ # The provenance label this whole surface exists to fix: an omp session riding the import
42
+ # introduces itself to the bridge as Claude Code.
43
+ EXTERNAL_AGENT_ID = "external-mcp/omp"
44
+ ENV_KEY = "ENTWURF_BRIDGE_EXTERNAL_AGENT_ID"
45
+
46
+ SCHEMA_URL = "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json"
47
+
48
+
49
+ def die(code: int, message: str) -> "None":
50
+ sys.stderr.write(message.rstrip("\n") + "\n")
51
+ raise SystemExit(code)
52
+
53
+
54
+ def load_object(path: str, label: str) -> dict:
55
+ try:
56
+ with open(path, "r", encoding="utf-8") as handle:
57
+ raw = handle.read()
58
+ except OSError as error:
59
+ die(4, f"omp-mcp: cannot read {label} {path}: {error}")
60
+ if not raw.strip():
61
+ return {}
62
+ try:
63
+ value = json.loads(raw)
64
+ except json.JSONDecodeError as error:
65
+ die(4, f"omp-mcp: {label} {path} is not valid JSON: {error}")
66
+ if not isinstance(value, dict):
67
+ die(4, f"omp-mcp: {label} {path} top-level must be a JSON object")
68
+ return value
69
+
70
+
71
+ def atomic_write(path: str, value: dict) -> None:
72
+ directory = os.path.dirname(os.path.abspath(path))
73
+ os.makedirs(directory, exist_ok=True)
74
+ fd, temporary = tempfile.mkstemp(dir=directory, prefix=".omp-mcp-", suffix=".json")
75
+ try:
76
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
77
+ json.dump(value, handle, indent=2)
78
+ handle.write("\n")
79
+ os.replace(temporary, path)
80
+ except BaseException:
81
+ try:
82
+ os.unlink(temporary)
83
+ except FileNotFoundError:
84
+ pass
85
+ raise
86
+
87
+
88
+ def now() -> str:
89
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
90
+
91
+
92
+ def our_entry(command: str, args: list) -> dict:
93
+ # `type` is omitted on purpose: stdio is the default in omp's own schema, and the
94
+ # vendor's writer omits it too. `env` carries the one fact that makes this entry worth
95
+ # writing at all — the omp provenance label.
96
+ return {"command": command, "args": args, "env": {ENV_KEY: EXTERNAL_AGENT_ID}}
97
+
98
+
99
+ def checked_state(state_path: str) -> dict:
100
+ state = load_object(state_path, "install-state")
101
+ required = (
102
+ "managedConfigPath",
103
+ "serverKey",
104
+ "command",
105
+ "args",
106
+ "detectMode",
107
+ "configExistedBefore",
108
+ "preimage",
109
+ )
110
+ if state.get("schemaVersion") != STATE_SCHEMA_VERSION or any(key not in state for key in required):
111
+ die(4, f"omp-mcp: install-state {state_path} has an unsupported shape")
112
+ if state.get("serverKey") != SERVER_KEY:
113
+ die(4, f"omp-mcp: install-state {state_path} serverKey is not {SERVER_KEY}")
114
+ return state
115
+
116
+
117
+ def prior_state(state_path: str, config_path: str) -> dict | None:
118
+ """The ownership state for THIS target, or a refusal.
119
+
120
+ A state that names a DIFFERENT config is not "no state" (#87 D1). Treating it as
121
+ absent captured a fresh preimage and overwrote the single state file, stranding the
122
+ previous profile's managed entry with no inverse left to remove it. There is one
123
+ state and one target: changing the target is uninstall-then-install, in that order.
124
+ """
125
+ if not os.path.exists(state_path):
126
+ return None
127
+ state = checked_state(state_path)
128
+ if state["managedConfigPath"] != os.path.abspath(config_path):
129
+ die(
130
+ 3,
131
+ f"omp-mcp: install-state {state_path} already manages {state['managedConfigPath']}, "
132
+ f"but this host now reads {os.path.abspath(config_path)}. One state owns one target: "
133
+ "overwriting it would strand the first entry with no inverse. Run uninstall-omp-mcp with "
134
+ "the ORIGINAL omp agent dir / profile selected, then install here.",
135
+ )
136
+ return state
137
+
138
+
139
+ def disabled_names(data: dict) -> list:
140
+ value = data.get("disabledServers")
141
+ return [name for name in value if isinstance(name, str)] if isinstance(value, list) else []
142
+
143
+
144
+ def install(config_path: str, command: str, args_json: str, state_path: str) -> None:
145
+ if os.path.islink(config_path):
146
+ die(3, f"omp-mcp: refusing to adopt {config_path} — symlink to {os.readlink(config_path)} (someone else's SSOT)")
147
+ try:
148
+ args = json.loads(args_json)
149
+ except json.JSONDecodeError as error:
150
+ die(5, f"omp-mcp: args must be a JSON array: {error}")
151
+ if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
152
+ die(5, "omp-mcp: args must be a JSON array of strings")
153
+
154
+ existed = os.path.exists(config_path)
155
+ data = load_object(config_path, "config") if existed else {"$schema": SCHEMA_URL}
156
+ servers = data.get("mcpServers")
157
+ if servers is None:
158
+ servers = {}
159
+ data["mcpServers"] = servers
160
+ if not isinstance(servers, dict):
161
+ die(4, f"omp-mcp: {config_path} mcpServers must be a JSON object")
162
+
163
+ # A pre-existing denylist on OUR key would suppress the entry we are about to write —
164
+ # and the import with it. Refuse rather than write a server nothing will ever load.
165
+ if SERVER_KEY in disabled_names(data):
166
+ die(
167
+ 3,
168
+ f"omp-mcp: {config_path} lists {SERVER_KEY!r} in disabledServers. Suppression is by NAME "
169
+ "(mcp/config.ts:123-127) and a suppressed item still claims the dedupe key "
170
+ "(capability/index.ts:191-196), so this would kill BOTH the native entry and the Claude "
171
+ "import. Remove that denylist entry first — it is never the way to hide an import.",
172
+ )
173
+
174
+ prior = prior_state(state_path, config_path)
175
+ if prior is None:
176
+ state = {
177
+ "schemaVersion": STATE_SCHEMA_VERSION,
178
+ "managedConfigPath": os.path.abspath(config_path),
179
+ "serverKey": SERVER_KEY,
180
+ "command": command,
181
+ "args": args,
182
+ "detectMode": "adopt-regular-file" if existed else "created-new",
183
+ "configExistedBefore": existed,
184
+ # THE PREIMAGE IS THE CURRENT VALUE ON DISK, even when it is byte-identical to
185
+ # what we are about to write. Inventing a "there was nothing here before" case
186
+ # for one backend is the special-casing the rail exists to prevent.
187
+ "preimage": servers.get(SERVER_KEY),
188
+ "installedAt": now(),
189
+ }
190
+ else:
191
+ state = prior
192
+
193
+ servers[SERVER_KEY] = our_entry(command, args)
194
+ atomic_write(config_path, data)
195
+ atomic_write(state_path, state)
196
+ sys.stdout.write(f"{state['detectMode']} {os.path.abspath(config_path)}\n")
197
+
198
+
199
+ def uninstall(state_path: str) -> None:
200
+ if not os.path.exists(state_path):
201
+ die(2, f"omp-mcp: no install-state at {state_path} — nothing to undo")
202
+ state = checked_state(state_path)
203
+ config_path = state.get("managedConfigPath")
204
+ if not isinstance(config_path, str) or not os.path.isabs(config_path):
205
+ die(4, f"omp-mcp: install-state {state_path} has no absolute managedConfigPath")
206
+ if os.path.islink(config_path):
207
+ die(3, f"omp-mcp: refusing to uninstall — {config_path} became a symlink (someone else's SSOT)")
208
+
209
+ if os.path.exists(config_path):
210
+ data = load_object(config_path, "config")
211
+ servers = data.get("mcpServers")
212
+ if isinstance(servers, dict):
213
+ preimage = state.get("preimage")
214
+ if preimage is None:
215
+ servers.pop(SERVER_KEY, None)
216
+ else:
217
+ servers[SERVER_KEY] = preimage
218
+ created = state.get("detectMode") == "created-new" and state.get("configExistedBefore") is False
219
+ # We created this file, and nothing but our own two keys is left in it. The
220
+ # honest inverse of "created-new" is an absent file — a stub carrying only a
221
+ # $schema line would be an artifact of ours surviving our own removal.
222
+ only_ours = created and len(servers) == 0 and set(data.keys()) <= {"mcpServers", "$schema"}
223
+ if only_ours:
224
+ os.remove(config_path)
225
+ else:
226
+ atomic_write(config_path, data)
227
+
228
+ os.remove(state_path)
229
+ sys.stdout.write(f"uninstalled {config_path}\n")
230
+
231
+
232
+ def native_entry_state(servers: dict) -> str:
233
+ """`absent` | `invalid` | `present` — STRUCTURAL validity of the entry under our key.
234
+
235
+ Deliberately says nothing about ownership, about which command it names, or about
236
+ provenance: those are other axes with other verdicts. This one answers only "would omp
237
+ be able to load this at all", which is what makes an invalid entry red without an
238
+ install-state (#87 B4).
239
+ """
240
+ if not isinstance(servers, dict) or SERVER_KEY not in servers:
241
+ return "absent"
242
+ server = servers.get(SERVER_KEY)
243
+ if not isinstance(server, dict):
244
+ return "invalid"
245
+ if server.get("type") not in (None, "stdio"):
246
+ return "invalid"
247
+ command = server.get("command")
248
+ if not isinstance(command, str) or not command:
249
+ return "invalid"
250
+ args = server.get("args", [])
251
+ if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
252
+ return "invalid"
253
+ env = server.get("env", {})
254
+ if not isinstance(env, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()):
255
+ return "invalid"
256
+ return "present"
257
+
258
+
259
+ def _doctor_invocation(config_path: str, command: str):
260
+ if os.path.islink(config_path):
261
+ return "symlink", None
262
+ if not os.path.exists(config_path):
263
+ return "file-absent", None
264
+ try:
265
+ data = load_object(config_path, "config")
266
+ except SystemExit:
267
+ return "invalid-json", None
268
+ if SERVER_KEY in disabled_names(data):
269
+ return "self-disabled", None
270
+ servers = data.get("mcpServers") if isinstance(data.get("mcpServers"), dict) else {}
271
+ # STRUCTURE first, through the one shared predicate — so "is this loadable at all" has
272
+ # a single answer here and in doctor-shadow, and cannot drift into two.
273
+ state = native_entry_state(servers)
274
+ if state == "absent":
275
+ return "not-ours", None
276
+ if state == "invalid":
277
+ return "invalid-entry", None
278
+ server = servers[SERVER_KEY]
279
+ found = server["command"]
280
+ args = server.get("args", [])
281
+ env = server.get("env", {})
282
+ if found != command:
283
+ return "not-ours", None
284
+ # The provenance label is the reason this entry exists. An entry that boots but
285
+ # introduces the session as some other harness is not our entry.
286
+ if env.get(ENV_KEY) != EXTERNAL_AGENT_ID:
287
+ return "foreign-provenance", None
288
+ return "configured", {"command": found, "args": args, "env": env}
289
+
290
+
291
+ def doctor_static(config_path: str, command: str) -> None:
292
+ status, invocation = _doctor_invocation(config_path, command)
293
+ if status != "configured":
294
+ sys.stdout.write(f"{status}\n")
295
+ return
296
+ sys.stdout.write(f"configured {invocation['command']}\n")
297
+
298
+
299
+ def doctor_invocation(config_path: str, command: str) -> None:
300
+ status, invocation = _doctor_invocation(config_path, command)
301
+ if status != "configured":
302
+ die(4, f"omp-mcp: cannot read configured invocation from {config_path}: {status}")
303
+ sys.stdout.write(json.dumps(invocation, separators=(",", ":")) + "\n")
304
+
305
+
306
+ def doctor_shadow(config_path: str, *import_paths: str) -> None:
307
+ """Is the EFFECTIVE `entwurf-bridge` this host's omp will load the native entry?
308
+
309
+ This is a CONFIGURATION read, never a runtime receipt: it reports what omp's own
310
+ precedence rules decide about the files on disk. The vendor's own `/mcp list` pane is
311
+ the live oracle, and it is taken once as a LIVE receipt rather than re-derived here.
312
+
313
+ Prints one line per fact, then a verdict line:
314
+ native <present|invalid|absent>
315
+ import <path> <key-present|key-absent|unreadable>
316
+ disabled <yes|no>
317
+ verdict <native-wins|native-invalid|import-wins|both-suppressed|no-entry>
318
+
319
+ RUNTIME VALIDITY AND OWNERSHIP ARE SEPARATE AXES (Hard Rule 13, #87 B4). "The key is
320
+ present" is not "the entry works": a null / non-object / malformed value under our key
321
+ still claims the dedupe slot, so the import is suppressed AND nothing loads. Reporting
322
+ that as `native-wins` turned a broken effective source into a PASS whenever no
323
+ ownership state happened to exist. An invalid entry gets its own verdict, and it is red
324
+ on the runtime axis whether or not entwurf owns anything here.
325
+ """
326
+ data = {}
327
+ if os.path.exists(config_path) and not os.path.islink(config_path):
328
+ try:
329
+ data = load_object(config_path, "config")
330
+ except SystemExit:
331
+ data = {}
332
+ servers = data.get("mcpServers") if isinstance(data.get("mcpServers"), dict) else {}
333
+ native_state = native_entry_state(servers)
334
+ sys.stdout.write(f"native {native_state}\n")
335
+
336
+ import_hit = False
337
+ for path in import_paths:
338
+ expanded = os.path.expanduser(path)
339
+ if not os.path.exists(expanded):
340
+ continue
341
+ try:
342
+ imported = load_object(expanded, "imported config")
343
+ except SystemExit:
344
+ sys.stdout.write(f"import {expanded} unreadable\n")
345
+ continue
346
+ keys = imported.get("mcpServers")
347
+ present = isinstance(keys, dict) and SERVER_KEY in keys
348
+ import_hit = import_hit or present
349
+ sys.stdout.write(f"import {expanded} {'key-present' if present else 'key-absent'}\n")
350
+
351
+ disabled = SERVER_KEY in disabled_names(data)
352
+ sys.stdout.write(f"disabled {'yes' if disabled else 'no'}\n")
353
+
354
+ if disabled:
355
+ verdict = "both-suppressed"
356
+ elif native_state == "invalid":
357
+ # It still claims the dedupe key, so the import is suppressed too — but nothing
358
+ # loads. Never `native-wins`.
359
+ verdict = "native-invalid"
360
+ elif native_state == "present":
361
+ verdict = "native-wins"
362
+ elif import_hit:
363
+ verdict = "import-wins"
364
+ else:
365
+ verdict = "no-entry"
366
+ sys.stdout.write(f"verdict {verdict}\n")
367
+
368
+
369
+ def main(argv: list[str]) -> None:
370
+ if len(argv) < 2:
371
+ die(5, "usage: omp-mcp-config.py <install|uninstall|doctor-static|doctor-invocation|doctor-shadow> ...")
372
+ match argv[1]:
373
+ case "install" if len(argv) == 6:
374
+ install(argv[2], argv[3], argv[4], argv[5])
375
+ case "uninstall" if len(argv) == 3:
376
+ uninstall(argv[2])
377
+ case "doctor-static" if len(argv) == 4:
378
+ doctor_static(argv[2], argv[3])
379
+ case "doctor-invocation" if len(argv) == 4:
380
+ doctor_invocation(argv[2], argv[3])
381
+ case "doctor-shadow" if len(argv) >= 3:
382
+ doctor_shadow(argv[2], *argv[3:])
383
+ case _:
384
+ die(
385
+ 5,
386
+ "usage: omp-mcp-config.py <install config command args-json state|uninstall state|"
387
+ "doctor-static config command|doctor-invocation config command|doctor-shadow config [import...]>",
388
+ )
389
+
390
+
391
+ if __name__ == "__main__":
392
+ main(sys.argv)
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env bash
2
+ # omp-receive-doctor.sh — the fail-loud surface for the OMP RECEIVER extension
3
+ # (#87 bundle B).
4
+ #
5
+ # TWO AXES, REPORTED SEPARATELY, EITHER RED IS RED (AGENTS.md Hard Rule 13):
6
+ # runtime — is there a receiver unit omp will import, does it carry the CURRENT
7
+ # writer, what did it do the last time it ran, and is a doorbell actually
8
+ # held right now?
9
+ # ownership — does entwurf own that unit, by a state whose shape and bindings hold?
10
+ #
11
+ # WHAT "ARMED" IS ALLOWED TO MEAN HERE, AND WHAT IT IS NOT. A receiver marker records
12
+ # that a LIVE owner reached the watch-arm emit. It is NOT proof that the vendor's file
13
+ # watch is still registered — the Claude unit says the same about its own marker in the
14
+ # same words (`meta-bridge-hook.ts:279-280`), and on this rail the owner is the operator's
15
+ # TUI process itself, so a wedged event loop looks identical to a healthy one from out
16
+ # here. This doctor therefore reports what it can prove (a marker, its owner's liveness,
17
+ # the mailbox behind it) and never upgrades that into "a wake will happen".
18
+ #
19
+ # THE MARKER READ GOES THROUGH THE PRODUCTION READER, never a filename or a grep:
20
+ # `./run.sh omp-receive-facts` projects `readMetaReceiverMarker` for both readings — the
21
+ # live one dispatch gets, and the file as written — so "nothing armed" stays
22
+ # distinguishable from "armed, then the session died".
23
+ #
24
+ # ZERO ARMED RECEIVERS IS NOT-YET, NEVER RED. A host with the unit installed and no omp
25
+ # TUI open has nothing to arm; that is the designed resting state.
26
+ set -uo pipefail
27
+
28
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
29
+ REPO="$(cd "$HERE/.." && pwd)"
30
+ UNIT="entwurf-receive-omp"
31
+ SRC="$REPO/pi/omp-receive"
32
+ ASM="${ENTWURF_OMP_RECEIVE_ASM:-${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/omp-receive/.assembled}"
33
+ STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/omp-receive"
34
+ STATE_FILE="$STATE_DIR/install-state.json"
35
+ BIRTH_STATE="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/omp-bridge/install-state.json"
36
+
37
+ fail=0
38
+ own_fail=0
39
+ ok() { echo " ok $*"; }
40
+ note() { echo " note $*"; }
41
+ bad() { echo " FAIL $*"; fail=1; }
42
+ own_bad() { echo " FAIL $*"; own_fail=1; }
43
+
44
+ # shellcheck source=scripts/omp-bridge-oracle.sh
45
+ . "$HERE/omp-bridge-oracle.sh"
46
+
47
+ echo "[omp-receive-doctor] unit: $UNIT"
48
+
49
+ # ── root-grammar preflight, BEFORE anything is read ──────────────────────────
50
+ # Same rule and same reason as the birth doctor (#87 A2): the four overrides are absolute
51
+ # or `~`-rooted only. A relative value resolves against each process's own cwd, and this
52
+ # doctor does not share one with the extension, so asking anything first would answer
53
+ # about a different directory. A refused environment is RUNTIME RED, never laundered into
54
+ # a NOT-YET about some other store.
55
+ ROOT_POLICY_REFUSAL=""
56
+ for var in ENTWURF_META_SESSIONS_DIR ENTWURF_META_MAILBOX_DIR ENTWURF_META_SENDERS_DIR ENTWURF_META_RECEIVERS_DIR; do
57
+ val="${!var:-}"
58
+ [ -n "$val" ] || continue
59
+ case "$val" in
60
+ /*|"~"|"~/"*) ;;
61
+ *) ROOT_POLICY_REFUSAL="${ROOT_POLICY_REFUSAL:+$ROOT_POLICY_REFUSAL, }$var=$val" ;;
62
+ esac
63
+ done
64
+
65
+ echo
66
+ echo "[omp-receive-doctor] runtime axis"
67
+
68
+ AGENT_DIR=""
69
+ UNIT_DIR=""
70
+ if AGENT_DIR="$(omp_agent_dir 2>/dev/null)"; then
71
+ UNIT_DIR="$AGENT_DIR/extensions/$UNIT"
72
+ ok "omp agent directory: $AGENT_DIR"
73
+ else
74
+ bad "the omp agent directory this host reads is AMBIGUOUS (inherited PI_CODING_AGENT_DIR / PI_CONFIG_DIR / PI_PROFILE) — no honest statement about the installed unit can be made; pass ENTWURF_OMP_AGENT_DIR if you mean a non-default one"
75
+ fi
76
+
77
+ # ── the artifact omp would import ────────────────────────────────────────────
78
+ INSTALLED_ENTRY=""
79
+ if [ -n "$UNIT_DIR" ]; then
80
+ if [ -L "$UNIT_DIR" ]; then
81
+ bad "$UNIT_DIR is a SYMLINK — this installer never creates one, so what omp would import is not ours"
82
+ elif [ -d "$UNIT_DIR" ]; then
83
+ if omp_assembly_valid "$(dirname "$UNIT_DIR")" "$UNIT" 2>/dev/null; then
84
+ [ -f "$UNIT_DIR/index.ts" ] && INSTALLED_ENTRY="index.ts"
85
+ [ -z "$INSTALLED_ENTRY" ] && [ -f "$UNIT_DIR/index.js" ] && INSTALLED_ENTRY="index.js"
86
+ ok "installed unit is structurally complete: $UNIT_DIR (entry $INSTALLED_ENTRY)"
87
+ else
88
+ bad "installed unit at $UNIT_DIR fails the structural oracle — omp may import a half-unit"
89
+ fi
90
+ else
91
+ note "NOT-INSTALLED: no receiver unit at $UNIT_DIR. omp citizens on this host are outbound-only; dispatch to them answers mailbox-undeliverable. Install with ./run.sh install-omp-receive"
92
+ fi
93
+ fi
94
+
95
+ # ── writer parity: source vs assembled vs installed ──────────────────────────
96
+ # The same stale-writer axis the birth and Claude doctors carry. A deployed unit that
97
+ # predates the source is the failure that looks like success: everything is green and the
98
+ # behaviour is last week's.
99
+ digest() { [ -f "$1" ] && python3 -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest()[:12])" "$1" || echo "-"; }
100
+ SRC_ENTRY="$REPO/pi-extensions/meta-bridge-receive-omp.ts"
101
+ case "$REPO" in
102
+ */node_modules/@junghanacs/entwurf) SRC_ENTRY="$REPO/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js" ;;
103
+ esac
104
+ # TWO artifacts, not one. The ENTRY is this unit's own logic; `lib/meta-session` is the
105
+ # SHARED writer every native unit carries a copy of, and it is the one that goes stale
106
+ # silently — a backend id, a marker field or a root-policy fix lands in the source while
107
+ # three deployed bundles keep last week's copy. Checking only the entry would call such a
108
+ # host green (measured: it did, in this very smoke).
109
+ if [ -n "$INSTALLED_ENTRY" ]; then
110
+ LIB_EXT="ts"; [ "$INSTALLED_ENTRY" = "index.js" ] && LIB_EXT="js"
111
+ SRC_LIB="$REPO/pi-extensions/lib/meta-session.ts"
112
+ [ "$LIB_EXT" = "js" ] && SRC_LIB="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
113
+ for pair in "entry:$SRC_ENTRY:$UNIT_DIR/$INSTALLED_ENTRY" "writer:$SRC_LIB:$UNIT_DIR/lib/meta-session.$LIB_EXT"; do
114
+ what="${pair%%:*}"; rest="${pair#*:}"; src="${rest%%:*}"; ins="${rest#*:}"
115
+ SRC_D="$(digest "$src")"; INS_D="$(digest "$ins")"
116
+ if [ "$SRC_D" = "$INS_D" ]; then
117
+ ok "$what parity: source == installed ($SRC_D)"
118
+ else
119
+ bad "STALE $what: source=$SRC_D installed=$INS_D ($ins) — the deployed receiver is not this checkout's. Re-run ./run.sh install-omp-receive (and 'pnpm run build-bridge' first in a dev clone if you changed the source)"
120
+ fi
121
+ done
122
+ fi
123
+
124
+ # ── what the unit DID, off its own log ───────────────────────────────────────
125
+ # Arm failures and doorbell failures are judged on SEPARATE axes: a citizen can be armed
126
+ # and still fail to ring, and folding the two would print "nothing is armed" about a
127
+ # session whose marker is right there.
128
+ RECEIVE_LOG=""
129
+ if [ -n "$ROOT_POLICY_REFUSAL" ]; then
130
+ bad "the omp meta-root policy REFUSES this environment: $ROOT_POLICY_REFUSAL — a garden root must be absolute or ~-rooted. The extension receives the same refusal, so no store can be named here"
131
+ else
132
+ RECEIVE_LOG="${ENTWURF_META_SESSIONS_DIR:+$(dirname "$ENTWURF_META_SESSIONS_DIR")}"
133
+ [ -n "$RECEIVE_LOG" ] || RECEIVE_LOG="$HOME/.pi/agent"
134
+ RECEIVE_LOG="$RECEIVE_LOG/meta-bridge-receive-omp.log"
135
+ fi
136
+ if [ -n "$RECEIVE_LOG" ] && [ -f "$RECEIVE_LOG" ]; then
137
+ # `grep -c` PRINTS 0 and EXITS 1 on no-match, so the idiomatic `|| echo 0` appends a
138
+ # SECOND zero and every later `[ "$N" -gt 0 ]` dies with "integer expected" — caught
139
+ # here on a real host, where a stale-marker read went through a doctor that had already
140
+ # printed two shell errors. Count without the fallback and normalise once.
141
+ count_in_log() { grep -c -- "$1" "$RECEIVE_LOG" 2>/dev/null | head -1 | tr -cd '0-9'; }
142
+ ARMED_N="$(count_in_log " armed garden=")"; ARMED_N="${ARMED_N:-0}"
143
+ RANG_N="$(count_in_log " rang garden=")"; RANG_N="${RANG_N:-0}"
144
+ GAVEUP_N="$(count_in_log "arm-gave-up")"; GAVEUP_N="${GAVEUP_N:-0}"
145
+ ARMFAIL_N="$(grep -cE "arm-failed|arm-refused" "$RECEIVE_LOG" 2>/dev/null | head -1 | tr -cd '0-9')"; ARMFAIL_N="${ARMFAIL_N:-0}"
146
+ RINGFAIL_N="$(grep -cE "doorbell-failed|watch-error|signal-vanished" "$RECEIVE_LOG" 2>/dev/null | head -1 | tr -cd '0-9')"; RINGFAIL_N="${RINGFAIL_N:-0}"
147
+ ok "receive log: $RECEIVE_LOG (armed=$ARMED_N rang=$RANG_N)"
148
+ [ "$GAVEUP_N" -gt 0 ] && bad "$GAVEUP_N session(s) GAVE UP arming — birth never wrote a sender marker in that process. Those citizens were never addressable; check ./run.sh doctor-omp-bridge"
149
+ [ "$ARMFAIL_N" -gt 0 ] && bad "$ARMFAIL_N arm failure/refusal line(s) — a citizen exists but no doorbell was published for it:" && grep "arm-failed\|arm-refused" "$RECEIVE_LOG" | tail -3 | sed 's/^/ /'
150
+ [ "$RINGFAIL_N" -gt 0 ] && bad "$RINGFAIL_N doorbell/watch failure line(s) — the wake half broke on a live citizen:" && grep "doorbell-failed\|watch-error\|signal-vanished" "$RECEIVE_LOG" | tail -3 | sed 's/^/ /'
151
+ elif [ -n "$RECEIVE_LOG" ]; then
152
+ note "NOT-YET: no receive log at $RECEIVE_LOG — this unit has not run. Open an omp TUI and re-run"
153
+ fi
154
+
155
+ # ── who is armed RIGHT NOW, through the production reader ────────────────────
156
+ if [ -z "$ROOT_POLICY_REFUSAL" ]; then
157
+ FACTS_RC=0
158
+ FACTS="$(env -u PI_CODING_AGENT_DIR "$REPO/run.sh" omp-receive-facts 2>&1)" || FACTS_RC=$?
159
+ if [ "$FACTS_RC" -ne 0 ]; then
160
+ bad "omp-receive-facts could not read the receiver markers (rc=$FACTS_RC):"
161
+ printf '%s\n' "$FACTS" | tail -3 | sed 's/^/ /'
162
+ else
163
+ SUMMARY="$(printf '%s' "$FACTS" | python3 -c '
164
+ import json, sys
165
+ d = json.load(sys.stdin)
166
+ rs = d.get("receivers") or []
167
+ live = [r for r in rs if r.get("ownerLive")]
168
+ dead = [r for r in rs if not r.get("ownerLive")]
169
+ print(len(rs), len(live), len(dead), len(d.get("unreadableMarkers") or []))
170
+ for r in live:
171
+ print("LIVE", r["gardenId"], r["ownerPid"], r["ownerKind"], r["unreadDelivered"], r["freshUnannounced"])
172
+ for r in dead:
173
+ print("DEAD", r["gardenId"], r["ownerPid"], r["ownerKind"], r["unreadDelivered"], r["freshUnannounced"])
174
+ ' 2>/dev/null)" || SUMMARY=""
175
+ if [ -z "$SUMMARY" ]; then
176
+ bad "omp-receive-facts returned output this doctor could not parse — refusing to guess"
177
+ else
178
+ COUNTS="$(printf '%s' "$SUMMARY" | head -1)"
179
+ TOTAL="$(echo "$COUNTS" | cut -d' ' -f1)"; LIVE="$(echo "$COUNTS" | cut -d' ' -f2)"
180
+ DEAD="$(echo "$COUNTS" | cut -d' ' -f3)"; BADM="$(echo "$COUNTS" | cut -d' ' -f4)"
181
+ if [ "$TOTAL" -eq 0 ]; then
182
+ note "NOT-YET: no omp receiver markers. Nothing is armed, so dispatch to an omp citizen is the honest mailbox-undeliverable refusal. Open an omp TUI with this unit installed and re-run"
183
+ else
184
+ # A stale marker is not a fault: the reader already refuses it and dispatch is
185
+ # fail-closed. It is reported so an operator can tell "nothing armed" from
186
+ # "something armed and then the session died" — two states one count would hide.
187
+ if [ "$LIVE" -gt 0 ]; then
188
+ ok "$LIVE live / $DEAD stale omp receiver marker(s)"
189
+ else
190
+ note "0 live / $DEAD stale omp receiver marker(s) — nothing is armed right now, so dispatch to an omp citizen is the honest mailbox-undeliverable refusal"
191
+ fi
192
+ printf '%s\n' "$SUMMARY" | tail -n +2 | while read -r state gid pid kind unread fresh; do
193
+ if [ "$state" = "LIVE" ]; then
194
+ echo " armed $gid owner=$pid ($kind) unread=$unread not-yet-announced=$fresh"
195
+ [ "${fresh:-0}" -gt 0 ] && echo " note $fresh body/ies are enqueued but no doorbell has announced them — the watch may not be firing"
196
+ else
197
+ echo " stale $gid owner=$pid ($kind) — owner is gone; the reader already refuses this, dispatch is fail-closed"
198
+ fi
199
+ done
200
+ fi
201
+ [ "${BADM:-0}" -gt 0 ] && bad "$BADM receiver marker file(s) the production reader could not parse — inspect them by hand"
202
+ fi
203
+ fi
204
+ fi
205
+
206
+ # ── the two units this one is useless without ────────────────────────────────
207
+ # Not a duplicate of their own doctors — a NOTE, on the one axis this doctor owns:
208
+ # a doorbell with no birth has no citizen to join, and a doorbell with no tool hand rings
209
+ # for a model that has no entwurf_inbox_read to drain with.
210
+ if [ -f "$BIRTH_STATE" ]; then
211
+ ok "birth unit is installed (ownership state present) — this receiver has a citizen to join"
212
+ else
213
+ note "the BIRTH unit is not installed here (no $BIRTH_STATE). Without it nothing mints a citizen, so this receiver will log arm-deferred and then give up. Install with ./run.sh install-omp-bridge"
214
+ fi
215
+ if [ -n "$AGENT_DIR" ] && [ -f "$AGENT_DIR/mcp.json" ] && grep -q "entwurf-bridge" "$AGENT_DIR/mcp.json" 2>/dev/null; then
216
+ ok "an entwurf-bridge MCP entry exists in $AGENT_DIR/mcp.json — the model has a way to reach entwurf_inbox_read (doctor-omp-mcp owns that axis)"
217
+ else
218
+ note "no entwurf-bridge entry found in the omp MCP config. The doorbell announces a tool the model would not have; install with ./run.sh install-omp-mcp and verify with ./run.sh doctor-omp-mcp"
219
+ fi
220
+
221
+ # ── ownership axis ───────────────────────────────────────────────────────────
222
+ echo
223
+ echo "[omp-receive-doctor] ownership axis"
224
+ if [ -L "$STATE_FILE" ]; then
225
+ own_bad "ownership state $STATE_FILE is a SYMLINK — refusing to trust it"
226
+ elif [ -f "$STATE_FILE" ]; then
227
+ if [ -n "$UNIT_DIR" ] && omp_state_read "$STATE_FILE" "$UNIT_DIR" "$ASM" >/dev/null 2>&1; then
228
+ ok "ownership state binds this installation: $STATE_FILE"
229
+ if [ -n "$UNIT_DIR" ] && [ ! -e "$UNIT_DIR" ]; then
230
+ own_bad "ownership state claims $UNIT_DIR but nothing is there — the inverse (./run.sh uninstall-omp-receive) will refuse until this is resolved"
231
+ fi
232
+ else
233
+ own_bad "ownership state $STATE_FILE is corrupt or names a DIFFERENT installation than $UNIT_DIR"
234
+ fi
235
+ elif [ -n "$UNIT_DIR" ] && [ -e "$UNIT_DIR" ]; then
236
+ own_bad "$UNIT_DIR exists with NO ownership state. A directory that looks like our unit is not proof it is ours — the installer refuses to adopt it and the inverse refuses to remove it. Resolve by hand"
237
+ else
238
+ note "no ownership state and no unit — this host has no omp receiver installed (a clean absence, not a fault)"
239
+ fi
240
+
241
+ echo
242
+ if [ "$fail" -eq 0 ]; then echo "[omp-receive-doctor] runtime axis: PASS"; else echo "[omp-receive-doctor] runtime axis: FAIL"; fi
243
+ if [ "$own_fail" -eq 0 ]; then echo "[omp-receive-doctor] ownership axis: PASS"; else echo "[omp-receive-doctor] ownership axis: FAIL"; fi
244
+ if [ "$fail" -eq 0 ] && [ "$own_fail" -eq 0 ]; then echo "[omp-receive-doctor] PASS"; exit 0; fi
245
+ echo "[omp-receive-doctor] FAIL"
246
+ exit 1