@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
@@ -43,6 +43,7 @@ import type {
43
43
  SuccessReceipt,
44
44
  } from "../pi-extensions/lib/entwurf-v2-decider.ts";
45
45
  import type { LockClaim } from "../pi-extensions/lib/entwurf-v2-lock.ts";
46
+ import type { NativePushPlan, NativePushSendResult } from "../pi-extensions/lib/entwurf-v2-native-push.ts";
46
47
  import {
47
48
  type DispatchExecutorDeps,
48
49
  type EntwurfV2RunDeps,
@@ -109,6 +110,16 @@ const MAILBOX_PLAN: MetaMailboxPlan = {
109
110
  wantsReply: false,
110
111
  message: "m",
111
112
  };
113
+ const NATIVE_PUSH_PLAN: NativePushPlan = {
114
+ transport: "native-push",
115
+ action: "send",
116
+ targetGardenId: GID,
117
+ backend: "antigravity",
118
+ nativeSessionId: "conv-xyz",
119
+ route: { lsAddress: "127.0.0.1:5599" },
120
+ wantsReply: false,
121
+ message: "m",
122
+ };
112
123
 
113
124
  const SUCCESS_RECEIPT: SuccessReceipt = {
114
125
  ok: true,
@@ -128,12 +139,14 @@ interface Trace {
128
139
  controlArgs?: { plan: ControlSocketPlan; lock: LockClaim | null };
129
140
  spawnArgs?: { plan: SpawnBgPlan; lock: LockClaim | null };
130
141
  mailboxArgs?: { plan: MetaMailboxPlan; lock: LockClaim | null };
142
+ nativePushArgs?: { plan: NativePushPlan; lock: LockClaim | null };
131
143
  }
132
144
 
133
145
  interface FakeSpec {
134
146
  control?: { result: ControlSocketSendResult } | { throw: unknown };
135
147
  spawn?: { result: SpawnBgResumeResult } | { throw: unknown };
136
148
  mailbox?: { result: RpcSendResult } | { throw: unknown };
149
+ nativePush?: { result: NativePushSendResult } | { throw: unknown };
137
150
  }
138
151
 
139
152
  function makeDeps(spec: FakeSpec): { deps: DispatchExecutorDeps; trace: Trace } {
@@ -160,6 +173,13 @@ function makeDeps(spec: FakeSpec): { deps: DispatchExecutorDeps; trace: Trace }
160
173
  if ("throw" in spec.mailbox) throw spec.mailbox.throw;
161
174
  return spec.mailbox.result;
162
175
  },
176
+ async sendNativePush(plan, lock) {
177
+ trace.calls.push("sendNativePush");
178
+ trace.nativePushArgs = { plan, lock };
179
+ if (!spec.nativePush) throw new Error("test: sendNativePush called but no spec");
180
+ if ("throw" in spec.nativePush) throw spec.nativePush.throw;
181
+ return spec.nativePush.result;
182
+ },
163
183
  };
164
184
  return { deps, trace };
165
185
  }
@@ -253,6 +273,44 @@ async function main(): Promise<void> {
253
273
  );
254
274
  }
255
275
 
276
+ // ── 4b: native-push execute → sendNativePush(plan, null), lock-free (봉인 4) ──
277
+ {
278
+ const { deps, trace } = makeDeps({ nativePush: { result: { success: true, retried: false } } });
279
+ const res = await executeDispatch(executeDecision(NATIVE_PUSH_PLAN, null), deps);
280
+ ok("4b: only sendNativePush ran", trace.calls.length === 1 && trace.calls[0] === "sendNativePush");
281
+ ok(
282
+ "4b: NULL lock passed verbatim (lock-free rail)",
283
+ trace.nativePushArgs?.lock === null && trace.nativePushArgs?.plan === NATIVE_PUSH_PLAN,
284
+ );
285
+ ok(
286
+ "4b: executed{native-push, success, retried:false}",
287
+ res.kind === "executed" &&
288
+ res.outcome.transport === "native-push" &&
289
+ res.outcome.success === true &&
290
+ res.outcome.retried === false,
291
+ );
292
+ }
293
+
294
+ // ── 4c: native-push retried flag surfaces onto the executed outcome ──────────
295
+ {
296
+ const { deps } = makeDeps({ nativePush: { result: { success: true, retried: true } } });
297
+ const res = await executeDispatch(executeDecision(NATIVE_PUSH_PLAN, null), deps);
298
+ ok(
299
+ "4c: retried:true carried onto executed{native-push}",
300
+ res.kind === "executed" && res.outcome.transport === "native-push" && res.outcome.retried === true,
301
+ );
302
+ }
303
+
304
+ // ── 4d: native-push hand THROWS → execution-failed, retry-unsafe ─────────────
305
+ {
306
+ const { deps } = makeDeps({ nativePush: { throw: new Error("agy send boom") } });
307
+ const res = await executeDispatch(executeDecision(NATIVE_PUSH_PLAN, null), deps);
308
+ ok(
309
+ "4d: native-push throw → execution-failed (retrySafe:false)",
310
+ res.kind === "execution-failed" && res.transport === "native-push" && res.retrySafe === false,
311
+ );
312
+ }
313
+
256
314
  // ── 5: N3 — control rejected with rejectReason carried ────────────────────
257
315
  {
258
316
  const { deps } = makeDeps({
@@ -179,6 +179,41 @@ async function main(): Promise<void> {
179
179
  !renderEntwurfV2Result(mailbox).isError && renderEntwurfV2Result(mailbox).text.includes("enqueued"),
180
180
  );
181
181
 
182
+ // native-push → delivered (no retry)
183
+ const np: EntwurfV2RunResult = {
184
+ kind: "executed",
185
+ receipt: { ...SUCCESS_RECEIPT, transport: "native-push" },
186
+ transport: "native-push",
187
+ outcome: { transport: "native-push", success: true, retried: false },
188
+ };
189
+ ok(
190
+ "2: native-push → not error + delivered",
191
+ !renderEntwurfV2Result(np).isError && renderEntwurfV2Result(np).text.includes("delivered"),
192
+ );
193
+
194
+ // native-push delivered after a 1-shot re-probe retry → retry note surfaced
195
+ const npRetried: EntwurfV2RunResult = {
196
+ kind: "executed",
197
+ receipt: { ...SUCCESS_RECEIPT, transport: "native-push" },
198
+ transport: "native-push",
199
+ outcome: { transport: "native-push", success: true, retried: true },
200
+ };
201
+ ok(
202
+ "2: native-push retried → not error + retry note surfaced",
203
+ !renderEntwurfV2Result(npRetried).isError && renderEntwurfV2Result(npRetried).text.includes("retry"),
204
+ );
205
+
206
+ // native-push owned reject → hint to switch to fire-and-forget
207
+ const npReject: EntwurfV2RunResult = {
208
+ kind: "rejected",
209
+ receipt: { ok: false, reason: "native-push-no-resume-authority", observedLiveness: "alive" },
210
+ };
211
+ const npRej = renderEntwurfV2Result(npReject);
212
+ ok(
213
+ "2: native-push-no-resume-authority → isError + fire-and-forget hint",
214
+ npRej.isError && npRej.text.includes("fire-and-forget"),
215
+ );
216
+
182
217
  // N1: execution-failed with finalizedOutcome + releaseFailed → delivered-but-dirty
183
218
  const n1: EntwurfV2RunResult = {
184
219
  kind: "execution-failed",
@@ -0,0 +1,357 @@
1
+ /**
2
+ * check-install-surface — the structural half of the node_modules strip-types fence.
3
+ *
4
+ * Node REFUSES `--experimental-strip-types` for any .ts below node_modules. An installed
5
+ * package therefore MUST reach compiled JS for every surface an operator can invoke. That
6
+ * rule was enforced by hand, per surface, and it failed three times: the agy imprint hook
7
+ * (0.12.7 blocker) plus `doctor-pi-provider` / `new-session-id` / `meta-bridge-prune`,
8
+ * which all shipped dead under node_modules while every dev clone stayed green.
9
+ *
10
+ * check-pack-install proves the fence is crossed by DRIVING the installed commands. This
11
+ * gate proves no NEW surface can reintroduce the class, statically and with no side
12
+ * effects — sweeping every subcommand for real would run installers and LIVE gates.
13
+ *
14
+ * S1 run.sh reaches .ts ONLY through run_ts (one fence, one place).
15
+ * S2 every operator subcommand (one that is neither a check- nor a smoke- gate) that runs
16
+ * a .ts — directly OR through a helper function, which is the house style — has a
17
+ * compiled twin declared in tsconfig.build.json. The exact miss that killed the three.
18
+ * S3a no npm bin points at a raw .ts at all; S3b every .sh bin that execs one branches.
19
+ * S4 dev-only gates are NOT emitted — the tarball ships operator surfaces, not 70+ gate
20
+ * leaves (a build bloat regression is as real as a missing artifact).
21
+ * S5 no offline smoke carries an obvious write to the operator's real $HOME, and any
22
+ * smoke that swaps HOME into a sandbox swaps XDG_DATA_HOME with it — moving HOME
23
+ * alone still writes real install-state below the inherited XDG root, which is
24
+ * exactly how a verification sweep polluted a live host's provenance (2026-07-14).
25
+ *
26
+ * HONEST SCOPE — what a green run does and does not mean. S1-S4 are structural: they read the
27
+ * dispatch graph and the build manifest, so they hold for any entrypoint written in this repo's
28
+ * style. S5 is a static TRIPWIRE over shell source: it catches a literal live path and ONE hop
29
+ * of variable aliasing, and it does not see a path assembled across several variables, built
30
+ * inside an embedded python/node heredoc, or reached through a helper in another file. A green
31
+ * S5 therefore means "no obvious destructive line", NOT "verification is sandboxed". The real
32
+ * guarantee is running the whole offline floor under a swapped HOME; that is an open item in
33
+ * NEXT.md, not a claim made here. Every S was mutation-checked, including bypasses found in
34
+ * review — do not add an S without proving it fails on the bug it names.
35
+ *
36
+ * Read-only: parses sources, spawns nothing.
37
+ */
38
+
39
+ import { readdirSync, readFileSync } from "node:fs";
40
+ import path from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+
43
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
44
+ const RUN_SH = readFileSync(path.join(REPO, "run.sh"), "utf8");
45
+ const BUILD_TSCONFIG = readFileSync(path.join(REPO, "mcp/entwurf-bridge/tsconfig.build.json"), "utf8");
46
+ const PACKAGE_JSON = JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8")) as {
47
+ bin?: Record<string, string>;
48
+ };
49
+
50
+ let failed = 0;
51
+ function ok(label: string, cond: boolean, detail?: string): void {
52
+ if (cond) {
53
+ console.log(` ok ${label}`);
54
+ return;
55
+ }
56
+ failed += 1;
57
+ console.error(` FAIL ${label}`);
58
+ if (detail) console.error(detail.replace(/^/gm, " "));
59
+ }
60
+
61
+ /** Lines of run.sh with comments and the run_ts definition body excluded. */
62
+ function runShCodeLines(): { line: string; n: number }[] {
63
+ const out: { line: string; n: number }[] = [];
64
+ let inRunTs = false;
65
+ RUN_SH.split("\n").forEach((line, i) => {
66
+ if (/^run_ts\(\)\s*\{/.test(line)) inRunTs = true;
67
+ else if (inRunTs && /^\}/.test(line)) inRunTs = false;
68
+ else if (!inRunTs && !/^\s*#/.test(line)) out.push({ line, n: i + 1 });
69
+ });
70
+ return out;
71
+ }
72
+
73
+ // ─── S1 — run.sh crosses the fence in exactly one place ──────────────────────────────
74
+ {
75
+ const raw = runShCodeLines().filter(({ line }) => /node\s+--experimental-strip-types/.test(line));
76
+ ok(
77
+ "S1: run.sh reaches .ts only through run_ts (no raw strip-types call sites)",
78
+ raw.length === 0,
79
+ raw.map(({ line, n }) => `run.sh:${n}: ${line.trim()}`).join("\n"),
80
+ );
81
+ }
82
+
83
+ // ─── S2/S4 — operator commands are compiled; dev gates are not ───────────────────────
84
+ const emitted = new Set(
85
+ [...BUILD_TSCONFIG.matchAll(/"\.\.\/\.\.\/scripts\/([a-z0-9-]+)\.ts"/g)].map((m) => m[1] as string),
86
+ );
87
+
88
+ /**
89
+ * subcommand → every .ts it can reach.
90
+ *
91
+ * The naive version — read the `run_ts` call sitting directly under a `case` label — is what the
92
+ * dominant style in this file does NOT look like: nearly every subcommand delegates to a function
93
+ * (`check-shell-quote) check_shell_quote ;;`). A gate that only sees direct calls would pass a new
94
+ * operator command written in the house style with no compiled twin, which is the very bug this
95
+ * file exists to prevent. So resolve through function bodies too.
96
+ */
97
+ const RUN_TS_CALL = /run_ts\s+scripts\/([a-z0-9-]+)\.ts/;
98
+
99
+ function functionTargets(): Map<string, string[]> {
100
+ const byFn = new Map<string, string[]>();
101
+ let fn: string | null = null;
102
+ for (const { line } of runShCodeLines()) {
103
+ // Read the definition line's own tail too: `f() { run_ts scripts/x.ts; }` is a single line,
104
+ // and a parser that only looks at SUBSEQUENT lines walks right past it.
105
+ const def = line.match(/^(?:function\s+)?([a-z_][a-z0-9_]*)\s*\(\)\s*\{(.*)$/);
106
+ if (def) {
107
+ fn = def[1] as string;
108
+ byFn.set(fn, []);
109
+ const tail = def[2] ?? "";
110
+ const inlineCall = tail.match(RUN_TS_CALL);
111
+ if (inlineCall) byFn.get(fn)?.push(inlineCall[1] as string);
112
+ if (tail.includes("}")) fn = null; // one-liner: body opened and closed here
113
+ continue;
114
+ }
115
+ if (fn && /^\}/.test(line)) {
116
+ fn = null;
117
+ continue;
118
+ }
119
+ const call = line.match(RUN_TS_CALL);
120
+ if (fn && call) byFn.get(fn)?.push(call[1] as string);
121
+ }
122
+ return byFn;
123
+ }
124
+
125
+ function subcommandTargets(): Map<string, string[]> {
126
+ const byFn = functionTargets();
127
+ const map = new Map<string, string[]>();
128
+ let current: string | null = null;
129
+ let inCaseBody = false;
130
+ for (const { line } of runShCodeLines()) {
131
+ const label = line.match(/^\s{2}([a-z][a-z0-9-]*)\)\s*$/);
132
+ if (label) {
133
+ current = label[1] as string;
134
+ inCaseBody = true;
135
+ map.set(current, []);
136
+ continue;
137
+ }
138
+ if (!current || !inCaseBody) continue;
139
+ if (/^\s{4};;\s*$/.test(line)) {
140
+ inCaseBody = false;
141
+ continue;
142
+ }
143
+ const direct = line.match(/run_ts\s+scripts\/([a-z0-9-]+)\.ts/);
144
+ if (direct) map.get(current)?.push(direct[1] as string);
145
+ // …and one hop through a helper function, which is how most subcommands are written.
146
+ for (const word of line.trim().split(/[\s;|&(]+/)) {
147
+ const viaFn = byFn.get(word);
148
+ if (viaFn?.length) map.get(current)?.push(...viaFn);
149
+ }
150
+ }
151
+ return map;
152
+ }
153
+
154
+ const targets = subcommandTargets();
155
+ const isDevGate = (cmd: string): boolean => cmd.startsWith("check-") || cmd.startsWith("smoke-");
156
+ const operatorCmds = [...targets].filter(([cmd, ts]) => !isDevGate(cmd) && ts.length > 0);
157
+
158
+ {
159
+ const dead = operatorCmds.flatMap(([cmd, tss]) => tss.filter((ts) => !emitted.has(ts)).map((ts) => [cmd, ts]));
160
+ ok(
161
+ `S2: every operator subcommand has a compiled twin (${operatorCmds.length} operator surfaces, direct + via helper)`,
162
+ dead.length === 0,
163
+ dead
164
+ .map(
165
+ ([cmd, ts]) =>
166
+ `'entwurf ${cmd}' runs scripts/${ts}.ts, which tsconfig.build.json does not emit —\n` +
167
+ `it will die under node_modules with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.\n` +
168
+ `Add "../../scripts/${ts}.ts" to the build include and to the check-pack artifact list.`,
169
+ )
170
+ .join("\n\n"),
171
+ );
172
+
173
+ const bloat = [...targets].flatMap(([cmd, tss]) =>
174
+ isDevGate(cmd) ? tss.filter((ts) => emitted.has(ts)).map((ts) => [cmd, ts]) : [],
175
+ );
176
+ ok(
177
+ "S4: dev-only gates are not emitted into the tarball",
178
+ bloat.length === 0,
179
+ bloat.map(([cmd, ts]) => `'${cmd}' is a dev gate but scripts/${ts}.ts is in the build include`).join("\n"),
180
+ );
181
+ }
182
+
183
+ // ─── S3 — no npm bin can reach a raw .ts when installed ──────────────────────────────
184
+ {
185
+ // Two ways a bin dies under node_modules: it IS a .ts, or it is a wrapper that execs one
186
+ // without branching. The first is flatly forbidden — there is no correct way to point an npm
187
+ // bin at a raw .ts, since Node cannot strip types there at all.
188
+ const rawTsBins: string[] = [];
189
+ const unbranchedWrappers: string[] = [];
190
+ for (const [name, rel] of Object.entries(PACKAGE_JSON.bin ?? {})) {
191
+ if (/\.ts$/.test(rel)) {
192
+ rawTsBins.push(`${name} → ${rel}`);
193
+ continue;
194
+ }
195
+ if (!rel.endsWith(".sh")) continue;
196
+ const src = readFileSync(path.join(REPO, rel), "utf8");
197
+ const execsTs = /--experimental-strip-types[^\n]*\.ts|\.ts"?\s*$/m.test(src) && /\.ts/.test(src);
198
+ if (!execsTs) continue; // python3/JS-only wrappers (the statuslines) never hit the fence
199
+ if (!/node_modules\/\*/.test(src)) unbranchedWrappers.push(rel);
200
+ }
201
+ ok(
202
+ "S3a: no npm bin points at a raw .ts (Node cannot strip types under node_modules at all)",
203
+ rawTsBins.length === 0,
204
+ rawTsBins.map((b) => `bin ${b} is a raw .ts — it can never run from an installed package`).join("\n"),
205
+ );
206
+ ok(
207
+ "S3b: every npm bin wrapper that execs a .ts branches on node_modules",
208
+ unbranchedWrappers.length === 0,
209
+ unbranchedWrappers
210
+ .map((f) => `${f} execs a .ts with no */node_modules/* branch — it dies when installed`)
211
+ .join("\n"),
212
+ );
213
+ }
214
+
215
+ // ─── S5 — verification must not rewire the operator's live host ──────────────────────
216
+ {
217
+ // A LIVE gate legitimately drives the real host (that is what LIVE means). The offline
218
+ // floor — everything in `pnpm check` — must not: it runs on every commit, and a smoke
219
+ // that writes the real ~/.claude, ~/.gemini or ~/.pi would uninstall the operator's
220
+ // own bridge as a side effect of "testing".
221
+ const LIVE_GATES = new Set(["smoke-resident-garden-guard.sh", "smoke-meta-async-drift.sh"]);
222
+
223
+ // Presence of a HOME= override SOMEWHERE in the file proves nothing — an isolated smoke
224
+ // can still carry one destructive line aimed at the real install. Look at the WRITES
225
+ // themselves: a mutation whose target is a literal $HOME/~ config path is operating on
226
+ // the operator's own bridge, whatever the rest of the file does. Reads ($HOME/.current-device,
227
+ // probing whether a real bundle exists) stay legal — they cannot uninstall anything.
228
+ const LIVE_ROOTS = String.raw`(?:\$HOME|\$\{HOME\}|~)/\.(?:claude|gemini|pi|config/pi|local/share/(?:claude|entwurf))`;
229
+ const MUTATORS = String.raw`rm\s+-\w+|rm|mv|cp|install|mkdir\s+-p|mkdir|touch|tee|ln\s+-s\w*|truncate`;
230
+
231
+ // A mutating run.sh drive is shell code, not prose. `bad "… re-run ./run.sh install-meta-bridge"`
232
+ // and a python assertion string both carry the same token, so normalize a line to its code
233
+ // before looking for a drive: drop message arguments, keep "$RUN" / "$REPO/run.sh" as the
234
+ // executable tokens they are, and blank every other quoted literal.
235
+ const MSG_ARG = /\b(?:bad|ok|echo|log|warn|fail|printf)\s+"(?:[^"\\]|\\.)*"/g;
236
+ const EXEC_TOKEN = /"(\$\{?RUN\}?|\$[A-Za-z_]\w*\/run\.sh|\.{0,2}\/?run\.sh)"/g;
237
+ const shellCode = (l: string) =>
238
+ l
239
+ .replace(MSG_ARG, "")
240
+ .replace(EXEC_TOKEN, "$1")
241
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
242
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''");
243
+ const RUNSH_DRIVE = /(?:\$\{?RUN\}?|run\.sh)\s+(install|setup:links|setup|remove-user-scope|remove)\b/;
244
+ // ensure_agent_dir_symlinks() hard-codes "$HOME/.pi/agent" and ignores PI_CODING_AGENT_DIR
245
+ // entirely (run.sh:424), so these commands relink the operator's REAL agent dir no matter how
246
+ // the override is set. Sandboxing the agent dir is not isolation for them; only HOME is.
247
+ const HOME_ROOTED = new Set(["install", "setup", "setup:links"]);
248
+
249
+ const offenders: { file: string; line: number; text: string }[] = [];
250
+ const xdgOffenders: string[] = [];
251
+ const agentDirXdgOffenders: string[] = [];
252
+ for (const f of readdirSync(path.join(REPO, "scripts"))) {
253
+ if (!/^(smoke|check)-.*\.sh$/.test(f) || LIVE_GATES.has(f)) continue;
254
+ const lines = readFileSync(path.join(REPO, "scripts", f), "utf8").split("\n");
255
+
256
+ // The install smokes DO write "$HOME/.claude/…" — legally, because they first swap the
257
+ // process HOME to a sandbox (`export HOME="$TMP/home"`), after which $HOME no longer names
258
+ // the operator's home at all. So the offense is not "writes $HOME"; it is writing a live
259
+ // path while $HOME still points at the real one: with no swap, or before the swap.
260
+ const swapAt = lines.findIndex((l) => /^\s*export\s+HOME=/.test(l) && !/(\$HOME|~)\//.test(l));
261
+
262
+ // A HOME swap DECLARES isolation — and isolation that inherits the operator's real
263
+ // XDG_DATA_HOME is a lie: every install adapter roots its state at
264
+ // ${XDG_DATA_HOME:-$HOME/.local/share}/entwurf, so the "sandboxed" run keeps writing
265
+ // real state records whose managed targets are sandbox paths. That is the exact leak
266
+ // that broke a live host's provenance on 2026-07-14 (AGENTS.md hard rule 11: swap BOTH).
267
+ // The swap must not itself lean on the unswapped variable ($XDG_DATA_HOME on the RHS).
268
+ const xdgSwapAt = lines.findIndex((l) => /^\s*export\s+XDG_DATA_HOME=/.test(l) && !/\$\{?XDG_DATA_HOME/.test(l));
269
+ const xdgSwapped = xdgSwapAt !== -1;
270
+ if (swapAt !== -1 && !xdgSwapped) xdgOffenders.push(f);
271
+
272
+ // A mutating run.sh drive writes through THREE roots, and no single override covers them:
273
+ // the agent dir (settings target), ${XDG_DATA_HOME}/entwurf (the ownership state whose
274
+ // recorded managedSettingsPath an inverse FOLLOWS), and $HOME (the hard-coded agent-dir
275
+ // symlinks). smoke-user-scope-citizen sandboxed the first and passed the operator's real
276
+ // second, so `pnpm check` removed the live MCP key while reporting green (2026-07-14).
277
+ // Find the drive, then demand what that command actually needs — in whatever env form
278
+ // carries it. The leak shipped as inline env; the same drive hoisted into an `export` is
279
+ // the same leak, and a tripwire that only sees one syntactic form is how the next one ships.
280
+ const agentDirExportAt = lines.findIndex((l) => /^\s*export\s+PI_CODING_AGENT_DIR=/.test(l));
281
+ lines.forEach((line, i) => {
282
+ if (/^\s*#/.test(line)) return;
283
+ const code = shellCode(line);
284
+ const drive = code.match(RUNSH_DRIVE);
285
+ if (!drive) return;
286
+ const cmd = drive[1] as string;
287
+ const homeOk = /(?:^|\s)HOME=/.test(code) || (swapAt !== -1 && i > swapAt);
288
+ const xdgOk = /XDG_DATA_HOME=/.test(code) || (xdgSwapAt !== -1 && i > xdgSwapAt);
289
+ const agentOk = /PI_CODING_AGENT_DIR=/.test(code) || (agentDirExportAt !== -1 && i > agentDirExportAt) || homeOk;
290
+ const missing: string[] = [];
291
+ if (!xdgOk)
292
+ missing.push(
293
+ "XDG_DATA_HOME (real ownership state — an inverse follows its managedSettingsPath to the live host)",
294
+ );
295
+ if (!agentOk) missing.push("PI_CODING_AGENT_DIR or HOME (real ~/.pi/agent/settings.json)");
296
+ if (HOME_ROOTED.has(cmd) && !homeOk)
297
+ missing.push(
298
+ "HOME (ensure_agent_dir_symlinks hard-codes $HOME/.pi/agent — the agent-dir override does not reach it)",
299
+ );
300
+ if (missing.length)
301
+ agentDirXdgOffenders.push(
302
+ `scripts/${f}:${i + 1} drives \`run.sh ${cmd}\` without a sandbox ${missing.join("\n and without a sandbox ")}`,
303
+ );
304
+ });
305
+
306
+ // One hop of aliasing: `VICTIM="$HOME/.gemini/…"` taints VICTIM, so `rm -rf "$VICTIM"` is
307
+ // caught too. Without this, renaming the path into a variable walks straight past the
308
+ // tripwire — which it did, until a review mutation proved it.
309
+ const tainted = new Set<string>();
310
+ lines.forEach((line, i) => {
311
+ if (/^\s*#/.test(line)) return;
312
+ const assign = line.match(new RegExp(String.raw`^\s*(?:local\s+|export\s+)?([A-Za-z_]\w*)=["']?${LIVE_ROOTS}`));
313
+ if (assign && (swapAt === -1 || i < swapAt)) tainted.add(assign[1] as string);
314
+ });
315
+ const aliasAlt = tainted.size
316
+ ? String.raw`|(?:${MUTATORS})\s+(?:-\w+\s+)*["']?\$\{?(?:${[...tainted].join("|")})\b`
317
+ : "";
318
+ const WRITE_TO_LIVE = new RegExp(
319
+ String.raw`(?:(?:${MUTATORS})\s+(?:-\w+\s+)*["']?${LIVE_ROOTS}|>>?\s*["']?${LIVE_ROOTS}${aliasAlt})`,
320
+ );
321
+
322
+ lines.forEach((line, i) => {
323
+ if (/^\s*#/.test(line)) return;
324
+ if (!WRITE_TO_LIVE.test(line)) return;
325
+ const sandboxed = swapAt !== -1 && i > swapAt;
326
+ if (!sandboxed) offenders.push({ file: f, line: i + 1, text: line.trim() });
327
+ });
328
+ }
329
+ ok(
330
+ "S5: no offline smoke carries an obvious write to the real $HOME (static tripwire, not a sandbox proof)",
331
+ offenders.length === 0,
332
+ offenders.map((o) => `scripts/${o.file}:${o.line} mutates the operator's live install:\n ${o.text}`).join("\n"),
333
+ );
334
+ ok(
335
+ "S5b: every offline smoke that swaps HOME also swaps XDG_DATA_HOME (HOME alone still writes real install-state)",
336
+ xdgOffenders.length === 0,
337
+ xdgOffenders
338
+ .map(
339
+ (f) =>
340
+ `scripts/${f} exports a sandbox HOME but never exports a sandbox XDG_DATA_HOME —\n` +
341
+ `its install-state writes land under the operator's real ~/.local/share/entwurf.`,
342
+ )
343
+ .join("\n"),
344
+ );
345
+ ok(
346
+ "S5c: every mutating run.sh drive in the offline floor is sandboxed at every root it writes",
347
+ agentDirXdgOffenders.length === 0,
348
+ agentDirXdgOffenders.join("\n"),
349
+ );
350
+ }
351
+
352
+ console.log(
353
+ failed === 0
354
+ ? "\ncheck-install-surface: install surface is structurally fenced"
355
+ : `\ncheck-install-surface: ${failed} FAILED`,
356
+ );
357
+ process.exit(failed === 0 ? 0 : 1);