@junghanacs/entwurf 0.12.6 → 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.
Files changed (92) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +22 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +18 -13
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -5
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +756 -214
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +36 -2
  79. package/scripts/register-pi-package.py +37 -3
  80. package/scripts/register-pi-provider.py +287 -0
  81. package/scripts/smoke-agy-hooks-state.sh +172 -0
  82. package/scripts/smoke-agy-install-state.sh +660 -0
  83. package/scripts/smoke-agy-native-push-live.ts +243 -0
  84. package/scripts/smoke-agy-statusline-state.sh +300 -0
  85. package/scripts/smoke-meta-async-drift.sh +9 -2
  86. package/scripts/smoke-meta-install-state.sh +20 -0
  87. package/scripts/smoke-pi-provider-state.sh +182 -0
  88. package/scripts/smoke-user-scope-citizen.sh +62 -0
  89. package/scripts/with-dist-lock.sh +81 -0
  90. package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
  91. package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
  92. package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env bash
2
+ # agy-bridge — the agy (Antigravity) MCP install ADAPTER (봉인 7). Separate from the Claude
3
+ # marketplace install (no generalization); only the runner/reporting is shared, here.
4
+ #
5
+ # install register ONE entwurf-bridge server entry in the agy mcp_config (adopt a regular
6
+ # file / create a new one / REFUSE a symlink — someone else's SSOT), recording an
7
+ # install-state for an honest inverse. The command written is a STABLE bin
8
+ # (`entwurf-bridge`), NEVER a repo/git-hash path (the oracle dangling lesson).
9
+ # uninstall honest inverse from the install-state (restore preimage / remove our key).
10
+ # doctor 2-tier: STATIC proves both candidate configs (documented + observed) resolve,
11
+ # parse, and carry a resolvable command; LIVE proves runtime-effectiveness only
12
+ # when an agy process exists, else an honest SKIP (never a PASS in disguise).
13
+ #
14
+ # GLOBAL ROOT (the one file that matters): live agy reads its global MCP config from
15
+ # ~/.gemini/config/mcp_config.json (agy's own builtin doc: mcp_servers.md — "Global Configuration:
16
+ # ~/.gemini/config/mcp_config.json, applies to all sessions"). The ~/.gemini/antigravity-cli copy
17
+ # is a stale mis-wiring agy does NOT read as global — install now targets config/ and CLEANS the
18
+ # antigravity-cli entry (one-way migration). This is the "뭐가 글로벌인지" that had been confusing.
19
+ #
20
+ # Paths (all overridable for the isolated smoke):
21
+ # AGY_MCP_CONFIG install target (global) (default: ~/.gemini/config/mcp_config.json)
22
+ # AGY_MCP_CONFIG_ALT legacy root to clean (default: ~/.gemini/antigravity-cli/mcp_config.json)
23
+ # AGY_BRIDGE_COMMAND command to register (default: entwurf-bridge — a stable bin)
24
+ # XDG_DATA_HOME install-state root (state at $XDG_DATA_HOME/entwurf/agy-bridge/)
25
+ set -euo pipefail
26
+
27
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
28
+ CONFIG_PY="$HERE/agy-bridge-config.py"
29
+
30
+ GLOBAL_CONFIG="${AGY_MCP_CONFIG:-$HOME/.gemini/config/mcp_config.json}"
31
+ LEGACY_CONFIG="${AGY_MCP_CONFIG_ALT:-$HOME/.gemini/antigravity-cli/mcp_config.json}"
32
+ # agy's MCP tool-schema cache root (appDataDir/mcp). agy NEVER prunes an orphaned server's cache
33
+ # itself, so a cut-over-FROM key (pi-tools-bridge → entwurf-bridge rename) lingers forever as
34
+ # config garbage. install prunes ONLY these exact legacy keys — never a scan-and-delete that could
35
+ # touch another harness's LIVE MCP cache. Cache, not config: agy re-fetches if ever reconfigured.
36
+ AGY_MCP_CACHE_DIR="${AGY_MCP_CACHE_DIR:-$HOME/.gemini/antigravity-cli/mcp}"
37
+ LEGACY_CACHE_KEYS="pi-tools-bridge"
38
+ COMMAND="${AGY_BRIDGE_COMMAND:-entwurf-bridge}"
39
+ STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/agy-bridge"
40
+ STATE_FILE="$STATE_DIR/install-state.json"
41
+ # agy's settings.json — the SAME file the statusline adapter owns, but a DIFFERENT element: it owns
42
+ # the `statusLine` subtree, we own one string in `permissions.allow`. Element-level ownership on
43
+ # both sides is what lets two adapters share a file without either clobbering the other (or the
44
+ # operator's own rules). Tracked in its own state file so each half has an honest inverse.
45
+ SETTINGS_FILE="${AGY_SETTINGS_CONFIG:-$HOME/.gemini/antigravity-cli/settings.json}"
46
+ PERMISSION_STATE_FILE="$STATE_DIR/permission-state.json"
47
+ ALLOW_RULE="mcp(entwurf-bridge/entwurf_v2)"
48
+
49
+ log() { printf '%s\n' "$*"; }
50
+ fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
51
+
52
+ # Is COMMAND runnable? A bare name is looked up on PATH; an absolute/relative path must be an
53
+ # executable file. A "dangling" command (the oracle lesson) fails here.
54
+ command_resolvable() {
55
+ local cmd="$1"
56
+ case "$cmd" in
57
+ */*) [ -x "$cmd" ] ;; # a path → must be an executable file
58
+ *) command -v "$cmd" >/dev/null 2>&1 ;; # a bare name → must be on PATH
59
+ esac
60
+ }
61
+
62
+ # Prune the agy MCP tool-schema cache for the KNOWN-legacy server keys (LEGACY_CACHE_KEYS). Removes
63
+ # ONLY the exact-named dirs — a symlink is left intact (not ours), and any OTHER server's cache is
64
+ # never touched. Idempotent (absent = no-op). One-shot cutover hygiene, not honest-inverse tracked.
65
+ prune_legacy_cache() {
66
+ local key dir
67
+ for key in $LEGACY_CACHE_KEYS; do
68
+ dir="$AGY_MCP_CACHE_DIR/$key"
69
+ [ -e "$dir" ] || continue
70
+ if [ -L "$dir" ]; then
71
+ log " cache: legacy '$key' is a symlink — left intact (not ours to remove)"
72
+ continue
73
+ fi
74
+ rm -rf "$dir" && log " cache: pruned stale legacy MCP tool cache '$key'"
75
+ done
76
+ }
77
+
78
+ do_install() {
79
+ log "[agy-bridge install]"
80
+ log " target: $GLOBAL_CONFIG"
81
+ log " command: $COMMAND"
82
+ log " state: $STATE_FILE"
83
+ if ! command_resolvable "$COMMAND"; then
84
+ log " note: '$COMMAND' is not currently resolvable on this host — install still records it"
85
+ log " (a stable bin is installed by the npm package; doctor will FAIL until it is on PATH)."
86
+ fi
87
+ local out rc
88
+ set +e
89
+ out="$(python3 "$CONFIG_PY" install "$GLOBAL_CONFIG" "$COMMAND" "$STATE_FILE" 2>&1)"
90
+ rc=$?
91
+ set -e
92
+ case "$rc" in
93
+ 0) log " ok: ${out}" ;;
94
+ 3) fail "refused (symlink) — ${out}" ;;
95
+ 4) fail "invalid JSON — ${out}" ;;
96
+ *) fail "install error (rc=$rc) — ${out}" ;;
97
+ esac
98
+ # One-way migration: drop the stale entwurf-bridge entry from the LEGACY root (agy does not read
99
+ # it as global MCP config). Non-fatal — a corrupt/symlinked legacy must not brick the install.
100
+ local lout lrc
101
+ set +e
102
+ lout="$(python3 "$CONFIG_PY" clean-legacy "$LEGACY_CONFIG" 2>&1)"
103
+ lrc=$?
104
+ set -e
105
+ case "$lout" in
106
+ cleaned-*) log " legacy: removed stale entwurf-bridge from $LEGACY_CONFIG" ;;
107
+ skip-symlink*) log " legacy: $LEGACY_CONFIG is a symlink (someone else's SSOT) — left intact" ;;
108
+ absent*|not-present*) : ;; # nothing to clean
109
+ *) [ "$lrc" -ne 0 ] && log " legacy: WARN could not clean $LEGACY_CONFIG — ${lout}" ;;
110
+ esac
111
+ prune_legacy_cache
112
+ install_permission
113
+ log " installed. Verify with: ./run.sh doctor-agy-bridge"
114
+ }
115
+
116
+ # The other half of a usable bridge: agy defaults every `mcp` action to Ask, so a registered-but-
117
+ # ungranted server stops for a y/n on EVERY entwurf_v2 call. We grant exactly our own tool —
118
+ # never mcp(*), never the operator's command/file rules; those are their trust decision, not the
119
+ # installer's.
120
+ #
121
+ # FAIL LOUD on an explicit install. A grant we could not write is a HALF-installed bridge: the
122
+ # server is registered, every call prompts, and reporting "installed" over that is the exact shape
123
+ # this repo exists to remove (a layer saying yes while the surface says no). The tolerance lives one
124
+ # level up, where it belongs: setup's wire_agy_bridge catches this nonzero and degrades to a
125
+ # reason-specific WARN, because agy is optional and must never brick a pi/Claude host.
126
+ install_permission() {
127
+ local out rc
128
+ set +e
129
+ out="$(python3 "$CONFIG_PY" permission-install "$SETTINGS_FILE" "$PERMISSION_STATE_FILE" 2>&1)"
130
+ rc=$?
131
+ set -e
132
+ case "$rc" in
133
+ 0) log " permission: ${out} in $SETTINGS_FILE" ;;
134
+ 3) fail "permission refused (symlink) — $SETTINGS_FILE is someone else's SSOT; left intact. The MCP server is registered but NOT granted, so agy would prompt on every entwurf_v2 call. Add '$ALLOW_RULE' to permissions.allow there, or replace the symlink with a regular file and re-run." ;;
135
+ 4) fail "permission invalid JSON — $SETTINGS_FILE could not be parsed; left intact. The MCP server is registered but NOT granted, so agy would prompt on every entwurf_v2 call. Repair that file, then re-run." ;;
136
+ *) fail "permission could not be granted (rc=$rc) — ${out}" ;;
137
+ esac
138
+ }
139
+
140
+ do_uninstall() {
141
+ log "[agy-bridge uninstall]"
142
+ log " state: $STATE_FILE"
143
+ local out rc
144
+ set +e
145
+ out="$(python3 "$CONFIG_PY" uninstall "$STATE_FILE" 2>&1)"
146
+ rc=$?
147
+ set -e
148
+ case "$rc" in
149
+ 0) log " ok: ${out}" ;;
150
+ 2) log " note: ${out}" ;; # no state → nothing to undo (idempotent)
151
+ 3) fail "refused (symlink) — ${out}" ;;
152
+ *) fail "uninstall error (rc=$rc) — ${out}" ;;
153
+ esac
154
+ # Honest inverse of the grant. If the operator ALREADY had the rule before we installed, the
155
+ # state records that and the rule stays — it was never ours to take away. A revoke we cannot
156
+ # perform is a FAILED inverse, not a footnote: uninstall would otherwise report success while
157
+ # leaving our rule in the operator's settings.
158
+ set +e
159
+ out="$(python3 "$CONFIG_PY" permission-uninstall "$PERMISSION_STATE_FILE" 2>&1)"
160
+ rc=$?
161
+ set -e
162
+ case "$rc" in
163
+ 0) log " permission: ${out}" ;;
164
+ 2) : ;; # never granted → nothing to undo (idempotent)
165
+ 3) fail "permission refused (symlink) — $SETTINGS_FILE became a symlink since install; our rule is STILL THERE. Remove '$ALLOW_RULE' from permissions.allow by hand at the link target." ;;
166
+ *) fail "permission could not be revoked (rc=$rc) — ${out}" ;;
167
+ esac
168
+ }
169
+
170
+ # Static-check ONE candidate config. Prints a status line; returns 1 on a hard failure
171
+ # (invalid JSON / configured-but-dangling command), 0 otherwise (absent / not-configured /
172
+ # configured+resolvable are not doctor failures — a candidate may legitimately be unused).
173
+ doctor_static_one() {
174
+ local label="$1" candidate="$2"
175
+ local link_note=""
176
+ if [ -L "$candidate" ]; then
177
+ link_note=" [symlink → $(readlink "$candidate")]"
178
+ fi
179
+ local status
180
+ status="$(python3 "$CONFIG_PY" doctor-static "$candidate")"
181
+ case "$status" in
182
+ absent) log " $label: absent$link_note"; return 0 ;;
183
+ not-configured) log " $label: present but entwurf-bridge NOT configured$link_note"; return 0 ;;
184
+ invalid-json) log " $label: INVALID JSON$link_note"; return 1 ;;
185
+ configured\ *)
186
+ local cmd="${status#configured }"
187
+ if command_resolvable "$cmd"; then
188
+ log " $label: configured → '$cmd' (resolvable)$link_note"
189
+ return 0
190
+ fi
191
+ log " $label: configured → '$cmd' DANGLING (not on PATH / not executable)$link_note"
192
+ return 1 ;;
193
+ *) log " $label: unexpected status '$status'$link_note"; return 1 ;;
194
+ esac
195
+ }
196
+
197
+ # Is our tool actually callable without a prompt? A registered server that stops for a y/n on every
198
+ # call is a half-installed bridge, and "why does agy ask me every time?" must not be a mystery — so
199
+ # the permission is doctor evidence, not silent state. The shadow case is the subtle one: agy
200
+ # evaluates Deny > Ask > Allow, so an operator rule like mcp(*) in their ask/deny list overrides our
201
+ # allow while our install-state still looks green. Name it rather than report a false pass.
202
+ doctor_permission() {
203
+ local status installed=0
204
+ # "Installed" = we have state for either half. A missing grant is only a NOTE on a host that never
205
+ # installed the bridge; on an installed one it is DRIFT — the server is registered and every call
206
+ # prompts, which is precisely the half-installed surface a green doctor must never bless. Same
207
+ # distinction the mcp-key N1 check draws between "never installed" and "installed then loosened".
208
+ if [ -f "$STATE_FILE" ] || [ -f "$PERMISSION_STATE_FILE" ]; then installed=1; fi
209
+ status="$(python3 "$CONFIG_PY" permission-doctor "$SETTINGS_FILE")"
210
+ case "$status" in
211
+ configured)
212
+ log " permission ($SETTINGS_FILE): allow → '$ALLOW_RULE' (agy calls entwurf_v2 without prompting)"; return 0 ;;
213
+ covered-by-allow\ *)
214
+ # The operator's own broader rule already grants our tool, so entwurf_v2 does NOT prompt —
215
+ # calling this DRIFT would be a false red about a working surface. But it is THEIR rule, not
216
+ # ours: narrowing that wildcard silently takes our grant with it. Note, never a silent pass.
217
+ local covering="${status#covered-by-allow }"
218
+ # Ownership axis first (hard rule 12): if OUR permission-state says WE added the exact rule
219
+ # to THIS settings file and it is now gone, that is drift of an owned element — the operator's
220
+ # broad rule keeps calls working (say so), but the verdict stays red. A whole-file settings
221
+ # relink (agent-config ensure_link) produces exactly this shape. A state bound to a DIFFERENT
222
+ # file (foreign) or unreadable (corrupt) is the independent state check's verdict, not this one.
223
+ if [ -f "$PERMISSION_STATE_FILE" ] && python3 -c '
224
+ import json, os, sys
225
+ try:
226
+ s = json.load(open(sys.argv[1]))
227
+ except Exception:
228
+ sys.exit(1)
229
+ p = s.get("managedSettingsPath")
230
+ same = isinstance(p, str) and os.path.isabs(p) and os.path.abspath(p) == os.path.abspath(sys.argv[2])
231
+ sys.exit(0 if same and s.get("ruleExistedBefore") is False else 1)' "$PERMISSION_STATE_FILE" "$SETTINGS_FILE"; then
232
+ log " permission ($SETTINGS_FILE): DRIFT — entwurf installed '$ALLOW_RULE' here and it is now GONE. Your '$covering' still covers the tool, so agy does not prompt today — but the grant entwurf owns (and repairs) no longer exists. Fix: ./run.sh install-agy-bridge"
233
+ return 1
234
+ fi
235
+ log " permission ($SETTINGS_FILE): NOTE — we own no rule here, but your '$covering' in permissions.allow already matches $ALLOW_RULE, so agy calls entwurf_v2 without prompting. That grant is YOURS: narrow '$covering' and entwurf_v2 starts prompting again. Run ./run.sh install-agy-bridge if you want the narrow rule owned (and repaired) by entwurf."
236
+ return 0 ;;
237
+ not-configured|absent)
238
+ local what="'$ALLOW_RULE' NOT granted"
239
+ [ "$status" = absent ] && what="settings file absent, so no grant"
240
+ if [ "$installed" -eq 1 ]; then
241
+ log " permission ($SETTINGS_FILE): DRIFT — $what, but the bridge IS installed. agy defaults mcp to Ask, so EVERY entwurf_v2 call prompts: the server is registered and unusable without a y/n. Fix: ./run.sh install-agy-bridge"
242
+ return 1
243
+ fi
244
+ log " permission ($SETTINGS_FILE): $what (bridge not installed here — nothing to grant yet)"; return 0 ;;
245
+ invalid-json)
246
+ log " permission ($SETTINGS_FILE): INVALID JSON — cannot read the permission engine's config"; return 1 ;;
247
+ shadowed-by-*)
248
+ local list rule
249
+ list="${status%% *}"; list="${list#shadowed-by-}"
250
+ rule="${status#* }"
251
+ log " permission ($SETTINGS_FILE): SHADOWED — your '$list' list carries '$rule', and agy evaluates Deny > Ask > Allow, so it OVERRIDES our allow of '$ALLOW_RULE'. agy will keep prompting (or blocking) on every entwurf_v2 call until that rule is narrowed."; return 1 ;;
252
+ *) log " permission ($SETTINGS_FILE): unexpected status '$status'"; return 1 ;;
253
+ esac
254
+ }
255
+
256
+ do_doctor() {
257
+ log "[agy-bridge doctor]"
258
+ local hard_fail=0 configured_any=0 resolvable_any=0
259
+
260
+ log "── static (configured candidates)"
261
+ doctor_static_one "global ($GLOBAL_CONFIG)" "$GLOBAL_CONFIG" || hard_fail=1
262
+ doctor_static_one "legacy ($LEGACY_CONFIG)" "$LEGACY_CONFIG" || hard_fail=1
263
+ doctor_permission || hard_fail=1
264
+ # Did EITHER candidate carry a configured + resolvable entwurf-bridge? Keep this runtime fact
265
+ # separate from ownership-state failures below: a FOREIGN TARGET makes the doctor red, but it
266
+ # does not make a visibly configured command disappear.
267
+ local c candidate_status candidate_cmd
268
+ for c in "$GLOBAL_CONFIG" "$LEGACY_CONFIG"; do
269
+ candidate_status="$(python3 "$CONFIG_PY" doctor-static "$c")"
270
+ case "$candidate_status" in
271
+ configured\ *)
272
+ configured_any=1
273
+ candidate_cmd="${candidate_status#configured }"
274
+ command_resolvable "$candidate_cmd" && resolvable_any=1
275
+ ;;
276
+ esac
277
+ done
278
+
279
+ # N1 (state-evidence, mirrors the meta-bridge doctor): if we HAVE an install-state, the
280
+ # managed config it recorded MUST still configure entwurf-bridge. state ∧ missing-key = DRIFT
281
+ # (installed, then someone removed it — the actual "wiring came loose / ?" case) → FAIL. This
282
+ # is the honest distinction from "never installed" (a note, below), which no state backs.
283
+ if [ -f "$STATE_FILE" ]; then
284
+ local managed expected_global
285
+ expected_global="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$GLOBAL_CONFIG")"
286
+ if ! managed="$(python3 -c 'import json,os,sys; v=json.load(open(sys.argv[1])).get("managedConfigPath"); assert isinstance(v,str) and os.path.isabs(v); print(os.path.abspath(v))' "$STATE_FILE" 2>/dev/null)"; then
287
+ # isabs: install always records an absolute path. A relative one is tampered/corrupt state —
288
+ # normalizing it against OUR cwd could bless whatever directory the doctor happens to run from.
289
+ log " state: CORRUPT — install-state is unreadable or its managedConfigPath is missing/non-absolute: $STATE_FILE"
290
+ hard_fail=1
291
+ elif [ "$managed" != "$expected_global" ]; then
292
+ # The state describes a DIFFERENT file than the one agy reads here. Checking the recorded
293
+ # file and calling that green blesses a host we do not own: uninstall would not touch the
294
+ # live config, and the live config's provenance is gone. A verification run that isolated
295
+ # HOME but SHARED XDG_DATA_HOME writes exactly this — sandbox settings, real state.
296
+ log " state: FOREIGN TARGET — install-state manages '$managed', but agy reads '$expected_global' on this host. Nothing here is package-owned. Re-run install-agy-bridge against this host (and check whether an isolated test leaked its state)."
297
+ hard_fail=1
298
+ else
299
+ local managed_status
300
+ managed_status="$(python3 "$CONFIG_PY" doctor-static "$managed")"
301
+ case "$managed_status" in
302
+ configured\ *) log " state: install-state present; its managed config still configures entwurf-bridge." ;;
303
+ absent)
304
+ log " state: ORPHANED — install-state records $managed but the file is absent (HOME reset). Auto-cleaning stale state."
305
+ rm -f "$STATE_FILE"
306
+ ;;
307
+ *) log " state: DRIFT — install-state records $managed but it no longer configures entwurf-bridge (removed since install)."; hard_fail=1 ;;
308
+ esac
309
+ fi
310
+ elif [ "$configured_any" -eq 0 ]; then
311
+ log " note: no install-state and neither candidate configures entwurf-bridge (never installed — this is the '?' the operator sees; run install-agy-bridge)."
312
+ fi
313
+
314
+ # The permission half carries its own target and can exist/drift independently of the MCP config
315
+ # state. Never nest this check under STATE_FILE: a foreign permission-state must fail even when
316
+ # the config state is absent.
317
+ if [ -f "$PERMISSION_STATE_FILE" ]; then
318
+ local pmanaged expected_settings
319
+ expected_settings="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$SETTINGS_FILE")"
320
+ if ! pmanaged="$(python3 -c 'import json,os,sys; v=json.load(open(sys.argv[1])).get("managedSettingsPath"); assert isinstance(v,str) and os.path.isabs(v); print(os.path.abspath(v))' "$PERMISSION_STATE_FILE" 2>/dev/null)"; then
321
+ log " state: CORRUPT (permission) — permission-state is unreadable or its managedSettingsPath is missing/non-absolute: $PERMISSION_STATE_FILE"
322
+ hard_fail=1
323
+ elif [ "$pmanaged" != "$expected_settings" ]; then
324
+ log " state: FOREIGN TARGET (permission) — permission-state manages '$pmanaged', but agy reads '$expected_settings'. The grant recorded there is not the grant on this host."
325
+ hard_fail=1
326
+ fi
327
+ fi
328
+
329
+ log "── live (runtime wiring)"
330
+ if command -v pgrep >/dev/null 2>&1 && pgrep -x agy >/dev/null 2>&1; then
331
+ if [ "$resolvable_any" -eq 1 ]; then
332
+ # HONEST label (N2): a running agy + a resolvable configured candidate is CONSISTENT with
333
+ # runtime wiring, but it does NOT prove agy actually read that config — that needs MCP
334
+ # tool-listing-grade evidence (deferred). Ownership/state failures remain red independently.
335
+ if [ "$hard_fail" -eq 0 ]; then
336
+ log " live: agy is running AND a configured candidate has a resolvable command — consistent with runtime wiring (config-read NOT proven; MCP-tool-listing evidence deferred)."
337
+ else
338
+ log " live: agy is running and a configured candidate resolves, but ownership/state errors above keep this doctor red (config-read NOT proven)."
339
+ fi
340
+ else
341
+ log " live: agy is running but no resolvable configured candidate — runtime wiring is broken."
342
+ hard_fail=1
343
+ fi
344
+ else
345
+ log " live: SKIP — no agy process (cannot check runtime wiring; NOT a pass)."
346
+ fi
347
+
348
+ if [ "$hard_fail" -ne 0 ]; then
349
+ fail "doctor found a broken candidate (invalid JSON / dangling command / broken live wiring)."
350
+ fi
351
+ log "doctor: ok (static candidates clean)."
352
+ }
353
+
354
+ case "${1:-}" in
355
+ install) do_install ;;
356
+ uninstall) do_uninstall ;;
357
+ doctor) do_doctor ;;
358
+ *) echo "usage: agy-bridge.sh <install|uninstall|doctor>" >&2; exit 2 ;;
359
+ esac
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env bash
2
+ # agy-hooks-bridge — install/uninstall/doctor for Antigravity PreInvocation birth imprint.
3
+ set -euo pipefail
4
+
5
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ CONFIG_PY="$HERE/agy-hooks-config.py"
7
+ HOOKS="${AGY_HOOKS_CONFIG:-$HOME/.gemini/config/plugins/entwurf-agy-imprint/hooks.json}"
8
+ LEGACY_HOOKS="${AGY_LEGACY_HOOKS_CONFIG:-$HOME/.gemini/antigravity-cli/hooks.json}"
9
+ LEGACY_PLUGIN_HOOKS="${AGY_LEGACY_PLUGIN_HOOKS_CONFIG:-$HOME/.gemini/config/plugins/entwurf-probe/hooks.json}"
10
+ PLUGIN_JSON="$(dirname "$HOOKS")/plugin.json"
11
+ COMMAND="${AGY_IMPRINT_COMMAND:-entwurf-agy-imprint}"
12
+ STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/agy-hooks"
13
+ STATE_FILE="$STATE_DIR/install-state.json"
14
+
15
+ log() { printf '%s\n' "$*"; }
16
+ fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
17
+
18
+ command_resolvable() {
19
+ local cmd="$1"
20
+ case "$cmd" in
21
+ */*) [ -x "$cmd" ] ;;
22
+ *) command -v "$cmd" >/dev/null 2>&1 ;;
23
+ esac
24
+ }
25
+
26
+ do_install() {
27
+ log "[agy-hooks install]"
28
+ log " target: $HOOKS"
29
+ log " command: $COMMAND"
30
+ log " state: $STATE_FILE"
31
+ if ! command_resolvable "$COMMAND"; then
32
+ log " note: '$COMMAND' is not currently resolvable on this host — install still records it"
33
+ log " (expose-dev-bin/npm bin-link provides it; doctor will FAIL until it is on PATH)."
34
+ fi
35
+ mkdir -p "$(dirname "$PLUGIN_JSON")"
36
+ if [ ! -e "$PLUGIN_JSON" ]; then
37
+ printf '{\n "name": "entwurf-agy-imprint",\n "version": "1.0.0",\n "description": "Entwurf Antigravity PreInvocation imprint hook"\n}\n' > "$PLUGIN_JSON"
38
+ fi
39
+ local out rc
40
+ set +e
41
+ out="$(python3 "$CONFIG_PY" install "$HOOKS" "$COMMAND" "$STATE_FILE" 2>&1)"
42
+ rc=$?
43
+ set -e
44
+ case "$rc" in
45
+ 0) log " ok: ${out}" ;;
46
+ 3) fail "refused (symlink) — ${out}" ;;
47
+ 4) fail "invalid JSON — ${out}" ;;
48
+ *) fail "install error (rc=$rc) — ${out}" ;;
49
+ esac
50
+
51
+ # Hard-cut SSOT: agy's live hook surface is the plugin hooks.json above. A previous probe pass
52
+ # wrote the same birth hook into ~/.gemini/antigravity-cli/hooks.json, which creates ambiguity
53
+ # (or double PreInvocation if a future agy reads both). Remove ONLY our known legacy keys there;
54
+ # preserve unrelated user hooks; refuse symlinks/corrupt JSON rather than guessing.
55
+ local legacy_out legacy_rc legacy_plugin_out legacy_plugin_rc
56
+ set +e
57
+ legacy_out="$(python3 "$CONFIG_PY" cleanup-legacy "$LEGACY_HOOKS" "$HOOKS" 2>&1)"
58
+ legacy_rc=$?
59
+ set -e
60
+ case "$legacy_rc" in
61
+ 0) log " legacy top-level: ${legacy_out}" ;;
62
+ 3) fail "legacy top-level cleanup refused (symlink) — ${legacy_out}" ;;
63
+ 4) fail "legacy top-level cleanup invalid JSON — ${legacy_out}" ;;
64
+ *) fail "legacy top-level cleanup error (rc=$legacy_rc) — ${legacy_out}" ;;
65
+ esac
66
+ set +e
67
+ legacy_plugin_out="$(python3 "$CONFIG_PY" cleanup-legacy "$LEGACY_PLUGIN_HOOKS" "$HOOKS" 2>&1)"
68
+ legacy_plugin_rc=$?
69
+ set -e
70
+ case "$legacy_plugin_rc" in
71
+ 0)
72
+ log " legacy plugin(entwurf-probe): ${legacy_plugin_out}"
73
+ if [ ! -e "$LEGACY_PLUGIN_HOOKS" ]; then
74
+ rm -f "$(dirname "$LEGACY_PLUGIN_HOOKS")/plugin.json"
75
+ rmdir "$(dirname "$LEGACY_PLUGIN_HOOKS")" 2>/dev/null || true
76
+ fi
77
+ ;;
78
+ 3) fail "legacy plugin cleanup refused (symlink) — ${legacy_plugin_out}" ;;
79
+ 4) fail "legacy plugin cleanup invalid JSON — ${legacy_plugin_out}" ;;
80
+ *) fail "legacy plugin cleanup error (rc=$legacy_plugin_rc) — ${legacy_plugin_out}" ;;
81
+ esac
82
+ log " installed. Verify with: ./run.sh doctor-agy-hooks"
83
+ }
84
+
85
+ do_uninstall() {
86
+ log "[agy-hooks uninstall]"
87
+ log " state: $STATE_FILE"
88
+ local out rc
89
+ set +e
90
+ out="$(python3 "$CONFIG_PY" uninstall "$STATE_FILE" 2>&1)"
91
+ rc=$?
92
+ set -e
93
+ case "$rc" in
94
+ 0) log " ok: ${out}" ;;
95
+ 2) log " note: ${out}" ;;
96
+ 3) fail "refused (symlink) — ${out}" ;;
97
+ *) fail "uninstall error (rc=$rc) — ${out}" ;;
98
+ esac
99
+ }
100
+
101
+ do_doctor() {
102
+ log "[agy-hooks doctor]"
103
+ local hard_fail=0 link_note=""
104
+ if [ -L "$HOOKS" ]; then link_note=" [symlink → $(readlink "$HOOKS")]"; fi
105
+
106
+ log "── static (hooks root: Antigravity plugin PreInvocation birth imprint)"
107
+ local status
108
+ status="$(python3 "$CONFIG_PY" doctor-static "$HOOKS")"
109
+ case "$status" in
110
+ file-absent) log " hooks: file absent$link_note" ;;
111
+ not-configured) log " hooks: entwurf-agy-imprint hook absent$link_note" ;;
112
+ invalid-json) log " hooks: INVALID JSON$link_note"; hard_fail=1 ;;
113
+ not-ours) log " hooks: entwurf-agy-imprint key present but NOT ours$link_note" ;;
114
+ configured\ *)
115
+ local cmd="${status#configured }"
116
+ if command_resolvable "$cmd"; then
117
+ log " hooks: configured → '$cmd' (resolvable)$link_note"
118
+ else
119
+ log " hooks: configured → '$cmd' DANGLING (not on PATH / not executable)$link_note"
120
+ hard_fail=1
121
+ fi ;;
122
+ *) log " hooks: unexpected status '$status'$link_note"; hard_fail=1 ;;
123
+ esac
124
+
125
+ log "── legacy hook guards (must not carry entwurf/probe keys)"
126
+ local legacy_status legacy_plugin_status
127
+ legacy_status="$(python3 "$CONFIG_PY" doctor-legacy "$LEGACY_HOOKS" "$HOOKS")"
128
+ case "$legacy_status" in
129
+ absent) log " legacy top-level: absent (ok)" ;;
130
+ same-path) log " legacy top-level: same as active target (ok under override)" ;;
131
+ clean) log " legacy top-level: present but carries no entwurf/probe hook keys (ok)" ;;
132
+ symlink\ *) log " legacy top-level: SYMLINK ${legacy_status#symlink } — cannot prove single-hook ownership"; hard_fail=1 ;;
133
+ invalid-json) log " legacy top-level: INVALID JSON — cannot prove single-hook ownership"; hard_fail=1 ;;
134
+ owned\ *) log " legacy top-level: DRIFT — still carries ${legacy_status#owned } (run install-agy-hooks to clean)"; hard_fail=1 ;;
135
+ *) log " legacy top-level: unexpected status '$legacy_status'"; hard_fail=1 ;;
136
+ esac
137
+ legacy_plugin_status="$(python3 "$CONFIG_PY" doctor-legacy "$LEGACY_PLUGIN_HOOKS" "$HOOKS")"
138
+ case "$legacy_plugin_status" in
139
+ absent) log " legacy plugin(entwurf-probe): absent (ok)" ;;
140
+ same-path) log " legacy plugin(entwurf-probe): same as active target (ok under override)" ;;
141
+ clean) log " legacy plugin(entwurf-probe): present but carries no entwurf/probe hook keys (ok)" ;;
142
+ symlink\ *) log " legacy plugin(entwurf-probe): SYMLINK ${legacy_plugin_status#symlink } — cannot prove single-hook ownership"; hard_fail=1 ;;
143
+ invalid-json) log " legacy plugin(entwurf-probe): INVALID JSON — cannot prove single-hook ownership"; hard_fail=1 ;;
144
+ owned\ *) log " legacy plugin(entwurf-probe): DRIFT — still carries ${legacy_plugin_status#owned } (run install-agy-hooks to clean)"; hard_fail=1 ;;
145
+ *) log " legacy plugin(entwurf-probe): unexpected status '$legacy_plugin_status'"; hard_fail=1 ;;
146
+ esac
147
+
148
+ if [ -f "$STATE_FILE" ]; then
149
+ local managed expected_hooks
150
+ expected_hooks="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$HOOKS")"
151
+ if ! managed="$(python3 -c 'import json,os,sys; v=json.load(open(sys.argv[1])).get("managedHooksPath"); assert isinstance(v,str) and os.path.isabs(v); print(os.path.abspath(v))' "$STATE_FILE" 2>/dev/null)"; then
152
+ # isabs: install always records an absolute path — a relative one is corrupt, and
153
+ # normalizing it against OUR cwd could bless whatever directory the doctor runs from.
154
+ log " state: CORRUPT — install-state is unreadable or its managedHooksPath is missing/non-absolute: $STATE_FILE"
155
+ hard_fail=1
156
+ elif [ "$managed" != "$expected_hooks" ]; then
157
+ # The state describes a DIFFERENT hooks file than the one agy loads here — so the live hook
158
+ # is unowned: uninstall would leave it in place, and its provenance (what the file held
159
+ # before we wrote it) is unrecorded. A test run that isolated HOME but SHARED XDG_DATA_HOME
160
+ # produces exactly this shape.
161
+ log " state: FOREIGN TARGET — install-state manages '$managed', but agy loads '$expected_hooks' on this host. The live PreInvocation hook is unowned. Re-run install-agy-hooks against this host (and check whether an isolated test leaked its state)."
162
+ hard_fail=1
163
+ else
164
+ local managed_status
165
+ managed_status="$(python3 "$CONFIG_PY" doctor-static "$managed")"
166
+ case "$managed_status" in
167
+ configured\ *) log " state: install-state present; its managed hooks still configure entwurf-agy-imprint." ;;
168
+ file-absent)
169
+ log " state: ORPHANED — install-state records $managed but the file is absent (HOME reset). Auto-cleaning stale state."
170
+ rm -f "$STATE_FILE"
171
+ ;;
172
+ *) log " state: DRIFT — install-state records $managed but it no longer configures entwurf-agy-imprint."; hard_fail=1 ;;
173
+ esac
174
+ fi
175
+ else
176
+ case "$status" in
177
+ configured\ *) : ;;
178
+ *) log " note: no install-state and imprint hook not entwurf-owned (never installed — this keeps agy at '?'; run install-agy-hooks)." ;;
179
+ esac
180
+ fi
181
+
182
+ if [ "$hard_fail" -ne 0 ]; then
183
+ fail "doctor found broken agy hooks (invalid JSON / dangling command / drift)."
184
+ fi
185
+ log "doctor: ok."
186
+ }
187
+
188
+ case "${1:-}" in
189
+ install) do_install ;;
190
+ uninstall) do_uninstall ;;
191
+ doctor) do_doctor ;;
192
+ *) echo "usage: agy-hooks-bridge.sh <install|uninstall|doctor>" >&2; exit 2 ;;
193
+ esac