@junghanacs/entwurf 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # smoke-resident-garden-guard — LIVE gate for the 0.9.0 resident garden-native
4
+ # session enforcement on `--entwurf-control` (NEXT.md "operator session garden
5
+ # identity"). Mirrors GLG's requirement: "native이든 entwurf이든 --entwurf-
6
+ # control 켜면 내 스타일로 고정 … 비-garden id가 보이면 바로 터져야 돼."
7
+ #
8
+ # NEGATIVE (0 tokens, the must-have): raw `pi --entwurf-control` with NO
9
+ # --session-id → pi mints a uuidv7 → the entwurf-control guard must BLOW UP
10
+ # at session_start BEFORE any model turn:
11
+ # - nonzero exit
12
+ # - NO model turn (no `agent_start`; zero tokens)
13
+ # - NO control socket created for that uuid session
14
+ # - the "Non-garden session id" reason on stderr
15
+ # Why this smoke exists: verified live 2026-06-04 that `ctx.shutdown()` alone
16
+ # does NOT stop the in-flight turn (26k tokens leaked through). The guard now
17
+ # hard-exits via process.exit(1); this smoke locks that guarantee so a
18
+ # regression to shutdown-only (silent token leak) fails the gate.
19
+ #
20
+ # REPLACEMENT (0 tokens): builtin /new + /clone (RPC) must be CANCELLED — they
21
+ # would mint a non-garden uuid in-process; the pre-switch guard cancels them.
22
+ #
23
+ # RESUME-INTO-UUID (0 tokens): an in-process resume (RPC switch_session) into a
24
+ # SYNTHETIC legacy-uuid session file must be pre-cancelled FRIENDLY at
25
+ # session_before_switch reason="resume" — proving that path directly, not just
26
+ # via the session_start hard-guard backstop. Asserts: switch_session
27
+ # cancelled:true, the "resume is blocked … not garden-native" guidance on
28
+ # stderr, the hard guard never fires, 0 tokens, resident stays on its garden
29
+ # id, no socket for the uuid.
30
+ #
31
+ # GNEW (0 tokens): /gnew is the garden-native in-process replacement for the
32
+ # blocked /new. Driven via RPC `prompt "/gnew"` (session.prompt intercepts the
33
+ # slash → the registered command, BEFORE any model turn). It pre-creates an
34
+ # empty garden session file and ctx.switchSession()es into it, so the new
35
+ # session is born on a garden id (header / control socket / PI_SESSION_ID),
36
+ # with no torn uuid. Asserts: new garden id ≠ old, 0 tokens, messageCount 0 /
37
+ # no conversation messages, no-rewrite-mint, socket rebound to the new id +
38
+ # old dropped + no uuid leak.
39
+ #
40
+ # POSITIVE (opt-in, ~1 cheap turn; set SMOKE_RGG_POSITIVE=1): garden
41
+ # --session-id "$(./run.sh new-session-id)" → guard passes, the session file
42
+ # header id is the garden id, and the resident name carries the `control`
43
+ # tag (NEVER `entwurf` — that tag is the entwurf_resume marker).
44
+ #
45
+ # GNEW T3 (opt-in, ~1 turn; under SMOKE_RGG_POSITIVE=1): after /gnew, one turn
46
+ # calls entwurf_self; the backend MCP child must report the NEW garden id —
47
+ # proving PI_SESSION_ID propagated through the in-process switch end to end.
48
+ #
49
+ # Cost: NEGATIVE + REPLACEMENT + RESUME-INTO-UUID + GNEW = 0 tokens. POSITIVE + GNEW T3 = ~2 cheap turns.
50
+ #
51
+ set -euo pipefail
52
+
53
+ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
54
+ ENTWURF_DIR="$HOME/.pi/entwurf-control"
55
+ SESSIONS_BASE="$HOME/.pi/agent/sessions"
56
+ # Default target stays pi-native (openai-codex/gpt-5.4), but honor the SAME target
57
+ # knob as the other live smokes: ENTWURF_LIVE_TARGET="<provider>/<model>". On
58
+ # acp-on-v2 the ACP `entwurf` provider is back, and `./run.sh smoke-acp-rgg-live`
59
+ # drives THIS runner against it (deterministic half) — the guard logic (garden-id
60
+ # enforcement) is provider-agnostic, no assertion checks the provider name, only the
61
+ # garden sessionId. RGG-specific SMOKE_RGG_PROVIDER/MODEL still override.
62
+ if [ -n "${ENTWURF_LIVE_TARGET:-}" ]; then
63
+ case "$ENTWURF_LIVE_TARGET" in
64
+ */*) _rgg_provider="${ENTWURF_LIVE_TARGET%%/*}"; _rgg_model="${ENTWURF_LIVE_TARGET#*/}" ;;
65
+ *) echo "[smoke-resident-garden-guard] ENTWURF_LIVE_TARGET must be \"<provider>/<model>\", got: $ENTWURF_LIVE_TARGET" >&2; exit 1 ;;
66
+ esac
67
+ else
68
+ _rgg_provider="${ENTWURF_LIVE_PROVIDER:-openai-codex}"
69
+ _rgg_model="${ENTWURF_LIVE_MODEL:-gpt-5.4}"
70
+ fi
71
+ PROVIDER="${SMOKE_RGG_PROVIDER:-$_rgg_provider}"
72
+ MODEL="${SMOKE_RGG_MODEL:-$_rgg_model}"
73
+ TIMEOUT="${SMOKE_RGG_TIMEOUT:-90}"
74
+ # Release-gate topology: this is a repo-under-test smoke, not a deployment smoke.
75
+ # Load ONLY this checkout's extension so results do not depend on device-local global
76
+ # packages / current branch wiring.
77
+ REPO_EXTENSION_ARGS=(--no-extensions -e "$REPO")
78
+
79
+ pass=0
80
+ fail=0
81
+ note() { printf ' %s\n' "$*"; }
82
+ ok() {
83
+ pass=$((pass + 1))
84
+ printf ' PASS %s\n' "$1"
85
+ }
86
+ bad() {
87
+ fail=$((fail + 1))
88
+ printf ' FAIL %s\n' "$1"
89
+ }
90
+
91
+ # ─── NEGATIVE — raw uuid session must blow up (0 tokens) ────────────────────
92
+ echo "[smoke-resident-garden-guard] NEGATIVE: raw 'pi --entwurf-control' (no --session-id)"
93
+ neg_out=""
94
+ neg_ec=0
95
+ neg_out=$(timeout "$TIMEOUT" pi "${REPO_EXTENSION_ARGS[@]}" --entwurf-control --provider "$PROVIDER" --model "$MODEL" \
96
+ --mode json -p 'RGG_NEGATIVE_SHOULD_NOT_RUN' 2>&1) || neg_ec=$?
97
+
98
+ # The session header pi minted (uuidv7) is printed on the --mode json stream.
99
+ neg_sid=$(printf '%s\n' "$neg_out" | grep -o '"type":"session"[^}]*"id":"[^"]*"' | head -1 | grep -o '"id":"[^"]*"' | head -1 | sed 's/.*:"//;s/"$//' || true)
100
+
101
+ if [ "$neg_ec" -ne 0 ]; then
102
+ ok "nonzero exit ($neg_ec)"
103
+ else
104
+ bad "expected nonzero exit, got 0 (guard did not blow up)"
105
+ fi
106
+
107
+ if printf '%s\n' "$neg_out" | grep -q "Non-garden session id"; then
108
+ ok "guard reason on stderr (Non-garden session id)"
109
+ else
110
+ bad "guard reason 'Non-garden session id' not found in output"
111
+ fi
112
+
113
+ if printf '%s\n' "$neg_out" | grep -q '"type":"agent_start"'; then
114
+ bad "agent_start present — the model turn RAN (token leak; shutdown-only regression?)"
115
+ else
116
+ ok "no agent_start — model turn never started (0 tokens)"
117
+ fi
118
+
119
+ if printf '%s\n' "$neg_out" | grep -qE '"totalTokens":[1-9]'; then
120
+ bad "nonzero token usage observed — turn leaked through the guard"
121
+ else
122
+ ok "no token usage observed"
123
+ fi
124
+
125
+ if [ -n "$neg_sid" ] && [ -e "$ENTWURF_DIR/$neg_sid.sock" ]; then
126
+ bad "control socket created for the uuid session ($neg_sid.sock) — server not refused"
127
+ else
128
+ ok "no control socket created for the refused session"
129
+ fi
130
+
131
+ # The refused uuid session must not have been left on disk either (it exits
132
+ # before the first assistant turn, so _persist never writes the file).
133
+ if [ -n "$neg_sid" ] && find "$SESSIONS_BASE" -name "*_${neg_sid}.jsonl" 2>/dev/null | grep -q .; then
134
+ bad "uuid session file written ($neg_sid) — turn progressed past session_start"
135
+ else
136
+ ok "no uuid session file written"
137
+ fi
138
+
139
+ # ─── REPLACEMENT — in-process new/clone must be CANCELLED, not hard-exit ─────
140
+ # Under --entwurf-control, /new and /fork|/clone mint a non-garden uuid in-process
141
+ # (no --session-id reaches an in-process switch, and the pre-switch hook result
142
+ # carries only { cancel } — it cannot inject a garden id). Before 0.9.0's pre-event
143
+ # guard these reached the session_start hard guard and process.exit(1) the WHOLE
144
+ # pi process — a routine /new killed the session. Now session_before_switch /
145
+ # session_before_fork cancel the mint: the session survives on its garden id at
146
+ # zero tokens. (GPT zero-token RPC repro, 2026-06-04.)
147
+ echo "[smoke-resident-garden-guard] REPLACEMENT: in-process new/clone cancelled (RPC, 0 tokens)"
148
+ rep_sid=$(bash "$REPO/run.sh" new-session-id)
149
+ rep_err=$(mktemp)
150
+ rep_out=$(printf '%s\n' '{"type":"get_state"}' '{"type":"new_session"}' '{"type":"clone"}' '{"type":"get_state"}' |
151
+ timeout "$TIMEOUT" pi "${REPO_EXTENSION_ARGS[@]}" --session-id "$rep_sid" --entwurf-control --provider "$PROVIDER" \
152
+ --model "$MODEL" --mode rpc 2>"$rep_err") || true
153
+
154
+ if printf '%s\n' "$rep_out" | grep -q '"command":"new_session","success":true,"data":{"cancelled":true}'; then
155
+ ok "/new (new_session) cancelled in-process"
156
+ else
157
+ bad "/new was NOT cancelled — in-process uuid mint reached the hard guard"
158
+ fi
159
+
160
+ if printf '%s\n' "$rep_out" | grep -q '"command":"clone","success":true,"data":{"cancelled":true}'; then
161
+ ok "/clone (fork) cancelled in-process"
162
+ else
163
+ bad "/clone was NOT cancelled — fork mint reached the hard guard"
164
+ fi
165
+
166
+ # Both get_state calls must report the original garden id (process survived, the
167
+ # session was never replaced by a uuid).
168
+ rep_ids=$(printf '%s\n' "$rep_out" | grep -o '"sessionId":"[^"]*"' | sort -u)
169
+ if [ "$rep_ids" = "\"sessionId\":\"$rep_sid\"" ]; then
170
+ ok "session stayed on the garden id ($rep_sid) — process survived, not replaced"
171
+ else
172
+ bad "sessionId drifted from $rep_sid — got: ${rep_ids:-<none>}"
173
+ fi
174
+
175
+ # The hard guard's "Non-garden session id" must NOT appear — the pre-switch cancel
176
+ # caught it first (our friendly "blocked under --entwurf-control" guidance is fine).
177
+ if grep -q "Non-garden session id" "$rep_err"; then
178
+ bad "hard guard fired (Non-garden session id) — pre-switch cancel missed a path"
179
+ else
180
+ ok "hard guard never fired (pre-switch cancel caught it; no process exit)"
181
+ fi
182
+
183
+ # No control socket may exist for any uuid (the cancelled mints never booted).
184
+ if find "$ENTWURF_DIR" -name '*-*-*-*-*.sock' 2>/dev/null | grep -q .; then
185
+ bad "a uuid-shaped control socket exists — a cancelled mint leaked a server"
186
+ else
187
+ ok "no uuid control socket leaked"
188
+ fi
189
+ rm -f "$rep_err"
190
+
191
+ # ─── RESUME-INTO-UUID — pre-cancel a resume INTO a legacy (non-garden) session ─
192
+ # A garden resident must refuse an IN-PROCESS resume that would land on a
193
+ # non-garden (legacy uuidv7) session id. session_before_switch reason="resume"
194
+ # reads the TARGET session header id; a non-garden target is cancelled FRIENDLY
195
+ # (0 tokens, process survives) so the resume never reaches the session_start hard
196
+ # guard that would process.exit(1) the whole resident. Driven via RPC
197
+ # switch_session into a SYNTHETIC legacy-uuid session file: runtime switchSession()
198
+ # calls emitBeforeSwitch("resume", path) BEFORE SessionManager.open, so the
199
+ # fixture only needs a readable {type:"session", id:<uuid>} header line. This is
200
+ # the missing direct proof of the friendly pre-cancel path (it was previously only
201
+ # backstopped by the session_start hard guard, never exercised on its own).
202
+ echo "[smoke-resident-garden-guard] RESUME-INTO-UUID: legacy-uuid resume pre-cancelled (RPC, 0 tokens)"
203
+ res_sid=$(bash "$REPO/run.sh" new-session-id)
204
+ legacy_uuid="0192f1a0-1234-7abc-89de-0123456789ab" # uuidv7 shape; NOT garden-native
205
+ legacy_dir=$(mktemp -d)
206
+ legacy_file="$legacy_dir/legacy-resume-target.jsonl"
207
+ printf '%s\n' "{\"type\":\"session\",\"id\":\"$legacy_uuid\",\"cwd\":\"$legacy_dir\"}" >"$legacy_file"
208
+ res_err=$(mktemp)
209
+ res_out=$(printf '%s\n' '{"type":"get_state"}' "{\"type\":\"switch_session\",\"sessionPath\":\"$legacy_file\"}" '{"type":"get_state"}' |
210
+ timeout "$TIMEOUT" pi "${REPO_EXTENSION_ARGS[@]}" --session-id "$res_sid" --entwurf-control --provider "$PROVIDER" \
211
+ --model "$MODEL" --mode rpc 2>"$res_err") || true
212
+
213
+ if printf '%s\n' "$res_out" | grep -q '"command":"switch_session","success":true,"data":{"cancelled":true}'; then
214
+ ok "resume-into-uuid: switch_session cancelled (friendly pre-cancel fired)"
215
+ else
216
+ bad "resume-into-uuid: switch_session was NOT cancelled — pre-switch guard missed the resume path"
217
+ fi
218
+
219
+ # Friendly guidance is the refuse path; assert BOTH the blocked-line and the
220
+ # garden-native reason so a generic block can't pass for the resume-specific one.
221
+ if grep -q "resume is blocked under --entwurf-control" "$res_err" && grep -q "is not garden-native" "$res_err"; then
222
+ ok "resume-into-uuid: friendly refuse message on stderr (garden-native reason)"
223
+ else
224
+ bad "resume-into-uuid: friendly refuse message not found (resume blocked + not garden-native)"
225
+ fi
226
+
227
+ # The session_start hard guard must NOT fire — the pre-switch cancel caught it first.
228
+ if grep -q "Non-garden session id" "$res_err"; then
229
+ bad "resume-into-uuid: hard guard fired (Non-garden session id) — pre-cancel missed; resident would exit"
230
+ else
231
+ ok "resume-into-uuid: hard guard never fired (pre-cancel caught it; resident survives)"
232
+ fi
233
+
234
+ # 0 tokens — the cancel happens before any model turn.
235
+ if printf '%s\n' "$res_out" | grep -q '"type":"agent_start"'; then
236
+ bad "resume-into-uuid: agent_start seen — a model turn RAN (should be a 0-token cancel)"
237
+ else
238
+ ok "resume-into-uuid: no agent_start — 0-token cancel path"
239
+ fi
240
+
241
+ # Resident stayed on its original garden id (both get_state report it, never the uuid).
242
+ res_ids=$(printf '%s\n' "$res_out" | grep -o '"sessionId":"[^"]*"' | sort -u)
243
+ if [ "$res_ids" = "\"sessionId\":\"$res_sid\"" ]; then
244
+ ok "resume-into-uuid: resident stayed on the garden id ($res_sid)"
245
+ else
246
+ bad "resume-into-uuid: sessionId drifted from $res_sid — got: ${res_ids:-<none>}"
247
+ fi
248
+
249
+ # No control socket for the legacy uuid (the cancelled resume never booted a server).
250
+ if [ -e "$ENTWURF_DIR/$legacy_uuid.sock" ]; then
251
+ bad "resume-into-uuid: control socket created for the legacy uuid — server booted"
252
+ else
253
+ ok "resume-into-uuid: no control socket for the legacy uuid"
254
+ fi
255
+ rm -f "$res_err"
256
+ rm -rf "$legacy_dir"
257
+
258
+ # ─── GNEW — /gnew births a NEW garden session IN-PROCESS (RPC, 0 tokens) ─────
259
+ # Builtin /new stays blocked (REPLACEMENT above); /gnew is its garden-native
260
+ # replacement. /gnew pre-creates an EMPTY garden session file and ctx.switchSession()es
261
+ # into it — switchSession→SessionManager.open() reads the garden header id BEFORE
262
+ # session_start, so the identity (file header, control socket, PI_SESSION_ID) is the
263
+ # garden id from the first bind: no torn uuid (unlike ctx.newSession, which mints a
264
+ # uuid first). Driven headless via RPC prompt "/gnew" — session.prompt intercepts the
265
+ # leading slash and runs the registered command BEFORE any model turn, so 0 tokens.
266
+ echo "[smoke-resident-garden-guard] GNEW: /gnew in-process garden birth (RPC prompt, 0 tokens)"
267
+ GARDEN_RE='^[0-9]{8}T[0-9]{6}-[0-9a-f]{6}$'
268
+ gnew_sid=$(bash "$REPO/run.sh" new-session-id)
269
+ gnew_json=$(node --experimental-strip-types "$REPO/scripts/gnew-rpc-drive.ts" "$gnew_sid" "$PROVIDER" "$MODEL" "$((TIMEOUT * 1000))") || true
270
+
271
+ jstr() { printf '%s' "$gnew_json" | grep -o "\"$1\":\"[^\"]*\"" | head -1 | sed 's/.*:"//;s/"$//'; }
272
+ g_before=$(jstr before)
273
+ g_after=$(jstr after)
274
+
275
+ if [ "$g_before" = "$gnew_sid" ]; then
276
+ ok "/gnew: session started on the launched garden id ($gnew_sid)"
277
+ else
278
+ bad "/gnew: before id ('$g_before') != launched ($gnew_sid)"
279
+ fi
280
+
281
+ if [ -n "$g_after" ] && [ "$g_after" != "$g_before" ] && [[ "$g_after" =~ $GARDEN_RE ]]; then
282
+ ok "/gnew: switched to a NEW garden id ($g_after)"
283
+ else
284
+ bad "/gnew: after id ('$g_after') is not a fresh garden id"
285
+ fi
286
+
287
+ if printf '%s' "$gnew_json" | grep -q '"promptOk":true'; then
288
+ ok "/gnew: prompt accepted (slash command intercepted, not modeled)"
289
+ else
290
+ bad "/gnew: prompt not acknowledged — command may not be registered"
291
+ fi
292
+
293
+ if printf '%s' "$gnew_json" | grep -q '"agentStartSeen":true'; then
294
+ bad "/gnew: agent_start seen — a model turn RAN (should be a 0-token command)"
295
+ else
296
+ ok "/gnew: no agent_start — 0-token command path"
297
+ fi
298
+
299
+ if printf '%s' "$gnew_json" | grep -q '"afterMsgCount":0'; then
300
+ ok "/gnew: new session is empty (messageCount 0, no turn)"
301
+ else
302
+ bad "/gnew: new session not empty — afterMsgCount != 0"
303
+ fi
304
+
305
+ if printf '%s' "$gnew_json" | grep -q '"extensionErrors":\[\]'; then
306
+ ok "/gnew: no extension error (handler did not throw)"
307
+ else
308
+ bad "/gnew: extension error surfaced — handler threw (switch likely did not happen)"
309
+ fi
310
+
311
+ # New session file on disk: header id IS the garden id (no-rewrite-mint). The file
312
+ # may carry session-metadata entries (model_change / session_info) but NEVER a
313
+ # conversation message — that is asserted separately below.
314
+ gfile=$(find "$SESSIONS_BASE" -name "*_${g_after}.jsonl" 2>/dev/null | head -1)
315
+ if [ -n "$gfile" ] && head -1 "$gfile" | grep -q "\"type\":\"session\"[^}]*\"id\":\"$g_after\""; then
316
+ ok "/gnew: new session file header id == $g_after (no uuid re-mint)"
317
+ else
318
+ bad "/gnew: garden header id not found on disk for $g_after"
319
+ fi
320
+ # no conversation ran: the file may carry session-metadata entries
321
+ # (model_change / session_info) but NEVER a user/assistant message.
322
+ if [ -n "$gfile" ] && ! grep -qE '"type":"(message|user_message)"|"role":"assistant"' "$gfile"; then
323
+ ok "/gnew: new session carries no conversation messages (clean birth)"
324
+ else
325
+ bad "/gnew: new session has conversation messages — a turn leaked"
326
+ fi
327
+ # /gnew sessions are first-class residents: the control-tagged garden name is set
328
+ # at switch (the file already exists), and it must NEVER carry the entwurf tag.
329
+ if [ -n "$gfile" ] && grep -q "\"name\":\"${g_after}==[^\"]*__control\"" "$gfile"; then
330
+ ok "/gnew: new session carries the 'control' resident name"
331
+ else
332
+ bad "/gnew: new session missing the 'control' resident name"
333
+ fi
334
+ # Tag-position check ONLY: the `entwurf` RESUME-MARKER lives in the tag segment
335
+ # (after `__`), never the title slug. After the repo rename the cwd basename is
336
+ # `entwurf`, so the title slug legitimately IS `entwurf` — parseSessionName treats
337
+ # title:"entwurf"+tags:["control"] as NOT an Entwurf session. A bare substring
338
+ # grep false-positives on that slug; match the `__...entwurf...` tag segment only.
339
+ if [ -n "$gfile" ] && grep -qE "\"name\":\"${g_after}==[^\"]*__([a-z0-9-]+_)*entwurf(_[a-z0-9-]+)*\"" "$gfile"; then
340
+ bad "/gnew: new session name carries the 'entwurf' tag — would be resumable as a child"
341
+ else
342
+ ok "/gnew: new session name does NOT carry the 'entwurf' tag"
343
+ fi
344
+
345
+ # Control socket rebound to the new garden id; old one dropped; no uuid leak.
346
+ # (socketsAfterSwitch is snapshotted by the driver while pi is still alive.)
347
+ if printf '%s' "$gnew_json" | grep -q "\"${g_after}.sock\""; then
348
+ ok "/gnew: control socket rebound to the new garden id"
349
+ else
350
+ bad "/gnew: no control socket for the new garden id after switch"
351
+ fi
352
+ if printf '%s' "$gnew_json" | grep -q "\"${g_before}.sock\""; then
353
+ bad "/gnew: old garden socket still present — switch did not drop it"
354
+ else
355
+ ok "/gnew: old garden socket dropped after switch"
356
+ fi
357
+ if printf '%s' "$gnew_json" | grep -qE '"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.sock"'; then
358
+ bad "/gnew: a uuid-shaped control socket exists — torn identity"
359
+ else
360
+ ok "/gnew: no uuid-shaped control socket (garden identity intact)"
361
+ fi
362
+ # clean up the empty garden session file /gnew created (litter hygiene)
363
+ [ -n "$gfile" ] && rm -f "$gfile"
364
+
365
+ # ─── GNEW T3 — backend identity after /gnew (entwurf_self, opt-in ~1 turn) ───
366
+ if [ "${SMOKE_RGG_POSITIVE:-0}" = "1" ]; then
367
+ echo "[smoke-resident-garden-guard] GNEW T3: backend identity after /gnew (entwurf_self, 1 turn)"
368
+ gself_sid=$(bash "$REPO/run.sh" new-session-id)
369
+ self_prompt='Call the entwurf_self tool now, then reply with only the sessionId it returned.'
370
+ gself_json=$(node --experimental-strip-types "$REPO/scripts/gnew-rpc-drive.ts" "$gself_sid" "$PROVIDER" "$MODEL" "$((TIMEOUT * 1000))" "$self_prompt") || true
371
+ gs_after=$(printf '%s' "$gself_json" | grep -o '"after":"[^"]*"' | head -1 | sed 's/.*:"//;s/"$//')
372
+ gs_ids=$(printf '%s' "$gself_json" | grep -o '"selfEnvelopeSessionIds":\[[^]]*\]' | head -1)
373
+
374
+ if printf '%s' "$gself_json" | grep -q '"selfTurnEnded":true'; then
375
+ ok "/gnew T3: entwurf_self turn completed"
376
+ else
377
+ bad "/gnew T3: entwurf_self turn did not complete (timeout?)"
378
+ fi
379
+ # The envelope sessionId the backend MCP child reports MUST be the NEW garden
380
+ # id — proves PI_SESSION_ID propagated through streamShellAcp after the switch.
381
+ if [ -n "$gs_after" ] && printf '%s' "$gs_ids" | grep -q "\"$gs_after\""; then
382
+ ok "/gnew T3: backend entwurf_self sessionId == new garden id ($gs_after)"
383
+ else
384
+ bad "/gnew T3: backend identity mismatch — envelope did not report $gs_after (got $gs_ids)"
385
+ fi
386
+ # ...and NOT the pre-switch launched id (that would mean a torn/stale identity).
387
+ if printf '%s' "$gs_ids" | grep -q "\"$gself_sid\""; then
388
+ bad "/gnew T3: backend reported the PRE-switch id ($gself_sid) — torn identity"
389
+ else
390
+ ok "/gnew T3: backend did not report the pre-switch id (no torn identity)"
391
+ fi
392
+ [ -n "$gs_after" ] && find "$SESSIONS_BASE" -name "*_${gs_after}.jsonl" -delete 2>/dev/null || true
393
+ fi
394
+
395
+ # ─── POSITIVE — garden session passes + control name (opt-in, costs a turn) ──
396
+ if [ "${SMOKE_RGG_POSITIVE:-0}" = "1" ]; then
397
+ echo "[smoke-resident-garden-guard] POSITIVE: garden --session-id (SMOKE_RGG_POSITIVE=1)"
398
+ pos_sid=$(bash "$REPO/run.sh" new-session-id)
399
+ pos_ec=0
400
+ pos_out=$(timeout "$TIMEOUT" pi "${REPO_EXTENSION_ARGS[@]}" --session-id "$pos_sid" --entwurf-control --provider "$PROVIDER" \
401
+ --model "$MODEL" --mode json -p 'reply OK only' 2>&1) || pos_ec=$?
402
+
403
+ if [ "$pos_ec" -eq 0 ] && ! printf '%s\n' "$pos_out" | grep -q "Non-garden session id"; then
404
+ ok "garden session not refused (exit 0, no guard)"
405
+ else
406
+ bad "garden session was refused (exit=$pos_ec) — false positive in the guard"
407
+ fi
408
+
409
+ pos_file=$(find "$SESSIONS_BASE" -name "*_${pos_sid}.jsonl" 2>/dev/null | head -1)
410
+ if [ -n "$pos_file" ] && grep -q "\"type\":\"session\"[^}]*\"id\":\"$pos_sid\"" "$pos_file"; then
411
+ ok "garden header id on disk ($pos_sid)"
412
+ else
413
+ bad "garden header id not found on disk for $pos_sid"
414
+ fi
415
+
416
+ if [ -n "$pos_file" ] && grep -q "\"name\":\"${pos_sid}==[^\"]*__control\"" "$pos_file"; then
417
+ ok "resident name carries the 'control' tag"
418
+ else
419
+ bad "resident name with 'control' tag not found"
420
+ fi
421
+ # Tag-position check only (see /gnew block above): match the `__...entwurf...`
422
+ # tag segment, not the title slug (which is legitimately `entwurf` post-rename).
423
+ if [ -n "$pos_file" ] && grep -qE "\"name\":\"${pos_sid}==[^\"]*__([a-z0-9-]+_)*entwurf(_[a-z0-9-]+)*\"" "$pos_file"; then
424
+ bad "resident name carries the 'entwurf' tag — would be resumable as a child"
425
+ else
426
+ ok "resident name does NOT carry the 'entwurf' tag"
427
+ fi
428
+ else
429
+ note "POSITIVE skipped (set SMOKE_RGG_POSITIVE=1 to run; costs ~1 cheap turn)"
430
+ fi
431
+
432
+ echo "[smoke-resident-garden-guard] PASS=$pass FAIL=$fail"
433
+ [ "$fail" -eq 0 ]
@@ -0,0 +1,187 @@
1
+ /**
2
+ * smoke-session-id-name — LIVE 3-turn substrate smoke for the Pi 0.78
3
+ * `--session-id` + `--name` primitives, exercised with a pi-native provider
4
+ * but WITHOUT touching the Entwurf tool surface (Phase 3a).
5
+ *
6
+ * It dogfoods the locked identity helpers (generateSessionId / buildSessionName
7
+ * / readSessionHeader / findSessionFilesById / analyzeSessionFileLike) against a
8
+ * real `pi` process, proving the substrate the Entwurf rewrite will sit on:
9
+ *
10
+ * T1 same-cwd turn 1 : header id == sessionId, header cwd == launch cwd,
11
+ * session_info.name == the denote-style name (info layer).
12
+ * T2 same-cwd turn 2 : NO --name re-supplied → exactly one session file,
13
+ * append-not-recreate (analyze turns grow), session name
14
+ * unchanged (spawn-only name).
15
+ * T3 wrong-cwd turn : same --session-id from a DIFFERENT cwd creates a SECOND
16
+ * session with the same header id but a different header
17
+ * cwd. Recorded as footgun EVIDENCE (not a failure) — this
18
+ * is exactly what the 0.9.0 resume guard must prevent by
19
+ * forcing child cwd to the saved header cwd.
20
+ *
21
+ * LIVE: spawns real `pi` turns (auth + tokens). Cheap sonnet `-p 'ok'`. Isolated
22
+ * via a temp PI_CODING_AGENT_DIR so the real ~/.pi/agent is never touched.
23
+ */
24
+
25
+ import assert from "node:assert/strict";
26
+ import { spawnSync } from "node:child_process";
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
33
+ const PI_BIN = process.env.PI_BIN ?? "pi";
34
+
35
+ // v2-only retarget: the substrate smoke no longer rides the (removed) ACP
36
+ // `entwurf` provider — the --session-id/--name primitives under test are
37
+ // provider-agnostic. Drive a real pi-native provider/model instead, sharing the
38
+ // v2 live-smoke env (ENTWURF_LIVE_TARGET, default openai-codex/gpt-5.4).
39
+ function resolveTarget(): { provider: string; model: string } {
40
+ const combined = process.env.ENTWURF_LIVE_TARGET?.trim();
41
+ if (combined) {
42
+ const slash = combined.indexOf("/");
43
+ if (slash <= 0 || slash === combined.length - 1) {
44
+ throw new Error(`ENTWURF_LIVE_TARGET must be "<provider>/<model>", got: ${JSON.stringify(combined)}`);
45
+ }
46
+ return { provider: combined.slice(0, slash), model: combined.slice(slash + 1) };
47
+ }
48
+ return {
49
+ provider: process.env.ENTWURF_LIVE_PROVIDER?.trim() || "openai-codex",
50
+ model: process.env.ENTWURF_LIVE_MODEL?.trim() || "gpt-5.4",
51
+ };
52
+ }
53
+ const { provider: LIVE_PROVIDER, model: LIVE_MODEL } = resolveTarget();
54
+
55
+ // Isolate session storage BEFORE importing entwurf-core (it computes
56
+ // SESSIONS_BASE at module load from PI_CODING_AGENT_DIR).
57
+ const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "psa-sidname-agent-"));
58
+ process.env.PI_CODING_AGENT_DIR = agentDir;
59
+ process.env.ENTWURF_TARGETS_PATH = path.join(REPO_ROOT, "pi", "entwurf-targets.json");
60
+
61
+ // The native retarget provider authenticates via <PI_CODING_AGENT_DIR>/auth.json.
62
+ // The isolated temp dir has none (old ACP `entwurf` rode Claude Code OAuth, so
63
+ // isolation was free); copy the real OAuth creds in so real turns authenticate while
64
+ // SESSIONS stay isolated — the whole point — and the dir is removed on exit.
65
+ const realAuth = path.join(os.homedir(), ".pi", "agent", "auth.json");
66
+ if (fs.existsSync(realAuth)) fs.copyFileSync(realAuth, path.join(agentDir, "auth.json"));
67
+
68
+ const { generateSessionId, buildSessionName, readSessionHeader, findSessionFilesById, analyzeSessionFileLike } =
69
+ await import("../pi-extensions/lib/entwurf-core.ts");
70
+
71
+ const cwd1 = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "psa-sidname-cwd1-")));
72
+ const cwd2 = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "psa-sidname-cwd2-")));
73
+
74
+ function cleanup() {
75
+ for (const d of [agentDir, cwd1, cwd2]) {
76
+ try {
77
+ fs.rmSync(d, { recursive: true, force: true });
78
+ } catch {
79
+ /* best-effort */
80
+ }
81
+ }
82
+ }
83
+
84
+ /** Latest `session_info` name wins (matches pi's getSessionName). */
85
+ function readSessionInfoName(file: string): string | null {
86
+ let name: string | null = null;
87
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
88
+ const t = line.trim();
89
+ if (!t) continue;
90
+ try {
91
+ const e = JSON.parse(t) as { type?: string; name?: unknown };
92
+ if (e.type === "session_info" && typeof e.name === "string") name = e.name;
93
+ } catch {
94
+ /* skip */
95
+ }
96
+ }
97
+ return name;
98
+ }
99
+
100
+ function runPiTurn(turnCwd: string, sessionId: string, name: string | null, prompt: string): void {
101
+ const args = [
102
+ "--no-extensions",
103
+ "-e",
104
+ REPO_ROOT,
105
+ "--session-id",
106
+ sessionId,
107
+ "--provider",
108
+ LIVE_PROVIDER,
109
+ "--model",
110
+ LIVE_MODEL,
111
+ "--mode",
112
+ "json",
113
+ ];
114
+ if (name !== null) args.splice(args.indexOf("--provider"), 0, "--name", name);
115
+ args.push("-p", prompt);
116
+
117
+ const res = spawnSync(PI_BIN, args, {
118
+ cwd: turnCwd,
119
+ encoding: "utf8",
120
+ timeout: 180_000,
121
+ env: { ...process.env, PI_CODING_AGENT_DIR: agentDir },
122
+ });
123
+ if (res.status !== 0) {
124
+ const tail = `${res.stdout ?? ""}\n${res.stderr ?? ""}`.split("\n").slice(-12).join("\n");
125
+ throw new Error(`pi turn failed (status=${res.status}, signal=${res.signal}) in ${turnCwd}:\n${tail}`);
126
+ }
127
+ }
128
+
129
+ try {
130
+ const sessionId = generateSessionId();
131
+ const name = buildSessionName({
132
+ sessionId,
133
+ provider: LIVE_PROVIDER,
134
+ model: LIVE_MODEL,
135
+ rawTitle: "substrate smoke session-id name",
136
+ tags: ["entwurf", "smoke"],
137
+ });
138
+ console.log(`[smoke-session-id-name] sessionId=${sessionId}`);
139
+ console.log(`[smoke-session-id-name] name=${name}`);
140
+
141
+ // ---- T1: same-cwd turn 1 (spawn with --session-id + --name) ----
142
+ runPiTurn(cwd1, sessionId, name, "ok");
143
+ const afterT1 = findSessionFilesById(sessionId);
144
+ assert.equal(afterT1.length, 1, `[T1] exactly one session file (got ${afterT1.length})`);
145
+ const fileT1 = afterT1[0] as string;
146
+ const headerT1 = readSessionHeader(fileT1);
147
+ assert.equal(headerT1?.id, sessionId, "[T1] header id == sessionId");
148
+ assert.equal(headerT1?.cwd, cwd1, `[T1] header cwd == launch cwd (${headerT1?.cwd} vs ${cwd1})`);
149
+ assert.equal(readSessionInfoName(fileT1), name, "[T1] session_info.name == denote name (info layer, not header)");
150
+ const turnsT1 = analyzeSessionFileLike(fileT1).turns;
151
+ assert.ok(turnsT1 >= 1, `[T1] at least one assistant turn (got ${turnsT1})`);
152
+ console.log(`[smoke-session-id-name] [T1] ok — file=${path.basename(fileT1)} turns=${turnsT1}`);
153
+
154
+ // ---- T2: same-cwd turn 2, NO --name (append, spawn-only name) ----
155
+ runPiTurn(cwd1, sessionId, null, "ok again");
156
+ const afterT2 = findSessionFilesById(sessionId);
157
+ assert.equal(afterT2.length, 1, `[T2] still exactly one session file — append, not recreate (got ${afterT2.length})`);
158
+ assert.equal(afterT2[0], fileT1, "[T2] same session file path");
159
+ const headerT2 = readSessionHeader(fileT1);
160
+ assert.equal(headerT2?.id, sessionId, "[T2] header id unchanged");
161
+ assert.equal(headerT2?.cwd, cwd1, "[T2] header cwd unchanged");
162
+ assert.equal(readSessionInfoName(fileT1), name, "[T2] session name unchanged without --name (spawn-only)");
163
+ const turnsT2 = analyzeSessionFileLike(fileT1).turns;
164
+ assert.ok(turnsT2 > turnsT1, `[T2] turns grew ${turnsT1} → ${turnsT2} (appended to existing session)`);
165
+ console.log(`[smoke-session-id-name] [T2] ok — append confirmed, turns=${turnsT2}`);
166
+
167
+ // ---- T3: wrong-cwd turn, same --session-id (footgun EVIDENCE) ----
168
+ runPiTurn(cwd2, sessionId, null, "ok");
169
+ const afterT3 = findSessionFilesById(sessionId);
170
+ assert.equal(
171
+ afterT3.length,
172
+ 2,
173
+ `[T3] same id from a different cwd created a SECOND session (footgun; got ${afterT3.length})`,
174
+ );
175
+ const cwds = afterT3.map((f) => readSessionHeader(f)?.cwd).sort();
176
+ assert.deepEqual(cwds, [cwd1, cwd2].sort(), "[T3] the two sessions carry different header cwds");
177
+ console.log(
178
+ `[smoke-session-id-name] [T3] footgun evidence recorded — same sessionId now exists under 2 cwds ` +
179
+ `(${cwd1}, ${cwd2}). 0.9.0 resume MUST force child cwd to the saved header cwd to avoid this.`,
180
+ );
181
+
182
+ console.log(
183
+ "[smoke-session-id-name] PASS — substrate proven (append + spawn-only name + wrong-cwd footgun documented)",
184
+ );
185
+ } finally {
186
+ cleanup();
187
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ // Sub-config for verification scripts. Same shape as mcp/tsconfig.json:
3
+ // these files run under Node's `--experimental-strip-types` resolver, so
4
+ // they import sibling .ts files with explicit `.ts` extensions. tsc pairs
5
+ // allowImportingTsExtensions with noEmit, which matches the typecheck-only
6
+ // role of these scripts. See AGENTS.md § Typecheck Boundary for the full
7
+ // fence policy — every .ts in this repo must be reached by exactly one
8
+ // `tsc --noEmit` pass.
9
+ //
10
+ // Why scripts/ is split off from the root config:
11
+ // - Root config is emit-capable (`./run.sh check-models` tsc-emits the
12
+ // project entry). It cannot enable allowImportingTsExtensions.
13
+ // - These scripts must use explicit .ts on the wire (strip-types runtime).
14
+ // - Same trade-off mcp/* already resolved. This split is the smallest
15
+ // expression that covers the runtime modes without weakening the fence.
16
+ "extends": "../tsconfig.json",
17
+ "compilerOptions": {
18
+ "allowImportingTsExtensions": true,
19
+ "noEmit": true,
20
+ "rootDir": ".."
21
+ },
22
+ // Override the root `exclude` (which lists "scripts"): without this, the
23
+ // inherited exclude filters out everything `include` adds under scripts/, so
24
+ // the verification scripts were silently NOT typechecked (fence gap found in
25
+ // Phase 3b). Re-list the root's other exclusions, minus "scripts".
26
+ "exclude": ["../node_modules", "../mcp", "../plugins"],
27
+ "include": [
28
+ "./**/*.ts",
29
+ "../pi-extensions/lib/**/*.ts",
30
+ "../pi-extensions/model-lock.ts",
31
+ "../pi-extensions/meta-bridge-hook.ts",
32
+ "../mcp/entwurf-bridge/src/resume-mode.ts"
33
+ ]
34
+ }