@junghanacs/entwurf 0.12.6 → 0.12.7

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 (92) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +22 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +18 -13
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -5
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +756 -214
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +36 -2
  79. package/scripts/register-pi-package.py +37 -3
  80. package/scripts/register-pi-provider.py +287 -0
  81. package/scripts/smoke-agy-hooks-state.sh +172 -0
  82. package/scripts/smoke-agy-install-state.sh +660 -0
  83. package/scripts/smoke-agy-native-push-live.ts +243 -0
  84. package/scripts/smoke-agy-statusline-state.sh +300 -0
  85. package/scripts/smoke-meta-async-drift.sh +9 -2
  86. package/scripts/smoke-meta-install-state.sh +20 -0
  87. package/scripts/smoke-pi-provider-state.sh +182 -0
  88. package/scripts/smoke-user-scope-citizen.sh +62 -0
  89. package/scripts/with-dist-lock.sh +81 -0
  90. package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
  91. package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
  92. package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
@@ -0,0 +1,364 @@
1
+ /**
2
+ * check-agy-sender-identity — deterministic gate for the #46 sender-identity lane: the agy
3
+ * hook WRITES the marker, and the bridge's resolver READS it into exactly one identity or
4
+ * refuses.
5
+ *
6
+ * The bug this closes: a birthed agy conversation called entwurf_v2 for real (MCP tool call,
7
+ * not shell-out) and its message still landed as `external-mcp/unknown-host`, non-replyable.
8
+ * The bridge held the owner pid but had no marker to look up: the hook wrote only the
9
+ * meta-record, and the resolver searched the `claude-code` marker directory alone.
10
+ *
11
+ * BEHAVIORAL, not source-regex: the resolver runs against isolated marker/record stores, and
12
+ * the hook runs as a real child process (so the marker's ownerPid is THIS gate's pid — the
13
+ * same parent-pid join the bridge performs in production).
14
+ *
15
+ * Rows:
16
+ * WRITER (real subprocess)
17
+ * - hook writes an antigravity marker keyed by its PARENT pid, matching the record it minted
18
+ * - upsert failure writes NO marker (record authority first — never a garden-id with no record)
19
+ * RESOLVER (real store)
20
+ * - 0 markers → null (anonymous; the caller decides whether that is fatal)
21
+ * - 1 trusted → that identity, on EITHER backend (the claude-code-only lookup was the bug)
22
+ * - marker with no backing record → null (a hint is not an identity)
23
+ * - marker whose nativeSessionId drifted from its record → null
24
+ * - 2 distinct live identities on one owner pid → THROW (never guess, never downgrade)
25
+ * - 2 markers naming the SAME identity → NOT a conflict (an older release wrote parent AND
26
+ * grandparent markers; both may still sit on disk)
27
+ * DOMAIN (the rail the bridge picks the replyable fact from)
28
+ * - antigravity is native-push, claude-code is not — so an agy sender's replyable comes from
29
+ * the adapter probe, never from a mailbox watch it can never arm (보정①)
30
+ */
31
+
32
+ import assert from "node:assert/strict";
33
+ import { execFileSync } from "node:child_process";
34
+ import * as fs from "node:fs";
35
+ import * as os from "node:os";
36
+ import * as path from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ import { nativePushSupported } from "../pi-extensions/lib/entwurf-v2-contract.ts";
39
+ import {
40
+ EntwurfSenderIdentityAmbiguityError,
41
+ META_SENDER_BACKENDS,
42
+ probeNativeSenderAlive,
43
+ resolveTrustedMetaSenderIdentity,
44
+ } from "../pi-extensions/lib/meta-sender-identity.ts";
45
+ import { type MetaIdentity, upsertMetaSession, writeMetaSenderMarker } from "../pi-extensions/lib/meta-session.ts";
46
+ import type { NativePushAdapter } from "../pi-extensions/lib/native-push/adapter.ts";
47
+
48
+ let passed = 0;
49
+ function ok(label: string, cond: boolean): void {
50
+ assert.ok(cond, label);
51
+ console.log(` ok ${label}`);
52
+ passed++;
53
+ }
54
+
55
+ const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
56
+ const HOOK = path.join(REPO_DIR, "scripts", "agy-imprint.ts");
57
+
58
+ // Isolated stores — this gate never touches the operator's real garden. XDG_STATE_HOME is
59
+ // isolated too: the hook's own log lives under it, and a gate that appends fabricated
60
+ // "upsert-failed" lines to the operator's real agy-imprint.log would be planting false
61
+ // evidence in the exact file a live agy debugging session reads.
62
+ const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-sender-identity-"));
63
+ const SESSIONS_DIR = path.join(ROOT, "meta-sessions");
64
+ const SENDERS_DIR = path.join(ROOT, "meta-senders");
65
+ const STATE_DIR = path.join(ROOT, "state");
66
+ process.env.ENTWURF_META_SESSIONS_DIR = SESSIONS_DIR;
67
+ process.env.ENTWURF_META_SENDERS_DIR = SENDERS_DIR;
68
+ process.env.XDG_STATE_HOME = STATE_DIR;
69
+
70
+ // The owner pid the markers are keyed to. process.pid is alive by construction, so
71
+ // writeMetaSenderMarker's start-key is a real one and the read-side pid-reuse guard passes —
72
+ // exactly as an agy host pid does in production.
73
+ const OWNER = process.pid;
74
+ const resolve = () => resolveTrustedMetaSenderIdentity({ ownerPids: [OWNER], sendersDir: SENDERS_DIR });
75
+
76
+ function clearMarkers(): void {
77
+ fs.rmSync(SENDERS_DIR, { recursive: true, force: true });
78
+ }
79
+
80
+ try {
81
+ // ── WRITER: the real hook, as a real child process ───────────────────────
82
+ {
83
+ // A synthetic id on purpose: a REAL conversation id here would write plausible-looking
84
+ // imprint lines that a later reader could mistake for live evidence.
85
+ const conversationId = "00000000-gate-0000-0000-agyimprint01";
86
+ const payload = JSON.stringify({
87
+ conversationId,
88
+ workspacePaths: [REPO_DIR],
89
+ modelName: "gemini-3.1-pro-low",
90
+ });
91
+ const stdout = execFileSync(
92
+ process.execPath,
93
+ ["--experimental-strip-types", "--disable-warning=ExperimentalWarning", HOOK],
94
+ { input: payload, encoding: "utf8", env: process.env },
95
+ );
96
+ ok(
97
+ "hook still prints the PreInvocation neutral response (the agy loop must never break)",
98
+ stdout.trim() === '{"injectSteps":[]}',
99
+ );
100
+
101
+ // The hook's parent IS this gate — the same shape as production, where the hook's parent
102
+ // is the agy host that also parents the bridge. So the marker must land under OUR pid.
103
+ const markerPath = path.join(SENDERS_DIR, "antigravity", `${OWNER}.json`);
104
+ ok("hook writes an antigravity sender marker keyed by its PARENT pid", fs.existsSync(markerPath));
105
+
106
+ const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
107
+ ok("marker carries backend=antigravity", marker.backend === "antigravity");
108
+ ok("marker's nativeSessionId is the agy conversationId", marker.nativeSessionId === conversationId);
109
+ ok(
110
+ "marker is start-key guarded (pid alone is reused; pid+start is boot-unique)",
111
+ typeof marker.ownerStartKey === "string" && marker.ownerStartKey.length > 0,
112
+ );
113
+
114
+ // End-to-end: the bridge's resolver, over what the hook actually wrote.
115
+ const trusted = resolve();
116
+ ok("resolver turns the hook's marker into ONE trusted identity", trusted !== null);
117
+ ok("resolved identity is the agy citizen the hook minted", trusted?.identity.gardenId === marker.gardenId);
118
+ ok(
119
+ "resolved identity carries backend=antigravity (this is what stops external-mcp/unknown-host)",
120
+ trusted?.identity.backend === "antigravity",
121
+ );
122
+ }
123
+
124
+ // ── TRACE CONTRACT: the owner-topology trace is OPT-IN, and stays that way ────
125
+ // PreInvocation runs before EVERY model turn. A trace left on by default would walk /proc six
126
+ // times per turn and append to the imprint log forever — an unbounded diagnostic in the file
127
+ // whose job is to hold birth evidence. It earned its place while proving the pid join and must
128
+ // now cost nothing until someone asks for it, so the DEFAULT is what this gate owns.
129
+ {
130
+ const logFile = path.join(STATE_DIR, "entwurf", "agy-imprint.log");
131
+ const runHook = (trace: boolean) =>
132
+ execFileSync(process.execPath, ["--experimental-strip-types", "--disable-warning=ExperimentalWarning", HOOK], {
133
+ input: JSON.stringify({ conversationId: "00000000-gate-0000-0000-tracecontract", workspacePaths: [REPO_DIR] }),
134
+ encoding: "utf8",
135
+ env: trace ? { ...process.env, ENTWURF_AGY_TRACE_OWNER: "1" } : process.env,
136
+ });
137
+
138
+ fs.rmSync(logFile, { force: true });
139
+ runHook(false);
140
+ ok(
141
+ "trace: default env writes NO ancestry line (no per-turn /proc walk, no unbounded log)",
142
+ !fs.readFileSync(logFile, "utf8").includes("pids conversationId="),
143
+ );
144
+ ok(
145
+ "trace: the useful evidence still lands (the marker write is what a healthy host needs)",
146
+ fs.readFileSync(logFile, "utf8").includes("sender marker "),
147
+ );
148
+
149
+ fs.rmSync(logFile, { force: true });
150
+ runHook(true);
151
+ ok(
152
+ "trace: ENTWURF_AGY_TRACE_OWNER=1 writes the ancestry line (the only way to say WHY a sender resolves anonymous)",
153
+ /pids conversationId=.* ppid=\d+ chain=/.test(fs.readFileSync(logFile, "utf8")),
154
+ );
155
+ }
156
+
157
+ // ── WRITER negative: no record → no marker ───────────────────────────────
158
+ {
159
+ clearMarkers();
160
+ // A file where the record dir must be: upsertMetaSession cannot write, so it throws.
161
+ const brokenRoot = path.join(ROOT, "broken-sessions");
162
+ fs.writeFileSync(brokenRoot, "not a directory\n");
163
+ execFileSync(process.execPath, ["--experimental-strip-types", "--disable-warning=ExperimentalWarning", HOOK], {
164
+ input: JSON.stringify({ conversationId: "dead-beef-0000", workspacePaths: [REPO_DIR] }),
165
+ encoding: "utf8",
166
+ env: { ...process.env, ENTWURF_META_SESSIONS_DIR: brokenRoot },
167
+ });
168
+ ok(
169
+ "upsert failure writes NO sender marker (record authority first — no garden-id without a record)",
170
+ !fs.existsSync(path.join(SENDERS_DIR, "antigravity")),
171
+ );
172
+
173
+ // The gate must be invisible to the operator's garden: the hook's log follows
174
+ // XDG_STATE_HOME, so it has to land in the isolated root, never in ~/.local/state.
175
+ ok(
176
+ "hook's imprint log stays inside the isolated XDG_STATE_HOME (no writes to the operator's real log)",
177
+ fs.existsSync(path.join(STATE_DIR, "entwurf", "agy-imprint.log")),
178
+ );
179
+ }
180
+ clearMarkers();
181
+ ok("no marker → null (anonymous; the caller decides whether that is fatal)", resolve() === null);
182
+
183
+ // A trusted identity on EITHER backend. The claude-code-only lookup is the bug — an agy
184
+ // citizen was invisible to the very bridge its own conversation spawned.
185
+ for (const backend of META_SENDER_BACKENDS) {
186
+ clearMarkers();
187
+ const nativeSessionId = `native-${backend}`;
188
+ const record = upsertMetaSession({ input: { backend, nativeSessionId, cwd: REPO_DIR } });
189
+ writeMetaSenderMarker({
190
+ backend,
191
+ gardenId: record.record.gardenId,
192
+ nativeSessionId,
193
+ cwd: REPO_DIR,
194
+ ownerPid: OWNER,
195
+ sendersDir: SENDERS_DIR,
196
+ });
197
+ const trusted = resolve();
198
+ ok(
199
+ `backend ${backend}: live record-backed marker → trusted identity`,
200
+ trusted?.identity.gardenId === record.record.gardenId,
201
+ );
202
+ }
203
+ clearMarkers();
204
+ writeMetaSenderMarker({
205
+ backend: "antigravity",
206
+ gardenId: "20260713T161524-0db936",
207
+ nativeSessionId: "no-such-conversation",
208
+ cwd: REPO_DIR,
209
+ ownerPid: OWNER,
210
+ sendersDir: SENDERS_DIR,
211
+ });
212
+ ok("marker with NO backing record → null (a stale marker names nobody)", resolve() === null);
213
+
214
+ {
215
+ clearMarkers();
216
+ const record = upsertMetaSession({
217
+ input: { backend: "antigravity", nativeSessionId: "conversation-A", cwd: REPO_DIR },
218
+ });
219
+ writeMetaSenderMarker({
220
+ backend: "antigravity",
221
+ gardenId: record.record.gardenId,
222
+ nativeSessionId: "conversation-B", // drifted from the record
223
+ cwd: REPO_DIR,
224
+ ownerPid: OWNER,
225
+ sendersDir: SENDERS_DIR,
226
+ });
227
+ ok("marker whose nativeSessionId drifted from its record → null", resolve() === null);
228
+ }
229
+
230
+ // THE REFUSAL. One native host driving two live sessions breaks the pid→conversation
231
+ // binding the marker rests on. We can see both identities and cannot name the caller, so
232
+ // we send under neither: a wrong attribution is worse than no send, and a silent fallback
233
+ // to anonymous would hide an identity we already hold.
234
+ {
235
+ clearMarkers();
236
+ const a = upsertMetaSession({ input: { backend: "antigravity", nativeSessionId: "conv-A", cwd: REPO_DIR } });
237
+ const b = upsertMetaSession({ input: { backend: "claude-code", nativeSessionId: "sess-B", cwd: REPO_DIR } });
238
+ writeMetaSenderMarker({
239
+ backend: "antigravity",
240
+ gardenId: a.record.gardenId,
241
+ nativeSessionId: "conv-A",
242
+ cwd: REPO_DIR,
243
+ ownerPid: OWNER,
244
+ sendersDir: SENDERS_DIR,
245
+ });
246
+ writeMetaSenderMarker({
247
+ backend: "claude-code",
248
+ gardenId: b.record.gardenId,
249
+ nativeSessionId: "sess-B",
250
+ cwd: REPO_DIR,
251
+ ownerPid: OWNER,
252
+ sendersDir: SENDERS_DIR,
253
+ });
254
+ let threw: unknown = null;
255
+ try {
256
+ resolve();
257
+ } catch (err) {
258
+ threw = err;
259
+ }
260
+ ok(
261
+ "two distinct live identities on one owner pid → THROW (never guess, never downgrade to anonymous)",
262
+ threw instanceof EntwurfSenderIdentityAmbiguityError,
263
+ );
264
+ ok(
265
+ "the refusal names both citizens so the wiring bug is debuggable",
266
+ threw instanceof EntwurfSenderIdentityAmbiguityError && threw.gardenIds.length === 2,
267
+ );
268
+ }
269
+
270
+ // Same identity reached twice is NOT ambiguity: an older release wrote a marker for the
271
+ // parent AND the grandparent. Throwing there would refuse a send that has one honest answer.
272
+ {
273
+ clearMarkers();
274
+ const record = upsertMetaSession({
275
+ input: { backend: "antigravity", nativeSessionId: "conv-solo", cwd: REPO_DIR },
276
+ });
277
+ for (const pid of [OWNER, process.ppid]) {
278
+ writeMetaSenderMarker({
279
+ backend: "antigravity",
280
+ gardenId: record.record.gardenId,
281
+ nativeSessionId: "conv-solo",
282
+ cwd: REPO_DIR,
283
+ ownerPid: pid,
284
+ sendersDir: SENDERS_DIR,
285
+ });
286
+ }
287
+ const trusted = resolveTrustedMetaSenderIdentity({
288
+ ownerPids: [OWNER, process.ppid],
289
+ sendersDir: SENDERS_DIR,
290
+ });
291
+ ok(
292
+ "two markers naming the SAME identity → not a conflict, one identity",
293
+ trusted?.identity.gardenId === record.record.gardenId,
294
+ );
295
+ }
296
+ ok(
297
+ "antigravity is a native-push backend → its replyable comes from the adapter probe",
298
+ nativePushSupported("antigravity"),
299
+ );
300
+ ok(
301
+ "claude-code is NOT native-push → its replyable stays on the mailbox receiver axis (보정①: the rails never borrow each other's facts)",
302
+ !nativePushSupported("claude-code"),
303
+ );
304
+
305
+ // ── PROBE ERROR POLICY: an adapter outcome is a FACT, a throw is a DEFECT ────
306
+ // The adapter reports "not reachable" as a value (dead / indeterminate). So a throw out of the
307
+ // probe can only be a broken registry or a probe that could not run at all. Folding that into
308
+ // replyable:false would tell the receiver a lie about this sender AND bury the defect — the
309
+ // exact Crash-Don't-Warn shape this lane exists to remove. It must propagate.
310
+ {
311
+ const identity: Pick<MetaIdentity, "backend" | "nativeSessionId"> = {
312
+ backend: "antigravity",
313
+ nativeSessionId: "conv-x",
314
+ };
315
+ const adapterWith = (probe: NativePushAdapter["probe"]) => ({ resolveAdapter: () => ({ probe }) });
316
+
317
+ ok(
318
+ "probe alive → replyable true",
319
+ (await probeNativeSenderAlive(
320
+ identity,
321
+ adapterWith(async () => ({ status: "alive", route: { lsAddress: "127.0.0.1:1" } })),
322
+ )) === true,
323
+ );
324
+
325
+ for (const status of ["dead", "indeterminate"] as const) {
326
+ ok(
327
+ `probe ${status} → replyable false (an adapter's OWN outcome is the fact)`,
328
+ (await probeNativeSenderAlive(
329
+ identity,
330
+ adapterWith(async () => ({ status, reason: "gate" })),
331
+ )) === false,
332
+ );
333
+ }
334
+
335
+ let registryThrew = false;
336
+ try {
337
+ await probeNativeSenderAlive(identity, {
338
+ resolveAdapter: () => {
339
+ throw new Error("no native-push adapter owns backend id");
340
+ },
341
+ });
342
+ } catch {
343
+ registryThrew = true;
344
+ }
345
+ ok("unresolvable adapter → THROWS (a registry bug is not a fact about the citizen)", registryThrew);
346
+
347
+ let probeThrew = false;
348
+ try {
349
+ await probeNativeSenderAlive(
350
+ identity,
351
+ adapterWith(async () => {
352
+ throw new Error("pgrep: command not found");
353
+ }),
354
+ );
355
+ } catch {
356
+ probeThrew = true;
357
+ }
358
+ ok("probe runner blows up → THROWS, never a silent replyable:false (Crash-Don't-Warn)", probeThrew);
359
+ }
360
+
361
+ console.log(`\ncheck-agy-sender-identity: ${passed} checks passed`);
362
+ } finally {
363
+ fs.rmSync(ROOT, { recursive: true, force: true });
364
+ }
@@ -13,8 +13,10 @@
13
13
  * - G1b: `entwurf_v2` is registered on the runtime tools/list surface with the
14
14
  * expected input schema (props ⊇ target/intent/message/mode/wants_reply;
15
15
  * required ⊇ target/intent/message; intent/mode enums).
16
+ * - G1c/G1d: the shipped manual native-registration fallback is present and
17
+ * retired v1 verbs remain absent.
16
18
  *
17
- * Scope boundary (D1=A안): this gate owns boot + entwurf_v2 registration/schema.
19
+ * Scope boundary (D1=A안): this gate owns boot + current tool registration/schema.
18
20
  * The broad protocol/negative suite stays in check-bridge/test.sh. Only a
19
21
  * `tools/list` is sent — no `tools/call` — so there is NO lock/filesystem side
20
22
  * effect and no auth/model is needed; it lives in `pnpm check`.
@@ -189,7 +191,11 @@ async function main(): Promise<void> {
189
191
  );
190
192
 
191
193
  ok(
192
- "G1c: legacy v1 MCP tools are absent",
194
+ "G1c: native registration fallback is present",
195
+ tools.some((t) => t?.name === "entwurf_register_native"),
196
+ );
197
+ ok(
198
+ "G1d: legacy v1 MCP tools are absent",
193
199
  !tools.some((t) => ["entwurf", "entwurf_resume", "entwurf_send"].includes(String(t?.name))),
194
200
  );
195
201
 
@@ -24,6 +24,8 @@ import { fileURLToPath } from "node:url";
24
24
  import {
25
25
  computeMetaReceiverActive,
26
26
  mailboxConversationalDeliverable,
27
+ type NativePushDeliverabilityFacts,
28
+ nativePushDeliverable,
27
29
  receiverMarkerMatchesIdentity,
28
30
  } from "../pi-extensions/lib/entwurf-deliverability.ts";
29
31
 
@@ -144,6 +146,38 @@ ok(
144
146
  ok("null marker → false (fail-closed)", !receiverMarkerMatchesIdentity(null, ID));
145
147
  ok("undefined marker → false (fail-closed)", !receiverMarkerMatchesIdentity(undefined, ID));
146
148
 
149
+ // ── nativePushDeliverable: the SEPARATE native-push axis (봉인 6) ────────────
150
+ // deliverable ⟺ recordBacked ∧ probeAlive. Distinct axis from the mailbox gate: it
151
+ // must NOT depend on watchArmed (the mailbox-only atom), so a native-push target's
152
+ // deliverability is unaffected by any watch/ownerAlive signal.
153
+ const NP = (f: Parameters<typeof nativePushDeliverable>[0]) => nativePushDeliverable(f).deliverable;
154
+
155
+ ok("native-push: record backed + probe alive → deliverable", NP({ recordBacked: true, probeAlive: true }) === true);
156
+ ok(
157
+ "native-push: no record → NOT deliverable (record-less native id)",
158
+ NP({ recordBacked: false, probeAlive: true }) === false,
159
+ );
160
+ ok(
161
+ "native-push: probe not alive → NOT deliverable (no live conversation)",
162
+ NP({ recordBacked: true, probeAlive: false }) === false,
163
+ );
164
+ ok("native-push: undefined axes → fail-closed inactive", NP({ recordBacked: true }) === false);
165
+ ok("native-push: both undefined → fail-closed inactive", NP({}) === false);
166
+ // axis separation (보정①): the native-push predicate has NO watchArmed/ownerAlive
167
+ // concept — its shape carries only recordBacked/probeAlive, so a mailbox liveness
168
+ // fact cannot be smuggled into it.
169
+ ok(
170
+ "native-push: reason distinguishes record-less from dead-probe",
171
+ nativePushDeliverable({ recordBacked: false, probeAlive: true }).reason !==
172
+ nativePushDeliverable({ recordBacked: true, probeAlive: false }).reason,
173
+ );
174
+ // Type-level guard: the fact keyset is EXACTLY recordBacked|probeAlive — if a future
175
+ // edit added `watchArmed`/`ownerAlive` (the mailbox atom fields), this stops compiling,
176
+ // so the mailbox atom can never be smuggled into the native-push axis (보정①).
177
+ type NativePushFactKeys = keyof NativePushDeliverabilityFacts;
178
+ const _nativePushKeysGuard: NativePushFactKeys extends "recordBacked" | "probeAlive" ? true : never = true;
179
+ void _nativePushKeysGuard;
180
+
147
181
  // ── WIRING: self-addressability shares the SAME atom ────────────────────────
148
182
  const selfSrc = readFileSync(path.join(REPO_DIR, "pi-extensions", "lib", "entwurf-self-address.ts"), "utf8");
149
183
  ok(
@@ -77,26 +77,93 @@ function row(facts: SelfAddressabilityFacts): { replyable: boolean; socketState:
77
77
  ok("pi + no session id → socketState none", none.socketState === "none");
78
78
  }
79
79
 
80
- // meta-session axis: 3-conjunct deliverability (recordBacked ∧ ownerAlive ∧ watchArmed).
80
+ // meta-session / SELF-FETCH rail (claude-code): 3-conjunct deliverability
81
+ // (recordBacked ∧ ownerAlive ∧ watchArmed).
81
82
  {
82
- const full = row({ origin: "meta-session", recordBacked: true, ownerAlive: true, watchArmed: true });
83
- ok("meta + record + owner-alive + watch-armed → replyable", full.replyable === true);
83
+ const meta = (f: Partial<SelfAddressabilityFacts>): SelfAddressabilityFacts => ({
84
+ origin: "meta-session",
85
+ metaDeliveryDomain: "self-fetch",
86
+ ...f,
87
+ });
88
+ const full = row(meta({ recordBacked: true, ownerAlive: true, watchArmed: true }));
89
+ ok("meta/self-fetch + record + owner-alive + watch-armed → replyable", full.replyable === true);
84
90
 
85
91
  // (b) REGRESSION-PROOF row: record PRESENT but owner dead.
86
- const ownerDead = row({ origin: "meta-session", recordBacked: true, ownerAlive: false, watchArmed: true });
87
- ok("meta + record present + owner-dead (start-key mismatch) → NOT replyable", ownerDead.replyable === false);
92
+ const ownerDead = row(meta({ recordBacked: true, ownerAlive: false, watchArmed: true }));
93
+ ok(
94
+ "meta/self-fetch + record present + owner-dead (start-key mismatch) → NOT replyable",
95
+ ownerDead.replyable === false,
96
+ );
88
97
 
89
98
  // (c) REGRESSION-PROOF row: record + owner present but watch never armed.
90
- const watchUnarmed = row({ origin: "meta-session", recordBacked: true, ownerAlive: true, watchArmed: false });
91
- ok("meta + record present + owner-alive + watch-unarmed → NOT replyable", watchUnarmed.replyable === false);
99
+ const watchUnarmed = row(meta({ recordBacked: true, ownerAlive: true, watchArmed: false }));
100
+ ok("meta/self-fetch + record + owner-alive + watch-unarmed → NOT replyable", watchUnarmed.replyable === false);
92
101
 
93
102
  // not backed by a record at all → false (the all-absent baseline; weakest row).
94
- const unbacked = row({ origin: "meta-session", recordBacked: false, ownerAlive: false, watchArmed: false });
95
- ok("meta + no backing record → NOT replyable", unbacked.replyable === false);
103
+ const unbacked = row(meta({ recordBacked: false, ownerAlive: false, watchArmed: false }));
104
+ ok("meta/self-fetch + no backing record → NOT replyable", unbacked.replyable === false);
96
105
 
97
106
  // fail-closed on missing axes (undefined treated as false, never optimistic).
98
- const partial = row({ origin: "meta-session", recordBacked: true });
99
- ok("meta + record but undefined owner/watch → fail-closed NOT replyable", partial.replyable === false);
107
+ const partial = row(meta({ recordBacked: true }));
108
+ ok("meta/self-fetch + record but undefined owner/watch → fail-closed NOT replyable", partial.replyable === false);
109
+ }
110
+
111
+ // meta-session / NATIVE-PUSH rail (antigravity): a SEPARATE axis — recordBacked ∧ probeAlive.
112
+ // The two rails must not be able to borrow each other's facts (보정①): a mailbox signal
113
+ // deciding an agy reply, or a probe deciding a Claude reply, is a category error.
114
+ {
115
+ const push = (f: Partial<SelfAddressabilityFacts>): SelfAddressabilityFacts => ({
116
+ origin: "meta-session",
117
+ metaDeliveryDomain: "native-push",
118
+ ...f,
119
+ });
120
+ const reachable = row(push({ recordBacked: true, probeAlive: true }));
121
+ ok("meta/native-push + record + probe-alive → replyable", reachable.replyable === true);
122
+
123
+ // THE ROW THIS RAIL EXISTS FOR: an agy citizen never arms a mailbox watch. Under the
124
+ // self-fetch atom it would be un-replyable forever; on its own rail it is reachable.
125
+ ok(
126
+ "meta/native-push + record + probe-alive + watch NEVER armed → STILL replyable (no mailbox axis leak)",
127
+ row(push({ recordBacked: true, probeAlive: true, watchArmed: false })).replyable === true,
128
+ );
129
+
130
+ // Dead conversation: the host is gone, so an injected reply has nowhere to land.
131
+ ok(
132
+ "meta/native-push + record + probe-dead → NOT replyable",
133
+ row(push({ recordBacked: true, probeAlive: false })).replyable === false,
134
+ );
135
+
136
+ // No record → not an identity, whatever the probe says.
137
+ ok(
138
+ "meta/native-push + no record + probe-alive → NOT replyable",
139
+ row(push({ recordBacked: false, probeAlive: true })).replyable === false,
140
+ );
141
+
142
+ // fail-closed: an unsupplied probe fact is false, never optimistic.
143
+ ok(
144
+ "meta/native-push + record but undefined probe → fail-closed NOT replyable",
145
+ row(push({ recordBacked: true })).replyable === false,
146
+ );
147
+
148
+ // A native-push citizen must NOT be able to buy replyability with mailbox facts.
149
+ ok(
150
+ "meta/native-push + owner-alive + watch-armed but probe-dead → NOT replyable (mailbox facts cannot rescue it)",
151
+ row(push({ recordBacked: true, ownerAlive: true, watchArmed: true, probeAlive: false })).replyable === false,
152
+ );
153
+ }
154
+
155
+ // meta-session with NO rail declared: we cannot say how a reply would travel, so we do not
156
+ // claim it would arrive. The domain is derived from nativePushSupported(backend) — a caller
157
+ // that forgets it gets a refusal, not an optimistic guess.
158
+ {
159
+ const noDomain = row({
160
+ origin: "meta-session",
161
+ recordBacked: true,
162
+ ownerAlive: true,
163
+ watchArmed: true,
164
+ probeAlive: true,
165
+ });
166
+ ok("meta + NO delivery domain (all facts true) → fail-closed NOT replyable", noDomain.replyable === false);
100
167
  }
101
168
 
102
169
  // external-mcp: never replyable.