@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,201 @@
1
+ #!/usr/bin/env python3
2
+ """Stateful Copilot statusLine settings adapter; stdlib only.
3
+
4
+ Owns the whole ``statusLine`` subtree and the one prerequisite leaf
5
+ ``footer.showCustom``. It adopts regular settings.json files, refuses symlinks,
6
+ and records first-install preimages for an honest inverse.
7
+ """
8
+
9
+ import datetime
10
+ import json
11
+ import os
12
+ import sys
13
+ import tempfile
14
+
15
+ STATE_SCHEMA_VERSION = 1
16
+ STATUSLINE_KEY = "statusLine"
17
+ FOOTER_KEY = "footer"
18
+ SHOW_CUSTOM_KEY = "showCustom"
19
+
20
+
21
+ def die(code: int, message: str) -> "None":
22
+ sys.stderr.write(message.rstrip("\n") + "\n")
23
+ raise SystemExit(code)
24
+
25
+
26
+ def load_object(path: str, label: str) -> dict:
27
+ try:
28
+ with open(path, "r", encoding="utf-8") as handle:
29
+ raw = handle.read()
30
+ except OSError as error:
31
+ die(4, f"copilot-statusline: cannot read {label} {path}: {error}")
32
+ if not raw.strip():
33
+ return {}
34
+ try:
35
+ value = json.loads(raw)
36
+ except json.JSONDecodeError as error:
37
+ die(4, f"copilot-statusline: {label} {path} is not valid JSON: {error}")
38
+ if not isinstance(value, dict):
39
+ die(4, f"copilot-statusline: {label} {path} top-level must be a JSON object")
40
+ return value
41
+
42
+
43
+ def atomic_write(path: str, value: dict) -> None:
44
+ directory = os.path.dirname(os.path.abspath(path))
45
+ os.makedirs(directory, exist_ok=True)
46
+ fd, temporary = tempfile.mkstemp(dir=directory, prefix=".copilot-statusline-", suffix=".json")
47
+ try:
48
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
49
+ json.dump(value, handle, indent=2)
50
+ handle.write("\n")
51
+ os.replace(temporary, path)
52
+ except BaseException:
53
+ try:
54
+ os.unlink(temporary)
55
+ except FileNotFoundError:
56
+ pass
57
+ raise
58
+
59
+
60
+ def now() -> str:
61
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
62
+
63
+
64
+ def checked_state(state_path: str) -> dict:
65
+ state = load_object(state_path, "install-state")
66
+ required = (
67
+ "managedSettingsPath",
68
+ "settingsExistedBefore",
69
+ "statusLineExisted",
70
+ "statusLinePreimage",
71
+ "footerExisted",
72
+ "showCustomExisted",
73
+ "showCustomPreimage",
74
+ )
75
+ if state.get("schemaVersion") != STATE_SCHEMA_VERSION or any(key not in state for key in required):
76
+ die(4, f"copilot-statusline: install-state {state_path} has an unsupported shape")
77
+ return state
78
+
79
+
80
+ def prior_state(state_path: str, settings_path: str) -> dict | None:
81
+ if not os.path.exists(state_path):
82
+ return None
83
+ state = checked_state(state_path)
84
+ return state if state["managedSettingsPath"] == os.path.abspath(settings_path) else None
85
+
86
+
87
+ def install(settings_path: str, command: str, state_path: str) -> None:
88
+ if os.path.islink(settings_path):
89
+ die(3, f"copilot-statusline: refusing to adopt {settings_path} — symlink to {os.readlink(settings_path)} (someone else's SSOT)")
90
+
91
+ existed = os.path.exists(settings_path)
92
+ settings = load_object(settings_path, "settings") if existed else {}
93
+ footer_existed = FOOTER_KEY in settings
94
+ footer = settings.get(FOOTER_KEY) if footer_existed else {}
95
+ if not isinstance(footer, dict):
96
+ die(4, f"copilot-statusline: {settings_path} footer must be a JSON object")
97
+
98
+ prior = prior_state(state_path, settings_path)
99
+ if prior is None:
100
+ state = {
101
+ "schemaVersion": STATE_SCHEMA_VERSION,
102
+ "managedSettingsPath": os.path.abspath(settings_path),
103
+ "command": command,
104
+ "detectMode": "adopt-regular-file" if existed else "created-new",
105
+ "settingsExistedBefore": existed,
106
+ "statusLineExisted": STATUSLINE_KEY in settings,
107
+ "statusLinePreimage": settings.get(STATUSLINE_KEY),
108
+ "footerExisted": footer_existed,
109
+ "showCustomExisted": SHOW_CUSTOM_KEY in footer,
110
+ "showCustomPreimage": footer.get(SHOW_CUSTOM_KEY),
111
+ "installedAt": now(),
112
+ }
113
+ else:
114
+ state = prior
115
+
116
+ settings[STATUSLINE_KEY] = {"command": command}
117
+ footer[SHOW_CUSTOM_KEY] = True
118
+ settings[FOOTER_KEY] = footer
119
+ atomic_write(settings_path, settings)
120
+ atomic_write(state_path, state)
121
+ sys.stdout.write(f"{state['detectMode']} {os.path.abspath(settings_path)}\n")
122
+
123
+
124
+ def uninstall(state_path: str) -> None:
125
+ if not os.path.exists(state_path):
126
+ die(2, f"copilot-statusline: no install-state at {state_path} — nothing to undo")
127
+ state = checked_state(state_path)
128
+ settings_path = state.get("managedSettingsPath")
129
+ if not isinstance(settings_path, str) or not os.path.isabs(settings_path):
130
+ die(4, f"copilot-statusline: install-state {state_path} has no absolute managedSettingsPath")
131
+ if os.path.islink(settings_path):
132
+ die(3, f"copilot-statusline: refusing to uninstall — {settings_path} became a symlink (someone else's SSOT)")
133
+
134
+ if os.path.exists(settings_path):
135
+ settings = load_object(settings_path, "settings")
136
+ if state.get("statusLineExisted"):
137
+ settings[STATUSLINE_KEY] = state.get("statusLinePreimage")
138
+ else:
139
+ settings.pop(STATUSLINE_KEY, None)
140
+
141
+ footer = settings.get(FOOTER_KEY)
142
+ if not isinstance(footer, dict):
143
+ die(4, f"copilot-statusline: {settings_path} footer changed to a non-object; refusing to guess")
144
+ if state.get("showCustomExisted"):
145
+ footer[SHOW_CUSTOM_KEY] = state.get("showCustomPreimage")
146
+ else:
147
+ footer.pop(SHOW_CUSTOM_KEY, None)
148
+ if footer or state.get("footerExisted"):
149
+ settings[FOOTER_KEY] = footer
150
+ else:
151
+ settings.pop(FOOTER_KEY, None)
152
+
153
+ created = state.get("detectMode") == "created-new" and state.get("settingsExistedBefore") is False
154
+ if created and not settings:
155
+ os.remove(settings_path)
156
+ else:
157
+ atomic_write(settings_path, settings)
158
+
159
+ os.remove(state_path)
160
+ sys.stdout.write(f"uninstalled {settings_path}\n")
161
+
162
+
163
+ def doctor_static(settings_path: str, command: str) -> None:
164
+ if os.path.islink(settings_path):
165
+ sys.stdout.write("symlink\n")
166
+ return
167
+ if not os.path.exists(settings_path):
168
+ sys.stdout.write("file-absent\n")
169
+ return
170
+ try:
171
+ settings = load_object(settings_path, "settings")
172
+ except SystemExit:
173
+ sys.stdout.write("invalid-json\n")
174
+ return
175
+ status_line = settings.get(STATUSLINE_KEY)
176
+ if not isinstance(status_line, dict) or status_line.get("command") != command:
177
+ sys.stdout.write("not-ours\n")
178
+ return
179
+ footer = settings.get(FOOTER_KEY)
180
+ if not isinstance(footer, dict) or footer.get(SHOW_CUSTOM_KEY) is not True:
181
+ sys.stdout.write("custom-disabled\n")
182
+ return
183
+ sys.stdout.write(f"configured {command}\n")
184
+
185
+
186
+ def main(argv: list[str]) -> None:
187
+ if len(argv) < 2:
188
+ die(5, "usage: copilot-statusline-config.py <install|uninstall|doctor-static> ...")
189
+ match argv[1]:
190
+ case "install" if len(argv) == 5:
191
+ install(argv[2], argv[3], argv[4])
192
+ case "uninstall" if len(argv) == 3:
193
+ uninstall(argv[2])
194
+ case "doctor-static" if len(argv) == 4:
195
+ doctor_static(argv[2], argv[3])
196
+ case _:
197
+ die(5, "usage: copilot-statusline-config.py <install settings command state|uninstall state|doctor-static settings command>")
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main(sys.argv)
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # copilot-statusline — Copilot custom footer: garden id only.
3
+ #
4
+ # Copilot already draws directory/branch/model/context/quota. This fills the
5
+ # custom slot with the garden id. Vocabulary matches Claude and agy
6
+ # (ready / ? / !); rail `cop`. Fail-quiet: nonzero blanks the slot (bundle Fxi).
7
+ # Bare bin: entwurf-copilot-statusline. No cache, no git, no ANSI.
8
+ # Reads session_id only — any other envelope key is Copilot's, not ours.
9
+ set -euo pipefail
10
+
11
+ # Copilot silently blanks a nonzero statusline, so a read failure degrades to ready.
12
+ input=$(cat || true)
13
+ if ! command -v python3 >/dev/null 2>&1; then
14
+ printf '🪛 ? cop'
15
+ exit 0
16
+ fi
17
+ output="$(STATUSLINE_INPUT="$input" python3 - <<'PY'
18
+ import json, os, sys
19
+ from pathlib import Path
20
+
21
+ def load_input():
22
+ raw = os.environ.get("STATUSLINE_INPUT", "")
23
+ try:
24
+ data = json.loads(raw) if raw.strip() else {}
25
+ except Exception:
26
+ return {}
27
+ return data if isinstance(data, dict) else {}
28
+
29
+ def meta_sessions_dir():
30
+ override = os.environ.get("ENTWURF_META_SESSIONS_DIR")
31
+ if override:
32
+ return Path(override).expanduser().resolve()
33
+ agent = os.environ.get("PI_CODING_AGENT_DIR")
34
+ if agent:
35
+ return Path(agent).expanduser().resolve() / "meta-sessions"
36
+ return Path.home() / ".pi" / "agent" / "meta-sessions"
37
+
38
+ def garden_lookup(native_session_id):
39
+ if not native_session_id:
40
+ return "ready"
41
+ root = meta_sessions_dir()
42
+ if not root.exists():
43
+ return "?"
44
+ matches = []
45
+ try:
46
+ entries = sorted(root.glob("*.meta.json"))
47
+ except Exception:
48
+ return "?"
49
+ for file in entries:
50
+ try:
51
+ record = json.loads(file.read_text(encoding="utf-8"))
52
+ except Exception:
53
+ continue
54
+ if isinstance(record, dict) and record.get("nativeSessionId") == native_session_id:
55
+ garden = record.get("gardenId")
56
+ matches.append(garden if isinstance(garden, str) and garden else "!")
57
+ if len(matches) == 1:
58
+ return matches[0]
59
+ if len(matches) > 1:
60
+ return "!"
61
+ return "?"
62
+
63
+ data = load_input()
64
+ sid = data.get("session_id")
65
+ sys.stdout.write(f"🪛 {garden_lookup(sid if isinstance(sid, str) else '')} cop")
66
+ PY
67
+ )" || output='🪛 ? cop'
68
+ printf '%s' "$output"
@@ -7,11 +7,13 @@
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)
16
+ # entwurf-copilot-statusline -> scripts/copilot-statusline.sh (copilot settings.statusLine command)
15
17
  #
16
18
  # Ownership discipline (mirrors the agy adopt/refuse rule, 봉인 7), applied PER BIN:
17
19
  # - create/refresh ONLY a link that is OURS — absent, or a symlink into our target, or the
@@ -26,15 +28,20 @@
26
28
  # name on a later expose is re-reported (drift visibility).
27
29
  #
28
30
  # expose [name] create/refresh managed link(s) (idempotent; REFUSE foreign → exit 3).
29
- # 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.
30
34
  # remove [name] honest inverse from state (remove only OUR link; REFUSE if it became foreign).
31
35
  # no name = all managed bins.
36
+ # verify <name> require OUR symlink → target and that exact link as the PATH winner.
32
37
  #
33
38
  # Overridable for the isolated smoke:
34
- # 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)
35
41
  # ENTWURF_BRIDGE_TARGET entwurf-bridge target (default: $REPO/mcp/entwurf-bridge/start.sh)
36
42
  # ENTWURF_AGY_STATUSLINE_TARGET entwurf-agy-statusline target (default: $REPO/scripts/agy-statusline.sh)
37
43
  # ENTWURF_AGY_IMPRINT_TARGET entwurf-agy-imprint target (default: $REPO/scripts/agy-imprint.sh)
44
+ # ENTWURF_COPILOT_STATUSLINE_TARGET entwurf-copilot-statusline target (default: $REPO/scripts/copilot-statusline.sh)
38
45
  # XDG_DATA_HOME state root (states under $XDG_DATA_HOME/entwurf/dev-bin/)
39
46
  set -euo pipefail
40
47
 
@@ -45,7 +52,7 @@ BIN_DIR="${ENTWURF_DEV_BIN_DIR:-$HOME/.local/bin}"
45
52
  STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/dev-bin"
46
53
  LEGACY_STATE="$STATE_DIR/install-state.json" # pre-multi-bin single state == entwurf-bridge
47
54
 
48
- MANAGED_BINS="entwurf-bridge entwurf-agy-statusline entwurf-agy-imprint"
55
+ MANAGED_BINS="entwurf entwurf-bridge entwurf-agy-statusline entwurf-agy-imprint entwurf-copilot-statusline"
49
56
 
50
57
  log() { printf '%s\n' "$*"; }
51
58
  warn() { printf '%s\n' "$*" >&2; }
@@ -53,9 +60,11 @@ fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
53
60
 
54
61
  bin_target() { # $1 = name → prints target path (env-overridable for smoke)
55
62
  case "$1" in
63
+ entwurf) echo "${ENTWURF_OPERATOR_TARGET:-$REPO_DIR/run.sh}" ;;
56
64
  entwurf-bridge) echo "${ENTWURF_BRIDGE_TARGET:-$REPO_DIR/mcp/entwurf-bridge/start.sh}" ;;
57
65
  entwurf-agy-statusline) echo "${ENTWURF_AGY_STATUSLINE_TARGET:-$REPO_DIR/scripts/agy-statusline.sh}" ;;
58
66
  entwurf-agy-imprint) echo "${ENTWURF_AGY_IMPRINT_TARGET:-$REPO_DIR/scripts/agy-imprint.sh}" ;;
67
+ entwurf-copilot-statusline) echo "${ENTWURF_COPILOT_STATUSLINE_TARGET:-$REPO_DIR/scripts/copilot-statusline.sh}" ;;
59
68
  *) return 1 ;;
60
69
  esac
61
70
  }
@@ -136,7 +145,9 @@ PY
136
145
  fi
137
146
  }
138
147
 
139
- 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.
140
151
  local name="$1" target link sf detect
141
152
  target="$(bin_target "$name")" || fail "unknown managed bin: $name"
142
153
  link="$BIN_DIR/$name"; sf="$(state_file_for "$name")"
@@ -146,7 +157,7 @@ expose_one() { # $1 = name
146
157
  [ -x "$target" ] || fail "target is not an executable file: $target (is this a dev checkout?)"
147
158
  if ! link_is_ours "$link" "$target" "$sf"; then
148
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."
149
- exit 3
160
+ return 3
150
161
  fi
151
162
  detect="created-new"; [ -L "$link" ] && detect="refresh-ours"
152
163
  mkdir -p "$BIN_DIR"
@@ -158,6 +169,21 @@ expose_one() { # $1 = name
158
169
  fi
159
170
  }
160
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
+
161
187
  remove_one() { # $1 = name
162
188
  local name="$1" target link sf recorded
163
189
  target="$(bin_target "$name")" || fail "unknown managed bin: $name"
@@ -182,7 +208,24 @@ remove_one() { # $1 = name
182
208
 
183
209
  do_expose() { # $1 = optional bin name
184
210
  migrate_legacy
185
- 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"
186
229
  }
187
230
  do_remove() { # $1 = optional bin name
188
231
  if [ -n "${1:-}" ]; then remove_one "$1"; else for b in $MANAGED_BINS; do remove_one "$b"; done; fi
@@ -191,5 +234,6 @@ do_remove() { # $1 = optional bin name
191
234
  case "${1:-}" in
192
235
  expose) shift; do_expose "${1:-}" ;;
193
236
  remove) shift; do_remove "${1:-}" ;;
194
- *) 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 ;;
195
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"
@@ -4,20 +4,32 @@
4
4
  # proves the Claude SessionStart/CwdChanged hook re-armed the mailbox watch. A
5
5
  # later `INFO attach record (event=UserPromptSubmit, ...)` is merely degraded
6
6
  # backfill and must NOT clear an arm/upsert failure.
7
-
7
+ #
8
+ # THIS JUDGEMENT IS CLAUDE'S ONLY, and since #82 it has to say so. The Copilot birth
9
+ # unit appends to the SAME log file (one grep for the operator), tagging its lines
10
+ # `LEVEL [copilot]`. Those lines must be invisible here, for a reason stronger than
11
+ # tidiness: a copilot ERROR is often a CORRECT fail-closed refusal (a degraded envelope
12
+ # the unit refused to mint from), and its recovery token can never appear — `armed
13
+ # watch` is the Claude hook rail's token. Copilot's measured extension transport has a
14
+ # different, not-yet-admitted lifecycle and writes no token this doctor owns. Counting them
15
+ # would make the CLAUDE doctor permanently red for a refusal on a different rail, with
16
+ # a prescription pointing at a Claude wake failure that never happened (cross-review,
17
+ # glm, 2026-08-21). Copilot's own recovery rule lives in copilot-bridge-doctor.sh.
8
18
  meta_bridge_hook_log_status() {
9
19
  local log="$1"
10
- if ! grep -q ' ERROR ' "$log" 2>/dev/null; then
20
+ local own
21
+ own="$(grep -v ' \[copilot\] ' "$log" 2>/dev/null || true)"
22
+ if ! printf '%s\n' "$own" | grep -q ' ERROR '; then
11
23
  echo "no-error"
12
24
  return 0
13
25
  fi
14
26
 
15
27
  local last_err last_recovery
16
- last_err="$(grep -n ' ERROR ' "$log" | tail -1 | cut -d: -f1)"
17
- last_recovery="$(grep -n ' INFO armed watch ' "$log" | tail -1 | cut -d: -f1)"
28
+ last_err="$(printf '%s\n' "$own" | grep -n ' ERROR ' | tail -1 | cut -d: -f1)"
29
+ last_recovery="$(printf '%s\n' "$own" | grep -n ' INFO armed watch ' | tail -1 | cut -d: -f1)"
18
30
 
19
31
  if [ -z "$last_recovery" ] || [ "$last_err" -gt "$last_recovery" ]; then
20
- grep ' ERROR ' "$log" | tail -1
32
+ printf '%s\n' "$own" | grep ' ERROR ' | tail -1
21
33
  return 1
22
34
  fi
23
35
 
@@ -0,0 +1,64 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "capability-cache",
4
+ "mutants": [
5
+ {
6
+ "claim": "CAPABILITY-REGISTRY-REREAD-SEES-REPLACEMENT",
7
+ "title": "memoizing the capability registry into a process-lifetime singleton makes every already-running dispatcher answer out of the registry it booted with — an install that moves a grade or wakeMode is invisible until the operator restarts, and nothing says so",
8
+ "subject": "pi-extensions/lib/meta-session.ts",
9
+ "find": [
10
+ "export function loadMetaCapabilityRegistry(): MetaCapabilityRegistry {",
11
+ "\treturn parseMetaCapabilityRegistry(fs.readFileSync(metaCapabilitiesFilePath(), \"utf8\"));",
12
+ "}"
13
+ ],
14
+ "replace": [
15
+ "let cachedMetaCapabilities: MetaCapabilityRegistry | null = null;",
16
+ "export function loadMetaCapabilityRegistry(): MetaCapabilityRegistry {",
17
+ "\tif (cachedMetaCapabilities === null) {",
18
+ "\t\tcachedMetaCapabilities = parseMetaCapabilityRegistry(fs.readFileSync(metaCapabilitiesFilePath(), \"utf8\"));",
19
+ "\t}",
20
+ "\treturn cachedMetaCapabilities;",
21
+ "}"
22
+ ],
23
+ "gate": ["bash", "run.sh", "check-meta-capability-source"],
24
+ "timeoutSeconds": 300,
25
+ "signature": "[QK:CAPABILITY-REGISTRY-REREAD-SEES-REPLACEMENT]",
26
+ "signatureSource": "scripts/check-meta-capability-source.ts"
27
+ },
28
+ {
29
+ "claim": "CAPABILITY-REGISTRY-MISSING-STILL-THROWS",
30
+ "title": "answering a missing registry with a built-in default turns a broken install into a citizen that silently claims capabilities nobody shipped",
31
+ "subject": "pi-extensions/lib/meta-session.ts",
32
+ "find": ["\treturn parseMetaCapabilityRegistry(fs.readFileSync(metaCapabilitiesFilePath(), \"utf8\"));"],
33
+ "replace": [
34
+ "\tconst registryPath = metaCapabilitiesFilePath();",
35
+ "\tif (!fs.existsSync(registryPath)) {",
36
+ "\t\treturn { schemaVersion: CAPABILITY_SCHEMA_VERSION, backends: {} } as unknown as MetaCapabilityRegistry;",
37
+ "\t}",
38
+ "\treturn parseMetaCapabilityRegistry(fs.readFileSync(registryPath, \"utf8\"));"
39
+ ],
40
+ "gate": ["bash", "run.sh", "check-meta-capability-source"],
41
+ "timeoutSeconds": 300,
42
+ "signature": "[QK:CAPABILITY-REGISTRY-MISSING-STILL-THROWS]",
43
+ "signatureSource": "scripts/check-meta-capability-source.ts"
44
+ },
45
+ {
46
+ "claim": "CAPABILITY-REGISTRY-CORRUPT-STILL-THROWS",
47
+ "title": "swallowing a parse failure and returning a fallback lets a half-written or hand-edited registry read as a valid one, so the strict parser stops being load-bearing",
48
+ "subject": "pi-extensions/lib/meta-session.ts",
49
+ "find": ["\treturn parseMetaCapabilityRegistry(fs.readFileSync(metaCapabilitiesFilePath(), \"utf8\"));"],
50
+ "replace": [
51
+ "\tconst rawRegistry = fs.readFileSync(metaCapabilitiesFilePath(), \"utf8\");",
52
+ "\ttry {",
53
+ "\t\treturn parseMetaCapabilityRegistry(rawRegistry);",
54
+ "\t} catch {",
55
+ "\t\treturn { schemaVersion: CAPABILITY_SCHEMA_VERSION, backends: {} } as unknown as MetaCapabilityRegistry;",
56
+ "\t}"
57
+ ],
58
+ "gate": ["bash", "run.sh", "check-meta-capability-source"],
59
+ "timeoutSeconds": 300,
60
+ "signature": "[QK:CAPABILITY-REGISTRY-CORRUPT-STILL-THROWS]",
61
+ "signatureSource": "scripts/check-meta-capability-source.ts"
62
+ }
63
+ ]
64
+ }