@junghanacs/entwurf 0.41.2 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +110 -1
  3. package/CHANGELOG.md +328 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/ENTWURF-CLI.json +1229 -0
  6. package/ENTWURF.json +284 -0
  7. package/FAQ.md +4 -4
  8. package/README.md +125 -184
  9. package/VERIFY.md +2 -2
  10. package/docs/external-mcp-host.md +3 -3
  11. package/docs/setup-clean-host.md +37 -12
  12. package/mcp/entwurf-bridge/dist/ENTWURF.json +284 -0
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +46 -196
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/agent-guidance.js +299 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +21 -25
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +498 -29
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +23 -20
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +68 -21
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +7 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-location.js +347 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js +1132 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +1 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +7 -4
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
  29. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -60
  30. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
  31. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
  33. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  34. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
  35. package/mcp/entwurf-bridge/src/index.ts +149 -327
  36. package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
  37. package/native/win32-x64/manifest.json +1 -1
  38. package/package.json +8 -4
  39. package/pi/pi-durable/bootstrap.mjs +179 -6
  40. package/pi/pi-durable/carrier/runtime.js +108 -9
  41. package/pi/pi-durable/carrier/session-name.js +11 -0
  42. package/pi/pi-durable/carrier/sessions.js +96 -20
  43. package/pi/pi-durable/overlay/upstream-pin.json +11 -5
  44. package/pi-extensions/lib/agent-guidance.ts +341 -0
  45. package/pi-extensions/lib/entwurf-v2-surface.ts +25 -31
  46. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +725 -54
  47. package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
  48. package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
  49. package/pi-extensions/lib/herdr-resume-call.ts +810 -0
  50. package/pi-extensions/lib/meta-session.ts +13 -8
  51. package/pi-extensions/lib/mux-fresh-call.ts +23 -26
  52. package/pi-extensions/lib/mux-resume-call.ts +95 -23
  53. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +9 -2
  54. package/pi-extensions/lib/pi-durable-location.ts +446 -0
  55. package/pi-extensions/lib/pi-durable-owner-fence.ts +1461 -0
  56. package/pi-extensions/lib/pi-durable-runtime.ts +4 -0
  57. package/pi-extensions/lib/remote-pair-send.ts +7 -4
  58. package/pi-extensions/lib/resume-launch-identity.ts +31 -12
  59. package/pi-extensions/meta-bridge-pi-durable.ts +52 -79
  60. package/run.sh +417 -654
  61. package/scripts/build-bridge.sh +10 -0
  62. package/scripts/check-capability-bundle-reach.ts +86 -0
  63. package/scripts/check-claude-launch.ts +341 -0
  64. package/scripts/check-entwurf-self-address.ts +3 -3
  65. package/scripts/check-entwurf-v2-surface.ts +94 -98
  66. package/scripts/check-fresh-call-dispatch.ts +70 -9
  67. package/scripts/check-fresh-cut-gate.sh +23 -9
  68. package/scripts/check-gate-qualification.ts +24 -17
  69. package/scripts/check-herdr-activation.ts +301 -23
  70. package/scripts/check-herdr-fresh-call.ts +161 -25
  71. package/scripts/check-herdr-plugin-build.ts +173 -1
  72. package/scripts/check-herdr-plugin.ts +28 -2
  73. package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
  74. package/scripts/check-install-surface.ts +1236 -6
  75. package/scripts/check-meta-doctor-oracle.sh +1 -1
  76. package/scripts/check-meta-facts.ts +20 -1
  77. package/scripts/check-mux-resume-call.ts +48 -3
  78. package/scripts/check-peer-facts.ts +39 -2
  79. package/scripts/cli-help.mjs +222 -0
  80. package/scripts/dev-bin.sh +39 -2
  81. package/scripts/herdr-activation.mjs +28 -6
  82. package/scripts/herdr-operator-link.mjs +389 -0
  83. package/scripts/herdr-plugin-activate.mjs +5 -0
  84. package/scripts/herdr-plugin-deactivate.mjs +18 -5
  85. package/scripts/herdr-rails.mjs +43 -7
  86. package/scripts/herdr-runtime.mjs +27 -0
  87. package/scripts/meta-bridge-fresh-cut.ts +1 -27
  88. package/scripts/meta-bridge-prune.ts +1 -1
  89. package/scripts/meta-facts.ts +1 -1
  90. package/scripts/mutants/agent-guidance.json +160 -0
  91. package/scripts/mutants/bridge-boot-resume.json +6 -8
  92. package/scripts/mutants/capability-bundle-reach.json +124 -0
  93. package/scripts/mutants/claude-launch.json +142 -0
  94. package/scripts/mutants/cli-help-source.json +420 -0
  95. package/scripts/mutants/control-send-receipt.json +21 -4
  96. package/scripts/mutants/dev-gate-dispatch.json +109 -0
  97. package/scripts/mutants/fresh-call-dispatch.json +38 -2
  98. package/scripts/mutants/fresh-cut.json +15 -0
  99. package/scripts/mutants/herdr-activation.json +128 -0
  100. package/scripts/mutants/herdr-fresh-call.json +278 -5
  101. package/scripts/mutants/herdr-operator-link.json +318 -0
  102. package/scripts/mutants/herdr-plugin-build.json +55 -0
  103. package/scripts/mutants/herdr-plugin.json +17 -0
  104. package/scripts/mutants/herdr-resume-call.json +288 -0
  105. package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
  106. package/scripts/mutants/meta-facts.json +15 -0
  107. package/scripts/mutants/mux-fresh-call.json +14 -3
  108. package/scripts/mutants/mux-resume-call.json +89 -3
  109. package/scripts/mutants/peer-facts.json +15 -0
  110. package/scripts/mutants/pi-durable.json +996 -6
  111. package/scripts/mutants/remote-pair-send.json +21 -2
  112. package/scripts/mutants/self-address.json +10 -10
  113. package/scripts/mutants/source-install.json +35 -0
  114. package/scripts/mutants/v2-surface.json +50 -64
  115. package/scripts/mutants/v2-visible-resume.json +266 -6
  116. package/scripts/peer-facts.ts +1 -1
  117. package/scripts/smoke-agy-install-state.sh +66 -0
  118. package/scripts/smoke-meta-prune.sh +23 -0
  119. package/scripts/smoke-omp-fresh-live.ts +12 -3
  120. package/scripts/smoke-omp-receive-live.ts +7 -1
  121. package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
  122. package/team.json.example +39 -0
@@ -19,6 +19,16 @@ TSC="$REPO_DIR/node_modules/.bin/tsc"
19
19
  [ -x "$TSC" ] || TSC=tsc
20
20
  "$TSC" -p "$REPO_DIR/mcp/entwurf-bridge/tsconfig.build.json"
21
21
 
22
+ # One authored product-guidance source (#154). The fixed module-relative reader resolves
23
+ # ../../ENTWURF.json from dist/pi-extensions/lib/, so its deterministic exact copy belongs at
24
+ # the DIST ROOT, not beside the capability registry. This is transport, never another source.
25
+ GUIDANCE_SOURCE="$REPO_DIR/ENTWURF.json"
26
+ if [ ! -f "$GUIDANCE_SOURCE" ] || [ -L "$GUIDANCE_SOURCE" ]; then
27
+ echo "build-bridge: $GUIDANCE_SOURCE is missing or not a regular file" >&2
28
+ exit 1
29
+ fi
30
+ cp "$GUIDANCE_SOURCE" "$REPO_DIR/mcp/entwurf-bridge/dist/ENTWURF.json"
31
+
22
32
  # The capability registry must TRAVEL WITH the bundle.
23
33
  #
24
34
  # `metaCapabilitiesFilePath()` computes its path from the module's own location.
@@ -32,6 +32,8 @@
32
32
  * the installer copies, and the hook staged with only that closure runs.
33
33
  * - D (#147 G3): the compiled Claude Code retire adapter resolves its helper beside itself,
34
34
  * byte-identical to pi-extensions/lib/cc-retire-pidfd.py.
35
+ * - F (#154): the fixed guidance carrier has the canonical Root bytes, and its emitted
36
+ * reader exports that Root's version/tools (plain JSON expected data, not a source reader).
35
37
  *
36
38
  * Deterministic: filesystem, import, and ONE shipped-hook child process (C2) that writes a
37
39
  * record into its own private store under a private HOME and XDG roots. No backend, vendor
@@ -48,6 +50,8 @@ import fs from "node:fs";
48
50
  import os from "node:os";
49
51
  import path from "node:path";
50
52
  import { pathToFileURL } from "node:url";
53
+ import { isDeepStrictEqual } from "node:util";
54
+ import { FRESH_CUT_PRESCRIPTION } from "../pi-extensions/lib/meta-session.ts";
51
55
  import { hookImportClosure, installerCopiedLibFiles, stageMetaHookBundle } from "./lib/meta-hook-bundle.ts";
52
56
  import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
53
57
 
@@ -260,4 +264,86 @@ for (const copy of copies) {
260
264
  );
261
265
  }
262
266
 
267
+ // F — one guidance carrier/current-reader contract, rooted in canonical authored bytes (#154).
268
+ // Presence/type are preconditions. The reader is NOT first loaded here: since #154 C1-C the compiled
269
+ // retire adapter that D imports reaches it through entwurf-v2-visible-resume, so the module is
270
+ // already evaluated and cached. A carrier that still parses and validates (a byte divergence) loaded
271
+ // fine there and fails the intended F1 byte assertion; a carrier the reader refuses already threw at
272
+ // D's import, unlabelled. The import below is skipped on divergence so F1 stays the reported cause.
273
+ {
274
+ const carrier = path.join(BRIDGE_DIST, "ENTWURF.json");
275
+ const reader = path.join(BRIDGE_DIST, "pi-extensions", "lib", "agent-guidance.js");
276
+ for (const file of [carrier, reader]) {
277
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
278
+ ok(
279
+ `F0: the compiled guidance file is regular and non-symlink: ${rel(file)}`,
280
+ stat !== undefined && stat.isFile() && !stat.isSymbolicLink(),
281
+ );
282
+ }
283
+ const rootBytes = fs.readFileSync(path.join(REPO, "ENTWURF.json"));
284
+ const root = JSON.parse(rootBytes.toString("utf8"));
285
+ const sameBytes = fs.readFileSync(carrier).equals(rootBytes);
286
+ const loaded = sameBytes ? await import(pathToFileURL(reader).href) : undefined;
287
+ ok(
288
+ "F1: the fixed guidance carrier has canonical Root bytes and its reader exports the current version/tools [QK:BUNDLE-REACH-GUIDANCE-CARRIER]",
289
+ sameBytes &&
290
+ isDeepStrictEqual(loaded?.ENTWURF_GUIDANCE_VERSION, {
291
+ schemaVersion: root.schemaVersion,
292
+ revision: root.revision,
293
+ }) &&
294
+ isDeepStrictEqual(loaded?.ENTWURF_TOOL_GUIDANCE, root.tools),
295
+ );
296
+ // F2 — the second authored family reaches the compiled reader too. Separate from F1 so a reader
297
+ // that mis-exports only the hints fails here while the carrier and tool payload stay green.
298
+ ok(
299
+ "F2: the compiled reader exports the canonical Root static reject hints [QK:BUNDLE-REACH-REJECT-HINTS]",
300
+ sameBytes && isDeepStrictEqual(loaded?.ENTWURF_REJECT_HINTS, root.rejectHints),
301
+ );
302
+ // F3 — the third family (#154 C1-C), separate for the same reason as F2.
303
+ ok(
304
+ "F3: the compiled reader exports the canonical Root visible-resume reject hints [QK:BUNDLE-REACH-RESUME-REJECT-HINTS]",
305
+ sameBytes && isDeepStrictEqual(loaded?.ENTWURF_RESUME_REJECT_HINTS, root.resumeRejectHints),
306
+ );
307
+ // F4 — the two tmux launch-stage families (#154 C1-D), one assertion because they enter together;
308
+ // this proves the compiled reader's exports, not the compiled mux renderers that read them.
309
+ ok(
310
+ "F4: the compiled reader exports the canonical Root fresh and resume launch-stage reject hints [QK:BUNDLE-REACH-LAUNCH-REJECT-HINTS]",
311
+ sameBytes &&
312
+ isDeepStrictEqual(loaded?.ENTWURF_FRESH_LAUNCH_REJECT_HINTS, root.freshLaunchRejectHints) &&
313
+ isDeepStrictEqual(loaded?.ENTWURF_RESUME_LAUNCH_REJECT_HINTS, root.resumeLaunchRejectHints),
314
+ );
315
+ // F5 — the delivery-result clauses (#154 E1a), separate for the same reason as F2; this proves the
316
+ // compiled reader's export, not the compiled receipt renderers that read it.
317
+ ok(
318
+ "F5: the compiled reader exports the canonical Root delivery-result clauses [QK:BUNDLE-REACH-DELIVERY-RESULT-CLAUSES]",
319
+ sameBytes && isDeepStrictEqual(loaded?.ENTWURF_DELIVERY_RESULT_CLAUSES, root.deliveryResultClauses),
320
+ );
321
+ // F6 — the slotted reject-hint segments (#154 E1b), separate for the same reason as F2; this proves the
322
+ // compiled reader's export, not the compiled surface that composes them with the code-owned slot.
323
+ ok(
324
+ "F6: the compiled reader exports the canonical Root slotted reject-hint segments [QK:BUNDLE-REACH-SLOTTED-REJECT-HINTS]",
325
+ sameBytes && isDeepStrictEqual(loaded?.ENTWURF_SLOTTED_REJECT_HINTS, root.slottedRejectHints),
326
+ );
327
+ // F7 — the first COMPILED CONSUMER proof (#154 E1b): the compiled surface, not only the reader, renders
328
+ // the record-less-socket hint from the Root segments around the compiled owner's fix command, and that
329
+ // command is the source owner's value (an emptied or drifted slot cannot pass by composing with itself).
330
+ // Imported only when the carrier matches, for the same reason as the reader import above.
331
+ const surface = sameBytes
332
+ ? await import(pathToFileURL(path.join(BRIDGE_DIST, "pi-extensions", "lib", "entwurf-v2-surface.js")).href)
333
+ : undefined;
334
+ const metaSession = sameBytes
335
+ ? await import(pathToFileURL(path.join(BRIDGE_DIST, "pi-extensions", "lib", "meta-session.js")).href)
336
+ : undefined;
337
+ const rls = root.slottedRejectHints["record-less-socket"];
338
+ ok(
339
+ "F7: the compiled surface renders the record-less-socket hint as the Root text around the compiled owner's fix command, which equals the source owner's [QK:BUNDLE-REACH-RECORD-LESS-SOCKET-CONSUMER]",
340
+ sameBytes &&
341
+ typeof metaSession?.FRESH_CUT_PRESCRIPTION === "string" &&
342
+ metaSession.FRESH_CUT_PRESCRIPTION.length > 0 &&
343
+ metaSession.FRESH_CUT_PRESCRIPTION === FRESH_CUT_PRESCRIPTION &&
344
+ surface?.actionableRejectHint("record-less-socket") ===
345
+ `${rls.beforeFreshCutPrescription}${FRESH_CUT_PRESCRIPTION}${rls.afterFreshCutPrescription}`,
346
+ );
347
+ }
348
+
263
349
  console.log(`\ncheck-capability-bundle-reach: PASS (${passed} assertions)`);
@@ -0,0 +1,341 @@
1
+ /**
2
+ * check-claude-launch — deterministic gate for the Claude Code launch (`entwurf claude`, #157 stage 4).
3
+ * Hermetic: no real Claude Code, no network, no model turn, no write outside its own temp root. Linux
4
+ * only: the fake vendor reads its own environment from /proc/$$/environ (it runs in the Linux check job).
5
+ *
6
+ * SAME ORACLE AS check-pi-launch and check-copilot-launch: the subject is a process REPLACEMENT, so
7
+ * the truth is a FAKE VENDOR — a real executable named `claude` on a sandbox PATH that reports the
8
+ * argv, cwd, environment and pid it was actually handed. Nothing below is asserted by reading the
9
+ * launcher's source, and the launcher is driven through its PUBLIC address (`run.sh claude`),
10
+ * because the dispatcher's own argv handling — the help preflight included — is part of the contract.
11
+ *
12
+ * WHAT THE LAUNCH PROMISES, AND WHAT IT DOES NOT. It is a spelling: the vendor's argv, cwd, exit
13
+ * status and signal are Claude Code's own, and `--help` is Claude Code's help, not an Entwurf page.
14
+ * It adds nothing to the argv. Its only environment changes are the recursion sentinel and the
15
+ * REMOVAL of an inherited PI_SESSION_ID/PI_AGENT_ID pair, which is another citizen's identity. It
16
+ * does not promise a garden citizen: that comes from the meta-bridge plugin and hooks `setup`
17
+ * installed, which this launch neither checks nor supplies, so nothing here models them.
18
+ *
19
+ * WHY TWO RECURSION FENCES WHEN PI HAS ONE. pi's single fence rests on the flag it injects: a PATH
20
+ * `pi` symlinked to run.sh re-enters as the unknown verb `--entwurf-control`. This launch injects
21
+ * nothing, so the same symlink would re-enter with the operator's argv as the VERB. The resolved-path
22
+ * fence refuses that, and the cell measuring it hands the loop an EMPTY argv on purpose: with the
23
+ * fence mutated away, the re-entry prints the root page and exits 0 instead of running any verb.
24
+ *
25
+ * TWO CELLS CARRY NO MUTANT, and the reason is measured coupling rather than a gap. run.sh is
26
+ * `set -euo pipefail`, so dropping `exec` still propagates a non-zero vendor status (set -e exits
27
+ * with it) and turns a vendor death by signal into a status of 128+n. The mutation that breaks the
28
+ * exit or signal claim is therefore the one that breaks the pid claim, and the pid cell is asserted
29
+ * first so that mutant lands on exactly one QK. The exit and signal cells stand on their assertions.
30
+ */
31
+
32
+ import assert from "node:assert/strict";
33
+ import { spawnSync } from "node:child_process";
34
+ import {
35
+ chmodSync,
36
+ existsSync,
37
+ mkdirSync,
38
+ mkdtempSync,
39
+ readFileSync,
40
+ realpathSync,
41
+ rmSync,
42
+ symlinkSync,
43
+ writeFileSync,
44
+ } from "node:fs";
45
+ import { tmpdir } from "node:os";
46
+ import * as path from "node:path";
47
+ import { fileURLToPath } from "node:url";
48
+
49
+ const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
50
+ const RUN_SH = path.join(REPO, "run.sh");
51
+ const SENTINEL = "ENTWURF_CLAUDE_LAUNCH_ACTIVE";
52
+ /** The variables bash itself maintains across a script and an exec — not the launcher's choice. */
53
+ const BASH_OWNED = new Set(["SHLVL", "_", "PWD", "OLDPWD"]);
54
+
55
+ let passed = 0;
56
+ function ok(label: string, cond: boolean, detail = ""): void {
57
+ assert.ok(cond, detail ? `${label}\n${detail}` : label);
58
+ console.log(` ok ${label}`);
59
+ passed++;
60
+ }
61
+
62
+ const root = mkdtempSync(path.join(tmpdir(), "entwurf-claude-launch."));
63
+ try {
64
+ const bin = path.join(root, "bin");
65
+ const work = path.join(root, "work");
66
+ mkdirSync(bin, { recursive: true });
67
+ mkdirSync(work, { recursive: true });
68
+ const envDump = path.join(root, "vendor.environ");
69
+
70
+ // The fake vendor, installed under the REAL name `claude`. It reports what exec handed it: its
71
+ // own initial environment is read from the kernel (/proc/$$/environ), not from its shell's view,
72
+ // so bash's own bookkeeping inside the fake is not mistaken for the launcher's.
73
+ const vendor = path.join(bin, "claude");
74
+ writeFileSync(
75
+ vendor,
76
+ `#!/usr/bin/env bash
77
+ echo "PID=$$"
78
+ echo "CWD=$(pwd -P)"
79
+ echo "SENTINEL=[\${${SENTINEL}-<unset>}]"
80
+ cat "/proc/$$/environ" >"$FAKE_CLAUDE_ENV_OUT"
81
+ for a in "$@"; do printf 'ARG<%s>\\n' "$a"; done
82
+ if [ -n "\${FAKE_CLAUDE_SIGNAL:-}" ]; then kill -s "$FAKE_CLAUDE_SIGNAL" "$$"; sleep 5; fi
83
+ exit "\${FAKE_CLAUDE_EXIT:-0}"
84
+ `,
85
+ );
86
+ chmodSync(vendor, 0o755);
87
+
88
+ // TOTAL sandbox, as the sibling launch gates: the launcher writes nothing, but it EXECS a vendor.
89
+ const sandboxEnv = {
90
+ HOME: path.join(root, "home"),
91
+ XDG_DATA_HOME: path.join(root, "xdg", "data"),
92
+ XDG_CONFIG_HOME: path.join(root, "xdg", "config"),
93
+ XDG_STATE_HOME: path.join(root, "xdg", "state"),
94
+ XDG_CACHE_HOME: path.join(root, "xdg", "cache"),
95
+ XDG_RUNTIME_DIR: path.join(root, "xdg", "runtime"),
96
+ PI_CODING_AGENT_DIR: path.join(root, "pi-agent"),
97
+ CLAUDE_CONFIG_DIR: path.join(root, "claude-config"),
98
+ };
99
+ for (const d of Object.values(sandboxEnv)) mkdirSync(d, { recursive: true });
100
+
101
+ // A PATH with no `claude` anywhere on it: every real entry that holds one is dropped, rather than
102
+ // emptying PATH (the launcher still needs bash, dirname and readlink).
103
+ const pathWithoutVendor = (process.env.PATH ?? "")
104
+ .split(":")
105
+ .filter((d) => d !== "" && !existsSync(path.join(d, "claude")))
106
+ .join(":");
107
+
108
+ /**
109
+ * Every bash this gate starts gets a NON-SOCKET stdin. Node's default stdin is a socketpair, and
110
+ * bash reads `-c` with a socket on stdin as a remote shell and sources the system bashrc — measured
111
+ * here on NixOS, where that re-runs set-environment and resets PATH, dropping the sandbox bin
112
+ * (S4CLI-F01-BASHC-REMOTE-SHELL-R1). The sibling gates never see it because they pass the
113
+ * operator's whole environment; this one builds its environment from scratch to compare it.
114
+ */
115
+ const STDIO: ["ignore", "pipe", "pipe"] = ["ignore", "pipe", "pipe"];
116
+
117
+ /** The caller's whole environment, built explicitly so the vendor's can be compared to it. */
118
+ function callerEnv(pathValue: string, extra: Record<string, string> = {}): Record<string, string> {
119
+ return { ...sandboxEnv, PATH: pathValue, LANG: "C.UTF-8", FAKE_CLAUDE_ENV_OUT: envDump, ...extra };
120
+ }
121
+
122
+ interface Run {
123
+ status: number | null;
124
+ signal: NodeJS.Signals | null;
125
+ out: string;
126
+ args: string[];
127
+ sentinel: string;
128
+ pid: string;
129
+ cwd: string;
130
+ env: Map<string, string> | null;
131
+ }
132
+ function launch(args: string[], env: Record<string, string>, cwd = work): Run {
133
+ rmSync(envDump, { force: true });
134
+ const r = spawnSync("bash", [RUN_SH, "claude", ...args], {
135
+ cwd,
136
+ encoding: "utf8",
137
+ env,
138
+ timeout: 30_000,
139
+ stdio: STDIO,
140
+ });
141
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
142
+ const argv: string[] = [];
143
+ for (const line of out.split("\n")) {
144
+ const m = /^ARG<([\s\S]*)>$/.exec(line);
145
+ if (m) argv.push(m[1] as string);
146
+ }
147
+ let vendorEnv: Map<string, string> | null = null;
148
+ if (existsSync(envDump)) {
149
+ vendorEnv = new Map();
150
+ for (const entry of readFileSync(envDump, "utf8").split("\0")) {
151
+ if (entry === "") continue;
152
+ const eq = entry.indexOf("=");
153
+ vendorEnv.set(entry.slice(0, eq), entry.slice(eq + 1));
154
+ }
155
+ }
156
+ return {
157
+ status: r.status,
158
+ signal: r.signal,
159
+ out,
160
+ args: argv,
161
+ sentinel: /^SENTINEL=\[(.*)\]$/m.exec(out)?.[1] ?? "<no-launch>",
162
+ pid: /^PID=(.*)$/m.exec(out)?.[1] ?? "",
163
+ cwd: /^CWD=(.*)$/m.exec(out)?.[1] ?? "",
164
+ env: vendorEnv,
165
+ };
166
+ }
167
+ const withVendor = `${bin}:${pathWithoutVendor}`;
168
+
169
+ // ── 1. the dispatcher verb must not reach the vendor ────────────────────────
170
+ {
171
+ const r = launch(["--model", "x"], callerEnv(withVendor));
172
+ ok(
173
+ "[QK:CLAUDELAUNCH-VERB-DROPPED] the dispatcher verb never reaches the vendor — without the shift, `claude` arrives as a prompt argument",
174
+ r.status === 0 && r.pid !== "" && !r.args.includes("claude"),
175
+ `status=${r.status} argv=${JSON.stringify(r.args)}\n${r.out}`,
176
+ );
177
+ }
178
+
179
+ // ── 2. operator argv crosses byte-identical, and nothing is added to it ─────
180
+ {
181
+ const operator = ["--model", "a b", "", "-", "--", "한글 인자", " spaced ", "--help"];
182
+ const r = launch(operator, callerEnv(withVendor));
183
+ ok(
184
+ "[QK:CLAUDELAUNCH-ARGV-UNTOUCHED] the vendor's argv IS the operator's, element for element — empty strings, spaces, a bare dash, the terminator, non-ASCII and a trailing --help — with nothing injected",
185
+ r.status === 0 && JSON.stringify(r.args) === JSON.stringify(operator),
186
+ `expected: ${JSON.stringify(operator)}\nactual: ${JSON.stringify(r.args)}`,
187
+ );
188
+ }
189
+
190
+ // ── 3. the vendor's own help, never an Entwurf page in its place ───────────
191
+ {
192
+ const bad = (["--help", "-h"] as const).flatMap((flag) => {
193
+ const r = launch([flag], callerEnv(withVendor));
194
+ return r.status === 0 && r.pid !== "" && JSON.stringify(r.args) === JSON.stringify([flag])
195
+ ? []
196
+ : [`${flag}: status=${r.status} argv=${JSON.stringify(r.args)} out=${JSON.stringify(r.out.slice(0, 160))}`];
197
+ });
198
+ ok(
199
+ "[QK:CLAUDELAUNCH-VENDOR-HELP] `entwurf claude --help` and `-h` reach Claude Code itself — the help preflight lets this verb through instead of answering with the command's description",
200
+ bad.length === 0,
201
+ bad.join("\n"),
202
+ );
203
+ }
204
+
205
+ // ── 4. the caller's directory is the vendor's ───────────────────────────────
206
+ {
207
+ const r = launch([], callerEnv(withVendor));
208
+ ok(
209
+ "[QK:CLAUDELAUNCH-CWD-KEPT] the vendor starts in the caller's directory, not the package's — Claude Code keys its project and session to that directory",
210
+ r.status === 0 && r.cwd === realpathSync(work),
211
+ `expected=${realpathSync(work)} actual=${r.cwd}`,
212
+ );
213
+ }
214
+
215
+ // ── 5–7. the environment: a sentinel added, a foreign identity removed, nothing else ──
216
+ {
217
+ const caller = callerEnv(withVendor, { PI_SESSION_ID: "foreign-garden", PI_AGENT_ID: "pi-agent-fixture" });
218
+ const r = launch([], caller);
219
+ ok(
220
+ "[QK:CLAUDELAUNCH-SENTINEL-EXPORTED] the sentinel is EXPORTED, so a PATH `claude` that shells back to `entwurf claude` sees it on re-entry",
221
+ r.sentinel === "1",
222
+ `sentinel=${r.sentinel}`,
223
+ );
224
+ ok(
225
+ "[QK:CLAUDELAUNCH-CARRIERS-SCRUBBED] an inherited PI_SESSION_ID/PI_AGENT_ID pair is another citizen's identity and BOTH are gone from the vendor's environment — absent, not emptied",
226
+ r.env !== null && !r.env.has("PI_SESSION_ID") && !r.env.has("PI_AGENT_ID"),
227
+ `vendor env keys: ${JSON.stringify(r.env === null ? null : [...r.env.keys()].filter((k) => k.startsWith("PI_")))}`,
228
+ );
229
+ const expected = new Map(Object.entries(caller));
230
+ expected.delete("PI_SESSION_ID");
231
+ expected.delete("PI_AGENT_ID");
232
+ expected.set(SENTINEL, "1");
233
+ const diff: string[] = [];
234
+ const actual = r.env ?? new Map<string, string>();
235
+ for (const [k, v] of expected)
236
+ if (actual.get(k) !== v) diff.push(`expected ${k}=${JSON.stringify(v)}, got ${JSON.stringify(actual.get(k))}`);
237
+ for (const [k, v] of actual)
238
+ if (!expected.has(k) && !BASH_OWNED.has(k)) diff.push(`added ${k}=${JSON.stringify(v)}`);
239
+ ok(
240
+ `[QK:CLAUDELAUNCH-ENV-EXACT] beyond those two changes the vendor's environment IS the caller's (${expected.size} variables; ${[...BASH_OWNED].join(", ")} are bash's own) — no auth, model, permission or settings variable is decided here`,
241
+ r.env !== null && diff.length === 0,
242
+ diff.join("\n"),
243
+ );
244
+ }
245
+
246
+ // ── 8. the vendor's exit status is this command's exit status (no mutant: see header) ──
247
+ {
248
+ const r = launch([], callerEnv(withVendor, { FAKE_CLAUDE_EXIT: "7" }));
249
+ ok(
250
+ "[QK:CLAUDELAUNCH-EXIT-PASSTHROUGH] the vendor's exit status is the launch's exit status",
251
+ r.status === 7,
252
+ `status=${r.status}`,
253
+ );
254
+ }
255
+
256
+ // ── 9. exec, not fork: the pid survives ─────────────────────────────────────
257
+ {
258
+ const r = spawnSync("bash", ["-c", `echo "SHELL_PID=$$"; exec bash "${RUN_SH}" claude`], {
259
+ cwd: work,
260
+ encoding: "utf8",
261
+ env: callerEnv(withVendor),
262
+ timeout: 30_000,
263
+ stdio: STDIO,
264
+ });
265
+ const shellPid = /^SHELL_PID=(\d+)$/m.exec(r.stdout ?? "")?.[1];
266
+ const vendorPid = /^PID=(\d+)$/m.exec(r.stdout ?? "")?.[1];
267
+ ok(
268
+ "[QK:CLAUDELAUNCH-EXEC-KEEPS-PID] the vendor REPLACES this process — a fork would leave run.sh as a parent that owns nothing, between Claude Code and the terminal",
269
+ Boolean(shellPid) && shellPid === vendorPid,
270
+ `shell=${shellPid} vendor=${vendorPid}`,
271
+ );
272
+ }
273
+
274
+ // ── 10. a vendor death by signal is the caller's to see (no mutant: see header) ──
275
+ {
276
+ const r = launch([], callerEnv(withVendor, { FAKE_CLAUDE_SIGNAL: "TERM" }));
277
+ ok(
278
+ "[QK:CLAUDELAUNCH-SIGNAL-PASSTHROUGH] a vendor killed by a signal is reported as that signal, not as a shell's 128+n status",
279
+ r.status === null && r.signal === "SIGTERM",
280
+ `status=${r.status} signal=${r.signal}`,
281
+ );
282
+ }
283
+
284
+ // ── 11. an absent vendor is a NAMED refusal, never a quiet success ──────────
285
+ {
286
+ const r = launch([], callerEnv(pathWithoutVendor));
287
+ ok(
288
+ "[QK:CLAUDELAUNCH-VENDOR-ABSENT] with no `claude` on PATH the launch refuses by name and non-zero, and runs nothing",
289
+ r.status === 1 && r.out.includes("no 'claude' executable found on PATH") && r.pid === "",
290
+ `status=${r.status}\n${r.out}`,
291
+ );
292
+ }
293
+
294
+ // ── 12. a PATH hit that is not a regular file is refused before exec ────────
295
+ {
296
+ const fifoBin = path.join(root, "fifobin");
297
+ mkdirSync(fifoBin, { recursive: true });
298
+ const made = spawnSync("mkfifo", [path.join(fifoBin, "claude")], { encoding: "utf8" });
299
+ assert.equal(made.status, 0, `mkfifo failed: ${made.stderr}`);
300
+ chmodSync(path.join(fifoBin, "claude"), 0o755);
301
+ const r = launch([], callerEnv(`${fifoBin}:${pathWithoutVendor}`));
302
+ ok(
303
+ "[QK:CLAUDELAUNCH-NOT-REGULAR] an executable PATH `claude` that is not a regular file (a FIFO) is refused by name, not handed to exec",
304
+ r.status === 1 && r.out.includes("is not an executable regular file") && r.pid === "",
305
+ `status=${r.status}\n${r.out}`,
306
+ );
307
+ }
308
+
309
+ // ── 13. the sentinel fence ──────────────────────────────────────────────────
310
+ {
311
+ const r = launch([], callerEnv(withVendor, { [SENTINEL]: "1" }));
312
+ ok(
313
+ "[QK:CLAUDELAUNCH-RECURSION-FENCE] a re-entered launch refuses by name instead of spinning behind a hung terminal",
314
+ r.status === 1 && r.out.includes("recursive managed launch detected") && r.pid === "",
315
+ `status=${r.status}\n${r.out}`,
316
+ );
317
+ }
318
+
319
+ // ── 14. the resolved-path fence: a PATH `claude` that IS run.sh ─────────────
320
+ {
321
+ // Two hops, the first relative, so the walk itself is exercised: loop/claude -> ../hop/claude
322
+ // -> run.sh. The argv is EMPTY on purpose (see header): without this fence the re-entry would
323
+ // print the root page and exit 0, never run a verb.
324
+ const loopBin = path.join(root, "loop");
325
+ const hop = path.join(root, "hop");
326
+ mkdirSync(loopBin, { recursive: true });
327
+ mkdirSync(hop, { recursive: true });
328
+ symlinkSync(RUN_SH, path.join(hop, "claude"));
329
+ symlinkSync(path.join("..", "hop", "claude"), path.join(loopBin, "claude"));
330
+ const r = launch([], callerEnv(`${loopBin}:${pathWithoutVendor}`));
331
+ ok(
332
+ "[QK:CLAUDELAUNCH-SELF-EXEC-FENCE] a PATH `claude` that resolves through symlinks to this run.sh is refused by name — with nothing injected, re-entry would make the operator's argv an entwurf verb",
333
+ r.status === 1 && r.out.includes(`resolves to entwurf's own '${realpathSync(RUN_SH)}'`) && r.pid === "",
334
+ `status=${r.status}\n${r.out.slice(0, 400)}`,
335
+ );
336
+ }
337
+
338
+ console.log(`\ncheck-claude-launch: ${passed} checks passed`);
339
+ } finally {
340
+ rmSync(root, { recursive: true, force: true });
341
+ }
@@ -253,9 +253,9 @@ function functionBody(name: string): string {
253
253
 
254
254
  /** Extract a `server.tool("NAME", ...)` call region by brace-counting from its open paren. */
255
255
  function toolRegion(name: string): string {
256
- const sig = `server.tool(\n\t"${name}"`;
257
- const at = src.indexOf(sig);
258
- assert.ok(at >= 0, `server.tool("${name}") present`);
256
+ const matches = [...src.matchAll(/server\.tool\(\s*"entwurf_self"\s*,/g)];
257
+ assert.equal(matches.length, 1, `server.tool("${name}") present exactly once`);
258
+ const at = matches[0].index;
259
259
  const open = src.indexOf("(", at);
260
260
  let depth = 0;
261
261
  for (let i = open; i < src.length; i++) {