@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
@@ -28,11 +28,40 @@ Scope asymmetry (봉인계약 4·6, REASONED — not the unfounded asymmetry dev
28
28
  our-managed shapes (the bare bin AND the legacy repo path). project-scope state is a
29
29
  NAMED FOLLOW-UP (NEXT), deliberately out of this lane.
30
30
 
31
+ USER-SCOPE OWNER BINDING (#86 C2): the user install-state additionally records
32
+ `installerRoot` (the repo/package root that installed the key), so an old root's
33
+ inverse can no longer delete the CURRENT stable provider key. Schema stays
34
+ version 1 — installerRoot is an optional field, judged by ONE typed classifier
35
+ (pi_settings_io.classify_installer_root, shared with the doctor-pi-package
36
+ coupling): absent/null is LEGACY, a non-empty string is the owner, and every
37
+ OTHER type (empty string, number, bool, object, array) is CORRUPT — install and
38
+ remove both refuse it (exit 4) before any write, because a state MORE
39
+ unattributed than legacy must never be treated MORE leniently than legacy:
40
+ - install, legacy state → safe ADOPTION: proceed and rewrite the state
41
+ with installerRoot = this root (named on stdout);
42
+ - install, installerRoot ≠ root → REFUSE (exit 6), zero settings write, unless
43
+ the operator-explicit --takeover replaces it
44
+ (old root reported);
45
+ - remove, installerRoot == root → today's honest inverse;
46
+ - remove, installerRoot ≠ root, owner LIVE → REFUSE (exit 6);
47
+ - remove, installerRoot ≠ root, owner MISSING → REFUSE unless --orphan-cleanup,
48
+ which only run.sh's aligned remove-user-scope path passes (package entry +
49
+ package state + provider installerRoot all naming that same missing root);
50
+ - remove, legacy state (no installerRoot) → REFUSE (fail-closed): an
51
+ unattributed key accepts no inverse; a same-root install/setup ADOPTS the
52
+ state first (named), then remove works.
53
+ - takeover over a USER-OVERRIDE key → split verdict: the override is preserved
54
+ and stays unowned, the stale ownership state is cleared, and the report says
55
+ "package owner moved; provider override preserved" — never a false both-owned.
56
+ `--preflight` runs the SAME ownership decision READ-ONLY (identical exit codes,
57
+ zero writes) so run.sh can complete both the package and provider preflights
58
+ before either writer runs (atomic user-scope operations).
59
+
31
60
  Subcommands:
32
- install <settings_path> <repo_dir> --scope <user|project> [--state <state_path>]
33
- remove <settings_path> <repo_dir> --scope <user|project> [--state <state_path>]
61
+ install <settings_path> <repo_dir> --scope <user|project> [--state <state_path>] [--takeover] [--preflight]
62
+ remove <settings_path> <repo_dir> --scope <user|project> [--state <state_path>] [--orphan-cleanup] [--preflight]
34
63
 
35
- Exit codes: 0 ok · 2 no-state · 3 refuse-symlink · 4 invalid-json · 5 usage.
64
+ Exit codes: 0 ok · 2 no-state · 3 refuse-symlink · 4 invalid-json/corrupt-state · 5 usage · 6 ownership-refusal.
36
65
  """
37
66
 
38
67
  import json
@@ -47,7 +76,7 @@ import sys
47
76
  # holds this directory when the script is run by path (how run.sh and the gates invoke
48
77
  # it); the explicit insert keeps the import true under any other invocation form.
49
78
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
50
- from pi_settings_io import detect_indent, dumps, unchanged # noqa: E402
79
+ from pi_settings_io import classify_installer_root, detect_indent, dumps, unchanged # noqa: E402
51
80
 
52
81
  SERVER_KEY = "entwurf-bridge"
53
82
  BARE_COMMAND = "entwurf-bridge"
@@ -172,12 +201,49 @@ def _provider_servers(data: dict, create: bool):
172
201
  return provider, servers
173
202
 
174
203
 
175
- def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str) -> None:
204
+ def _load_state_file(state_path: str) -> dict | None:
205
+ if not state_path or not os.path.exists(state_path):
206
+ return None
207
+ state = _load(state_path)
208
+ return state
209
+
210
+
211
+ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str, takeover: bool = False,
212
+ preflight: bool = False) -> None:
176
213
  if os.path.islink(settings_path):
177
214
  target = os.readlink(settings_path)
178
215
  _die(3, f"register-pi-provider: refusing to adopt {settings_path} — it is a symlink to {target} "
179
216
  f"(someone else's SSOT). Manage it there, or replace it with a regular file, then retry.")
180
217
 
218
+ # Owner binding BEFORE any settings mutation (#86 C2): a user-scope state whose
219
+ # installerRoot names another root refuses the whole install — zero settings
220
+ # bytes — unless the operator-explicit takeover replaces it.
221
+ prior_state = _load_state_file(state_path) if scope == "user" else None
222
+ prior_kind, prior_root = ("legacy", None)
223
+ if scope == "user" and isinstance(prior_state, dict):
224
+ # State↔target binding (#86 C2 final amendment): the state names WHICH
225
+ # settings file it manages; an operation targeting a different file is an
226
+ # ownership mismatch — fail closed before either writer runs.
227
+ prior_managed = prior_state.get("managedSettingsPath")
228
+ if not isinstance(prior_managed, str):
229
+ _die(4, f"register-pi-provider: install-state {state_path} has no managedSettingsPath string")
230
+ if os.path.abspath(prior_managed) != os.path.abspath(settings_path):
231
+ _die(6, f"register-pi-provider: install-state {state_path} manages {prior_managed}, but this "
232
+ f"operation targets {settings_path} — ownership record and target disagree; zero settings bytes written.")
233
+ # One typed installerRoot verdict (#86 C2 corrective amendment, B blocker 3):
234
+ # a wrong-TYPE value used to fall between the isinstance(str) arm and the
235
+ # is-None legacy arm and proceed unattributed — now it is CORRUPT, refused
236
+ # before any settings or state write.
237
+ prior_kind, prior_root = classify_installer_root(prior_state)
238
+ if prior_kind == "corrupt":
239
+ _die(4, f"register-pi-provider: install-state {state_path} has a CORRUPT installerRoot — expected "
240
+ "a non-empty string (owner) or null/absent (legacy). Refusing to install over an "
241
+ "unattributed state; repair or remove the state file first — zero settings bytes written.")
242
+ if scope == "user" and prior_kind == "owner" and prior_root != repo_dir and not takeover:
243
+ _die(6, f"register-pi-provider: the user-scope {SERVER_KEY} key is owned by another root: {prior_root}. "
244
+ "Normal install never replaces another owner — zero settings bytes written. "
245
+ "Use './run.sh takeover-user-scope' to move it explicitly.")
246
+
181
247
  raw = _read(settings_path)
182
248
  # Parsed TWICE on purpose: everything below mutates in place, so `before` has to be
183
249
  # an independent document or the comparison would be against itself.
@@ -189,12 +255,28 @@ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str)
189
255
  existing_cmd = existing.get("command") if isinstance(existing, dict) else existing
190
256
  ownership = _classify(existing_cmd, repo_dir)
191
257
 
258
+ if preflight:
259
+ # Read-only half of the atomic user-scope operation: the ownership decision
260
+ # above already refused a foreign installerRoot; everything past this point
261
+ # would write, so report the classification and stop.
262
+ sys.stdout.write(f"preflight: install ok (ownership={ownership}{', takeover' if takeover else ''})\n")
263
+ return
264
+
192
265
  if ownership == "user-override":
193
266
  # DO NOT overwrite our key, DO NOT own it (no state). doctor reports it as unowned. Still
194
267
  # persist IF the legacy prune above (or a materialized parent) actually changed something.
195
268
  sys.stdout.write(
196
269
  f"install: preserved entwurfProvider.mcpServers.{SERVER_KEY} (user override, NOT owned: {existing_cmd!r})\n"
197
270
  )
271
+ if scope == "user" and takeover and state_path and os.path.exists(state_path):
272
+ # Split verdict (#86 C2): the package half moved, but the provider key is
273
+ # the OPERATOR'S override — preserve it, and clear the stale ownership
274
+ # state so no root claims a key nobody manages. Never a false "both owned".
275
+ os.remove(state_path)
276
+ sys.stdout.write(
277
+ "takeover: provider override preserved, unowned — stale provider ownership state cleared "
278
+ "(package owner moved; the provider key remains the operator's)\n"
279
+ )
198
280
  if not _persist(settings_path, before, data, raw):
199
281
  sys.stdout.write(f"install: no change — {settings_path} left untouched (bytes and mtime stable)\n")
200
282
  return
@@ -219,6 +301,16 @@ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str)
219
301
 
220
302
  if scope == "user":
221
303
  if state_path:
304
+ if prior_state is not None and prior_kind == "legacy":
305
+ # LEGACY v1 state (pre-#86 C2, no installerRoot): safe adoption — the
306
+ # rewrite below binds it to this root, and the adoption is named.
307
+ sys.stdout.write(
308
+ f"install: adopted legacy provider install-state (no installerRoot recorded) — now bound to {repo_dir}\n"
309
+ )
310
+ if takeover and prior_kind == "owner" and prior_root != repo_dir:
311
+ sys.stdout.write(
312
+ f"takeover: user-scope {SERVER_KEY} provider ownership moved {prior_root} -> {repo_dir}\n"
313
+ )
222
314
  state = {
223
315
  "schemaVersion": STATE_SCHEMA_VERSION,
224
316
  "managedSettingsPath": os.path.abspath(settings_path),
@@ -226,6 +318,7 @@ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str)
226
318
  "key": f"entwurfProvider.mcpServers.{SERVER_KEY}",
227
319
  "command": BARE_COMMAND,
228
320
  "ownership": ownership, # absent | managed-current | managed-legacy
321
+ "installerRoot": repo_dir, # #86 C2: the root whose inverse may remove this key
229
322
  "preimage": existing, # raw prior value (audit only; NOT restored)
230
323
  "installedAt": _now(),
231
324
  }
@@ -237,22 +330,66 @@ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str)
237
330
  )
238
331
 
239
332
 
240
- def cmd_remove(settings_path: str, repo_dir: str, scope: str, state_path: str) -> None:
333
+ def cmd_remove(settings_path: str, repo_dir: str, scope: str, state_path: str, orphan: bool = False,
334
+ preflight: bool = False) -> None:
241
335
  if scope == "user":
242
336
  if not state_path or not os.path.exists(state_path):
243
337
  sys.stdout.write("remove: no install-state — nothing to undo (never owned, or already removed).\n")
244
338
  return
245
339
  state = _load(state_path)
340
+ # Same-owner-only inverse (#86 C2): a state bound to another root must not
341
+ # let that OTHER root's stale inverse delete the CURRENT stable key. The
342
+ # typed classifier (B blocker 3) closes the wrong-TYPE gap: corrupt refuses
343
+ # BEFORE the foreign/legacy arms, so no value can fall between them.
344
+ installer_kind, installer_root = classify_installer_root(state)
345
+ if installer_kind == "corrupt":
346
+ _die(4, f"register-pi-provider: install-state {state_path} has a CORRUPT installerRoot — expected "
347
+ "a non-empty string (owner) or null/absent (legacy). Refusing to remove on an unattributed "
348
+ "state; repair or remove the state file first — zero settings bytes written.")
349
+ if installer_kind == "owner" and installer_root != repo_dir:
350
+ if os.path.isdir(installer_root):
351
+ _die(6, f"register-pi-provider: the user-scope {SERVER_KEY} key is owned by another LIVE root: "
352
+ f"{installer_root}. This root's inverse must not remove it — zero settings bytes written.")
353
+ if not orphan:
354
+ _die(6, f"register-pi-provider: the recorded owner root is MISSING: {installer_root}. "
355
+ "Refusing outside run.sh's aligned orphan path (remove-user-scope checks package entry, "
356
+ "package state and provider installerRoot together).")
357
+ if not preflight:
358
+ sys.stdout.write(
359
+ f"remove: orphan cleanup — proceeding for the MISSING owner {installer_root}\n"
360
+ )
361
+ elif installer_kind == "legacy":
362
+ # LEGACY v1 state (no installerRoot): fail-closed (#86 C2 amendment). An
363
+ # unattributed state must not let ANY root's inverse — least of all an old
364
+ # checkout's — delete the current stable key. Adoption is install-only:
365
+ # run install/setup from the owning root first, then remove.
366
+ _die(6, f"register-pi-provider: install-state {state_path} is LEGACY (no installerRoot recorded) — "
367
+ "refusing to remove an unattributed key. Run './run.sh setup' or 'entwurf install' from the "
368
+ "owning root first (named adoption binds the state), then remove.")
369
+ # PREFLIGHT COMPLETENESS (#86 C2 final amendment): everything the writer
370
+ # would check is checked HERE, before any green — managedSettingsPath shape
371
+ # and target equality, the symlink refusal, and settings/provider
372
+ # parseability — so a run.sh caller that saw this preflight green cannot
373
+ # have its provider writer break after the package writer already wrote.
246
374
  managed = state.get("managedSettingsPath")
247
375
  if not isinstance(managed, str):
248
376
  _die(4, f"register-pi-provider: install-state {state_path} has no managedSettingsPath")
377
+ if os.path.abspath(managed) != os.path.abspath(settings_path):
378
+ _die(6, f"register-pi-provider: install-state {state_path} manages {managed}, but this operation "
379
+ f"targets {settings_path} — ownership record and target disagree; zero settings bytes written.")
249
380
  if os.path.islink(managed):
250
381
  _die(3, f"register-pi-provider: refusing to uninstall — {managed} became a symlink since install.")
382
+ raw = ""
383
+ before = data = provider = servers = None
251
384
  if os.path.exists(managed):
252
385
  raw = _read(managed)
253
- before = _parse_settings(managed, raw)
386
+ before = _parse_settings(managed, raw) # dies 4 on corrupt — preflight and writer alike
254
387
  data = _parse_settings(managed, raw)
255
388
  provider, servers = _provider_servers(data, create=False)
389
+ if preflight:
390
+ sys.stdout.write("preflight: remove ok (owner verified; managed target bound and parseable)\n")
391
+ return
392
+ if os.path.exists(managed):
256
393
  # honest inverse: absent/managed-* → remove OUR key (a legacy repo path is NOT
257
394
  # restored — it was our old managed value, not a user value).
258
395
  if isinstance(servers, dict):
@@ -304,8 +441,9 @@ def cmd_remove(settings_path: str, repo_dir: str, scope: str, state_path: str) -
304
441
 
305
442
 
306
443
  def _parse(argv: list):
307
- # positional: settings_path repo_dir ; flags: --scope <s> [--state <p>]
444
+ # positional: settings_path repo_dir ; flags: --scope <s> [--state <p>] [--takeover] [--orphan-cleanup] [--preflight]
308
445
  pos, scope, state_path = [], None, ""
446
+ takeover, orphan, preflight = False, False, False
309
447
  i = 0
310
448
  while i < len(argv):
311
449
  a = argv[i]
@@ -315,17 +453,23 @@ def _parse(argv: list):
315
453
  elif a == "--state":
316
454
  i += 1
317
455
  state_path = argv[i] if i < len(argv) else ""
456
+ elif a == "--takeover":
457
+ takeover = True
458
+ elif a == "--orphan-cleanup":
459
+ orphan = True
460
+ elif a == "--preflight":
461
+ preflight = True
318
462
  else:
319
463
  pos.append(a)
320
464
  i += 1
321
- return pos, scope, state_path
465
+ return pos, scope, state_path, takeover, orphan, preflight
322
466
 
323
467
 
324
468
  def main(argv: list) -> None:
325
469
  if len(argv) < 2:
326
470
  _die(5, "usage: register-pi-provider.py <install|remove> <settings_path> <repo_dir> --scope <user|project> [--state <path>]")
327
471
  sub = argv[1]
328
- pos, scope, state_path = _parse(argv[2:])
472
+ pos, scope, state_path, takeover, orphan, preflight = _parse(argv[2:])
329
473
  if sub not in ("install", "remove"):
330
474
  _die(5, f"register-pi-provider.py: unknown subcommand {sub!r}")
331
475
  if len(pos) != 2:
@@ -335,10 +479,16 @@ def main(argv: list) -> None:
335
479
  settings_path, repo_dir = pos[0], str(os.path.abspath(pos[1]))
336
480
  if scope == "user" and not state_path:
337
481
  _die(5, "register-pi-provider.py: --state is required for --scope user")
482
+ if (takeover or orphan or preflight) and scope != "user":
483
+ _die(5, "register-pi-provider.py: --takeover/--orphan-cleanup/--preflight require --scope user")
484
+ if takeover and sub != "install":
485
+ _die(5, "register-pi-provider.py: --takeover is an install action")
486
+ if orphan and sub != "remove":
487
+ _die(5, "register-pi-provider.py: --orphan-cleanup is a remove action")
338
488
  if sub == "install":
339
- cmd_install(settings_path, repo_dir, scope, state_path)
489
+ cmd_install(settings_path, repo_dir, scope, state_path, takeover, preflight)
340
490
  else:
341
- cmd_remove(settings_path, repo_dir, scope, state_path)
491
+ cmd_remove(settings_path, repo_dir, scope, state_path, orphan, preflight)
342
492
 
343
493
 
344
494
  if __name__ == "__main__":
@@ -20,12 +20,14 @@
20
20
  # - CREATE-NEW → uninstall removes the file it created.
21
21
  # - SETUP INTEGRATION (막힘 ①): the `wire_agy_bridge` wrapper folded into `./run.sh setup` —
22
22
  # agy absent → honest skip + NO state; agy present + regular → idempotent install + state;
23
- # agy present + symlink/corrupt → NON-FATAL WARN + continue (exit 0, reason-specific, no
24
- # clobber, no state). Driven via the hidden `wire-agy-bridge` subcommand with AGY_BIN pinned.
23
+ # agy present + symlink/corrupt → reason-specific WARN + leaf EXIT NONZERO (#86 C6/C7:
24
+ # named component FAIL, aggregate keeps later components alive; no clobber, no state).
25
+ # Driven via the hidden `wire-agy-bridge` subcommand with AGY_BIN pinned.
25
26
  # - DEV BIN (막힘 ②): the managed `entwurf-bridge` symlink dev-bin.sh exposes so the agy
26
27
  # config's BARE command resolves in a dev checkout — ownership-checked link (REFUSE a
27
28
  # foreign bin, never a blind ln -sf), state + honest inverse (remove only OUR link), and
28
- # the NON-FATAL setup wrapper (foreign → WARN + continue). Isolated bin dir + fake target.
29
+ # the truthful setup wrapper (foreign → WARN + rc=3, every unit still attempted #86 C10).
30
+ # Isolated bin dir + fake target.
29
31
  # - ⓪ discipline day-one: the checkout stays byte-identical (nothing written under $REPO).
30
32
  # Offline + deterministic (deps: bash + python3).
31
33
  set -euo pipefail
@@ -377,13 +379,16 @@ want "cache-prune(symlink): symlinked legacy cache left intact (not clobbered)"
377
379
  "[ -L '$CACHE/pi-tools-bridge' ] && [ -d '$SB/foreign-cache' ]"
378
380
  bash "$BRIDGE" uninstall >/dev/null; rm -f "$CACHE/pi-tools-bridge" "$GLOBAL" "$STATE"
379
381
 
380
- # ── I: setup integration — wire_agy_bridge (막힘 ①: detection-gated, NON-FATAL) ─────
382
+ # ── I: setup integration — wire_agy_bridge (막힘 ①: detection-gated, truthful) ─────
381
383
  # The setup wrapper folded into `./run.sh setup`. Driven here via the hidden `wire-agy-bridge`
382
384
  # subcommand with AGY_BIN pinned (a fake agy / a nonexistent path) so detection is hermetic
383
- # regardless of the CI/dev host's real agy. Locks: agy absent → honest skip + NO state; agy
384
- # present + regular → idempotent install + state; agy present + symlink/corrupt → NON-FATAL
385
- # WARN + continue (exit 0 an optional harness must never brick a pi/Claude setup), reason-
386
- # specific, no clobber, no state. Clean slate here (H left no config/state).
385
+ # regardless of the CI/dev host's real agy. Locks (#86 C6/C7): agy absent → honest skip + NO
386
+ # state + exit 0; agy present + regular → idempotent install + state + exit 0; agy present +
387
+ # symlink/corrupt reason-specific WARN, no clobber, no state, and EXIT NONZERO the leaf
388
+ # reports a detected-incomplete integration truthfully, and the aggregate setup records the
389
+ # named component FAIL while keeping the remaining components alive. The old NON-FATAL exit-0
390
+ # posture was the false-green shape this contract retires. Clean slate here (H left no
391
+ # config/state).
387
392
  rm -f "$GLOBAL" "$LEGACY" "$STATE"
388
393
  printf '#!/usr/bin/env bash\necho fake-agy\n' > "$SB/bin/agy"; chmod +x "$SB/bin/agy"
389
394
 
@@ -408,11 +413,12 @@ want "wire(agy+regular, re-run): config still valid + entwurf-bridge present" \
408
413
  want "wire(agy+regular, re-run): unrelated server still preserved" "grep -q '\"other\"' '$GLOBAL'"
409
414
  bash "$BRIDGE" uninstall >/dev/null; rm -f "$GLOBAL"
410
415
 
411
- # I-3: agy PRESENT + SYMLINK config → NON-FATAL WARN + continue (exit 0), no clobber, no state
416
+ # I-3: agy PRESENT + SYMLINK config → reason-specific WARN + EXIT NONZERO (component FAIL),
417
+ # no clobber, no state
412
418
  printf '{"mcpServers":{}}\n' > "$SB/real_wire_cfg.json"
413
419
  ln -s "$SB/real_wire_cfg.json" "$GLOBAL"
414
420
  set +e; OUT="$(AGY_BIN="$SB/bin/agy" bash "$REPO_DIR/run.sh" wire-agy-bridge 2>&1)"; RC=$?; set -e
415
- want "wire(agy+symlink): exits 0 (NON-FATAL setup not bricked)" "[ '$RC' -eq 0 ]"
421
+ want "wire(agy+symlink): exits nonzero (detected agy + refused config = component FAIL, not cosmetic green) [QK:SETUP-AGY-COSMETIC-RETURN]" "[ '$RC' -ne 0 ]"
416
422
  want "wire(agy+symlink): reason-specific WARN names the symlink/SSOT" \
417
423
  "printf '%s' \"\$OUT\" | grep -qi 'symlink'"
418
424
  want "wire(agy+symlink): linked SSOT NOT clobbered" \
@@ -420,31 +426,69 @@ want "wire(agy+symlink): linked SSOT NOT clobbered" \
420
426
  want "wire(agy+symlink): NO state written" "[ ! -f '$STATE' ]"
421
427
  rm -f "$GLOBAL"
422
428
 
423
- # I-4: agy PRESENT + CORRUPT config (invalid JSON) → NON-FATAL WARN + continue, corrupt-specific
429
+ # I-4: agy PRESENT + CORRUPT config (invalid JSON) → reason-specific WARN + EXIT NONZERO,
430
+ # corrupt-specific
424
431
  printf 'this is not json{{{' > "$GLOBAL"
425
432
  set +e; OUT="$(AGY_BIN="$SB/bin/agy" bash "$REPO_DIR/run.sh" wire-agy-bridge 2>&1)"; RC=$?; set -e
426
- want "wire(agy+corrupt): exits 0 (NON-FATAL)" "[ '$RC' -eq 0 ]"
433
+ want "wire(agy+corrupt): exits nonzero (detected agy + corrupt config = component FAIL)" "[ '$RC' -ne 0 ]"
427
434
  want "wire(agy+corrupt): reason-specific WARN flags invalid JSON (not a silent skip)" \
428
435
  "printf '%s' \"\$OUT\" | grep -qi 'invalid JSON'"
429
436
  want "wire(agy+corrupt): NO state written" "[ ! -f '$STATE' ]"
430
437
  rm -f "$GLOBAL" "$SB/bin/agy"
431
438
 
432
439
  # ── J: dev bin exposure — dev-bin.sh (막힘 ②: managed stable-bin symlinks) ─────────
433
- # dev-bin.sh now manages MULTIPLE bins (entwurf-bridge + entwurf-agy-statusline), each with its
434
- # OWN <name>.install-state.json. J drives the entwurf-bridge bin by NAME so these locks stay
435
- # byte-for-byte the pre-multi-bin regression (무회귀 판정): ownership-checked link (REFUSE
436
- # foreign, never a blind ln -sf), state + honest inverse, remove only OUR link, NON-FATAL setup
437
- # wrapper on a foreign bin. J-5 adds the new legacy-state migration. Isolated: a sandbox bin dir
438
- # + fake executable targets + the sandbox XDG state.
440
+ # dev-bin.sh manages multiple package commands, each with its OWN <name>.install-state.json.
441
+ # J-0 drives the NO-ARG setup composition and proves the source checkout supplies the operator
442
+ # command Copilot fresh resolves (`entwurf` this checkout's run.sh-equivalent target), including
443
+ # a real argv-preserving invocation through PATH. J-1 onward keeps the older entwurf-bridge
444
+ # ownership regression byte-for-byte: REFUSE foreign, state + honest inverse, remove only OUR
445
+ # link, the truthful setup wrapper (rc=3 propagated), and legacy-state migration. Isolated:
446
+ # sandbox bin/targets/XDG.
439
447
  DEVBIN="$REPO_DIR/scripts/dev-bin.sh"
440
448
  DBIN_DIR="$SB/devbin"
441
449
  DLINK="$DBIN_DIR/entwurf-bridge"
442
450
  DSTATE="$XDG_DATA_HOME/entwurf/dev-bin/entwurf-bridge.install-state.json" # bin-scoped state
451
+ OLINK="$DBIN_DIR/entwurf"
452
+ OSTATE="$XDG_DATA_HOME/entwurf/dev-bin/entwurf.install-state.json"
443
453
  printf '#!/usr/bin/env bash\necho fake-bridge\n' > "$SB/fake-start.sh"; chmod +x "$SB/fake-start.sh"
444
454
  printf '#!/usr/bin/env bash\necho fake-status\n' > "$SB/fake-status.sh"; chmod +x "$SB/fake-status.sh"
455
+ printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$@" > "${ENTWURF_OPERATOR_ARGV_LOG:?}"\n' > "$SB/fake-entwurf.sh"; chmod +x "$SB/fake-entwurf.sh"
456
+ printf '#!/usr/bin/env bash\nexit 0\n' > "$SB/fake-aux.sh"; chmod +x "$SB/fake-aux.sh"
445
457
  export ENTWURF_DEV_BIN_DIR="$DBIN_DIR"
458
+ export ENTWURF_OPERATOR_TARGET="$SB/fake-entwurf.sh"
446
459
  export ENTWURF_BRIDGE_TARGET="$SB/fake-start.sh"
447
- export ENTWURF_AGY_STATUSLINE_TARGET="$SB/fake-status.sh" # for the no-arg setup wrapper (two bins)
460
+ export ENTWURF_AGY_STATUSLINE_TARGET="$SB/fake-status.sh"
461
+ export ENTWURF_AGY_IMPRINT_TARGET="$SB/fake-aux.sh"
462
+ export ENTWURF_COPILOT_STATUSLINE_TARGET="$SB/fake-aux.sh"
463
+
464
+ # J-0: the setup wrapper calls no-arg expose. This is the cross-contract cell the #82 regression
465
+ # lacked: an npm consumer gets `entwurf` from package bin linking, and source setup must expose the
466
+ # same operator command without a second global package install.
467
+ set +e; OUT="$(PATH="$DBIN_DIR:$PATH" bash "$REPO_DIR/run.sh" expose-dev-bin 2>&1)"; RC=$?; set -e
468
+ want "source setup exposes the managed Copilot runtime on PATH [QK:SOURCE-OPERATOR-BIN]" \
469
+ "[ '$RC' -eq 0 ] && [ -L '$OLINK' ] && [ \"\$(readlink '$OLINK')\" = '$SB/fake-entwurf.sh' ] && [ -f '$OSTATE' ]"
470
+ want "source operator bin resolves through the sandbox PATH" \
471
+ "PATH='$DBIN_DIR':\"\$PATH\" command -v entwurf | grep -Fx '$OLINK' >/dev/null"
472
+ OPERATOR_ARGV="$SB/operator.argv"
473
+ ENTWURF_OPERATOR_ARGV_LOG="$OPERATOR_ARGV" "$OLINK" copilot --model auto
474
+ want "source operator bin reaches its target with argv byte-for-byte" \
475
+ "python3 -c 'import sys; sys.exit(0 if open(sys.argv[1]).read().splitlines() == [\"copilot\",\"--model\",\"auto\"] else 1)' '$OPERATOR_ARGV'"
476
+ bash "$DEVBIN" remove >/dev/null 2>&1
477
+ want "source operator inverse removes its link and state" "[ ! -e '$OLINK' ] && [ ! -f '$OSTATE' ]"
478
+ bash "$DEVBIN" remove entwurf >/dev/null 2>&1
479
+ ok "source operator inverse is idempotent"
480
+ printf 'FOREIGN OPERATOR\n' > "$OLINK"
481
+ if bash "$DEVBIN" expose entwurf >/dev/null 2>&1; then die "dev-bin: entwurf expose should REFUSE a foreign operator bin"; fi
482
+ want "source operator expose leaves a foreign bin untouched" "[ \"\$(cat '$OLINK')\" = 'FOREIGN OPERATOR' ]"
483
+ set +e; OUT="$(PATH="$DBIN_DIR:$PATH" bash "$REPO_DIR/run.sh" expose-dev-bin 2>&1)"; RC=$?; set -e
484
+ want "source setup fails loud when it cannot own its operator command [QK:SOURCE-OPERATOR-FAILS-LOUD]" \
485
+ "[ '$RC' -ne 0 ] && printf '%s' \"\$OUT\" | grep -q 'source command is not the PATH winner owned by this checkout'"
486
+ rm -f "$OLINK"
487
+ bash "$DEVBIN" expose entwurf >/dev/null 2>&1
488
+ set +e; OUT="$(PATH="$PATH" bash "$REPO_DIR/run.sh" expose-dev-bin 2>&1)"; RC=$?; set -e
489
+ want "source setup fails for the operator-certification reason when its managed bin directory is outside PATH" \
490
+ "[ '$RC' -ne 0 ] && printf '%s' \"\$OUT\" | grep -q 'source command is not the PATH winner owned by this checkout'"
491
+ bash "$DEVBIN" remove >/dev/null 2>&1
448
492
 
449
493
  # J-1: expose the entwurf-bridge bin BY NAME → creates the managed symlink + state (created-new)
450
494
  bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1
@@ -479,11 +523,20 @@ if bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1; then die "dev-bin: expo
479
523
  ok "dev-bin expose: refused a foreign bin (nonzero exit)"
480
524
  want "dev-bin expose: foreign bin NOT clobbered" "[ \"\$(cat '$DLINK')\" = 'FOREIGN NPM BIN' ]"
481
525
  want "dev-bin expose: no state written on foreign refuse" "[ ! -f '$DSTATE' ]"
482
- # the NON-FATAL setup wrapper (no-arg all bins) turns that refuse into a WARN + continue. The
483
- # foreign entwurf-bridge is the FIRST managed bin, so the wrapper stops there (statusline unreached).
484
- set +e; OUT="$(bash "$REPO_DIR/run.sh" expose-dev-bin 2>&1)"; RC=$?; set -e
485
- want "dev-bin wrapper(foreign): setup wrapper exits 0 (NON-FATAL)" "[ '$RC' -eq 0 ]"
486
- want "dev-bin wrapper(foreign): WARNs about a foreign bin (not ours)" "printf '%s' \"\$OUT\" | grep -qi 'not ours'"
526
+ # The setup wrapper exposes/certifies the CORE operator, and dev-bin attempts EVERY unit
527
+ # independently (#86 C10): a foreign helper no longer aborts the loop or hides behind a WARN —
528
+ # the wrapper propagates rc=3, the summary reports attempted/ok/refused truthfully, later units
529
+ # are still attempted, and the aggregate setup records a named bins FAIL. A helper conflict must
530
+ # still not erase the operator command the source lane needs.
531
+ set +e; OUT="$(PATH="$DBIN_DIR:$PATH" bash "$REPO_DIR/run.sh" expose-dev-bin 2>&1)"; RC=$?; set -e
532
+ want "dev-bin wrapper(helper foreign): rc=3 with the source operator still certified" \
533
+ "[ '$RC' -eq 3 ] && [ \"\$(readlink '$OLINK')\" = '$SB/fake-entwurf.sh' ]"
534
+ want "dev-bin wrapper(helper foreign): WARNs about a foreign bin (not ours)" "printf '%s' \"\$OUT\" | grep -qi 'not ours'"
535
+ want "dev-bin wrapper(helper foreign): later units were still attempted (loop did not stop at the refusal) [QK:DEV-BIN-ATTEMPTS-ALL]" \
536
+ "[ -L '$DBIN_DIR/entwurf-agy-statusline' ] && [ -L '$DBIN_DIR/entwurf-copilot-statusline' ]"
537
+ want "dev-bin wrapper(helper foreign): truthful attempted/refused summary names the refused unit" \
538
+ "printf '%s' \"\$OUT\" | grep -q 'refused=1: entwurf-bridge'"
539
+ bash "$DEVBIN" remove >/dev/null 2>&1 # clear the attempted helper links/states for the cells below
487
540
  rm -f "$DLINK"
488
541
 
489
542
  # J-4: remove is honest-inverse — removes ONLY our link, refuses a link that became foreign
@@ -523,7 +576,9 @@ printf '{"linkPath":"%s/something-else","target":"x"}\n' "$DBIN_DIR" > "$LEGACY"
523
576
  if bash "$DEVBIN" expose entwurf-bridge >/dev/null 2>&1; then die "dev-bin migrate: foreign legacy should REFUSE"; fi
524
577
  ok "dev-bin migrate: foreign legacy refused (linkPath basename != entwurf-bridge)"
525
578
  rm -f "$LEGACY" "$DLINK" "$DSTATE"
526
- unset ENTWURF_DEV_BIN_DIR ENTWURF_BRIDGE_TARGET ENTWURF_AGY_STATUSLINE_TARGET
579
+ bash "$DEVBIN" remove >/dev/null 2>&1
580
+ unset ENTWURF_DEV_BIN_DIR ENTWURF_OPERATOR_TARGET ENTWURF_BRIDGE_TARGET ENTWURF_AGY_STATUSLINE_TARGET
581
+ unset ENTWURF_AGY_IMPRINT_TARGET ENTWURF_COPILOT_STATUSLINE_TARGET
527
582
 
528
583
  # ── K: permission grant — the OTHER half of a usable bridge ───────────────────
529
584
  # Registering the server only makes the tools REACHABLE. agy defaults every `mcp` action to Ask, so
@@ -872,15 +927,16 @@ want "permission: a symlinked settings.json is left untouched (someone else's SS
872
927
  "! has_rule '$SB/foreign-settings.json'"
873
928
  want "permission: no permission state is written for a refused symlink" "[ ! -e '$PSTATE' ]"
874
929
 
875
- # …and the SAME failure, seen from setup: NON-FATAL, reason-specific, never silent.
930
+ # …and the SAME failure, seen from setup: reason-specific, never silent, and truthful (#86
931
+ # C6/C7): a detected agy whose grant cannot be written is a component FAIL, so the wrapper
932
+ # exits NONZERO and the aggregate setup records it while later components stay attempted.
876
933
  # Detection must be hermetic here exactly as it is in section I: re-mint the fake agy (I tore it
877
934
  # down at its end) and pin AGY_BIN at it. Unpinned, the wrapper falls back to the host's PATH —
878
- # it finds a real agy on a dev box and degrades honestly, but on a CI runner with no agy it takes
879
- # the skip branch and exits 0 with no WARN. The exit-0 assertion below passes either way; only the
880
- # NOT-GRANTED one can tell a degrade from a skip, which is the whole point of this pair.
935
+ # on a CI runner with no agy it would take the skip branch (exit 0, no WARN) and only the
936
+ # NOT-GRANTED assertion could tell a degrade from a skip.
881
937
  printf '#!/usr/bin/env bash\necho fake-agy\n' > "$SB/bin/agy"; chmod +x "$SB/bin/agy"
882
938
  set +e; OUT="$(AGY_BIN="$SB/bin/agy" bash "$REPO_DIR/run.sh" wire-agy-bridge 2>&1)"; RC=$?; set -e
883
- want "permission(setup): the wrapper keeps setup alive (exit 0) despite the failed grant" "[ '$RC' -eq 0 ]"
939
+ want "permission(setup): the wrapper exits nonzero on the failed grant (detected FAIL, not cosmetic green)" "[ '$RC' -ne 0 ]"
884
940
  want "permission(setup): the wrapper says the bridge is REGISTERED but NOT GRANTED (no silent pass)" \
885
941
  "printf '%s' \"\$OUT\" | grep -q 'NOT GRANTED'"
886
942
  rm -f "$SETTINGS" "$PSTATE" "$STATE" "$GLOBAL" "$SB/bin/agy"
@@ -16,7 +16,8 @@
16
16
  # - CREATE-NEW → uninstall removes the file it created.
17
17
  # - SETUP INTEGRATION: the wire-agy-statusline wrapper — agy absent → honest skip + NO state;
18
18
  # agy present + regular → idempotent install + state; agy present + symlink/corrupt →
19
- # NON-FATAL WARN + continue (exit 0, reason-specific, no clobber, no state).
19
+ # reason-specific WARN + leaf EXIT NONZERO (#86 C6/C7: the aggregate records a named
20
+ # component FAIL and keeps later components alive), no clobber, no state.
20
21
  # - checkout stays byte-identical (nothing written under $REPO).
21
22
  # Offline + deterministic (deps: bash + python3).
22
23
  set -euo pipefail
@@ -201,7 +202,7 @@ want "create-new: state removed" "[ ! -f '$STATE' ]"
201
202
  bash "$BRIDGE" uninstall >/dev/null 2>&1
202
203
  ok "idempotent: uninstall with no state exits 0 (nothing to undo)"
203
204
 
204
- # ── I: setup integration — wire-agy-statusline (detection-gated, NON-FATAL) ──
205
+ # ── I: setup integration — wire-agy-statusline (detection-gated, truthful) ──
205
206
  rm -f "$SET" "$STATE"
206
207
  printf '#!/usr/bin/env bash\necho fake-agy\n' > "$SB/bin/agy"; chmod +x "$SB/bin/agy"
207
208
 
@@ -222,21 +223,23 @@ set +e; OUT="$(AGY_BIN="$SB/bin/agy" bash "$REPO_DIR/run.sh" wire-agy-statusline
222
223
  want "wire(agy+regular, re-run): idempotent exit 0" "[ '$RC' -eq 0 ]"
223
224
  bash "$BRIDGE" uninstall >/dev/null; rm -f "$SET"
224
225
 
225
- # I-3: agy PRESENT + SYMLINK settings → NON-FATAL WARN + continue (exit 0), no clobber, no state
226
+ # I-3: agy PRESENT + SYMLINK settings → reason-specific WARN + EXIT NONZERO (detected component
227
+ # FAIL — the aggregate keeps later components alive), no clobber, no state
226
228
  printf '{"model":"x"}\n' > "$SB/real_wire_set.json"
227
229
  ln -s "$SB/real_wire_set.json" "$SET"
228
230
  set +e; OUT="$(AGY_BIN="$SB/bin/agy" bash "$REPO_DIR/run.sh" wire-agy-statusline 2>&1)"; RC=$?; set -e
229
- want "wire(agy+symlink): exits 0 (NON-FATAL setup not bricked)" "[ '$RC' -eq 0 ]"
231
+ want "wire(agy+symlink): exits nonzero (detected agy + refused settings = component FAIL, not cosmetic green)" "[ '$RC' -ne 0 ]"
230
232
  want "wire(agy+symlink): reason-specific WARN names the symlink" "printf '%s' \"\$OUT\" | grep -qi 'symlink'"
231
233
  want "wire(agy+symlink): linked SSOT NOT clobbered" \
232
234
  "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if \"statusLine\" not in d else 1)' '$SB/real_wire_set.json'"
233
235
  want "wire(agy+symlink): NO state written" "[ ! -f '$STATE' ]"
234
236
  rm -f "$SET"
235
237
 
236
- # I-4: agy PRESENT + CORRUPT settings (invalid JSON) → NON-FATAL WARN + continue
238
+ # I-4: agy PRESENT + CORRUPT settings (invalid JSON) → reason-specific WARN + EXIT NONZERO,
239
+ # corrupt-specific
237
240
  printf 'not json{{{' > "$SET"
238
241
  set +e; OUT="$(AGY_BIN="$SB/bin/agy" bash "$REPO_DIR/run.sh" wire-agy-statusline 2>&1)"; RC=$?; set -e
239
- want "wire(agy+corrupt): exits 0 (NON-FATAL)" "[ '$RC' -eq 0 ]"
242
+ want "wire(agy+corrupt): exits nonzero (detected agy + corrupt settings = component FAIL)" "[ '$RC' -ne 0 ]"
240
243
  want "wire(agy+corrupt): reason-specific WARN flags invalid JSON" "printf '%s' \"\$OUT\" | grep -qi 'invalid JSON'"
241
244
  want "wire(agy+corrupt): NO state written" "[ ! -f '$STATE' ]"
242
245
  rm -f "$SET" "$SB/bin/agy"