@junghanacs/entwurf 0.12.0 → 0.12.1

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 (45) hide show
  1. package/CHANGELOG.md +21 -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 +29 -9
  37. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
  38. package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
  39. package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
  40. package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
  41. package/run.sh +140 -15
  42. package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
  43. package/scripts/check-entwurf-v2-contract.ts +6 -4
  44. package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
  45. 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
 
@@ -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") {