@junghanacs/entwurf 0.14.2 → 0.15.1
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.
- package/AGENTS.md +9 -5
- package/CHANGELOG.md +149 -0
- package/DELIVERY.md +72 -59
- package/README.md +120 -20
- package/VERIFY.md +10 -7
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +2 -2
- package/docs/external-mcp-host.md +26 -4
- package/docs/setup-clean-host.md +118 -18
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
- package/mcp/entwurf-bridge/src/index.ts +10 -6
- package/mcp/entwurf-bridge/tsconfig.build.json +2 -1
- package/package.json +22 -11
- package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
- package/pi-extensions/entwurf-control.ts +17 -8
- package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
- package/pi-extensions/lib/meta-sender-identity.ts +10 -2
- package/pi-extensions/lib/meta-session.ts +102 -29
- package/pi-extensions/lib/mux-fresh-call.ts +82 -11
- package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
- package/run.sh +1079 -186
- package/scripts/agy-bridge.sh +4 -2
- package/scripts/check-copilot-birth-hook.ts +1174 -0
- package/scripts/check-copilot-launch.ts +395 -0
- package/scripts/check-copilot-receive-arm.ts +734 -0
- package/scripts/check-copilot-statusline.ts +122 -0
- package/scripts/check-entwurf-capabilities.ts +25 -6
- package/scripts/check-fresh-cut-gate.sh +104 -27
- package/scripts/check-gate-qualification.ts +10 -1
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-meta-capability-source.ts +89 -5
- package/scripts/check-meta-doctor-oracle.sh +26 -0
- package/scripts/check-meta-manifest-schema.py +38 -0
- package/scripts/check-meta-receiver-marker.ts +25 -2
- package/scripts/check-meta-session.ts +32 -0
- package/scripts/check-setup-qualification.sh +126 -0
- package/scripts/copilot-bridge-doctor.sh +242 -0
- package/scripts/copilot-bridge-install.sh +384 -0
- package/scripts/copilot-bridge-oracle.sh +223 -0
- package/scripts/copilot-bridge-uninstall.sh +137 -0
- package/scripts/copilot-launch.sh +236 -0
- package/scripts/copilot-mcp-bridge.sh +177 -0
- package/scripts/copilot-mcp-config.py +221 -0
- package/scripts/copilot-receive-bridge.sh +483 -0
- package/scripts/copilot-statusline-bridge.sh +149 -0
- package/scripts/copilot-statusline-config.py +201 -0
- package/scripts/copilot-statusline.sh +68 -0
- package/scripts/dev-bin.sh +53 -9
- package/scripts/fake-copilot-vendor.sh +67 -0
- package/scripts/meta-bridge-hook-log.sh +17 -5
- package/scripts/mutants/capability-cache.json +64 -0
- package/scripts/mutants/copilot-birth.json +244 -0
- package/scripts/mutants/copilot-launch.json +187 -0
- package/scripts/mutants/copilot-receive.json +263 -0
- package/scripts/mutants/fresh-cut.json +17 -0
- package/scripts/mutants/mux-fresh-call.json +216 -2
- package/scripts/mutants/pack-install.json +17 -0
- package/scripts/mutants/pi-package-ownership.json +92 -0
- package/scripts/mutants/setup-verdict.json +148 -0
- package/scripts/mutants/source-install.json +32 -0
- package/scripts/pi_settings_io.py +22 -0
- package/scripts/raw-async-delivery/README.md +170 -125
- package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
- package/scripts/register-pi-package.py +525 -7
- package/scripts/register-pi-provider.py +162 -12
- package/scripts/smoke-agy-install-state.sh +86 -30
- package/scripts/smoke-agy-statusline-state.sh +9 -6
- package/scripts/smoke-copilot-mcp-state.sh +153 -0
- package/scripts/smoke-copilot-statusline-state.sh +131 -0
- package/scripts/smoke-mux-fresh-call-live.ts +2 -0
- package/scripts/smoke-mux-lifecycle-live.ts +3 -1
- package/scripts/smoke-setup-verdict.sh +291 -0
- package/scripts/smoke-user-scope-citizen.sh +388 -1
- package/scripts/tsconfig.json +1 -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
|
-
|
|
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
|
-
|
|
283
|
-
args
|
|
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
|
-
|
|
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"
|