@junghanacs/entwurf 0.12.0 → 0.12.2

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 (49) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +88 -28
  3. package/docs/setup-clean-host.md +117 -219
  4. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +454 -0
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +111 -0
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +1683 -0
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +121 -0
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +155 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +119 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +160 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +63 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +81 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +290 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +254 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +365 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +64 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +218 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +108 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-resume-marker.js +33 -0
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +116 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +125 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +184 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +237 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +216 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +164 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +66 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +1502 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +50 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +259 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +81 -0
  32. package/mcp/entwurf-bridge/dist/protocol.js +29 -0
  33. package/mcp/entwurf-bridge/start.sh +49 -7
  34. package/mcp/entwurf-bridge/test.sh +12 -3
  35. package/mcp/entwurf-bridge/tsconfig.build.json +42 -0
  36. package/package.json +30 -9
  37. package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
  38. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
  39. package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
  40. package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
  41. package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
  42. package/run.sh +150 -15
  43. package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
  44. package/scripts/check-entwurf-v2-contract.ts +6 -4
  45. package/scripts/check-meta-manifest-schema.py +145 -0
  46. package/scripts/meta-bridge-install.sh +17 -3
  47. package/scripts/meta-bridge-state.py +37 -10
  48. package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
  49. package/scripts/smoke-acp-carrier-augment-live.ts +35 -19
@@ -0,0 +1,146 @@
1
+ // check-entwurf-bridge-pi-free (0.12.1 A-gate, static half)
2
+ //
3
+ // INVARIANT: the entwurf-bridge MCP server must boot WITHOUT any pi package
4
+ // (`@earendil-works/pi-*`). entwurf is a harness-neutral npm package; pi is one
5
+ // optional adapter lane, not a boot dependency. A plain `npm install
6
+ // @junghanacs/entwurf` (no pi peers) must still stand `entwurf-bridge` up and
7
+ // answer MCP `tools/list`.
8
+ //
9
+ // This gate is the STATIC half of that contract: it walks the EAGER static
10
+ // value-import closure of the bridge entry (`mcp/entwurf-bridge/src/index.ts`)
11
+ // and fails if any reachable module statically value-imports `@earendil-works/*`.
12
+ //
13
+ // "Eager static value-import" is the precise boundary (GPT-agreed):
14
+ // - `import type …` → erased by strip-types → NOT followed
15
+ // - `import { type A, type B }` → all-type, erased → NOT followed
16
+ // - `import { type A, b }` → has a value binding, kept → followed
17
+ // - `import x` / `import * as x` → value → followed
18
+ // - `import "x"` (side-effect) → value → followed
19
+ // - `export { a } from "x"` → value re-export → followed
20
+ // - `export type { a } from "x"` → type re-export, erased → NOT followed
21
+ // - `await import("x")` (dynamic) → INTENDED lazy boundary → NOT followed
22
+ //
23
+ // The dynamic-import exemption is deliberate: B-2 makes the pi-coding-agent
24
+ // `preflight` a lazy `await import("./entwurf-preflight.ts")` reached ONLY on an
25
+ // owned-outcome spawn-bg resume, so it is allowed to pull pi at runtime in that
26
+ // one branch. The runtime boot smoke (separate half) is the final authority that
27
+ // peers/self/list/mailbox-deliver come up pi-free.
28
+
29
+ import { existsSync, readFileSync } from "node:fs";
30
+ import { dirname, relative, resolve } from "node:path";
31
+
32
+ const REPO = resolve(import.meta.dirname, "..");
33
+ const ENTRY = resolve(REPO, "mcp/entwurf-bridge/src/index.ts");
34
+ const PI_SPECIFIER = /^@earendil-works\/pi-(ai|coding-agent|tui)(\/|$)/;
35
+
36
+ /** Strip line + block comments so a commented-out import never registers. */
37
+ function stripComments(src: string): string {
38
+ return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/[^\n]*/g, "$1");
39
+ }
40
+
41
+ /** Is every named binding in a `{ … }` clause a `type` binding? (→ import erased) */
42
+ function allNamedAreType(clause: string): boolean {
43
+ const parts = clause
44
+ .split(",")
45
+ .map((p) => p.trim())
46
+ .filter(Boolean);
47
+ if (parts.length === 0) return false; // `import {} from` is odd; treat as value.
48
+ return parts.every((p) => /^type\s/.test(p));
49
+ }
50
+
51
+ /**
52
+ * Return the runtime-loaded (non-type, non-dynamic) module specifiers of `src`.
53
+ * Only static `import …`/`export … from` whose effect survives strip-types.
54
+ */
55
+ function runtimeSpecifiers(src: string): string[] {
56
+ const clean = stripComments(src);
57
+ const specs: string[] = [];
58
+ // import … from "spec" | import "spec"
59
+ const importRe = /\bimport\b([^"'`;]*?)\bfrom\b\s*["'`]([^"'`]+)["'`]|\bimport\s*["'`]([^"'`]+)["'`]/g;
60
+ for (let m = importRe.exec(clean); m !== null; m = importRe.exec(clean)) {
61
+ const sideEffect = m[3];
62
+ if (sideEffect) {
63
+ specs.push(sideEffect);
64
+ continue;
65
+ }
66
+ const clause = (m[1] ?? "").trim();
67
+ const spec = m[2];
68
+ if (/^type\b/.test(clause)) continue; // `import type …`
69
+ const braced = clause.match(/\{([^}]*)\}/);
70
+ // A pure `{ … }` clause (no default/namespace) that is all-type is erased.
71
+ if (braced && !/^[A-Za-z0-9_$]/.test(clause) && allNamedAreType(braced[1])) continue;
72
+ specs.push(spec);
73
+ }
74
+ // export … from "spec" (value re-export); export type … from → skip
75
+ const reexportRe = /\bexport\b([^"'`;]*?)\bfrom\b\s*["'`]([^"'`]+)["'`]/g;
76
+ for (let m = reexportRe.exec(clean); m !== null; m = reexportRe.exec(clean)) {
77
+ const clause = (m[1] ?? "").trim();
78
+ const spec = m[2];
79
+ if (/^type\b/.test(clause)) continue; // `export type … from`
80
+ const braced = clause.match(/\{([^}]*)\}/);
81
+ if (braced && allNamedAreType(braced[1])) continue;
82
+ specs.push(spec);
83
+ }
84
+ return specs;
85
+ }
86
+
87
+ /** Resolve a relative specifier to an on-disk source file, or null if external. */
88
+ function resolveLocal(fromFile: string, spec: string): string | null {
89
+ if (!spec.startsWith(".")) return null; // bare/external — handled by caller
90
+ const base = resolve(dirname(fromFile), spec);
91
+ const candidates = [
92
+ base,
93
+ `${base}.ts`,
94
+ `${base}.js`,
95
+ `${base}.mjs`,
96
+ resolve(base, "index.ts"),
97
+ resolve(base, "index.js"),
98
+ ];
99
+ for (const c of candidates) if (existsSync(c)) return c;
100
+ return null;
101
+ }
102
+
103
+ // BFS over the eager static value-import closure.
104
+ const visited = new Set<string>();
105
+ const violations: Array<{ chain: string[]; specifier: string }> = [];
106
+ const queue: Array<{ file: string; chain: string[] }> = [{ file: ENTRY, chain: [relative(REPO, ENTRY)] }];
107
+
108
+ while (queue.length > 0) {
109
+ const { file, chain } = queue.shift()!;
110
+ if (visited.has(file)) continue;
111
+ visited.add(file);
112
+ let src: string;
113
+ try {
114
+ src = readFileSync(file, "utf8");
115
+ } catch {
116
+ continue;
117
+ }
118
+ for (const spec of runtimeSpecifiers(src)) {
119
+ if (PI_SPECIFIER.test(spec)) {
120
+ violations.push({ chain, specifier: spec });
121
+ continue;
122
+ }
123
+ const local = resolveLocal(file, spec);
124
+ if (local && !visited.has(local)) {
125
+ queue.push({ file: local, chain: [...chain, relative(REPO, local)] });
126
+ }
127
+ }
128
+ }
129
+
130
+ if (violations.length > 0) {
131
+ console.error("[check-entwurf-bridge-pi-free] FAIL: bridge boot closure statically value-imports pi:");
132
+ for (const v of violations) {
133
+ console.error(` - ${v.specifier}`);
134
+ console.error(` via ${v.chain.join(" -> ")}`);
135
+ }
136
+ console.error(
137
+ "\n The entwurf-bridge MCP server must boot pi-free. Move the pi value-import behind a\n" +
138
+ " type-only import, a pi-side module the bridge does not reach, or a lazy `await import()`\n" +
139
+ " on the owned-outcome resume branch only.",
140
+ );
141
+ process.exit(1);
142
+ }
143
+
144
+ console.log(
145
+ `[check-entwurf-bridge-pi-free] ok — bridge boot closure is pi-free (${visited.size} modules walked, no static @earendil-works/pi-* value-import)`,
146
+ );
@@ -41,10 +41,6 @@ import {
41
41
  ENTWURF_V2_OWNERSHIPS,
42
42
  ENTWURF_V2_REJECT_REASONS,
43
43
  ENTWURF_V2_TRANSPORTS,
44
- EntwurfV2InputSchema,
45
- EntwurfV2ReceiptRejectSchema,
46
- EntwurfV2ReceiptSchema,
47
- EntwurfV2ReceiptSuccessSchema,
48
44
  FACT_LIVENESSES,
49
45
  type FactLiveness,
50
46
  factLivenessOf,
@@ -57,6 +53,12 @@ import {
57
53
  resolveDispatch,
58
54
  UNSUPPORTED_DISPATCH_TABLE,
59
55
  } from "../pi-extensions/lib/entwurf-v2-contract.ts";
56
+ import {
57
+ EntwurfV2InputSchema,
58
+ EntwurfV2ReceiptRejectSchema,
59
+ EntwurfV2ReceiptSchema,
60
+ EntwurfV2ReceiptSuccessSchema,
61
+ } from "../pi-extensions/lib/entwurf-v2-contract-schema.ts";
60
62
  import { SESSION_ID_RE } from "../pi-extensions/lib/session-id.js";
61
63
  import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
62
64
 
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3
2
+ # check-meta-manifest-schema — deterministic, CLI-version-INDEPENDENT guard for the
3
+ # meta-bridge plugin manifests + the installed-vs-clone MCP wiring decision.
4
+ #
5
+ # Why this exists (0.12.2): `claude plugin validate` is a CLOSED schema — it REJECTS
6
+ # unrecognized keys, and the allowed keyset differs by Claude Code version. 0.12.1
7
+ # shipped a marketplace.json carrying a root `description`; Claude 2.1.195 (the dev
8
+ # box) accepted it, Claude 2.1.97 (the install floor) rejected it with
9
+ # `Unrecognized key: "description"`, so `entwurf install-meta-bridge` died on the
10
+ # floor host while the release looked green. The lesson: a "nice to have" decorative
11
+ # key in a closed-schema manifest is a future regression surface. This guard pins the
12
+ # committed manifests to the MINIMAL keyset confirmed to validate on the lowest
13
+ # supported Claude, independent of whatever CLI version happens to run here.
14
+ #
15
+ # It also asserts meta-bridge-state.py::desired_mcp() picks the stable `entwurf-bridge`
16
+ # bin for an installed package and the clone's start.sh for a dev clone (the other
17
+ # 0.12.1 install-surface fragility: baking the pnpm store path goes stale on peer bumps).
18
+ #
19
+ # Offline / hermetic. Deps: python3 only.
20
+ import json
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ HERE = Path(__file__).resolve().parent
26
+ REPO = HERE.parent
27
+ MB = REPO / "pi" / "meta-bridge"
28
+ STATE = HERE / "meta-bridge-state.py"
29
+
30
+ fail = 0
31
+
32
+
33
+ def ok(msg: str) -> None:
34
+ print(f" ok {msg}")
35
+
36
+
37
+ def bad(msg: str) -> None:
38
+ global fail
39
+ print(f" FAIL {msg}")
40
+ fail = 1
41
+
42
+
43
+ def load(path: Path):
44
+ try:
45
+ return json.loads(path.read_text(encoding="utf-8"))
46
+ except Exception as exc: # noqa: BLE001
47
+ bad(f"cannot parse {path}: {exc}")
48
+ return None
49
+
50
+
51
+ def subset(label: str, got, allowed: set[str]) -> None:
52
+ if not isinstance(got, dict):
53
+ bad(f"{label}: expected an object, got {type(got).__name__}")
54
+ return
55
+ extra = set(got) - allowed
56
+ if extra:
57
+ bad(f"{label}: keys {sorted(extra)} outside minimal allowed set {sorted(allowed)} "
58
+ f"(closed-schema risk: an older Claude may reject them)")
59
+ else:
60
+ ok(f"{label}: keys ⊆ {sorted(allowed)}")
61
+
62
+
63
+ # --- marketplace.json --------------------------------------------------------
64
+ mkt = load(MB / ".claude-plugin" / "marketplace.json")
65
+ if mkt is not None:
66
+ subset("marketplace root", mkt, {"name", "owner", "plugins"})
67
+ subset("marketplace.owner", mkt.get("owner", {}), {"name"})
68
+ plugins = mkt.get("plugins")
69
+ if not isinstance(plugins, list) or not plugins:
70
+ bad("marketplace.plugins must be a non-empty array")
71
+ else:
72
+ for i, p in enumerate(plugins):
73
+ subset(f"marketplace.plugins[{i}]", p, {"name", "source", "description"})
74
+
75
+ # --- plugin.json -------------------------------------------------------------
76
+ plug = load(MB / "entwurf-meta-receive" / ".claude-plugin" / "plugin.json")
77
+ if plug is not None:
78
+ subset("plugin.json", plug, {"name", "version", "description"})
79
+
80
+ # --- hooks.json --------------------------------------------------------------
81
+ hooks = load(MB / "entwurf-meta-receive" / "hooks" / "hooks.json")
82
+ if hooks is not None:
83
+ subset("hooks.json root", hooks, {"hooks"})
84
+ # Pin the hook EVENT names too — an unrecognized event key is the same closed-schema
85
+ # risk as an unrecognized field. These four are the meta-bridge's load-bearing events.
86
+ subset("hooks.json events", hooks.get("hooks") or {},
87
+ {"SessionStart", "CwdChanged", "UserPromptSubmit", "FileChanged"})
88
+ for event, entries in (hooks.get("hooks") or {}).items():
89
+ if not isinstance(entries, list):
90
+ bad(f"hooks.{event} must be an array")
91
+ continue
92
+ for j, entry in enumerate(entries):
93
+ subset(f"hooks.{event}[{j}]", entry, {"matcher", "hooks"})
94
+ for k, h in enumerate(entry.get("hooks", []) if isinstance(entry, dict) else []):
95
+ # asyncRewake/timeout are load-bearing on the FileChanged doorbell —
96
+ # allowed, NOT decorative. Anything beyond this set must be reviewed.
97
+ subset(f"hooks.{event}[{j}].hooks[{k}]", h, {"type", "command", "asyncRewake", "timeout"})
98
+
99
+ # --- desired_mcp() installed-vs-clone dual-mode ------------------------------
100
+ def desired_mcp(repo: str):
101
+ out = subprocess.run(
102
+ [sys.executable, str(STATE), "desired-mcp", "--repo", repo],
103
+ capture_output=True, text=True,
104
+ )
105
+ if out.returncode != 0:
106
+ bad(f"desired-mcp --repo {repo} exited {out.returncode}: {out.stderr.strip()}")
107
+ return None
108
+ return json.loads(out.stdout)
109
+
110
+
111
+ # Both probes use SYNTHETIC paths that need not exist — never REPO. If this guard ran
112
+ # from an installed package (e.g. `entwurf check-meta-manifest-schema`), REPO would
113
+ # itself end in node_modules/@junghanacs/entwurf and a REPO-based clone probe would
114
+ # wrongly resolve to the installed shape and self-fail. Fixed, installed-vs-clone-shaped
115
+ # literals make the assertion location-independent.
116
+ installed = desired_mcp("/opt/x/node_modules/@junghanacs/entwurf")
117
+ if installed is not None:
118
+ if installed.get("command") == "entwurf-bridge" and installed.get("args") == []:
119
+ ok("desired_mcp(installed-shaped) → stable `entwurf-bridge` bin (no store path baked)")
120
+ else:
121
+ bad(f"desired_mcp(installed-shaped) should wire the `entwurf-bridge` bin, got {installed.get('command')} {installed.get('args')}")
122
+
123
+ clone = desired_mcp("/opt/entwurf-dev-clone")
124
+ if clone is not None:
125
+ args = clone.get("args") or [""]
126
+ if clone.get("command") == "bash" and str(args[0]).endswith("mcp/entwurf-bridge/start.sh"):
127
+ ok("desired_mcp(clone-shaped) → that clone's start.sh (not the global bin)")
128
+ else:
129
+ bad(f"desired_mcp(clone-shaped) should wire bash start.sh, got {clone.get('command')} {args}")
130
+
131
+ # both modes must keep the canonical sender env
132
+ for label, entry in (("installed", installed), ("clone", clone)):
133
+ if entry is None:
134
+ continue
135
+ env = entry.get("env", {})
136
+ if env.get("ENTWURF_BRIDGE_REQUIRE_META_SENDER") == "1" and \
137
+ env.get("ENTWURF_BRIDGE_EXTERNAL_AGENT_ID") == "external-mcp/claude-code":
138
+ ok(f"desired_mcp({label}) carries canonical sender env")
139
+ else:
140
+ bad(f"desired_mcp({label}) dropped canonical sender env: {env}")
141
+
142
+ if fail:
143
+ print("check-meta-manifest-schema FAIL")
144
+ sys.exit(1)
145
+ print("check-meta-manifest-schema PASS")
@@ -115,9 +115,23 @@ claude mcp remove entwurf-bridge -s user >/dev/null 2>&1 || true
115
115
  # 0.11 S2 cutover: drop any stale USER-scope pi-tools-bridge entry written by a
116
116
  # prior version (one-shot rename cleanup, not a runtime alias).
117
117
  claude mcp remove pi-tools-bridge -s user >/dev/null 2>&1 || true
118
- claude mcp add -s user entwurf-bridge \
119
- -e ENTWURF_BRIDGE_EXTERNAL_AGENT_ID=external-mcp/claude-code \
120
- -- bash "$REPO/mcp/entwurf-bridge/start.sh" >/dev/null
118
+ # 0.12.2 installed-vs-clone dual-mode MUST mirror meta-bridge-state.py::desired_mcp(),
119
+ # which the trailing `apply` re-asserts as the SSOT. An installed package ($REPO ends
120
+ # in node_modules/@junghanacs/entwurf) wires the STABLE `entwurf-bridge` bin shim; baking
121
+ # the pnpm store path here would go stale on any peer/version bump. A dev clone pins to
122
+ # this clone's start.sh. Both branches carry the same two env vars desired_mcp() writes.
123
+ case "$REPO" in
124
+ */node_modules/@junghanacs/entwurf)
125
+ claude mcp add -s user entwurf-bridge \
126
+ -e ENTWURF_BRIDGE_EXTERNAL_AGENT_ID=external-mcp/claude-code \
127
+ -e ENTWURF_BRIDGE_REQUIRE_META_SENDER=1 \
128
+ -- entwurf-bridge >/dev/null ;;
129
+ *)
130
+ claude mcp add -s user entwurf-bridge \
131
+ -e ENTWURF_BRIDGE_EXTERNAL_AGENT_ID=external-mcp/claude-code \
132
+ -e ENTWURF_BRIDGE_REQUIRE_META_SENDER=1 \
133
+ -- bash "$REPO/mcp/entwurf-bridge/start.sh" >/dev/null ;;
134
+ esac
121
135
  (cd /tmp && claude mcp get entwurf-bridge 2>/dev/null | grep -q "Scope: User config") || \
122
136
  die "post-install: entwurf-bridge is not reachable as USER-scope MCP from /tmp"
123
137
  echo "[meta-bridge-install] installed entwurf-bridge MCP (scope: user = global receiver tools)"
@@ -264,20 +264,42 @@ def desired_marketplace(asm: Path) -> dict[str, Any]:
264
264
  return {"source": {"source": "directory", "path": str(asm.resolve())}}
265
265
 
266
266
 
267
+ def is_installed_package(repo: Path) -> bool:
268
+ # An installed (npm / pnpm) layout always ends in node_modules/@junghanacs/entwurf
269
+ # (pnpm global nests it under .pnpm/<hash>/node_modules/@junghanacs/entwurf). A dev
270
+ # clone never lives under node_modules/@junghanacs/entwurf, so the trailing three
271
+ # path components cleanly distinguish the two WITHOUT a PATH lookup (`command -v
272
+ # entwurf-bridge` could resolve a stale GLOBAL bin from a clone host and is wrong here).
273
+ parts = repo.resolve().parts
274
+ return parts[-3:] == ("node_modules", "@junghanacs", "entwurf")
275
+
276
+
267
277
  def desired_mcp(repo: Path) -> dict[str, Any]:
278
+ env = {
279
+ "ENTWURF_BRIDGE_EXTERNAL_AGENT_ID": "external-mcp/claude-code",
280
+ # Anonymous sends are forbidden on the Claude Code install path: a send
281
+ # with no pi-session identity AND no meta-sender marker is refused, not
282
+ # delivered as an unidentified external. The SessionStart hook writes
283
+ # the marker (parent-pid keyed), so a normally-opened session always has
284
+ # an authoritative garden-id sender.
285
+ "ENTWURF_BRIDGE_REQUIRE_META_SENDER": "1",
286
+ }
287
+ if is_installed_package(repo):
288
+ # Installed package: wire the STABLE `entwurf-bridge` bin shim that npm/pnpm
289
+ # place on PATH. Baking repo/mcp/entwurf-bridge/start.sh would embed the pnpm
290
+ # store path (.pnpm/@junghanacs+entwurf@<ver>_<peer-hash>/...), which moves
291
+ # on any peer/version change and leaves a dead MCP command. The bare bin name
292
+ # tracks whatever version is currently installed — the right behaviour for a
293
+ # managed install host. start.sh's own position-based dual-mode then runs the
294
+ # dist boot under node_modules. (0.12.2 fix; 0.12.1 shipped only the clone path.)
295
+ return {"type": "stdio", "command": "entwurf-bridge", "args": [], "env": env}
296
+ # Dev clone: pin to THIS clone's launcher (an absolute resolved path), never the
297
+ # global bin, so a clone host stays bound to its own source tree.
268
298
  return {
269
299
  "type": "stdio",
270
300
  "command": "bash",
271
301
  "args": [str((repo / "mcp" / "entwurf-bridge" / "start.sh").resolve())],
272
- "env": {
273
- "ENTWURF_BRIDGE_EXTERNAL_AGENT_ID": "external-mcp/claude-code",
274
- # Anonymous sends are forbidden on the Claude Code install path: a send
275
- # with no pi-session identity AND no meta-sender marker is refused, not
276
- # delivered as an unidentified external. The SessionStart hook writes
277
- # the marker (parent-pid keyed), so a normally-opened session always has
278
- # an authoritative garden-id sender.
279
- "ENTWURF_BRIDGE_REQUIRE_META_SENDER": "1",
280
- },
302
+ "env": env,
281
303
  }
282
304
 
283
305
 
@@ -519,7 +541,7 @@ def main() -> int:
519
541
  parser = argparse.ArgumentParser(description="entwurf meta-bridge state manager")
520
542
  parser.add_argument(
521
543
  "command",
522
- choices=["prepare", "apply", "preflight-uninstall", "uninstall", "check", "managed-keys"],
544
+ choices=["prepare", "apply", "preflight-uninstall", "uninstall", "check", "managed-keys", "desired-mcp"],
523
545
  )
524
546
  parser.add_argument("--repo", default=Path(__file__).resolve().parents[1], type=Path)
525
547
  parser.add_argument("--asm", default=None, type=Path)
@@ -539,6 +561,11 @@ def main() -> int:
539
561
  check(repo, asm)
540
562
  elif args.command == "managed-keys":
541
563
  print(json.dumps(managed_keys(), indent=2))
564
+ elif args.command == "desired-mcp":
565
+ # Debug/guard surface: print the user-scope MCP entry desired_mcp() would
566
+ # write for --repo. Lets a deterministic guard assert the installed-vs-clone
567
+ # dual-mode without spinning up a real `claude` CLI. --repo need not exist.
568
+ print(json.dumps(desired_mcp(repo), indent=2))
542
569
  except StateError as exc:
543
570
  print(f"meta-bridge-state: {exc}", file=sys.stderr)
544
571
  return 1
@@ -207,9 +207,20 @@ async function main(): Promise<void> {
207
207
  );
208
208
 
209
209
  // Drive ONE model turn over the stdin RPC: call the BUNDLED bridge's entwurf_self.
210
+ // Ask for LABELED lines ("socketState: <value>"), not bare values. The old
211
+ // "reply with the values ... one per line, and nothing else" prompt contradicted
212
+ // the assertions below, which match on the FIELD NAME (e.g. /socketState…alive/):
213
+ // a model that obeyed "values only" replied with a bare "alive" line that the
214
+ // envelope filter (it keys on field-name tokens) dropped, and the [tool:done]
215
+ // notice truncates the envelope before socketState — so the gate flaked on the
216
+ // model's formatting choice. Labeled lines make the three identity fields
217
+ // deterministically observable in the reply regardless of notice truncation.
210
218
  const prompt =
211
- "Call the mcp__entwurf-bridge__entwurf_self tool now. Then reply with exactly the " +
212
- "sessionId, agentId, and socketState values it returned, one per line, and nothing else. " +
219
+ "Call the mcp__entwurf-bridge__entwurf_self tool now. Then reply with exactly these " +
220
+ "three lines, copying the tool result verbatim and nothing else:\n" +
221
+ "sessionId: <value>\n" +
222
+ "agentId: <value>\n" +
223
+ "socketState: <value>\n" +
213
224
  "Do not paraphrase or invent values — copy them verbatim from the tool result.";
214
225
  // The prompt must NOT leak the gid — the gid in the envelope is the proof the bridge
215
226
  // answered, so a gid in the prompt would make that proof circular (the model could echo
@@ -7,10 +7,17 @@
7
7
  // This is the S2e-1 live half: it drives the REAL pi provider path and proves the
8
8
  // two 핀1-critical behaviors the gate cannot observe on a real model turn:
9
9
  //
10
- // 1. the augment reaches the model — a unique secret written ONLY into the
11
- // scratch cwd's AGENTS.md (NEVER into the user prompt) comes back in the
12
- // reply, so buildPiContextAugment's "## <cwd>/AGENTS.md" section actually rode
13
- // the wire to the live model via streamShellAcp.
10
+ // 1. the augment reaches the model — a unique BENIGN factual marker written
11
+ // ONLY into the scratch cwd's AGENTS.md (NEVER into the user prompt) comes
12
+ // back in the reply, so buildPiContextAugment's "## <cwd>/AGENTS.md" section
13
+ // actually rode the wire to the live model via streamShellAcp. The marker is
14
+ // a plain project fact ("internal build codename: <nonce>") asked back with a
15
+ // normal "answer from project context" question — NOT a "SECRET ... reply
16
+ // with the value" directive. The old secret-echo phrasing read as a prompt
17
+ // injection embedded in a /tmp AGENTS.md, and current Claude correctly
18
+ // REFUSES it ("typical injection pattern"), which made this MUST gate fail
19
+ // even though the augment DID ride the wire. Proving delivery must not depend
20
+ // on the model agreeing to echo an exfil-shaped secret.
14
21
  // 2. the default (EMPTY) carrier does not trip subscription billing — the turn
15
22
  // exits 0 with no HTTP-400 / "extra usage" billing error. This is the 핀1
16
23
  // live check: a carrier-absent run must bill like a normal subscription call.
@@ -18,11 +25,13 @@
18
25
  // Read-tool caveat (deliberate — GPT c32a6c8 Q1): Claude ACP exposes Read, so a
19
26
  // model COULD read AGENTS.md directly instead of answering from the augment. We do
20
27
  // not forbid that at the wire — the deterministic gate already locks the augment
21
- // SHAPE; this smoke asks the model to answer WITHOUT tools and treats the secret
28
+ // SHAPE; this smoke asks the model to answer WITHOUT tools and treats the marker
22
29
  // in the reply as evidence that the augment+provider path is live AND billing-
23
30
  // clean. The honest claim is "the augment rode the live provider path and the
24
31
  // empty-carrier turn billed fine", NOT "the model was physically unable to read
25
- // the file". A wire-dump would over-build for this cut's purpose.
32
+ // the file". A live wire-dump would over-build for this cut's purpose (the
33
+ // production ACP path has no prompt-capture seam; only the deterministic gate's
34
+ // fake child captures payloads).
26
35
  //
27
36
  // Optional carrier-present path (SMOKE_ACP_CARRIER_PRESENT=1, non-blocking — GPT
28
37
  // c32a6c8 Q2): a second turn with a TINY engraving via ENTWURF_ACP_ENGRAVING_PATH
@@ -122,45 +131,52 @@ function assertCleanTurn(label: string, turn: { status: number | null; combined:
122
131
  const scratch = mkdtempSync(join(tmpdir(), "entwurf-s2e1-"));
123
132
  try {
124
133
  // A nonce unique to this run; lives ONLY in the cwd AGENTS.md, never the prompt.
134
+ // The marker is a BENIGN project FACT (an internal build codename) with no
135
+ // directive attached — not a "SECRET ... reply with the value" instruction.
136
+ // A factual line cannot read as an embedded user-turn injection, so asking for
137
+ // it back is a normal "answer from project context" task the model performs
138
+ // instead of refusing (see header). Its uniqueness still proves delivery: the
139
+ // codename appears nowhere but the cwd AGENTS.md the augment carries.
125
140
  const nonce = `${process.pid.toString(36)}${Date.now().toString(36)}`;
126
- const secret = `SAC_${nonce}`;
141
+ const marker = `nimbus${nonce}`;
127
142
  const agentsPath = join(scratch, "AGENTS.md");
128
143
  writeFileSync(
129
144
  agentsPath,
130
145
  [
131
- "# Scratch project entwurf S2e-1 augment live check",
146
+ "# Scratch project: entwurf augment live check",
132
147
  "",
133
- `SECRET_PROJECT_CODE: ${secret}`,
148
+ "## Project facts",
134
149
  "",
135
- "When asked for the secret project code, reply with the value above.",
150
+ `- Internal build codename: ${marker}`,
136
151
  "",
137
152
  ].join("\n"),
138
153
  );
139
154
 
140
- // The prompt never names the secret; only the cwd AGENTS.md (carried by the
155
+ // The prompt never names the marker; only the cwd AGENTS.md (carried by the
141
156
  // augment) holds it. "Without using any tool" pushes the model to answer from
142
- // the provided instructions rather than reading the file (Read-tool caveat).
157
+ // the provided project context rather than reading the file (Read-tool caveat).
143
158
  const prompt =
144
- "Without using any tool or reading any file, reply with exactly the " +
145
- "SECRET_PROJECT_CODE value from this project's instructions, and nothing else.";
159
+ "Based on the project context you were given, what is this project's " +
160
+ "internal build codename? Reply with just the codename and nothing else, " +
161
+ "and answer from the provided context without using any tool or reading any file.";
146
162
 
147
163
  console.error(`[smoke-acp-carrier-augment-live] repo: ${REPO_ROOT}`);
148
164
  console.error(`[smoke-acp-carrier-augment-live] cwd: ${scratch}`);
149
165
  console.error(`[smoke-acp-carrier-augment-live] model: ${PROVIDER}/${MODEL}`);
150
- console.error(`[smoke-acp-carrier-augment-live] secret: ${secret} (only in cwd AGENTS.md)`);
166
+ console.error(`[smoke-acp-carrier-augment-live] marker: ${marker} (only in cwd AGENTS.md)`);
151
167
 
152
168
  // --- MUST: empty (default) carrier + augment behavior ---------------------
153
169
  const turn1 = runTurn(scratch, prompt, {});
154
170
  assertCleanTurn("empty-carrier turn", turn1);
155
171
  assert.ok(
156
- turn1.stdout.includes(secret),
157
- `empty-carrier turn: reply did not carry the cwd-AGENTS secret ${secret} ` +
172
+ turn1.stdout.includes(marker),
173
+ `empty-carrier turn: reply did not carry the cwd-AGENTS marker ${marker} ` +
158
174
  `(the augment did not reach the model). stdout tail: ${JSON.stringify(turn1.stdout.slice(-300))}`,
159
175
  );
160
- console.log("[smoke-acp-carrier-augment-live] PASS (MUST) — augment delivered the cwd AGENTS.md secret on a");
176
+ console.log("[smoke-acp-carrier-augment-live] PASS (MUST) — augment delivered the cwd AGENTS.md marker on a");
161
177
  console.log(" live provider turn; the empty default carrier billed clean (exit 0, no 400 canary).");
162
178
  console.log(` model: ${PROVIDER}/${MODEL}`);
163
- console.log(` secret: ${secret} present in assistant reply`);
179
+ console.log(` marker: ${marker} present in assistant reply`);
164
180
 
165
181
  // --- OPTIONAL (non-blocking): tiny carrier-present billing -----------------
166
182
  if (process.env.SMOKE_ACP_CARRIER_PRESENT === "1") {