@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,182 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-pi-provider-state — regression gate for the pi provider install adapter (#46 Task 2):
3
+ # register-pi-provider.py (ownership-classified install/remove, user+project scopes) + the
4
+ # read-only doctor-pi-provider.ts (effective shadow view). Runs in an ISOLATED HOME + XDG with a
5
+ # fake stable bin and isolated settings files (no real ~/.pi). Asserts:
6
+ # - user install ownership matrix: absent→create+state; managed-legacy(repo start.sh)→bare bin +
7
+ # state(preimage audit, NOT restored) + siblings preserved; managed-current→idempotent;
8
+ # user-override→NOT overwritten, NO state (unowned).
9
+ # - user remove: state-based honest inverse (managed-* → remove OUR key, a legacy repo path is
10
+ # NOT restored; siblings kept; parent tidied); user-override → no state → nothing to undo.
11
+ # - legacy bundle prune (session-bridge/pi-tools-bridge repo path) alongside, user my-own kept.
12
+ # - project scope: NO state; install normalizes the bare bin, remove strips our-managed shapes
13
+ # (bare bin AND legacy repo path), a user override left in place.
14
+ # - doctor: effective = project shadows user; user-only/both-bare → ok green; project-STALE
15
+ # (user bare, project legacy) → effective legacy note (the "doctor green runtime red" guard);
16
+ # state-owned DRIFT → FAIL; bare-but-dangling → FAIL; malformed settings → FAIL; symlink refuse.
17
+ # - checkout stays byte-identical (nothing under $REPO).
18
+ # Offline + deterministic (deps: bash + python3 + node --experimental-strip-types).
19
+ set -euo pipefail
20
+
21
+ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
22
+ REG="$REPO_DIR/scripts/register-pi-provider.py"
23
+ DOCTOR="$REPO_DIR/scripts/doctor-pi-provider.ts"
24
+
25
+ pass=0
26
+ ok() { printf ' ok %s\n' "$1"; pass=$((pass + 1)); }
27
+ die() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
28
+ want() { eval "$2" && ok "$1" || die "$1"; }
29
+
30
+ REPO_BEFORE="$(cd "$REPO_DIR" && git status --porcelain)"
31
+
32
+ SB="$(mktemp -d)"
33
+ trap 'rm -rf "$SB"' EXIT
34
+ export HOME="$SB/home"
35
+ export XDG_DATA_HOME="$SB/xdg"
36
+ GLOBAL="$SB/global.json"
37
+ PROJECT="$SB/proj/.pi/settings.json"
38
+ STATE="$XDG_DATA_HOME/entwurf/pi-provider/install-state.json"
39
+ mkdir -p "$SB/proj/.pi" "$SB/bin" "$SB/home"
40
+
41
+ # A fake repo anchor for the managed-legacy predicate (endswith /entwurf/mcp/entwurf-bridge/start.sh).
42
+ FAKE_REPO="$SB/checkout/entwurf"
43
+ LEGACY_CMD="$FAKE_REPO/mcp/entwurf-bridge/start.sh"
44
+
45
+ # fake stable bin on PATH so the bare command RESOLVES in the doctor. Keep THIS fake
46
+ # authoritative: drop the dir that holds a REAL entwurf-bridge (this dev host exposes one at
47
+ # ~/.local/bin) so the dangling test is not masked by it.
48
+ printf '#!/usr/bin/env bash\necho fake-bridge\n' > "$SB/bin/entwurf-bridge"
49
+ chmod +x "$SB/bin/entwurf-bridge"
50
+ _real_bridge="$(command -v entwurf-bridge 2>/dev/null || true)"
51
+ _real_dir="${_real_bridge%/*}"
52
+ if [ -n "$_real_dir" ]; then
53
+ PATH="$(printf '%s' "$PATH" | tr ':' '\n' | grep -vFx "$_real_dir" | paste -sd: -)"
54
+ fi
55
+ export PATH="$SB/bin:$PATH"
56
+
57
+ reg() { python3 "$REG" "$@"; }
58
+ doctor() { PI_PROVIDER_GLOBAL_SETTINGS="$GLOBAL" PI_PROVIDER_PROJECT_SETTINGS="$PROJECT" PI_PROVIDER_STATE="$STATE" \
59
+ node --experimental-strip-types "$DOCTOR"; }
60
+ cmd_of() { python3 -c 'import json,sys
61
+ try:
62
+ d=json.load(open(sys.argv[1])); print(d["entwurfProvider"]["mcpServers"]["entwurf-bridge"]["command"])
63
+ except Exception: print("<none>")' "$1"; }
64
+
65
+ # ── A: user install — absent → create + state(ownership=absent) ───────────────
66
+ printf '{"defaultProvider":"x"}\n' > "$GLOBAL"
67
+ reg install "$GLOBAL" "$FAKE_REPO" --scope user --state "$STATE" >/dev/null
68
+ want "user/absent: command is bare stable bin" "[ \"\$(cmd_of '$GLOBAL')\" = entwurf-bridge ]"
69
+ want "user/absent: state ownership=absent" \
70
+ "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"ownership\"])' '$STATE' | grep -qx absent"
71
+
72
+ # ── B: user install — managed-legacy(repo start.sh) → bare bin + preimage + siblings kept ──
73
+ rm -f "$STATE"
74
+ python3 -c 'import json,sys; json.dump({"entwurfProvider":{"appendSystemPrompt":False,"skillPlugins":["/p"],"mcpServers":{"entwurf-bridge":{"command":sys.argv[2]}}},"packages":["y"]}, open(sys.argv[1],"w"))' "$GLOBAL" "$LEGACY_CMD"
75
+ OUT="$(reg install "$GLOBAL" "$FAKE_REPO" --scope user --state "$STATE")"
76
+ want "user/managed-legacy: reported as managed-legacy" "printf '%s' \"\$OUT\" | grep -q 'managed-legacy'"
77
+ want "user/managed-legacy: normalized to bare bin" "[ \"\$(cmd_of '$GLOBAL')\" = entwurf-bridge ]"
78
+ want "user/managed-legacy: siblings preserved (skillPlugins/appendSystemPrompt)" \
79
+ "python3 -c 'import json,sys; ep=json.load(open(sys.argv[1]))[\"entwurfProvider\"]; sys.exit(0 if ep.get(\"skillPlugins\")==[\"/p\"] and ep.get(\"appendSystemPrompt\") is False else 1)' '$GLOBAL'"
80
+ want "user/managed-legacy: state ownership=managed-legacy" \
81
+ "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"ownership\"])' '$STATE' | grep -qx managed-legacy"
82
+ want "user/managed-legacy: preimage records the repo path (audit, not restored)" \
83
+ "python3 -c 'import json,sys; p=json.load(open(sys.argv[1]))[\"preimage\"]; sys.exit(0 if p and p.get(\"command\")==sys.argv[2] else 1)' '$STATE' '$LEGACY_CMD'"
84
+ want "user/managed-legacy: timing honesty label emitted" \
85
+ "printf '%s' \"\$OUT\" | grep -q 'existing pi sessions unaffected until restart'"
86
+
87
+ # ── C: user install — managed-current(bare) → idempotent ──────────────────────
88
+ OUT="$(reg install "$GLOBAL" "$FAKE_REPO" --scope user --state "$STATE")"
89
+ want "user/managed-current: idempotent (stays bare)" "[ \"\$(cmd_of '$GLOBAL')\" = entwurf-bridge ]"
90
+ want "user/managed-current: reported managed-current" "printf '%s' \"\$OUT\" | grep -q 'managed-current'"
91
+
92
+ # ── D: user install — user-override → NOT overwritten, NO state ───────────────
93
+ rm -f "$STATE"
94
+ printf '{"entwurfProvider":{"mcpServers":{"entwurf-bridge":{"command":"/my/own/thing"}}}}\n' > "$GLOBAL"
95
+ OUT="$(reg install "$GLOBAL" "$FAKE_REPO" --scope user --state "$STATE")"
96
+ want "user/override: command left untouched" "[ \"\$(cmd_of '$GLOBAL')\" = /my/own/thing ]"
97
+ want "user/override: NO state written (unowned)" "[ ! -f '$STATE' ]"
98
+ want "user/override: reported preserved user override" "printf '%s' \"\$OUT\" | grep -qi 'user override'"
99
+
100
+ # ── E: user remove — state-based honest inverse (key removed, siblings kept) ──
101
+ python3 -c 'import json,sys; json.dump({"entwurfProvider":{"skillPlugins":["/p"],"mcpServers":{"entwurf-bridge":{"command":sys.argv[2]}}}}, open(sys.argv[1],"w"))' "$GLOBAL" "$LEGACY_CMD"
102
+ reg install "$GLOBAL" "$FAKE_REPO" --scope user --state "$STATE" >/dev/null
103
+ reg remove "$GLOBAL" "$FAKE_REPO" --scope user --state "$STATE" >/dev/null
104
+ want "user/remove: our key removed (NOT restored to repo path)" "[ \"\$(cmd_of '$GLOBAL')\" = '<none>' ]"
105
+ want "user/remove: sibling skillPlugins preserved" \
106
+ "python3 -c 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))[\"entwurfProvider\"].get(\"skillPlugins\")==[\"/p\"] else 1)' '$GLOBAL'"
107
+ want "user/remove: state removed" "[ ! -f '$STATE' ]"
108
+ reg remove "$GLOBAL" "$FAKE_REPO" --scope user --state "$STATE" >/dev/null 2>&1
109
+ ok "user/remove: no-state is idempotent (nothing to undo)"
110
+
111
+ # ── F: legacy bundle prune alongside, user-authored server kept ───────────────
112
+ python3 -c 'import json,sys; json.dump({"entwurfProvider":{"mcpServers":{"entwurf-bridge":{"command":sys.argv[2]},"session-bridge":{"command":sys.argv[3]},"my-own":{"command":"/custom"}}}}, open(sys.argv[1],"w"))' "$GLOBAL" "$LEGACY_CMD" "$FAKE_REPO/mcp/session-bridge/start.sh"
113
+ reg install "$GLOBAL" "$FAKE_REPO" --scope user --state "$STATE" >/dev/null
114
+ want "legacy-prune: session-bridge removed" \
115
+ "python3 -c 'import json,sys; sys.exit(0 if \"session-bridge\" not in json.load(open(sys.argv[1]))[\"entwurfProvider\"][\"mcpServers\"] else 1)' '$GLOBAL'"
116
+ want "legacy-prune: user-authored my-own kept" \
117
+ "python3 -c 'import json,sys; sys.exit(0 if \"my-own\" in json.load(open(sys.argv[1]))[\"entwurfProvider\"][\"mcpServers\"] else 1)' '$GLOBAL'"
118
+ reg remove "$GLOBAL" "$FAKE_REPO" --scope user --state "$STATE" >/dev/null; rm -f "$STATE"
119
+
120
+ # ── G: project scope — NO state; install normalizes, remove strips managed shapes ──
121
+ python3 -c 'import json,sys; json.dump({"entwurfProvider":{"mcpServers":{"entwurf-bridge":{"command":sys.argv[2]}}}}, open(sys.argv[1],"w"))' "$PROJECT" "$LEGACY_CMD"
122
+ reg install "$PROJECT" "$FAKE_REPO" --scope project >/dev/null
123
+ want "project/install: normalized to bare bin" "[ \"\$(cmd_of '$PROJECT')\" = entwurf-bridge ]"
124
+ want "project/install: NO user-scope state created" "[ ! -f '$STATE' ]"
125
+ reg remove "$PROJECT" "$FAKE_REPO" --scope project >/dev/null
126
+ want "project/remove: strips the bare bin (our-managed)" "[ \"\$(cmd_of '$PROJECT')\" = '<none>' ]"
127
+
128
+ # ── H: doctor — effective shadow view + verdicts ──────────────────────────────
129
+ # H-1: user-only bare → effective user, ok green
130
+ printf '{"entwurfProvider":{"mcpServers":{"entwurf-bridge":{"command":"entwurf-bridge"}}}}\n' > "$GLOBAL"
131
+ printf '{}\n' > "$PROJECT"; rm -f "$STATE"
132
+ OUT="$(doctor)"; RC=$?
133
+ want "doctor(user-only bare): exits 0" "[ '$RC' -eq 0 ]"
134
+ want "doctor(user-only bare): ok — bare bin resolves" "printf '%s' \"\$OUT\" | grep -q 'bare stable bin'"
135
+
136
+ # H-2: project STALE (user bare, project legacy) → effective=project=legacy note (green-runtime-red guard)
137
+ printf '{"entwurfProvider":{"mcpServers":{"entwurf-bridge":{"command":"entwurf-bridge"}}}}\n' > "$GLOBAL"
138
+ python3 -c 'import json,sys; json.dump({"entwurfProvider":{"mcpServers":{"entwurf-bridge":{"command":sys.argv[2]}}}}, open(sys.argv[1],"w"))' "$PROJECT" "$LEGACY_CMD"
139
+ OUT="$(doctor)"; RC=$?
140
+ want "doctor(project-stale): exits 0 (honest note, not a crash)" "[ '$RC' -eq 0 ]"
141
+ want "doctor(project-stale): EFFECTIVE is project (shadows user)" "printf '%s' \"\$OUT\" | grep -q 'EFFECTIVE (project)'"
142
+ want "doctor(project-stale): flags legacy managed path (not adopted)" "printf '%s' \"\$OUT\" | grep -q 'LEGACY managed repo path'"
143
+
144
+ # H-3: state-owned DRIFT → FAIL (state says managed but effective is foreign)
145
+ printf '{"entwurfProvider":{"mcpServers":{"entwurf-bridge":{"command":"/foreign/thing"}}}}\n' > "$GLOBAL"
146
+ printf '{}\n' > "$PROJECT"
147
+ python3 -c 'import json,sys; json.dump({"schemaVersion":1,"managedSettingsPath":sys.argv[1],"scope":"user","ownership":"managed-legacy","command":"entwurf-bridge","preimage":None}, open(sys.argv[2],"w"))' "$GLOBAL" "$STATE"
148
+ if doctor >/dev/null 2>&1; then die "doctor(drift): should FAIL (state owns but effective drifted)"; fi
149
+ ok "doctor(drift): FAILS on state-owned + effective-drifted"
150
+ rm -f "$STATE"
151
+
152
+ # H-4: bare-but-dangling (bin not on PATH) → FAIL
153
+ printf '{"entwurfProvider":{"mcpServers":{"entwurf-bridge":{"command":"entwurf-bridge"}}}}\n' > "$GLOBAL"
154
+ printf '{}\n' > "$PROJECT"
155
+ mv "$SB/bin/entwurf-bridge" "$SB/bin/entwurf-bridge.hidden"
156
+ if doctor >/dev/null 2>&1; then die "doctor(dangling): should FAIL (bare bin not resolvable)"; fi
157
+ ok "doctor(dangling): FAILS when the bare bin does not resolve"
158
+ mv "$SB/bin/entwurf-bridge.hidden" "$SB/bin/entwurf-bridge"
159
+
160
+ # H-5: malformed settings → FAIL (SSOT read throws)
161
+ printf 'not json{{{' > "$GLOBAL"; printf '{}\n' > "$PROJECT"
162
+ if doctor >/dev/null 2>&1; then die "doctor(malformed): should FAIL"; fi
163
+ ok "doctor(malformed): FAILS on malformed settings (SSOT read throws)"
164
+
165
+ # H-6: none configured → honest '?' note, exits 0
166
+ printf '{}\n' > "$GLOBAL"; printf '{}\n' > "$PROJECT"
167
+ OUT="$(doctor)"; RC=$?
168
+ want "doctor(none): exits 0" "[ '$RC' -eq 0 ]"
169
+ want "doctor(none): honest never-installed '?' note" "printf '%s' \"\$OUT\" | grep -q 'never installed'"
170
+
171
+ # ── I: symlink target → install REFUSES ───────────────────────────────────────
172
+ printf '{}\n' > "$SB/real.json"; rm -f "$GLOBAL"; ln -s "$SB/real.json" "$GLOBAL"
173
+ if reg install "$GLOBAL" "$FAKE_REPO" --scope user --state "$STATE" >/dev/null 2>&1; then die "symlink: install should REFUSE"; fi
174
+ ok "symlink: install refused (someone else's SSOT)"
175
+ rm -f "$GLOBAL"
176
+
177
+ # ── checkout purity ───────────────────────────────────────────────────────────
178
+ REPO_AFTER="$(cd "$REPO_DIR" && git status --porcelain)"
179
+ want "purity: checkout unchanged (all writes stayed in the sandbox HOME+XDG)" \
180
+ "[ \"\$REPO_BEFORE\" = \"\$REPO_AFTER\" ]"
181
+
182
+ printf '\nsmoke-pi-provider-state: %d checks passed\n' "$pass"
@@ -23,6 +23,18 @@ command -v python3 >/dev/null || { echo "FAIL: python3 not on PATH"; exit 1; }
23
23
 
24
24
  TMP="$(mktemp -d -t psa-user-citizen.XXXXXX)"
25
25
  trap 'rm -rf "$TMP"' EXIT
26
+
27
+ # Isolation is a property of every root run.sh writes through, not of the one this gate happens
28
+ # to override. `remove-user-scope` today reads PI_CODING_AGENT_DIR (settings) and XDG_DATA_HOME
29
+ # (ownership state) — passing the operator's real XDG_DATA_HOME is what let this gate delete the
30
+ # live MCP key during `pnpm check` (2026-07-14). It does not touch HOME *today*; sandbox HOME and
31
+ # the whole XDG trio anyway, so the next root run.sh reaches for is already fenced (AGENTS rule 11).
32
+ export HOME="$TMP/home"
33
+ export XDG_DATA_HOME="$TMP/xdg"
34
+ export XDG_STATE_HOME="$TMP/state"
35
+ export XDG_CACHE_HOME="$TMP/cache"
36
+ mkdir -p "$HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME"
37
+
26
38
  S="$TMP/settings.json"
27
39
  FAKE_REPO="$TMP/fake/entwurf"; mkdir -p "$FAKE_REPO"
28
40
  RESOLVED="$(cd "$FAKE_REPO" && pwd)"
@@ -125,5 +137,55 @@ assert '../../repos/gh/andenken' in p, 'andenken over-removed'
125
137
  assert '/home/me/entwurf-notes' in p, 'entwurf-notes over-removed (substring over-delete)'
126
138
  " 2>/dev/null; then ok "--remove drops entwurf, preserves unrelated + look-alikes (symmetric with install)"; else bad "--remove over-deleted a look-alike or missed entwurf"; fi
127
139
 
140
+ # 9. --dry-run remove REPORTS the count and writes NOTHING (backs run.sh's project
141
+ # `remove` pointer note that suggests the global inverse only when relevant).
142
+ cat > "$S" <<JSON
143
+ {"packages": ["$RESOLVED", "../../repos/gh/andenken"]}
144
+ JSON
145
+ MT_DR="$(stat -c %Y "$S")"; sleep 1
146
+ OUT_DR="$(python3 "$REG" "$S" "$FAKE_REPO" --remove --dry-run)"
147
+ MT_DR2="$(stat -c %Y "$S")"
148
+ if printf '%s' "$OUT_DR" | grep -q 'would remove 1'; then ok "--dry-run reports 'would remove' count"; else bad "--dry-run did not report the would-remove count: $OUT_DR"; fi
149
+ if [ "$MT_DR" = "$MT_DR2" ] && has_pkg "$RESOLVED"; then ok "--dry-run writes nothing (entry intact, mtime stable)"; else bad "--dry-run mutated settings (mtime $MT_DR -> $MT_DR2)"; fi
150
+ OUT_DR_NONE="$(python3 "$REG" "$TMP/absent.json" "$FAKE_REPO" --remove --dry-run)"
151
+ if printf '%s' "$OUT_DR_NONE" | grep -q 'no entwurf'; then ok "--dry-run on an entwurf-free file reports nothing to remove"; else bad "--dry-run false-positive on absent entry: $OUT_DR_NONE"; fi
152
+
153
+ # 9b. --dry-run WITHOUT --remove must FAIL LOUD and write nothing. A flag named
154
+ # "dry-run" that falls through to the register write path and mutates settings
155
+ # is an install-hygiene footgun (GPT blocker, 2026-07-03).
156
+ cat > "$S" <<'JSON'
157
+ {"packages": ["../../repos/gh/andenken"]}
158
+ JSON
159
+ MT_DRR="$(stat -c %Y "$S")"; sleep 1
160
+ if python3 "$REG" "$S" "$FAKE_REPO" --dry-run >/dev/null 2>&1; then bad "--dry-run without --remove should fail loud, not register"; else ok "--dry-run without --remove fails loud (never a silent write)"; fi
161
+ MT_DRR2="$(stat -c %Y "$S")"
162
+ if [ "$MT_DRR" = "$MT_DRR2" ] && ! has_pkg "$RESOLVED"; then ok "--dry-run without --remove wrote nothing (mtime stable, no entwurf entry added)"; else bad "--dry-run without --remove mutated settings (mtime $MT_DRR -> $MT_DRR2)"; fi
163
+ if python3 "$REG" "$S" "$FAKE_REPO" --bogus >/dev/null 2>&1; then bad "unknown flag should be rejected"; else ok "unknown flag rejected (fail-loud parser)"; fi
164
+
165
+ # 10. the run.sh SHELL path `remove-user-scope` reaches the SAME SSOT against a
166
+ # PI_CODING_AGENT_DIR-overridden ~/.pi/agent — proves install's user-scope
167
+ # inverse is REACHABLE from run.sh, not only from the python SSOT (the gap GPT
168
+ # flagged: remove symmetry existed at the SSOT but no run.sh path exercised it).
169
+ # REPO_DIR (run.sh's own dir) is the real checkout, so seed THAT as the entry.
170
+ RUN="$REPO/run.sh"
171
+ AGENT_DIR="$TMP/agent"; mkdir -p "$AGENT_DIR"
172
+ US="$AGENT_DIR/settings.json"
173
+ cat > "$US" <<JSON
174
+ {"defaultProvider": "openai-codex", "packages": ["$REPO", "../../repos/gh/andenken"]}
175
+ JSON
176
+ # remove-user-scope uses BOTH PI_CODING_AGENT_DIR (settings target) and XDG_DATA_HOME
177
+ # (ownership-state authority). Isolating only the former lets the fake inverse consume the
178
+ # operator's real state, follow its recorded managedSettingsPath, and remove the live MCP key.
179
+ XDG_DATA_HOME="$TMP/xdg" PI_CODING_AGENT_DIR="$AGENT_DIR" bash "$RUN" remove-user-scope >/dev/null 2>&1 || bad "run.sh remove-user-scope exited non-zero"
180
+ if python3 -c "
181
+ import json
182
+ d=json.load(open('$US')); p=d['packages']
183
+ assert '$REPO' not in p, 'run.sh remove-user-scope did not drop the global entwurf entry'
184
+ assert '../../repos/gh/andenken' in p, 'run.sh remove-user-scope over-removed an unrelated package'
185
+ assert d['defaultProvider']=='openai-codex', 'run.sh remove-user-scope dropped an unrelated key'
186
+ " 2>/dev/null; then ok "run.sh remove-user-scope drops the global citizen, preserves unrelated (SSOT reached via shell)"; else bad "run.sh remove-user-scope path failed"; fi
187
+ # idempotent: a second remove-user-scope is a clean no-op (no crash on absent entry)
188
+ if XDG_DATA_HOME="$TMP/xdg" PI_CODING_AGENT_DIR="$AGENT_DIR" bash "$RUN" remove-user-scope >/dev/null 2>&1; then ok "run.sh remove-user-scope is idempotent (no-op second run)"; else bad "run.sh remove-user-scope second run crashed"; fi
189
+
128
190
  echo
129
191
  if [ "$fail" -eq 0 ]; then echo "smoke-user-scope-citizen: PASS"; else echo "smoke-user-scope-citizen: FAIL (see above)"; exit 1; fi
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Serialize a command that rebuilds OR reads mcp/entwurf-bridge/dist against every
4
+ # other such command: `with-dist-lock.sh <cmd...>` runs <cmd> holding an atomic
5
+ # lock over the shared dist dir.
6
+ #
7
+ # Why the lock has to wrap the WHOLE command, not just the emit:
8
+ # build-bridge does `rm -rf dist` then a tsc emit; `npm pack` runs build-bridge as
9
+ # its prepack hook and then, AFTER prepack returns, walks the tree to read dist into
10
+ # the tarball. So the window where dist must stay intact is [prepack build ‥ npm's
11
+ # post-build read] — the entire pack, not the emit alone. A lock scoped to just the
12
+ # emit (released when build-bridge exits) is not merely insufficient, it BACKFIRES:
13
+ # it staggers concurrent packs so each run's lock-free read lands squarely inside
14
+ # the next run's `rm -rf dist`, turning an occasional race into a near-deterministic
15
+ # phantom "dist missing" pack failure (measured: 7/8 packs MISSING). Wrapping the
16
+ # whole pack fixes it — the read is now inside the lock (2026-07-03).
17
+ #
18
+ # Reentrancy: the wrapped `npm pack` re-invokes build-bridge as its prepack, which
19
+ # is itself wrapped by this script (package.json). ENTWURF_BUILD_LOCK_HELD, exported
20
+ # once the outer wrapper acquires, tells the nested invocation to run through
21
+ # without re-acquiring, so the prepack never self-deadlocks against its own gate.
22
+ #
23
+ # mkdir is the atomic primitive (POSIX, no `flock` dependency — portable across the
24
+ # npm/pnpm lifecycle on the Linux publish host). Mirrors the entwurf-v2 dispatch
25
+ # lock discipline: atomic acquire, holder pid recorded for human cleanup, same-host
26
+ # stale reclaim (a SIGKILL'd holder never wedges every future build), and a
27
+ # bounded wait that fails loud rather than hanging or building into a half-wiped dir.
28
+ set -euo pipefail
29
+
30
+ if [ "$#" -eq 0 ]; then
31
+ echo "usage: with-dist-lock.sh <command> [args...]" >&2
32
+ exit 2
33
+ fi
34
+
35
+ # An ancestor already holds the dist lock (e.g. this is `npm pack`'s prepack
36
+ # build-bridge under a gate that wraps the whole pack) — run reentrantly.
37
+ if [ -n "${ENTWURF_BUILD_LOCK_HELD:-}" ]; then
38
+ exec "$@"
39
+ fi
40
+
41
+ REPO_DIR=$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
42
+ LOCK_DIR="$REPO_DIR/.tmp-verify/dist.lock"
43
+ mkdir -p "$REPO_DIR/.tmp-verify"
44
+
45
+ try_acquire() {
46
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
47
+ return 0
48
+ fi
49
+ # Same-host stale reclaim: a holder killed with SIGKILL leaves the dir behind.
50
+ # If its recorded pid is gone, the lock is dead — reclaim it so a crashed build
51
+ # never wedges every future one. (Builds/packs always run on the local host, so
52
+ # a same-host pid probe is authoritative.)
53
+ local holder
54
+ holder=$(cat "$LOCK_DIR/pid" 2>/dev/null || echo "")
55
+ if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then
56
+ rm -rf "$LOCK_DIR"
57
+ mkdir "$LOCK_DIR" 2>/dev/null && return 0
58
+ fi
59
+ return 1
60
+ }
61
+
62
+ # Bounded wait (~120s): a full pack (prepack tsc emit + npm tree walk) is a few
63
+ # seconds, so this clears several serialized contenders with headroom, then fails
64
+ # loud rather than hanging forever.
65
+ acquired=0
66
+ for _ in $(seq 1 1200); do
67
+ if try_acquire; then acquired=1; break; fi
68
+ sleep 0.1
69
+ done
70
+ if [ "$acquired" != 1 ]; then
71
+ echo "[with-dist-lock] could not acquire dist lock after ~120s: $LOCK_DIR" >&2
72
+ echo "[with-dist-lock] if no other build/pack is running, remove it: rm -rf \"$LOCK_DIR\"" >&2
73
+ exit 1
74
+ fi
75
+ echo "$$" > "$LOCK_DIR/pid" 2>/dev/null || true
76
+ # Release on ANY exit (incl. the wrapped command failing) so a red run never wedges
77
+ # the next. The nested prepack build-bridge sees this and skips re-acquiring.
78
+ export ENTWURF_BUILD_LOCK_HELD=$$
79
+ trap 'rm -rf "$LOCK_DIR"' EXIT
80
+
81
+ "$@"