@junghanacs/entwurf 0.12.0 → 0.12.2

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 (49) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +88 -28
  3. package/docs/setup-clean-host.md +117 -219
  4. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +454 -0
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +111 -0
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +1683 -0
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +121 -0
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +155 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +119 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +160 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +63 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +81 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +290 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +254 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +365 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +64 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +218 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +108 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-resume-marker.js +33 -0
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +116 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +125 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +184 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +237 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +216 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +164 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +66 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +1502 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +50 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +259 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +81 -0
  32. package/mcp/entwurf-bridge/dist/protocol.js +29 -0
  33. package/mcp/entwurf-bridge/start.sh +49 -7
  34. package/mcp/entwurf-bridge/test.sh +12 -3
  35. package/mcp/entwurf-bridge/tsconfig.build.json +42 -0
  36. package/package.json +30 -9
  37. package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
  38. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
  39. package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
  40. package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
  41. package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
  42. package/run.sh +150 -15
  43. package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
  44. package/scripts/check-entwurf-v2-contract.ts +6 -4
  45. package/scripts/check-meta-manifest-schema.py +145 -0
  46. package/scripts/meta-bridge-install.sh +17 -3
  47. package/scripts/meta-bridge-state.py +37 -10
  48. package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
  49. package/scripts/smoke-acp-carrier-augment-live.ts +35 -19
@@ -0,0 +1,76 @@
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
+ * The shared active-receiver atom. Every axis is required (fail-closed: an undefined
28
+ * fact is treated as false, never optimistic), and each failure names its own cause so
29
+ * a terminated-owner is never conflated with a missing record or an unarmed watch.
30
+ */
31
+ export function computeMetaReceiverActive(facts) {
32
+ if (facts.recordBacked !== true) {
33
+ return { active: false, reason: "no backing meta-record" };
34
+ }
35
+ if (facts.ownerAlive !== true) {
36
+ return { active: false, reason: "owner not alive (start-key mismatch — session exited or pid reused)" };
37
+ }
38
+ if (facts.watchArmed !== true) {
39
+ return { active: false, reason: "idle-watch not armed — a reply would enqueue with no doorbell wake" };
40
+ }
41
+ return { active: true, reason: "record backed, owner alive, watch armed" };
42
+ }
43
+ /**
44
+ * Does this presence marker actually belong to the target identity? A marker that is
45
+ * absent, or whose garden id / backend / native session id has drifted from the record,
46
+ * is NOT this receiver — fail-closed (a stale/foreign marker must never raise a dead
47
+ * target to "active"). The single source of truth for "marker ↔ identity match" shared
48
+ * by the v1 mailbox guard (gatherMailboxDeliverabilityFacts) and the v2 production
49
+ * `mailboxDeliverabilityFor` seam, so the two paths cannot drift to different meanings.
50
+ */
51
+ export function receiverMarkerMatchesIdentity(marker, identity) {
52
+ return (!!marker &&
53
+ marker.gardenId === identity.gardenId &&
54
+ marker.backend === identity.backend &&
55
+ marker.nativeSessionId === identity.nativeSessionId);
56
+ }
57
+ /**
58
+ * The conversational-mailbox enqueue gate. False (no enqueue) unless the backend is
59
+ * self-fetch AND the receiver is active. A direct-inject backend (pi) is refused
60
+ * outright — it has no mailbox drain, so an enqueue would be a silent false success.
61
+ */
62
+ export function mailboxConversationalDeliverable(facts) {
63
+ if (facts.wakeMode !== "self-fetch") {
64
+ return {
65
+ deliverable: false,
66
+ reason: `backend wake mode ${facts.wakeMode ?? "(unset)"} is not self-fetch — a mailbox enqueue would never be drained`,
67
+ };
68
+ }
69
+ const recv = computeMetaReceiverActive(facts);
70
+ return {
71
+ deliverable: recv.active,
72
+ reason: recv.active
73
+ ? `self-fetch receiver active (${recv.reason})`
74
+ : `self-fetch receiver inactive — ${recv.reason}`,
75
+ };
76
+ }
@@ -0,0 +1,121 @@
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
+ import { isNonPiGardenIdSocketConflict, resolveFactList } from "./entwurf-facts.js";
30
+ import { isLivenessSupported } from "./entwurf-v2-contract.js";
31
+ import { listAllMetaIdentities } from "./meta-session.js";
32
+ import { scanSocketProbes } from "./socket-discovery.js";
33
+ function diagnosticSortKey(d) {
34
+ switch (d.kind) {
35
+ case "meta-record-read-error":
36
+ return `0:${d.filename}`;
37
+ case "garden-id-socket-conflict":
38
+ return `1:${d.gardenId}`;
39
+ case "socket-symlink-rejected":
40
+ return `2:${d.gardenId}`;
41
+ case "malformed-socket-name":
42
+ return `3:${d.name}`;
43
+ case "socket-dir-read-error":
44
+ return "4:";
45
+ }
46
+ }
47
+ /**
48
+ * Assemble the facts-only listing. Pure over its injected deps (no direct IO) so
49
+ * the gate drives it without a filesystem; slice 4c supplies the real readdir /
50
+ * readFile / probe. Live socket probes may carry get_info runtime enrich
51
+ * (cwd/model/idle); null remains honest and renders as "not enriched".
52
+ */
53
+ export async function listEntwurfFacts(deps) {
54
+ const diagnostics = [];
55
+ // 1. meta-store axis — expected corruption becomes diagnostics, not a throw.
56
+ const { identities, errors } = listAllMetaIdentities(deps.metaEntries, deps.readRecord);
57
+ for (const e of errors) {
58
+ diagnostics.push({ kind: "meta-record-read-error", filename: e.filename, message: e.message });
59
+ }
60
+ // 2. socket axis — probe (dir sockets) ∪ (in-domain citizen canonical paths).
61
+ // Its three hazards (symlink forgery / malformed name / dir-read error) are
62
+ // folded into diagnostics here so the listing survives but never lies.
63
+ const piGids = identities.filter((i) => isLivenessSupported(i.backend)).map((i) => i.gardenId);
64
+ const scan = await scanSocketProbes(piGids, deps.socket ?? {});
65
+ const probes = scan.probes;
66
+ const socketGids = new Set(probes.map((p) => p.gardenId));
67
+ const symlinkedGids = new Set(scan.symlinkedGardenIds);
68
+ for (const gardenId of scan.symlinkedGardenIds) {
69
+ diagnostics.push({
70
+ kind: "socket-symlink-rejected",
71
+ gardenId,
72
+ message: "control socket is a symlink — never probed (it could redirect to another session's listener and forge " +
73
+ "an alive liveness for this gardenId); a citizen owning it is treated as dead (dormant), a record-less one dropped.",
74
+ });
75
+ }
76
+ for (const name of scan.malformedNames) {
77
+ diagnostics.push({
78
+ kind: "malformed-socket-name",
79
+ name,
80
+ message: "control-socket filename is not a garden id — no citizen to correlate to; dropped from the listing.",
81
+ });
82
+ }
83
+ if (scan.dirError !== null) {
84
+ diagnostics.push({
85
+ kind: "socket-dir-read-error",
86
+ message: `control-socket directory unreadable (socket axis incomplete; meta-record citizens still listed): ${scan.dirError}`,
87
+ });
88
+ }
89
+ // 3. pre-quarantine non-pi citizens that collide with a control socket. The
90
+ // predicate is SHARED with the v2 decider (isNonPiGardenIdSocketConflict) so
91
+ // listing and dispatch cannot drift, and it unions socketGids with the
92
+ // symlinkedGids: a symlinked socket is never probed (absent from socketGids),
93
+ // so the old socketGids-only check let a non-pi citizen with a forged
94
+ // (symlinked) socket survive as a clean PeerFact while the legacy send path
95
+ // still followed the symlink — the gap this closes.
96
+ const conflictGids = new Set();
97
+ for (const id of identities) {
98
+ if (isNonPiGardenIdSocketConflict(id.backend, id.gardenId, socketGids, symlinkedGids)) {
99
+ conflictGids.add(id.gardenId);
100
+ diagnostics.push({
101
+ kind: "garden-id-socket-conflict",
102
+ gardenId: id.gardenId,
103
+ backend: id.backend,
104
+ message: `non-pi citizen (${id.backend}) shares its gardenId with a control socket (real or symlinked) — address ` +
105
+ "ambiguity; both the citizen and the socket are quarantined from the listing.",
106
+ });
107
+ }
108
+ }
109
+ // 4. resolveFactList over CLEAN inputs only. Its throws (duplicate identity /
110
+ // unprobed in-domain citizen) are impossible wiring invariants — left to
111
+ // fire as the last line of defense, never caught here.
112
+ const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
113
+ const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
114
+ const facts = resolveFactList(cleanIdentities, cleanProbes);
115
+ diagnostics.sort((a, b) => {
116
+ const ka = diagnosticSortKey(a);
117
+ const kb = diagnosticSortKey(b);
118
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
119
+ });
120
+ return { facts, diagnostics };
121
+ }
@@ -0,0 +1,155 @@
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
+ import { factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.js";
30
+ /**
31
+ * Compose a `PeerFact` from a citizen's identity and an optional socket probe.
32
+ *
33
+ * `socket` is the 3-value control-socket result for an IN-DOMAIN backend (pi),
34
+ * or `null` when no probe was taken (out-of-domain backend, or in-domain with no
35
+ * socket found). `factLivenessOf` resolves the 4-value fact:
36
+ * - out-of-domain backend → `unsupported` (R1, regardless of `socket`)
37
+ * - in-domain, socket present → that socket value
38
+ * - in-domain, socket null → `indeterminate` (no proof, never `dead`)
39
+ *
40
+ * Pure: same inputs → same output, no IO.
41
+ */
42
+ export function resolvePeerFact(identity, socket) {
43
+ return {
44
+ gardenId: identity.gardenId,
45
+ backend: identity.backend,
46
+ nativeSessionId: identity.nativeSessionId,
47
+ cwd: identity.cwd,
48
+ model: identity.model,
49
+ parentGardenId: identity.parentGardenId,
50
+ isEntwurf: identity.isEntwurf,
51
+ createdAt: identity.createdAt,
52
+ recordUpdatedAt: identity.recordUpdatedAt,
53
+ liveness: factLivenessOf(identity.backend, socket),
54
+ };
55
+ }
56
+ /**
57
+ * A non-pi RECORD whose gardenId collides with a control socket — a real (probed)
58
+ * one OR a symlinked/forged one. The gardenId is the universal address (동결결정3),
59
+ * so a non-pi citizen sharing it with a socket means a send-path that reaches the
60
+ * socket first hits a DIFFERENT receiver than the record names — an address split.
61
+ * Both the citizen and the socket are quarantined from the facts listing.
62
+ *
63
+ * The union `socketGids ∪ symlinkedGardenIds` is load-bearing: `socketGids` are
64
+ * gids with a real probed `*.sock`, but `symlinkedGardenIds` are NEVER probed (P1)
65
+ * and so are absent from `socketGids`. Looking at `socketGids` alone (the
66
+ * fact-provider:125 gap this closes) let a non-pi citizen with a *symlinked* socket
67
+ * survive as a clean PeerFact while the legacy send path still followed the symlink
68
+ * to a forged receiver. Both axes claim the gid → both must quarantine it.
69
+ *
70
+ * SCOPE: this is the RECORD-side, non-pi conflict only — shared by the fact-provider
71
+ * (listing) and the v2 decider (dispatch) so the two cannot drift (4c "재유도 금지"
72
+ * 동형; only the observation-bit source is parameterized). A pi citizen whose own
73
+ * canonical socket is a symlink is NOT this predicate's concern — that is a
74
+ * target-specific lstat conflict the decider's `inspectTargetControlSocket` raises
75
+ * as `address-conflict`, kept deliberately separate (GPT 1차 검수 C).
76
+ */
77
+ export function isNonPiGardenIdSocketConflict(backend, gardenId, socketGids, symlinkedGardenIds) {
78
+ return !isLivenessSupported(backend) && (socketGids.has(gardenId) || symlinkedGardenIds.has(gardenId));
79
+ }
80
+ /**
81
+ * Pure union of the meta-store axis (citizens) and the socket axis (probes) into
82
+ * a facts-only listing. No IO — slice-3 wiring reads the meta-store and probes
83
+ * the sockets, then injects both lists.
84
+ *
85
+ * Correlation key = `gardenId` (동결결정3; `nativeSessionId` is backend-local, not
86
+ * a global key). Rules frozen 2026-06-11 (GPT힣 + Fable):
87
+ * - in-domain (pi) citizen: liveness = its socket probe (3-value preserved).
88
+ * The wiring MUST probe every in-domain citizen's canonical socket path, so a
89
+ * citizen ABSENT from `socketProbes` is a wiring-invariant violation → throw.
90
+ * We never pass `null` for a pi citizen (resolvePeerFact would map it to
91
+ * `indeterminate` and strand a dormant citizen as un-resumable); a dormant
92
+ * citizen's absent socket file is probed to `dead` (ENOENT) by the wiring and
93
+ * arrives here AS `dead` → dormant → resumable.
94
+ * - out-of-domain citizen WITH a control socket at its gardenId → fail-loud
95
+ * (address ambiguity; a non-pi citizen must not own a pi control socket).
96
+ * - out-of-domain citizen without a socket → `unsupported` (via resolvePeerFact).
97
+ * - a probed gardenId with NO citizen → `SocketOnlyFact` (socket-only pi).
98
+ * A gardenId is never emitted as both a `PeerFact` and a `SocketOnlyFact`; once a
99
+ * pi meta-record writer ships, a socket-only entry is promoted to a `PeerFact`.
100
+ */
101
+ export function resolveFactList(identities, socketProbes) {
102
+ const probeMap = new Map();
103
+ for (const probe of socketProbes) {
104
+ if (probeMap.has(probe.gardenId)) {
105
+ throw new Error(`resolveFactList: duplicate socket probe for gardenId ${probe.gardenId}`);
106
+ }
107
+ probeMap.set(probe.gardenId, probe);
108
+ }
109
+ const peers = [];
110
+ const consumed = new Set();
111
+ for (const identity of identities) {
112
+ const gid = identity.gardenId;
113
+ if (consumed.has(gid)) {
114
+ throw new Error(`resolveFactList: duplicate meta-record for gardenId ${gid}`);
115
+ }
116
+ let socket;
117
+ if (isLivenessSupported(identity.backend)) {
118
+ const probe = probeMap.get(gid);
119
+ if (!probe) {
120
+ throw new Error(`resolveFactList: in-domain citizen ${gid} (${identity.backend}) was not probed — ` +
121
+ "wiring must probe every in-domain citizen's canonical socket path (absent file → dead, never unprobed)");
122
+ }
123
+ socket = probe.liveness;
124
+ }
125
+ else {
126
+ if (probeMap.has(gid)) {
127
+ throw new Error(`resolveFactList: out-of-domain citizen ${gid} (${identity.backend}) has a control socket — ` +
128
+ "address ambiguity (a non-pi citizen must not own a pi control socket)");
129
+ }
130
+ socket = null;
131
+ }
132
+ peers.push(resolvePeerFact(identity, socket));
133
+ consumed.add(gid);
134
+ }
135
+ const socketOnly = [];
136
+ for (const probe of socketProbes) {
137
+ if (consumed.has(probe.gardenId))
138
+ continue;
139
+ socketOnly.push({
140
+ kind: "socket-only",
141
+ gardenId: probe.gardenId,
142
+ liveness: probe.liveness,
143
+ cwd: probe.cwd,
144
+ model: probe.model,
145
+ idle: probe.idle,
146
+ infoError: probe.infoError,
147
+ });
148
+ }
149
+ // Sort by gardenId with a plain `<` compare (not localeCompare) so both fact
150
+ // surfaces and the socket scan share one locale-independent ordering.
151
+ const byGardenId = (a, b) => a.gardenId < b.gardenId ? -1 : a.gardenId > b.gardenId ? 1 : 0;
152
+ peers.sort(byGardenId);
153
+ socketOnly.sort(byGardenId);
154
+ return { peers, socketOnly };
155
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * entwurf-peers-render — the PURE render/payload layer for the MCP `entwurf_peers`
3
+ * surface (0.11 Stage 0 step 4, slice 4c). The MCP handler stays thin: it does IO
4
+ * (readdir the meta-store + probe sockets via `listEntwurfFacts`) and then calls
5
+ * THIS to shape the text + JSON. No IO here, so the gate drives it without a
6
+ * filesystem and the SAME facts can feed pi-native / doctor / v2 dispatch later
7
+ * (a handler that did its own brain work would deny them that reuse).
8
+ *
9
+ * Three hard rules carried from the frozen contract (동결결정 10):
10
+ * - FACTS ONLY, NO VERB-ROUTING. Neither the payload nor the text may carry a
11
+ * `sendable`/`resumable`/`dispatch`/`action`/`transport`/`mailboxDeliverable`
12
+ * field or word. Whether a target is sent-to or resumed is computed at call
13
+ * time by the entwurf_v2 dispatch table from `liveness` — baking it into the
14
+ * listing is exactly what makes `entwurf_peers` lie. The gate scans both the
15
+ * JSON keys AND the text for the forbidden words (a section title like
16
+ * "resumable peers" leaks routing that a key scan would miss).
17
+ * - THREE SECTIONS, NEVER MERGED. `peers` (citizens, 4-value liveness) and
18
+ * `socketOnly` (record-less sockets, 3-value liveness) are DISTINCT subjects
19
+ * (slice 2's two-array split); `diagnostics` is a third. Merging them into one
20
+ * array collapses the subject separation at the surface.
21
+ * - LEGACY `sessions` IS A PROJECTION OF FACTS, not a second scan. We do NOT
22
+ * re-run the old `getLiveSessions` (a separate live-socket scan would bypass
23
+ * the provider's quarantine — a non-pi citizen colliding with a socket, which
24
+ * `listEntwurfFacts` removes from BOTH normal arrays, could reappear in
25
+ * `sessions`). `sessions` is derived from the SAME facts: alive pi citizens +
26
+ * alive socket-only entries. Its socketPath is built by `controlSocketPath`
27
+ * (the SSOT helper), never re-concatenated, so the filename↔gardenId
28
+ * correlation authority (동결결정3) cannot drift between scan and render.
29
+ */
30
+ import { controlSocketPath } from "./socket-discovery.js";
31
+ /**
32
+ * Derive the legacy `sessions` projection from the facts: an active session is an
33
+ * alive pi citizen OR an alive record-less socket. `peers` and `socketOnly` are
34
+ * gid-disjoint (resolveFactList guarantees a gid is in one or the other, never
35
+ * both), so the concatenation needs no dedup. socketPath via `controlSocketPath`
36
+ * (SSOT). Sorted by sessionId for determinism.
37
+ */
38
+ function deriveSessions(peers, socketOnly, controlDir) {
39
+ const sessions = [];
40
+ for (const p of peers) {
41
+ if (p.backend === "pi" && p.liveness === "alive") {
42
+ sessions.push({ sessionId: p.gardenId, socketPath: controlSocketPath(p.gardenId, controlDir) });
43
+ }
44
+ }
45
+ for (const s of socketOnly) {
46
+ if (s.liveness === "alive") {
47
+ sessions.push({ sessionId: s.gardenId, socketPath: controlSocketPath(s.gardenId, controlDir) });
48
+ }
49
+ }
50
+ sessions.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
51
+ return sessions;
52
+ }
53
+ function renderPeerLine(p) {
54
+ const model = p.model ?? "(unknown)";
55
+ const entwurf = p.isEntwurf ? " entwurf" : "";
56
+ return `- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} cwd=${p.cwd} model=${model}${entwurf}`;
57
+ }
58
+ function renderSocketOnlyLine(s) {
59
+ // Null enrich is "(not enriched)" — NOT "(unknown)", which would read as
60
+ // identity-unknown rather than not-yet-fetched / not available for this socket.
61
+ const cwd = s.cwd ?? "(not enriched)";
62
+ const model = s.model ?? "(not enriched)";
63
+ const idle = s.idle === null ? "" : ` idle=${s.idle ? "yes" : "no"}`;
64
+ const infoError = s.infoError === null ? "" : ` infoError=${s.infoError}`;
65
+ return `- ${s.gardenId} liveness=${s.liveness} cwd=${cwd} model=${model}${idle}${infoError}`;
66
+ }
67
+ function renderDiagnosticLine(d) {
68
+ switch (d.kind) {
69
+ case "meta-record-read-error":
70
+ return `- meta-record-read-error ${d.filename}: ${d.message}`;
71
+ case "garden-id-socket-conflict":
72
+ return `- garden-id-socket-conflict ${d.gardenId} (backend=${d.backend}): ${d.message}`;
73
+ case "socket-symlink-rejected":
74
+ return `- socket-symlink-rejected ${d.gardenId}: ${d.message}`;
75
+ case "malformed-socket-name":
76
+ return `- malformed-socket-name ${d.name}: ${d.message}`;
77
+ case "socket-dir-read-error":
78
+ return `- socket-dir-read-error: ${d.message}`;
79
+ }
80
+ }
81
+ function compactLines(lines, max = 32) {
82
+ if (lines.length <= max)
83
+ return lines;
84
+ const omitted = lines.length - max;
85
+ return [` … (${omitted} older entries omitted; showing latest ${max})`, ...lines.slice(-max)];
86
+ }
87
+ function section(title, lines, opts = {}) {
88
+ // Empty sections render "(none)" — hiding them would erase the honesty the
89
+ // listing exists to provide (especially diagnostics: "(none)" is a trust
90
+ // signal, and an `unsupported` peer must never be silently dropped).
91
+ const rendered = opts.compact ? compactLines(lines) : lines;
92
+ return rendered.length > 0 ? `${title}\n${rendered.join("\n")}` : `${title}\n (none)`;
93
+ }
94
+ /**
95
+ * Shape the facts into the `entwurf_peers` text + JSON. Pure over its inputs.
96
+ * `controlDir` is the same directory the socket scan used — passing it here (not
97
+ * re-deriving) keeps the socketPath SSOT.
98
+ */
99
+ export function renderEntwurfPeers(result, controlDir) {
100
+ const { peers, socketOnly } = result.facts;
101
+ const { diagnostics } = result;
102
+ const sessions = deriveSessions(peers, socketOnly, controlDir);
103
+ const text = [
104
+ section("Garden citizens (meta-record):", peers.map(renderPeerLine), { compact: true }),
105
+ "",
106
+ section("Socket-only control sockets (no meta-record):", socketOnly.map(renderSocketOnlyLine), { compact: true }),
107
+ "",
108
+ section("Diagnostics:", diagnostics.map(renderDiagnosticLine)),
109
+ ].join("\n");
110
+ const payload = {
111
+ controlDir,
112
+ count: sessions.length,
113
+ sessions,
114
+ peers,
115
+ socketOnly,
116
+ diagnostics,
117
+ };
118
+ return { text, payload };
119
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * entwurf-preflight — 0.11 Stage 0 (2): the SINGLE trust/launch decision owner.
3
+ *
4
+ * The controlled-launch surface, the global `project_trust` handler, and any
5
+ * MCP fact tool ALL consume this module's outcome — nobody else re-derives a
6
+ * prefix, re-reads `trust.json`, or re-probes trust inputs. pi's raw trust
7
+ * semantics are followed by importing pi's PUBLIC root exports directly (frozen
8
+ * decision 9, 재구현 금지): `ProjectTrustStore` (the canonical `trust.json`
9
+ * reader, which itself canonicalizes the cwd and takes a `proper-lockfile` on
10
+ * every read) and `hasTrustRequiringProjectResources` (the trust-input probe).
11
+ * We never copy pi's trust detail — if pi changes it, this import tracks it.
12
+ *
13
+ * The returned `PreflightOutcome` is deliberately RICH, not just {kind,reason}:
14
+ * a fact tool must explain *why* a cwd is approved and *what* it may load
15
+ * without re-running the probe, and an error/handler must name the matched root
16
+ * or the trust-store value. Thin outcomes would push callers to recompute, which
17
+ * is exactly the re-derivation this module exists to prevent.
18
+ *
19
+ * trust ≠ discovery: this decision touches the store for a SINGLE launch-time
20
+ * cwd only. `peers`/`who-can` discovery does not call here (frozen decision 4).
21
+ *
22
+ * Precedence (frozen decision 8) — saved distrust is stronger than a prefix
23
+ * allow; a prefix only promotes the UNDECIDED (null) case; no-trust-inputs is
24
+ * trusted but needs no launch arg; everything else is fail-fast:
25
+ *
26
+ * saved === false → deny (explicit distrust; store wins)
27
+ * saved === true → approve (saved trust → internal --approve)
28
+ * null + prefix match → approve (operator prefix promotes null→yes)
29
+ * null + no trust inputs → trusted-no-arg (no trust-gated input — pi 0.79.x
30
+ * excludes AGENTS.md/CLAUDE.md, so
31
+ * context files may still be loaded)
32
+ * else (null + inputs) → fail-fast (unknown/untrusted controlled launch)
33
+ *
34
+ * Injection (frozen decision 4): `agentDir` defaults to `getAgentDir()` but is
35
+ * overridable so tests point `ProjectTrustStore` at a temp dir (or set
36
+ * `PI_CODING_AGENT_DIR`, same isolation as 0.10.0) and never read or dirty the
37
+ * operator's real `~/.pi/agent/trust.json`. `prefixRoots` is an OPERATOR-policy
38
+ * input with NO package default (frozen decision 7): a public package must not
39
+ * hardcode a broad auto-approve, so an empty roots list means "no prefix
40
+ * promotion" — the caller injects the operator's roots (e.g. `~/repos/gh`).
41
+ */
42
+ import { realpathSync } from "node:fs";
43
+ import { homedir } from "node:os";
44
+ import { isAbsolute, join, resolve, sep } from "node:path";
45
+ import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore, } from "@earendil-works/pi-coding-agent";
46
+ /**
47
+ * Render the human-facing reason a controlled launch was refused (N3b). This is
48
+ * a PURE formatter over a deny outcome — it does NOT touch a launcher, a socket,
49
+ * or pi; wiring it into the controlled-launch surface is bucket B (step 5), not
50
+ * here. The launcher/handler/error layers all call this so the refusal text is
51
+ * identical everywhere and always sourced from F5a evidence.
52
+ *
53
+ * The inherited-false branch is the one that matters: an operator distrust on an
54
+ * ANCESTOR (e.g. `~/repos/gh`) silently denies a child cwd, and an agent CANNOT
55
+ * lift it — that is an intended security property (N3a: a controlled launch
56
+ * short-circuits on `trustOverride` and never reaches the human-only active
57
+ * prompt). So the message must (1) name the inherited source (`inheritedFrom`)
58
+ * and (2) give the only real remedy: open an interactive pi AT the cwd and
59
+ * approve, which writes a direct child trust that beats the inherited decision
60
+ * (the "escape direction" proven in check-pi-preflight #13b).
61
+ */
62
+ export function formatPreflightDenial(outcome) {
63
+ const cwd = outcome.canonicalCwd;
64
+ const openHere = `open an interactive pi at ${cwd} and approve when prompted`;
65
+ if (outcome.reason === "saved-false") {
66
+ if (outcome.trustStoreInherited && outcome.trustStoreEntryPath !== undefined) {
67
+ return (`Controlled launch refused: ${cwd} is distrusted by inheritance from ${outcome.trustStoreEntryPath} ` +
68
+ `(an ancestor carries a saved "no"). An agent cannot self-promote trust — this is an intended ` +
69
+ `security property. To trust THIS cwd only, ${openHere}; that writes a direct decision for ${cwd} ` +
70
+ `which overrides the inherited one.`);
71
+ }
72
+ return `Controlled launch refused: ${cwd} is explicitly distrusted (a saved "no" on this directory). To change it, ${openHere}.`;
73
+ }
74
+ // fail-fast: undecided + trust inputs + no operator prefix root.
75
+ return (`Controlled launch refused: ${cwd} is untrusted — it has trust inputs but no saved decision and no ` +
76
+ `operator prefix root. Refusing a silent degraded launch. Either add ${cwd} under an operator prefix ` +
77
+ `root, or ${openHere}.`);
78
+ }
79
+ /**
80
+ * Normalize a path the way pi resolves one before the trust store sees it:
81
+ * expand a leading `~`, make it absolute (`path.resolve`), then `realpathSync`;
82
+ * on a resolve failure fall back to the RESOLVED absolute path (not the raw
83
+ * input), so a not-yet-existing root still compares on an absolute basis.
84
+ */
85
+ function normalizePath(p) {
86
+ let expanded = p;
87
+ if (p === "~") {
88
+ expanded = homedir();
89
+ }
90
+ else if (p.startsWith("~/")) {
91
+ expanded = join(homedir(), p.slice(2));
92
+ }
93
+ const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
94
+ try {
95
+ return realpathSync(abs);
96
+ }
97
+ catch {
98
+ return abs;
99
+ }
100
+ }
101
+ /**
102
+ * Return the canonical operator root that contains `canonicalCwd`, by canonical
103
+ * path + separator boundary (frozen decision 7). `/org` matches `/org/a` but NOT
104
+ * `/org2` — never a bare `startsWith`. Roots are normalized the same as the cwd.
105
+ */
106
+ function matchedPrefixRoot(canonicalCwd, roots) {
107
+ for (const root of roots) {
108
+ const r = normalizePath(root);
109
+ if (canonicalCwd === r || canonicalCwd.startsWith(r + sep)) {
110
+ return r;
111
+ }
112
+ }
113
+ return undefined;
114
+ }
115
+ /** Decide trust for a single controlled-launch cwd. See module header. */
116
+ export function preflight(input) {
117
+ const agentDir = input.agentDir ?? getAgentDir();
118
+ const prefixRoots = input.prefixRoots ?? [];
119
+ const canonicalCwd = normalizePath(input.cwd);
120
+ const store = new ProjectTrustStore(agentDir);
121
+ // getEntry, not get: get() throws away which path decided. getEntry returns
122
+ // `{ path, decision } | null` — the nearest ancestor (or the cwd itself)
123
+ // carrying an explicit decision. We recover the same decision value AND the
124
+ // deciding path, so the fact/handler/error layers can name an inherited
125
+ // source without re-walking the store. entry.path is pi-canonical, the same
126
+ // realpath axis as `canonicalCwd`, so an entry on the cwd ITSELF compares
127
+ // equal (= direct) and an ancestor compares unequal (= inherited).
128
+ const entry = store.getEntry(input.cwd);
129
+ const trustStoreDecision = entry?.decision ?? null;
130
+ const trustStoreInherited = entry !== null && entry.path !== canonicalCwd;
131
+ // Computed unconditionally: a fact tool must report what a prefix-approved
132
+ // cwd could load, so the probe runs even when a prefix already decides.
133
+ const hasTrustInputs = hasTrustRequiringProjectResources(input.cwd);
134
+ const matched = matchedPrefixRoot(canonicalCwd, prefixRoots);
135
+ const evidence = {
136
+ launchArgs: [],
137
+ trustStoreDecision,
138
+ trustStoreInherited,
139
+ hasTrustInputs,
140
+ canonicalCwd,
141
+ ...(entry !== null ? { trustStoreEntryPath: entry.path } : {}),
142
+ ...(matched !== undefined ? { matchedPrefixRoot: matched } : {}),
143
+ };
144
+ // Explicit distrust wins over everything, including a prefix match.
145
+ if (trustStoreDecision === false) {
146
+ return { ...evidence, kind: "deny", reason: "saved-false" };
147
+ }
148
+ if (trustStoreDecision === true) {
149
+ return { ...evidence, kind: "approve", reason: "saved-true", launchArgs: ["--approve"] };
150
+ }
151
+ // trustStoreDecision === null (undecided): a prefix promotes it; otherwise the
152
+ // absence of trust inputs makes it trusted-but-no-arg; otherwise fail-fast.
153
+ if (matched !== undefined) {
154
+ return { ...evidence, kind: "approve", reason: "prefix-match", launchArgs: ["--approve"] };
155
+ }
156
+ if (!hasTrustInputs) {
157
+ return { ...evidence, kind: "trusted-no-arg", reason: "no-trust-inputs" };
158
+ }
159
+ return { ...evidence, kind: "deny", reason: "fail-fast" };
160
+ }