@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,319 @@
1
+ /**
2
+ * check-native-push-adapter — deterministic gate for the native-push adapter LEAF
3
+ * (봉인 3/8). Drives `createAntigravityAdapter` with a FAKE runner (no real agy / ss /
4
+ * pgrep), so probe + send are gate-provable without a live host.
5
+ *
6
+ * Proves:
7
+ * - FULL pid scan (NOT head -1): with two host pids where only the SECOND serves the
8
+ * conversation, probe still finds the route — the raw-agy-send.sh:16 single-pid
9
+ * assumption is corrected.
10
+ * - dead vs indeterminate: no host process → dead; host(s) alive but no LS port served
11
+ * the conversation → indeterminate (never coerced to dead).
12
+ * - VOLATILE route / no cache (봉인 3): a repeated probe re-runs pgrep + ss + metadata
13
+ * and re-discovers the (changed) route — the adapter never stores it.
14
+ * - send argv + env: send-message argv === [binary, agentapi, send-message, conv, body]
15
+ * with ANTIGRAVITY_LS_ADDRESS === the route; a non-zero exit THROWS (fail-loud).
16
+ * - NO retry in the adapter: send is a single attempt (the 1-shot re-probe→re-send is
17
+ * the executor hand's job, step ⑥) — a failed send throws immediately, no re-probe.
18
+ * - resolver fail-fast: resolveNativePushAdapter("antigravity") resolves; unknown throws.
19
+ *
20
+ * Pure; no backend, no socket, no real process.
21
+ */
22
+
23
+ import assert from "node:assert/strict";
24
+ import { readFileSync } from "node:fs";
25
+ import * as path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import {
28
+ AGY_METADATA_TIMEOUT_MS,
29
+ AGY_SEND_TIMEOUT_MS,
30
+ antigravityAdapter,
31
+ createAntigravityAdapter,
32
+ type NativePushRunner,
33
+ resolveNativePushAdapter,
34
+ } from "../pi-extensions/lib/native-push/adapter.ts";
35
+
36
+ let passed = 0;
37
+ function ok(label: string, cond: boolean): void {
38
+ assert.ok(cond, label);
39
+ console.log(` ok ${label}`);
40
+ passed++;
41
+ }
42
+ function eq(label: string, actual: unknown, expected: unknown): void {
43
+ assert.deepStrictEqual(actual, expected, label);
44
+ console.log(` ok ${label}`);
45
+ passed++;
46
+ }
47
+
48
+ const FAKE_BINARY = "/fake/bin/agy";
49
+ const CONV = "conv-abc-123";
50
+
51
+ interface ExecCall {
52
+ argv: string[];
53
+ env?: Record<string, string>;
54
+ timeoutMs?: number;
55
+ }
56
+
57
+ /** A recorded ss -lntp fixture line for a localhost listener owned by `pid`. */
58
+ function ssLine(port: number, pid: number): string {
59
+ return `LISTEN 0 4096 127.0.0.1:${port} 0.0.0.0:* users:(("agy",pid=${pid},fd=7))`;
60
+ }
61
+
62
+ interface FakeConfig {
63
+ pids: number[];
64
+ ss: string;
65
+ /** which (lsAddress, conv) answer get-conversation-metadata as the serving route. */
66
+ serving: (lsAddress: string, conv: string) => boolean;
67
+ /** exit codes for successive send-message calls (default: all 0). */
68
+ sendCodes?: number[];
69
+ /** simulate a TIMEOUT kill (code 124) on every get-conversation-metadata call. */
70
+ metadataTimeout?: boolean;
71
+ }
72
+
73
+ function makeFakeRunner(config: FakeConfig): { runner: NativePushRunner; calls: ExecCall[] } {
74
+ const calls: ExecCall[] = [];
75
+ let sendIdx = 0;
76
+ const runner: NativePushRunner = {
77
+ async exec(argv, opts) {
78
+ calls.push({ argv: [...argv], env: opts?.env, timeoutMs: opts?.timeoutMs });
79
+ const a = [...argv];
80
+ if (a[0] === "pgrep") {
81
+ const code = config.pids.length > 0 ? 0 : 1;
82
+ return { code, stdout: config.pids.length ? `${config.pids.join("\n")}\n` : "", stderr: "" };
83
+ }
84
+ if (a[0] === "ss") {
85
+ return { code: 0, stdout: config.ss, stderr: "" };
86
+ }
87
+ if (a.includes("get-conversation-metadata")) {
88
+ if (config.metadataTimeout) {
89
+ return { code: 124, stdout: "", stderr: "" }; // timeout kill (bounded, no hang)
90
+ }
91
+ const conv = a[a.length - 1];
92
+ const ls = opts?.env?.ANTIGRAVITY_LS_ADDRESS ?? "";
93
+ const serves = config.serving(ls, conv);
94
+ return {
95
+ code: serves ? 0 : 1,
96
+ stdout: serves ? '{"conversationMetadata":{"id":"x"}}' : "",
97
+ stderr: serves ? "" : "conversation not found",
98
+ };
99
+ }
100
+ if (a.includes("send-message")) {
101
+ const code = config.sendCodes ? (config.sendCodes[sendIdx++] ?? 0) : 0;
102
+ return { code, stdout: "", stderr: code === 0 ? "" : "send failed" };
103
+ }
104
+ return { code: 127, stdout: "", stderr: `unknown command ${a[0]}` };
105
+ },
106
+ };
107
+ return { runner, calls };
108
+ }
109
+
110
+ // ── FULL pid scan (not head -1): only the 2nd pid serves the conversation ────
111
+ {
112
+ const { runner, calls } = makeFakeRunner({
113
+ pids: [123, 456],
114
+ ss: `${ssLine(5001, 123)}\n${ssLine(5002, 456)}`,
115
+ serving: (ls) => ls === "127.0.0.1:5002", // only pid 456's port serves it
116
+ });
117
+ const adapter = createAntigravityAdapter({ runner, binary: FAKE_BINARY });
118
+ const result = await adapter.probe(CONV);
119
+ ok("full-scan: probe finds the route on the SECOND pid (not head -1)", result.status === "alive");
120
+ eq(
121
+ "full-scan: route is pid 456's port (5002)",
122
+ result.status === "alive" ? result.route.lsAddress : "(dead)",
123
+ "127.0.0.1:5002",
124
+ );
125
+ // proof it did NOT stop at pid 123: it probed BOTH 5001 (miss) and 5002 (hit).
126
+ const metaTargets = calls
127
+ .filter((c) => c.argv.includes("get-conversation-metadata"))
128
+ .map((c) => c.env?.ANTIGRAVITY_LS_ADDRESS);
129
+ ok("full-scan: probed pid 123's port 5001 before pid 456 (no head -1)", metaTargets.includes("127.0.0.1:5001"));
130
+ ok("full-scan: probed pid 456's serving port 5002", metaTargets.includes("127.0.0.1:5002"));
131
+ // pgrep scanned WITHOUT `head -1` — the adapter never invokes head, it parses all pids.
132
+ ok(
133
+ "full-scan: pgrep invoked as `pgrep -x agy` (no head)",
134
+ calls.some((c) => c.argv.join(" ") === "pgrep -x agy"),
135
+ );
136
+ }
137
+
138
+ // ── dead: no host process ────────────────────────────────────────────────────
139
+ {
140
+ const { runner } = makeFakeRunner({ pids: [], ss: "", serving: () => false });
141
+ const adapter = createAntigravityAdapter({ runner, binary: FAKE_BINARY });
142
+ const result = await adapter.probe(CONV);
143
+ ok("no-host: probe → dead", result.status === "dead");
144
+ ok(
145
+ "no-host: dead reason names the missing host process",
146
+ result.status === "dead" && /no live agy process/.test(result.reason),
147
+ );
148
+ }
149
+
150
+ // ── indeterminate: host alive but no LS port serves the conversation ─────────
151
+ {
152
+ const { runner } = makeFakeRunner({
153
+ pids: [123],
154
+ ss: ssLine(5001, 123),
155
+ serving: () => false, // agy up, but the conv is nowhere
156
+ });
157
+ const adapter = createAntigravityAdapter({ runner, binary: FAKE_BINARY });
158
+ const result = await adapter.probe(CONV);
159
+ ok("host-no-conv: probe → indeterminate (NOT dead)", result.status === "indeterminate");
160
+ ok(
161
+ "host-no-conv: indeterminate reason names the unserved conversation",
162
+ result.status === "indeterminate" && /no LS port served/.test(result.reason),
163
+ );
164
+ }
165
+
166
+ // ── VOLATILE route / no cache (봉인 3): a repeated probe re-discovers a CHANGED route ──
167
+ {
168
+ let servingPort = 5002;
169
+ const { runner, calls } = makeFakeRunner({
170
+ pids: [123],
171
+ ss: `${ssLine(5002, 123)}\n${ssLine(5003, 123)}`,
172
+ serving: (ls) => ls === `127.0.0.1:${servingPort}`,
173
+ });
174
+ const adapter = createAntigravityAdapter({ runner, binary: FAKE_BINARY });
175
+
176
+ const r1 = await adapter.probe(CONV);
177
+ eq("volatile-route: probe #1 route = 5002", r1.status === "alive" ? r1.route.lsAddress : "(dead)", "127.0.0.1:5002");
178
+ const pgrepAfter1 = calls.filter((c) => c.argv[0] === "pgrep").length;
179
+
180
+ // the LS port shifts (per-process, volatile) — a fresh probe must RE-DISCOVER it.
181
+ servingPort = 5003;
182
+ const r2 = await adapter.probe(CONV);
183
+ eq(
184
+ "volatile-route: probe #2 RE-DISCOVERS the changed route = 5003 (no cache)",
185
+ r2.status === "alive" ? r2.route.lsAddress : "(dead)",
186
+ "127.0.0.1:5003",
187
+ );
188
+ ok(
189
+ "volatile-route: probe #2 re-ran the pgrep scan (route never stored across dispatches)",
190
+ calls.filter((c) => c.argv[0] === "pgrep").length === pgrepAfter1 + 1,
191
+ );
192
+ ok("volatile-route: probe #2 re-ran the ss scan", calls.filter((c) => c.argv[0] === "ss").length === 2);
193
+ }
194
+
195
+ // ── send: argv + env; single attempt; non-zero exit throws (fail-loud) ───────
196
+ {
197
+ const { runner, calls } = makeFakeRunner({ pids: [123], ss: ssLine(5002, 123), serving: () => true });
198
+ const adapter = createAntigravityAdapter({ runner, binary: FAKE_BINARY });
199
+ await adapter.send({ lsAddress: "127.0.0.1:5002" }, CONV, "hello world");
200
+ const sendCall = calls.find((c) => c.argv.includes("send-message"));
201
+ eq("send: argv === [binary, agentapi, send-message, conv, content]", sendCall?.argv, [
202
+ FAKE_BINARY,
203
+ "agentapi",
204
+ "send-message",
205
+ CONV,
206
+ "hello world",
207
+ ]);
208
+ eq("send: env ANTIGRAVITY_LS_ADDRESS === the route", sendCall?.env?.ANTIGRAVITY_LS_ADDRESS, "127.0.0.1:5002");
209
+ }
210
+ {
211
+ const { runner, calls } = makeFakeRunner({
212
+ pids: [123],
213
+ ss: ssLine(5002, 123),
214
+ serving: () => true,
215
+ sendCodes: [1], // first (and only) attempt fails
216
+ });
217
+ const adapter = createAntigravityAdapter({ runner, binary: FAKE_BINARY });
218
+ let threw = false;
219
+ try {
220
+ await adapter.send({ lsAddress: "127.0.0.1:5002" }, CONV, "hi");
221
+ } catch (err) {
222
+ threw = true;
223
+ ok("send-fail: error names the non-zero exit", /native-push send failed/.test((err as Error).message));
224
+ }
225
+ ok("send-fail: a non-zero exit THROWS (fail-loud)", threw);
226
+ // NO retry in the adapter: exactly ONE send-message call, no re-probe.
227
+ eq(
228
+ "send-fail: adapter makes exactly ONE send attempt (retry is executor-owned, not here)",
229
+ calls.filter((c) => c.argv.includes("send-message")).length,
230
+ 1,
231
+ );
232
+ ok(
233
+ "send-fail: adapter did NOT re-probe after failure (no pgrep/ss triggered by send)",
234
+ !calls.some((c) => c.argv[0] === "pgrep"),
235
+ );
236
+ }
237
+
238
+ // ── Q12: bounded agy calls — no hang on a stalled LS route ───────────────────
239
+ {
240
+ // every get-conversation-metadata + send-message call carries a timeout bound.
241
+ const { runner, calls } = makeFakeRunner({ pids: [123], ss: ssLine(5002, 123), serving: () => true });
242
+ const adapter = createAntigravityAdapter({ runner, binary: FAKE_BINARY });
243
+ await adapter.probe(CONV);
244
+ await adapter.send({ lsAddress: "127.0.0.1:5002" }, CONV, "hi");
245
+ const metaCall = calls.find((c) => c.argv.includes("get-conversation-metadata"));
246
+ const sendCall = calls.find((c) => c.argv.includes("send-message"));
247
+ eq("timeout: get-conversation-metadata carries the metadata timeout", metaCall?.timeoutMs, AGY_METADATA_TIMEOUT_MS);
248
+ eq("timeout: send-message carries the send timeout", sendCall?.timeoutMs, AGY_SEND_TIMEOUT_MS);
249
+ ok(
250
+ "timeout: pgrep is unbounded (fast local scan)",
251
+ calls.find((c) => c.argv[0] === "pgrep")?.timeoutMs === undefined,
252
+ );
253
+ }
254
+ {
255
+ // a metadata TIMEOUT (code 124) on the only port → probe INDETERMINATE (never hangs, never dead).
256
+ const { runner } = makeFakeRunner({
257
+ pids: [123],
258
+ ss: ssLine(5002, 123),
259
+ serving: () => true,
260
+ metadataTimeout: true,
261
+ });
262
+ const adapter = createAntigravityAdapter({ runner, binary: FAKE_BINARY });
263
+ const result = await adapter.probe(CONV);
264
+ ok(
265
+ "timeout: metadata timeout → probe indeterminate (bounded, not a hang, not dead)",
266
+ result.status === "indeterminate",
267
+ );
268
+ }
269
+ {
270
+ // a send TIMEOUT (code 124) → throws (fail-loud; executor owns the 1-shot re-probe).
271
+ const { runner } = makeFakeRunner({ pids: [123], ss: ssLine(5002, 123), serving: () => true, sendCodes: [124] });
272
+ const adapter = createAntigravityAdapter({ runner, binary: FAKE_BINARY });
273
+ let threw = false;
274
+ try {
275
+ await adapter.send({ lsAddress: "127.0.0.1:5002" }, CONV, "hi");
276
+ } catch {
277
+ threw = true;
278
+ }
279
+ ok("timeout: send timeout (124) → throws (fail-loud)", threw);
280
+ }
281
+
282
+ // ── resolver fail-fast (mirror resolveAcpBackendAdapter) ─────────────────────
283
+ eq("resolve: antigravity → the antigravity adapter", resolveNativePushAdapter("antigravity").id, "antigravity");
284
+ eq("adapter id: antigravityAdapter.id === antigravity", antigravityAdapter.id, "antigravity");
285
+ {
286
+ let threw = false;
287
+ try {
288
+ resolveNativePushAdapter("codex");
289
+ } catch {
290
+ threw = true;
291
+ }
292
+ ok("resolve: unknown backend (codex) → throw (no silent default)", threw);
293
+ }
294
+ {
295
+ let threw = false;
296
+ try {
297
+ resolveNativePushAdapter("bogus");
298
+ } catch {
299
+ threw = true;
300
+ }
301
+ ok("resolve: bogus backend → throw", threw);
302
+ }
303
+
304
+ // ── Q13 (보정① long-term defense): the native-push file group never reaches for a ──
305
+ // mailbox atom. If either the adapter leaf OR the send hand imported
306
+ // computeMetaReceiverActive / readMetaReceiverMarker / mailboxConversationalDeliverable, a
307
+ // future edit could quietly fold mailbox liveness (watchArmed) into the native-push axis.
308
+ {
309
+ const libDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "pi-extensions", "lib");
310
+ const FORBIDDEN = ["computeMetaReceiverActive", "readMetaReceiverMarker", "mailboxConversationalDeliverable"];
311
+ for (const rel of ["native-push/adapter.ts", "entwurf-v2-native-push.ts", "native-push/register.ts"]) {
312
+ const src = readFileSync(path.join(libDir, rel), "utf8");
313
+ for (const atom of FORBIDDEN) {
314
+ ok(`axis-guard: ${rel} does NOT reference the mailbox atom ${atom} (보정①)`, !src.includes(atom));
315
+ }
316
+ }
317
+ }
318
+
319
+ console.log(`\ncheck-native-push-adapter: ${passed} assertions passed`);
@@ -0,0 +1,130 @@
1
+ /**
2
+ * check-native-push-register — deterministic gate for registerNativeConversation (봉인 5),
3
+ * the core of the `entwurf_register_native` MCP tool. Drives it with a FAKE adapter (no live
4
+ * agy) and an ISOLATED temp meta-record store (never the real ~/.pi).
5
+ *
6
+ * Proves:
7
+ * - live probe → CREATE: a fresh live conversation mints a garden citizen whose meta-record
8
+ * carries the backend / nativeSessionId / caller-stated cwd; the action is "create".
9
+ * - re-register → ATTACH: the same conversation resolves to the SAME garden id and REFRESHES
10
+ * the cwd (봉인 5) — it never mints a second id.
11
+ * - not-live probe → REFUSE: a dead / indeterminate probe THROWS and writes NO record (no
12
+ * garden id is engraved onto a pointer that does not resolve to a live host).
13
+ * - RECEIVER-MARKER ABSTINENCE (보정①): the register source references NO receiver-marker
14
+ * writer — a native-push citizen has no idle-wake watch, so arming one would smuggle
15
+ * native-push liveness into the mailbox deliverability atom.
16
+ *
17
+ * Deterministic (fake adapter); the only IO is an isolated mkdtemp store (like the meta gates).
18
+ */
19
+
20
+ import assert from "node:assert/strict";
21
+ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
22
+ import * as os from "node:os";
23
+ import * as path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { readMetaIdentityByGardenId } from "../pi-extensions/lib/meta-session.ts";
26
+ import type { NativePushAdapter, NativePushProbeResult } from "../pi-extensions/lib/native-push/adapter.ts";
27
+ import { registerNativeConversation } from "../pi-extensions/lib/native-push/register.ts";
28
+
29
+ let passed = 0;
30
+ function ok(label: string, cond: boolean): void {
31
+ assert.ok(cond, label);
32
+ console.log(` ok ${label}`);
33
+ passed++;
34
+ }
35
+
36
+ const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
37
+
38
+ /** A fake native-push adapter whose probe returns a fixed result and whose send is unused. */
39
+ function fakeAdapter(probe: NativePushProbeResult): NativePushAdapter {
40
+ return {
41
+ id: "antigravity",
42
+ async probe() {
43
+ return probe;
44
+ },
45
+ async send() {
46
+ throw new Error("register gate: adapter.send must not be called during registration");
47
+ },
48
+ };
49
+ }
50
+
51
+ const ALIVE: NativePushProbeResult = { status: "alive", route: { lsAddress: "127.0.0.1:5599" } };
52
+
53
+ async function main(): Promise<void> {
54
+ const dir = mkdtempSync(path.join(os.tmpdir(), "entwurf-register-"));
55
+ try {
56
+ // ── live probe → CREATE ───────────────────────────────────────────────────
57
+ const created = await registerNativeConversation(
58
+ { backend: "antigravity", nativeSessionId: "conv-abc", cwd: "/work/one" },
59
+ { resolveAdapter: () => fakeAdapter(ALIVE), sessionsDir: dir },
60
+ );
61
+ ok("create: action = create", created.action === "create");
62
+ ok("create: gardenId shaped YYYYMMDDTHHMMSS-xxxxxx", /^\d{8}T\d{6}-[0-9a-f]{6}$/.test(created.gardenId));
63
+ ok("create: backend antigravity", created.backend === "antigravity");
64
+ ok("create: nativeSessionId echoed", created.nativeSessionId === "conv-abc");
65
+ ok("create: cwd = caller-stated", created.cwd === "/work/one");
66
+ // the on-disk record agrees (read back through the authority).
67
+ const rec = readMetaIdentityByGardenId(created.gardenId, dir);
68
+ ok("create: record backend antigravity", rec.backend === "antigravity");
69
+ ok("create: record nativeSessionId bound", rec.nativeSessionId === "conv-abc");
70
+ ok("create: record cwd recorded", rec.cwd === "/work/one");
71
+
72
+ // ── re-register same conversation → ATTACH (same gid, cwd refreshed) ───────
73
+ const reattached = await registerNativeConversation(
74
+ { backend: "antigravity", nativeSessionId: "conv-abc", cwd: "/work/two" },
75
+ { resolveAdapter: () => fakeAdapter(ALIVE), sessionsDir: dir },
76
+ );
77
+ ok("attach: action = attach", reattached.action === "attach");
78
+ ok("attach: SAME garden id (no second mint)", reattached.gardenId === created.gardenId);
79
+ ok(
80
+ "attach: cwd REFRESHED to the new value",
81
+ reattached.gardenId === created.gardenId && reattached.cwd === "/work/two",
82
+ );
83
+ const rec2 = readMetaIdentityByGardenId(created.gardenId, dir);
84
+ ok("attach: on-disk cwd refreshed", rec2.cwd === "/work/two");
85
+ // exactly ONE record file for this conversation (no duplicate mint).
86
+ const records = readdirSync(dir).filter((f) => f.endsWith(".meta.json"));
87
+ ok("attach: exactly ONE meta-record (no duplicate garden id)", records.length === 1);
88
+
89
+ // ── not-live probe → REFUSE (throw), NO record written ────────────────────
90
+ for (const probe of [
91
+ { status: "dead", reason: "no host" } as const,
92
+ { status: "indeterminate", reason: "no port served" } as const,
93
+ ]) {
94
+ const before = readdirSync(dir).length;
95
+ let threw = false;
96
+ try {
97
+ await registerNativeConversation(
98
+ { backend: "antigravity", nativeSessionId: "conv-ghost", cwd: "/work/x" },
99
+ { resolveAdapter: () => fakeAdapter(probe), sessionsDir: dir },
100
+ );
101
+ } catch (err) {
102
+ threw = true;
103
+ ok(
104
+ `refuse(${probe.status}): error names the non-live probe`,
105
+ new RegExp(probe.status).test((err as Error).message),
106
+ );
107
+ }
108
+ ok(`refuse(${probe.status}): THROWS (no garden id for a non-live conversation)`, threw);
109
+ ok(`refuse(${probe.status}): NO record written`, readdirSync(dir).length === before);
110
+ }
111
+ } finally {
112
+ rmSync(dir, { recursive: true, force: true });
113
+ }
114
+
115
+ // ── RECEIVER-MARKER ABSTINENCE (보정①): source references no marker writer ───
116
+ const src = readFileSync(path.join(REPO_DIR, "pi-extensions", "lib", "native-push", "register.ts"), "utf8");
117
+ ok("no-marker: register source does NOT call writeMetaReceiverMarker", !/writeMetaReceiverMarker/.test(src));
118
+ ok("no-marker: register source does NOT reference armProvenance", !/armProvenance/i.test(src));
119
+ ok(
120
+ "no-marker: register source does NOT reference META_RECEIVER_ARM_PROVENANCES",
121
+ !/META_RECEIVER_ARM_PROVENANCES/.test(src),
122
+ );
123
+ // it DOES reuse the upsert authority (봉인 5) and the adapter probe.
124
+ ok("wiring: register reuses upsertMetaSession", /upsertMetaSession/.test(src));
125
+ ok("wiring: register probes the adapter before minting", /\.probe\(/.test(src));
126
+
127
+ console.log(`\ncheck-native-push-register: ${passed} assertions passed`);
128
+ }
129
+
130
+ void main();
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env bash
2
+ # dev-bin — expose entwurf's STABLE bins on PATH for a DEV checkout (막힘 ②). A CONSUMER (npm
3
+ # install) gets these bins on PATH from package.json "bin" bin-linking; a DEV checkout does NOT,
4
+ # because pnpm does not self-link a package's own bins. `./run.sh setup` is the dev install
5
+ # command, so setup owns this exposure via MANAGED symlinks
6
+ # <BIN_DIR>/<name> -> <target in this checkout>
7
+ # each recorded in its OWN checkout-OUTSIDE install-state (<name>.install-state.json) for an
8
+ # honest inverse.
9
+ #
10
+ # Managed bins (both are BARE names that configs record — no repo/checkout path is EVER written
11
+ # into any config; this only makes the bare name RESOLVE in a dev checkout):
12
+ # entwurf-bridge -> mcp/entwurf-bridge/start.sh (agy mcp_config command)
13
+ # entwurf-agy-statusline -> scripts/agy-statusline.sh (agy settings.statusLine command)
14
+ # entwurf-agy-imprint -> scripts/agy-imprint.sh (agy PreInvocation birth hook)
15
+ #
16
+ # Ownership discipline (mirrors the agy adopt/refuse rule, 봉인 7), applied PER BIN:
17
+ # - create/refresh ONLY a link that is OURS — absent, or a symlink into our target, or the
18
+ # exact link our state recorded (target may have moved checkouts).
19
+ # - a FOREIGN target (regular file / dir / symlink pointing elsewhere) is REFUSED, never
20
+ # clobbered (someone else's SSOT).
21
+ #
22
+ # Legacy migration (pre-multi-bin, honest): a single `install-state.json` meant entwurf-bridge.
23
+ # On expose it is ADOPTED as `entwurf-bridge.install-state.json` ONLY when its content is
24
+ # entwurf-bridge's (linkPath basename == entwurf-bridge); corrupt/foreign → refuse+report (never
25
+ # guess). New name appears first (atomic rename), old name is gone after. A re-appearing legacy
26
+ # name on a later expose is re-reported (drift visibility).
27
+ #
28
+ # expose [name] create/refresh managed link(s) (idempotent; REFUSE foreign → exit 3).
29
+ # no name = all managed bins.
30
+ # remove [name] honest inverse from state (remove only OUR link; REFUSE if it became foreign).
31
+ # no name = all managed bins.
32
+ #
33
+ # Overridable for the isolated smoke:
34
+ # ENTWURF_DEV_BIN_DIR link location (default: ~/.local/bin — where agy itself lives)
35
+ # ENTWURF_BRIDGE_TARGET entwurf-bridge target (default: $REPO/mcp/entwurf-bridge/start.sh)
36
+ # ENTWURF_AGY_STATUSLINE_TARGET entwurf-agy-statusline target (default: $REPO/scripts/agy-statusline.sh)
37
+ # ENTWURF_AGY_IMPRINT_TARGET entwurf-agy-imprint target (default: $REPO/scripts/agy-imprint.sh)
38
+ # XDG_DATA_HOME state root (states under $XDG_DATA_HOME/entwurf/dev-bin/)
39
+ set -euo pipefail
40
+
41
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
42
+ REPO_DIR="$(cd "$HERE/.." && pwd)"
43
+
44
+ BIN_DIR="${ENTWURF_DEV_BIN_DIR:-$HOME/.local/bin}"
45
+ STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/dev-bin"
46
+ LEGACY_STATE="$STATE_DIR/install-state.json" # pre-multi-bin single state == entwurf-bridge
47
+
48
+ MANAGED_BINS="entwurf-bridge entwurf-agy-statusline entwurf-agy-imprint"
49
+
50
+ log() { printf '%s\n' "$*"; }
51
+ warn() { printf '%s\n' "$*" >&2; }
52
+ fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
53
+
54
+ bin_target() { # $1 = name → prints target path (env-overridable for smoke)
55
+ case "$1" in
56
+ entwurf-bridge) echo "${ENTWURF_BRIDGE_TARGET:-$REPO_DIR/mcp/entwurf-bridge/start.sh}" ;;
57
+ entwurf-agy-statusline) echo "${ENTWURF_AGY_STATUSLINE_TARGET:-$REPO_DIR/scripts/agy-statusline.sh}" ;;
58
+ entwurf-agy-imprint) echo "${ENTWURF_AGY_IMPRINT_TARGET:-$REPO_DIR/scripts/agy-imprint.sh}" ;;
59
+ *) return 1 ;;
60
+ esac
61
+ }
62
+ state_file_for() { echo "$STATE_DIR/$1.install-state.json"; }
63
+
64
+ state_link() { # $1 = state file → recorded linkPath, or empty
65
+ [ -f "$1" ] || { echo ""; return 0; }
66
+ python3 -c 'import json,sys
67
+ try: print(json.load(open(sys.argv[1])).get("linkPath",""))
68
+ except Exception: print("")' "$1" 2>/dev/null || echo ""
69
+ }
70
+ state_target() { # $1 = state file → recorded target, or empty
71
+ [ -f "$1" ] || { echo ""; return 0; }
72
+ python3 -c 'import json,sys
73
+ try: print(json.load(open(sys.argv[1])).get("target",""))
74
+ except Exception: print("")' "$1" 2>/dev/null || echo ""
75
+ }
76
+
77
+ # Is $link ours to manage? ours = truly absent OR a symlink into our target OR the exact link our
78
+ # state recorded AND still pointing where we recorded it (moved-checkout relink stays ours; a
79
+ # foreign symlink SWAPPED IN at our recorded path is refused — GPT R). $1=link $2=target $3=state.
80
+ link_is_ours() {
81
+ local link="$1" target="$2" sf="$3"
82
+ if [ ! -e "$link" ] && [ ! -L "$link" ]; then return 0; fi # truly absent (not even dangling)
83
+ if [ -L "$link" ]; then
84
+ local cur; cur="$(readlink "$link")"
85
+ [ "$cur" = "$target" ] && return 0
86
+ [ "$(state_link "$sf")" = "$link" ] && [ "$cur" = "$(state_target "$sf")" ] && return 0
87
+ return 1 # foreign symlink
88
+ fi
89
+ return 1 # regular file / dir → foreign
90
+ }
91
+
92
+ path_has_bindir() { case ":$PATH:" in *":$BIN_DIR:"*) return 0 ;; *) return 1 ;; esac; }
93
+
94
+ write_state() { # $1=state_file $2=link $3=target $4=detect
95
+ mkdir -p "$STATE_DIR"
96
+ python3 - "$1" "$2" "$3" "$4" <<'PY'
97
+ import json, os, sys, tempfile, time
98
+ state, link, target, detect = sys.argv[1:5]
99
+ doc = {"schemaVersion": 1, "linkPath": link, "target": target, "detectMode": detect,
100
+ "stampedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
101
+ d = os.path.dirname(state)
102
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".install-state.", suffix=".json")
103
+ with os.fdopen(fd, "w") as f:
104
+ json.dump(doc, f, indent=2)
105
+ f.write("\n")
106
+ os.replace(tmp, state) # atomic
107
+ PY
108
+ }
109
+
110
+ # Adopt a pre-multi-bin single install-state.json as entwurf-bridge's, content-checked.
111
+ migrate_legacy() {
112
+ [ -f "$LEGACY_STATE" ] || return 0
113
+ local new; new="$(state_file_for entwurf-bridge)"
114
+ [ "$LEGACY_STATE" = "$new" ] && return 0 # defensive (names differ by construction)
115
+ local verdict
116
+ verdict="$(python3 - "$LEGACY_STATE" <<'PY'
117
+ import json, os, sys
118
+ try:
119
+ d = json.load(open(sys.argv[1]))
120
+ print("yes" if os.path.basename(d.get("linkPath", "")) == "entwurf-bridge" else "no")
121
+ except Exception:
122
+ print("corrupt")
123
+ PY
124
+ )"
125
+ case "$verdict" in
126
+ yes) : ;;
127
+ no) fail "dev-bin legacy migration REFUSE: $LEGACY_STATE is not an entwurf-bridge state (linkPath basename != entwurf-bridge). Resolve by hand — refusing to guess." ;;
128
+ *) fail "dev-bin legacy migration REFUSE: $LEGACY_STATE is corrupt/unreadable. Resolve by hand." ;;
129
+ esac
130
+ if [ -f "$new" ]; then
131
+ warn "[dev-bin] legacy $LEGACY_STATE re-appeared alongside $new — dropping legacy (already migrated; investigate if recurring)."
132
+ rm -f "$LEGACY_STATE"
133
+ else
134
+ mv "$LEGACY_STATE" "$new" # atomic rename: new name is present, old is gone
135
+ log "[dev-bin] migrated legacy dev-bin state → $(basename "$new")"
136
+ fi
137
+ }
138
+
139
+ expose_one() { # $1 = name
140
+ local name="$1" target link sf detect
141
+ target="$(bin_target "$name")" || fail "unknown managed bin: $name"
142
+ link="$BIN_DIR/$name"; sf="$(state_file_for "$name")"
143
+ log "[dev-bin expose] $name"
144
+ log " link: $link"
145
+ log " target: $target"
146
+ [ -x "$target" ] || fail "target is not an executable file: $target (is this a dev checkout?)"
147
+ if ! link_is_ours "$link" "$target" "$sf"; then
148
+ warn "[dev-bin] REFUSE ($name): $link exists and is NOT ours (a foreign file/symlink — e.g. a real npm consumer bin). Not clobbering someone else's SSOT."
149
+ exit 3
150
+ fi
151
+ detect="created-new"; [ -L "$link" ] && detect="refresh-ours"
152
+ mkdir -p "$BIN_DIR"
153
+ ln -sfn "$target" "$link"
154
+ write_state "$sf" "$link" "$target" "$detect"
155
+ log " ok: $name → this checkout ($detect)"
156
+ if ! path_has_bindir; then
157
+ warn "[dev-bin] WARN: $BIN_DIR is not on PATH — the bare name '$name' will NOT resolve yet. Add it to PATH (we do not touch your profile). doctor stays FAIL until then."
158
+ fi
159
+ }
160
+
161
+ remove_one() { # $1 = name
162
+ local name="$1" target link sf recorded
163
+ target="$(bin_target "$name")" || fail "unknown managed bin: $name"
164
+ link="$BIN_DIR/$name"; sf="$(state_file_for "$name")"
165
+ log "[dev-bin remove] $name"
166
+ recorded="$(state_link "$sf")"
167
+ if [ -z "$recorded" ]; then log " note: no state for $name — nothing to undo (idempotent)."; return 0; fi
168
+ if [ -L "$recorded" ]; then
169
+ if [ "$(readlink "$recorded")" = "$target" ]; then
170
+ rm -f "$recorded"; log " removed managed link: $recorded"
171
+ else
172
+ fail "refused: $recorded is now a symlink to something else (foreign) — not removing it."
173
+ fi
174
+ elif [ -e "$recorded" ]; then
175
+ fail "refused: $recorded is no longer our symlink (became a regular file/dir) — not removing it."
176
+ else
177
+ log " note: managed link already gone: $recorded"
178
+ fi
179
+ rm -f "$sf"
180
+ rmdir "$STATE_DIR" 2>/dev/null || true
181
+ }
182
+
183
+ do_expose() { # $1 = optional bin name
184
+ migrate_legacy
185
+ if [ -n "${1:-}" ]; then expose_one "$1"; else for b in $MANAGED_BINS; do expose_one "$b"; done; fi
186
+ }
187
+ do_remove() { # $1 = optional bin name
188
+ if [ -n "${1:-}" ]; then remove_one "$1"; else for b in $MANAGED_BINS; do remove_one "$b"; done; fi
189
+ }
190
+
191
+ case "${1:-}" in
192
+ expose) shift; do_expose "${1:-}" ;;
193
+ remove) shift; do_remove "${1:-}" ;;
194
+ *) echo "usage: dev-bin.sh <expose|remove> [bin-name]" >&2; exit 2 ;;
195
+ esac