@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,123 @@
1
+ /**
2
+ * entwurf-deliverability — the PURE conversational-mailbox deliverability predicate
3
+ * (SE-1/SE-2 slice 2c). "If I enqueue a conversational reply to this target's mailbox
4
+ * right now, will a model actually see it — or will it rot as garbage?"
5
+ *
6
+ * Two layers, both pure (facts injected, no IO):
7
+ *
8
+ * - computeMetaReceiverActive(facts): the shared "is this receiver active?" atom —
9
+ * recordBacked AND ownerAlive AND watchArmed. This is the SAME conjunction the
10
+ * self-addressability predicate uses for its meta branch; both import it so the
11
+ * "active receiver" definition has ONE source of truth (concept shared, API split).
12
+ *
13
+ * - mailboxConversationalDeliverable(facts): the enqueue gate. A mailbox enqueue +
14
+ * doorbell only delivers for a SELF-FETCH backend (Claude Code / Codex / agy): the
15
+ * receiver drains its own inbox on wake. A DIRECT-INJECT backend (pi) has no
16
+ * mailbox drain at all — enqueuing for it is the SE-1 false success ("✓ delivered"
17
+ * into a void). So deliverable = wakeMode === "self-fetch" AND the receiver is
18
+ * active. This is the guard that the v1 fallback, MCP v1, pi-native v1, and the v2
19
+ * decider/send-fallback enqueue sites must all pass before writing a .msg (slice 2d).
20
+ *
21
+ * The contract is "mailboxConversationalDeliverable", NOT a broad "deliverable": it is
22
+ * specifically about a conversational reply that needs a live doorbell wake, NOT about
23
+ * an archival mailbox someone reads later. Naming it narrowly keeps a future archival
24
+ * path from silently inheriting this gate.
25
+ */
26
+
27
+ import type { WakeMode } from "./meta-session.ts";
28
+
29
+ export interface MetaReceiverActiveFacts {
30
+ /** The receiver's garden id is backed by a live meta-record (identity exists). */
31
+ recordBacked?: boolean;
32
+ /** The receiver's watch owner pid is still the same live process (start-key match). */
33
+ ownerAlive?: boolean;
34
+ /** The receiver's idle-wake watch is armed (presence marker from an arm event). */
35
+ watchArmed?: boolean;
36
+ }
37
+
38
+ export interface MetaReceiverActiveResult {
39
+ active: boolean;
40
+ reason: string;
41
+ }
42
+
43
+ /**
44
+ * The shared active-receiver atom. Every axis is required (fail-closed: an undefined
45
+ * fact is treated as false, never optimistic), and each failure names its own cause so
46
+ * a terminated-owner is never conflated with a missing record or an unarmed watch.
47
+ */
48
+ export function computeMetaReceiverActive(facts: MetaReceiverActiveFacts): MetaReceiverActiveResult {
49
+ if (facts.recordBacked !== true) {
50
+ return { active: false, reason: "no backing meta-record" };
51
+ }
52
+ if (facts.ownerAlive !== true) {
53
+ return { active: false, reason: "owner not alive (start-key mismatch — session exited or pid reused)" };
54
+ }
55
+ if (facts.watchArmed !== true) {
56
+ return { active: false, reason: "idle-watch not armed — a reply would enqueue with no doorbell wake" };
57
+ }
58
+ return { active: true, reason: "record backed, owner alive, watch armed" };
59
+ }
60
+
61
+ /**
62
+ * The identity axes a receiver presence marker must agree on to count as THIS target's
63
+ * live receiver (SE-2 slice 2d-3). A structural shape — both `MetaReceiverMarker` and
64
+ * `MetaIdentity` carry these fields, so callers pass either without importing the
65
+ * meta-session types here (keeps this module pure and dependency-light). `backend` is
66
+ * compared as a string (equality only); the enum is validated by the meta-session reader.
67
+ */
68
+ export interface ReceiverIdentityFacts {
69
+ gardenId: string;
70
+ backend: string;
71
+ nativeSessionId: string;
72
+ }
73
+
74
+ /**
75
+ * Does this presence marker actually belong to the target identity? A marker that is
76
+ * absent, or whose garden id / backend / native session id has drifted from the record,
77
+ * is NOT this receiver — fail-closed (a stale/foreign marker must never raise a dead
78
+ * target to "active"). The single source of truth for "marker ↔ identity match" shared
79
+ * by the v1 mailbox guard (gatherMailboxDeliverabilityFacts) and the v2 production
80
+ * `mailboxDeliverabilityFor` seam, so the two paths cannot drift to different meanings.
81
+ */
82
+ export function receiverMarkerMatchesIdentity(
83
+ marker: ReceiverIdentityFacts | null | undefined,
84
+ identity: ReceiverIdentityFacts,
85
+ ): boolean {
86
+ return (
87
+ !!marker &&
88
+ marker.gardenId === identity.gardenId &&
89
+ marker.backend === identity.backend &&
90
+ marker.nativeSessionId === identity.nativeSessionId
91
+ );
92
+ }
93
+
94
+ export interface MailboxDeliverabilityFacts extends MetaReceiverActiveFacts {
95
+ /** The target backend's wake mode (from the capability registry). */
96
+ wakeMode?: WakeMode | string;
97
+ }
98
+
99
+ export interface MailboxDeliverabilityResult {
100
+ deliverable: boolean;
101
+ reason: string;
102
+ }
103
+
104
+ /**
105
+ * The conversational-mailbox enqueue gate. False (no enqueue) unless the backend is
106
+ * self-fetch AND the receiver is active. A direct-inject backend (pi) is refused
107
+ * outright — it has no mailbox drain, so an enqueue would be a silent false success.
108
+ */
109
+ export function mailboxConversationalDeliverable(facts: MailboxDeliverabilityFacts): MailboxDeliverabilityResult {
110
+ if (facts.wakeMode !== "self-fetch") {
111
+ return {
112
+ deliverable: false,
113
+ reason: `backend wake mode ${facts.wakeMode ?? "(unset)"} is not self-fetch — a mailbox enqueue would never be drained`,
114
+ };
115
+ }
116
+ const recv = computeMetaReceiverActive(facts);
117
+ return {
118
+ deliverable: recv.active,
119
+ reason: recv.active
120
+ ? `self-fetch receiver active (${recv.reason})`
121
+ : `self-fetch receiver inactive — ${recv.reason}`,
122
+ };
123
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * entwurf-fact-provider — the fact-provider's ASSEMBLY layer (0.11 Stage 0 step
3
+ * 4, slice 4b). Composes the two axes into the listing the MCP `entwurf_peers`
4
+ * surface (slice 4c) renders. Lives in its own module so nothing imports it back
5
+ * (one-way: provider → facts / socket-discovery / meta-session) — no import cycle
6
+ * with `entwurf-facts.ts` (which owns `SocketProbe`/`resolveFactList`).
7
+ *
8
+ * listAllMetaIdentities → pi gid 추출 → scanSocketProbes(piGids)
9
+ * → pre-quarantine non-pi/socket conflicts → resolveFactList(clean)
10
+ * → { facts, diagnostics }
11
+ *
12
+ * Two throw-vs-diagnostics policies, kept distinct (GPT힣 C-원칙):
13
+ * - EXPECTED data corruption → diagnostics, listing survives. A meta-record
14
+ * parse failure (from listAllMetaIdentities) and a gardenId↔socket address
15
+ * collision are external-state problems; one must not blind `entwurf_peers`.
16
+ * - IMPOSSIBLE wiring invariant → throw, NOT swallowed. resolveFactList's
17
+ * duplicate-identity / unprobed-in-domain throws are assembly BUGS; catching
18
+ * them here would hide a code defect. We feed resolveFactList only CLEAN
19
+ * inputs (conflicts pre-removed), so its throw stays the last line of defense
20
+ * — that is not a re-implementation of the collision rule, it is input
21
+ * sanitation that leaves the pure-core invariant intact.
22
+ *
23
+ * The non-pi+socket collision quarantines BOTH sides (the PeerFact AND the
24
+ * socket): gardenId is the universal address and a send path reads the socket
25
+ * first, so surfacing the record alone (as a clean `unsupported` PeerFact) while
26
+ * a same-gid socket exists would be half a lie. Both leave the normal output;
27
+ * one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
28
+ */
29
+
30
+ import { type FactList, isNonPiGardenIdSocketConflict, resolveFactList } from "./entwurf-facts.ts";
31
+ import { isLivenessSupported } from "./entwurf-v2-contract.ts";
32
+ import { listAllMetaIdentities, type MetaBackendV2 } from "./meta-session.ts";
33
+ import { type SocketScanDeps, scanSocketProbes } from "./socket-discovery.ts";
34
+
35
+ /** A listing-surface problem, surfaced explicitly rather than hidden or thrown.
36
+ * Kind-tagged so the render layer shows provenance; each carries only verbatim
37
+ * facts (never a half-parsed identity). The last three are socket-axis hazards
38
+ * folded from `scanSocketProbes` (slice 4c, Fable 검수): a symlinked socket is a
39
+ * correlation-authority forgery attempt (P1), a malformed `*.sock` name a visible
40
+ * drop (P3), a non-ENOENT dir-read failure asymmetric loss of the socket axis
41
+ * (P2e②). */
42
+ export type EntwurfDiagnostic =
43
+ | { kind: "meta-record-read-error"; filename: string; message: string }
44
+ | { kind: "garden-id-socket-conflict"; gardenId: string; backend: MetaBackendV2; message: string }
45
+ | { kind: "socket-symlink-rejected"; gardenId: string; message: string }
46
+ | { kind: "malformed-socket-name"; name: string; message: string }
47
+ | { kind: "socket-dir-read-error"; message: string };
48
+
49
+ export interface EntwurfFactsResult {
50
+ facts: FactList;
51
+ diagnostics: EntwurfDiagnostic[];
52
+ }
53
+
54
+ export interface EntwurfFactsDeps {
55
+ /** Meta-store axis: the `.meta.json` entry names + a record reader. */
56
+ metaEntries: readonly string[];
57
+ readRecord: (filename: string) => string;
58
+ /** Socket axis: injected into scanSocketProbes (controlDir/readdir/probe). */
59
+ socket?: Partial<SocketScanDeps>;
60
+ }
61
+
62
+ function diagnosticSortKey(d: EntwurfDiagnostic): string {
63
+ switch (d.kind) {
64
+ case "meta-record-read-error":
65
+ return `0:${d.filename}`;
66
+ case "garden-id-socket-conflict":
67
+ return `1:${d.gardenId}`;
68
+ case "socket-symlink-rejected":
69
+ return `2:${d.gardenId}`;
70
+ case "malformed-socket-name":
71
+ return `3:${d.name}`;
72
+ case "socket-dir-read-error":
73
+ return "4:";
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Assemble the facts-only listing. Pure over its injected deps (no direct IO) so
79
+ * the gate drives it without a filesystem; slice 4c supplies the real readdir /
80
+ * readFile / probe. Live socket probes may carry get_info runtime enrich
81
+ * (cwd/model/idle); null remains honest and renders as "not enriched".
82
+ */
83
+ export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfFactsResult> {
84
+ const diagnostics: EntwurfDiagnostic[] = [];
85
+
86
+ // 1. meta-store axis — expected corruption becomes diagnostics, not a throw.
87
+ const { identities, errors } = listAllMetaIdentities(deps.metaEntries, deps.readRecord);
88
+ for (const e of errors) {
89
+ diagnostics.push({ kind: "meta-record-read-error", filename: e.filename, message: e.message });
90
+ }
91
+
92
+ // 2. socket axis — probe (dir sockets) ∪ (in-domain citizen canonical paths).
93
+ // Its three hazards (symlink forgery / malformed name / dir-read error) are
94
+ // folded into diagnostics here so the listing survives but never lies.
95
+ const piGids = identities.filter((i) => isLivenessSupported(i.backend)).map((i) => i.gardenId);
96
+ const scan = await scanSocketProbes(piGids, deps.socket ?? {});
97
+ const probes = scan.probes;
98
+ const socketGids = new Set(probes.map((p) => p.gardenId));
99
+ const symlinkedGids = new Set(scan.symlinkedGardenIds);
100
+ for (const gardenId of scan.symlinkedGardenIds) {
101
+ diagnostics.push({
102
+ kind: "socket-symlink-rejected",
103
+ gardenId,
104
+ message:
105
+ "control socket is a symlink — never probed (it could redirect to another session's listener and forge " +
106
+ "an alive liveness for this gardenId); a citizen owning it is treated as dead (dormant), a record-less one dropped.",
107
+ });
108
+ }
109
+ for (const name of scan.malformedNames) {
110
+ diagnostics.push({
111
+ kind: "malformed-socket-name",
112
+ name,
113
+ message: "control-socket filename is not a garden id — no citizen to correlate to; dropped from the listing.",
114
+ });
115
+ }
116
+ if (scan.dirError !== null) {
117
+ diagnostics.push({
118
+ kind: "socket-dir-read-error",
119
+ message: `control-socket directory unreadable (socket axis incomplete; meta-record citizens still listed): ${scan.dirError}`,
120
+ });
121
+ }
122
+
123
+ // 3. pre-quarantine non-pi citizens that collide with a control socket. The
124
+ // predicate is SHARED with the v2 decider (isNonPiGardenIdSocketConflict) so
125
+ // listing and dispatch cannot drift, and it unions socketGids with the
126
+ // symlinkedGids: a symlinked socket is never probed (absent from socketGids),
127
+ // so the old socketGids-only check let a non-pi citizen with a forged
128
+ // (symlinked) socket survive as a clean PeerFact while the legacy send path
129
+ // still followed the symlink — the gap this closes.
130
+ const conflictGids = new Set<string>();
131
+ for (const id of identities) {
132
+ if (isNonPiGardenIdSocketConflict(id.backend, id.gardenId, socketGids, symlinkedGids)) {
133
+ conflictGids.add(id.gardenId);
134
+ diagnostics.push({
135
+ kind: "garden-id-socket-conflict",
136
+ gardenId: id.gardenId,
137
+ backend: id.backend,
138
+ message:
139
+ `non-pi citizen (${id.backend}) shares its gardenId with a control socket (real or symlinked) — address ` +
140
+ "ambiguity; both the citizen and the socket are quarantined from the listing.",
141
+ });
142
+ }
143
+ }
144
+
145
+ // 4. resolveFactList over CLEAN inputs only. Its throws (duplicate identity /
146
+ // unprobed in-domain citizen) are impossible wiring invariants — left to
147
+ // fire as the last line of defense, never caught here.
148
+ const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
149
+ const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
150
+ const facts: FactList = resolveFactList(cleanIdentities, cleanProbes);
151
+
152
+ diagnostics.sort((a, b) => {
153
+ const ka = diagnosticSortKey(a);
154
+ const kb = diagnosticSortKey(b);
155
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
156
+ });
157
+ return { facts, diagnostics };
158
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * entwurf-facts — the TS fact-provider's PURE core (0.11 Stage 0 step 4).
3
+ *
4
+ * The "brain" reads disk SSOT (meta-record) + a socket probe and emits FACTS,
5
+ * never verbs. This module holds the single pure composition step:
6
+ *
7
+ * (MetaIdentity, SocketLiveness | null) → PeerFact
8
+ *
9
+ * What it deliberately does NOT do (동결결정 10 / bucket B boundary):
10
+ * - NO verb-routing. A `PeerFact` carries no `resumable`/`sendable`/`transport`
11
+ * /`dispatch` field. Whether a target is resumed or sent-to is computed at
12
+ * call time by the entwurf_v2 dispatch table from `liveness` — baking that
13
+ * decision into the fact layer is exactly what makes `entwurf_peers` lie
14
+ * (the reason 동결결정 10 orders contract-lock before this provider).
15
+ * - NO IO. readdir of the meta-store and the live socket probe are slice-2
16
+ * wiring; this slice locks the fact SHAPE and the R1/R3b liveness invariant
17
+ * in code first (gate-first discipline), with both inputs injected.
18
+ * - NO transcriptPath. The transcript path is a private on-disk location, not
19
+ * a peer-facing fact; `entwurf_peers` exposes identity + cwd-history, not
20
+ * filesystem internals. (who-can / dispatch read it via the meta-record
21
+ * directly when they genuinely need it — it does not belong in the listing.)
22
+ *
23
+ * The 4-value liveness (`alive|dead|indeterminate|unsupported`, R3b) and the
24
+ * out-of-domain → `unsupported` rule (R1: never coerce an unprobed backend to
25
+ * `dead`/`indeterminate`) come from entwurf-v2-contract's `factLivenessOf` — the
26
+ * frozen contract is the single source for that mapping; this module only shapes
27
+ * the surrounding identity facts around it.
28
+ */
29
+
30
+ import { type FactLiveness, factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.ts";
31
+ import type { MetaBackendV2, MetaIdentity } from "./meta-session.ts";
32
+ import type { SocketLiveness } from "./socket-probe.ts";
33
+
34
+ /**
35
+ * Facts-only view of one garden citizen for `entwurf_peers`. Every field except
36
+ * `liveness` is verbatim identity / cwd-history from the meta-record; `liveness`
37
+ * is the one COMPUTED fact (4-value, R1/R3b). No verb-routing, no transcript.
38
+ */
39
+ export interface PeerFact {
40
+ // — identity + cwd-history facts (verbatim from the meta-record) —
41
+ gardenId: string;
42
+ backend: MetaBackendV2;
43
+ nativeSessionId: string;
44
+ cwd: string;
45
+ model: string | null;
46
+ parentGardenId: string | null;
47
+ isEntwurf: boolean;
48
+ createdAt: string;
49
+ recordUpdatedAt: string;
50
+ // — the single computed fact: 4-value liveness (R1/R3b). NOT a verb. —
51
+ liveness: FactLiveness;
52
+ }
53
+
54
+ /**
55
+ * Compose a `PeerFact` from a citizen's identity and an optional socket probe.
56
+ *
57
+ * `socket` is the 3-value control-socket result for an IN-DOMAIN backend (pi),
58
+ * or `null` when no probe was taken (out-of-domain backend, or in-domain with no
59
+ * socket found). `factLivenessOf` resolves the 4-value fact:
60
+ * - out-of-domain backend → `unsupported` (R1, regardless of `socket`)
61
+ * - in-domain, socket present → that socket value
62
+ * - in-domain, socket null → `indeterminate` (no proof, never `dead`)
63
+ *
64
+ * Pure: same inputs → same output, no IO.
65
+ */
66
+ export function resolvePeerFact(identity: MetaIdentity, socket: SocketLiveness | null): PeerFact {
67
+ return {
68
+ gardenId: identity.gardenId,
69
+ backend: identity.backend,
70
+ nativeSessionId: identity.nativeSessionId,
71
+ cwd: identity.cwd,
72
+ model: identity.model,
73
+ parentGardenId: identity.parentGardenId,
74
+ isEntwurf: identity.isEntwurf,
75
+ createdAt: identity.createdAt,
76
+ recordUpdatedAt: identity.recordUpdatedAt,
77
+ liveness: factLivenessOf(identity.backend, socket),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * A non-pi RECORD whose gardenId collides with a control socket — a real (probed)
83
+ * one OR a symlinked/forged one. The gardenId is the universal address (동결결정3),
84
+ * so a non-pi citizen sharing it with a socket means a send-path that reaches the
85
+ * socket first hits a DIFFERENT receiver than the record names — an address split.
86
+ * Both the citizen and the socket are quarantined from the facts listing.
87
+ *
88
+ * The union `socketGids ∪ symlinkedGardenIds` is load-bearing: `socketGids` are
89
+ * gids with a real probed `*.sock`, but `symlinkedGardenIds` are NEVER probed (P1)
90
+ * and so are absent from `socketGids`. Looking at `socketGids` alone (the
91
+ * fact-provider:125 gap this closes) let a non-pi citizen with a *symlinked* socket
92
+ * survive as a clean PeerFact while the legacy send path still followed the symlink
93
+ * to a forged receiver. Both axes claim the gid → both must quarantine it.
94
+ *
95
+ * SCOPE: this is the RECORD-side, non-pi conflict only — shared by the fact-provider
96
+ * (listing) and the v2 decider (dispatch) so the two cannot drift (4c "재유도 금지"
97
+ * 동형; only the observation-bit source is parameterized). A pi citizen whose own
98
+ * canonical socket is a symlink is NOT this predicate's concern — that is a
99
+ * target-specific lstat conflict the decider's `inspectTargetControlSocket` raises
100
+ * as `address-conflict`, kept deliberately separate (GPT 1차 검수 C).
101
+ */
102
+ export function isNonPiGardenIdSocketConflict(
103
+ backend: string,
104
+ gardenId: string,
105
+ socketGids: ReadonlySet<string>,
106
+ symlinkedGardenIds: ReadonlySet<string>,
107
+ ): boolean {
108
+ return !isLivenessSupported(backend) && (socketGids.has(gardenId) || symlinkedGardenIds.has(gardenId));
109
+ }
110
+
111
+ // ── slice 2: meta-store axis ⨯ socket axis → facts-only listing ─────────────
112
+ // (설계 동결 2026-06-11, GPT힣 + Fable 수렴 — NEXT.md "step 4 slice 2 설계 동결")
113
+
114
+ /**
115
+ * The SOCKET-axis input to the union: one 3-value probe of a control socket plus
116
+ * its get_info-derived runtime enrich. Slice-3 wiring fills this by probing the
117
+ * control-socket dir AND every in-domain citizen's canonical socket path with
118
+ * `probeSocketLiveness` (3-value, indeterminate preserved). `liveness` is the
119
+ * 3-value `SocketLiveness` — never `unsupported`, because a probe genuinely ran.
120
+ * The enrich fields are probe-derived RUNTIME facts (the get_info RPC), labelled
121
+ * as such — they are NOT meta-record identity and NOT synthetic; a `null` means
122
+ * the RPC did not surface that field (see `infoError`).
123
+ */
124
+ export interface SocketProbe {
125
+ gardenId: string;
126
+ liveness: SocketLiveness;
127
+ cwd: string | null;
128
+ model: string | null;
129
+ idle: boolean | null;
130
+ infoError: string | null;
131
+ }
132
+
133
+ /**
134
+ * A record-less control-socket probe — a socket path that no meta-record citizen
135
+ * claims. The canonical case is "socket-only pi": a live pi session that predates
136
+ * the pi meta-record writer, or one caught in a deploy-lag / crash window. But
137
+ * `liveness` is the full 3-value `SocketLiveness`, NOT only `alive` — a
138
+ * dir-present stale socket arrives as `dead` and a load-stalled one as
139
+ * `indeterminate`; both stay in the listing (hiding a dead socket is GC's job,
140
+ * not the listing's, and an indeterminate socket-only — "not unlinked, not live"
141
+ * — is the most worth surfacing). Kept as a DISTINCT fact kind, never folded into
142
+ * `PeerFact` and never given a 5th liveness value: "this socket has no citizen"
143
+ * is a statement whose SUBJECT is the socket, not a citizen, so it must not
144
+ * borrow the citizen-keyed 4-value liveness enum (the sibling of R1 — do not
145
+ * collapse a different subject). All fields are the socket filename (gardenId =
146
+ * 동결결정3 correlation authority) + probe-derived runtime facts.
147
+ */
148
+ export interface SocketOnlyFact {
149
+ kind: "socket-only";
150
+ gardenId: string;
151
+ liveness: SocketLiveness;
152
+ cwd: string | null;
153
+ model: string | null;
154
+ idle: boolean | null;
155
+ infoError: string | null;
156
+ }
157
+
158
+ /**
159
+ * The union output. Two arrays, NOT one discriminated array: the surface layer
160
+ * (slice 4) may tag a `PeerFact` with `kind:"peer"` when it merges the sections,
161
+ * but the pure core keeps `PeerFact`'s slice-1 keyset untouched (no `kind` field
162
+ * baked onto it). `entwurf_peers` reports both sections so the new listing fully
163
+ * replaces the old live-pi discovery.
164
+ */
165
+ export interface FactList {
166
+ peers: PeerFact[];
167
+ socketOnly: SocketOnlyFact[];
168
+ }
169
+
170
+ /**
171
+ * Pure union of the meta-store axis (citizens) and the socket axis (probes) into
172
+ * a facts-only listing. No IO — slice-3 wiring reads the meta-store and probes
173
+ * the sockets, then injects both lists.
174
+ *
175
+ * Correlation key = `gardenId` (동결결정3; `nativeSessionId` is backend-local, not
176
+ * a global key). Rules frozen 2026-06-11 (GPT힣 + Fable):
177
+ * - in-domain (pi) citizen: liveness = its socket probe (3-value preserved).
178
+ * The wiring MUST probe every in-domain citizen's canonical socket path, so a
179
+ * citizen ABSENT from `socketProbes` is a wiring-invariant violation → throw.
180
+ * We never pass `null` for a pi citizen (resolvePeerFact would map it to
181
+ * `indeterminate` and strand a dormant citizen as un-resumable); a dormant
182
+ * citizen's absent socket file is probed to `dead` (ENOENT) by the wiring and
183
+ * arrives here AS `dead` → dormant → resumable.
184
+ * - out-of-domain citizen WITH a control socket at its gardenId → fail-loud
185
+ * (address ambiguity; a non-pi citizen must not own a pi control socket).
186
+ * - out-of-domain citizen without a socket → `unsupported` (via resolvePeerFact).
187
+ * - a probed gardenId with NO citizen → `SocketOnlyFact` (socket-only pi).
188
+ * A gardenId is never emitted as both a `PeerFact` and a `SocketOnlyFact`; once a
189
+ * pi meta-record writer ships, a socket-only entry is promoted to a `PeerFact`.
190
+ */
191
+ export function resolveFactList(identities: MetaIdentity[], socketProbes: SocketProbe[]): FactList {
192
+ const probeMap = new Map<string, SocketProbe>();
193
+ for (const probe of socketProbes) {
194
+ if (probeMap.has(probe.gardenId)) {
195
+ throw new Error(`resolveFactList: duplicate socket probe for gardenId ${probe.gardenId}`);
196
+ }
197
+ probeMap.set(probe.gardenId, probe);
198
+ }
199
+
200
+ const peers: PeerFact[] = [];
201
+ const consumed = new Set<string>();
202
+ for (const identity of identities) {
203
+ const gid = identity.gardenId;
204
+ if (consumed.has(gid)) {
205
+ throw new Error(`resolveFactList: duplicate meta-record for gardenId ${gid}`);
206
+ }
207
+ let socket: SocketLiveness | null;
208
+ if (isLivenessSupported(identity.backend)) {
209
+ const probe = probeMap.get(gid);
210
+ if (!probe) {
211
+ throw new Error(
212
+ `resolveFactList: in-domain citizen ${gid} (${identity.backend}) was not probed — ` +
213
+ "wiring must probe every in-domain citizen's canonical socket path (absent file → dead, never unprobed)",
214
+ );
215
+ }
216
+ socket = probe.liveness;
217
+ } else {
218
+ if (probeMap.has(gid)) {
219
+ throw new Error(
220
+ `resolveFactList: out-of-domain citizen ${gid} (${identity.backend}) has a control socket — ` +
221
+ "address ambiguity (a non-pi citizen must not own a pi control socket)",
222
+ );
223
+ }
224
+ socket = null;
225
+ }
226
+ peers.push(resolvePeerFact(identity, socket));
227
+ consumed.add(gid);
228
+ }
229
+
230
+ const socketOnly: SocketOnlyFact[] = [];
231
+ for (const probe of socketProbes) {
232
+ if (consumed.has(probe.gardenId)) continue;
233
+ socketOnly.push({
234
+ kind: "socket-only",
235
+ gardenId: probe.gardenId,
236
+ liveness: probe.liveness,
237
+ cwd: probe.cwd,
238
+ model: probe.model,
239
+ idle: probe.idle,
240
+ infoError: probe.infoError,
241
+ });
242
+ }
243
+
244
+ // Sort by gardenId with a plain `<` compare (not localeCompare) so both fact
245
+ // surfaces and the socket scan share one locale-independent ordering.
246
+ const byGardenId = (a: { gardenId: string }, b: { gardenId: string }): number =>
247
+ a.gardenId < b.gardenId ? -1 : a.gardenId > b.gardenId ? 1 : 0;
248
+ peers.sort(byGardenId);
249
+ socketOnly.sort(byGardenId);
250
+ return { peers, socketOnly };
251
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * entwurf-mailbox-guard — the IO orchestration that gates a conversational mailbox
3
+ * enqueue on the (pure) deliverability predicate (SE-1/SE-2 slice 2d). Kept SEPARATE
4
+ * from entwurf-deliverability.ts so the predicate stays pure (gate-pinnable) and only
5
+ * this thin wrapper touches the record store / capability registry / presence marker.
6
+ *
7
+ * The contract every conversational-reply enqueue site (MCP entwurf_v2 fallback,
8
+ * pi-native entwurf_v2 fallback) must use INSTEAD of calling enqueueMetaMessage
9
+ * directly:
10
+ * 1. gather facts for the target garden id (record? wakeMode? receiver active?),
11
+ * 2. ask mailboxConversationalDeliverable,
12
+ * 3. enqueue ONLY when deliverable; otherwise return a typed non-delivery with a
13
+ * reason — no .msg written, no doorbell poked.
14
+ *
15
+ * This is the SE-2 "no garbage in a dead/absent receiver's mailbox" guard and the
16
+ * SE-1 "never enqueue for a backend with no mailbox drain (pi)" guard, in one place.
17
+ * The low-level enqueueMetaMessage stays a raw primitive — it has no in-band reject;
18
+ * the refusal happens HERE, before transport, not inside it.
19
+ */
20
+
21
+ import {
22
+ type MailboxDeliverabilityFacts,
23
+ mailboxConversationalDeliverable,
24
+ receiverMarkerMatchesIdentity,
25
+ } from "./entwurf-deliverability.ts";
26
+ import {
27
+ type MetaBackendV2,
28
+ type MetaCapability,
29
+ type MetaIdentity,
30
+ type MetaReceiverMarker,
31
+ metaCapabilityFor,
32
+ readMetaIdentityByGardenId,
33
+ readMetaReceiverMarker,
34
+ } from "./meta-session.ts";
35
+
36
+ export interface MailboxGuardDeps {
37
+ /** Read the target's identity (throws when there is no backing record). */
38
+ readIdentity?: (gardenId: string) => MetaIdentity;
39
+ /** Read the target's receiver presence marker (null = no live, armed receiver). */
40
+ readReceiverMarker?: (gardenId: string) => MetaReceiverMarker | null;
41
+ /** Resolve a backend's capability (wake mode). */
42
+ capabilityFor?: (backend: MetaBackendV2) => MetaCapability;
43
+ }
44
+
45
+ /**
46
+ * Gather the deliverability facts for a target garden id, all via injected (or
47
+ * production-default) readers. recordBacked/wakeMode come from the record + capability
48
+ * registry; ownerAlive and watchArmed both derive from the receiver presence marker —
49
+ * at runtime they move together (a verified marker means a live owner that reached the
50
+ * watch-arm path; its absence/dead-owner means neither), but the pure predicate keeps
51
+ * the axes separate so each cause stays nameable. A present marker only counts when it
52
+ * actually belongs to THIS identity (garden/backend/native id match via the shared
53
+ * receiverMarkerMatchesIdentity helper) — a drifted/foreign marker is fail-closed to
54
+ * inactive, the same SSOT the v2 production mailboxDeliverabilityFor seam uses.
55
+ */
56
+ export function gatherMailboxDeliverabilityFacts(
57
+ gardenId: string,
58
+ deps: MailboxGuardDeps = {},
59
+ ): MailboxDeliverabilityFacts {
60
+ const readIdentity = deps.readIdentity ?? ((g: string) => readMetaIdentityByGardenId(g));
61
+ const readReceiverMarker = deps.readReceiverMarker ?? ((g: string) => readMetaReceiverMarker({ gardenId: g }));
62
+ const capabilityFor = deps.capabilityFor ?? metaCapabilityFor;
63
+
64
+ let recordBacked = false;
65
+ let wakeMode: string | undefined;
66
+ let identity: MetaIdentity | null = null;
67
+ try {
68
+ identity = readIdentity(gardenId);
69
+ recordBacked = true;
70
+ wakeMode = capabilityFor(identity.backend).wakeMode;
71
+ } catch {
72
+ recordBacked = false;
73
+ identity = null;
74
+ }
75
+
76
+ // No record → no identity → never matched (a marker without a record cannot be
77
+ // verified against one). With a record, the marker must agree on garden/backend/
78
+ // native id; otherwise it is a stale or foreign marker and the receiver is inactive.
79
+ const marker = readReceiverMarker(gardenId);
80
+ const matched = identity !== null && receiverMarkerMatchesIdentity(marker, identity);
81
+ return { wakeMode, recordBacked, ownerAlive: matched, watchArmed: matched };
82
+ }
83
+
84
+ export type GuardedMailboxOutcome<T> = { delivered: true; result: T } | { delivered: false; reason: string };
85
+
86
+ /**
87
+ * Enqueue a conversational reply to the target's mailbox ONLY when it is deliverable.
88
+ * When not, returns `{ delivered: false, reason }` and the injected `enqueue` is never
89
+ * called — the SE-2 guarantee that a refused send mutates nothing.
90
+ */
91
+ export function guardedMailboxEnqueue<T>(
92
+ gardenId: string,
93
+ deps: MailboxGuardDeps,
94
+ enqueue: () => T,
95
+ ): GuardedMailboxOutcome<T> {
96
+ const facts = gatherMailboxDeliverabilityFacts(gardenId, deps);
97
+ const verdict = mailboxConversationalDeliverable(facts);
98
+ if (!verdict.deliverable) return { delivered: false, reason: verdict.reason };
99
+ return { delivered: true, result: enqueue() };
100
+ }