@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
@@ -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
+ }
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bash
2
+ # copilot-bridge-uninstall.sh — the package-owned INVERSE of copilot-bridge-install.sh
3
+ # (#86 C3a). Removes exactly what the ownership state records this package installed —
4
+ # the qualified plugin, the local marketplace registration, the assembly — and nothing
5
+ # else. The stale Claude unit is INSTALL-ONLY territory; this inverse never touches it.
6
+ #
7
+ # ORDER AND AUTHORITY (amendment: the ownership preflight is COMPLETE before any vendor
8
+ # mutation). The state is required and validated fail-closed against this package's
9
+ # constants and the effective assembly path; both vendor lists are read next and a
10
+ # failing list is UNKNOWN (an unknown host is not an empty one — refuse); then EVERY
11
+ # ownership and safety fact — marketplace name/path/recorded ownership, assembly path
12
+ # safety, exact plugin row presence — is decided read-only. Only after all of it passes
13
+ # may the first vendor write run. So a refusal, any refusal, is zero vendor/filesystem
14
+ # writes with the state retained. Vendor steps run before filesystem steps, the state
15
+ # is deleted LAST, and any failure stops with the state retained so a rerun can finish
16
+ # the job. `--force` is forbidden: `marketplace remove --force` uninstalls that
17
+ # marketplace's plugins as a side effect (measured 2026-08-27).
18
+ set -euo pipefail
19
+
20
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ MKT_NAME="meta-bridge-copilot-local"
22
+ PLUGIN="entwurf-meta-receive-copilot"
23
+ QUALIFIED="$PLUGIN@$MKT_NAME"
24
+ ASM="${ENTWURF_COPILOT_ASM:-${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge-copilot/.assembled}"
25
+ STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/copilot-bridge"
26
+ STATE_FILE="$STATE_DIR/install-state.json"
27
+
28
+ die() { echo "[copilot-bridge-uninstall] $*" >&2; exit 1; }
29
+
30
+ [ $# -eq 0 ] || die "unknown argument: $1 (this inverse takes no arguments; --force does not exist here)"
31
+ command -v copilot >/dev/null 2>&1 || die "the 'copilot' CLI is not on PATH."
32
+ command -v python3 >/dev/null 2>&1 || die "python3 is required and is not on PATH."
33
+
34
+ # shellcheck source=copilot-bridge-oracle.sh
35
+ . "$HERE/copilot-bridge-oracle.sh"
36
+
37
+ # --- 1. the ownership state is the sole removal authority --------------------
38
+ [ -L "$STATE_FILE" ] && die "ownership state $STATE_FILE is a symlink — refusing to trust it."
39
+ [ -f "$STATE_FILE" ] || die "no ownership state at $STATE_FILE — nothing this package provably owns to remove. A legacy no-state installation is adopted by './run.sh install-copilot-bridge' first, then removed."
40
+ STATE_FACTS="$(copilot_state_read "$STATE_FILE" "$QUALIFIED" "$MKT_NAME" "$ASM")" \
41
+ || die "ownership state $STATE_FILE is corrupt or names a different installation (see above) — refusing with zero vendor/filesystem writes."
42
+ OWNED_MKT="$(printf '%s' "$STATE_FACTS" | cut -d' ' -f1)"
43
+ OWNED_ASM="$(printf '%s' "$STATE_FACTS" | cut -d' ' -f2)"
44
+ STATE_PLUGIN_VERSION="$(printf '%s' "$STATE_FACTS" | cut -d' ' -f3)"
45
+
46
+ # --- 2. read-only vendor facts (a failing list is UNKNOWN, never absence) ----
47
+ if ! INSTALLED_LIST="$(copilot plugin list 2>/dev/null)"; then
48
+ die "'copilot plugin list' failed, so this host's installed plugins are UNKNOWN — refusing with zero writes. Fix the Copilot CLI error and re-run."
49
+ fi
50
+ if ! MKT_LIST="$(copilot plugin marketplace list 2>/dev/null)"; then
51
+ die "'copilot plugin marketplace list' failed, so this host's marketplaces are UNKNOWN — refusing with zero writes. Fix the Copilot CLI error and re-run."
52
+ fi
53
+
54
+ # --- 3. COMPLETE ownership/safety preflight, read-only, before ANY mutation --
55
+ # 3a. the marketplace half: exact name must sit at the recorded path AND be recorded
56
+ # as ours. Refusing HERE — before the plugin uninstall — is the amendment's point:
57
+ # the same qualified id could have been installed from a same-named marketplace at
58
+ # another path, and an inverse that had already uninstalled the plugin would have
59
+ # removed someone else's unit before discovering that.
60
+ MKT_PRESENT=0
61
+ # ONE marketplace-row grammar (C3a amendment, B defects 1+2): the shared oracle
62
+ # refuses malformed/non-Local rows and DUPLICATE same-named rows instead of silently
63
+ # acting on the first — an inverse steered at the wrong same-named marketplace would
64
+ # remove a registration it cannot attribute.
65
+ MKT_ROW="$(copilot_marketplace_local_path "$MKT_LIST" "$MKT_NAME")" \
66
+ || die "the 'copilot plugin marketplace list' rows for '$MKT_NAME' are malformed, non-Local, or duplicated (see above) — refusing with zero vendor/filesystem writes."
67
+ if [ "$MKT_ROW" != "absent" ]; then
68
+ MKT_PRESENT=1
69
+ MKT_PATH="${MKT_ROW#one }"
70
+ if [ "$MKT_PATH" != "$ASM" ]; then
71
+ die "a marketplace named '$MKT_NAME' is registered at '$MKT_PATH', not at the recorded assembly ($ASM). That registration is not provably ours — refusing with zero vendor/filesystem writes; inspect 'copilot plugin marketplace list' manually."
72
+ fi
73
+ if [ "$OWNED_MKT" != "true" ]; then
74
+ die "marketplace '$MKT_NAME' is registered at our recorded path, but the ownership state does not record marketplace ownership. Completing this inverse would leave a registered marketplace pointing at removed backing — refusing with zero vendor/filesystem writes (plugin, marketplace, assembly and state all preserved). Resolve the marketplace registration manually, or re-run './run.sh install-copilot-bridge' to re-bind ownership."
75
+ fi
76
+ fi
77
+ # 3b. assembly delete safety, decided BEFORE any vendor write: the recorded path must
78
+ # be a sane rm target or nothing at all may start.
79
+ if [ "$OWNED_ASM" = "true" ]; then
80
+ case "$ASM" in
81
+ /) die "recorded assembly path is '/' — refusing everything." ;;
82
+ /*) : ;;
83
+ *) die "recorded assembly path is not absolute: $ASM — refusing everything." ;;
84
+ esac
85
+ [ -L "$ASM" ] && die "recorded assembly path $ASM is a symlink — refusing everything; the inverse removes only the real recorded directory."
86
+ if [ -e "$ASM" ] && [ ! -d "$ASM" ]; then
87
+ die "recorded assembly path $ASM exists but is not a directory — refusing everything."
88
+ fi
89
+ fi
90
+ # 3c. the plugin half (final amendment): the exact QUALIFIED must be ABSENT (a previous
91
+ # run got that far — retry-safe) or present EXACTLY ONCE at the state's recorded
92
+ # version. A different version is `version-drift` — something other than the
93
+ # recorded install put it there, and this inverse must not remove what it cannot
94
+ # prove it installed. Malformed/multiple exact rows refuse too. Longer tokens that
95
+ # merely contain the qualified id remain foreign, same as everywhere.
96
+ PLUGIN_PRESENT=0
97
+ LISTED_ROW="$(copilot_exact_row_version "$INSTALLED_LIST" "$QUALIFIED")" \
98
+ || die "the 'copilot plugin list' rows for $QUALIFIED are malformed or ambiguous (see above) — refusing with zero vendor/filesystem writes."
99
+ case "$LISTED_ROW" in
100
+ absent) : ;;
101
+ "one $STATE_PLUGIN_VERSION") PLUGIN_PRESENT=1 ;;
102
+ one\ *)
103
+ die "version-drift: $QUALIFIED is listed at ${LISTED_ROW#one }, but the ownership state recorded v$STATE_PLUGIN_VERSION. This inverse removes only what it provably installed — refusing with zero vendor/filesystem writes (plugin, marketplace, assembly and state all preserved). Re-run './run.sh install-copilot-bridge' to re-bind ownership, then remove." ;;
104
+ *) die "unexpected exact-row verdict '$LISTED_ROW' — refusing with zero writes." ;;
105
+ esac
106
+
107
+ # --- 4. vendor mutation (licensed by the complete preflight above) -----------
108
+ if [ "$PLUGIN_PRESENT" -eq 1 ]; then
109
+ copilot plugin uninstall "$QUALIFIED" >/dev/null \
110
+ || die "'copilot plugin uninstall $QUALIFIED' failed — stopping with the state retained; re-run to finish."
111
+ echo "[copilot-bridge-uninstall] uninstalled $QUALIFIED"
112
+ else
113
+ echo "[copilot-bridge-uninstall] $QUALIFIED (v$STATE_PLUGIN_VERSION) is already absent from 'copilot plugin list' (a previous run got this far) — continuing"
114
+ fi
115
+ if [ "$MKT_PRESENT" -eq 1 ]; then
116
+ copilot plugin marketplace remove "$MKT_NAME" >/dev/null \
117
+ || die "'copilot plugin marketplace remove $MKT_NAME' failed (never retried with --force) — stopping with the state retained; re-run to finish."
118
+ echo "[copilot-bridge-uninstall] removed marketplace $MKT_NAME"
119
+ else
120
+ echo "[copilot-bridge-uninstall] marketplace $MKT_NAME is already absent (a previous run got this far) — continuing"
121
+ fi
122
+
123
+ # --- 5. the assembly: only the recorded directory, only when owned -----------
124
+ if [ "$OWNED_ASM" = "true" ]; then
125
+ if [ -d "$ASM" ]; then
126
+ rm -rf "$ASM"
127
+ echo "[copilot-bridge-uninstall] removed assembly $ASM"
128
+ else
129
+ echo "[copilot-bridge-uninstall] assembly already absent at $ASM (a previous run got this far) — continuing"
130
+ fi
131
+ else
132
+ echo "[copilot-bridge-uninstall] state does not record assembly ownership — preserving $ASM"
133
+ fi
134
+
135
+ # --- 6. the state, LAST ------------------------------------------------------
136
+ rm -f "$STATE_FILE"
137
+ echo "[copilot-bridge-uninstall] DONE — ownership state cleared ($STATE_FILE). The stale Claude unit, if any, was deliberately not touched (install-only territory)."
@@ -7,8 +7,9 @@
7
7
  # each recorded in its OWN checkout-OUTSIDE install-state (<name>.install-state.json) for an
8
8
  # honest inverse.
9
9
  #
10
- # Managed bins (both are BARE names that configs record — no repo/checkout path is EVER written
11
- # into any config; this only makes the bare name RESOLVE in a dev checkout):
10
+ # Managed bins (all are BARE names — no repo/checkout path is EVER written into a harness
11
+ # config; this only makes each package command RESOLVE in a dev checkout):
12
+ # entwurf -> run.sh (operator command / managed Copilot runtime)
12
13
  # entwurf-bridge -> mcp/entwurf-bridge/start.sh (agy mcp_config command)
13
14
  # entwurf-agy-statusline -> scripts/agy-statusline.sh (agy settings.statusLine command)
14
15
  # entwurf-agy-imprint -> scripts/agy-imprint.sh (agy PreInvocation birth hook)
@@ -27,12 +28,16 @@
27
28
  # name on a later expose is re-reported (drift visibility).
28
29
  #
29
30
  # expose [name] create/refresh managed link(s) (idempotent; REFUSE foreign → exit 3).
30
- # no name = all managed bins.
31
+ # no name = all managed bins, each attempted INDEPENDENTLY (#86 C1): one
32
+ # foreign refusal no longer aborts the loop, the trailing summary reports
33
+ # attempted/ok/refused truthfully, and any refusal still exits 3.
31
34
  # remove [name] honest inverse from state (remove only OUR link; REFUSE if it became foreign).
32
35
  # no name = all managed bins.
36
+ # verify <name> require OUR symlink → target and that exact link as the PATH winner.
33
37
  #
34
38
  # Overridable for the isolated smoke:
35
- # ENTWURF_DEV_BIN_DIR link location (default: ~/.local/bin — where agy itself lives)
39
+ # ENTWURF_DEV_BIN_DIR link location (default: ~/.local/bin — where agy itself lives)
40
+ # ENTWURF_OPERATOR_TARGET entwurf target (default: $REPO/run.sh)
36
41
  # ENTWURF_BRIDGE_TARGET entwurf-bridge target (default: $REPO/mcp/entwurf-bridge/start.sh)
37
42
  # ENTWURF_AGY_STATUSLINE_TARGET entwurf-agy-statusline target (default: $REPO/scripts/agy-statusline.sh)
38
43
  # ENTWURF_AGY_IMPRINT_TARGET entwurf-agy-imprint target (default: $REPO/scripts/agy-imprint.sh)
@@ -47,7 +52,7 @@ BIN_DIR="${ENTWURF_DEV_BIN_DIR:-$HOME/.local/bin}"
47
52
  STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/dev-bin"
48
53
  LEGACY_STATE="$STATE_DIR/install-state.json" # pre-multi-bin single state == entwurf-bridge
49
54
 
50
- MANAGED_BINS="entwurf-bridge entwurf-agy-statusline entwurf-agy-imprint entwurf-copilot-statusline"
55
+ MANAGED_BINS="entwurf entwurf-bridge entwurf-agy-statusline entwurf-agy-imprint entwurf-copilot-statusline"
51
56
 
52
57
  log() { printf '%s\n' "$*"; }
53
58
  warn() { printf '%s\n' "$*" >&2; }
@@ -55,6 +60,7 @@ fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
55
60
 
56
61
  bin_target() { # $1 = name → prints target path (env-overridable for smoke)
57
62
  case "$1" in
63
+ entwurf) echo "${ENTWURF_OPERATOR_TARGET:-$REPO_DIR/run.sh}" ;;
58
64
  entwurf-bridge) echo "${ENTWURF_BRIDGE_TARGET:-$REPO_DIR/mcp/entwurf-bridge/start.sh}" ;;
59
65
  entwurf-agy-statusline) echo "${ENTWURF_AGY_STATUSLINE_TARGET:-$REPO_DIR/scripts/agy-statusline.sh}" ;;
60
66
  entwurf-agy-imprint) echo "${ENTWURF_AGY_IMPRINT_TARGET:-$REPO_DIR/scripts/agy-imprint.sh}" ;;
@@ -139,7 +145,9 @@ PY
139
145
  fi
140
146
  }
141
147
 
142
- expose_one() { # $1 = name
148
+ expose_one() { # $1 = name; a foreign link RETURNS 3 (no exit) so the no-arg
149
+ # composition can attempt every unit independently (#86 C1) —
150
+ # a broken checkout (missing/unknown target) still fails loud.
143
151
  local name="$1" target link sf detect
144
152
  target="$(bin_target "$name")" || fail "unknown managed bin: $name"
145
153
  link="$BIN_DIR/$name"; sf="$(state_file_for "$name")"
@@ -149,7 +157,7 @@ expose_one() { # $1 = name
149
157
  [ -x "$target" ] || fail "target is not an executable file: $target (is this a dev checkout?)"
150
158
  if ! link_is_ours "$link" "$target" "$sf"; then
151
159
  warn "[dev-bin] REFUSE ($name): $link exists and is NOT ours (a foreign file/symlink — e.g. a real npm consumer bin). Not clobbering someone else's SSOT."
152
- exit 3
160
+ return 3
153
161
  fi
154
162
  detect="created-new"; [ -L "$link" ] && detect="refresh-ours"
155
163
  mkdir -p "$BIN_DIR"
@@ -161,6 +169,21 @@ expose_one() { # $1 = name
161
169
  fi
162
170
  }
163
171
 
172
+ verify_one() { # $1 = name
173
+ local name="$1" target link resolved
174
+ target="$(bin_target "$name")" || fail "unknown managed bin: $name"
175
+ link="$BIN_DIR/$name"
176
+ resolved="$(command -v "$name" 2>/dev/null || true)"
177
+ if [ ! -L "$link" ] || [ "$(readlink "$link" 2>/dev/null || true)" != "$target" ] || [ "$resolved" != "$link" ]; then
178
+ warn "[dev-bin] FAIL ($name): source command is not the PATH winner owned by this checkout."
179
+ warn " expected: $link -> $target"
180
+ warn " resolved: ${resolved:-<none>}"
181
+ warn " fix: remove a foreign/shadowing bin or put $BIN_DIR on PATH, then re-run setup."
182
+ return 1
183
+ fi
184
+ log "[dev-bin verify] $name → $target (PATH winner: $link)"
185
+ }
186
+
164
187
  remove_one() { # $1 = name
165
188
  local name="$1" target link sf recorded
166
189
  target="$(bin_target "$name")" || fail "unknown managed bin: $name"
@@ -185,7 +208,24 @@ remove_one() { # $1 = name
185
208
 
186
209
  do_expose() { # $1 = optional bin name
187
210
  migrate_legacy
188
- if [ -n "${1:-}" ]; then expose_one "$1"; else for b in $MANAGED_BINS; do expose_one "$b"; done; fi
211
+ if [ -n "${1:-}" ]; then expose_one "$1"; return $?; fi
212
+ # No-arg = the setup composition. Attempt EVERY unit independently (#86 C1):
213
+ # the old loop stopped at the first foreign refusal while the caller's warning
214
+ # claimed only that one bin was left as-is — units after it were never
215
+ # attempted. Now each refusal is recorded, later units still run, and the
216
+ # summary line reports attempted/ok/refused so no caller can claim less than
217
+ # what happened. Any refusal keeps the loop's exit 3 contract.
218
+ local attempted=0 exposed=0 refused="" rc b
219
+ for b in $MANAGED_BINS; do
220
+ attempted=$((attempted + 1))
221
+ rc=0; expose_one "$b" || rc=$?
222
+ if [ "$rc" -eq 0 ]; then exposed=$((exposed + 1)); else refused="$refused $b"; fi
223
+ done
224
+ if [ -n "$refused" ]; then
225
+ warn "[dev-bin expose] summary: attempted=$attempted ok=$exposed refused=$((attempted - exposed)):$refused (every unit was attempted independently)"
226
+ return 3
227
+ fi
228
+ log "[dev-bin expose] summary: attempted=$attempted ok=$exposed refused=0"
189
229
  }
190
230
  do_remove() { # $1 = optional bin name
191
231
  if [ -n "${1:-}" ]; then remove_one "$1"; else for b in $MANAGED_BINS; do remove_one "$b"; done; fi
@@ -194,5 +234,6 @@ do_remove() { # $1 = optional bin name
194
234
  case "${1:-}" in
195
235
  expose) shift; do_expose "${1:-}" ;;
196
236
  remove) shift; do_remove "${1:-}" ;;
197
- *) echo "usage: dev-bin.sh <expose|remove> [bin-name]" >&2; exit 2 ;;
237
+ verify) shift; [ -n "${1:-}" ] || fail "verify requires one managed bin name"; verify_one "$1" ;;
238
+ *) echo "usage: dev-bin.sh <expose|remove> [bin-name] | verify <bin-name>" >&2; exit 2 ;;
198
239
  esac
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ # fake-copilot-vendor.sh — ONE factory for the stateful fake Copilot CLI the
3
+ # aggregate-setup gates drive (#86 C3b). Two consumers share it so their fakes
4
+ # cannot drift: smoke-setup-verdict's copilot-present cell and check-pack-install's
5
+ # installed copilot-present consumer row. The answer shapes are the MEASURED CLI's
6
+ # (copilot 1.0.80, 2026-08-27) — the same shapes check-copilot-birth-hook.ts bakes
7
+ # into its own TS fake: `plugin list` prints qualified ids with a `(vX)` suffix,
8
+ # `plugin marketplace list` prints `<name> (Local: <abs path>)`, and `--force`
9
+ # anywhere is refused loudly (the real `marketplace remove --force` uninstalls that
10
+ # marketplace's plugins as a side effect; no entwurf surface may reach for it).
11
+ #
12
+ # This factory models the SUCCESS host only. A failing-list vendor is two lines and
13
+ # stays inline in the cell that needs it (check-setup-qualification Cell D).
14
+ #
15
+ # usage: bash scripts/fake-copilot-vendor.sh <dir> <plugin_json>
16
+ #
17
+ # EXECUTE this file; do not source it. `_check_pack_install_impl` holds a
18
+ # `trap … RETURN` around its temp roots, and bash fires a RETURN trap when a
19
+ # `.`/`source` finishes too — sourcing this factory there deleted the whole
20
+ # npm sandbox mid-gate (measured 2026-08-27). A child process cannot.
21
+ #
22
+ # Writes <dir>/copilot (executable) plus its state files into <dir>:
23
+ # installed.txt qualified ids `plugin install` appended
24
+ # marketplaces.txt `<name>\t<path>` rows `plugin marketplace add` appended
25
+ # calls.log every argv line the fake answered
26
+ # The advertised version is read from <plugin_json> (the same file the real
27
+ # installer reads), so the post-install exact-row check joins on one SSOT.
28
+ set -euo pipefail
29
+
30
+ make_fake_copilot() { # $1 = dir, $2 = plugin.json path
31
+ local dir="$1" plugin_json="$2" ver
32
+ ver="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["version"])' "$plugin_json")" || return 1
33
+ mkdir -p "$dir"
34
+ : > "$dir/installed.txt"
35
+ : > "$dir/marketplaces.txt"
36
+ : > "$dir/calls.log"
37
+ cat > "$dir/copilot" <<FAKE
38
+ #!/usr/bin/env bash
39
+ STATE="$dir/installed.txt"
40
+ MKTS="$dir/marketplaces.txt"
41
+ LOG="$dir/calls.log"
42
+ VER="$ver"
43
+ echo "\$*" >> "\$LOG"
44
+ for a in "\$@"; do [ "\$a" = "--force" ] && { echo "fake copilot: --force is forbidden here" >&2; exit 99; }; done
45
+ case "\$1 \$2 \$3" in
46
+ "plugin marketplace list")
47
+ echo "Included with GitHub Copilot:"
48
+ while IFS=\$'\t' read -r n p; do [ -n "\$n" ] && echo " • \$n (Local: \$p)"; done < "\$MKTS"
49
+ exit 0 ;;
50
+ "plugin marketplace add") printf "%s\t%s\n" "meta-bridge-copilot-local" "\$4" >> "\$MKTS"; exit 0 ;;
51
+ "plugin marketplace remove") awk -F"\t" -v n="\$4" '\$1 != n' "\$MKTS" > "\$MKTS.tmp"; mv "\$MKTS.tmp" "\$MKTS"; exit 0 ;;
52
+ esac
53
+ case "\$1 \$2" in
54
+ "plugin list") echo "Installed plugins:"; sed "s/^/ • /;s/\$/ (v\$VER)/" "\$STATE"; exit 0 ;;
55
+ "plugin uninstall") grep -Fvx "\$3" "\$STATE" > "\$STATE.tmp"; mv "\$STATE.tmp" "\$STATE"; exit 0 ;;
56
+ "plugin install") echo "\$3" >> "\$STATE"; exit 0 ;;
57
+ esac
58
+ exit 0
59
+ FAKE
60
+ chmod +x "$dir/copilot"
61
+ }
62
+
63
+ if [ "$#" -ne 2 ]; then
64
+ echo "usage: fake-copilot-vendor.sh <dir> <plugin_json>" >&2
65
+ exit 2
66
+ fi
67
+ make_fake_copilot "$1" "$2"
@@ -90,7 +90,7 @@ const H_FS = /from\s+["']node:fs["']|require\(["']node:fs["']\)/;
90
90
  // "process.env.LIVE"; it spawns no live turn of its own. So the predicate runs on the
91
91
  // CODE-ONLY projection (comments and inert literals blanked, shell expansions kept)
92
92
  // and matches a variable READ or an assignment, never prose.
93
- const H_LIVE = /process\.env\.LIVE\b|\$\{LIVE[:}\-]|\$LIVE\b|(?:^|\n)\s*(?:export\s+)?LIVE=1\b/;
93
+ const H_LIVE = /process\.env\.LIVE\b|\$\{LIVE[:}-]|\$LIVE\b|(?:^|\n)\s*(?:export\s+)?LIVE=1\b/;
94
94
 
95
95
  /**
96
96
  * Blank what cannot gate execution, so H_LIVE reads code and not prose.
@@ -15,10 +15,18 @@
15
15
  # would make the CLAUDE doctor permanently red for a refusal on a different rail, with
16
16
  # a prescription pointing at a Claude wake failure that never happened (cross-review,
17
17
  # glm, 2026-08-21). Copilot's own recovery rule lives in copilot-bridge-doctor.sh.
18
+ #
19
+ # The OMP birth unit (#87) joins the same file tagged `LEVEL [omp]`, and the rule extends
20
+ # for the same reason plus a sharper one: omp's own scope fence writes a line for EVERY
21
+ # task subagent it refuses (`INFO [omp] scope-refused`), which is the designed answer, and
22
+ # its failures are mint/marker faults on a rail whose recovery token — `create`/`attach` —
23
+ # is not `armed watch` either. omp's recovery rule lives in omp-bridge-doctor.sh. Excluding
24
+ # a backend here is never a way to quiet a red: it is how each doctor keeps judging only
25
+ # the evidence it owns.
18
26
  meta_bridge_hook_log_status() {
19
27
  local log="$1"
20
28
  local own
21
- own="$(grep -v ' \[copilot\] ' "$log" 2>/dev/null || true)"
29
+ own="$(grep -v -e ' \[copilot\] ' -e ' \[omp\] ' "$log" 2>/dev/null || true)"
22
30
  if ! printf '%s\n' "$own" | grep -q ' ERROR '; then
23
31
  echo "no-error"
24
32
  return 0