@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,154 @@
1
+ // Deterministic gate for the S2b Claude config overlay materializer.
2
+ //
3
+ // Drives ensureClaudeConfigOverlay against INJECTED temp realDir/overlayDir
4
+ // (no operator ~/.claude touched), then asserts the overlay shape the ACP
5
+ // child must see:
6
+ // - settings.json: permissions.defaultMode "default", autoMemoryEnabled
7
+ // false, hooks === {} (configured-but-empty → mailbox absence by design);
8
+ // - whitelisted operator entries symlinked to their real path;
9
+ // - projects/sessions are overlay-PRIVATE real dirs, NOT symlinks;
10
+ // - non-whitelist operator entries (CLAUDE.md, settings.local.json, plugins,
11
+ // agents) never appear in the overlay;
12
+ // - stale symlinks (a prior "linked everything" overlay) are cleaned up,
13
+ // including a binary-owned STALE SYMLINK (backups → operator data) torn down
14
+ // and a whitelist symlink whose real source is gone (telemetry) removed;
15
+ // - binary-owned real files (.claude.json) are preserved across re-runs;
16
+ // - claudeLaunchEnvDefaults plants CLAUDE_CONFIG_DIR = overlayDir;
17
+ // - idempotent (a second run yields the identical shape).
18
+ //
19
+ // Pure/deterministic — no live model, no network, OUT of nothing (IN pnpm check).
20
+
21
+ import { strict as assert } from "node:assert";
22
+ import {
23
+ lstatSync,
24
+ mkdirSync,
25
+ mkdtempSync,
26
+ readFileSync,
27
+ readlinkSync,
28
+ rmSync,
29
+ symlinkSync,
30
+ writeFileSync,
31
+ } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { join } from "node:path";
34
+ import {
35
+ claudeLaunchEnvDefaults,
36
+ ensureClaudeConfigOverlay,
37
+ OVERLAY_BINARY_OWNED,
38
+ OVERLAY_EMPTY_DIRS,
39
+ OVERLAY_PASSTHROUGH,
40
+ } from "../pi-extensions/lib/acp/overlay.ts";
41
+
42
+ const root = mkdtempSync(join(tmpdir(), "entwurf-s2b-overlay-"));
43
+ const realDir = join(root, "real-claude");
44
+ const overlayDir = join(root, "overlay");
45
+
46
+ try {
47
+ // --- seed a fake operator ~/.claude ---
48
+ mkdirSync(realDir, { recursive: true });
49
+ // whitelisted (must be linked)
50
+ writeFileSync(join(realDir, ".credentials.json"), '{"fake":"creds"}\n', "utf8");
51
+ mkdirSync(join(realDir, "skills"), { recursive: true });
52
+ mkdirSync(join(realDir, "cache"), { recursive: true });
53
+ writeFileSync(join(realDir, "stats-cache.json"), "{}\n", "utf8");
54
+ // NON-whitelist operator personal config (must NOT appear in overlay)
55
+ writeFileSync(join(realDir, "CLAUDE.md"), "# personal\n", "utf8");
56
+ writeFileSync(join(realDir, "settings.local.json"), '{"env":{"SECRET":"x"}}\n', "utf8");
57
+ mkdirSync(join(realDir, "plugins"), { recursive: true });
58
+ mkdirSync(join(realDir, "agents"), { recursive: true });
59
+ // operator data the overlay must shadow with empty dirs
60
+ mkdirSync(join(realDir, "projects", "some-cwd"), { recursive: true });
61
+ mkdirSync(join(realDir, "sessions"), { recursive: true });
62
+
63
+ // --- pre-seed a STALE overlay from an earlier "linked everything" version ---
64
+ mkdirSync(overlayDir, { recursive: true });
65
+ symlinkSync(join(realDir, "plugins"), join(overlayDir, "plugins")); // stale non-whitelist symlink
66
+ symlinkSync(join(realDir, "projects"), join(overlayDir, "projects")); // stale projects symlink → must become empty dir
67
+ writeFileSync(join(overlayDir, ".claude.json"), '{"binary":"owned"}\n', "utf8"); // binary-owned real file → preserved
68
+ symlinkSync(join(realDir, "plugins"), join(overlayDir, "backups")); // binary-owned STALE SYMLINK → must be torn down (not preserved)
69
+ symlinkSync(join(realDir, "telemetry"), join(overlayDir, "telemetry")); // whitelist symlink whose real source is GONE → must be removed
70
+
71
+ // --- run ---
72
+ ensureClaudeConfigOverlay(realDir, overlayDir);
73
+
74
+ // settings.json
75
+ const settings = JSON.parse(readFileSync(join(overlayDir, "settings.json"), "utf8"));
76
+ assert.equal(settings.permissions?.defaultMode, "default", "settings.permissions.defaultMode must be 'default'");
77
+ assert.equal(settings.autoMemoryEnabled, false, "settings.autoMemoryEnabled must be false");
78
+ assert.ok(
79
+ settings.hooks && typeof settings.hooks === "object" && Object.keys(settings.hooks).length === 0,
80
+ "settings.hooks must be a configured-but-EMPTY object {} (mailbox absence by design)",
81
+ );
82
+
83
+ // whitelisted entries → symlinks to the real path
84
+ for (const entry of OVERLAY_PASSTHROUGH) {
85
+ const overlayPath = join(overlayDir, entry);
86
+ const realPath = join(realDir, entry);
87
+ if (entry === ".credentials.json" || entry === "skills" || entry === "cache" || entry === "stats-cache.json") {
88
+ const st = lstatSync(overlayPath);
89
+ assert.ok(st.isSymbolicLink(), `whitelist entry ${entry} must be a symlink`);
90
+ assert.equal(readlinkSync(overlayPath), realPath, `whitelist entry ${entry} must point at the real path`);
91
+ } else {
92
+ // not seeded in realDir → must be absent in overlay
93
+ assert.throws(() => lstatSync(overlayPath), `unseeded whitelist entry ${entry} must not exist in overlay`);
94
+ }
95
+ }
96
+
97
+ // projects/sessions → real dirs, NOT symlinks
98
+ for (const entry of OVERLAY_EMPTY_DIRS) {
99
+ const st = lstatSync(join(overlayDir, entry));
100
+ assert.ok(st.isDirectory() && !st.isSymbolicLink(), `${entry} must be an overlay-private real dir, not a symlink`);
101
+ }
102
+
103
+ // non-whitelist operator config never leaks
104
+ for (const leak of ["CLAUDE.md", "settings.local.json", "plugins", "agents"]) {
105
+ assert.throws(() => lstatSync(join(overlayDir, leak)), `non-whitelist entry ${leak} must NOT appear in overlay`);
106
+ }
107
+
108
+ // binary-owned real file preserved
109
+ assert.ok(OVERLAY_BINARY_OWNED.has(".claude.json"), "test premise: .claude.json is binary-owned");
110
+ assert.equal(
111
+ readFileSync(join(overlayDir, ".claude.json"), "utf8"),
112
+ '{"binary":"owned"}\n',
113
+ "binary-owned .claude.json must be preserved across overlay build",
114
+ );
115
+
116
+ // binary-owned STALE SYMLINK torn down — a binary-owned name is preserved only
117
+ // when it is a real binary-authored file/dir; a symlink pointing at operator
118
+ // data (migration residue) must be removed so the binary re-inits fresh.
119
+ assert.ok(OVERLAY_BINARY_OWNED.has("backups"), "test premise: backups is binary-owned");
120
+ assert.throws(
121
+ () => lstatSync(join(overlayDir, "backups")),
122
+ "binary-owned stale symlink (backups → operator data) must be torn down, not preserved",
123
+ );
124
+
125
+ // whitelist entry whose real source is GONE → stale overlay copy removed (the
126
+ // telemetry symlink was preseeded but realDir/telemetry was never created).
127
+ assert.throws(
128
+ () => lstatSync(join(overlayDir, "telemetry")),
129
+ "whitelist entry must be removed when its operator-side source no longer exists",
130
+ );
131
+
132
+ // launch env builder
133
+ assert.deepEqual(
134
+ claudeLaunchEnvDefaults(overlayDir),
135
+ { CLAUDE_CONFIG_DIR: overlayDir },
136
+ "claudeLaunchEnvDefaults must plant CLAUDE_CONFIG_DIR = overlayDir",
137
+ );
138
+
139
+ // idempotency — a second run yields the identical critical shape
140
+ ensureClaudeConfigOverlay(realDir, overlayDir);
141
+ const credSt = lstatSync(join(overlayDir, ".credentials.json"));
142
+ assert.ok(credSt.isSymbolicLink(), "credentials symlink must survive a second run");
143
+ const projSt = lstatSync(join(overlayDir, "projects"));
144
+ assert.ok(projSt.isDirectory() && !projSt.isSymbolicLink(), "projects must stay a real dir on re-run");
145
+ assert.throws(() => lstatSync(join(overlayDir, "plugins")), "stale plugins symlink must stay gone on re-run");
146
+
147
+ console.log(
148
+ `[check-acp-overlay] ok — overlay authors hooks:{} settings, symlinks ${OVERLAY_PASSTHROUGH.size}-entry whitelist, ` +
149
+ `shadows projects/sessions with private dirs, hides operator personal config, preserves binary-owned real files ` +
150
+ `while tearing down binary-owned/whitelist stale symlinks, plants CLAUDE_CONFIG_DIR, idempotent`,
151
+ );
152
+ } finally {
153
+ rmSync(root, { recursive: true, force: true });
154
+ }
@@ -0,0 +1,191 @@
1
+ // Deterministic gate for the S2d bootstrapPath-scoped ACP prompt builder (핀4).
2
+ //
3
+ // The S2c→S2d hazard: contextToAcpPrompt flattens the WHOLE transcript because
4
+ // spawn-per-turn sessions hold no memory. Once S2d adds session reuse, sending
5
+ // that whole transcript to a session that ALREADY remembers the prior turns
6
+ // duplicates history. So the prompt SCOPE must follow bootstrapPath:
7
+ // - "new" → full transcript (the only history carrier)
8
+ // - "reuse" | "resume" | "load"→ latest user delta only (first user after the
9
+ // last assistant; the trailing-group FIRST user
10
+ // so the SessionStart hook message is skipped).
11
+ //
12
+ // This gate proves the split deterministically — BEFORE the session store that
13
+ // produces the reuse paths exists, so the builder is correct the moment it is
14
+ // wired. Pure/deterministic — IN pnpm check.
15
+
16
+ import { strict as assert } from "node:assert";
17
+ import type { Context } from "@earendil-works/pi-ai";
18
+ import {
19
+ type AcpBootstrapPath,
20
+ buildAcpPrompt,
21
+ contextToAcpPrompt,
22
+ latestUserDelta,
23
+ } from "../pi-extensions/lib/acp/context.ts";
24
+
25
+ const REUSE_PATHS: AcpBootstrapPath[] = ["reuse", "resume", "load"];
26
+
27
+ // A multi-turn context: a prior turn, an assistant answer, the real latest user
28
+ // turn (with an image), and a SessionStart hook user-message appended AFTER it.
29
+ function multiTurn(): Context {
30
+ return {
31
+ systemPrompt: "SECRET-SYSTEM-PROMPT",
32
+ tools: [{ name: "x", description: "d", parameters: {} as never }],
33
+ messages: [
34
+ { role: "user", content: "first question", timestamp: 0 },
35
+ {
36
+ role: "assistant",
37
+ content: [{ type: "text", text: "an answer" }],
38
+ api: "x",
39
+ provider: "x",
40
+ model: "x",
41
+ usage: {
42
+ input: 0,
43
+ output: 0,
44
+ cacheRead: 0,
45
+ cacheWrite: 0,
46
+ totalTokens: 0,
47
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
48
+ },
49
+ stopReason: "stop",
50
+ timestamp: 0,
51
+ },
52
+ {
53
+ role: "user",
54
+ content: [
55
+ { type: "image", data: "BASE64IMAGEDATA", mimeType: "image/png" },
56
+ { type: "text", text: "follow up" },
57
+ ],
58
+ timestamp: 0,
59
+ },
60
+ // pi appends the SessionStart hook AFTER the real prompt — the builder
61
+ // must NOT pick this as the latest user delta.
62
+ { role: "user", content: "device=thinkpad time_kst=20260618T160000", timestamp: 0 },
63
+ ],
64
+ };
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // 1) "new" → full transcript (identical to contextToAcpPrompt)
69
+ // ---------------------------------------------------------------------------
70
+ {
71
+ const ctx = multiTurn();
72
+ const prompt = buildAcpPrompt(ctx, "new");
73
+ assert.deepEqual(prompt, contextToAcpPrompt(ctx), '"new" is the full-transcript builder');
74
+ assert.equal(prompt.length, 1, '"new" is a single text block');
75
+ const text = prompt[0].text;
76
+ assert.match(text, /User: first question/, '"new" carries the prior user turn (history)');
77
+ assert.match(text, /Assistant: an answer/, '"new" carries the assistant turn');
78
+ assert.match(text, /follow up/, '"new" carries the latest user turn');
79
+ assert.ok(!text.includes("SECRET-SYSTEM-PROMPT"), '"new" never leaks systemPrompt');
80
+ assert.ok(!text.includes("BASE64IMAGEDATA"), '"new" never embeds raw image data');
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // 2) reuse / resume / load → latest user delta ONLY
85
+ // ---------------------------------------------------------------------------
86
+ for (const path of REUSE_PATHS) {
87
+ const ctx = multiTurn();
88
+ const prompt = buildAcpPrompt(ctx, path);
89
+ assert.deepEqual(prompt, latestUserDelta(ctx), `"${path}" is the delta builder`);
90
+ assert.equal(prompt.length, 1, `"${path}" is a single text block`);
91
+ const text = prompt[0].text;
92
+ // Only the real latest user turn — not prior history, not the hook message.
93
+ assert.match(text, /follow up/, `"${path}" carries the latest user turn`);
94
+ assert.ok(!text.includes("first question"), `"${path}" excludes prior history (no duplicate injection)`);
95
+ assert.ok(!text.includes("an answer"), `"${path}" excludes the assistant turn`);
96
+ assert.ok(
97
+ !text.includes("device=thinkpad"),
98
+ `"${path}" skips the SessionStart hook user-message (trailing-group FIRST user, not last)`,
99
+ );
100
+ // Image still leaves a marker, never raw data.
101
+ assert.match(text, /\[image omitted: image\/png\]/, `"${path}" keeps the image marker`);
102
+ assert.ok(!text.includes("BASE64IMAGEDATA"), `"${path}" never embeds raw image data`);
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // 3) delta differs from full transcript (the whole point)
107
+ // ---------------------------------------------------------------------------
108
+ {
109
+ const ctx = multiTurn();
110
+ assert.notDeepEqual(
111
+ buildAcpPrompt(ctx, "reuse"),
112
+ buildAcpPrompt(ctx, "new"),
113
+ "reuse delta must NOT equal the full-transcript new prompt",
114
+ );
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // 4) no assistant yet → first user of the whole context is the delta
119
+ // ---------------------------------------------------------------------------
120
+ {
121
+ const ctx: Context = {
122
+ messages: [
123
+ { role: "user", content: "only question", timestamp: 0 },
124
+ { role: "user", content: "device=thinkpad time_kst=x", timestamp: 0 },
125
+ ],
126
+ };
127
+ const delta = latestUserDelta(ctx);
128
+ assert.equal(delta.length, 1, "delta exists when there is a user turn");
129
+ assert.equal(delta[0].text, "only question", "first user wins (hook message skipped) even with no assistant");
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // 5) trailing message is assistant (no user after it) → empty delta
134
+ // ---------------------------------------------------------------------------
135
+ {
136
+ const ctx: Context = {
137
+ messages: [
138
+ { role: "user", content: "q", timestamp: 0 },
139
+ {
140
+ role: "assistant",
141
+ content: [{ type: "text", text: "a" }],
142
+ api: "x",
143
+ provider: "x",
144
+ model: "x",
145
+ usage: {
146
+ input: 0,
147
+ output: 0,
148
+ cacheRead: 0,
149
+ cacheWrite: 0,
150
+ totalTokens: 0,
151
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
152
+ },
153
+ stopReason: "stop",
154
+ timestamp: 0,
155
+ },
156
+ ],
157
+ };
158
+ assert.deepEqual(latestUserDelta(ctx), [], "no user after the last assistant → empty delta");
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // 6) empty context → empty for every path
163
+ // ---------------------------------------------------------------------------
164
+ {
165
+ const empty: Context = { messages: [] };
166
+ assert.deepEqual(buildAcpPrompt(empty, "new"), [], "empty context → empty new prompt");
167
+ for (const path of REUSE_PATHS) {
168
+ assert.deepEqual(buildAcpPrompt(empty, path), [], `empty context → empty ${path} prompt`);
169
+ }
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // 7) unknown bootstrapPath → THROW (fail-loud, never silent delta-only)
174
+ // ---------------------------------------------------------------------------
175
+ // 핀4 safety: a bad path must crash, not quietly fall through to delta-only —
176
+ // that would lose history on a path that should carry the full transcript.
177
+ {
178
+ const ctx = multiTurn();
179
+ assert.throws(
180
+ () => buildAcpPrompt(ctx, "bogus" as AcpBootstrapPath),
181
+ /unknown bootstrapPath/,
182
+ "unknown bootstrapPath must crash, not silently delta-only (history loss is the fail-open hazard)",
183
+ );
184
+ }
185
+
186
+ console.log(
187
+ "[check-acp-prompt-builder] ok — bootstrapPath scopes the prompt: new=full transcript (history carrier), " +
188
+ "reuse/resume/load=latest user delta (first user after last assistant, SessionStart hook skipped, image marker " +
189
+ "kept, no raw image data, prior history excluded), no-assistant/trailing-assistant/empty edges, " +
190
+ "unknown bootstrapPath → throw (fail-loud, never silent delta-only)",
191
+ );
@@ -0,0 +1,159 @@
1
+ // Deterministic gate for the ACP provider registration surface (S0 loader/fence,
2
+ // updated S2c when the real backend replaced the fail-loud stub).
3
+ //
4
+ // Three layers:
5
+ // (1 lib) loads the REAL lib modules and asserts the curated Claude
6
+ // surface + no-auth sentinel shape;
7
+ // (2 entry) COMPILES the project to a temp dir (root tsc emit, so the `.js`
8
+ // imports resolve to real emitted `.js`), imports the compiled
9
+ // acp-provider.js, and drives its default export against a fake pi
10
+ // that captures registerProvider — real execution capture of the
11
+ // actual entry, idempotency included, and that streamSimple is the
12
+ // real streamShellAcp backend (by name, NOT invoked — that spawns);
13
+ // (3 source) an auxiliary source-shape lock on acp-provider.ts.
14
+ //
15
+ // Layer 2 is the GPT-reviewed resolution to a fence tension: acp-provider.ts
16
+ // imports its lib with `.js` suffixes (the root/jiti runtime convention), which
17
+ // plain `node --experimental-strip-types` cannot resolve to `.ts`. Rather than
18
+ // force the entry onto a `.ts` strip-types fence (S0 avoids that) or collapse the
19
+ // lib/acp split, the gate emits the real build artifact and imports THAT — so the
20
+ // `.js` imports and the real default export both execute. Temp output lives under
21
+ // .tmp-verify/ (forbidden from the npm tarball) and is removed after the run.
22
+
23
+ import { strict as assert } from "node:assert";
24
+ import { execFileSync } from "node:child_process";
25
+ import { readFileSync, rmSync } from "node:fs";
26
+ import { resolve } from "node:path";
27
+ import { pathToFileURL } from "node:url";
28
+ import {
29
+ CURATED_ANCHOR_MODEL_ID,
30
+ curatedClaudeModels,
31
+ ENTWURF_ACP_NO_AUTH_SENTINEL,
32
+ PROVIDER_ID,
33
+ } from "../pi-extensions/lib/acp/models.ts";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Layer 1 — lib-level surface (real modules, real behavior)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ // Current pre-rename provider id. S1 must rename this load-bearing routing id to `entwurf` together with its gates.
40
+ assert.equal(PROVIDER_ID, "entwurf", "PROVIDER_ID must match the current pre-rename provider id");
41
+
42
+ // no-auth sentinel shape: lowercase + hyphen only, so pi does not read it as an
43
+ // ENV reference. An ALL-CAPS value would trip the legacy-env path.
44
+ assert.match(
45
+ ENTWURF_ACP_NO_AUTH_SENTINEL,
46
+ /^[a-z0-9-]+$/,
47
+ `no-auth sentinel must be lowercase+hyphen (got "${ENTWURF_ACP_NO_AUTH_SENTINEL}")`,
48
+ );
49
+ assert.equal(ENTWURF_ACP_NO_AUTH_SENTINEL, "entwurf-no-auth", "no-auth sentinel literal drifted");
50
+
51
+ // curated Claude anchor present + full row shape.
52
+ const models = curatedClaudeModels();
53
+ assert.ok(models.length >= 1, "curated Claude surface must register at least one model");
54
+ const ids = models.map((m) => m.id);
55
+ assert.ok(
56
+ ids.includes(CURATED_ANCHOR_MODEL_ID),
57
+ `curated Claude anchor ${CURATED_ANCHOR_MODEL_ID} missing from surface: ${ids.join(", ")}`,
58
+ );
59
+ const REQUIRED_MODEL_FIELDS = ["id", "name", "reasoning", "input", "cost", "contextWindow", "maxTokens"] as const;
60
+ for (const m of models) {
61
+ for (const field of REQUIRED_MODEL_FIELDS) {
62
+ assert.ok(field in m, `model ${m.id} missing required ProviderModelConfig field: ${field}`);
63
+ }
64
+ assert.ok(m.contextWindow > 0, `model ${m.id} contextWindow must be positive`);
65
+ assert.ok(m.maxTokens > 0, `model ${m.id} maxTokens must be positive`);
66
+ }
67
+
68
+ // S2c: the provider path is open — streamSimple is the REAL ACP backend, no
69
+ // longer the S0 fail-loud stub. The wiring is verified through the compiled
70
+ // entry (Layer 2) WITHOUT invoking it: streamShellAcp returns its stream
71
+ // synchronously and spawns the backend on a microtask, so calling it in a
72
+ // deterministic gate would launch a real child. Live behavior is proved by
73
+ // smoke-acp-provider-live (LIVE-gated).
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Layer 2 — real entry capture (compiled acp-provider.js driven by a fake pi)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ interface CapturedCfg {
80
+ baseUrl?: string;
81
+ apiKey?: string;
82
+ api?: string;
83
+ models?: Array<{ id: string }>;
84
+ streamSimple?: (...args: unknown[]) => unknown;
85
+ }
86
+
87
+ const TMP_EMIT = ".tmp-verify/acp-entry-capture";
88
+ rmSync(TMP_EMIT, { recursive: true, force: true });
89
+ try {
90
+ // Root tsc emit (no input files → uses the root tsconfig's program), so the
91
+ // entry's `.js` imports resolve to real emitted siblings.
92
+ execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
93
+ stdio: "pipe",
94
+ });
95
+
96
+ const entryUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/acp-provider.js")).href;
97
+ const mod = await import(entryUrl);
98
+
99
+ const calls: Array<{ id: string; cfg: CapturedCfg }> = [];
100
+ const fakePi = {
101
+ registerProvider(id: string, cfg: CapturedCfg) {
102
+ calls.push({ id, cfg });
103
+ },
104
+ };
105
+
106
+ // Drive the REAL default export twice — the second call must be a no-op.
107
+ mod.default(fakePi);
108
+ mod.default(fakePi);
109
+
110
+ assert.equal(
111
+ calls.length,
112
+ 1,
113
+ `entry must register exactly once across two calls (idempotency) — got ${calls.length}`,
114
+ );
115
+ const cap = calls[0];
116
+ assert.equal(cap.id, PROVIDER_ID, `entry registered the wrong provider id: ${cap.id}`);
117
+ assert.equal(cap.cfg.apiKey, ENTWURF_ACP_NO_AUTH_SENTINEL, "entry apiKey is not the no-auth sentinel");
118
+ assert.equal(cap.cfg.api, "entwurf", "entry api field drifted");
119
+ const capIds = (cap.cfg.models ?? []).map((m) => m.id);
120
+ for (const want of ["claude-sonnet-4-6", CURATED_ANCHOR_MODEL_ID]) {
121
+ assert.ok(capIds.includes(want), `entry model surface missing ${want} (got: ${capIds.join(", ") || "none"})`);
122
+ }
123
+ assert.equal(typeof cap.cfg.streamSimple, "function", "entry streamSimple must be a function");
124
+ // Regression guard: the entry must wire the REAL backend (streamShellAcp),
125
+ // not the removed S0 stub. We check by name rather than invoking — invoking
126
+ // would spawn a real ACP child. The compiled fn keeps its source name.
127
+ assert.equal(
128
+ cap.cfg.streamSimple?.name,
129
+ "streamShellAcp",
130
+ `entry must wire the real streamShellAcp backend (got "${cap.cfg.streamSimple?.name}")`,
131
+ );
132
+ } finally {
133
+ rmSync(TMP_EMIT, { recursive: true, force: true });
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Layer 3 — auxiliary source-shape lock (pi-extensions/acp-provider.ts)
138
+ // ---------------------------------------------------------------------------
139
+
140
+ const entrySrc = readFileSync("pi-extensions/acp-provider.ts", "utf8");
141
+ assert.ok(
142
+ !/apiKey:\s*["'`]/.test(entrySrc),
143
+ "entry must not assign apiKey a string literal — use the no-auth sentinel constant",
144
+ );
145
+ const registerCalls = entrySrc.match(/\.registerProvider\(/g) ?? [];
146
+ assert.equal(
147
+ registerCalls.length,
148
+ 1,
149
+ `entry must call registerProvider exactly once in source (found ${registerCalls.length})`,
150
+ );
151
+ // The entry must wire the real backend and must not reference the removed stub.
152
+ assert.match(entrySrc, /streamSimple:\s*streamShellAcp\b/, "entry must wire streamSimple: streamShellAcp");
153
+ assert.ok(!/backend-stub/.test(entrySrc), "entry must not import the removed S0 backend-stub");
154
+
155
+ console.log(
156
+ `[check-acp-provider-surface] ok — compiled entry registers ${PROVIDER_ID} once (idempotent) with the no-auth ` +
157
+ `sentinel + curated Claude surface (sonnet + ${CURATED_ANCHOR_MODEL_ID}) + real streamShellAcp backend (S2c); ` +
158
+ `lib surface verified live (${models.length} model(s))`,
159
+ );