@junghanacs/entwurf 0.14.1 → 0.15.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.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +113 -0
- package/DELIVERY.md +73 -3
- package/README.md +55 -4
- package/VERIFY.md +6 -6
- package/demo/README.md +3 -1
- package/demo/demo-baseline.sh +12 -1
- package/demo/demo.sh +9 -1
- package/docs/acp-backend-rail.md +103 -4
- package/docs/external-mcp-host.md +26 -4
- package/docs/setup-clean-host.md +3 -3
- 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/dist/scripts/doctor-pi-provider.js +139 -47
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
- package/mcp/entwurf-bridge/src/index.ts +10 -6
- package/mcp/entwurf-bridge/tsconfig.build.json +17 -6
- package/package.json +23 -12
- 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/acp/backend.ts +229 -9
- 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 +389 -26
- package/scripts/agy-bridge-config.py +47 -13
- package/scripts/agy-bridge.sh +73 -23
- package/scripts/check-acp-prompt-lifecycle.ts +221 -9
- package/scripts/check-copilot-birth-hook.ts +497 -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-bridge-boot.ts +28 -0
- package/scripts/check-entwurf-capabilities.ts +25 -6
- package/scripts/check-fresh-cut-gate.sh +92 -23
- package/scripts/check-gate-qualification.ts +10 -3
- 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-probe-bridge-command.ts +201 -0
- package/scripts/check-release-gate-outcomes.ts +54 -1
- package/scripts/copilot-bridge-doctor.sh +209 -0
- package/scripts/copilot-bridge-install.sh +224 -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 +4 -1
- package/scripts/doctor-pi-provider.ts +155 -51
- package/scripts/meta-bridge-hook-log.sh +17 -5
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/capability-cache.json +64 -0
- package/scripts/mutants/copilot-birth.json +155 -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/release-gate.json +13 -0
- package/scripts/probe-bridge-command.ts +330 -0
- package/scripts/raw-async-delivery/README.md +203 -1
- 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/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +76 -2
- package/scripts/smoke-copilot-mcp-state.sh +153 -0
- package/scripts/smoke-copilot-statusline-state.sh +131 -0
- package/scripts/smoke-entwurf-chain-live.ts +1 -1
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-mux-fresh-call-live.ts +3 -1
- package/scripts/smoke-mux-lifecycle-live.ts +4 -2
- package/scripts/smoke-pi-provider-state.sh +135 -6
- package/scripts/smoke-resident-garden-guard.sh +2 -2
- 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
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
# entwurf-bridge -> mcp/entwurf-bridge/start.sh (agy mcp_config command)
|
|
13
13
|
# entwurf-agy-statusline -> scripts/agy-statusline.sh (agy settings.statusLine command)
|
|
14
14
|
# entwurf-agy-imprint -> scripts/agy-imprint.sh (agy PreInvocation birth hook)
|
|
15
|
+
# entwurf-copilot-statusline -> scripts/copilot-statusline.sh (copilot settings.statusLine command)
|
|
15
16
|
#
|
|
16
17
|
# Ownership discipline (mirrors the agy adopt/refuse rule, 봉인 7), applied PER BIN:
|
|
17
18
|
# - create/refresh ONLY a link that is OURS — absent, or a symlink into our target, or the
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
# ENTWURF_BRIDGE_TARGET entwurf-bridge target (default: $REPO/mcp/entwurf-bridge/start.sh)
|
|
36
37
|
# ENTWURF_AGY_STATUSLINE_TARGET entwurf-agy-statusline target (default: $REPO/scripts/agy-statusline.sh)
|
|
37
38
|
# ENTWURF_AGY_IMPRINT_TARGET entwurf-agy-imprint target (default: $REPO/scripts/agy-imprint.sh)
|
|
39
|
+
# ENTWURF_COPILOT_STATUSLINE_TARGET entwurf-copilot-statusline target (default: $REPO/scripts/copilot-statusline.sh)
|
|
38
40
|
# XDG_DATA_HOME state root (states under $XDG_DATA_HOME/entwurf/dev-bin/)
|
|
39
41
|
set -euo pipefail
|
|
40
42
|
|
|
@@ -45,7 +47,7 @@ BIN_DIR="${ENTWURF_DEV_BIN_DIR:-$HOME/.local/bin}"
|
|
|
45
47
|
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/dev-bin"
|
|
46
48
|
LEGACY_STATE="$STATE_DIR/install-state.json" # pre-multi-bin single state == entwurf-bridge
|
|
47
49
|
|
|
48
|
-
MANAGED_BINS="entwurf-bridge entwurf-agy-statusline entwurf-agy-imprint"
|
|
50
|
+
MANAGED_BINS="entwurf-bridge entwurf-agy-statusline entwurf-agy-imprint entwurf-copilot-statusline"
|
|
49
51
|
|
|
50
52
|
log() { printf '%s\n' "$*"; }
|
|
51
53
|
warn() { printf '%s\n' "$*" >&2; }
|
|
@@ -56,6 +58,7 @@ bin_target() { # $1 = name → prints target path (env-overridable for smoke)
|
|
|
56
58
|
entwurf-bridge) echo "${ENTWURF_BRIDGE_TARGET:-$REPO_DIR/mcp/entwurf-bridge/start.sh}" ;;
|
|
57
59
|
entwurf-agy-statusline) echo "${ENTWURF_AGY_STATUSLINE_TARGET:-$REPO_DIR/scripts/agy-statusline.sh}" ;;
|
|
58
60
|
entwurf-agy-imprint) echo "${ENTWURF_AGY_IMPRINT_TARGET:-$REPO_DIR/scripts/agy-imprint.sh}" ;;
|
|
61
|
+
entwurf-copilot-statusline) echo "${ENTWURF_COPILOT_STATUSLINE_TARGET:-$REPO_DIR/scripts/copilot-statusline.sh}" ;;
|
|
59
62
|
*) return 1 ;;
|
|
60
63
|
esac
|
|
61
64
|
}
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// doctor-pi-provider — fail-loud doctor for the pi provider (entwurfProvider.mcpServers.
|
|
3
|
-
// entwurf-bridge) ownership (#46 Task 2).
|
|
3
|
+
// entwurf-bridge) ownership (#46 Task 2). A BOUNDED BOOT PROBE, not a static inspector: it writes
|
|
4
|
+
// no operator state, but it does exec the configured command on this host (#81). Uses the config.ts SSOT
|
|
4
5
|
// `readProviderSettingsFile` so the effective (shadow-resolved) view matches what pi actually
|
|
5
6
|
// loads — NOT a re-implemented merge (GPT D: a python re-impl drifts into "doctor green, runtime
|
|
6
7
|
// red"). Reports user / project / EFFECTIVE command (project shadows user per-name, the
|
|
7
|
-
// resolveProviderConfig rule), plus install-state ownership, and gates on stable
|
|
8
|
+
// resolveProviderConfig rule), plus install-state ownership, and gates on the stable bin actually
|
|
9
|
+
// BOOTING (#81) — not merely resolving. `command -v` answering yes is not evidence that pi gets a
|
|
10
|
+
// bridge: on the reference host the bare name resolved through a relocated pnpm shim whose $0-derived
|
|
11
|
+
// target did not exist, so the launcher exited 127, the ACP turn had no mcp__entwurf-bridge__* tool,
|
|
12
|
+
// and THIS doctor still printed ok. Resolvability is kept as the first, cheaper cell so the two
|
|
13
|
+
// failures stay distinguishable (nothing on PATH vs. on PATH but dead).
|
|
14
|
+
//
|
|
15
|
+
// The probe sends `initialize` + `tools/list` only — never a `tools/call` — so it takes no lock,
|
|
16
|
+
// writes no record and delivers nothing. That bounds OUR bridge tightly (start.sh is an `exec node`,
|
|
17
|
+
// so there is no grandchild). It does not make the doctor side-effect free in general: whatever the
|
|
18
|
+
// operator configured is what gets executed, and a foreign launcher's own startup is its own
|
|
19
|
+
// business. Running this doctor is therefore a decision to run that command. See
|
|
20
|
+
// probe-bridge-command.ts, which owns the bounded reap of the child it spawns.
|
|
8
21
|
//
|
|
9
22
|
// Env overrides (for the hermetic smoke):
|
|
10
23
|
// PI_PROVIDER_GLOBAL_SETTINGS default: $PI_CODING_AGENT_DIR/settings.json or ~/.pi/agent/settings.json
|
|
@@ -12,12 +25,13 @@
|
|
|
12
25
|
// PI_PROVIDER_STATE default: $XDG_DATA_HOME/entwurf/pi-provider/install-state.json
|
|
13
26
|
//
|
|
14
27
|
// Exit: 0 ok (incl. honest "never installed / unowned" notes) · 1 hard fail (malformed settings /
|
|
15
|
-
// state-owned-but-drifted / stable bin dangling).
|
|
16
|
-
import {
|
|
17
|
-
import { existsSync, constants as FS, readFileSync
|
|
28
|
+
// state-owned-but-drifted / stable bin dangling / stable bin present but does NOT boot).
|
|
29
|
+
import { execFileSync } from "node:child_process";
|
|
30
|
+
import { accessSync, existsSync, constants as FS, readFileSync } from "node:fs";
|
|
18
31
|
import { homedir } from "node:os";
|
|
19
32
|
import { join } from "node:path";
|
|
20
|
-
import { readProviderSettingsFile } from "../pi-extensions/lib/acp/config.ts";
|
|
33
|
+
import { normalizeMcpServers, readProviderSettingsFile } from "../pi-extensions/lib/acp/config.ts";
|
|
34
|
+
import { probeBridgeCommand } from "./probe-bridge-command.ts";
|
|
21
35
|
|
|
22
36
|
const BARE = "entwurf-bridge";
|
|
23
37
|
const KEY = "entwurf-bridge";
|
|
@@ -32,12 +46,55 @@ const statePath = process.env.PI_PROVIDER_STATE || join(xdg, "entwurf", "pi-prov
|
|
|
32
46
|
let hardFail = 0;
|
|
33
47
|
const log = (s: string) => process.stdout.write(s + "\n");
|
|
34
48
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
/** The configured stdio invocation for our key, as PRODUCTION would build it. */
|
|
50
|
+
interface BridgeEntry {
|
|
51
|
+
command: string;
|
|
52
|
+
args: string[];
|
|
53
|
+
env: Record<string, string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Build the effective entry EXACTLY the way production does (config.ts `resolveProviderConfig`):
|
|
57
|
+
// a shallow PER-NAME merge of the two `mcpServers` maps, then ONE `normalizeMcpServers` over the
|
|
58
|
+
// merged map. Hand-parsing command/args (this doctor's first shape) dropped a malformed arg
|
|
59
|
+
// silently and probed an invocation production never runs.
|
|
60
|
+
//
|
|
61
|
+
// The ORDER is load-bearing too. Normalizing each file separately validates entries production
|
|
62
|
+
// never sees: a malformed server in the global map that the project map shadows is gone by the time
|
|
63
|
+
// pi normalizes, yet a per-file doctor throws on it and calls a session red that in fact starts
|
|
64
|
+
// fine. That is the same "the doctor's subject differs from the runtime's" defect as the silent
|
|
65
|
+
// arg-drop, pointed the other way — and it is not confined to our key, since any unrelated global
|
|
66
|
+
// server could trip it.
|
|
67
|
+
//
|
|
68
|
+
// THROWS (McpServerConfigError) when the MERGED map is malformed — the fail-loud path, caught by
|
|
69
|
+
// the caller. That error names the offending SERVER and reason, not a file: after the merge an
|
|
70
|
+
// entry no longer belongs to one scope, so claiming a filename here would be a guess. A non-stdio
|
|
71
|
+
// (http/sse) entry is returned as `null` so the caller can say WHY it cannot be probed instead of
|
|
72
|
+
// pretending it is absent.
|
|
73
|
+
function mergedBridgeEntry(
|
|
74
|
+
globalSettings: { mcpServers?: Record<string, unknown> },
|
|
75
|
+
projectSettings: { mcpServers?: Record<string, unknown> },
|
|
76
|
+
): BridgeEntry | null | undefined {
|
|
77
|
+
const mergedRaw = { ...(globalSettings.mcpServers ?? {}), ...(projectSettings.mcpServers ?? {}) };
|
|
78
|
+
const { servers } = normalizeMcpServers(mergedRaw);
|
|
79
|
+
const entry = servers.find((srv) => srv.name === KEY);
|
|
80
|
+
if (entry === undefined) return undefined; // not configured in either scope
|
|
81
|
+
if ("url" in entry) return null; // http/sse — a real config, but nothing to spawn
|
|
82
|
+
return {
|
|
83
|
+
command: entry.command,
|
|
84
|
+
args: entry.args,
|
|
85
|
+
// The ACP wire shape is a name/value list; collapse it to the env map a spawn wants.
|
|
86
|
+
env: Object.fromEntries(entry.env.map((kv) => [kv.name, kv.value])),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Per-scope DISPLAY only — never validation. The scope lines exist so an operator can see which
|
|
91
|
+
// file supplied the effective entry; judging shape here would re-introduce the per-file validation
|
|
92
|
+
// the merge rule above exists to avoid.
|
|
93
|
+
function describeScope(settings: { mcpServers?: Record<string, unknown> }): string {
|
|
94
|
+
const raw = settings.mcpServers?.[KEY];
|
|
95
|
+
if (raw === undefined) return "entwurf-bridge NOT configured";
|
|
96
|
+
const cmd = (raw as { command?: unknown } | null)?.command;
|
|
97
|
+
return typeof cmd === "string" ? `'${cmd}'` : "configured (shape judged in the merged view)";
|
|
41
98
|
}
|
|
42
99
|
|
|
43
100
|
// Does the command resolve in the environment (best local proxy for "where pi/agy runs")?
|
|
@@ -45,16 +102,16 @@ function commandOf(settings: { mcpServers?: Record<string, unknown> }): string |
|
|
|
45
102
|
function resolvable(cmd: string): boolean {
|
|
46
103
|
if (cmd.includes("/")) {
|
|
47
104
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return (statSync(cmd).mode & FS.S_IXUSR) !== 0;
|
|
105
|
+
accessSync(cmd, FS.X_OK);
|
|
106
|
+
return true;
|
|
51
107
|
} catch {
|
|
52
108
|
return false;
|
|
53
109
|
}
|
|
54
110
|
}
|
|
55
111
|
try {
|
|
56
|
-
// `command -v` is a POSIX sh builtin
|
|
57
|
-
|
|
112
|
+
// `command -v` is a POSIX sh builtin. Pass the configured name as argv, never shell text:
|
|
113
|
+
// an unowned override is still operator data and must not become a doctor injection surface.
|
|
114
|
+
execFileSync("sh", ["-c", 'command -v -- "$1" >/dev/null 2>&1', "sh", cmd], { stdio: "ignore" });
|
|
58
115
|
return true;
|
|
59
116
|
} catch {
|
|
60
117
|
return false;
|
|
@@ -64,24 +121,41 @@ function resolvable(cmd: string): boolean {
|
|
|
64
121
|
log("[pi-provider doctor]");
|
|
65
122
|
|
|
66
123
|
// Read via the SSOT — a malformed settings file THROWS here (fail-loud, named file).
|
|
67
|
-
let
|
|
68
|
-
let
|
|
124
|
+
let effectiveEntry: BridgeEntry | null | undefined;
|
|
125
|
+
let userScopeDesc = "";
|
|
126
|
+
let projScopeDesc = "";
|
|
127
|
+
let effectiveScope = "none";
|
|
69
128
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
129
|
+
const userSettings = readProviderSettingsFile(globalPath).settings;
|
|
130
|
+
const projSettings = readProviderSettingsFile(projectPath).settings;
|
|
131
|
+
userScopeDesc = describeScope(userSettings);
|
|
132
|
+
projScopeDesc = describeScope(projSettings);
|
|
133
|
+
// EFFECTIVE = project shadows user per-NAME. The whole ENTRY shadows, not a field of it, so
|
|
134
|
+
// command, args and env always come from one scope; the scope label is decided by which map
|
|
135
|
+
// owns the key, which is exactly what the merge spread resolves.
|
|
136
|
+
effectiveEntry = mergedBridgeEntry(userSettings, projSettings);
|
|
137
|
+
const ownsKey = (m: Record<string, unknown> | undefined): boolean => m !== undefined && Object.hasOwn(m, KEY);
|
|
138
|
+
effectiveScope = ownsKey(projSettings.mcpServers)
|
|
139
|
+
? "project"
|
|
140
|
+
: ownsKey(userSettings.mcpServers)
|
|
141
|
+
? "user(global)"
|
|
142
|
+
: "none";
|
|
72
143
|
} catch (err) {
|
|
73
144
|
log(` FAIL: ${err instanceof Error ? err.message : String(err)}`);
|
|
74
145
|
process.exit(1);
|
|
75
146
|
}
|
|
76
147
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
148
|
+
const effectiveDesc =
|
|
149
|
+
effectiveEntry === undefined
|
|
150
|
+
? "entwurf-bridge NOT configured"
|
|
151
|
+
: effectiveEntry === null
|
|
152
|
+
? "configured as an http/sse server"
|
|
153
|
+
: `'${effectiveEntry.command}'`;
|
|
80
154
|
|
|
81
155
|
log("── scopes (project shadows user per-name)");
|
|
82
|
-
log(` user(global) ${globalPath}: ${
|
|
83
|
-
log(` project ${projectPath}: ${
|
|
84
|
-
log(` EFFECTIVE (${effectiveScope}): ${
|
|
156
|
+
log(` user(global) ${globalPath}: ${userScopeDesc}`);
|
|
157
|
+
log(` project ${projectPath}: ${projScopeDesc}`);
|
|
158
|
+
log(` EFFECTIVE (${effectiveScope}): ${effectiveDesc}`);
|
|
85
159
|
|
|
86
160
|
// install-state ownership (user scope). absent state on a configured effective is either a
|
|
87
161
|
// pre-Task-2 install or a user-override we deliberately did not own.
|
|
@@ -100,36 +174,66 @@ if (existsSync(statePath)) {
|
|
|
100
174
|
}
|
|
101
175
|
|
|
102
176
|
log("── verdict");
|
|
103
|
-
if (
|
|
177
|
+
if (effectiveEntry === undefined) {
|
|
104
178
|
log(
|
|
105
179
|
" note: no entwurfProvider.mcpServers.entwurf-bridge in any scope (never installed — this is the '?'; run ./run.sh setup).",
|
|
106
180
|
);
|
|
107
|
-
} else if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
181
|
+
} else if (effectiveEntry === null) {
|
|
182
|
+
// A real, well-formed config that this bridge cannot be: entwurf-bridge is a stdio server, so
|
|
183
|
+
// an http/sse entry under our key means pi would connect to something that is not us. Nothing
|
|
184
|
+
// here is spawnable, so there is no boot to prove — say that plainly rather than reporting the
|
|
185
|
+
// absence of a failure as ok.
|
|
186
|
+
log(
|
|
187
|
+
` FAIL: entwurf-bridge is configured as an http/sse server in ${effectiveScope} scope. This bridge is a stdio server — pi would reach something that is not entwurf, and no boot evidence is possible. Restore a stdio entry with ./run.sh setup, or remove the key if the override is deliberate.`,
|
|
188
|
+
);
|
|
189
|
+
hardFail = 1;
|
|
114
190
|
} else {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
191
|
+
const cmd = effectiveEntry.command;
|
|
192
|
+
const isBare = cmd === BARE;
|
|
193
|
+
const isLegacyManaged = cmd.endsWith("/entwurf/mcp/entwurf-bridge/start.sh");
|
|
194
|
+
|
|
195
|
+
// Runtime truth is independent of ownership truth. An unowned override remains the operator's
|
|
196
|
+
// choice, but it still shadows the stable bridge in production; calling a dead override green
|
|
197
|
+
// repeats #81 under a different spelling. Probe the exact normalized command + args + env for
|
|
198
|
+
// EVERY effective stdio entry, then classify who owns that entry separately below.
|
|
199
|
+
if (!resolvable(cmd)) {
|
|
200
|
+
log(` FAIL: effective command '${cmd}' does NOT resolve or is not executable.`);
|
|
124
201
|
hardFail = 1;
|
|
125
|
-
} else if (isLegacyManaged) {
|
|
126
|
-
log(
|
|
127
|
-
` note: effective is our LEGACY managed repo path ('${effectiveCmd}'), not yet adopted to the bare stable bin. Run ./run.sh setup to normalize (this is the pre-Task-2 '?').`,
|
|
128
|
-
);
|
|
129
202
|
} else {
|
|
130
|
-
log(
|
|
131
|
-
|
|
132
|
-
|
|
203
|
+
log(` effective command '${cmd}' RESOLVES — probing the exact configured invocation…`);
|
|
204
|
+
const probe = await probeBridgeCommand({
|
|
205
|
+
command: cmd,
|
|
206
|
+
args: effectiveEntry.args,
|
|
207
|
+
env: { ...process.env, ...effectiveEntry.env },
|
|
208
|
+
});
|
|
209
|
+
if (probe.ok) {
|
|
210
|
+
log(
|
|
211
|
+
isBare
|
|
212
|
+
? ` ok: effective command is the bare stable bin '${BARE}' and it BOOTS — ${probe.detail}`
|
|
213
|
+
: ` runtime: configured override BOOTS — ${probe.detail}`,
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
log(` FAIL: effective invocation does NOT serve MCP [${probe.reason}] — ${probe.detail}`);
|
|
217
|
+
log(` Diagnose its launcher and configured args/env; entwurf will not overwrite an unowned command.`);
|
|
218
|
+
hardFail = 1;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!isBare) {
|
|
223
|
+
// Ownership classification never rounds a broken runtime up to green. It only says who may
|
|
224
|
+
// repair the non-canonical entry after the independent boot verdict above.
|
|
225
|
+
if (ownership && ownership !== "user-override") {
|
|
226
|
+
log(` FAIL: state owns entwurf-bridge (ownership=${ownership}) but the effective command drifted to '${cmd}'.`);
|
|
227
|
+
hardFail = 1;
|
|
228
|
+
} else if (isLegacyManaged) {
|
|
229
|
+
log(
|
|
230
|
+
` note: effective is our LEGACY managed repo path ('${cmd}'), not yet adopted to the bare stable bin. Run ./run.sh setup to normalize.`,
|
|
231
|
+
);
|
|
232
|
+
} else {
|
|
233
|
+
log(
|
|
234
|
+
` note: entwurf-bridge is an UNOWNED override ('${cmd}'). Runtime was judged above; run ./run.sh setup only if you choose to adopt the bare stable bin.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
133
237
|
}
|
|
134
238
|
}
|
|
135
239
|
|
|
@@ -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
|
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"claim": "REUSE-CARRIES-CHILD-DIAGNOSTICS",
|
|
55
55
|
"title": "the reuse path drops the child's stderr tail again — exactly the live sonnet failure that showed only \"ACP connection closed\"",
|
|
56
56
|
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
57
|
-
"find": ["\t\t\tfinishError(err, aborted, session.stderrTail);"],
|
|
58
|
-
"replace": ["\t\t\tfinishError(err, aborted);"],
|
|
57
|
+
"find": ["\t\t\tfinishError(err, aborted, session.stderrTail, lifecycle);"],
|
|
58
|
+
"replace": ["\t\t\tfinishError(err, aborted, undefined, lifecycle);"],
|
|
59
59
|
"gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
|
|
60
60
|
"timeoutSeconds": 180,
|
|
61
61
|
"signature": "[QK:REUSE-CARRIES-CHILD-DIAGNOSTICS]",
|
|
@@ -89,12 +89,34 @@
|
|
|
89
89
|
"claim": "RETIRED-TEARDOWN-NOT-ANNOUNCED",
|
|
90
90
|
"title": "a teardown we performed ourselves is reported as a death — every ordinary turn-scoped turn cries wolf about its own cleanup",
|
|
91
91
|
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
92
|
-
"find": ["\t} else if (!session.retiring) {"],
|
|
92
|
+
"find": ["\t} else if (!session.retiring && !session.reporting) {"],
|
|
93
93
|
"replace": ["\t} else {"],
|
|
94
94
|
"gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
|
|
95
95
|
"timeoutSeconds": 180,
|
|
96
96
|
"signature": "[QK:RETIRED-TEARDOWN-NOT-ANNOUNCED]",
|
|
97
97
|
"signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"claim": "EOF-FIRST-CARRIES-CHILD-END",
|
|
101
|
+
"title": "the failing turn seals the moment the transport closes, without waiting for the child's exit — re-plants the #72 field signature (a bare \"ACP connection closed\" naming neither exit code nor signal)",
|
|
102
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
103
|
+
"find": ["\tawait settleChildEnd(session.childEnd, CHILD_END_SETTLE_MS);"],
|
|
104
|
+
"replace": ["\tvoid CHILD_END_SETTLE_MS;"],
|
|
105
|
+
"gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
|
|
106
|
+
"timeoutSeconds": 180,
|
|
107
|
+
"signature": "[QK:EOF-FIRST-CARRIES-CHILD-END]",
|
|
108
|
+
"signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"claim": "CHILD-END-SILENCE-BOUNDED",
|
|
112
|
+
"title": "the post-mortem window grows past the bound it contracts for — a turn whose child never reports an end waits on a clock the caller did not choose",
|
|
113
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
114
|
+
"find": ["\tawait settleChildEnd(session.childEnd, CHILD_END_SETTLE_MS);"],
|
|
115
|
+
"replace": ["\tawait settleChildEnd(session.childEnd, 2_500);"],
|
|
116
|
+
"gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
|
|
117
|
+
"timeoutSeconds": 180,
|
|
118
|
+
"signature": "[QK:CHILD-END-SILENCE-BOUNDED]",
|
|
119
|
+
"signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
|
|
98
120
|
}
|
|
99
121
|
]
|
|
100
122
|
}
|