@junghanacs/entwurf 0.14.2 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -5
- package/CHANGELOG.md +149 -0
- package/DELIVERY.md +72 -59
- package/README.md +120 -20
- package/VERIFY.md +10 -7
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +2 -2
- package/docs/external-mcp-host.md +26 -4
- package/docs/setup-clean-host.md +118 -18
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
- package/mcp/entwurf-bridge/src/index.ts +10 -6
- package/mcp/entwurf-bridge/tsconfig.build.json +2 -1
- package/package.json +22 -11
- package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
- package/pi-extensions/entwurf-control.ts +17 -8
- package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
- package/pi-extensions/lib/meta-sender-identity.ts +10 -2
- package/pi-extensions/lib/meta-session.ts +102 -29
- package/pi-extensions/lib/mux-fresh-call.ts +82 -11
- package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
- package/run.sh +1079 -186
- package/scripts/agy-bridge.sh +4 -2
- package/scripts/check-copilot-birth-hook.ts +1174 -0
- package/scripts/check-copilot-launch.ts +395 -0
- package/scripts/check-copilot-receive-arm.ts +734 -0
- package/scripts/check-copilot-statusline.ts +122 -0
- package/scripts/check-entwurf-capabilities.ts +25 -6
- package/scripts/check-fresh-cut-gate.sh +104 -27
- package/scripts/check-gate-qualification.ts +10 -1
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-meta-capability-source.ts +89 -5
- package/scripts/check-meta-doctor-oracle.sh +26 -0
- package/scripts/check-meta-manifest-schema.py +38 -0
- package/scripts/check-meta-receiver-marker.ts +25 -2
- package/scripts/check-meta-session.ts +32 -0
- package/scripts/check-setup-qualification.sh +126 -0
- package/scripts/copilot-bridge-doctor.sh +242 -0
- package/scripts/copilot-bridge-install.sh +384 -0
- package/scripts/copilot-bridge-oracle.sh +223 -0
- package/scripts/copilot-bridge-uninstall.sh +137 -0
- package/scripts/copilot-launch.sh +236 -0
- package/scripts/copilot-mcp-bridge.sh +177 -0
- package/scripts/copilot-mcp-config.py +221 -0
- package/scripts/copilot-receive-bridge.sh +483 -0
- package/scripts/copilot-statusline-bridge.sh +149 -0
- package/scripts/copilot-statusline-config.py +201 -0
- package/scripts/copilot-statusline.sh +68 -0
- package/scripts/dev-bin.sh +53 -9
- package/scripts/fake-copilot-vendor.sh +67 -0
- package/scripts/meta-bridge-hook-log.sh +17 -5
- package/scripts/mutants/capability-cache.json +64 -0
- package/scripts/mutants/copilot-birth.json +244 -0
- package/scripts/mutants/copilot-launch.json +187 -0
- package/scripts/mutants/copilot-receive.json +263 -0
- package/scripts/mutants/fresh-cut.json +17 -0
- package/scripts/mutants/mux-fresh-call.json +216 -2
- package/scripts/mutants/pack-install.json +17 -0
- package/scripts/mutants/pi-package-ownership.json +92 -0
- package/scripts/mutants/setup-verdict.json +148 -0
- package/scripts/mutants/source-install.json +32 -0
- package/scripts/pi_settings_io.py +22 -0
- package/scripts/raw-async-delivery/README.md +170 -125
- package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
- package/scripts/register-pi-package.py +525 -7
- package/scripts/register-pi-provider.py +162 -12
- package/scripts/smoke-agy-install-state.sh +86 -30
- package/scripts/smoke-agy-statusline-state.sh +9 -6
- package/scripts/smoke-copilot-mcp-state.sh +153 -0
- package/scripts/smoke-copilot-statusline-state.sh +131 -0
- package/scripts/smoke-mux-fresh-call-live.ts +2 -0
- package/scripts/smoke-mux-lifecycle-live.ts +3 -1
- package/scripts/smoke-setup-verdict.sh +291 -0
- package/scripts/smoke-user-scope-citizen.sh +388 -1
- package/scripts/tsconfig.json +1 -0
|
@@ -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"
|
package/scripts/dev-bin.sh
CHANGED
|
@@ -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 (
|
|
11
|
-
#
|
|
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
|
|
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
|
-
|
|
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";
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
17
|
-
last_recovery="$(grep -n ' INFO armed watch '
|
|
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
|
-
|
|
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
|
+
}
|