@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.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- 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`);
|