@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,316 @@
1
+ /**
2
+ * check-entwurf-fact-provider — deterministic gate for `listEntwurfFacts` (0.11
3
+ * Stage 0 step 4, slice 4b). Drives the assembly layer with injected meta + socket
4
+ * deps (no IO) and proves the throw-vs-diagnostics policy:
5
+ *
6
+ * - basic assembly: pi citizen + claude citizen + record-less socket →
7
+ * 2 peers (pi alive / claude unsupported) + 1 socketOnly, 0 diagnostics,
8
+ * - corrupt record → meta-record-read-error diagnostic, listing NOT blinded,
9
+ * - gardenId↔socket collision (non-pi citizen + same-gid socket) → BOTH sides
10
+ * quarantined (gid in neither peers nor socketOnly) + one
11
+ * garden-id-socket-conflict diagnostic, and listEntwurfFacts does NOT throw
12
+ * (expected external-state corruption → diagnostics, not a crash),
13
+ * - the conflict diagnostic carries backend + gardenId ONLY (no identity field),
14
+ * - enrich stays null (probe-only slice),
15
+ * - socket-axis hazards folded (slice 4c, Fable 검수): a symlinked socket →
16
+ * socket-symlink-rejected diagnostic (P1), a malformed *.sock name →
17
+ * malformed-socket-name diagnostic (P3), a non-ENOENT dir-read failure →
18
+ * socket-dir-read-error diagnostic (P2e②),
19
+ * - diagnostics are kind-tagged and sorted.
20
+ *
21
+ * No IO — meta entries/reader and socket dir/readdir/probe are injected fakes.
22
+ */
23
+
24
+ import assert from "node:assert/strict";
25
+ import * as path from "node:path";
26
+ import { type EntwurfFactsDeps, listEntwurfFacts } from "../pi-extensions/lib/entwurf-fact-provider.ts";
27
+ import { type MetaBackendV2, serializeMetaIdentity } from "../pi-extensions/lib/meta-session.ts";
28
+ import { SOCKET_SUFFIX, type SocketDirEntry } from "../pi-extensions/lib/socket-discovery.ts";
29
+ import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
30
+
31
+ let passed = 0;
32
+ function ok(label: string, cond: boolean): void {
33
+ assert.ok(cond, label);
34
+ console.log(` ok ${label}`);
35
+ passed++;
36
+ }
37
+
38
+ const DIR = "/fake/entwurf-control";
39
+ const GID_PI = "20260611T115213-3aa371"; // pi citizen, live socket
40
+ const GID_CLAUDE = "20260611T112732-0f42b6"; // claude citizen, no socket
41
+ const GID_SOCKET_ONLY = "20260611T222222-bbbbbb"; // live socket, no record
42
+ const GID_CONFLICT = "20260611T333333-cccccc"; // claude citizen + same-gid socket
43
+
44
+ function rec(gardenId: string, backend: MetaBackendV2): string {
45
+ return serializeMetaIdentity({
46
+ schemaVersion: 2,
47
+ gardenId,
48
+ backend,
49
+ nativeSessionId: `n-${gardenId}`,
50
+ cwd: "/x",
51
+ model: null,
52
+ transcriptPath: null,
53
+ parentGardenId: null,
54
+ isEntwurf: false,
55
+ createdAt: "2026-06-11T00:00:00.000Z",
56
+ recordUpdatedAt: "2026-06-11T00:00:00.000Z",
57
+ });
58
+ }
59
+
60
+ interface SocketOpts {
61
+ /** gids whose `*.sock` entry is a symlink (P1). */
62
+ symlinks?: string[];
63
+ /** extra raw dir entries (e.g. a malformed `*.sock` name) (P3). */
64
+ extraNames?: string[];
65
+ /** readdir throws with this code (e.g. "EACCES" / "ENOENT") (P2e②). */
66
+ readdirErrorCode?: string;
67
+ }
68
+
69
+ function deps(
70
+ meta: Record<string, string>,
71
+ sockets: Record<string, SocketLiveness>,
72
+ opts: SocketOpts = {},
73
+ ): EntwurfFactsDeps {
74
+ const symlinkSet = new Set(opts.symlinks ?? []);
75
+ return {
76
+ metaEntries: Object.keys(meta),
77
+ readRecord: (f: string) => {
78
+ const v = meta[f];
79
+ if (v === undefined) throw new Error(`ENOENT: ${f}`);
80
+ return v;
81
+ },
82
+ socket: {
83
+ dir: DIR,
84
+ readdir: async (): Promise<SocketDirEntry[]> => {
85
+ if (opts.readdirErrorCode) {
86
+ const e = new Error(`${opts.readdirErrorCode}: readdir failed`) as NodeJS.ErrnoException;
87
+ e.code = opts.readdirErrorCode;
88
+ throw e;
89
+ }
90
+ const sockEntries: SocketDirEntry[] = Object.keys(sockets).map((g) => ({
91
+ name: `${g}${SOCKET_SUFFIX}`,
92
+ isSymbolicLink: symlinkSet.has(g),
93
+ }));
94
+ const extra: SocketDirEntry[] = (opts.extraNames ?? []).map((n) => ({ name: n, isSymbolicLink: false }));
95
+ return [...sockEntries, ...extra];
96
+ },
97
+ probe: async (p: string) => sockets[path.basename(p, SOCKET_SUFFIX)] ?? "dead",
98
+ },
99
+ };
100
+ }
101
+
102
+ async function main(): Promise<void> {
103
+ // ── basic assembly ─────────────────────────────────────────────────────────
104
+ {
105
+ const r = await listEntwurfFacts(
106
+ deps(
107
+ { [`${GID_PI}.meta.json`]: rec(GID_PI, "pi"), [`${GID_CLAUDE}.meta.json`]: rec(GID_CLAUDE, "claude-code") },
108
+ { [GID_PI]: "alive", [GID_SOCKET_ONLY]: "alive" },
109
+ ),
110
+ );
111
+ ok(
112
+ "basic: 2 peers + 1 socketOnly + 0 diagnostics",
113
+ r.facts.peers.length === 2 && r.facts.socketOnly.length === 1 && r.diagnostics.length === 0,
114
+ );
115
+ ok("basic: pi citizen alive", r.facts.peers.find((p) => p.gardenId === GID_PI)?.liveness === "alive");
116
+ ok(
117
+ "basic: claude citizen unsupported",
118
+ r.facts.peers.find((p) => p.gardenId === GID_CLAUDE)?.liveness === "unsupported",
119
+ );
120
+ ok("basic: record-less socket → socketOnly", r.facts.socketOnly[0]?.gardenId === GID_SOCKET_ONLY);
121
+ ok(
122
+ "basic: enrich null (probe-only slice)",
123
+ r.facts.socketOnly[0]?.cwd === null && r.facts.socketOnly[0]?.model === null,
124
+ );
125
+ }
126
+
127
+ // ── corrupt record does NOT blind the listing ──────────────────────────────
128
+ {
129
+ const r = await listEntwurfFacts(
130
+ deps(
131
+ { [`${GID_PI}.meta.json`]: rec(GID_PI, "pi"), [`${GID_CLAUDE}.meta.json`]: "{ broken" },
132
+ { [GID_PI]: "alive" },
133
+ ),
134
+ );
135
+ ok(
136
+ "corrupt: pi citizen still listed (not blinded)",
137
+ r.facts.peers.length === 1 && r.facts.peers[0]?.gardenId === GID_PI,
138
+ );
139
+ ok(
140
+ "corrupt: 1 meta-record-read-error diagnostic",
141
+ r.diagnostics.length === 1 && r.diagnostics[0]?.kind === "meta-record-read-error",
142
+ );
143
+ }
144
+
145
+ // ── gardenId↔socket collision: quarantine BOTH, do NOT throw ────────────────
146
+ {
147
+ const r = await listEntwurfFacts(
148
+ deps(
149
+ { [`${GID_PI}.meta.json`]: rec(GID_PI, "pi"), [`${GID_CONFLICT}.meta.json`]: rec(GID_CONFLICT, "claude-code") },
150
+ { [GID_PI]: "alive", [GID_CONFLICT]: "alive" },
151
+ ),
152
+ );
153
+ ok("collision: conflict gid NOT in peers", !r.facts.peers.some((p) => p.gardenId === GID_CONFLICT));
154
+ ok(
155
+ "collision: conflict gid NOT in socketOnly (both quarantined)",
156
+ !r.facts.socketOnly.some((s) => s.gardenId === GID_CONFLICT),
157
+ );
158
+ ok(
159
+ "collision: pi citizen still present (listing survives)",
160
+ r.facts.peers.some((p) => p.gardenId === GID_PI),
161
+ );
162
+ const conflict = r.diagnostics.find((d) => d.kind === "garden-id-socket-conflict");
163
+ ok(
164
+ "collision: 1 garden-id-socket-conflict diagnostic",
165
+ conflict !== undefined && conflict.kind === "garden-id-socket-conflict",
166
+ );
167
+ if (conflict && conflict.kind === "garden-id-socket-conflict") {
168
+ ok("collision: diagnostic gardenId", conflict.gardenId === GID_CONFLICT);
169
+ ok("collision: diagnostic backend", conflict.backend === "claude-code");
170
+ const keys = Object.keys(conflict).sort();
171
+ assert.deepStrictEqual(
172
+ keys,
173
+ ["backend", "gardenId", "kind", "message"],
174
+ `conflict diagnostic keyset drift: ${keys.join(",")}`,
175
+ );
176
+ ok("collision: diagnostic carries backend+gardenId only (no identity field)", true);
177
+ }
178
+ }
179
+
180
+ // ── non-pi citizen + SYMLINKED socket: the fact-provider:125 gap (closed) ───
181
+ // A non-pi record sharing its gid with a *symlinked* `*.sock`. The symlink is
182
+ // never probed, so its gid is absent from socketGids — the old socketGids-only
183
+ // check missed this and the non-pi citizen survived as a clean PeerFact while
184
+ // the legacy send path still followed the symlink to a forged receiver. The
185
+ // shared isNonPiGardenIdSocketConflict predicate unions socketGids with the
186
+ // symlinked gids, so the citizen is now quarantined (gid in NEITHER peers nor
187
+ // socketOnly) and the conflict diagnostic is raised — alongside the existing
188
+ // socket-symlink-rejected one (both are honest: symlink-rejected = "not probed",
189
+ // conflict = "non-pi address split").
190
+ {
191
+ const r = await listEntwurfFacts(
192
+ deps(
193
+ { [`${GID_PI}.meta.json`]: rec(GID_PI, "pi"), [`${GID_CONFLICT}.meta.json`]: rec(GID_CONFLICT, "claude-code") },
194
+ { [GID_PI]: "alive", [GID_CONFLICT]: "alive" },
195
+ { symlinks: [GID_CONFLICT] },
196
+ ),
197
+ );
198
+ ok(
199
+ "symlink-collision: non-pi citizen quarantined (NOT in peers)",
200
+ !r.facts.peers.some((p) => p.gardenId === GID_CONFLICT),
201
+ );
202
+ ok(
203
+ "symlink-collision: gid NOT in socketOnly (symlink never probed)",
204
+ !r.facts.socketOnly.some((s) => s.gardenId === GID_CONFLICT),
205
+ );
206
+ ok(
207
+ "symlink-collision: pi citizen still present",
208
+ r.facts.peers.some((p) => p.gardenId === GID_PI),
209
+ );
210
+ const conflict = r.diagnostics.find((d) => d.kind === "garden-id-socket-conflict" && d.gardenId === GID_CONFLICT);
211
+ ok("symlink-collision: garden-id-socket-conflict diagnostic raised for the symlinked gid", conflict !== undefined);
212
+ ok(
213
+ "symlink-collision: socket-symlink-rejected diagnostic ALSO raised (both honest)",
214
+ r.diagnostics.some((d) => d.kind === "socket-symlink-rejected" && d.gardenId === GID_CONFLICT),
215
+ );
216
+ }
217
+
218
+ // ── impossible-invariant throw is NOT swallowed (C-원칙) ────────────────────
219
+ // listEntwurfFacts feeds resolveFactList only CLEAN inputs, so its
220
+ // duplicate/unprobed throws never fire on real data — but they remain the last
221
+ // line of defense. The collision case above proves expected corruption is a
222
+ // diagnostic, not a crash; the absence of a catch-all around resolveFactList
223
+ // (verified by reading the source) keeps wiring bugs loud.
224
+ ok("C-원칙: collision handled as diagnostic, not crash (no throw above)", true);
225
+
226
+ // ── socket-axis hazard: symlinked socket → diagnostic, never probed (P1) ────
227
+ {
228
+ // A pi citizen whose socket is a symlink: it must NOT probe alive (forgery),
229
+ // it is forced dead → still a peer (dormant), and a diagnostic is raised.
230
+ const r = await listEntwurfFacts(
231
+ deps({ [`${GID_PI}.meta.json`]: rec(GID_PI, "pi") }, { [GID_PI]: "alive" }, { symlinks: [GID_PI] }),
232
+ );
233
+ const peer = r.facts.peers.find((p) => p.gardenId === GID_PI);
234
+ ok("P1: symlinked pi citizen forced dead (not probed alive)", peer?.liveness === "dead");
235
+ const sym = r.diagnostics.find((d) => d.kind === "socket-symlink-rejected");
236
+ ok("P1: socket-symlink-rejected diagnostic raised", sym?.kind === "socket-symlink-rejected");
237
+ if (sym && sym.kind === "socket-symlink-rejected") {
238
+ ok("P1: symlink diagnostic carries the gardenId", sym.gardenId === GID_PI);
239
+ const keys = Object.keys(sym).sort();
240
+ assert.deepStrictEqual(
241
+ keys,
242
+ ["gardenId", "kind", "message"],
243
+ `symlink diagnostic keyset drift: ${keys.join(",")}`,
244
+ );
245
+ }
246
+ }
247
+
248
+ // ── socket-axis hazard: malformed *.sock name → visible diagnostic (P3) ─────
249
+ {
250
+ const r = await listEntwurfFacts(
251
+ deps({ [`${GID_PI}.meta.json`]: rec(GID_PI, "pi") }, { [GID_PI]: "alive" }, { extraNames: ["not-a-gid.sock"] }),
252
+ );
253
+ const mal = r.diagnostics.find((d) => d.kind === "malformed-socket-name");
254
+ ok("P3: malformed-socket-name diagnostic raised", mal?.kind === "malformed-socket-name");
255
+ if (mal && mal.kind === "malformed-socket-name") {
256
+ ok("P3: malformed diagnostic carries the offending name", mal.name === "not-a-gid.sock");
257
+ }
258
+ ok("P3: pi citizen still listed alongside the malformed-name diagnostic", r.facts.peers.length === 1);
259
+ }
260
+
261
+ // ── socket-axis hazard: non-ENOENT dir-read failure → diagnostic (P2e②) ─────
262
+ {
263
+ const r = await listEntwurfFacts(
264
+ deps({ [`${GID_PI}.meta.json`]: rec(GID_PI, "pi") }, { [GID_PI]: "alive" }, { readdirErrorCode: "EACCES" }),
265
+ );
266
+ const dirErr = r.diagnostics.find((d) => d.kind === "socket-dir-read-error");
267
+ ok("P2e②: socket-dir-read-error diagnostic raised on EACCES", dirErr?.kind === "socket-dir-read-error");
268
+ ok(
269
+ "P2e②: meta citizen still listed (socket axis lost, citizen axis survives)",
270
+ r.facts.peers.length === 1 && r.facts.peers[0]?.gardenId === GID_PI,
271
+ );
272
+ ok(
273
+ "P2e②/P1: untrusted dir → pi citizen held at indeterminate (NOT probed alive through unverified path)",
274
+ r.facts.peers[0]?.liveness === "indeterminate",
275
+ );
276
+ }
277
+
278
+ // ── ENOENT dir is the normal empty → NO socket-dir-read-error (P2e②) ────────
279
+ {
280
+ const r = await listEntwurfFacts(
281
+ deps({ [`${GID_PI}.meta.json`]: rec(GID_PI, "pi") }, { [GID_PI]: "alive" }, { readdirErrorCode: "ENOENT" }),
282
+ );
283
+ ok(
284
+ "P2e②: ENOENT dir → no socket-dir-read-error diagnostic (normal empty)",
285
+ !r.diagnostics.some((d) => d.kind === "socket-dir-read-error"),
286
+ );
287
+ }
288
+
289
+ // ── determinism: diagnostics sorted, kind-tagged ───────────────────────────
290
+ {
291
+ const r = await listEntwurfFacts(
292
+ deps({ [`${GID_CLAUDE}.meta.json`]: "{ broken b", [`${GID_PI}.meta.json`]: "{ broken a" }, {}),
293
+ );
294
+ ok("determinism: 2 read-error diagnostics", r.diagnostics.length === 2);
295
+ ok("determinism: diagnostics sorted by key", diagnosticKey(r.diagnostics[0]) <= diagnosticKey(r.diagnostics[1]));
296
+ }
297
+
298
+ console.log(`\n[check-entwurf-fact-provider] ${passed} assertions ok`);
299
+ }
300
+
301
+ function diagnosticKey(d: { kind: string; filename?: string; gardenId?: string; name?: string }): string {
302
+ switch (d.kind) {
303
+ case "meta-record-read-error":
304
+ return `0:${d.filename}`;
305
+ case "garden-id-socket-conflict":
306
+ return `1:${d.gardenId}`;
307
+ case "socket-symlink-rejected":
308
+ return `2:${d.gardenId}`;
309
+ case "malformed-socket-name":
310
+ return `3:${d.name}`;
311
+ default:
312
+ return "4:";
313
+ }
314
+ }
315
+
316
+ await main();
@@ -0,0 +1,358 @@
1
+ /**
2
+ * check-entwurf-facts — deterministic gate for the fact-provider's pure core
3
+ * (0.11 Stage 0 step 4, slice 1). Locks the FACT shape and the R1/R3b liveness
4
+ * invariant in code BEFORE any IO wiring (gate-first).
5
+ *
6
+ * Proves:
7
+ * - R1 (out-of-domain → unsupported, NEVER coerced): claude-code / codex /
8
+ * antigravity resolve to `unsupported` for EVERY socket input (alive, dead,
9
+ * indeterminate, null) — the socket value is never folded into the fact for a
10
+ * backend whose liveness predicate is undefined.
11
+ * - R3b (in-domain 4-value): pi maps alive→alive, dead→dead,
12
+ * indeterminate→indeterminate, and null→indeterminate (no proof is
13
+ * `indeterminate`, never `dead`).
14
+ * - liveness is always a member of FACT_LIVENESSES.
15
+ * - identity / cwd-history fields pass through verbatim from the meta-record.
16
+ * - facts-only keyset: PeerFact carries EXACTLY the identity facts + `liveness`
17
+ * — no verb-routing field (resumable/sendable/transport/dispatch/action) and
18
+ * no transcriptPath. (동결결정 10: the fact layer must not bake routing.)
19
+ *
20
+ * Pure; no IO, no backend, no API.
21
+ */
22
+
23
+ import assert from "node:assert/strict";
24
+ import {
25
+ type FactList,
26
+ isNonPiGardenIdSocketConflict,
27
+ type PeerFact,
28
+ resolveFactList,
29
+ resolvePeerFact,
30
+ type SocketOnlyFact,
31
+ type SocketProbe,
32
+ } from "../pi-extensions/lib/entwurf-facts.ts";
33
+ import {
34
+ FACT_LIVENESSES,
35
+ type FactLiveness,
36
+ isLivenessSupported,
37
+ LIVENESS_DOMAIN_BACKENDS,
38
+ } from "../pi-extensions/lib/entwurf-v2-contract.ts";
39
+ import { META_BACKENDS_V2, type MetaBackendV2, type MetaIdentity } from "../pi-extensions/lib/meta-session.ts";
40
+ import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
41
+
42
+ // Derive the backend coverage from the SSOT registry so a NEW backend in
43
+ // META_BACKENDS_V2 auto-extends this gate (Fable/GPT non-blocking suggestion):
44
+ // out-of-domain backends must all resolve to `unsupported`, the in-domain set is
45
+ // the only one with a defined liveness predicate. A drift assertion below pins
46
+ // the in-domain set to ["pi"] so widening the domain forces new R3b coverage.
47
+ const OUT_OF_DOMAIN = META_BACKENDS_V2.filter((b) => !isLivenessSupported(b));
48
+ const IN_DOMAIN = META_BACKENDS_V2.filter((b) => isLivenessSupported(b));
49
+
50
+ let passed = 0;
51
+ function ok(label: string, cond: boolean): void {
52
+ assert.ok(cond, label);
53
+ console.log(` ok ${label}`);
54
+ passed++;
55
+ }
56
+
57
+ function identity(backend: MetaBackendV2, over: Partial<MetaIdentity> = {}): MetaIdentity {
58
+ return {
59
+ schemaVersion: 2,
60
+ gardenId: "20260611T093858-14984d",
61
+ backend,
62
+ nativeSessionId: "native-abc",
63
+ cwd: "/home/junghan/repos/gh/entwurf",
64
+ model: "claude-opus-4-8",
65
+ transcriptPath: "/home/junghan/.claude/projects/x/native-abc.jsonl",
66
+ parentGardenId: null,
67
+ isEntwurf: false,
68
+ createdAt: "2026-06-11T00:38:58.000Z",
69
+ recordUpdatedAt: "2026-06-11T02:40:00.000Z",
70
+ ...over,
71
+ };
72
+ }
73
+
74
+ const SOCKET_INPUTS: (SocketLiveness | null)[] = ["alive", "dead", "indeterminate", null];
75
+
76
+ // ── R1: out-of-domain backend → unsupported for EVERY socket input ──────────
77
+ ok("R1 out-of-domain set is non-empty (gate is actually exercising R1)", OUT_OF_DOMAIN.length > 0);
78
+ for (const backend of OUT_OF_DOMAIN) {
79
+ for (const socket of SOCKET_INPUTS) {
80
+ const fact = resolvePeerFact(identity(backend), socket);
81
+ ok(`R1 ${backend} + socket=${socket ?? "null"} → unsupported (not coerced)`, fact.liveness === "unsupported");
82
+ }
83
+ }
84
+
85
+ // Drift guard: the in-domain set is exactly the contract's domain and is ["pi"].
86
+ // If the domain widens, this fails → forces adding R3b value coverage for the
87
+ // new backend instead of silently leaving it untested.
88
+ assert.deepStrictEqual(IN_DOMAIN, [...LIVENESS_DOMAIN_BACKENDS], "IN_DOMAIN ≠ LIVENESS_DOMAIN_BACKENDS");
89
+ assert.deepStrictEqual(IN_DOMAIN, ["pi"], "liveness domain widened — add R3b coverage for the new backend");
90
+ ok("drift guard: in-domain set == LIVENESS_DOMAIN_BACKENDS == ['pi']", true);
91
+
92
+ // ── R3b: in-domain (pi) 4-value mapping, null = indeterminate (never dead) ──
93
+ const PI_CASES: [SocketLiveness | null, FactLiveness][] = [
94
+ ["alive", "alive"],
95
+ ["dead", "dead"],
96
+ ["indeterminate", "indeterminate"],
97
+ [null, "indeterminate"],
98
+ ];
99
+ for (const [socket, expected] of PI_CASES) {
100
+ const fact = resolvePeerFact(identity("pi"), socket);
101
+ ok(`R3b pi + socket=${socket ?? "null"} → ${expected}`, fact.liveness === expected);
102
+ }
103
+ ok(
104
+ "R3b pi null is indeterminate, NOT dead (no proof ≠ dead)",
105
+ resolvePeerFact(identity("pi"), null).liveness === "indeterminate",
106
+ );
107
+
108
+ // ── liveness always ∈ FACT_LIVENESSES ──────────────────────────────────────
109
+ for (const backend of META_BACKENDS_V2) {
110
+ for (const socket of SOCKET_INPUTS) {
111
+ const fact = resolvePeerFact(identity(backend), socket);
112
+ ok(
113
+ `liveness ∈ FACT_LIVENESSES (${backend}/${socket ?? "null"} → ${fact.liveness})`,
114
+ (FACT_LIVENESSES as readonly string[]).includes(fact.liveness),
115
+ );
116
+ }
117
+ }
118
+
119
+ // ── identity / cwd-history pass through verbatim ────────────────────────────
120
+ {
121
+ const id = identity("pi", {
122
+ gardenId: "20260611T112732-0f42b6",
123
+ nativeSessionId: "uuid-xyz",
124
+ cwd: "/tmp/work",
125
+ model: null,
126
+ parentGardenId: "20260101T000000-aaaaaa",
127
+ isEntwurf: true,
128
+ createdAt: "2026-06-11T01:00:00.000Z",
129
+ recordUpdatedAt: "2026-06-11T03:00:00.000Z",
130
+ });
131
+ const fact = resolvePeerFact(id, "alive");
132
+ ok("passthrough gardenId", fact.gardenId === id.gardenId);
133
+ ok("passthrough backend", fact.backend === id.backend);
134
+ ok("passthrough nativeSessionId", fact.nativeSessionId === id.nativeSessionId);
135
+ ok("passthrough cwd", fact.cwd === id.cwd);
136
+ ok("passthrough model (null preserved)", fact.model === null);
137
+ ok("passthrough parentGardenId", fact.parentGardenId === id.parentGardenId);
138
+ ok("passthrough isEntwurf", fact.isEntwurf === true);
139
+ ok("passthrough createdAt", fact.createdAt === id.createdAt);
140
+ ok("passthrough recordUpdatedAt", fact.recordUpdatedAt === id.recordUpdatedAt);
141
+ }
142
+
143
+ // ── facts-only keyset: identity facts + liveness, NO verb-routing/transcript ─
144
+ {
145
+ const fact = resolvePeerFact(identity("pi"), "alive");
146
+ const keys = Object.keys(fact).sort();
147
+ const expected = [
148
+ "backend",
149
+ "createdAt",
150
+ "cwd",
151
+ "gardenId",
152
+ "isEntwurf",
153
+ "liveness",
154
+ "model",
155
+ "nativeSessionId",
156
+ "parentGardenId",
157
+ "recordUpdatedAt",
158
+ ].sort();
159
+ assert.deepStrictEqual(keys, expected, `PeerFact keyset drift: got ${keys.join(",")}`);
160
+ ok("facts-only keyset exact (identity facts + liveness)", true);
161
+
162
+ const FORBIDDEN = ["resumable", "sendable", "transport", "dispatch", "action", "transcriptPath"];
163
+ for (const k of FORBIDDEN) {
164
+ ok(`no '${k}' on PeerFact (facts-only, no verb-routing/transcript)`, !(k in fact));
165
+ }
166
+ }
167
+
168
+ // Type-level guard: the test references PeerFact so a field rename breaks tsc.
169
+ const _typecheck: (f: PeerFact) => FactLiveness = (f) => f.liveness;
170
+ void _typecheck;
171
+
172
+ // ════════════════════════════════════════════════════════════════════════════
173
+ // slice 2 — resolveFactList: meta-store ⨯ socket union (설계 동결 2026-06-11,
174
+ // GPT힣 + Fable). PeerFact for record citizens, SocketOnlyFact for record-less
175
+ // live sockets; gardenId is the correlation key.
176
+ // ════════════════════════════════════════════════════════════════════════════
177
+
178
+ function socketProbe(gardenId: string, liveness: SocketLiveness, over: Partial<SocketProbe> = {}): SocketProbe {
179
+ return { gardenId, liveness, cwd: null, model: null, idle: null, infoError: null, ...over };
180
+ }
181
+
182
+ const GID_PI_LIVE = "20260611T115213-3aa371";
183
+ const GID_PI_DORMANT = "20260611T093858-14984d";
184
+ const GID_CLAUDE = "20260611T112732-0f42b6";
185
+ const GID_SOCKET_ONLY = "20260611T135517-5f0d25";
186
+
187
+ // ── basic union: pi-live citizen + claude citizen + record-less live socket ──
188
+ {
189
+ const ids = [identity("pi", { gardenId: GID_PI_LIVE }), identity("claude-code", { gardenId: GID_CLAUDE })];
190
+ const probes = [socketProbe(GID_PI_LIVE, "alive"), socketProbe(GID_SOCKET_ONLY, "alive", { cwd: "/tmp/x" })];
191
+ const out = resolveFactList(ids, probes);
192
+ ok("union: 2 citizens → 2 PeerFacts", out.peers.length === 2);
193
+ ok("union: 1 record-less socket → 1 SocketOnlyFact", out.socketOnly.length === 1);
194
+ ok(
195
+ "union: pi-live citizen liveness = alive (from its probe)",
196
+ out.peers.find((p) => p.gardenId === GID_PI_LIVE)?.liveness === "alive",
197
+ );
198
+ ok(
199
+ "union: claude citizen liveness = unsupported (out-of-domain, socket ignored)",
200
+ out.peers.find((p) => p.gardenId === GID_CLAUDE)?.liveness === "unsupported",
201
+ );
202
+ ok("union: socket-only gardenId surfaced", out.socketOnly[0]?.gardenId === GID_SOCKET_ONLY);
203
+ }
204
+
205
+ // ── dormant trap (Fable): pi citizen probed dead → dead (NOT indeterminate) ──
206
+ {
207
+ const out = resolveFactList([identity("pi", { gardenId: GID_PI_DORMANT })], [socketProbe(GID_PI_DORMANT, "dead")]);
208
+ ok(
209
+ "dormant trap: pi citizen + probe=dead → liveness=dead (resumable, not stranded)",
210
+ out.peers[0]?.liveness === "dead",
211
+ );
212
+ ok("dormant trap: dead pi citizen is a PeerFact, not socket-only", out.socketOnly.length === 0);
213
+ }
214
+
215
+ // ── F3 preserve: pi citizen probed indeterminate → not folded to dead ────────
216
+ {
217
+ const out = resolveFactList([identity("pi", { gardenId: GID_PI_LIVE })], [socketProbe(GID_PI_LIVE, "indeterminate")]);
218
+ ok(
219
+ "F3 preserve: pi citizen + probe=indeterminate → indeterminate (never folded)",
220
+ out.peers[0]?.liveness === "indeterminate",
221
+ );
222
+ }
223
+
224
+ // ── wiring invariant: unprobed in-domain citizen → throw (null is unprobed-only) ─
225
+ {
226
+ let threw = false;
227
+ try {
228
+ resolveFactList([identity("pi", { gardenId: GID_PI_LIVE })], []);
229
+ } catch {
230
+ threw = true;
231
+ }
232
+ ok("wiring invariant: in-domain citizen absent from probes → throw (never silent null/indeterminate)", threw);
233
+ }
234
+
235
+ // ── fail-loud (동결3): out-of-domain citizen owning a control socket = ambiguity ─
236
+ {
237
+ let threw = false;
238
+ try {
239
+ resolveFactList([identity("claude-code", { gardenId: GID_CLAUDE })], [socketProbe(GID_CLAUDE, "alive")]);
240
+ } catch {
241
+ threw = true;
242
+ }
243
+ ok("dedup/authority: non-pi citizen + control socket at same gid → fail-loud", threw);
244
+ }
245
+
246
+ // ── dedup (동결3): pi citizen + its socket → PeerFact only, never both ────────
247
+ {
248
+ const out = resolveFactList([identity("pi", { gardenId: GID_PI_LIVE })], [socketProbe(GID_PI_LIVE, "alive")]);
249
+ ok("dedup: pi citizen consumes its socket (1 PeerFact)", out.peers.length === 1);
250
+ ok("dedup: consumed gid not also emitted as SocketOnlyFact", out.socketOnly.length === 0);
251
+ const allGids = [...out.peers.map((p) => p.gardenId), ...out.socketOnly.map((s) => s.gardenId)];
252
+ ok("dedup: no gardenId appears in both sections", new Set(allGids).size === allGids.length);
253
+ }
254
+
255
+ // ── duplicate inputs → throw ─────────────────────────────────────────────────
256
+ {
257
+ let dupProbe = false;
258
+ try {
259
+ resolveFactList([], [socketProbe(GID_PI_LIVE, "alive"), socketProbe(GID_PI_LIVE, "dead")]);
260
+ } catch {
261
+ dupProbe = true;
262
+ }
263
+ ok("duplicate socket probe for one gid → throw", dupProbe);
264
+
265
+ let dupId = false;
266
+ try {
267
+ resolveFactList(
268
+ [identity("pi", { gardenId: GID_PI_LIVE }), identity("pi", { gardenId: GID_PI_LIVE })],
269
+ [socketProbe(GID_PI_LIVE, "alive")],
270
+ );
271
+ } catch {
272
+ dupId = true;
273
+ }
274
+ ok("duplicate meta-record for one gid → throw", dupId);
275
+ }
276
+
277
+ // ── SocketOnlyFact: 3-value liveness + probe-derived enrich, no synthetic id ──
278
+ {
279
+ const out = resolveFactList(
280
+ [],
281
+ [socketProbe(GID_SOCKET_ONLY, "alive", { cwd: "/tmp/w", model: "gpt-5.5", idle: true, infoError: null })],
282
+ );
283
+ const s = out.socketOnly[0] as SocketOnlyFact;
284
+ ok("socket-only: kind discriminant", s.kind === "socket-only");
285
+ ok("socket-only: liveness ∈ SocketLiveness 3-value (never unsupported)", s.liveness === "alive");
286
+ ok("socket-only: probe-derived cwd passthrough", s.cwd === "/tmp/w");
287
+ ok("socket-only: probe-derived model passthrough", s.model === "gpt-5.5");
288
+ ok("socket-only: probe-derived idle passthrough", s.idle === true);
289
+ const keys = Object.keys(s).sort();
290
+ const expected = ["cwd", "gardenId", "idle", "infoError", "kind", "liveness", "model"].sort();
291
+ assert.deepStrictEqual(keys, expected, `SocketOnlyFact keyset drift: ${keys.join(",")}`);
292
+ ok("socket-only: keyset exact (gardenId + liveness + probe-derived enrich)", true);
293
+ const FORBIDDEN = [
294
+ "resumable",
295
+ "sendable",
296
+ "transport",
297
+ "dispatch",
298
+ "action",
299
+ "backend",
300
+ "nativeSessionId",
301
+ "isEntwurf",
302
+ ];
303
+ for (const k of FORBIDDEN) {
304
+ ok(`socket-only: no '${k}' (no verb-routing, no synthetic identity)`, !(k in s));
305
+ }
306
+ }
307
+
308
+ // ── determinism: output sorted by gardenId in each section ───────────────────
309
+ {
310
+ const ids = [
311
+ identity("pi", { gardenId: "20260611T222222-bbbbbb" }),
312
+ identity("pi", { gardenId: "20260611T111111-aaaaaa" }),
313
+ ];
314
+ const probes = [
315
+ socketProbe("20260611T222222-bbbbbb", "alive"),
316
+ socketProbe("20260611T111111-aaaaaa", "alive"),
317
+ socketProbe("20260611T333333-cccccc", "alive"),
318
+ ];
319
+ const out = resolveFactList(ids, probes);
320
+ ok("determinism: peers sorted by gardenId", out.peers[0]?.gardenId === "20260611T111111-aaaaaa");
321
+ ok("determinism: socketOnly sorted by gardenId", out.socketOnly[0]?.gardenId === "20260611T333333-cccccc");
322
+ }
323
+
324
+ // ── isNonPiGardenIdSocketConflict: the SHARED record-side conflict predicate ──
325
+ // (fact-provider listing + v2 decider dispatch consume this same fn — 4c 재유도
326
+ // 금지 동형). The union over socketGids ∪ symlinkedGardenIds is the fact-provider:125
327
+ // gap closure: a symlinked socket is never probed, so its gid is absent from
328
+ // socketGids; the predicate must STILL flag a non-pi citizen owning it.
329
+ {
330
+ const G = "20260611T444444-dddddd";
331
+ const realSockets = new Set([G]);
332
+ const symlinked = new Set([G]);
333
+ const empty = new Set<string>();
334
+ for (const piBackend of IN_DOMAIN) {
335
+ ok(
336
+ `conflict-predicate: in-domain (${piBackend}) is NEVER a non-pi conflict (even with a colliding socket)`,
337
+ !isNonPiGardenIdSocketConflict(piBackend, G, realSockets, symlinked),
338
+ );
339
+ }
340
+ ok(
341
+ "conflict-predicate: non-pi + real socket → conflict",
342
+ isNonPiGardenIdSocketConflict("claude-code", G, realSockets, empty),
343
+ );
344
+ ok(
345
+ "conflict-predicate: non-pi + SYMLINKED socket only → conflict (the :125 gap)",
346
+ isNonPiGardenIdSocketConflict("claude-code", G, empty, symlinked),
347
+ );
348
+ ok(
349
+ "conflict-predicate: non-pi + no socket of either kind → no conflict",
350
+ !isNonPiGardenIdSocketConflict("claude-code", G, empty, empty),
351
+ );
352
+ }
353
+
354
+ // Type-level guard: reference FactList so a shape rename breaks tsc.
355
+ const _factlist: (f: FactList) => number = (f) => f.peers.length + f.socketOnly.length;
356
+ void _factlist;
357
+
358
+ console.log(`\n[check-entwurf-facts] ${passed} assertions ok`);