@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
@@ -51,7 +51,53 @@ This wiring (user scope) dropped when `pi install` was removed from setup
51
51
  lets run.sh (both scopes + remove) and smoke-user-scope-citizen share ONE
52
52
  implementation — mirrors the meta-bridge-state.py split.
53
53
 
54
- Usage: register-pi-package.py <settings.json> <repo_dir> [--remove]
54
+ USER-SCOPE OWNERSHIP (#86 C2). `--scope user --state <path>` puts the GLOBAL
55
+ packages[] entry under a recorded owner (`packageRoot` in
56
+ $XDG_DATA_HOME/entwurf/pi-package/install-state.json) and retires the silent
57
+ last-writer-wins normalization for that scope. Once an owner is recorded, EVERY
58
+ recorded-owner operation (install / takeover / inverse / doctor, preflight and
59
+ writer alike) judges the settings through ONE bounded exact-owner classifier:
60
+ the owner must hold exactly ONE exact entry (the resolved absolute path, or a
61
+ settings-relative path resolving exactly there) and there must be NO additional
62
+ entwurf-shaped root. A missing, duplicated, or extra root is a named zero-write
63
+ refusal (exit 6) — user scope never routes through the broad project-scope
64
+ `register()` collapse, which would delete another root's entry as collateral:
65
+ - fresh (no state, no entwurf entry) → append exact entry + write owner state;
66
+ - same root (state.packageRoot == root) → classifier holds → no-op (bytes/mtime
67
+ stable); classifier violated → refuse;
68
+ - legacy no-state, sole entry EXACTLY this root (absolute or settings-relative)
69
+ → ADOPTION: state written, settings
70
+ bytes/mtime untouched;
71
+ - legacy no-state, any other/ambiguous entwurf shape → REFUSE (exit 6), zero write;
72
+ - state owned by ANOTHER root, live OR missing → normal install REFUSE (exit 6),
73
+ zero write, naming `takeover-user-scope`; a missing owner additionally shows as
74
+ the doctor verdict `missing-owner`;
75
+ - the ONLY writer that replaces another owner is the operator-explicit
76
+ `--takeover` (run.sh takeover-user-scope): the recorded owner's EXACT entry is
77
+ replaced in place (old→new, both roots reported). Takeover licenses moving THE
78
+ registration, never collateral deletion — an ambiguous store refuses. With no
79
+ recorded owner it claims a SINGLE legacy entwurf-shaped entry; two or more are
80
+ ambiguous and refuse.
81
+ Remove under user scope is same-owner-only and exact-entry-only: a live foreign
82
+ owner refuses; a MISSING owner is removable only through run.sh's aligned orphan
83
+ path (`--orphan-cleanup`, passed after package entry + package state + provider
84
+ installerRoot all agree on that same missing root). No --force flag exists.
85
+ `--doctor` reports unregistered / owned / legacy-no-state / mismatch /
86
+ foreign-owner(live) / missing-owner without writing; its owner judgment asks the
87
+ SAME exact classifier, so a store the inverse would refuse can never read green.
88
+ PROJECT scope keeps the state-less behavior above.
89
+
90
+ `--preflight` (user scope) runs the SAME ownership decision READ-ONLY: identical
91
+ exit codes, zero writes — run.sh completes both the package and provider
92
+ preflights before either writer runs, so a refusal on one side leaves the other
93
+ side byte-identical (atomic user-scope operations). `--doctor` additionally takes
94
+ `--provider-state <path>` to report a packageRoot↔installerRoot coupling mismatch
95
+ as FAIL (ownership coupling only; provider runtime stays with doctor-pi-provider).
96
+
97
+ Usage: register-pi-package.py <settings.json> <repo_dir> [--remove] [--dry-run]
98
+ [--scope user|project] [--state <path>] [--takeover] [--orphan-cleanup]
99
+ [--doctor] [--preflight] [--provider-state <path>]
100
+ Exit codes: 0 ok · 3 refuse-symlink · 4 corrupt-state/settings · 6 ownership-refusal.
55
101
  """
56
102
 
57
103
  from __future__ import annotations
@@ -68,7 +114,7 @@ from pathlib import Path
68
114
  # script is run by path, which is how run.sh and every gate invoke it; the explicit
69
115
  # insert keeps the import true under any other invocation form.
70
116
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
71
- from pi_settings_io import detect_indent, dumps # noqa: E402
117
+ from pi_settings_io import classify_installer_root, detect_indent, dumps # noqa: E402
72
118
 
73
119
  # A leading `<scheme>:` means the string is a package SPEC (npm:, git:, https:),
74
120
  # never a filesystem path — so it is never resolved against the settings dir.
@@ -278,15 +324,474 @@ def would_remove(settings_path: Path, repo_dir_arg: str) -> tuple[int, int]:
278
324
  return len(removable), len(preserved)
279
325
 
280
326
 
327
+ # ── user-scope ownership (#86 C2) ────────────────────────────────────────────
328
+ STATE_SCHEMA_VERSION = 1
329
+
330
+
331
+ def _refuse(code: int, msg: str) -> None:
332
+ sys.stderr.write(msg.rstrip("\n") + "\n")
333
+ raise SystemExit(code)
334
+
335
+
336
+ def _refuse_symlink(settings_path: Path) -> None:
337
+ # The package writer must never follow a foreign symlink into someone else's
338
+ # SSOT — same rule (and exit code) as register-pi-provider, checked BEFORE
339
+ # any provider step can refuse for its own reasons.
340
+ if settings_path.is_symlink():
341
+ _refuse(3, f"register-pi-package: refusing to write through {settings_path} — it is a symlink "
342
+ "(someone else's SSOT). Manage it there, or replace it with a regular file, then retry.")
343
+
344
+
345
+ def _load_state(state_path: Path) -> dict | None:
346
+ if not state_path.exists():
347
+ return None
348
+ try:
349
+ data = json.loads(state_path.read_text())
350
+ except json.JSONDecodeError as err:
351
+ _refuse(4, f"register-pi-package: install-state {state_path} is not valid JSON: {err}")
352
+ if not isinstance(data, dict) or not isinstance(data.get("packageRoot"), str):
353
+ _refuse(4, f"register-pi-package: install-state {state_path} has no packageRoot")
354
+ return data
355
+
356
+
357
+ def _write_state(state_path: Path, repo_dir: str, settings_path: Path) -> None:
358
+ import datetime
359
+
360
+ state_path.parent.mkdir(parents=True, exist_ok=True)
361
+ state = {
362
+ "schemaVersion": STATE_SCHEMA_VERSION,
363
+ "packageRoot": repo_dir,
364
+ "managedSettingsPath": str(settings_path.resolve()),
365
+ "installedAt": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
366
+ }
367
+ tmp = state_path.with_name(state_path.name + f".tmp-{os.getpid()}")
368
+ tmp.write_text(json.dumps(state, indent=2) + "\n")
369
+ os.replace(tmp, state_path)
370
+
371
+
372
+ def _sole_exact_self_entry(settings_path: Path, repo_dir: str) -> bool:
373
+ """True iff the settings carry exactly ONE entwurf entry and it names THIS root
374
+ exactly (canonical absolute or settings-relative self) — the only legacy
375
+ no-state shape adoption may claim without asking the operator."""
376
+ data = _load(settings_path) if settings_path.exists() else {}
377
+ packages = _packages(settings_path, data)
378
+ settings_dir = _settings_dir(settings_path)
379
+ matches = _entwurf_matches(packages, repo_dir, settings_dir)
380
+ return (
381
+ len(matches) == 1
382
+ and isinstance(matches[0], str)
383
+ and (matches[0] == repo_dir or is_settings_relative_self(matches[0], settings_dir, repo_dir))
384
+ )
385
+
386
+
387
+ def _has_entwurf_entries(settings_path: Path, repo_dir: str) -> bool:
388
+ data = _load(settings_path) if settings_path.exists() else {}
389
+ packages = _packages(settings_path, data)
390
+ return bool(_entwurf_matches(packages, repo_dir, _settings_dir(settings_path)))
391
+
392
+
393
+ def _check_settings_binding(state: dict, settings_path: Path, state_path: Path) -> None:
394
+ """The ownership state names WHICH settings file it manages. Any user-scope
395
+ operation targeting a DIFFERENT file is an ownership mismatch — fail closed
396
+ BEFORE either writer runs (this state is #86 C2 new; no legacy exemption)."""
397
+ managed = state.get("managedSettingsPath")
398
+ if not isinstance(managed, str):
399
+ _refuse(4, f"register-pi-package: install-state {state_path} has no managedSettingsPath string")
400
+ if os.path.abspath(managed) != os.path.abspath(str(settings_path)):
401
+ _refuse(6, f"register-pi-package: install-state {state_path} manages {managed}, but this operation "
402
+ f"targets {settings_path} — ownership record and target disagree; zero settings bytes written. "
403
+ "Inspect with './run.sh doctor-pi-package'.")
404
+
405
+
406
+ def _classify_user_owner_entries(settings_path: Path, owner: str) -> tuple[list, list]:
407
+ """THE bounded exact-owner classifier (#86 C2 corrective amendment, B blockers 1+2).
408
+
409
+ Returns (exact, extra) for the recorded user-scope owner:
410
+ exact packages[] STRING entries naming `owner` EXACTLY — the resolved absolute
411
+ path, or a settings-relative path resolving exactly there. Deliberately
412
+ narrower than is_entwurf_source: an owner operation must never reach an
413
+ npm spec or an unrelated `.../entwurf` path merely because the broad
414
+ matcher would.
415
+ extra every OTHER entwurf-shaped entry (any shape) — with a recorded owner,
416
+ a second Entwurf-shaped root is AMBIGUITY, never collateral that
417
+ install/takeover/inverse may collapse or that the doctor may bless.
418
+
419
+ Every recorded-owner judgment (install, takeover, inverse, doctor, preflight and
420
+ writer alike) asks THIS function, so the verdicts cannot drift apart the way the
421
+ broad-install/exact-inverse split did.
422
+ """
423
+ data = _load(settings_path) if settings_path.exists() else {}
424
+ packages = _packages(settings_path, data)
425
+ settings_dir = _settings_dir(settings_path)
426
+ exact = [
427
+ p for p in packages
428
+ if isinstance(p, str) and (p.rstrip("/") == owner or is_settings_relative_self(p, settings_dir, owner))
429
+ ]
430
+ shaped = _entwurf_matches(packages, owner, settings_dir)
431
+ extra = [item for item in shaped if item not in exact]
432
+ return exact, extra
433
+
434
+
435
+ def _require_user_owner_entry(settings_path: Path, owner: str) -> object:
436
+ """The refusing form of the classifier: exactly one exact entry, no extra
437
+ entwurf-shaped root — anything else is a named zero-write refusal (exit 6).
438
+ Returns the single exact entry."""
439
+ exact, extra = _classify_user_owner_entries(settings_path, owner)
440
+ if len(exact) != 1 or extra:
441
+ _refuse(6, f"register-pi-package: the recorded owner {owner} has {len(exact)} exact packages[] "
442
+ f"entr{'y' if len(exact) == 1 else 'ies'} (expected exactly one) and {len(extra)} additional "
443
+ f"entwurf-shaped entr{'y' if len(extra) == 1 else 'ies'} (expected none) — the ownership "
444
+ "record and the settings are ambiguous; zero settings bytes written. "
445
+ "Inspect with './run.sh doctor-pi-package'.")
446
+ return exact[0]
447
+
448
+
449
+ def _replace_packages_entry(settings_path: Path, old_item: object, replacement: str) -> None:
450
+ """Swap ONE packages[] entry in place (first occurrence), preserving order,
451
+ every other entry, and the file's own indent unit."""
452
+ original_text = _read_text(settings_path)
453
+ data = _load(settings_path)
454
+ packages = _packages(settings_path, data)
455
+ swapped = False
456
+ out = []
457
+ for item in packages:
458
+ if not swapped and item == old_item:
459
+ out.append(replacement)
460
+ swapped = True
461
+ else:
462
+ out.append(item)
463
+ data["packages"] = out
464
+ _write(settings_path, data, original_text)
465
+
466
+
467
+ def _remove_exact_owner_entry(settings_path: Path, owner: str) -> None:
468
+ """Remove the recorded owner's single classified exact entry — never through the
469
+ broad entwurf-shape matcher. An ambiguous store already refused upstream in
470
+ _require_user_owner_entry."""
471
+ exact = _require_user_owner_entry(settings_path, owner)
472
+ original_text = _read_text(settings_path)
473
+ data = _load(settings_path)
474
+ packages = _packages(settings_path, data)
475
+ removed = False
476
+ kept_pkgs = []
477
+ for item in packages:
478
+ if not removed and item == exact:
479
+ removed = True
480
+ continue
481
+ kept_pkgs.append(item)
482
+ data["packages"] = kept_pkgs
483
+ _write(settings_path, data, original_text)
484
+
485
+
486
+ def register_user(settings_path: Path, repo_dir_arg: str, state_path: Path, takeover: bool,
487
+ preflight: bool = False) -> int:
488
+ repo_dir = str(Path(repo_dir_arg).resolve())
489
+ _refuse_symlink(settings_path)
490
+ state = _load_state(state_path)
491
+ if state is not None:
492
+ _check_settings_binding(state, settings_path, state_path)
493
+
494
+ if state is not None and not takeover:
495
+ owner = state["packageRoot"]
496
+ if owner == repo_dir:
497
+ # Same-owner install NEVER routes through the broad register() collapse
498
+ # (#86 C2 corrective amendment, B blocker 1): with a recorded owner, the
499
+ # store either already holds exactly one exact entry with no other
500
+ # entwurf-shaped root — a no-op — or it is ambiguous and refuses. Another
501
+ # root's entry is never collateral a normal install may delete.
502
+ _require_user_owner_entry(settings_path, owner)
503
+ if preflight:
504
+ print("preflight: install ok (this root owns one unambiguous registration)")
505
+ return 0
506
+ print(f"install: entwurf package already registered (no-op) -> {repo_dir}")
507
+ return 0
508
+ if os.path.isdir(owner):
509
+ _refuse(6, "register-pi-package: the user-scope entwurf registration is owned by another LIVE root: "
510
+ f"{owner}. Normal install/setup never replaces another owner — zero settings bytes written. "
511
+ f"Run './run.sh takeover-user-scope' from {repo_dir} to explicitly move the shared entry.")
512
+ _refuse(6, "register-pi-package: the user-scope entwurf registration is owned by a MISSING root: "
513
+ f"{owner} (doctor verdict: missing-owner). Normal install/setup still refuses — zero settings "
514
+ "bytes written. Use './run.sh takeover-user-scope' to claim it explicitly, or "
515
+ "'./run.sh remove-user-scope' for the aligned orphan cleanup.")
516
+
517
+ if state is not None:
518
+ # Operator-explicit takeover with a recorded owner: the ONE writer allowed to
519
+ # move the shared user-scope entry between roots. It moves the old owner's
520
+ # EXACT classified entry in place — takeover licenses moving THE registration,
521
+ # never collateral deletion, so an ambiguous store refuses here too.
522
+ old_root = state["packageRoot"]
523
+ if old_root == repo_dir:
524
+ _require_user_owner_entry(settings_path, old_root)
525
+ if preflight:
526
+ print("preflight: takeover ok (already this root; one unambiguous registration)")
527
+ return 0
528
+ _write_state(state_path, repo_dir, settings_path)
529
+ print(f"takeover: user-scope entwurf registration remains {repo_dir} (already this root)")
530
+ return 0
531
+ old_entry = _require_user_owner_entry(settings_path, old_root)
532
+ if preflight:
533
+ print(f"preflight: takeover would move {old_root} -> {repo_dir}")
534
+ return 0
535
+ _replace_packages_entry(settings_path, old_entry, repo_dir)
536
+ _write_state(state_path, repo_dir, settings_path)
537
+ print(f"takeover: user-scope entwurf registration moved {old_root} -> {repo_dir} (exact owner entry only)")
538
+ return 0
539
+
540
+ # ── no recorded owner (no state) ──
541
+ if not _has_entwurf_entries(settings_path, repo_dir):
542
+ if preflight:
543
+ print("preflight: takeover would claim {} (fresh; no other owner recorded)".format(repo_dir)
544
+ if takeover else "preflight: install ok (fresh registration)")
545
+ return 0
546
+ # Fresh registration appends the exact entry directly — the broad register()
547
+ # collapse has nothing to normalize here and user scope never invokes it.
548
+ original_text = _read_text(settings_path)
549
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
550
+ data = _load(settings_path)
551
+ packages = _packages(settings_path, data)
552
+ data["packages"] = packages + [repo_dir]
553
+ _write(settings_path, data, original_text)
554
+ _write_state(state_path, repo_dir, settings_path)
555
+ if takeover:
556
+ print(f"takeover: user-scope entwurf registration now {repo_dir} (fresh; no other owner was recorded)")
557
+ else:
558
+ print(f"install: registered entwurf package -> {settings_path}")
559
+ print(f"install: package source -> {repo_dir} (owner state written)")
560
+ return 0
561
+ if _sole_exact_self_entry(settings_path, repo_dir):
562
+ # Legacy no-state entry that EXACTLY names this root: adopt by writing the
563
+ # owner state only — the settings bytes and mtime stay untouched.
564
+ if preflight:
565
+ print("preflight: install ok (legacy same-root adoption)")
566
+ return 0
567
+ _write_state(state_path, repo_dir, settings_path)
568
+ print(f"install: adopted legacy user-scope registration for {repo_dir} (owner state written; settings untouched)")
569
+ return 0
570
+ if takeover:
571
+ # Explicit claim of an ownerless legacy store: unambiguous only when a SINGLE
572
+ # entwurf-shaped entry exists — that entry IS the registration the operator
573
+ # asked to take over, and it is replaced in place. Two or more shapes cannot
574
+ # be attributed and refuse; collapsing them would be collateral deletion.
575
+ data = _load(settings_path) if settings_path.exists() else {}
576
+ packages = _packages(settings_path, data)
577
+ shaped = _entwurf_matches(packages, repo_dir, _settings_dir(settings_path))
578
+ if len(shaped) == 1:
579
+ old = source_of(shaped[0])
580
+ if preflight:
581
+ print(f"preflight: takeover would claim the legacy entry {old!r} -> {repo_dir}")
582
+ return 0
583
+ _replace_packages_entry(settings_path, shaped[0], repo_dir)
584
+ _write_state(state_path, repo_dir, settings_path)
585
+ print(f"takeover: claimed the legacy user-scope registration {old!r} -> {repo_dir} "
586
+ "(single ownerless entry; no other root touched)")
587
+ return 0
588
+ _refuse(6, f"register-pi-package: takeover refused — {len(shaped)} legacy entwurf-shaped packages[] "
589
+ "entries exist with NO recorded owner; the claim is ambiguous and collapsing them would "
590
+ "delete entries this root cannot attribute. Zero settings bytes written — resolve the "
591
+ "store by hand (see './run.sh doctor-pi-package'), then retry.")
592
+ _refuse(6, "register-pi-package: legacy user-scope entwurf entr(y/ies) exist with NO recorded owner and do not "
593
+ f"exactly name this root ({repo_dir}). Refusing to normalize them silently — zero settings bytes "
594
+ "written. Use './run.sh takeover-user-scope' to claim the registration explicitly.")
595
+ return 1 # unreachable
596
+
597
+
598
+ def remove_user(settings_path: Path, repo_dir_arg: str, state_path: Path, orphan: bool,
599
+ preflight: bool = False) -> int:
600
+ repo_dir = str(Path(repo_dir_arg).resolve())
601
+ if settings_path.exists():
602
+ _refuse_symlink(settings_path)
603
+ state = _load_state(state_path)
604
+ if state is not None:
605
+ _check_settings_binding(state, settings_path, state_path)
606
+
607
+ if state is not None:
608
+ owner = state["packageRoot"]
609
+ if owner == repo_dir:
610
+ # Exact-entry inverse (#86 C2): the owned removal drops ONLY the recorded
611
+ # owner's classified exact entry. A missing/duplicated exact entry OR any
612
+ # additional entwurf-shaped root is ambiguity the classifier refuses,
613
+ # checked identically by preflight and writer.
614
+ _require_user_owner_entry(settings_path, owner)
615
+ if preflight:
616
+ print("preflight: remove ok (this root owns one unambiguous registration)")
617
+ return 0
618
+ _remove_exact_owner_entry(settings_path, owner)
619
+ state_path.unlink()
620
+ print(f"remove: removed the owner's exact packages[] entry from {settings_path} (owner state cleared)")
621
+ return 0
622
+ if os.path.isdir(owner):
623
+ _refuse(6, "register-pi-package: the user-scope registration is owned by another LIVE root: "
624
+ f"{owner}. This root's inverse must not remove someone else's registration — zero settings "
625
+ "bytes written. Run remove from that root, or take the entry over first.")
626
+ if orphan:
627
+ # run.sh's aligned orphan path: entry + package state + provider
628
+ # installerRoot all named this same MISSING root before the flag was
629
+ # passed. Same exact classifier as the owned inverse.
630
+ _require_user_owner_entry(settings_path, owner)
631
+ if preflight:
632
+ print(f"preflight: orphan cleanup would remove the MISSING owner {owner} (one unambiguous entry)")
633
+ return 0
634
+ _remove_exact_owner_entry(settings_path, owner)
635
+ state_path.unlink()
636
+ print(f"remove: orphan cleanup — removed the exact entry for the MISSING owner {owner} (state cleared)")
637
+ return 0
638
+ _refuse(6, "register-pi-package: the recorded owner root is MISSING: "
639
+ f"{owner}. Refusing to remove without the aligned orphan path — run './run.sh remove-user-scope' "
640
+ "so package entry, package state and provider installerRoot are checked together.")
641
+
642
+ # no state: same-owner-only under user scope — remove ONLY entries that exactly
643
+ # name this root; any other managed-looking shape is preserved and reported.
644
+ if not settings_path.exists():
645
+ print(f"remove: nothing to do ({settings_path} missing)")
646
+ return 0
647
+ if preflight:
648
+ print("preflight: remove ok (no owner state; self-exact entries only)")
649
+ return 0
650
+ original_text = _read_text(settings_path)
651
+ data = _load(settings_path)
652
+ packages = _packages(settings_path, data)
653
+ settings_dir = _settings_dir(settings_path)
654
+ matches = _entwurf_matches(packages, repo_dir, settings_dir)
655
+ self_removable = [
656
+ m for m in matches
657
+ if isinstance(m, str) and m == repo_dir
658
+ ]
659
+ preserved_rel = [m for m in matches if _is_relative_self_item(m, settings_dir, repo_dir)]
660
+ foreign_like = [m for m in matches if m not in self_removable and m not in preserved_rel]
661
+ if self_removable:
662
+ data["packages"] = [item for item in packages if item not in self_removable]
663
+ _write(settings_path, data, original_text)
664
+ print(f"remove: removed {len(self_removable)} entwurf packages[] entr{'y' if len(self_removable) == 1 else 'ies'} naming this root")
665
+ else:
666
+ print(f"remove: no entwurf packages[] entry naming this root ({settings_path})")
667
+ if preserved_rel:
668
+ print(f"remove: kept {len(preserved_rel)} settings-relative entwurf entr{'y' if len(preserved_rel) == 1 else 'ies'} — committed/operator source, not install state")
669
+ if foreign_like:
670
+ print(f"remove: kept {len(foreign_like)} entwurf-shaped entr{'y' if len(foreign_like) == 1 else 'ies'} with no recorded owner "
671
+ "(not provably this root's) — use './run.sh doctor-pi-package' / takeover-user-scope to resolve ownership")
672
+ return 0
673
+
674
+
675
+ def doctor_user(settings_path: Path, repo_dir_arg: str, state_path: Path,
676
+ provider_state_path: Path | None = None) -> int:
677
+ """Package-side ownership verdict plus the package↔provider OWNERSHIP coupling
678
+ (installerRoot vs packageRoot). Provider RUNTIME verdicts stay with
679
+ doctor-pi-provider — this doctor never probes the bridge."""
680
+ repo_dir = str(Path(repo_dir_arg).resolve())
681
+ state = _load_state(state_path)
682
+ coupling_fail = False
683
+ if state is not None:
684
+ managed = state.get("managedSettingsPath")
685
+ if not isinstance(managed, str) or os.path.abspath(managed) != os.path.abspath(str(settings_path)):
686
+ print(f"doctor-pi-package: FAIL managedSettingsPath mismatch — the package state manages "
687
+ f"{managed!r} but this host's target is {settings_path}")
688
+ coupling_fail = True
689
+ if provider_state_path is not None and provider_state_path.exists() and state is not None:
690
+ try:
691
+ pp = json.loads(provider_state_path.read_text())
692
+ except json.JSONDecodeError:
693
+ pp = None
694
+ if not isinstance(pp, dict):
695
+ print("doctor-pi-package: FAIL provider install-state is not readable as a JSON object — "
696
+ "the ownership coupling cannot be judged from an unattributed state")
697
+ coupling_fail = True
698
+ else:
699
+ # One typed installerRoot verdict (#86 C2 corrective amendment, B blocker 3):
700
+ # the SAME classifier the provider writers refuse on, so a wrong-TYPE value
701
+ # can never read green here while install/remove fail closed on it.
702
+ installer_kind, installer_root = classify_installer_root(pp)
703
+ if installer_kind == "corrupt":
704
+ print("doctor-pi-package: FAIL provider installerRoot is CORRUPT — expected a non-empty string "
705
+ "(owner) or null/absent (legacy); a wrong-typed value is unattributed and fail-closed")
706
+ coupling_fail = True
707
+ elif installer_kind == "owner" and installer_root != state["packageRoot"]:
708
+ print(f"doctor-pi-package: FAIL coupling mismatch — provider installerRoot {installer_root} "
709
+ f"!= packageRoot {state['packageRoot']} (the two halves of the user-scope ownership disagree)")
710
+ coupling_fail = True
711
+ elif installer_kind == "legacy":
712
+ print("doctor-pi-package: note — provider install-state is LEGACY (no installerRoot); "
713
+ "a same-root install/setup adopts it")
714
+ pp_managed = pp.get("managedSettingsPath") if isinstance(pp, dict) else None
715
+ if isinstance(pp_managed, str) and os.path.abspath(pp_managed) != os.path.abspath(str(settings_path)):
716
+ print(f"doctor-pi-package: FAIL provider managedSettingsPath mismatch — the provider state manages "
717
+ f"{pp_managed!r} but this host's target is {settings_path}")
718
+ coupling_fail = True
719
+ data = _load(settings_path) if settings_path.exists() else {}
720
+ packages = _packages(settings_path, data)
721
+ settings_dir = _settings_dir(settings_path)
722
+ matches = _entwurf_matches(packages, repo_dir, settings_dir)
723
+
724
+ if state is None:
725
+ if not matches:
726
+ print("doctor-pi-package: unregistered — no owner state, no entwurf packages[] entry")
727
+ return 0
728
+ print(f"doctor-pi-package: legacy-no-state — {len(matches)} entwurf entr{'y' if len(matches) == 1 else 'ies'} with no recorded owner; "
729
+ "repair: './run.sh setup' from the owning root (exact-self adopts) or './run.sh takeover-user-scope'")
730
+ return 1
731
+ owner = state["packageRoot"]
732
+ owner_live = os.path.isdir(owner)
733
+ # The SAME exact-owner classifier the writers refuse on (#86 C2 corrective
734
+ # amendment, B blocker 2): a store the inverse would refuse — owner's exact
735
+ # entry missing/duplicated, or an extra entwurf-shaped root beside it — must
736
+ # read RED here, never "owned" through the broad shape matcher.
737
+ owner_exact, owner_extra = _classify_user_owner_entries(settings_path, owner)
738
+ if len(owner_exact) != 1 or owner_extra:
739
+ print(f"doctor-pi-package: mismatch — owner state records {owner} but the settings hold "
740
+ f"{len(owner_exact)} exact entr{'y' if len(owner_exact) == 1 else 'ies'} and "
741
+ f"{len(owner_extra)} additional entwurf-shaped entr{'y' if len(owner_extra) == 1 else 'ies'} "
742
+ "(expected exactly one exact entry and no other entwurf-shaped root)")
743
+ return 1
744
+ if not owner_live:
745
+ print(f"doctor-pi-package: missing-owner — recorded owner root {owner} does not exist; "
746
+ "repair: './run.sh takeover-user-scope' from a live root, or './run.sh remove-user-scope' (aligned orphan cleanup)")
747
+ return 1
748
+ if owner == repo_dir:
749
+ print(f"doctor-pi-package: owned — this root ({repo_dir}) owns the user-scope registration")
750
+ return 1 if coupling_fail else 0
751
+ print(f"doctor-pi-package: owned-by-other (live) — {owner} owns the user-scope registration; this root does not")
752
+ return 1 if coupling_fail else 0
753
+
754
+
281
755
  def main(argv: list[str]) -> int:
282
- flags = {a for a in argv[1:] if a.startswith("--")}
283
- args = [a for a in argv[1:] if not a.startswith("--")]
756
+ flag_names = {"--remove", "--dry-run", "--takeover", "--orphan-cleanup", "--doctor", "--preflight"}
757
+ args: list[str] = []
758
+ flags: set[str] = set()
759
+ scope = "project"
760
+ state_arg = ""
761
+ provider_state_arg = ""
762
+ i = 1
763
+ while i < len(argv):
764
+ a = argv[i]
765
+ if a == "--scope":
766
+ i += 1
767
+ scope = argv[i] if i < len(argv) else ""
768
+ elif a == "--state":
769
+ i += 1
770
+ state_arg = argv[i] if i < len(argv) else ""
771
+ elif a == "--provider-state":
772
+ i += 1
773
+ provider_state_arg = argv[i] if i < len(argv) else ""
774
+ elif a.startswith("--"):
775
+ flags.add(a)
776
+ else:
777
+ args.append(a)
778
+ i += 1
284
779
  do_remove = "--remove" in flags
285
780
  dry_run = "--dry-run" in flags
286
- known = {"--remove", "--dry-run"}
287
- unknown = flags - known
781
+ unknown = flags - flag_names
288
782
  if unknown:
289
783
  raise SystemExit(f"unknown flag(s): {', '.join(sorted(unknown))}")
784
+ if scope not in ("user", "project"):
785
+ raise SystemExit("register-pi-package.py: --scope must be user or project")
786
+ if scope == "user" and not state_arg:
787
+ raise SystemExit("register-pi-package.py: --state is required with --scope user")
788
+ for f in ("--takeover", "--orphan-cleanup", "--doctor", "--preflight"):
789
+ if f in flags and (scope != "user" or not state_arg):
790
+ raise SystemExit(f"register-pi-package.py: {f} requires --scope user --state <path>")
791
+ if "--orphan-cleanup" in flags and not do_remove:
792
+ raise SystemExit("register-pi-package.py: --orphan-cleanup is only supported with --remove")
793
+ if "--takeover" in flags and (do_remove or "--doctor" in flags):
794
+ raise SystemExit("register-pi-package.py: --takeover is an install action")
290
795
  # --dry-run is a REMOVE-only preview. Without --remove it would otherwise fall
291
796
  # through to the register path and WRITE — a flag literally named "dry-run"
292
797
  # mutating settings is an install-hygiene footgun, so reject it loud instead of
@@ -294,11 +799,24 @@ def main(argv: list[str]) -> int:
294
799
  if dry_run and not do_remove:
295
800
  raise SystemExit("--dry-run is only supported with --remove")
296
801
  if len(args) != 2:
297
- raise SystemExit("usage: register-pi-package.py <settings.json> <repo_dir> [--remove] [--dry-run]")
802
+ raise SystemExit("usage: register-pi-package.py <settings.json> <repo_dir> [--remove] [--dry-run] "
803
+ "[--scope user|project] [--state <path>] [--takeover] [--orphan-cleanup] [--doctor]")
298
804
  settings_path = Path(args[0])
299
805
  repo_dir_arg = args[1]
300
806
  resolved = str(Path(repo_dir_arg).resolve())
301
807
 
808
+ if scope == "user":
809
+ state_path = Path(state_arg)
810
+ preflight = "--preflight" in flags
811
+ if "--doctor" in flags:
812
+ return doctor_user(settings_path, repo_dir_arg, state_path,
813
+ Path(provider_state_arg) if provider_state_arg else None)
814
+ if do_remove:
815
+ if dry_run:
816
+ raise SystemExit("register-pi-package.py: --dry-run is not supported with --scope user")
817
+ return remove_user(settings_path, repo_dir_arg, state_path, "--orphan-cleanup" in flags, preflight)
818
+ return register_user(settings_path, repo_dir_arg, state_path, "--takeover" in flags, preflight)
819
+
302
820
  if do_remove:
303
821
  n, kept = (would_remove if dry_run else remove)(settings_path, repo_dir_arg)
304
822
  verb = "would remove" if dry_run else "removed"