@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
@@ -40,96 +40,122 @@ PLUGIN="entwurf-meta-receive-copilot"
40
40
  QUALIFIED="$PLUGIN@$MKT_NAME"
41
41
  STALE_CLAUDE_UNIT="entwurf-meta-receive@meta-bridge-local"
42
42
  ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge-copilot/.assembled"
43
+ STATE_FILE="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/copilot-bridge/install-state.json"
43
44
  AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
44
45
  SESSIONS_DIR="$AGENT_DIR/meta-sessions"
45
46
  HOOK_LOG="$AGENT_DIR/meta-bridge-hook.log"
46
47
 
48
+ # RUNTIME truth and OWNERSHIP truth are separate axes (#86 C3a, same discipline as
49
+ # doctor-pi-package): a visibly working registration does not prove entwurf owns it,
50
+ # and a broken ownership record does not erase working runtime configuration. Either
51
+ # required axis red makes the final verdict red.
47
52
  fail=0
48
- ok() { echo " ok $*"; }
49
- bad() { echo " FAIL $*"; fail=1; }
50
- note() { echo " note $*"; }
53
+ own_fail=0
54
+ ok() { echo " ok $*"; }
55
+ bad() { echo " FAIL $*"; fail=1; }
56
+ badown(){ echo " FAIL $*"; own_fail=1; }
57
+ note() { echo " note $*"; }
58
+
59
+ # Shared with the installer and the inverse: one structural oracle, one state
60
+ # validator, one exact list-row grammar — three surfaces, zero drifting copies.
61
+ # shellcheck source=copilot-bridge-oracle.sh
62
+ . "$HERE/copilot-bridge-oracle.sh"
51
63
 
52
64
  echo "[copilot-bridge-doctor] toolchain"
53
65
  command -v copilot >/dev/null 2>&1 && ok "copilot CLI on PATH" || bad "copilot CLI missing from PATH"
54
66
  command -v node >/dev/null 2>&1 && ok "node on PATH" || bad "node missing from PATH"
55
67
 
56
68
  echo "[copilot-bridge-doctor] assembled artifact"
57
- if [ -d "$ASM/$PLUGIN" ]; then
58
- ok "assembly present at $ASM"
69
+ # The SAME structural oracle the installer's adoption preflight and the inverse use
70
+ # (C3a amendment, B defect 1): launcher presence/bake, live baked node and hook
71
+ # entry, capability registry at the plugin root, and the native baked hooks.json form
72
+ # are all ONE parser in copilot-bridge-oracle.sh — the doctor keeps no second copy
73
+ # that could drift from what install/adopt actually accepts. The oracle prints its
74
+ # first failing reason, which is surfaced verbatim.
75
+ if ORACLE_REASON="$(copilot_assembly_valid "$ASM" "$PLUGIN" 2>&1)"; then
76
+ ok "assembly at $ASM passes the shared structural oracle (baked launcher, live node + hook entry, capability registry, native hooks.json)"
59
77
  else
60
- bad "assembly missing at $ASM — run ./run.sh install-copilot-bridge"
78
+ bad "assembly fails the shared structural oracle: ${ORACLE_REASON:-no reason printed} — run ./run.sh install-copilot-bridge"
61
79
  fi
62
80
 
63
- LAUNCHER="$ASM/$PLUGIN/scripts/copilot-hook-launch.sh"
64
- HOOKS="$ASM/$PLUGIN/hooks/hooks.json"
65
- if [ -x "$LAUNCHER" ]; then
66
- ok "launcher present and executable"
67
- # The exec form names the launcher as THE executable — a lost +x bit is ENOEXEC at
68
- # first prompt, not a degraded path.
69
- if grep -q "__NODE_BIN__\|__HOOK_ENTRY__" "$LAUNCHER"; then
70
- bad "launcher still carries an installer placeholder (bake did not run)"
81
+ echo "[copilot-bridge-doctor] copilot wiring"
82
+ # A failing list is UNKNOWN, never an absence (#86 C3a): reporting "not installed" off
83
+ # a broken/unauthenticated CLI would send the operator to reinstall over a host whose
84
+ # real contents nobody read.
85
+ QUALIFIED_REGISTERED=0
86
+ LISTED_VERSION=""
87
+ if ! PLUGIN_LIST="$(copilot plugin list 2>/dev/null)"; then
88
+ bad "'copilot plugin list' failed this host's installed plugins are UNKNOWN (not empty); fix the Copilot CLI error and re-run"
89
+ PLUGIN_LIST=""
90
+ else
91
+ # EXACT row read with its version captured: a substring read would accept a longer id
92
+ # that merely contains ours, and a malformed/ambiguous listing is something nobody
93
+ # may act on. WHAT A MATCH DOES NOT PROVE: Copilot exposes no load/execution receipt,
94
+ # so a listing says the plugin is REGISTERED, never that Copilot loaded this unit or
95
+ # will invoke its hook. Only a real first prompt settles that — see birth evidence.
96
+ if LISTED_ROW="$(copilot_exact_row_version "$PLUGIN_LIST" "$QUALIFIED" 2>&1)"; then
97
+ if [ "$LISTED_ROW" = "absent" ]; then
98
+ bad "$QUALIFIED is NOT installed in Copilot — run ./run.sh install-copilot-bridge"
99
+ else
100
+ QUALIFIED_REGISTERED=1
101
+ LISTED_VERSION="${LISTED_ROW#one }"
102
+ ok "$QUALIFIED (v$LISTED_VERSION) is registered in Copilot (registration, not proof it is loaded)"
103
+ fi
71
104
  else
72
- ok "launcher is baked (no placeholders)"
73
- BAKED_NODE="$(sed -n 's/^NODE_BIN="\(.*\)"$/\1/p' "$LAUNCHER" | head -1)"
74
- [ -x "$BAKED_NODE" ] && ok "baked node exists: $BAKED_NODE" \
75
- || bad "baked node is missing or not executable: ${BAKED_NODE:-(unparsed)} — node moved (NixOS store churn?); reinstall"
76
- BAKED_ENTRY="$(sed -n 's|^HOOK_ENTRY="\$PLUGIN_ROOT/\(.*\)"$|\1|p' "$LAUNCHER" | head -1)"
77
- [ -n "$BAKED_ENTRY" ] && [ -f "$ASM/$PLUGIN/$BAKED_ENTRY" ] && ok "hook entry present: $BAKED_ENTRY" \
78
- || bad "hook entry missing beside the launcher: ${BAKED_ENTRY:-(unparsed)}"
105
+ bad "the plugin listing for $QUALIFIED is malformed or ambiguous: $LISTED_ROW"
79
106
  fi
80
- else
81
- bad "launcher missing or not executable: $LAUNCHER"
107
+ case "$PLUGIN_LIST" in
108
+ *"$STALE_CLAUDE_UNIT"*)
109
+ bad "the Claude unit '$STALE_CLAUDE_UNIT' is still installed in Copilot — it fires on every prompt and exits 1 before node starts (Copilot's schema has no args key). Re-run the installer without --keep-stale-claude-unit." ;;
110
+ *) ok "the Claude unit is not installed in Copilot" ;;
111
+ esac
82
112
  fi
83
113
 
84
- [ -f "$ASM/$PLUGIN/entwurf-capabilities.json" ] \
85
- && ok "capability registry travels at the plugin root (every mint reads it)" \
86
- || bad "capability registry missing at the plugin root every mint would throw"
87
-
88
- if [ -f "$HOOKS" ]; then
89
- HOOKS_PATH="$HOOKS" ASM_LAUNCHER="$LAUNCHER" python3 - <<'PY'
90
- import json, os, sys
91
- from pathlib import Path
92
- hooks = json.loads(Path(os.environ["HOOKS_PATH"]).read_text(encoding="utf-8"))
93
- launcher = os.environ["ASM_LAUNCHER"]
94
- problems = []
95
- if hooks.get("version") != 1:
96
- problems.append("hooks.json root `version` must be the literal 1 (Copilot rejects the plugin otherwise)")
97
- events = hooks.get("hooks") or {}
98
- if set(events) != {"sessionStart", "userPromptSubmitted"}:
99
- problems.append(f"hook events drifted: {sorted(events)} — want exactly ['sessionStart', 'userPromptSubmitted']")
100
- for name, entries in events.items():
101
- for i, entry in enumerate(entries if isinstance(entries, list) else []):
102
- exec_value = entry.get("exec")
103
- if not isinstance(exec_value, str):
104
- problems.append(f"hooks.{name}[{i}].exec must be a STRING (an array is rejected at plugin load)")
105
- elif exec_value != launcher:
106
- problems.append(f"hooks.{name}[{i}].exec does not point at the assembled launcher: {exec_value}")
107
- if "args" in entry:
108
- problems.append(f"hooks.{name}[{i}] carries `args`, which Copilot's schema has no key for")
109
- for p in problems:
110
- print(f" FAIL {p}")
111
- sys.exit(1 if problems else 0)
112
- PY
113
- [ $? -eq 0 ] && ok "hooks.json is the Copilot native form, baked at the assembled launcher" || fail=1
114
+ echo "[copilot-bridge-doctor] ownership (package-owned state; separate axis from runtime)"
115
+ if ! MKT_LIST="$(copilot plugin marketplace list 2>/dev/null)"; then
116
+ badown "'copilot plugin marketplace list' failed this host's marketplaces are UNKNOWN (not empty); fix the Copilot CLI error and re-run"
117
+ MKT_LIST=""
114
118
  else
115
- bad "hooks.json missing: $HOOKS"
119
+ # The SAME marketplace-row grammar install/inverse refuse on (C3a amendment, B
120
+ # defects 1+2): a malformed, non-Local, or DUPLICATE same-named listing is a red
121
+ # ownership fact here, never "the first row".
122
+ if MKT_ROW="$(copilot_marketplace_local_path "$MKT_LIST" "$MKT_NAME" 2>&1)"; then
123
+ if [ "$MKT_ROW" = "absent" ]; then
124
+ note "marketplace $MKT_NAME is not registered (consistent with an uninstalled or partially installed host)"
125
+ else
126
+ MKT_PATH="${MKT_ROW#one }"
127
+ if [ "$MKT_PATH" = "$ASM" ]; then
128
+ ok "marketplace $MKT_NAME is registered at this package's assembly path"
129
+ else
130
+ badown "marketplace '$MKT_NAME' is registered at '$MKT_PATH', not at $ASM — ownership drift; that registration is not provably ours"
131
+ fi
132
+ fi
133
+ else
134
+ badown "the marketplace listing for '$MKT_NAME' is malformed, non-Local, or duplicated: $MKT_ROW"
135
+ fi
136
+ fi
137
+ if [ -L "$STATE_FILE" ]; then
138
+ badown "ownership state $STATE_FILE is a symlink — not a trustworthy record"
139
+ elif [ -f "$STATE_FILE" ]; then
140
+ # The SAME fail-closed validator the installer and the inverse use (exact keyset,
141
+ # exact types, constants + effective-ASM binding, no flag coercion).
142
+ if STATE_VERDICT="$(copilot_state_read "$STATE_FILE" "$QUALIFIED" "$MKT_NAME" "$ASM" 2>&1)"; then
143
+ ok "ownership state present and bound to this installation (ownedMarketplace/ownedAssembly/pluginVersion: $STATE_VERDICT)"
144
+ # version drift (final amendment): the vendor lists an exact row at a version the
145
+ # ownership record did not install — the inverse would refuse, so the doctor names
146
+ # it now instead of letting the operator discover it there.
147
+ STATE_PLUGIN_VERSION="$(printf '%s' "$STATE_VERDICT" | cut -d' ' -f3)"
148
+ if [ -n "$LISTED_VERSION" ] && [ "$LISTED_VERSION" != "$STATE_PLUGIN_VERSION" ]; then
149
+ badown "version drift — $QUALIFIED is listed at v$LISTED_VERSION but the ownership state recorded v$STATE_PLUGIN_VERSION; repair: './run.sh install-copilot-bridge' re-binds ownership"
150
+ fi
151
+ else
152
+ badown "ownership state is corrupt or names a different installation: $STATE_VERDICT — inspect $STATE_FILE"
153
+ fi
154
+ elif [ "$QUALIFIED_REGISTERED" -eq 1 ]; then
155
+ badown "LEGACY no-state installation: $QUALIFIED is registered but no ownership state exists — repair: './run.sh install-copilot-bridge' (same-host adoption binds the state)"
156
+ else
157
+ note "no ownership state (nothing this package records as installed here)"
116
158
  fi
117
-
118
- echo "[copilot-bridge-doctor] copilot wiring"
119
- PLUGIN_LIST="$(copilot plugin list 2>/dev/null)"
120
- case "$PLUGIN_LIST" in
121
- *"$QUALIFIED"*)
122
- # WHAT THIS DOES NOT PROVE. Copilot exposes no load/execution receipt, so a listing
123
- # says the plugin is REGISTERED, never that Copilot loaded this unit or will invoke
124
- # its hook. Only a real first prompt settles that — see the birth-evidence section.
125
- ok "$QUALIFIED is registered in Copilot (registration, not proof it is loaded)" ;;
126
- *) bad "$QUALIFIED is NOT installed in Copilot — run ./run.sh install-copilot-bridge" ;;
127
- esac
128
- case "$PLUGIN_LIST" in
129
- *"$STALE_CLAUDE_UNIT"*)
130
- bad "the Claude unit '$STALE_CLAUDE_UNIT' is still installed in Copilot — it fires on every prompt and exits 1 before node starts (Copilot's schema has no args key). Re-run the installer without --keep-stale-claude-unit." ;;
131
- *) ok "the Claude unit is not installed in Copilot" ;;
132
- esac
133
159
 
134
160
  echo "[copilot-bridge-doctor] birth evidence"
135
161
  COPILOT_RECORDS=0
@@ -202,8 +228,15 @@ else
202
228
  fi
203
229
 
204
230
  echo
205
- if [ "$fail" -ne 0 ]; then
231
+ # The two axes are reported separately and either red is a red verdict (#86 C3a):
232
+ # runtime coverage does not prove ownership, and broken ownership does not erase
233
+ # visibly working runtime configuration.
234
+ if [ "$fail" -ne 0 ] || [ "$own_fail" -ne 0 ]; then
235
+ [ "$fail" -ne 0 ] && echo "[copilot-bridge-doctor] runtime axis: FAIL" || echo "[copilot-bridge-doctor] runtime axis: PASS"
236
+ [ "$own_fail" -ne 0 ] && echo "[copilot-bridge-doctor] ownership axis: FAIL" || echo "[copilot-bridge-doctor] ownership axis: PASS"
206
237
  echo "[copilot-bridge-doctor] FAIL"
207
238
  exit 1
208
239
  fi
240
+ echo "[copilot-bridge-doctor] runtime axis: PASS"
241
+ echo "[copilot-bridge-doctor] ownership axis: PASS"
209
242
  echo "[copilot-bridge-doctor] PASS"
@@ -45,6 +45,12 @@ ASM="${ENTWURF_COPILOT_ASM:-${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-br
45
45
  # the unit THIS repo installed (cross-review, terra).
46
46
  STALE_CLAUDE_UNIT="entwurf-meta-receive@meta-bridge-local"
47
47
  QUALIFIED="$PLUGIN@$MKT_NAME"
48
+ # Package-owned ownership state (#86 C3a): the birth unit gets the same discipline the
49
+ # MCP/receiver/footer units already have. The state names exactly what this installer
50
+ # owns — the qualified plugin id, the marketplace name, and the assembly root — so the
51
+ # inverse can remove precisely that and nothing else. No credential, no preimage.
52
+ STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/copilot-bridge"
53
+ STATE_FILE="$STATE_DIR/install-state.json"
48
54
  KEEP_STALE=0
49
55
  # --assemble-only stops after the assembly, before the Copilot CLI is touched. It is
50
56
  # how the gate reaches the real bake on a host (and in CI) that has no Copilot.
@@ -77,6 +83,140 @@ NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]' 2>/dev/null |
77
83
  # inside Copilot, where nothing surfaces it. Refuse here instead.
78
84
  [ "$NODE_MAJOR" -ge 24 ] 2>/dev/null || die "node >= 24 is required (package engines); resolved $NODE_BIN reports major '$NODE_MAJOR'."
79
85
 
86
+ # shellcheck source=copilot-bridge-oracle.sh
87
+ . "$HERE/copilot-bridge-oracle.sh"
88
+
89
+ if [ "$ASSEMBLE_ONLY" -eq 1 ]; then
90
+ # --assemble-only is a GATE-ONLY path that deliberately writes no ownership state, so
91
+ # it must never touch the LIVE assembly: without a state, a rebuilt live assembly
92
+ # would drift from what the state/marketplace record. Require the explicit temp
93
+ # override its callers already pass, BEFORE any assembly mutation (amendment,
94
+ # 2026-08-27).
95
+ DEFAULT_ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge-copilot/.assembled"
96
+ if [ -z "${ENTWURF_COPILOT_ASM:-}" ]; then
97
+ die "--assemble-only requires an explicit ENTWURF_COPILOT_ASM (a temp dir): this gate-only path writes no ownership state and must not rebuild the live assembly."
98
+ fi
99
+ if [ "$(python3 -c 'import os,sys;print(os.path.abspath(sys.argv[1]))' "$ENTWURF_COPILOT_ASM")" = \
100
+ "$(python3 -c 'import os,sys;print(os.path.abspath(sys.argv[1]))' "$DEFAULT_ASM")" ]; then
101
+ die "--assemble-only refuses the DEFAULT live assembly path ($DEFAULT_ASM): pass a temp ENTWURF_COPILOT_ASM instead."
102
+ fi
103
+ fi
104
+
105
+ # The shipped plugin version travels into the ownership state so the post-install list
106
+ # evidence and the doctor can join on it.
107
+ PLUGIN_VERSION="$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" "$SRC/$PLUGIN/.claude-plugin/plugin.json")" \
108
+ || die "could not read the shipped plugin version from $SRC/$PLUGIN/.claude-plugin/plugin.json"
109
+ # C3a amendment (B defect 4): the version rides space-separated state facts and exact
110
+ # list rows, so a shipped version carrying whitespace/control (or nothing at all)
111
+ # would truncate into a fabricated value downstream. Refuse it at the source.
112
+ case "$PLUGIN_VERSION" in
113
+ ""|*[![:graph:]]*) die "shipped plugin version ${PLUGIN_VERSION:-<empty>} is empty or carries whitespace/control characters — it must be one printable token (it travels through space-separated fact transports and exact list rows)." ;;
114
+ esac
115
+
116
+ # Atomic state writer: regular file, temp+rename, exact schema. `ownedMarketplace`
117
+ # flips false→true around the marketplace add so a partial failure leaves an honest
118
+ # record of what is actually owned.
119
+ write_state() { # $1 = ownedMarketplace (true|false)
120
+ mkdir -p "$STATE_DIR"
121
+ STATE_FILE_ENV="$STATE_FILE" QUALIFIED_ENV="$QUALIFIED" MKT_ENV="$MKT_NAME" ASM_ENV="$ASM" \
122
+ PLUGIN_VERSION_ENV="$PLUGIN_VERSION" OWNED_MKT_ENV="$1" python3 - <<'PY' || die "could not write the ownership state at $STATE_FILE"
123
+ import json, os, tempfile
124
+ state_file = os.environ["STATE_FILE_ENV"]
125
+ state = {
126
+ "schemaVersion": 1,
127
+ "qualifiedId": os.environ["QUALIFIED_ENV"],
128
+ "marketplaceName": os.environ["MKT_ENV"],
129
+ "assemblyPath": os.path.abspath(os.environ["ASM_ENV"]),
130
+ "pluginVersion": os.environ["PLUGIN_VERSION_ENV"],
131
+ "ownedMarketplace": os.environ["OWNED_MKT_ENV"] == "true",
132
+ "ownedAssembly": True,
133
+ "installedAt": __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
134
+ }
135
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(state_file), prefix=".install-state.")
136
+ with os.fdopen(fd, "w") as fh:
137
+ json.dump(state, fh, indent=2)
138
+ fh.write("\n")
139
+ os.replace(tmp, state_file)
140
+ PY
141
+ }
142
+
143
+ # ── 0. vendor ownership preflight (READ-ONLY; #86 C3a) ────────────────────────
144
+ # Every destructive vendor step below is licensed HERE, from `plugin list` +
145
+ # `plugin marketplace list` plus the ownership state — a failing list is UNKNOWN and
146
+ # refuses (an unknown host is not an empty one), a same-named marketplace at another
147
+ # path is someone else's and refuses, and a no-state host adopts an existing exact
148
+ # QUALIFIED only when the listed marketplace path is OUR effective assembly AND that
149
+ # assembly passes the doctor's structural oracle. Refusals here are zero-write: no
150
+ # assembly rebuild, no state, no vendor mutation.
151
+ MKT_PREREGISTERED=0
152
+ QUALIFIED_LISTED=0
153
+ INSTALLED_LIST=""
154
+ if [ "$ASSEMBLE_ONLY" -eq 0 ]; then
155
+ if ! INSTALLED_LIST="$(copilot plugin list 2>/dev/null)"; then
156
+ die "'copilot plugin list' failed, so this host's installed plugins are UNKNOWN — refusing before any write. Fix the Copilot CLI error and re-run."
157
+ fi
158
+ if ! MKT_LIST="$(copilot plugin marketplace list 2>/dev/null)"; then
159
+ die "'copilot plugin marketplace list' failed, so this host's marketplaces are UNKNOWN — refusing before any write. Fix the Copilot CLI error and re-run."
160
+ fi
161
+ # EXACT row membership (amendment): a substring match would let `$QUALIFIED-extra`
162
+ # or `prefix-$QUALIFIED` from someone else authorize the adoption/uninstall branches.
163
+ # ANY exact version is accepted here — a reinstall over an older shipped version is
164
+ # exactly the bounded upgrade path (the version is recorded below and the sequence
165
+ # still runs the loud exact uninstall first) — but a malformed or ambiguous listing
166
+ # is something nobody may act on and refuses.
167
+ LISTED_ROW="$(copilot_exact_row_version "$INSTALLED_LIST" "$QUALIFIED")" \
168
+ || die "the 'copilot plugin list' rows for $QUALIFIED are malformed or ambiguous (see above) — refusing before any write."
169
+ if [ "$LISTED_ROW" != "absent" ]; then
170
+ QUALIFIED_LISTED=1
171
+ echo "[copilot-bridge-install] $QUALIFIED currently listed at ${LISTED_ROW#one } (shipped: v$PLUGIN_VERSION) — bounded upgrade via loud exact uninstall"
172
+ fi
173
+ # ONE marketplace-row grammar (C3a amendment, B defects 1+2): the shared oracle
174
+ # parses the measured `<name> (Local: <abs path>)` form, refuses a non-Local or
175
+ # garbled row, and refuses DUPLICATE same-named rows instead of silently taking the
176
+ # first — a second marketplace with our name could otherwise steer every path check
177
+ # at the wrong target. The exact registered path is what ownership joins on.
178
+ MKT_ROW="$(copilot_marketplace_local_path "$MKT_LIST" "$MKT_NAME")" \
179
+ || die "the 'copilot plugin marketplace list' rows for '$MKT_NAME' are malformed, non-Local, or duplicated (see above) — refusing before any write."
180
+ if [ "$MKT_ROW" != "absent" ]; then
181
+ MKT_PREREGISTERED=1
182
+ MKT_PATH="${MKT_ROW#one }"
183
+ if [ "$MKT_PATH" != "$ASM" ]; then
184
+ die "a marketplace named '$MKT_NAME' is registered at '$MKT_PATH', not at this install's assembly ($ASM). That registration is not provably ours — zero writes. Inspect with 'copilot plugin marketplace list' and remove it manually if it is stale."
185
+ fi
186
+ fi
187
+
188
+ if [ -L "$STATE_FILE" ]; then
189
+ die "ownership state $STATE_FILE is a symlink — refusing to trust or overwrite it."
190
+ fi
191
+ if [ -f "$STATE_FILE" ]; then
192
+ # The ONE fail-closed schema/constants/binding validator, shared with the inverse
193
+ # and the doctor (copilot_state_read in the oracle) — exact keyset, exact types,
194
+ # no flag coercion. pluginVersion equality with the shipped version is deliberately
195
+ # NOT required here: a state written by an older shipped version is exactly the
196
+ # upgrade-reinstall case.
197
+ copilot_state_read "$STATE_FILE" "$QUALIFIED" "$MKT_NAME" "$ASM" >/dev/null \
198
+ || die "ownership state $STATE_FILE is corrupt or names a different installation (see above) — refusing with zero writes. Inspect it, or remove it manually if it is stale."
199
+ echo "[copilot-bridge-install] ownership state matches this installation (reinstall/repair)"
200
+ else
201
+ # No state. Adoption is EXPLICIT and narrow; anything else that already exists and
202
+ # cannot be proven ours refuses with zero writes.
203
+ if [ "$QUALIFIED_LISTED" -eq 1 ]; then
204
+ if [ "$MKT_PREREGISTERED" -eq 1 ] && copilot_assembly_valid "$ASM" "$PLUGIN"; then
205
+ echo "[copilot-bridge-install] adopting the legacy no-state installation ($QUALIFIED at $ASM; structural oracle green)"
206
+ else
207
+ die "'$QUALIFIED' is installed but carries no ownership state and its assembly/marketplace does not prove it ours (marketplace at ASM: $MKT_PREREGISTERED; structural oracle above). Refusing with zero writes — inspect 'copilot plugin list' / '$ASM' manually."
208
+ fi
209
+ elif [ "$MKT_PREREGISTERED" -eq 1 ]; then
210
+ if copilot_assembly_valid "$ASM" "$PLUGIN"; then
211
+ echo "[copilot-bridge-install] adopting the legacy marketplace registration ($MKT_NAME at $ASM, plugin not installed; structural oracle green)"
212
+ else
213
+ die "marketplace '$MKT_NAME' is registered at this install's assembly path but that assembly fails the structural oracle (see above) and no ownership state exists. Refusing with zero writes — remove the marketplace manually ('copilot plugin marketplace remove $MKT_NAME'), then re-run."
214
+ fi
215
+ fi
216
+ # fresh host (both absent): proceed.
217
+ fi
218
+ fi
219
+
80
220
  # Same install-shape split as the Claude installer: an installed package lives below
81
221
  # node_modules where Node refuses strip-types on `.ts`, so it runs the tsc-emitted
82
222
  # closure; a dev clone (and the XDG artifact) runs the `.ts` source.
@@ -179,22 +319,21 @@ if [ "$ASSEMBLE_ONLY" -eq 1 ]; then
179
319
  exit 0
180
320
  fi
181
321
 
322
+ # Ownership state BEFORE any vendor mutation (#86 C3a): once the complete assembly is
323
+ # published, the state exists so every partial vendor failure below leaves a repair and
324
+ # inverse authority behind. `ownedMarketplace` starts true only when the preflight
325
+ # matched/adopted the exact registration; otherwise it flips true after the add. The
326
+ # state is never deleted on a later failure — rerunning this installer is the repair.
327
+ if [ "$MKT_PREREGISTERED" -eq 1 ]; then write_state true; else write_state false; fi
328
+ echo "[copilot-bridge-install] ownership state written: $STATE_FILE"
329
+
182
330
  # --- 2. retire the stale Claude unit from Copilot ---------------------------
183
331
  # It is ours, it fires on every Copilot prompt, and it exits 1 before node starts.
184
332
  # Leaving it installed means the operator keeps paying for a hook that cannot work.
333
+ # The installed-plugin list was captured (and its failure refused) by the shared
334
+ # read-only preflight above — an unknown list never reaches this branch.
335
+ # INSTALL-ONLY territory: the inverse never touches this unit.
185
336
  if [ "$KEEP_STALE" -eq 0 ]; then
186
- # ASK FIRST, then act. Treating every nonzero uninstall as "not present" would report
187
- # a CLI, config or permission failure as an absence and then install on top of a unit
188
- # that is still firing (cross-review, terra). `copilot plugin list` prints the
189
- # QUALIFIED id, which is also what distinguishes our unit from a same-named plugin
190
- # somebody else installed from another marketplace.
191
- # The LIST failing is not an empty list. `|| true` would hand the "nothing installed"
192
- # arm a broken, unauthenticated or permission-denied Copilot CLI and then install on
193
- # top of whatever is really there (cross-review, terra). The assignment IS the
194
- # condition so `set -e` cannot kill us before we can say why.
195
- if ! INSTALLED_LIST="$(copilot plugin list 2>/dev/null)"; then
196
- die "'copilot plugin list' failed, so this host's installed plugins are UNKNOWN. An unknown list is not an empty one — the stale Claude unit may still be firing. Fix the Copilot CLI error, or re-run with --keep-stale-claude-unit to skip this check deliberately."
197
- fi
198
337
  case "$INSTALLED_LIST" in
199
338
  *"$STALE_CLAUDE_UNIT"*)
200
339
  copilot plugin uninstall "$STALE_CLAUDE_UNIT" >/dev/null \
@@ -208,17 +347,38 @@ if [ "$KEEP_STALE" -eq 0 ]; then
208
347
  fi
209
348
 
210
349
  # --- 3. (re)register the marketplace and install ----------------------------
211
- copilot plugin uninstall "$QUALIFIED" >/dev/null 2>&1 || true
212
- copilot plugin marketplace remove "$MKT_NAME" >/dev/null 2>&1 || true
213
- copilot plugin marketplace add "$ASM" >/dev/null || die "'copilot plugin marketplace add $ASM' failed."
214
- copilot plugin install "$QUALIFIED" >/dev/null || die "'copilot plugin install $QUALIFIED' failed."
350
+ # LOUD, in a bounded order, licensed by the preflight (#86 C3a): the old blind
351
+ # `|| true` pair could swallow a real vendor failure and then report a working
352
+ # install. Each step now acts only when the preflight saw its subject, and a failure
353
+ # is an honest partial failure state and assembly stay for the rerun-repair.
354
+ # `--force` is forbidden: `marketplace remove --force` uninstalls that marketplace's
355
+ # plugins as a side effect (measured 2026-08-27), and no entwurf writer takes that
356
+ # shortcut.
357
+ if [ "$QUALIFIED_LISTED" -eq 1 ]; then
358
+ copilot plugin uninstall "$QUALIFIED" >/dev/null \
359
+ || die "'copilot plugin uninstall $QUALIFIED' failed — honest partial failure; state and assembly are retained, re-run to repair."
360
+ fi
361
+ if [ "$MKT_PREREGISTERED" -eq 1 ]; then
362
+ copilot plugin marketplace remove "$MKT_NAME" >/dev/null \
363
+ || die "'copilot plugin marketplace remove $MKT_NAME' failed (never retried with --force) — honest partial failure; state and assembly are retained, re-run to repair."
364
+ fi
365
+ copilot plugin marketplace add "$ASM" >/dev/null || die "'copilot plugin marketplace add $ASM' failed — state and assembly are retained, re-run to repair."
366
+ write_state true
367
+ copilot plugin install "$QUALIFIED" >/dev/null || die "'copilot plugin install $QUALIFIED' failed — state and assembly are retained, re-run to repair."
215
368
 
216
369
  # --- 4. evidence ------------------------------------------------------------
217
370
  echo "--- copilot plugin list ---"
218
- # The qualified id, not the bare name: a bare grep would be satisfied by a same-named
219
- # plugin from another marketplace and report an install that never happened.
220
- copilot plugin list 2>/dev/null | grep -F "$QUALIFIED" || die "post-install: $QUALIFIED is not in 'copilot plugin list' (install did not take)."
371
+ # The qualified id AND the shipped version as an EXACT row: a bare-name or substring
372
+ # grep would be satisfied by a same-named plugin from another marketplace, a stale
373
+ # version this run did not install, or a longer id that merely contains ours (list
374
+ # shape measured on copilot 1.0.80).
375
+ POST_LIST="$(copilot plugin list 2>/dev/null)" || die "post-install: 'copilot plugin list' failed — the install evidence is UNKNOWN."
376
+ POST_ROW="$(copilot_exact_row_version "$POST_LIST" "$QUALIFIED")" \
377
+ || die "post-install: the 'copilot plugin list' rows for $QUALIFIED are malformed or ambiguous (see above)."
378
+ [ "$POST_ROW" = "one $PLUGIN_VERSION" ] \
379
+ || die "post-install: want exactly one '$QUALIFIED (v$PLUGIN_VERSION)' row, got '$POST_ROW' (install did not take)."
380
+ echo "$QUALIFIED (v$PLUGIN_VERSION) registered"
221
381
  echo
222
382
  echo "[copilot-bridge-install] DONE. A Copilot session becomes a citizen on its FIRST PROMPT,"
223
383
  echo "not when the window opens — sessionStart is deferred to the first prompt (measured)."
224
- echo "Verify with: ./run.sh doctor-copilot-bridge"
384
+ echo "Verify with: ./run.sh doctor-copilot-bridge · inverse: ./run.sh uninstall-copilot-bridge"