@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,349 @@
1
+ #!/usr/bin/env python3
2
+ """Read omp's resolved agent-dir config.yml for the MCP tool-surface.
3
+
4
+ stdlib only. This is a RUNTIME read of ``tools.xdev`` / ``tools.xdevInlineDevices``
5
+ (Hard Rule 13 — never an ownership claim). Absent file or absent key means the
6
+ vendor default applies: ``tools.xdev: true`` and an empty inline allowlist
7
+ (oh-my-pi ``settings-schema.ts`` ``tools.xdev`` default true,
8
+ ``tools.xdevInlineDevices`` default ``[]``, measured omp 18.0.0).
9
+
10
+ The doctor, not this leaf, decides RED vs note. This leaf reports the effective
11
+ state and whether any inline glob covers our dialect send-tool name.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import fnmatch
17
+ import os
18
+ import sys
19
+
20
+ # Vendor load order: config.yml then config.yaml (utils/src/dirs.ts MAIN_CONFIG_FILENAMES).
21
+ MAIN_CONFIG_FILENAMES = ("config.yml", "config.yaml")
22
+
23
+ # omp mints mcp__<server>_<tool> after lowercasing and replacing [^a-z_]+ with _.
24
+ # The send tool `entwurf_v2` therefore surfaces as mcp__entwurf_bridge_entwurf_v.
25
+ # A glob "covers our server key" when it matches that dialect name.
26
+ COVER_NAME = "mcp__entwurf_bridge_entwurf_v"
27
+
28
+ TRUE_WORDS = {"true", "yes", "on", "y"}
29
+ FALSE_WORDS = {"false", "no", "off", "n"}
30
+
31
+
32
+ def die(code: int, message: str) -> None:
33
+ sys.stderr.write(message.rstrip("\n") + "\n")
34
+ raise SystemExit(code)
35
+
36
+
37
+ def strip_comment(raw: str) -> str:
38
+ in_single = False
39
+ in_double = False
40
+ escaped = False
41
+ for index, char in enumerate(raw):
42
+ if escaped:
43
+ escaped = False
44
+ continue
45
+ if char == "\\" and in_double:
46
+ escaped = True
47
+ continue
48
+ if char == "'" and not in_double:
49
+ in_single = not in_single
50
+ continue
51
+ if char == '"' and not in_single:
52
+ in_double = not in_double
53
+ continue
54
+ if char == "#" and not in_single and not in_double:
55
+ return raw[:index].rstrip()
56
+ return raw.rstrip()
57
+
58
+
59
+ def parse_flow_seq(text: str) -> list[object] | None:
60
+ body = text.strip()
61
+ if not (body.startswith("[") and body.endswith("]")):
62
+ return None
63
+ inner = body[1:-1].strip()
64
+ if inner == "":
65
+ return []
66
+ items: list[object] = []
67
+ buf: list[str] = []
68
+ in_single = False
69
+ in_double = False
70
+ depth = 0
71
+ for char in inner:
72
+ if char == "'" and not in_double:
73
+ in_single = not in_single
74
+ buf.append(char)
75
+ continue
76
+ if char == '"' and not in_single:
77
+ in_double = not in_double
78
+ buf.append(char)
79
+ continue
80
+ if not in_single and not in_double:
81
+ if char in "[{":
82
+ depth += 1
83
+ elif char in "]}":
84
+ depth -= 1
85
+ elif char == "," and depth == 0:
86
+ items.append(parse_scalar("".join(buf).strip()))
87
+ buf = []
88
+ continue
89
+ buf.append(char)
90
+ if in_single or in_double or depth != 0:
91
+ return None
92
+ items.append(parse_scalar("".join(buf).strip()))
93
+ return items
94
+
95
+
96
+ def parse_scalar(raw: str) -> object:
97
+ text = raw.strip()
98
+ if text == "" or text in ("~", "null", "Null", "NULL"):
99
+ return None
100
+ if (len(text) >= 2) and ((text[0] == text[-1] == '"') or (text[0] == text[-1] == "'")):
101
+ return text[1:-1]
102
+ if text.startswith("[") and text.endswith("]"):
103
+ parsed = parse_flow_seq(text)
104
+ return parsed if parsed is not None else text
105
+ folded = text.casefold()
106
+ if folded in TRUE_WORDS:
107
+ return True
108
+ if folded in FALSE_WORDS:
109
+ return False
110
+ return text
111
+
112
+
113
+ def split_key_value(stripped: str) -> tuple[str, str] | None:
114
+ in_single = False
115
+ in_double = False
116
+ for index, char in enumerate(stripped):
117
+ if char == "'" and not in_double:
118
+ in_single = not in_single
119
+ continue
120
+ if char == '"' and not in_single:
121
+ in_double = not in_double
122
+ continue
123
+ if char == ":" and not in_single and not in_double:
124
+ key = stripped[:index].strip()
125
+ if not key:
126
+ return None
127
+ if (len(key) >= 2) and ((key[0] == key[-1] == '"') or (key[0] == key[-1] == "'")):
128
+ key = key[1:-1]
129
+ return key, stripped[index + 1 :].strip()
130
+ return None
131
+
132
+
133
+ def parse_block(lines: list[tuple[int, str]], start: int, indent: int) -> tuple[object, int]:
134
+ """Parse a mapping or list whose items are indented strictly deeper than ``indent``."""
135
+ if start >= len(lines):
136
+ return {}, start
137
+ first_indent, first_text = lines[start]
138
+ if first_indent <= indent:
139
+ return {}, start
140
+ if first_text.startswith("- "):
141
+ items: list[object] = []
142
+ index = start
143
+ while index < len(lines):
144
+ item_indent, item_text = lines[index]
145
+ if item_indent < first_indent:
146
+ break
147
+ if item_indent > first_indent:
148
+ return None, start
149
+ if not item_text.startswith("- "):
150
+ return None, start
151
+ rest = item_text[2:].strip()
152
+ index += 1
153
+ if rest == "" or split_key_value(rest) is not None:
154
+ nested, index = parse_block(lines, index, item_indent)
155
+ if nested is None:
156
+ return None, start
157
+ if rest == "":
158
+ items.append(nested)
159
+ else:
160
+ pair = split_key_value(rest)
161
+ if pair is None:
162
+ return None, start
163
+ key, raw = pair
164
+ node: dict[str, object] = dict(nested) if isinstance(nested, dict) else {}
165
+ if raw == "":
166
+ child, index = parse_block(lines, index, item_indent)
167
+ if child is None:
168
+ return None, start
169
+ node[key] = child
170
+ else:
171
+ node[key] = parse_scalar(raw)
172
+ items.append(node)
173
+ else:
174
+ items.append(parse_scalar(rest))
175
+ return items, index
176
+
177
+ mapping: dict[str, object] = {}
178
+ index = start
179
+ while index < len(lines):
180
+ item_indent, item_text = lines[index]
181
+ if item_indent < first_indent:
182
+ break
183
+ if item_indent > first_indent:
184
+ return None, start
185
+ pair = split_key_value(item_text)
186
+ if pair is None:
187
+ return None, start
188
+ key, raw = pair
189
+ index += 1
190
+ if raw == "":
191
+ child, index = parse_block(lines, index, item_indent)
192
+ if child is None:
193
+ return None, start
194
+ mapping[key] = child
195
+ else:
196
+ mapping[key] = parse_scalar(raw)
197
+ return mapping, index
198
+
199
+
200
+ def load_top_mapping(text: str) -> dict[str, object] | None:
201
+ lines: list[tuple[int, str]] = []
202
+ for raw in text.splitlines():
203
+ if "\t" in raw.split("#", 1)[0]:
204
+ return None
205
+ stripped_nl = raw.rstrip("\n")
206
+ if stripped_nl.strip() == "":
207
+ continue
208
+ body = strip_comment(stripped_nl)
209
+ if body.strip() == "":
210
+ continue
211
+ if body.strip() in ("---", "...") or body.strip().startswith("%"):
212
+ continue
213
+ indent = len(body) - len(body.lstrip(" "))
214
+ lines.append((indent, body.lstrip(" ")))
215
+ if not lines:
216
+ return {}
217
+ mapping, index = parse_block(lines, 0, -1)
218
+ if mapping is None or index != len(lines) or not isinstance(mapping, dict):
219
+ return None
220
+ return mapping
221
+
222
+
223
+ def covers(globs: list[str]) -> bool:
224
+ for pattern in globs:
225
+ if not pattern:
226
+ continue
227
+ if fnmatch.fnmatchcase(COVER_NAME, pattern):
228
+ return True
229
+ return False
230
+
231
+
232
+ def emit(fields: list[tuple[str, str]]) -> None:
233
+ for key, value in fields:
234
+ sys.stdout.write(f"{key} {value}\n")
235
+
236
+
237
+ def report_unreadable(path: str) -> None:
238
+ emit([("file", path), ("verdict", "unreadable")])
239
+
240
+
241
+ def classify(agent_dir: str) -> None:
242
+ chosen = None
243
+ for name in MAIN_CONFIG_FILENAMES:
244
+ candidate = os.path.join(agent_dir, name)
245
+ if os.path.lexists(candidate):
246
+ chosen = candidate
247
+ break
248
+ if chosen is None:
249
+ emit(
250
+ [
251
+ ("file", "absent"),
252
+ ("xdev-key", "absent"),
253
+ ("xdev", "default-true"),
254
+ ("inline-key", "absent"),
255
+ ("covers", "no"),
256
+ ("verdict", "xdev-on-uncovered"),
257
+ ]
258
+ )
259
+ return
260
+
261
+ path = os.path.abspath(chosen)
262
+ if os.path.isdir(path):
263
+ report_unreadable(path)
264
+ return
265
+ try:
266
+ with open(path, "r", encoding="utf-8") as handle:
267
+ text = handle.read()
268
+ except OSError:
269
+ report_unreadable(path)
270
+ return
271
+
272
+ mapping = load_top_mapping(text)
273
+ if mapping is None:
274
+ report_unreadable(path)
275
+ return
276
+
277
+ tools = mapping.get("tools", None)
278
+ if tools is None:
279
+ emit(
280
+ [
281
+ ("file", path),
282
+ ("xdev-key", "absent"),
283
+ ("xdev", "default-true"),
284
+ ("inline-key", "absent"),
285
+ ("covers", "no"),
286
+ ("verdict", "xdev-on-uncovered"),
287
+ ]
288
+ )
289
+ return
290
+ if not isinstance(tools, dict):
291
+ report_unreadable(path)
292
+ return
293
+
294
+ xdev_present = "xdev" in tools
295
+ xdev_value = tools.get("xdev") if xdev_present else None
296
+ inline_present = "xdevInlineDevices" in tools
297
+ inline_value = tools.get("xdevInlineDevices") if inline_present else None
298
+
299
+ globs: list[str] = []
300
+ if inline_present:
301
+ # Vendor compileInlineGlobs drops a non-array (a scalar is an empty allowlist).
302
+ if isinstance(inline_value, list):
303
+ globs = [item for item in inline_value if isinstance(item, str)]
304
+ elif inline_value is None:
305
+ globs = []
306
+ else:
307
+ globs = []
308
+
309
+ covered = covers(globs)
310
+
311
+ if not xdev_present or xdev_value is None:
312
+ verdict = "xdev-on-uncovered"
313
+ xdev_field = "default-true"
314
+ xdev_key = "absent"
315
+ elif xdev_value is False:
316
+ verdict = "xdev-off"
317
+ xdev_field = "false"
318
+ xdev_key = "present"
319
+ elif xdev_value is True:
320
+ xdev_field = "true"
321
+ xdev_key = "present"
322
+ verdict = "xdev-on-covered" if covered else "xdev-on-uncovered"
323
+ else:
324
+ report_unreadable(path)
325
+ return
326
+
327
+ emit(
328
+ [
329
+ ("file", path),
330
+ ("xdev-key", xdev_key),
331
+ ("xdev", xdev_field),
332
+ ("inline-key", "present" if inline_present else "absent"),
333
+ ("covers", "yes" if covered else "no"),
334
+ ("verdict", verdict),
335
+ ]
336
+ )
337
+
338
+
339
+ def main(argv: list[str]) -> None:
340
+ if len(argv) != 2:
341
+ die(5, "usage: omp-tool-surface.py <absolute-agent-dir>")
342
+ agent_dir = argv[1]
343
+ if not os.path.isabs(agent_dir):
344
+ die(5, "omp-tool-surface: agent dir must be absolute")
345
+ classify(agent_dir)
346
+
347
+
348
+ if __name__ == "__main__":
349
+ main(sys.argv)
@@ -63,3 +63,25 @@ def unchanged(before: dict, after: dict) -> bool:
63
63
  writers mutate in place, so they parse the raw text twice rather than aliasing it.
64
64
  """
65
65
  return before == after
66
+
67
+
68
+ def classify_installer_root(state: dict) -> "tuple[str, str | None]":
69
+ """ONE typed verdict for a user-scope install-state's `installerRoot` (#86 C2).
70
+
71
+ Shared by register-pi-provider install/remove and the doctor-pi-package ownership
72
+ coupling, because two per-site checks (`isinstance(str)` in one arm, `is None` in
73
+ another) let every OTHER type — a number, an empty string, a bool, an object, an
74
+ array — fall between them and proceed unattributed. A state MORE unattributed than
75
+ legacy must never be treated MORE leniently than legacy.
76
+
77
+ ("legacy", None) key absent, or an explicit null — the pre-#86 shape; each
78
+ caller names its own adoption/refusal contract for it.
79
+ ("owner", root) a non-empty string — the recorded owner root.
80
+ ("corrupt", None) everything else — fail-closed before any write.
81
+ """
82
+ if "installerRoot" not in state or state["installerRoot"] is None:
83
+ return "legacy", None
84
+ root = state["installerRoot"]
85
+ if isinstance(root, str) and root:
86
+ return "owner", root
87
+ return "corrupt", None