@junghanacs/entwurf 0.12.5 → 0.12.7
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 +31 -17
- package/BASELINE.md +42 -8
- package/CHANGELOG.md +44 -0
- package/DELIVERY.md +71 -14
- package/README.md +77 -45
- package/VERIFY.md +33 -38
- package/docs/setup-clean-host.md +80 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
- package/mcp/entwurf-bridge/src/index.ts +101 -67
- package/mcp/entwurf-bridge/test.sh +1 -1
- package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
- package/package.json +10 -6
- package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
- package/pi-extensions/lib/entwurf-self-address.ts +58 -15
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
- package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
- package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
- package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
- package/pi-extensions/lib/meta-sender-identity.ts +154 -0
- package/pi-extensions/lib/native-push/adapter.ts +255 -0
- package/pi-extensions/lib/native-push/register.ts +99 -0
- package/run.sh +974 -248
- package/scripts/agy-bridge-config.py +446 -0
- package/scripts/agy-bridge.sh +359 -0
- package/scripts/agy-hooks-bridge.sh +193 -0
- package/scripts/agy-hooks-config.py +257 -0
- package/scripts/agy-imprint.sh +28 -0
- package/scripts/agy-imprint.ts +193 -0
- package/scripts/agy-statusline-bridge.sh +176 -0
- package/scripts/agy-statusline-config.py +213 -0
- package/scripts/agy-statusline.sh +256 -0
- package/scripts/build-bridge.sh +20 -0
- package/scripts/check-agy-sender-identity.ts +364 -0
- package/scripts/check-entwurf-bridge-boot.ts +8 -2
- package/scripts/check-entwurf-deliverability.ts +34 -0
- package/scripts/check-entwurf-self-address.ts +78 -11
- package/scripts/check-entwurf-v2-contract.ts +136 -1
- package/scripts/check-entwurf-v2-decider.ts +95 -1
- package/scripts/check-entwurf-v2-matrix.ts +14 -3
- package/scripts/check-entwurf-v2-native-push.ts +193 -0
- package/scripts/check-entwurf-v2-production.ts +68 -1
- package/scripts/check-entwurf-v2-runner.ts +58 -0
- package/scripts/check-entwurf-v2-surface.ts +35 -0
- package/scripts/check-install-surface.ts +357 -0
- package/scripts/check-native-push-adapter.ts +319 -0
- package/scripts/check-native-push-register.ts +130 -0
- package/scripts/dev-bin.sh +195 -0
- package/scripts/doctor-pi-provider.ts +140 -0
- package/scripts/meta-bridge-doctor.sh +47 -9
- package/scripts/meta-bridge-install.sh +20 -17
- package/scripts/meta-bridge-state.py +40 -2
- package/scripts/meta-bridge-uninstall.sh +23 -12
- package/scripts/register-pi-package.py +183 -0
- package/scripts/register-pi-provider.py +287 -0
- package/scripts/smoke-agy-hooks-state.sh +172 -0
- package/scripts/smoke-agy-install-state.sh +660 -0
- package/scripts/smoke-agy-native-push-live.ts +243 -0
- package/scripts/smoke-agy-statusline-state.sh +300 -0
- package/scripts/smoke-meta-async-drift.sh +9 -2
- package/scripts/smoke-meta-install-state.sh +178 -8
- package/scripts/smoke-pi-provider-state.sh +182 -0
- package/scripts/smoke-user-scope-citizen.sh +191 -0
- package/scripts/with-dist-lock.sh +81 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""register-pi-provider — own entwurfProvider.mcpServers.entwurf-bridge in a pi settings.json
|
|
3
|
+
(#46 Task 2). The provider analog of register-pi-package.py: user + project scope, same
|
|
4
|
+
predicate/idempotency, ONE shared SSOT. The owned keyset is the SINGLE key
|
|
5
|
+
`entwurfProvider.mcpServers.entwurf-bridge` (봉인계약 4); every sibling
|
|
6
|
+
(skillPlugins/appendSystemPrompt/showToolNotifications/other mcpServers) is preserved untouched.
|
|
7
|
+
|
|
8
|
+
The command written is ALWAYS the bare stable bin `entwurf-bridge` (dev AND installed — the repo
|
|
9
|
+
start.sh path lives only in the dev-bin symlink state, never in a settings file; #46 tripwire).
|
|
10
|
+
|
|
11
|
+
ownership classification of the EXISTING command (GPT caveat: an old managed repo path is NOT a
|
|
12
|
+
user value, so its inverse is key-removal, never a restore):
|
|
13
|
+
absent key not present → we create it
|
|
14
|
+
managed-current command == entwurf-bridge → already ours (idempotent)
|
|
15
|
+
managed-legacy command is our old repo start.sh
|
|
16
|
+
(== <repo>/mcp/entwurf-bridge/start.sh, or endswith
|
|
17
|
+
/entwurf/mcp/entwurf-bridge/start.sh) → normalize to the bare bin
|
|
18
|
+
user-override anything else → DO NOT overwrite, DO NOT own
|
|
19
|
+
(no state; doctor: unowned)
|
|
20
|
+
|
|
21
|
+
Scope asymmetry (봉인계약 4·6, REASONED — not the unfounded asymmetry dev-bin (B) rejected):
|
|
22
|
+
user ~/.pi/agent/settings.json — GLOBAL, durable,파급s to every cwd → install-state +
|
|
23
|
+
honest inverse. absent/managed-* → remove OUR key on uninstall (a legacy repo path is
|
|
24
|
+
NOT restored); user-override → no state taken, left untouched. A parent object emptied
|
|
25
|
+
of our key is tidied; siblings kept.
|
|
26
|
+
project <repo>/.pi/settings.json — checkout-LOCAL, disposable, re-creatable, and `run.sh
|
|
27
|
+
remove` already covers it → NO state. install normalizes the command; remove strips
|
|
28
|
+
our-managed shapes (the bare bin AND the legacy repo path). project-scope state is a
|
|
29
|
+
NAMED FOLLOW-UP (NEXT), deliberately out of this lane.
|
|
30
|
+
|
|
31
|
+
Subcommands:
|
|
32
|
+
install <settings_path> <repo_dir> --scope <user|project> [--state <state_path>]
|
|
33
|
+
remove <settings_path> <repo_dir> --scope <user|project> [--state <state_path>]
|
|
34
|
+
|
|
35
|
+
Exit codes: 0 ok · 2 no-state · 3 refuse-symlink · 4 invalid-json · 5 usage.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
|
|
42
|
+
SERVER_KEY = "entwurf-bridge"
|
|
43
|
+
BARE_COMMAND = "entwurf-bridge"
|
|
44
|
+
STATE_SCHEMA_VERSION = 1
|
|
45
|
+
|
|
46
|
+
# Legacy-bundled MCP names prior installers wrote; the current cutover supersedes them. Pruned at
|
|
47
|
+
# install time (and project remove) ONLY when the command matches our bundled start.sh path, so a
|
|
48
|
+
# user-customized command is left alone. Mirrors run.sh's pre-Task-2 inline prune (no regression).
|
|
49
|
+
LEGACY_BUNDLED = {
|
|
50
|
+
"session-bridge": "retracted in 0.4.14, issue #7",
|
|
51
|
+
"pi-tools-bridge": "renamed to entwurf-bridge in 0.11 S2 cutover",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _prune_legacy(servers: dict, repo_dir: str) -> None:
|
|
56
|
+
for name, reason in LEGACY_BUNDLED.items():
|
|
57
|
+
existing = servers.get(name)
|
|
58
|
+
if not isinstance(existing, dict):
|
|
59
|
+
continue
|
|
60
|
+
cmd = existing.get("command")
|
|
61
|
+
if not isinstance(cmd, str):
|
|
62
|
+
continue
|
|
63
|
+
if cmd == f"{repo_dir}/mcp/{name}/start.sh" or cmd.endswith(f"/entwurf/mcp/{name}/start.sh"):
|
|
64
|
+
del servers[name]
|
|
65
|
+
sys.stdout.write(f"pruned legacy entwurfProvider.mcpServers.{name} ({reason})\n")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _die(code: int, msg: str) -> "None":
|
|
69
|
+
sys.stderr.write(msg.rstrip("\n") + "\n")
|
|
70
|
+
sys.exit(code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _atomic_write(path: str, text: str) -> None:
|
|
74
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
75
|
+
tmp = f"{path}.tmp-{os.getpid()}"
|
|
76
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
77
|
+
fh.write(text)
|
|
78
|
+
os.replace(tmp, path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load(path: str) -> dict:
|
|
82
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
83
|
+
raw = fh.read()
|
|
84
|
+
if raw.strip() == "":
|
|
85
|
+
return {}
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(raw)
|
|
88
|
+
except json.JSONDecodeError as err:
|
|
89
|
+
_die(4, f"register-pi-provider: {path} is not valid JSON: {err}")
|
|
90
|
+
if not isinstance(data, dict):
|
|
91
|
+
_die(4, f"register-pi-provider: {path} top-level must be a JSON object")
|
|
92
|
+
return data
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _dump(data: dict) -> str:
|
|
96
|
+
return json.dumps(data, indent=2) + "\n"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _now() -> str:
|
|
100
|
+
import datetime
|
|
101
|
+
|
|
102
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _classify(existing_cmd, repo_dir: str) -> str:
|
|
106
|
+
if existing_cmd is None:
|
|
107
|
+
return "absent"
|
|
108
|
+
if existing_cmd == BARE_COMMAND:
|
|
109
|
+
return "managed-current"
|
|
110
|
+
if isinstance(existing_cmd, str) and (
|
|
111
|
+
existing_cmd == f"{repo_dir}/mcp/{SERVER_KEY}/start.sh"
|
|
112
|
+
or existing_cmd.endswith(f"/entwurf/mcp/{SERVER_KEY}/start.sh")
|
|
113
|
+
):
|
|
114
|
+
return "managed-legacy"
|
|
115
|
+
return "user-override"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _provider_servers(data: dict, create: bool):
|
|
119
|
+
"""Return (provider, servers) dicts. When create, setdefault them; else may be None."""
|
|
120
|
+
if create:
|
|
121
|
+
provider = data.setdefault("entwurfProvider", {})
|
|
122
|
+
if not isinstance(provider, dict):
|
|
123
|
+
_die(4, "register-pi-provider: entwurfProvider is not an object")
|
|
124
|
+
servers = provider.setdefault("mcpServers", {})
|
|
125
|
+
if not isinstance(servers, dict):
|
|
126
|
+
_die(4, "register-pi-provider: entwurfProvider.mcpServers is not an object")
|
|
127
|
+
return provider, servers
|
|
128
|
+
provider = data.get("entwurfProvider")
|
|
129
|
+
if not isinstance(provider, dict):
|
|
130
|
+
return None, None
|
|
131
|
+
servers = provider.get("mcpServers")
|
|
132
|
+
if not isinstance(servers, dict):
|
|
133
|
+
return provider, None
|
|
134
|
+
return provider, servers
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str) -> None:
|
|
138
|
+
if os.path.islink(settings_path):
|
|
139
|
+
target = os.readlink(settings_path)
|
|
140
|
+
_die(3, f"register-pi-provider: refusing to adopt {settings_path} — it is a symlink to {target} "
|
|
141
|
+
f"(someone else's SSOT). Manage it there, or replace it with a regular file, then retry.")
|
|
142
|
+
|
|
143
|
+
data = _load(settings_path) if os.path.exists(settings_path) else {}
|
|
144
|
+
provider, servers = _provider_servers(data, create=True)
|
|
145
|
+
_prune_legacy(servers, repo_dir) # independent of entwurf-bridge ownership
|
|
146
|
+
existing = servers.get(SERVER_KEY)
|
|
147
|
+
existing_cmd = existing.get("command") if isinstance(existing, dict) else existing
|
|
148
|
+
ownership = _classify(existing_cmd, repo_dir)
|
|
149
|
+
|
|
150
|
+
if ownership == "user-override":
|
|
151
|
+
# DO NOT overwrite our key, DO NOT own it (no state). doctor reports it as unowned. Still
|
|
152
|
+
# persist so any legacy prune above (and a materialized parent) is written.
|
|
153
|
+
sys.stdout.write(
|
|
154
|
+
f"install: preserved entwurfProvider.mcpServers.{SERVER_KEY} (user override, NOT owned: {existing_cmd!r})\n"
|
|
155
|
+
)
|
|
156
|
+
_atomic_write(settings_path, _dump(data))
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# absent / managed-current / managed-legacy → normalize to the bare stable bin.
|
|
160
|
+
newval = {"command": BARE_COMMAND}
|
|
161
|
+
# preserve non-empty custom args if the operator set them; else default [].
|
|
162
|
+
if isinstance(existing, dict) and existing.get("args") not in (None, []):
|
|
163
|
+
newval["args"] = existing["args"]
|
|
164
|
+
else:
|
|
165
|
+
newval["args"] = []
|
|
166
|
+
servers[SERVER_KEY] = newval
|
|
167
|
+
_atomic_write(settings_path, _dump(data))
|
|
168
|
+
sys.stdout.write(
|
|
169
|
+
f"install: {ownership} → entwurfProvider.mcpServers.{SERVER_KEY} = {BARE_COMMAND} (bare stable bin)\n"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if scope == "user":
|
|
173
|
+
if state_path:
|
|
174
|
+
state = {
|
|
175
|
+
"schemaVersion": STATE_SCHEMA_VERSION,
|
|
176
|
+
"managedSettingsPath": os.path.abspath(settings_path),
|
|
177
|
+
"scope": "user",
|
|
178
|
+
"key": f"entwurfProvider.mcpServers.{SERVER_KEY}",
|
|
179
|
+
"command": BARE_COMMAND,
|
|
180
|
+
"ownership": ownership, # absent | managed-current | managed-legacy
|
|
181
|
+
"preimage": existing, # raw prior value (audit only; NOT restored)
|
|
182
|
+
"installedAt": _now(),
|
|
183
|
+
}
|
|
184
|
+
_atomic_write(state_path, _dump(state))
|
|
185
|
+
# reload-timing honesty (봉인: no implicit reload assumption): a running pi does not
|
|
186
|
+
# re-read settings mid-session — only new sessions pick this up.
|
|
187
|
+
sys.stdout.write(
|
|
188
|
+
"install: existing pi sessions unaffected until restart; new sessions pick up the change\n"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def cmd_remove(settings_path: str, repo_dir: str, scope: str, state_path: str) -> None:
|
|
193
|
+
if scope == "user":
|
|
194
|
+
if not state_path or not os.path.exists(state_path):
|
|
195
|
+
sys.stdout.write("remove: no install-state — nothing to undo (never owned, or already removed).\n")
|
|
196
|
+
return
|
|
197
|
+
state = _load(state_path)
|
|
198
|
+
managed = state.get("managedSettingsPath")
|
|
199
|
+
if not isinstance(managed, str):
|
|
200
|
+
_die(4, f"register-pi-provider: install-state {state_path} has no managedSettingsPath")
|
|
201
|
+
if os.path.islink(managed):
|
|
202
|
+
_die(3, f"register-pi-provider: refusing to uninstall — {managed} became a symlink since install.")
|
|
203
|
+
if os.path.exists(managed):
|
|
204
|
+
data = _load(managed)
|
|
205
|
+
provider, servers = _provider_servers(data, create=False)
|
|
206
|
+
# honest inverse: absent/managed-* → remove OUR key (a legacy repo path is NOT
|
|
207
|
+
# restored — it was our old managed value, not a user value).
|
|
208
|
+
if isinstance(servers, dict):
|
|
209
|
+
servers.pop(SERVER_KEY, None)
|
|
210
|
+
if not servers:
|
|
211
|
+
provider.pop("mcpServers", None)
|
|
212
|
+
if isinstance(provider, dict) and not provider:
|
|
213
|
+
data.pop("entwurfProvider", None)
|
|
214
|
+
_atomic_write(managed, _dump(data))
|
|
215
|
+
os.remove(state_path)
|
|
216
|
+
sys.stdout.write(f"remove: removed our {SERVER_KEY} key (ownership={state.get('ownership')}) from {managed}\n")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# project scope: no state — strip our-managed shapes (bare bin OR legacy repo path).
|
|
220
|
+
if not os.path.exists(settings_path):
|
|
221
|
+
sys.stdout.write(f"remove: nothing to do ({settings_path} missing)\n")
|
|
222
|
+
return
|
|
223
|
+
if os.path.islink(settings_path):
|
|
224
|
+
_die(3, f"register-pi-provider: refusing to touch {settings_path} — it is a symlink.")
|
|
225
|
+
data = _load(settings_path)
|
|
226
|
+
provider, servers = _provider_servers(data, create=False)
|
|
227
|
+
if not isinstance(servers, dict):
|
|
228
|
+
sys.stdout.write("remove: no entwurfProvider.mcpServers — nothing to do.\n")
|
|
229
|
+
return
|
|
230
|
+
_prune_legacy(servers, repo_dir)
|
|
231
|
+
existing = servers.get(SERVER_KEY)
|
|
232
|
+
existing_cmd = existing.get("command") if isinstance(existing, dict) else existing
|
|
233
|
+
ownership = _classify(existing_cmd, repo_dir)
|
|
234
|
+
if ownership in ("managed-current", "managed-legacy"):
|
|
235
|
+
del servers[SERVER_KEY]
|
|
236
|
+
sys.stdout.write(f"remove: removed entwurfProvider.mcpServers.{SERVER_KEY} ({ownership})\n")
|
|
237
|
+
if not servers:
|
|
238
|
+
provider.pop("mcpServers", None)
|
|
239
|
+
if isinstance(provider, dict) and not provider:
|
|
240
|
+
data.pop("entwurfProvider", None)
|
|
241
|
+
elif ownership == "user-override":
|
|
242
|
+
sys.stdout.write(f"remove: preserved entwurfProvider.mcpServers.{SERVER_KEY} (user override: {existing_cmd!r})\n")
|
|
243
|
+
else: # absent
|
|
244
|
+
sys.stdout.write(f"remove: entwurfProvider.mcpServers.{SERVER_KEY} already absent.\n")
|
|
245
|
+
_atomic_write(settings_path, _dump(data))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _parse(argv: list):
|
|
249
|
+
# positional: settings_path repo_dir ; flags: --scope <s> [--state <p>]
|
|
250
|
+
pos, scope, state_path = [], None, ""
|
|
251
|
+
i = 0
|
|
252
|
+
while i < len(argv):
|
|
253
|
+
a = argv[i]
|
|
254
|
+
if a == "--scope":
|
|
255
|
+
i += 1
|
|
256
|
+
scope = argv[i] if i < len(argv) else None
|
|
257
|
+
elif a == "--state":
|
|
258
|
+
i += 1
|
|
259
|
+
state_path = argv[i] if i < len(argv) else ""
|
|
260
|
+
else:
|
|
261
|
+
pos.append(a)
|
|
262
|
+
i += 1
|
|
263
|
+
return pos, scope, state_path
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main(argv: list) -> None:
|
|
267
|
+
if len(argv) < 2:
|
|
268
|
+
_die(5, "usage: register-pi-provider.py <install|remove> <settings_path> <repo_dir> --scope <user|project> [--state <path>]")
|
|
269
|
+
sub = argv[1]
|
|
270
|
+
pos, scope, state_path = _parse(argv[2:])
|
|
271
|
+
if sub not in ("install", "remove"):
|
|
272
|
+
_die(5, f"register-pi-provider.py: unknown subcommand {sub!r}")
|
|
273
|
+
if len(pos) != 2:
|
|
274
|
+
_die(5, f"usage: register-pi-provider.py {sub} <settings_path> <repo_dir> --scope <user|project> [--state <path>]")
|
|
275
|
+
if scope not in ("user", "project"):
|
|
276
|
+
_die(5, "register-pi-provider.py: --scope must be user or project")
|
|
277
|
+
settings_path, repo_dir = pos[0], str(os.path.abspath(pos[1]))
|
|
278
|
+
if scope == "user" and not state_path:
|
|
279
|
+
_die(5, "register-pi-provider.py: --state is required for --scope user")
|
|
280
|
+
if sub == "install":
|
|
281
|
+
cmd_install(settings_path, repo_dir, scope, state_path)
|
|
282
|
+
else:
|
|
283
|
+
cmd_remove(settings_path, repo_dir, scope, state_path)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
main(sys.argv)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# smoke-agy-hooks-state — regression gate for Antigravity PreInvocation birth imprint wiring.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
6
|
+
BRIDGE="$REPO_DIR/scripts/agy-hooks-bridge.sh"
|
|
7
|
+
IMPRINT="$REPO_DIR/scripts/agy-imprint.ts"
|
|
8
|
+
|
|
9
|
+
pass=0
|
|
10
|
+
ok() { printf ' ok %s\n' "$1"; pass=$((pass + 1)); }
|
|
11
|
+
die() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
|
|
12
|
+
want() { eval "$2" && ok "$1" || die "$1"; }
|
|
13
|
+
|
|
14
|
+
REPO_BEFORE="$(cd "$REPO_DIR" && git status --porcelain)"
|
|
15
|
+
SB="$(mktemp -d)"
|
|
16
|
+
trap 'rm -rf "$SB"' EXIT
|
|
17
|
+
export HOME="$SB/home"
|
|
18
|
+
export XDG_DATA_HOME="$SB/xdg"
|
|
19
|
+
export XDG_STATE_HOME="$SB/state"
|
|
20
|
+
HOOKS="$HOME/.gemini/config/plugins/entwurf-agy-imprint/hooks.json"
|
|
21
|
+
LEGACY="$HOME/.gemini/antigravity-cli/hooks.json"
|
|
22
|
+
LEGACY_PLUGIN="$HOME/.gemini/config/plugins/entwurf-probe/hooks.json"
|
|
23
|
+
LEGACY_PLUGIN_JSON="$(dirname "$LEGACY_PLUGIN")/plugin.json"
|
|
24
|
+
PLUGIN_JSON="$(dirname "$HOOKS")/plugin.json"
|
|
25
|
+
STATE="$XDG_DATA_HOME/entwurf/agy-hooks/install-state.json"
|
|
26
|
+
mkdir -p "$(dirname "$HOOKS")" "$(dirname "$LEGACY")" "$(dirname "$LEGACY_PLUGIN")" "$SB/bin"
|
|
27
|
+
printf '#!/usr/bin/env bash\necho {\\"injectSteps\\":[]}\n' > "$SB/bin/entwurf-agy-imprint"
|
|
28
|
+
chmod +x "$SB/bin/entwurf-agy-imprint"
|
|
29
|
+
export PATH="$SB/bin:$PATH"
|
|
30
|
+
|
|
31
|
+
printf '{"keep-me":{"PreInvocation":[{"type":"command","command":"echo keep"}]}}\n' > "$HOOKS"
|
|
32
|
+
printf '{"agy-birth-probe":{"PreInvocation":[{"type":"command","command":"cat > /tmp/old"}]},"entwurf-agy-imprint":{"PreInvocation":[{"type":"command","command":"old-imprint"}]},"keep-legacy":{"PreInvocation":[{"type":"command","command":"echo legacy"}]}}\n' > "$LEGACY"
|
|
33
|
+
printf '{"name":"entwurf-probe","version":"1.0.0"}\n' > "$LEGACY_PLUGIN_JSON"
|
|
34
|
+
printf '{"agy-birth-probe":{"PreInvocation":[{"type":"command","command":"cat > /tmp/plugin-old"}]}}\n' > "$LEGACY_PLUGIN"
|
|
35
|
+
bash "$BRIDGE" install >/dev/null
|
|
36
|
+
want "install: imprint hook configured" \
|
|
37
|
+
"python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d[\"entwurf-agy-imprint\"][\"PreInvocation\"][0][\"command\"]==\"entwurf-agy-imprint\" else 1)' '$HOOKS'"
|
|
38
|
+
want "install: unrelated hook preserved" \
|
|
39
|
+
"python3 -c 'import json,sys; sys.exit(0 if \"keep-me\" in json.load(open(sys.argv[1])) else 1)' '$HOOKS'"
|
|
40
|
+
want "install: state written" "[ -f '$STATE' ]"
|
|
41
|
+
want "install: plugin.json materialized" "[ -f '$PLUGIN_JSON' ]"
|
|
42
|
+
want "install: plugin.json uses non-probe name" "grep -q 'entwurf-agy-imprint' '$PLUGIN_JSON'"
|
|
43
|
+
want "install: stable command recorded" \
|
|
44
|
+
"python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"command\"])' '$STATE' | grep -qx entwurf-agy-imprint"
|
|
45
|
+
want "install: legacy top-level agy-birth-probe removed" \
|
|
46
|
+
"python3 -c 'import json,sys; sys.exit(0 if \"agy-birth-probe\" not in json.load(open(sys.argv[1])) else 1)' '$LEGACY'"
|
|
47
|
+
want "install: legacy top-level duplicate imprint removed" \
|
|
48
|
+
"python3 -c 'import json,sys; sys.exit(0 if \"entwurf-agy-imprint\" not in json.load(open(sys.argv[1])) else 1)' '$LEGACY'"
|
|
49
|
+
want "install: unrelated legacy hook preserved" \
|
|
50
|
+
"python3 -c 'import json,sys; sys.exit(0 if \"keep-legacy\" in json.load(open(sys.argv[1])) else 1)' '$LEGACY'"
|
|
51
|
+
want "install: old entwurf-probe plugin hook file removed" "[ ! -e '$LEGACY_PLUGIN' ]"
|
|
52
|
+
want "install: old entwurf-probe plugin.json removed" "[ ! -e '$LEGACY_PLUGIN_JSON' ]"
|
|
53
|
+
|
|
54
|
+
DOC_OUT="$(bash "$BRIDGE" doctor)"; DOC_RC=$?
|
|
55
|
+
want "doctor: exits 0" "[ '$DOC_RC' -eq 0 ]"
|
|
56
|
+
want "doctor: command resolvable" "printf '%s' \"\$DOC_OUT\" | grep -q \"configured → 'entwurf-agy-imprint' (resolvable)\""
|
|
57
|
+
want "doctor: state confirms configured" "printf '%s' \"\$DOC_OUT\" | grep -q 'still configure entwurf-agy-imprint'"
|
|
58
|
+
want "doctor: legacy top-level guard reports clean" "printf '%s' \"\$DOC_OUT\" | grep -q 'legacy top-level: present but carries no entwurf/probe hook keys'"
|
|
59
|
+
want "doctor: old plugin guard reports absent" "printf '%s' \"\$DOC_OUT\" | grep -q 'legacy plugin(entwurf-probe): absent'"
|
|
60
|
+
|
|
61
|
+
python3 -c 'import json,sys; p=sys.argv[1]; d=json.load(open(p)); d["agy-birth-probe"]={"PreInvocation":[{"type":"command","command":"old"}]}; json.dump(d,open(p,"w"))' "$LEGACY"
|
|
62
|
+
if bash "$BRIDGE" doctor >/dev/null 2>&1; then die "legacy-drift: doctor should FAIL when top-level carries old probe key"; fi
|
|
63
|
+
ok "legacy-drift: doctor FAILS on top-level agy-birth-probe"
|
|
64
|
+
bash "$BRIDGE" install >/dev/null
|
|
65
|
+
want "legacy-drift: re-install cleans top-level again" \
|
|
66
|
+
"python3 -c 'import json,sys; sys.exit(0 if \"agy-birth-probe\" not in json.load(open(sys.argv[1])) else 1)' '$LEGACY'"
|
|
67
|
+
mkdir -p "$(dirname "$LEGACY_PLUGIN")"
|
|
68
|
+
printf '{"agy-birth-probe":{"PreInvocation":[{"type":"command","command":"old-plugin"}]}}\n' > "$LEGACY_PLUGIN"
|
|
69
|
+
if bash "$BRIDGE" doctor >/dev/null 2>&1; then die "legacy-plugin-drift: doctor should FAIL when old plugin carries probe key"; fi
|
|
70
|
+
ok "legacy-plugin-drift: doctor FAILS on old entwurf-probe plugin key"
|
|
71
|
+
bash "$BRIDGE" install >/dev/null
|
|
72
|
+
want "legacy-plugin-drift: re-install removes old plugin hook file" "[ ! -e '$LEGACY_PLUGIN' ]"
|
|
73
|
+
|
|
74
|
+
python3 -c 'import json,sys; p=sys.argv[1]; d=json.load(open(p)); d.pop("entwurf-agy-imprint", None); json.dump(d,open(p,"w"))' "$HOOKS"
|
|
75
|
+
if bash "$BRIDGE" doctor >/dev/null 2>&1; then die "drift: doctor should FAIL when state hook is removed"; fi
|
|
76
|
+
ok "drift: doctor FAILS on state-present + hook-removed"
|
|
77
|
+
bash "$BRIDGE" install >/dev/null
|
|
78
|
+
|
|
79
|
+
rm -f "$HOOKS"
|
|
80
|
+
DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?
|
|
81
|
+
want "orphan: doctor exits 0 when hooks file is absent" "[ '$DOC_RC' -eq 0 ]"
|
|
82
|
+
want "orphan: doctor logs ORPHANED" "printf '%s' \"\$DOC_OUT\" | grep -q ORPHANED"
|
|
83
|
+
want "orphan: state removed" "[ ! -f '$STATE' ]"
|
|
84
|
+
|
|
85
|
+
printf '{"keep-me":{"PreInvocation":[{"type":"command","command":"echo keep"}]}}\n' > "$HOOKS"
|
|
86
|
+
bash "$BRIDGE" install >/dev/null
|
|
87
|
+
bash "$BRIDGE" uninstall >/dev/null
|
|
88
|
+
want "uninstall: imprint hook removed" \
|
|
89
|
+
"python3 -c 'import json,sys; sys.exit(0 if \"entwurf-agy-imprint\" not in json.load(open(sys.argv[1])) else 1)' '$HOOKS'"
|
|
90
|
+
want "uninstall: unrelated hook survived" \
|
|
91
|
+
"python3 -c 'import json,sys; sys.exit(0 if \"keep-me\" in json.load(open(sys.argv[1])) else 1)' '$HOOKS'"
|
|
92
|
+
want "uninstall: state removed" "[ ! -f '$STATE' ]"
|
|
93
|
+
|
|
94
|
+
rm -f "$HOOKS"
|
|
95
|
+
printf '{}\n' > "$SB/real_hooks.json"
|
|
96
|
+
ln -s "$SB/real_hooks.json" "$HOOKS"
|
|
97
|
+
if bash "$BRIDGE" install >/dev/null 2>&1; then die "symlink: install should REFUSE"; fi
|
|
98
|
+
ok "symlink: install refused"
|
|
99
|
+
want "symlink: no state written" "[ ! -f '$STATE' ]"
|
|
100
|
+
rm -f "$HOOKS"
|
|
101
|
+
|
|
102
|
+
# Direct imprint contract: stdout neutral, create then attach by conversationId, no duplicate.
|
|
103
|
+
AGENT="$SB/agent"
|
|
104
|
+
payload='{"conversationId":"conv-smoke","workspacePaths":["/work/smoke"],"transcriptPath":"/tmp/t.jsonl","modelName":"gemini-smoke"}'
|
|
105
|
+
printf '%s' "$payload" | PI_CODING_AGENT_DIR="$AGENT" node --experimental-strip-types "$IMPRINT" > "$SB/out1"
|
|
106
|
+
want "imprint: stdout is neutral PreInvocation response" "grep -qx '{\"injectSteps\":\[\]}' '$SB/out1'"
|
|
107
|
+
want "imprint: creates one antigravity record" \
|
|
108
|
+
"[ \$(find '$AGENT/meta-sessions' -name '*.meta.json' | wc -l) -eq 1 ] && grep -q '\"backend\": \"antigravity\"' '$AGENT/meta-sessions'/*.meta.json"
|
|
109
|
+
printf '%s' "$payload" | PI_CODING_AGENT_DIR="$AGENT" node --experimental-strip-types "$IMPRINT" >/dev/null
|
|
110
|
+
want "imprint: second run attaches, no duplicate" "[ \$(find '$AGENT/meta-sessions' -name '*.meta.json' | wc -l) -eq 1 ]"
|
|
111
|
+
printf '%s' '{"workspacePaths":["/work/smoke"]}' | PI_CODING_AGENT_DIR="$SB/agent2" node --experimental-strip-types "$IMPRINT" >/dev/null
|
|
112
|
+
want "imprint: missing conversationId writes no record" "[ ! -d '$SB/agent2/meta-sessions' ] || [ \$(find '$SB/agent2/meta-sessions' -name '*.meta.json' | wc -l) -eq 0 ]"
|
|
113
|
+
|
|
114
|
+
# ── RE-INSTALL PROVENANCE: an installer is re-run on every upgrade ────────────
|
|
115
|
+
# The preimage answers "what was here before US". Re-capturing it on each install would record OUR
|
|
116
|
+
# OWN previous hook as the operator's, and the honest inverse would then restore us — uninstall
|
|
117
|
+
# leaving behind the very thing it exists to remove. Same bug class, all three agy adapters.
|
|
118
|
+
rm -f "$HOOKS" "$STATE"
|
|
119
|
+
mkdir -p "$(dirname "$HOOKS")"
|
|
120
|
+
printf '{"other-plugin":{"PreInvocation":[{"type":"command","command":"keepme"}]}}\n' > "$HOOKS"
|
|
121
|
+
bash "$BRIDGE" install >/dev/null 2>&1
|
|
122
|
+
bash "$BRIDGE" install >/dev/null 2>&1
|
|
123
|
+
bash "$BRIDGE" install >/dev/null 2>&1
|
|
124
|
+
want "re-install: provenance stays the FIRST install's (preimage still null, not our own hook)" \
|
|
125
|
+
"python3 -c \"import json,sys;sys.exit(0 if json.load(open('$STATE'))['preimage'] is None else 1)\""
|
|
126
|
+
bash "$BRIDGE" uninstall >/dev/null 2>&1
|
|
127
|
+
want "re-install: uninstall after install×3 still removes our hook (no self-restore)" \
|
|
128
|
+
"! python3 -c \"import json,sys;sys.exit(0 if 'entwurf-agy-imprint' in json.load(open('$HOOKS')) else 1)\""
|
|
129
|
+
want "re-install: an unrelated plugin's hook survives the whole cycle" \
|
|
130
|
+
"python3 -c \"import json,sys;sys.exit(0 if 'other-plugin' in json.load(open('$HOOKS')) else 1)\""
|
|
131
|
+
|
|
132
|
+
REPO_AFTER="$(cd "$REPO_DIR" && git status --porcelain)"
|
|
133
|
+
want "purity: checkout unchanged (all writes stayed in sandbox HOME+XDG)" "[ \"$REPO_BEFORE\" = \"$REPO_AFTER\" ]"
|
|
134
|
+
|
|
135
|
+
# ── FOREIGN TARGET: state describes a hooks file this host does not load ──────────────
|
|
136
|
+
# The recorded hooks file is perfectly configured — it is just not the one agy loads here. A doctor
|
|
137
|
+
# that only inspects the recorded path calls that green while the LIVE PreInvocation hook is
|
|
138
|
+
# unowned: uninstall would leave it in place, and its provenance is unrecorded. A real host reached
|
|
139
|
+
# this shape via a verification run that isolated HOME but SHARED XDG_DATA_HOME. Isolation must move
|
|
140
|
+
# HOME and XDG_DATA_HOME together.
|
|
141
|
+
bash "$BRIDGE" install >/dev/null # re-own this host (earlier cases may have torn the state down)
|
|
142
|
+
FOREIGN_HOOKS="$SB/foreign-hooks.json"
|
|
143
|
+
cp "$HOOKS" "$FOREIGN_HOOKS"
|
|
144
|
+
python3 -c "
|
|
145
|
+
import json,sys
|
|
146
|
+
p=sys.argv[1]; d=json.load(open(p)); d['managedHooksPath']=sys.argv[2]
|
|
147
|
+
json.dump(d, open(p,'w'))" "$STATE" "$FOREIGN_HOOKS"
|
|
148
|
+
set +e; FT_OUT="$(bash "$BRIDGE" doctor 2>&1)"; FT_RC=$?; set -e
|
|
149
|
+
want "foreign-target: doctor FAILS when install-state manages hooks this host does not load" "[ '$FT_RC' -ne 0 ]"
|
|
150
|
+
want "foreign-target: the report names both the recorded and the live hooks path" \
|
|
151
|
+
"printf '%s' \"\$FT_OUT\" | grep -q 'FOREIGN TARGET' && printf '%s' \"\$FT_OUT\" | grep -qF '$FOREIGN_HOOKS' && printf '%s' \"\$FT_OUT\" | grep -qF '$HOOKS'"
|
|
152
|
+
want "foreign-target: it names the real damage — the live hook is unowned" \
|
|
153
|
+
"printf '%s' \"\$FT_OUT\" | grep -qi 'unowned'"
|
|
154
|
+
want "foreign-target: neither the foreign hooks file nor the state is auto-deleted" "[ -f '$FOREIGN_HOOKS' ] && [ -f '$STATE' ]"
|
|
155
|
+
rm -f "$FOREIGN_HOOKS"
|
|
156
|
+
|
|
157
|
+
# A present state with invalid JSON or no managed target is corrupt evidence, not "no evidence".
|
|
158
|
+
# The doctor must fail loud instead of parsing to an empty string and skipping the ownership check.
|
|
159
|
+
printf 'not-json{{{' > "$STATE"
|
|
160
|
+
set +e; FT_OUT="$(bash "$BRIDGE" doctor 2>&1)"; FT_RC=$?; set -e
|
|
161
|
+
want "corrupt-state: doctor FAILS on unreadable install-state" "[ '$FT_RC' -ne 0 ]"
|
|
162
|
+
want "corrupt-state: report names CORRUPT instead of silently skipping ownership" \
|
|
163
|
+
"printf '%s' \"\$FT_OUT\" | grep -q 'state: CORRUPT'"
|
|
164
|
+
|
|
165
|
+
# A RELATIVE managed path is corrupt too — install records absolute paths only, and resolving a
|
|
166
|
+
# relative one against the doctor's cwd could bless whatever directory it happens to run from.
|
|
167
|
+
printf '{"managedHooksPath":".gemini/config/plugins/entwurf-agy-imprint/hooks.json"}' > "$STATE"
|
|
168
|
+
set +e; FT_OUT="$(bash "$BRIDGE" doctor 2>&1)"; FT_RC=$?; set -e
|
|
169
|
+
want "corrupt-state: a relative managedHooksPath is CORRUPT, never resolved against the doctor's cwd" \
|
|
170
|
+
"[ '$FT_RC' -ne 0 ] && printf '%s' \"\$FT_OUT\" | grep -q 'state: CORRUPT'"
|
|
171
|
+
|
|
172
|
+
printf '\nsmoke-agy-hooks-state: %d checks passed\n' "$pass"
|