@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,408 @@
1
+ /**
2
+ * check-model-lock — deterministic unit test for `pi-extensions/model-lock.ts`.
3
+ *
4
+ * Mocks `ExtensionAPI` + `ExtensionContext` and drives the
5
+ * `session_start` / `agent_start` / `model_select` handlers through every
6
+ * case in the policy matrix. No pi process, no network, no API cost. The
7
+ * harness shape mirrors pi-core's actual behavior (setModel mutates +
8
+ * emits synthetic model_select with source="set") so reentry-guard
9
+ * validation is realistic.
10
+ *
11
+ * Coverage (every assertion must pass):
12
+ * 1. native → native: no-op
13
+ * 2. native → entwurf: revert (warning notify)
14
+ * 3. entwurf → native: revert (warning notify)
15
+ * 4. entwurf/X → entwurf/Y (id differs): revert (warning notify)
16
+ * 5. entwurf → entwurf (same id): skip (changed=false)
17
+ * 6. undefined → any: skip (!from)
18
+ * 7. source="restore": skip
19
+ * 8. Reentry guard: revert's synthetic emit MUST NOT re-trigger revert
20
+ * 9. setModel returns false: notify level "error" instead of "warning"
21
+ * 10. session_start "startup" + entries=0: lock NOT engaged, model_select no-op
22
+ * 11. session_start "startup" + entries already have a message: lock engaged
23
+ * 12. session_start "startup" empty → agent_start → lock engaged
24
+ * 13. session_start "resume": lock engaged immediately
25
+ * 14. session_start "new" + entries=0: lock NOT engaged
26
+ * 15. session_start "reload" + entries have a message: lock engaged
27
+ * 16. session_start "fork": lock engaged immediately
28
+ * 17. session_start "reload" + entries=0 BUT prior sessionLocked=true: lock preserved
29
+ * 18. getEntries() throws: defensive `catch { return true }` engages the lock
30
+ *
31
+ * Scope (B-side, the extension hook):
32
+ * this drives the model-lock extension hook directly. Its former A-side
33
+ * companion `smoke-model-switch` exercised the ACP bridge guard
34
+ * (`ensureBridgeSession`) and was removed with the ACP purge (v2-only),
35
+ * so this B-side gate now stands alone as the policy verification surface.
36
+ *
37
+ * Module-level state note: model-lock.ts uses two module-level flags
38
+ * (`reverting`, `sessionLocked`) that persist across tests in the same
39
+ * Node process. Each `makeHarness` call explicitly establishes the
40
+ * starting state via `initial:` so tests do not implicitly depend on
41
+ * order.
42
+ *
43
+ * See AGENTS.md § session model lock, NEXT.md § Session model lock.
44
+ */
45
+
46
+ import assert from "node:assert/strict";
47
+
48
+ import modelLockExtension from "../pi-extensions/model-lock.ts";
49
+
50
+ type Provider = string;
51
+ type ModelLike = { provider: Provider; id: string };
52
+ type Source = "set" | "cycle" | "restore";
53
+ type SessionStartReason = "startup" | "reload" | "new" | "resume" | "fork";
54
+
55
+ interface ModelSelectEventLike {
56
+ type: "model_select";
57
+ model: ModelLike;
58
+ previousModel: ModelLike | undefined;
59
+ source: Source;
60
+ }
61
+
62
+ interface SessionStartEventLike {
63
+ type: "session_start";
64
+ reason: SessionStartReason;
65
+ previousSessionFile?: string;
66
+ }
67
+
68
+ interface AgentStartEventLike {
69
+ type: "agent_start";
70
+ }
71
+
72
+ interface EntryLike {
73
+ type: string;
74
+ }
75
+
76
+ interface NotifyCall {
77
+ message: string;
78
+ level: "info" | "warning" | "error";
79
+ }
80
+
81
+ interface Harness {
82
+ dispatchSessionStart(reason: SessionStartReason): Promise<void>;
83
+ dispatchAgentStart(): Promise<void>;
84
+ dispatch(event: ModelSelectEventLike): Promise<void>;
85
+ setEntries(entries: EntryLike[]): void;
86
+ setEntriesThrowing(throwing: boolean): void;
87
+ setModelCalls: ModelLike[];
88
+ notifyCalls: NotifyCall[];
89
+ emitsDuringSetModel(): number;
90
+ }
91
+
92
+ /**
93
+ * `initial` controls the starting state of module-level `sessionLocked`:
94
+ *
95
+ * "locked" — emit session_start "resume" (immediate lock). Default for
96
+ * tests 1-9 which predate the sessionLocked gate.
97
+ * "unlocked" — emit session_start "startup" with empty entries (no lock).
98
+ * For tests verifying pre-turn freedom.
99
+ * "manual" — emit nothing. Test code drives session_start /
100
+ * agent_start explicitly. For tests with multi-step
101
+ * state setup (e.g., test 17 reload-preserve).
102
+ */
103
+ type InitialState = "locked" | "unlocked" | "manual";
104
+
105
+ interface MakeHarnessOpts {
106
+ setModelReturns?: boolean;
107
+ initial?: InitialState;
108
+ entries?: EntryLike[];
109
+ }
110
+
111
+ async function makeHarness(opts: MakeHarnessOpts = {}): Promise<Harness> {
112
+ const setModelReturns = opts.setModelReturns ?? true;
113
+ const initial: InitialState = opts.initial ?? "locked";
114
+ let entries: EntryLike[] = opts.entries ?? [];
115
+ let entriesThrowing = false;
116
+
117
+ const modelSelectHandlers: Array<(event: ModelSelectEventLike, ctx: unknown) => Promise<void> | void> = [];
118
+ const sessionStartHandlers: Array<(event: SessionStartEventLike, ctx: unknown) => Promise<void> | void> = [];
119
+ const agentStartHandlers: Array<(event: AgentStartEventLike, ctx: unknown) => Promise<void> | void> = [];
120
+
121
+ const setModelCalls: ModelLike[] = [];
122
+ const notifyCalls: NotifyCall[] = [];
123
+ let emitsDuringSetModel = 0;
124
+
125
+ const ctx = {
126
+ ui: {
127
+ notify(message: string, level: "info" | "warning" | "error" = "info"): void {
128
+ notifyCalls.push({ message, level });
129
+ },
130
+ },
131
+ sessionManager: {
132
+ getEntries(): EntryLike[] {
133
+ if (entriesThrowing) throw new Error("simulated session-manager teardown");
134
+ return entries;
135
+ },
136
+ },
137
+ };
138
+
139
+ const pi = {
140
+ on(event: string, handler: any): void {
141
+ if (event === "model_select") modelSelectHandlers.push(handler);
142
+ else if (event === "session_start") sessionStartHandlers.push(handler);
143
+ else if (event === "agent_start") agentStartHandlers.push(handler);
144
+ },
145
+ async setModel(model: ModelLike): Promise<boolean> {
146
+ setModelCalls.push(model);
147
+ // Simulate pi-core: AgentSession.setModel emits a synthetic
148
+ // model_select with source "set" AFTER mutating state. The
149
+ // reentry guard must catch this and skip.
150
+ const syntheticEvent: ModelSelectEventLike = {
151
+ type: "model_select",
152
+ model,
153
+ previousModel: { provider: "fake-prev", id: "synthetic" },
154
+ source: "set",
155
+ };
156
+ emitsDuringSetModel += 1;
157
+ for (const handler of modelSelectHandlers) {
158
+ await handler(syntheticEvent, ctx);
159
+ }
160
+ return setModelReturns;
161
+ },
162
+ };
163
+
164
+ // Register the extension. After this, handlers[] arrays hold the listeners.
165
+ modelLockExtension(pi as any);
166
+
167
+ const harness: Harness = {
168
+ async dispatchSessionStart(reason: SessionStartReason) {
169
+ for (const handler of sessionStartHandlers) {
170
+ await handler({ type: "session_start", reason }, ctx);
171
+ }
172
+ },
173
+ async dispatchAgentStart() {
174
+ for (const handler of agentStartHandlers) {
175
+ await handler({ type: "agent_start" }, ctx);
176
+ }
177
+ },
178
+ async dispatch(event: ModelSelectEventLike) {
179
+ for (const handler of modelSelectHandlers) {
180
+ await handler(event, ctx);
181
+ }
182
+ },
183
+ setEntries(next: EntryLike[]) {
184
+ entries = next;
185
+ },
186
+ setEntriesThrowing(throwing: boolean) {
187
+ entriesThrowing = throwing;
188
+ },
189
+ setModelCalls,
190
+ notifyCalls,
191
+ emitsDuringSetModel: () => emitsDuringSetModel,
192
+ };
193
+
194
+ // Establish the requested initial state via real handler dispatch so
195
+ // module-level `sessionLocked` reflects the choice deterministically,
196
+ // overriding whatever the previous test left behind.
197
+ if (initial === "locked") {
198
+ await harness.dispatchSessionStart("resume");
199
+ } else if (initial === "unlocked") {
200
+ // startup with empty entries → sessionLocked = false
201
+ entries = [];
202
+ await harness.dispatchSessionStart("startup");
203
+ }
204
+ // "manual": leave whatever module state is. Test must drive session_start
205
+ // explicitly before any model_select assertion.
206
+
207
+ return harness;
208
+ }
209
+
210
+ // Test models.
211
+ const NATIVE_A: ModelLike = { provider: "openai-codex", id: "gpt-5.4" };
212
+ const NATIVE_B: ModelLike = { provider: "openai-codex", id: "gpt-5.5" };
213
+ const PSA_SONNET: ModelLike = { provider: "entwurf", id: "claude-sonnet-4-6" };
214
+ const PSA_OPUS: ModelLike = { provider: "entwurf", id: "claude-opus-4-8" };
215
+
216
+ let passed = 0;
217
+ let failed = 0;
218
+
219
+ async function run(name: string, body: () => Promise<void>): Promise<void> {
220
+ try {
221
+ await body();
222
+ console.log(` ok ${name}`);
223
+ passed += 1;
224
+ } catch (err) {
225
+ const msg = err instanceof Error ? err.message : String(err);
226
+ console.error(` FAIL ${name}`);
227
+ console.error(` ${msg}`);
228
+ failed += 1;
229
+ }
230
+ }
231
+
232
+ console.log("[check-model-lock] policy unit tests");
233
+
234
+ // =============================================================================
235
+ // Tests 1-9 — original policy matrix (sessionLocked=true via default "locked")
236
+ // =============================================================================
237
+
238
+ await run("1. native → native: no-op (touchesEntwurf=false)", async () => {
239
+ const h = await makeHarness();
240
+ await h.dispatch({ type: "model_select", model: NATIVE_B, previousModel: NATIVE_A, source: "set" });
241
+ assert.equal(h.setModelCalls.length, 0);
242
+ assert.equal(h.notifyCalls.length, 0);
243
+ });
244
+
245
+ await run("2. native → entwurf: revert + warning notify", async () => {
246
+ const h = await makeHarness();
247
+ await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: NATIVE_A, source: "set" });
248
+ assert.equal(h.setModelCalls.length, 1);
249
+ assert.deepEqual(h.setModelCalls[0], NATIVE_A);
250
+ assert.equal(h.notifyCalls.length, 1);
251
+ assert.equal(h.notifyCalls[0].level, "warning");
252
+ assert.match(h.notifyCalls[0].message, /locked to openai-codex\/gpt-5\.4/);
253
+ assert.match(h.notifyCalls[0].message, /entwurf\/claude-sonnet-4-6/);
254
+ });
255
+
256
+ await run("3. entwurf → native: revert + warning notify", async () => {
257
+ const h = await makeHarness();
258
+ await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
259
+ assert.equal(h.setModelCalls.length, 1);
260
+ assert.deepEqual(h.setModelCalls[0], PSA_SONNET);
261
+ assert.equal(h.notifyCalls.length, 1);
262
+ assert.equal(h.notifyCalls[0].level, "warning");
263
+ });
264
+
265
+ await run("4. entwurf/X → entwurf/Y (id differs): revert + warning notify", async () => {
266
+ const h = await makeHarness();
267
+ await h.dispatch({ type: "model_select", model: PSA_OPUS, previousModel: PSA_SONNET, source: "set" });
268
+ assert.equal(h.setModelCalls.length, 1, "B reverts same-provider mismatch BEFORE next prompt");
269
+ assert.deepEqual(h.setModelCalls[0], PSA_SONNET);
270
+ assert.equal(h.notifyCalls.length, 1);
271
+ assert.equal(h.notifyCalls[0].level, "warning");
272
+ });
273
+
274
+ await run("5. entwurf → entwurf (same id): no-op (changed=false)", async () => {
275
+ const h = await makeHarness();
276
+ await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: PSA_SONNET, source: "set" });
277
+ assert.equal(h.setModelCalls.length, 0);
278
+ assert.equal(h.notifyCalls.length, 0);
279
+ });
280
+
281
+ await run("6. undefined → entwurf (first selection): no-op (!from)", async () => {
282
+ const h = await makeHarness();
283
+ await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: undefined, source: "set" });
284
+ assert.equal(h.setModelCalls.length, 0);
285
+ assert.equal(h.notifyCalls.length, 0);
286
+ });
287
+
288
+ await run('7. source="restore": no-op (skip restore)', async () => {
289
+ const h = await makeHarness();
290
+ await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: NATIVE_A, source: "restore" });
291
+ assert.equal(h.setModelCalls.length, 0);
292
+ assert.equal(h.notifyCalls.length, 0);
293
+ });
294
+
295
+ await run("8. Reentry guard: synthetic model_select inside setModel must NOT re-trigger revert", async () => {
296
+ const h = await makeHarness();
297
+ await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: NATIVE_A, source: "set" });
298
+ assert.equal(h.setModelCalls.length, 1, "setModel must be called exactly once (no reentry)");
299
+ assert.equal(h.emitsDuringSetModel(), 1, "exactly one synthetic emit happened (sanity check)");
300
+ assert.equal(h.notifyCalls.length, 1, "notify must be called exactly once (no reentry)");
301
+ });
302
+
303
+ await run("9. setModel returns false (auth missing): notify level error", async () => {
304
+ const h = await makeHarness({ setModelReturns: false });
305
+ await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
306
+ assert.equal(h.setModelCalls.length, 1);
307
+ assert.equal(h.notifyCalls.length, 1);
308
+ assert.equal(h.notifyCalls[0].level, "error");
309
+ assert.match(h.notifyCalls[0].message, /failed to revert/);
310
+ });
311
+
312
+ // =============================================================================
313
+ // Tests 10-18 — session lifecycle gate (sessionLocked)
314
+ // =============================================================================
315
+
316
+ await run('10. session_start "startup" + entries=0: lock NOT engaged, model_select no-op', async () => {
317
+ const h = await makeHarness({ initial: "unlocked" });
318
+ // Pre-turn model change (CLI --model, settings.json default, etc.) — must be free.
319
+ await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: NATIVE_A, source: "set" });
320
+ assert.equal(h.setModelCalls.length, 0, "lock must NOT fire before first agent_start");
321
+ assert.equal(h.notifyCalls.length, 0);
322
+ });
323
+
324
+ await run('11. session_start "startup" + entries have a message: lock engaged', async () => {
325
+ // Future-proof guard: pi-mono currently distinguishes resume from
326
+ // startup, but if a future pi version loads a saved session under
327
+ // "startup" the entries-based check engages the lock.
328
+ const h = await makeHarness({ initial: "manual", entries: [{ type: "message" }] });
329
+ await h.dispatchSessionStart("startup");
330
+ await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
331
+ assert.equal(h.setModelCalls.length, 1, "saved-session-on-startup engages the lock");
332
+ assert.equal(h.notifyCalls[0].level, "warning");
333
+ });
334
+
335
+ await run('12. session_start "startup" empty → agent_start → lock engaged', async () => {
336
+ const h = await makeHarness({ initial: "unlocked" });
337
+ // Pre-turn: still unlocked, change allowed.
338
+ await h.dispatch({ type: "model_select", model: PSA_OPUS, previousModel: PSA_SONNET, source: "set" });
339
+ assert.equal(h.setModelCalls.length, 0, "pre-turn no-op");
340
+
341
+ // First prompt arrives → agent_start fires → lock engaged.
342
+ await h.dispatchAgentStart();
343
+ await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
344
+ assert.equal(h.setModelCalls.length, 1, "post agent_start lock engaged");
345
+ assert.equal(h.notifyCalls[0].level, "warning");
346
+ });
347
+
348
+ await run('13. session_start "resume": lock engaged immediately', async () => {
349
+ const h = await makeHarness({ initial: "manual" });
350
+ await h.dispatchSessionStart("resume");
351
+ await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
352
+ assert.equal(h.setModelCalls.length, 1, "resume engages lock without needing agent_start");
353
+ assert.equal(h.notifyCalls[0].level, "warning");
354
+ });
355
+
356
+ await run('14. session_start "new" + entries=0: lock NOT engaged', async () => {
357
+ const h = await makeHarness({ initial: "manual" });
358
+ await h.dispatchSessionStart("new");
359
+ await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: NATIVE_A, source: "set" });
360
+ assert.equal(h.setModelCalls.length, 0, "new session pre-turn must be free");
361
+ });
362
+
363
+ await run('15. session_start "reload" + entries have a message: lock engaged', async () => {
364
+ const h = await makeHarness({ initial: "manual", entries: [{ type: "message" }] });
365
+ await h.dispatchSessionStart("reload");
366
+ await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
367
+ assert.equal(h.setModelCalls.length, 1, "reload of an active session engages lock");
368
+ });
369
+
370
+ await run('16. session_start "fork": lock engaged immediately', async () => {
371
+ const h = await makeHarness({ initial: "manual" });
372
+ await h.dispatchSessionStart("fork");
373
+ await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
374
+ assert.equal(h.setModelCalls.length, 1, "fork engages lock immediately");
375
+ });
376
+
377
+ await run('17. session_start "reload" + entries=0 BUT prior sessionLocked=true: lock preserved', async () => {
378
+ // This is the GPT-flagged edge case: a session that already locked
379
+ // (via earlier agent_start or resume) must survive a reload even
380
+ // if message entries are not visible at the reload instant. The
381
+ // guard `sessionLocked = sessionLocked || hasStartedConversation(ctx)`
382
+ // keeps the prior `true` instead of resetting to `false`.
383
+ const h = await makeHarness({ initial: "manual" });
384
+ await h.dispatchSessionStart("resume"); // sessionLocked = true
385
+ h.setEntries([]); // simulate entries momentarily empty at reload boundary
386
+ await h.dispatchSessionStart("reload"); // sessionLocked = true || false = true (preserved)
387
+ await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
388
+ assert.equal(h.setModelCalls.length, 1, "reload must NOT release a prior lock");
389
+ assert.equal(h.notifyCalls[0].level, "warning");
390
+ });
391
+
392
+ await run("18. getEntries() throws: defensive catch engages the lock", async () => {
393
+ const h = await makeHarness({ initial: "manual" });
394
+ h.setEntriesThrowing(true);
395
+ // Without the try/catch fallback, this dispatch would propagate the
396
+ // throw and the module would be in an indeterminate state. With the
397
+ // `catch { return true }` defensive probe, the lock engages.
398
+ await h.dispatchSessionStart("startup");
399
+ await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
400
+ assert.equal(h.setModelCalls.length, 1, "throwing probe must conservatively engage lock");
401
+ });
402
+
403
+ console.log("");
404
+ console.log(`[check-model-lock] ${passed} passed, ${failed} failed`);
405
+
406
+ if (failed > 0) {
407
+ process.exit(1);
408
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * check-package-source-routing — deterministic gate for #29 (package-installed
3
+ * Entwurf ACP routing). No backend, no spawn, no API cost.
4
+ *
5
+ * Pins resolveExplicitExtensionSpec()'s package-source → install-root mapping and
6
+ * the fail-fast routing contract, exercised through the two public routing
7
+ * surfaces the spawn/resume paths actually call:
8
+ *
9
+ * - getRegistryRouting(target, isRemote) — spawn path
10
+ * - getEntwurfExplicitExtensions(model, isRemote, recordedProvider) — resume path
11
+ *
12
+ * The original bug: a `git:` / `npm:` Pi settings package source returned null,
13
+ * so `provider=entwurf` spawned a `--no-extensions` child that died with
14
+ * `Unknown provider "entwurf"`. This gate covers the install matrix:
15
+ *
16
+ * local path / git user / npm user (+ version spec) / install-missing /
17
+ * project-scope (unseen → unresolved) / no-source, across local + remote,
18
+ * plus the self-root fallback property and the resume fail-fast signal.
19
+ *
20
+ * Self-root note: for LOCAL entwurf resolution the parent module's own path
21
+ * is a valid bridge root, so local resolution always succeeds even when settings
22
+ * has no source — that IS the desired "local always works" property. Fail-fast is
23
+ * therefore asserted on the REMOTE path, where a local self-root cannot cross SSH.
24
+ *
25
+ * Env-isolation: PI_CODING_AGENT_DIR / PI_SETTINGS_PATH are set to temp paths
26
+ * before the resolver is imported (directly or in the subprocess env-check), so
27
+ * the module-level AGENT_DIR / PI_SETTINGS_PATH consts capture them. The real
28
+ * ~/.pi/agent is never read or written; os.homedir() is only used to build the
29
+ * EXPECTED remote path string (proving the local override does not leak remote).
30
+ */
31
+
32
+ import assert from "node:assert/strict";
33
+ import { execFileSync } from "node:child_process";
34
+ import * as fs from "node:fs";
35
+ import * as os from "node:os";
36
+ import * as path from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ import type { ResolvedTarget } from "../pi-extensions/lib/entwurf-core.ts";
39
+
40
+ // --- Isolated agent dir (must be set BEFORE importing entwurf-core) ----------
41
+ const tmpAgent = fs.mkdtempSync(path.join(os.tmpdir(), "psa-route-"));
42
+ process.env.PI_CODING_AGENT_DIR = tmpAgent;
43
+ delete process.env.ENTWURF_ACP_FOR_CODEX; // control wantsCodexBridge per-case
44
+
45
+ // Dynamic import so the resolver's AGENT_DIR / PI_SETTINGS_PATH consts capture
46
+ // the env above. A static import would evaluate them against the real ~/.pi.
47
+ const core = await import("../pi-extensions/lib/entwurf-core.ts");
48
+ const { getRegistryRouting, getEntwurfExplicitExtensions, EntwurfRoutingError } = core;
49
+
50
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
51
+ const SETTINGS_PATH = path.join(tmpAgent, "settings.json");
52
+ const REMOTE_AGENT = path.posix.join(os.homedir(), ".pi", "agent");
53
+
54
+ let passed = 0;
55
+ function check(label: string, fn: () => void): void {
56
+ fn();
57
+ passed += 1;
58
+ console.log(`[check-package-source-routing] ${label}: ok`);
59
+ }
60
+
61
+ // --- Fixture helpers ---------------------------------------------------------
62
+ function setSource(source: string | null): void {
63
+ const packages = source ? [source] : [];
64
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify({ packages }, null, 2));
65
+ }
66
+
67
+ function makeInstallDir(...segs: string[]): string {
68
+ const dir = path.join(tmpAgent, ...segs);
69
+ fs.mkdirSync(dir, { recursive: true });
70
+ return dir;
71
+ }
72
+
73
+ function rmInstall(...segs: string[]): void {
74
+ fs.rmSync(path.join(tmpAgent, ...segs), { recursive: true, force: true });
75
+ }
76
+
77
+ const ACP: ResolvedTarget = { provider: "entwurf", model: "claude-sonnet-4-6", explicitOnly: false };
78
+ const eArg = (r: { args: string[] }): string | undefined => {
79
+ const i = r.args.indexOf("-e");
80
+ return i >= 0 ? r.args[i + 1] : undefined;
81
+ };
82
+
83
+ // =============================================================================
84
+ // LOCAL spawn (getRegistryRouting, isRemote=false)
85
+ // =============================================================================
86
+
87
+ // 1. Local path package source resolves to <agentDir>/<source>.
88
+ check("local-path source resolves to agentDir-relative root", () => {
89
+ const root = makeInstallDir("local-pkgs", "entwurf");
90
+ setSource("local-pkgs/entwurf");
91
+ const r = getRegistryRouting(ACP, false);
92
+ assert.equal(r.provider, "entwurf");
93
+ assert.equal(eArg(r), root);
94
+ });
95
+
96
+ // 2. git: user source → <agentDir>/git/<host>/<path>.
97
+ check("git: source resolves to agentDir/git/<host>/<path>", () => {
98
+ const root = makeInstallDir("git", "github.com", "junghan0611", "entwurf");
99
+ setSource("git:github.com/junghan0611/entwurf");
100
+ const r = getRegistryRouting(ACP, false);
101
+ assert.equal(eArg(r), root);
102
+ });
103
+
104
+ // 3. npm: user source → <agentDir>/npm/node_modules/<name>.
105
+ check("npm: source resolves to agentDir/npm/node_modules/<name>", () => {
106
+ const root = makeInstallDir("npm", "node_modules", "@junghanacs", "entwurf");
107
+ setSource("npm:@junghanacs/entwurf");
108
+ const r = getRegistryRouting(ACP, false);
109
+ assert.equal(eArg(r), root);
110
+ });
111
+
112
+ // 4. npm: source WITH a version spec — root keys on the bare name, version stripped.
113
+ check("npm: source with @version strips the version from the install root", () => {
114
+ const root = makeInstallDir("npm", "node_modules", "@junghanacs", "entwurf");
115
+ setSource("npm:@junghanacs/entwurf@0.8.0");
116
+ const r = getRegistryRouting(ACP, false);
117
+ assert.equal(eArg(r), root);
118
+ });
119
+
120
+ // 5. Self-root fallback — settings source absent, but LOCAL resolution still
121
+ // succeeds via the loaded module's own root (the "local always works" property).
122
+ check("local self-root fallback resolves when settings has no source", () => {
123
+ rmInstall("git");
124
+ rmInstall("npm");
125
+ rmInstall("local-pkgs");
126
+ setSource(null);
127
+ const r = getRegistryRouting(ACP, false);
128
+ assert.equal(eArg(r), REPO_ROOT, "self-root should be the entwurf-core package root");
129
+ });
130
+
131
+ // 6. Native provider passes through untouched — no bridge, no throw.
132
+ check("native provider target passes through with no -e and no throw", () => {
133
+ setSource(null);
134
+ const r = getRegistryRouting({ provider: "openai-codex", model: "gpt-5.4", explicitOnly: false }, false);
135
+ assert.equal(r.provider, "openai-codex");
136
+ assert.equal(eArg(r), undefined);
137
+ });
138
+
139
+ // =============================================================================
140
+ // REMOTE spawn (getRegistryRouting, isRemote=true) — self-root excluded
141
+ // =============================================================================
142
+
143
+ // 7. git: remote → remotePath uses the plain ~/.pi/agent layout, NOT the local
144
+ // PI_CODING_AGENT_DIR override (no local-env leak into the SSH path).
145
+ check("git: remote resolves to ~/.pi/agent/git/... (no local-env leak)", () => {
146
+ makeInstallDir("git", "github.com", "junghan0611", "entwurf"); // local mirror gates existence
147
+ setSource("git:github.com/junghan0611/entwurf");
148
+ const r = getRegistryRouting(ACP, true);
149
+ assert.equal(eArg(r), path.posix.join(REMOTE_AGENT, "git", "github.com", "junghan0611", "entwurf"));
150
+ });
151
+
152
+ // 8. npm: remote with version → ~/.pi/agent/npm/node_modules/<name>.
153
+ check("npm: remote with @version resolves to ~/.pi/agent/npm/node_modules/<name>", () => {
154
+ makeInstallDir("npm", "node_modules", "@junghanacs", "entwurf");
155
+ setSource("npm:@junghanacs/entwurf@0.8.0");
156
+ const r = getRegistryRouting(ACP, true);
157
+ assert.equal(eArg(r), path.posix.join(REMOTE_AGENT, "npm", "node_modules", "@junghanacs", "entwurf"));
158
+ });
159
+
160
+ // 9. Install dir missing, remote → fail-fast (EntwurfRoutingError), NOT warn-and-spawn.
161
+ check("remote git: with missing install dir throws EntwurfRoutingError", () => {
162
+ rmInstall("git");
163
+ setSource("git:github.com/junghan0611/entwurf");
164
+ assert.throws(() => getRegistryRouting(ACP, true), EntwurfRoutingError);
165
+ });
166
+
167
+ // 10. No source in settings, remote → fail-fast.
168
+ check("remote with no settings source throws EntwurfRoutingError", () => {
169
+ setSource(null);
170
+ assert.throws(() => getRegistryRouting(ACP, true), EntwurfRoutingError);
171
+ });
172
+
173
+ // 10b. PI_SETTINGS_PATH override must be honored independently from
174
+ // PI_CODING_AGENT_DIR/settings.json. This is run in a subprocess because
175
+ // entwurf-core captures the env at module import time.
176
+ check("PI_SETTINGS_PATH env override is honored", () => {
177
+ const agent = fs.mkdtempSync(path.join(os.tmpdir(), "psa-route-settings-"));
178
+ try {
179
+ const root = path.join(agent, "git", "github.com", "junghan0611", "entwurf");
180
+ fs.mkdirSync(root, { recursive: true });
181
+ const settingsPath = path.join(agent, "custom-settings.json");
182
+ fs.writeFileSync(settingsPath, JSON.stringify({ packages: ["git:github.com/junghan0611/entwurf"] }));
183
+ const out = execFileSync(
184
+ process.execPath,
185
+ ["--experimental-strip-types", path.join(REPO_ROOT, "scripts", "resolve-acp-bridge.ts"), "remote"],
186
+ {
187
+ env: { ...process.env, PI_CODING_AGENT_DIR: agent, PI_SETTINGS_PATH: settingsPath },
188
+ encoding: "utf8",
189
+ },
190
+ );
191
+ assert.equal(out, path.posix.join(REMOTE_AGENT, "git", "github.com", "junghan0611", "entwurf"));
192
+ } finally {
193
+ fs.rmSync(agent, { recursive: true, force: true });
194
+ }
195
+ });
196
+
197
+ // 11. Project-scope (-l) sources live in cwd/.pi, never user settings.json, so the
198
+ // resolver never sees them — they resolve to nothing and fail-fast on remote
199
+ // rather than silently emitting Unknown provider.
200
+ check("project-scope source (unseen in user settings) fails fast on remote", () => {
201
+ // A project install would record under ./.pi; user settings stays empty.
202
+ setSource(null);
203
+ assert.throws(() => getRegistryRouting(ACP, true), EntwurfRoutingError);
204
+ });
205
+
206
+ // =============================================================================
207
+ // RESUME path (getEntwurfExplicitExtensions) — unresolvedAcpIntent signal
208
+ // =============================================================================
209
+
210
+ // 12. recorded provider=entwurf + remote + no source → unresolvedAcpIntent
211
+ // (resume callers fail-fast; no -e injected).
212
+ check("resume recorded entwurf + remote + no source → unresolvedAcpIntent", () => {
213
+ setSource(null);
214
+ const r = getEntwurfExplicitExtensions("claude-sonnet-4-6", true, "entwurf");
215
+ assert.equal(r.unresolvedAcpIntent, true);
216
+ assert.equal(eArg(r), undefined);
217
+ assert.ok(r.warnings.length > 0, "should warn about the unresolved bridge");
218
+ });
219
+
220
+ // 13. recorded provider=entwurf + LOCAL → self-root resolves, no fail-fast.
221
+ check("resume recorded entwurf + local resolves via self-root", () => {
222
+ setSource(null);
223
+ const r = getEntwurfExplicitExtensions("claude-sonnet-4-6", false, "entwurf");
224
+ assert.ok(!r.unresolvedAcpIntent, "local self-root should resolve");
225
+ assert.equal(eArg(r), REPO_ROOT);
226
+ assert.equal(r.provider, "entwurf");
227
+ });
228
+
229
+ // 14. opt-in Codex-via-ACP + remote + no source → unresolvedAcpIntent (fail-fast).
230
+ check("resume Codex-via-ACP opt-in + remote + no source → unresolvedAcpIntent", () => {
231
+ setSource(null);
232
+ process.env.ENTWURF_ACP_FOR_CODEX = "1";
233
+ try {
234
+ const r = getEntwurfExplicitExtensions("gpt-5.4", true, undefined);
235
+ assert.equal(r.unresolvedAcpIntent, true);
236
+ } finally {
237
+ delete process.env.ENTWURF_ACP_FOR_CODEX;
238
+ }
239
+ });
240
+
241
+ // 15. Claude-only heuristic (no recorded ACP signal) stays warning-only — NOT
242
+ // fail-fast — because the legacy pi-claude-code-use bridge may exist.
243
+ check("resume Claude-only heuristic + remote + no source stays warning-only", () => {
244
+ setSource(null);
245
+ const r = getEntwurfExplicitExtensions("claude-sonnet-4-6", true, undefined);
246
+ assert.ok(!r.unresolvedAcpIntent, "Claude heuristic must not fail-fast");
247
+ assert.ok(r.warnings.length > 0, "should warn");
248
+ assert.equal(eArg(r), undefined);
249
+ });
250
+
251
+ // --- Cleanup + summary -------------------------------------------------------
252
+ fs.rmSync(tmpAgent, { recursive: true, force: true });
253
+ console.log(`[check-package-source-routing] ${passed} assertions ok`);