@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.
Files changed (87) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +149 -0
  3. package/DELIVERY.md +72 -59
  4. package/README.md +120 -20
  5. package/VERIFY.md +10 -7
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +2 -2
  8. package/docs/external-mcp-host.md +26 -4
  9. package/docs/setup-clean-host.md +118 -18
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
  11. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
  17. package/mcp/entwurf-bridge/src/index.ts +10 -6
  18. package/mcp/entwurf-bridge/tsconfig.build.json +2 -1
  19. package/package.json +22 -11
  20. package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
  21. package/pi/entwurf-capabilities.json +1 -0
  22. package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
  23. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
  24. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
  25. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
  26. package/pi-extensions/entwurf-control.ts +17 -8
  27. package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
  28. package/pi-extensions/lib/meta-sender-identity.ts +10 -2
  29. package/pi-extensions/lib/meta-session.ts +102 -29
  30. package/pi-extensions/lib/mux-fresh-call.ts +82 -11
  31. package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
  32. package/run.sh +1079 -186
  33. package/scripts/agy-bridge.sh +4 -2
  34. package/scripts/check-copilot-birth-hook.ts +1174 -0
  35. package/scripts/check-copilot-launch.ts +395 -0
  36. package/scripts/check-copilot-receive-arm.ts +734 -0
  37. package/scripts/check-copilot-statusline.ts +122 -0
  38. package/scripts/check-entwurf-capabilities.ts +25 -6
  39. package/scripts/check-fresh-cut-gate.sh +104 -27
  40. package/scripts/check-gate-qualification.ts +10 -1
  41. package/scripts/check-install-container.sh +2 -2
  42. package/scripts/check-install-surface.ts +1 -1
  43. package/scripts/check-meta-capability-source.ts +89 -5
  44. package/scripts/check-meta-doctor-oracle.sh +26 -0
  45. package/scripts/check-meta-manifest-schema.py +38 -0
  46. package/scripts/check-meta-receiver-marker.ts +25 -2
  47. package/scripts/check-meta-session.ts +32 -0
  48. package/scripts/check-setup-qualification.sh +126 -0
  49. package/scripts/copilot-bridge-doctor.sh +242 -0
  50. package/scripts/copilot-bridge-install.sh +384 -0
  51. package/scripts/copilot-bridge-oracle.sh +223 -0
  52. package/scripts/copilot-bridge-uninstall.sh +137 -0
  53. package/scripts/copilot-launch.sh +236 -0
  54. package/scripts/copilot-mcp-bridge.sh +177 -0
  55. package/scripts/copilot-mcp-config.py +221 -0
  56. package/scripts/copilot-receive-bridge.sh +483 -0
  57. package/scripts/copilot-statusline-bridge.sh +149 -0
  58. package/scripts/copilot-statusline-config.py +201 -0
  59. package/scripts/copilot-statusline.sh +68 -0
  60. package/scripts/dev-bin.sh +53 -9
  61. package/scripts/fake-copilot-vendor.sh +67 -0
  62. package/scripts/meta-bridge-hook-log.sh +17 -5
  63. package/scripts/mutants/capability-cache.json +64 -0
  64. package/scripts/mutants/copilot-birth.json +244 -0
  65. package/scripts/mutants/copilot-launch.json +187 -0
  66. package/scripts/mutants/copilot-receive.json +263 -0
  67. package/scripts/mutants/fresh-cut.json +17 -0
  68. package/scripts/mutants/mux-fresh-call.json +216 -2
  69. package/scripts/mutants/pack-install.json +17 -0
  70. package/scripts/mutants/pi-package-ownership.json +92 -0
  71. package/scripts/mutants/setup-verdict.json +148 -0
  72. package/scripts/mutants/source-install.json +32 -0
  73. package/scripts/pi_settings_io.py +22 -0
  74. package/scripts/raw-async-delivery/README.md +170 -125
  75. package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
  76. package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
  77. package/scripts/register-pi-package.py +525 -7
  78. package/scripts/register-pi-provider.py +162 -12
  79. package/scripts/smoke-agy-install-state.sh +86 -30
  80. package/scripts/smoke-agy-statusline-state.sh +9 -6
  81. package/scripts/smoke-copilot-mcp-state.sh +153 -0
  82. package/scripts/smoke-copilot-statusline-state.sh +131 -0
  83. package/scripts/smoke-mux-fresh-call-live.ts +2 -0
  84. package/scripts/smoke-mux-lifecycle-live.ts +3 -1
  85. package/scripts/smoke-setup-verdict.sh +291 -0
  86. package/scripts/smoke-user-scope-citizen.sh +388 -1
  87. package/scripts/tsconfig.json +1 -0
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env bash
2
+ # copilot-bridge-install.sh — operator-grade global installer for the Copilot BIRTH
3
+ # plugin (#82). The Copilot sibling of meta-bridge-install.sh, deliberately NOT a
4
+ # mode of it.
5
+ #
6
+ # WHY A SEPARATE INSTALLER (§4-5, and one measured blocker)
7
+ #
8
+ # 1. The Claude installer assembles ONE marketplace root and copies exactly one
9
+ # plugin out of it (`cp -r "$SRC/$PLUGIN"`). Adding the Copilot unit to that
10
+ # same marketplace.json would publish a manifest whose second `source` is not
11
+ # in the assembly, and `claude plugin validate` runs over that root before any
12
+ # user config is touched. Two marketplace roots, two assemblies (cross-review,
13
+ # terra, 2026-08-20).
14
+ # 2. The Claude doctor certifies Claude's own receiver marker and
15
+ # FileChanged/asyncRewake doorbell. Copilot's receiver is a forked first-party
16
+ # extension with its own installer and its own doctor. Sharing a doctor would
17
+ # make one rail certify evidence owned by another.
18
+ #
19
+ # WHAT THIS INSTALLS. One plugin whose managed job is to mint a meta-record on the
20
+ # first prompt of a Copilot session and name that citizen as this host's sender. MCP
21
+ # wiring has its own installer, and so does the RECEIVER: the doorbell is a forked
22
+ # first-party extension, installed by `run.sh install-copilot-receive`, which also owns
23
+ # the launch-flag check. Four surfaces, four installers, four failure modes.
24
+ #
25
+ # Platform: Linux only, same fence as the Claude installer.
26
+ set -euo pipefail
27
+
28
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
29
+ REPO="$(cd "$HERE/.." && pwd)"
30
+ MKT_NAME="meta-bridge-copilot-local"
31
+ PLUGIN="entwurf-meta-receive-copilot"
32
+ SRC="$REPO/pi/meta-bridge-copilot"
33
+ # `ENTWURF_COPILOT_ASM` exists for ONE caller: check-copilot-birth-hook drives this
34
+ # script for real, into a temp dir, with --assemble-only. A gate that re-implemented
35
+ # the bake would be testing its own copy of the logic instead of the shipped one.
36
+ ASM="${ENTWURF_COPILOT_ASM:-${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge-copilot/.assembled}"
37
+ # The Claude unit that is currently installed INTO Copilot. It fires there and then
38
+ # dies in hook-launch.sh's no-argv refusal on every prompt, because Copilot's schema
39
+ # has no `args` (measured: that refusal is why copilot held 0 of 409 meta-records).
40
+ #
41
+ # QUALIFIED, never bare. `copilot plugin uninstall` accepts `plugin-name` or
42
+ # `plugin-name@marketplace-name` (measured 2026-08-21 from `plugin uninstall --help`),
43
+ # and `copilot plugin list` prints the qualified id. A bare name would also match a
44
+ # same-named plugin from somebody else's marketplace — this installer may only remove
45
+ # the unit THIS repo installed (cross-review, terra).
46
+ STALE_CLAUDE_UNIT="entwurf-meta-receive@meta-bridge-local"
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"
54
+ KEEP_STALE=0
55
+ # --assemble-only stops after the assembly, before the Copilot CLI is touched. It is
56
+ # how the gate reaches the real bake on a host (and in CI) that has no Copilot.
57
+ ASSEMBLE_ONLY=0
58
+
59
+ die() { echo "[copilot-bridge-install] $*" >&2; exit 1; }
60
+
61
+ for arg in "$@"; do
62
+ case "$arg" in
63
+ --keep-stale-claude-unit) KEEP_STALE=1 ;;
64
+ --assemble-only) ASSEMBLE_ONLY=1 ;;
65
+ *) die "unknown argument: $arg (--keep-stale-claude-unit, --assemble-only)" ;;
66
+ esac
67
+ done
68
+
69
+ [ "$(uname -s)" = "Linux" ] || die "Linux only; $(uname -s) is not a certified axis for this install."
70
+ if [ "$ASSEMBLE_ONLY" -eq 0 ]; then
71
+ command -v copilot >/dev/null 2>&1 || die "the 'copilot' CLI is not on PATH."
72
+ fi
73
+
74
+ # PREFLIGHT, and all of it BEFORE the first destructive step. The assembly is removed
75
+ # and rebuilt in place further down; discovering a missing python3 or an under-floor
76
+ # node AFTER that would leave the operator with no assembly at all (cross-review, terra).
77
+ command -v python3 >/dev/null 2>&1 || die "python3 is required (both bakes run through it) and is not on PATH."
78
+ NODE_BIN="$(command -v node)" || die "node is not on PATH."
79
+ [ -x "$NODE_BIN" ] || die "resolved node is not executable: $NODE_BIN"
80
+ NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
81
+ # The dev-clone shape bakes the raw `.ts` entry and relies on strip-types, so an
82
+ # under-floor node does not fail at install — it fails at the operator's first prompt,
83
+ # inside Copilot, where nothing surfaces it. Refuse here instead.
84
+ [ "$NODE_MAJOR" -ge 24 ] 2>/dev/null || die "node >= 24 is required (package engines); resolved $NODE_BIN reports major '$NODE_MAJOR'."
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
+
220
+ # Same install-shape split as the Claude installer: an installed package lives below
221
+ # node_modules where Node refuses strip-types on `.ts`, so it runs the tsc-emitted
222
+ # closure; a dev clone (and the XDG artifact) runs the `.ts` source.
223
+ case "$REPO" in
224
+ */node_modules/@junghanacs/entwurf)
225
+ HOOK_ENTRY="meta-bridge-hook-copilot.js"
226
+ HOOK_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js"
227
+ LIB_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
228
+ LIB_EXT="js" ;;
229
+ *)
230
+ HOOK_ENTRY="meta-bridge-hook-copilot.ts"
231
+ HOOK_SRC="$REPO/pi-extensions/meta-bridge-hook-copilot.ts"
232
+ LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
233
+ LIB_EXT="ts" ;;
234
+ esac
235
+
236
+ # --- 1. assemble a self-contained, baked plugin -----------------------------
237
+ [ -f "$HOOK_SRC" ] || die "hook artifact missing: $HOOK_SRC (run 'pnpm run build-bridge' in a dev clone, or reinstall the package)."
238
+ [ -f "$LIB_SRC" ] || die "hook lib artifact missing: $LIB_SRC (same build-bridge dist closure)."
239
+
240
+ # ATOMIC: assemble beside the live artifact and swap only once it is complete. The
241
+ # previous shape removed the live assembly first, so any failure between there and the
242
+ # marketplace re-registration left the INSTALLED plugin pointing at a launcher that no
243
+ # longer existed — a working hook broken by a failed upgrade (cross-review, terra).
244
+ STAGE="$ASM.staging.$$"
245
+ rm -rf "$STAGE"
246
+ trap 'rm -rf "$STAGE"' EXIT
247
+ mkdir -p "$STAGE"
248
+ cp -r "$SRC/.claude-plugin" "$STAGE/.claude-plugin"
249
+ cp -r "$SRC/$PLUGIN" "$STAGE/$PLUGIN"
250
+ cp "$HOOK_SRC" "$STAGE/$PLUGIN/$HOOK_ENTRY"
251
+ mkdir -p "$STAGE/$PLUGIN/lib"
252
+ cp "$LIB_SRC" "$STAGE/$PLUGIN/lib/meta-session.$LIB_EXT"
253
+ cp "$REPO/pi-extensions/lib/session-id.js" "$STAGE/$PLUGIN/lib/session-id.js"
254
+ # The capability registry must travel at the plugin ROOT: metaCapabilitiesFilePath()
255
+ # resolves it via `../` from lib/ in the bundle layout. Without it every mint throws.
256
+ cp "$REPO/pi/entwurf-capabilities.json" "$STAGE/$PLUGIN/entwurf-capabilities.json"
257
+
258
+ LAUNCHER="$STAGE/$PLUGIN/scripts/copilot-hook-launch.sh"
259
+ # What gets BAKED into hooks.json is the FINAL path, not the staging one: the manifest
260
+ # has to name where the launcher will live after the swap.
261
+ FINAL_LAUNCHER="$ASM/$PLUGIN/scripts/copilot-hook-launch.sh"
262
+ chmod +x "$LAUNCHER"
263
+
264
+ # Bake node + entry into the LAUNCHER (not into hooks.json): Copilot's `exec` is a
265
+ # single string with no argv beside it, so the payload coordinates cannot ride the
266
+ # manifest the way Claude's `args` array carries them.
267
+ LAUNCHER_PATH="$LAUNCHER" NODE_PATH_TO_BAKE="$NODE_BIN" HOOK_ENTRY_TO_BAKE="$HOOK_ENTRY" python3 - <<'PY'
268
+ from pathlib import Path
269
+ import os
270
+ launcher = Path(os.environ["LAUNCHER_PATH"])
271
+ node = os.environ["NODE_PATH_TO_BAKE"]
272
+ hook_entry = os.environ["HOOK_ENTRY_TO_BAKE"]
273
+ text = launcher.read_text(encoding="utf-8")
274
+ for placeholder in ("__NODE_BIN__", "__HOOK_ENTRY__"):
275
+ if placeholder not in text:
276
+ raise SystemExit(f"launcher bake failed before replacement ({placeholder} absent in {launcher})")
277
+ launcher.write_text(text.replace("__NODE_BIN__", node).replace("__HOOK_ENTRY__", hook_entry), encoding="utf-8")
278
+ PY
279
+ for placeholder in "__NODE_BIN__" "__HOOK_ENTRY__"; do
280
+ grep -q "$placeholder" "$LAUNCHER" && die "launcher bake failed ($placeholder still present in $LAUNCHER)."
281
+ done
282
+
283
+ # Bake the launcher's ABSOLUTE path into hooks.json. `${COPILOT_PLUGIN_ROOT}` and
284
+ # `${CLAUDE_PLUGIN_ROOT}` both appear as recognized names in the shipped runtime, but
285
+ # whether an `exec` string is substituted was never measured — and the assembly path
286
+ # is stable and outside the checkout, so baking needs no such bet.
287
+ HOOKS="$STAGE/$PLUGIN/hooks/hooks.json"
288
+ HOOKS_PATH="$HOOKS" LAUNCHER_TO_BAKE="$FINAL_LAUNCHER" python3 - <<'PY'
289
+ from pathlib import Path
290
+ import os
291
+ hooks = Path(os.environ["HOOKS_PATH"])
292
+ launcher = os.environ["LAUNCHER_TO_BAKE"]
293
+ text = hooks.read_text(encoding="utf-8")
294
+ if "__COPILOT_LAUNCHER__" not in text:
295
+ raise SystemExit(f"hooks bake failed before replacement (__COPILOT_LAUNCHER__ absent in {hooks})")
296
+ hooks.write_text(text.replace("__COPILOT_LAUNCHER__", launcher), encoding="utf-8")
297
+ PY
298
+ grep -q "__COPILOT_LAUNCHER__" "$HOOKS" && die "hooks bake failed (__COPILOT_LAUNCHER__ still present in $HOOKS)."
299
+
300
+ # SWAP. Move the old assembly aside rather than deleting it, so a failed rename can be
301
+ # undone; only then drop it.
302
+ PREV=""
303
+ if [ -d "$ASM" ]; then
304
+ PREV="$ASM.previous.$$"
305
+ mv "$ASM" "$PREV" || die "could not move the previous assembly aside ($ASM)."
306
+ fi
307
+ mkdir -p "$(dirname "$ASM")"
308
+ if ! mv "$STAGE" "$ASM"; then
309
+ [ -n "$PREV" ] && mv "$PREV" "$ASM"
310
+ die "could not publish the staged assembly into $ASM (previous assembly restored)."
311
+ fi
312
+ trap - EXIT
313
+ [ -n "$PREV" ] && rm -rf "$PREV"
314
+
315
+ echo "[copilot-bridge-install] assembled $ASM (birth plugin only; MCP and receive are separate surfaces)"
316
+
317
+ if [ "$ASSEMBLE_ONLY" -eq 1 ]; then
318
+ echo "[copilot-bridge-install] --assemble-only: stopping before the Copilot CLI is touched"
319
+ exit 0
320
+ fi
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
+
330
+ # --- 2. retire the stale Claude unit from Copilot ---------------------------
331
+ # It is ours, it fires on every Copilot prompt, and it exits 1 before node starts.
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.
336
+ if [ "$KEEP_STALE" -eq 0 ]; then
337
+ case "$INSTALLED_LIST" in
338
+ *"$STALE_CLAUDE_UNIT"*)
339
+ copilot plugin uninstall "$STALE_CLAUDE_UNIT" >/dev/null \
340
+ || die "the stale Claude unit '$STALE_CLAUDE_UNIT' is installed in Copilot but could not be removed. It fires on every prompt and exits 1 before node starts, so installing on top of it would leave that noise in place. Fix the Copilot CLI error above, or re-run with --keep-stale-claude-unit to proceed deliberately."
341
+ echo "[copilot-bridge-install] removed the stale Claude unit '$STALE_CLAUDE_UNIT' from Copilot (it exits 1 on every prompt there)"
342
+ ;;
343
+ *)
344
+ echo "[copilot-bridge-install] stale Claude unit '$STALE_CLAUDE_UNIT' is not installed in Copilot (nothing to remove)"
345
+ ;;
346
+ esac
347
+ fi
348
+
349
+ # --- 3. (re)register the marketplace and install ----------------------------
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."
368
+
369
+ # --- 4. evidence ------------------------------------------------------------
370
+ echo "--- copilot plugin list ---"
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"
381
+ echo
382
+ echo "[copilot-bridge-install] DONE. A Copilot session becomes a citizen on its FIRST PROMPT,"
383
+ echo "not when the window opens — sessionStart is deferred to the first prompt (measured)."
384
+ echo "Verify with: ./run.sh doctor-copilot-bridge · inverse: ./run.sh uninstall-copilot-bridge"
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env bash
2
+ # copilot-bridge-oracle.sh — the ONE structural oracle for an assembled Copilot birth
3
+ # unit (#86 C3a). Sourced by the installer (adoption preflight) and the doctor
4
+ # (ownership axis) so "valid assembly" cannot mean two different things: a unit the
5
+ # doctor would flag must never be silently adopted as ours.
6
+ #
7
+ # copilot_assembly_valid <asm-root> <plugin-name>
8
+ # rc 0 = the assembly at <asm-root> is a complete, baked unit
9
+ # rc 1 = it is not; the first failing reason is printed to stderr
10
+ #
11
+ # copilot_state_read <state_file> <qualified> <mkt_name> <asm>
12
+ # Fail-closed EXACT-schema reader for the ownership state — one validator for the
13
+ # installer, the inverse and the doctor so "valid state" cannot drift into three
14
+ # copies. rc 0 prints `<ownedMarketplace> <ownedAssembly> <pluginVersion>`; any
15
+ # missing/unknown key, wrong type, empty string, relative assemblyPath, constant
16
+ # drift or effective-ASM mismatch is rc 1 with the reason on stderr. Flags are never
17
+ # coerced: a missing or non-boolean owned* refuses instead of defaulting to false.
18
+ #
19
+ # copilot_marketplace_local_path <mkt-list-text> <mkt-name>
20
+ # The one exact marketplace-row grammar (see its own header below): absent /
21
+ # exactly-one Local path / malformed-non-Local-or-duplicate refusal.
22
+ #
23
+ # Deliberately BOUNDED: structure, one state schema, one plugin list-row grammar, one
24
+ # marketplace list-row grammar. Registration, records, and hook-log truth stay with
25
+ # the doctor's own axes.
26
+ copilot_state_read() {
27
+ local state_file="$1" qualified="$2" mkt="$3" asm="$4"
28
+ STATE_FILE_ENV="$state_file" QUALIFIED_ENV="$qualified" MKT_ENV="$mkt" ASM_ENV="$asm" python3 - <<'PY'
29
+ import json, os, sys
30
+ try:
31
+ state = json.load(open(os.environ["STATE_FILE_ENV"]))
32
+ except (OSError, json.JSONDecodeError) as err:
33
+ print(f"state parse failed: {err}", file=sys.stderr)
34
+ sys.exit(1)
35
+ EXACT_KEYS = {
36
+ "schemaVersion", "qualifiedId", "marketplaceName", "assemblyPath",
37
+ "pluginVersion", "ownedMarketplace", "ownedAssembly", "installedAt",
38
+ }
39
+ if not isinstance(state, dict) or set(state) != EXACT_KEYS:
40
+ print(f"state keyset drifted: {sorted(state) if isinstance(state, dict) else type(state).__name__} != {sorted(EXACT_KEYS)}", file=sys.stderr)
41
+ sys.exit(1)
42
+ if state["schemaVersion"] != 1:
43
+ print(f"state schemaVersion is {state['schemaVersion']!r}, want the literal 1", file=sys.stderr)
44
+ sys.exit(1)
45
+ for key in ("qualifiedId", "marketplaceName", "assemblyPath", "pluginVersion", "installedAt"):
46
+ if not isinstance(state[key], str) or not state[key]:
47
+ print(f"state {key} must be a nonempty string, got {state[key]!r}", file=sys.stderr)
48
+ sys.exit(1)
49
+ if any(ord(c) < 0x21 or ord(c) == 0x7f for c in state["pluginVersion"]):
50
+ # C3a amendment (B defect 4): these facts travel space-separated and consumers
51
+ # cut field 3, so a version carrying whitespace/control would silently truncate
52
+ # into a FABRICATED value (and a fabricated drift reason downstream). Refuse the
53
+ # state instead of shipping an ambiguous transport.
54
+ print(f"state pluginVersion contains whitespace/control characters: {state['pluginVersion']!r} — "
55
+ "the space-separated fact transport would truncate it into a fabricated version", file=sys.stderr)
56
+ sys.exit(1)
57
+ for key in ("ownedMarketplace", "ownedAssembly"):
58
+ if not isinstance(state[key], bool):
59
+ print(f"state {key} must be a boolean, got {state[key]!r} — flags are never coerced", file=sys.stderr)
60
+ sys.exit(1)
61
+ if not os.path.isabs(state["assemblyPath"]):
62
+ print(f"state assemblyPath must be absolute, got {state['assemblyPath']!r}", file=sys.stderr)
63
+ sys.exit(1)
64
+ for key, want in (("qualifiedId", os.environ["QUALIFIED_ENV"]), ("marketplaceName", os.environ["MKT_ENV"])):
65
+ if state[key] != want:
66
+ print(f"state {key} is {state[key]!r}, want {want!r}", file=sys.stderr)
67
+ sys.exit(1)
68
+ if state["assemblyPath"] != os.path.abspath(os.environ["ASM_ENV"]):
69
+ print(f"state assemblyPath is {state['assemblyPath']!r}, but this host's effective assembly is {os.path.abspath(os.environ['ASM_ENV'])!r}", file=sys.stderr)
70
+ sys.exit(1)
71
+ print(f"{'true' if state['ownedMarketplace'] else 'false'} {'true' if state['ownedAssembly'] else 'false'} {state['pluginVersion']}")
72
+ PY
73
+ }
74
+
75
+ # copilot_exact_row_version <list-text> <qualified>
76
+ # The finer read of the SAME row grammar (final amendment): reports whether the exact
77
+ # QUALIFIED is absent, present exactly once with a parsed nonempty version, or in a
78
+ # shape nobody may act on. rc 0 prints `absent` or `one <version>`; rc 1 with the
79
+ # reason on stderr for a MALFORMED exact row (claims our qualified id but does not
80
+ # parse as `<qualified> (v<nonempty>)`) or MULTIPLE exact rows. Longer tokens that
81
+ # merely contain the qualified id remain foreign and are ignored, same as above.
82
+ copilot_exact_row_version() {
83
+ local list_text="$1" qualified="$2"
84
+ LIST_TEXT_ENV="$list_text" QUALIFIED_ENV="$qualified" python3 - <<'PY'
85
+ import os, sys
86
+ qualified = os.environ["QUALIFIED_ENV"]
87
+ versions = []
88
+ for raw in os.environ["LIST_TEXT_ENV"].splitlines():
89
+ row = raw.strip()
90
+ for glyph in ("•", "◆", "-"):
91
+ if row.startswith(glyph):
92
+ row = row[len(glyph):].strip()
93
+ # C3a amendment (B defect 3): the claim test is the exact QUALIFIED as the row's
94
+ # first whitespace-delimited token. Every claiming row must then parse as the one
95
+ # measured form `<qualified> (v<nonempty>)` IN FULL — a truncated/garbled tail
96
+ # (e.g. `(v0.1` with no closing paren) used to fall through the old
97
+ # startswith/endswith pair and round to ABSENT, which the inverse reads as
98
+ # retry-safe absence. Malformed is malformed; nothing rounds to absent.
99
+ parts = row.split(None, 1)
100
+ if not parts or parts[0] != qualified:
101
+ continue # foreign row, including longer ids that merely contain ours
102
+ rest = parts[1] if len(parts) == 2 else ""
103
+ if not (rest.startswith("(v") and rest.endswith(")")):
104
+ print(f"malformed exact row for {qualified}: {row!r} does not parse as '<qualified> (v<version>)'", file=sys.stderr)
105
+ sys.exit(1)
106
+ version = rest[2:-1]
107
+ if not version:
108
+ print(f"malformed exact row for {qualified}: empty version in {row!r}", file=sys.stderr)
109
+ sys.exit(1)
110
+ if any(ord(c) < 0x21 or ord(c) == 0x7f for c in version):
111
+ # Same transport rule as the state validator: the parsed version rides
112
+ # space-separated verdicts (`one <version>`), so an embedded whitespace or
113
+ # control character is ambiguity, not a version.
114
+ print(f"malformed exact row for {qualified}: version {version!r} carries whitespace/control characters", file=sys.stderr)
115
+ sys.exit(1)
116
+ versions.append(version)
117
+ if len(versions) > 1:
118
+ print(f"multiple exact rows for {qualified}: versions {versions} — nobody may act on an ambiguous listing", file=sys.stderr)
119
+ sys.exit(1)
120
+ print(f"one {versions[0]}" if versions else "absent")
121
+ PY
122
+ }
123
+
124
+ # copilot_marketplace_local_path <mkt-list-text> <mkt-name>
125
+ # The ONE exact marketplace-row grammar (C3a amendment, B defects 1+2) — installer,
126
+ # inverse and doctor all read the `plugin marketplace list` output through THIS
127
+ # parser instead of three copy-pasted `grep|head -1` pipelines that silently took
128
+ # the first of several same-named rows. rc 0 prints `absent` or `one <local abs
129
+ # path>`; rc 1 with the reason on stderr when a row claims the exact name but does
130
+ # not parse as the measured `<name> (Local: <path>)` form (a non-Local source is
131
+ # not provably ours), or when MULTIPLE rows claim the name (duplicates are
132
+ # ambiguity nobody may act on — never "the first one"). Rows whose first token
133
+ # merely contains the name remain foreign and are ignored. Path-vs-assembly drift
134
+ # stays with the callers: this parser reports the exact registered path, and each
135
+ # surface names its own refusal when that path is not its assembly.
136
+ copilot_marketplace_local_path() {
137
+ local list_text="$1" mkt_name="$2"
138
+ LIST_TEXT_ENV="$list_text" MKT_NAME_ENV="$mkt_name" python3 - <<'PY'
139
+ import os, sys
140
+ name = os.environ["MKT_NAME_ENV"]
141
+ paths = []
142
+ for raw in os.environ["LIST_TEXT_ENV"].splitlines():
143
+ row = raw.strip()
144
+ for glyph in ("•", "◆", "-"):
145
+ if row.startswith(glyph):
146
+ row = row[len(glyph):].strip()
147
+ parts = row.split(None, 1)
148
+ if not parts or parts[0] != name:
149
+ continue # foreign row, including longer names that merely contain ours
150
+ rest = parts[1] if len(parts) == 2 else ""
151
+ if not (rest.startswith("(Local: ") and rest.endswith(")")):
152
+ print(f"malformed marketplace row for {name}: {row!r} does not parse as '<name> (Local: <path>)' — "
153
+ "a non-Local or garbled registration is not provably ours", file=sys.stderr)
154
+ sys.exit(1)
155
+ path = rest[len("(Local: "):-1]
156
+ if not path:
157
+ print(f"malformed marketplace row for {name}: empty Local path in {row!r}", file=sys.stderr)
158
+ sys.exit(1)
159
+ paths.append(path)
160
+ if len(paths) > 1:
161
+ print(f"multiple marketplace rows named {name}: paths {paths} — duplicates are ambiguity nobody may act on", file=sys.stderr)
162
+ sys.exit(1)
163
+ print(f"one {paths[0]}" if paths else "absent")
164
+ PY
165
+ }
166
+
167
+ copilot_assembly_valid() {
168
+ local asm="$1" plugin="$2"
169
+ local unit="$asm/$plugin"
170
+ local launcher="$unit/scripts/copilot-hook-launch.sh"
171
+ local hooks="$unit/hooks/hooks.json"
172
+ [ -d "$unit" ] || { echo "structural oracle: unit dir missing: $unit" >&2; return 1; }
173
+ [ -x "$launcher" ] || { echo "structural oracle: launcher missing or not executable: $launcher" >&2; return 1; }
174
+ if grep -q "__NODE_BIN__\|__HOOK_ENTRY__" "$launcher"; then
175
+ echo "structural oracle: launcher still carries an installer placeholder (unbaked): $launcher" >&2
176
+ return 1
177
+ fi
178
+ local baked_node
179
+ baked_node="$(sed -n 's/^NODE_BIN="\(.*\)"$/\1/p' "$launcher" | head -1)"
180
+ [ -n "$baked_node" ] && [ -x "$baked_node" ] || {
181
+ echo "structural oracle: baked node missing or not executable: ${baked_node:-(unparsed)}" >&2
182
+ return 1
183
+ }
184
+ local baked_entry
185
+ baked_entry="$(sed -n 's|^HOOK_ENTRY="\$PLUGIN_ROOT/\(.*\)"$|\1|p' "$launcher" | head -1)"
186
+ [ -n "$baked_entry" ] && [ -f "$unit/$baked_entry" ] || {
187
+ echo "structural oracle: hook entry missing beside the launcher: ${baked_entry:-(unparsed)}" >&2
188
+ return 1
189
+ }
190
+ [ -f "$unit/entwurf-capabilities.json" ] || {
191
+ echo "structural oracle: capability registry missing at the plugin root" >&2
192
+ return 1
193
+ }
194
+ [ -f "$hooks" ] || { echo "structural oracle: hooks.json missing: $hooks" >&2; return 1; }
195
+ if ! HOOKS_PATH="$hooks" ORACLE_LAUNCHER="$launcher" python3 - <<'PY'
196
+ import json, os, sys
197
+ from pathlib import Path
198
+ try:
199
+ hooks = json.loads(Path(os.environ["HOOKS_PATH"]).read_text(encoding="utf-8"))
200
+ except (OSError, json.JSONDecodeError) as err:
201
+ print(f"structural oracle: hooks.json unreadable: {err}", file=sys.stderr)
202
+ sys.exit(1)
203
+ launcher = os.environ["ORACLE_LAUNCHER"]
204
+ if hooks.get("version") != 1:
205
+ print("structural oracle: hooks.json version is not the literal 1", file=sys.stderr)
206
+ sys.exit(1)
207
+ events = hooks.get("hooks") or {}
208
+ if set(events) != {"sessionStart", "userPromptSubmitted"}:
209
+ print(f"structural oracle: hook events drifted: {sorted(events)}", file=sys.stderr)
210
+ sys.exit(1)
211
+ for name, entries in events.items():
212
+ for i, entry in enumerate(entries if isinstance(entries, list) else []):
213
+ exec_value = entry.get("exec")
214
+ if not isinstance(exec_value, str) or exec_value != launcher or "args" in entry:
215
+ print(f"structural oracle: hooks.{name}[{i}] is not the baked exec-string form", file=sys.stderr)
216
+ sys.exit(1)
217
+ sys.exit(0)
218
+ PY
219
+ then
220
+ return 1
221
+ fi
222
+ return 0
223
+ }