@junghanacs/entwurf 0.12.1 → 0.12.3
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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +43 -0
- package/README.md +3 -5
- package/VERIFY.md +4 -4
- package/demo/README.md +5 -5
- package/demo/demo-baseline.sh +2 -2
- package/demo/demo.sh +3 -3
- package/docs/setup-clean-host.md +5 -5
- package/package.json +11 -10
- package/pi/entwurf-targets.json +3 -6
- package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
- package/pi-extensions/lib/acp/acp-client.ts +2 -2
- package/pi-extensions/lib/acp/models.ts +7 -9
- package/run.sh +31 -21
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-config.ts +3 -3
- package/scripts/check-acp-event-mapper.ts +1 -1
- package/scripts/check-acp-provider-surface.ts +1 -1
- package/scripts/check-acp-sdk-surface.ts +22 -22
- package/scripts/check-acp-session-reuse.ts +3 -3
- package/scripts/check-acp-session-store.ts +6 -6
- package/scripts/check-acp-tool-surface.ts +2 -2
- package/scripts/check-entwurf-session-identity.ts +9 -9
- package/scripts/check-meta-manifest-schema.py +145 -0
- package/scripts/check-model-lock.ts +2 -2
- package/scripts/check-package-source-routing.ts +4 -4
- package/scripts/meta-bridge-doctor.sh +1 -1
- package/scripts/meta-bridge-install.sh +17 -3
- package/scripts/meta-bridge-state.py +39 -10
- package/scripts/resolve-acp-bridge.ts +1 -1
- package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
- package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
- package/scripts/smoke-acp-mcp-live.ts +1 -1
- package/scripts/smoke-acp-memory-containment-live.ts +8 -9
- package/scripts/smoke-acp-overlay-live.ts +8 -9
- package/scripts/smoke-acp-provider-live.ts +1 -1
- package/scripts/smoke-acp-raw-turn-live.ts +12 -11
- package/scripts/smoke-acp-session-reuse-live.ts +24 -9
- package/scripts/smoke-acp-skill-live.ts +1 -1
- package/scripts/smoke-meta-install-state.sh +3 -3
|
@@ -58,7 +58,7 @@ function settings(name: string, block: unknown): string {
|
|
|
58
58
|
function resolveWith(globalPath: string, projectPath: string, adapter: AcpBackendAdapter = claudeLikeFake) {
|
|
59
59
|
return resolveProviderConfig({
|
|
60
60
|
cwd: root,
|
|
61
|
-
modelId: "claude-sonnet-
|
|
61
|
+
modelId: "claude-sonnet-5",
|
|
62
62
|
adapter,
|
|
63
63
|
globalSettingsPath: globalPath,
|
|
64
64
|
projectSettingsPath: projectPath,
|
|
@@ -291,11 +291,11 @@ try {
|
|
|
291
291
|
weather: { type: "http", url: "https://w.test" },
|
|
292
292
|
other: { command: "x" },
|
|
293
293
|
}).servers;
|
|
294
|
-
const enriched = enrichMcpServersWithEnvelope(servers, { modelId: "claude-sonnet-
|
|
294
|
+
const enriched = enrichMcpServersWithEnvelope(servers, { modelId: "claude-sonnet-5", piSessionId: "LIVE-1" });
|
|
295
295
|
const bridge = enriched.find((s) => s.name === "entwurf-bridge") as Extract<AcpMcpServer, { command: string }>;
|
|
296
296
|
const env = Object.fromEntries(bridge.env.map((e) => [e.name, e.value]));
|
|
297
297
|
assert.equal(env.PI_SESSION_ID, "LIVE-1", "live PI_SESSION_ID wins over the stale operator value");
|
|
298
|
-
assert.equal(env.PI_AGENT_ID, "entwurf/claude-sonnet-
|
|
298
|
+
assert.equal(env.PI_AGENT_ID, "entwurf/claude-sonnet-5", "PI_AGENT_ID injected from the model id");
|
|
299
299
|
assert.equal(env.FOO, "bar", "operator env preserved");
|
|
300
300
|
const weather = enriched.find((s) => s.name === "weather");
|
|
301
301
|
assert.ok(!("env" in (weather ?? {})), "http server not enriched (no env carrier)");
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
finalizeAcpStreamState,
|
|
22
22
|
} from "../pi-extensions/lib/acp/event-mapper.ts";
|
|
23
23
|
|
|
24
|
-
const IDENTITY = { api: "entwurf", provider: "entwurf", model: "claude-sonnet-
|
|
24
|
+
const IDENTITY = { api: "entwurf", provider: "entwurf", model: "claude-sonnet-5" };
|
|
25
25
|
|
|
26
26
|
// Drive a set of synthetic updates through a fresh mapper and collect every
|
|
27
27
|
// emitted event (push is synchronous; the terminal done drains the queue).
|
|
@@ -117,7 +117,7 @@ try {
|
|
|
117
117
|
assert.equal(cap.cfg.apiKey, ENTWURF_ACP_NO_AUTH_SENTINEL, "entry apiKey is not the no-auth sentinel");
|
|
118
118
|
assert.equal(cap.cfg.api, "entwurf", "entry api field drifted");
|
|
119
119
|
const capIds = (cap.cfg.models ?? []).map((m) => m.id);
|
|
120
|
-
for (const want of ["claude-sonnet-
|
|
120
|
+
for (const want of ["claude-sonnet-5", CURATED_ANCHOR_MODEL_ID]) {
|
|
121
121
|
assert.ok(capIds.includes(want), `entry model surface missing ${want} (got: ${capIds.join(", ") || "none"})`);
|
|
122
122
|
}
|
|
123
123
|
assert.equal(typeof cap.cfg.streamSimple, "function", "entry streamSimple must be a function");
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// Deterministic gate for the S2a ACP SDK dependency surface.
|
|
2
2
|
//
|
|
3
|
-
// Pins the ACP runtime deps to the
|
|
3
|
+
// Pins the ACP runtime deps to the current behavior-oracle versions and locks
|
|
4
4
|
// the peer-resolution that makes the Claude ACP adapter satisfiable:
|
|
5
5
|
//
|
|
6
|
-
// @agentclientprotocol/sdk
|
|
7
|
-
// @agentclientprotocol/claude-agent-acp 0.
|
|
6
|
+
// @agentclientprotocol/sdk 1.1.0 wire SDK (acp-bridge import source)
|
|
7
|
+
// @agentclientprotocol/claude-agent-acp 0.54.1 Claude adapter (spawn binary)
|
|
8
8
|
// @anthropic-ai/sdk 0.100.1 peer-resolution pin ONLY (see below)
|
|
9
9
|
//
|
|
10
10
|
// The anthropic SDK is NOT an API client / auth surface here. It is a direct
|
|
11
|
-
// dep solely to satisfy @anthropic-ai/claude-agent-sdk@0.3.
|
|
11
|
+
// dep solely to satisfy @anthropic-ai/claude-agent-sdk@0.3.197's peer floor
|
|
12
12
|
// (>=0.93.0); drop it and the tree resolves a stale 0.91.1 so the peer goes
|
|
13
|
-
// unmet — a failure that would only surface at the first raw turn.
|
|
13
|
+
// unmet — a failure that would only surface at the first raw turn. The
|
|
14
14
|
// lockfile proves the same shape. Source-level import / API-client
|
|
15
15
|
// instantiation / credential use stays forbidden — asserted in layer (4).
|
|
16
16
|
// (GPT hard constraint 2, revised 2026-06-18: direct dep allowed ONLY as an
|
|
@@ -42,15 +42,15 @@ const read = (p: string): string => readFileSync(resolve(repoRoot, p), "utf8");
|
|
|
42
42
|
const pkg = JSON.parse(read("package.json")) as { dependencies?: Record<string, string> };
|
|
43
43
|
const deps = pkg.dependencies ?? {};
|
|
44
44
|
const PINS: Record<string, string> = {
|
|
45
|
-
"@agentclientprotocol/sdk": "
|
|
46
|
-
"@agentclientprotocol/claude-agent-acp": "0.
|
|
45
|
+
"@agentclientprotocol/sdk": "1.1.0",
|
|
46
|
+
"@agentclientprotocol/claude-agent-acp": "0.54.1",
|
|
47
47
|
[ANTHROPIC_SDK]: "0.100.1",
|
|
48
48
|
};
|
|
49
49
|
for (const [name, ver] of Object.entries(PINS)) {
|
|
50
50
|
assert.equal(
|
|
51
51
|
deps[name],
|
|
52
52
|
ver,
|
|
53
|
-
`package.json dependencies["${name}"] must be exact "${ver}" (got "${deps[name]}") — S2a pins the
|
|
53
|
+
`package.json dependencies["${name}"] must be exact "${ver}" (got "${deps[name]}") — S2a pins the current oracle versions`,
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -63,13 +63,13 @@ for (const [name, ver] of Object.entries(PINS)) {
|
|
|
63
63
|
const lock = read("pnpm-lock.yaml");
|
|
64
64
|
assert.match(
|
|
65
65
|
lock,
|
|
66
|
-
/@agentclientprotocol\/claude-agent-acp@0\.
|
|
67
|
-
"pnpm-lock: claude-agent-acp@0.
|
|
66
|
+
/@agentclientprotocol\/claude-agent-acp@0\.54\.1\(@anthropic-ai\/sdk@0\.100\.1/,
|
|
67
|
+
"pnpm-lock: claude-agent-acp@0.54.1 must peer-resolve @anthropic-ai/sdk@0.100.1 (peer-pin), not the stale 0.91.1",
|
|
68
68
|
);
|
|
69
69
|
assert.match(
|
|
70
70
|
lock,
|
|
71
|
-
/@anthropic-ai\/claude-agent-sdk@0\.3\.
|
|
72
|
-
"pnpm-lock: claude-agent-sdk@0.3.
|
|
71
|
+
/@anthropic-ai\/claude-agent-sdk@0\.3\.197\(@anthropic-ai\/sdk@0\.100\.1/,
|
|
72
|
+
"pnpm-lock: claude-agent-sdk@0.3.197 must peer-resolve @anthropic-ai/sdk@0.100.1 — else its >=0.93.0 peer floor is unmet",
|
|
73
73
|
);
|
|
74
74
|
|
|
75
75
|
// ---------------------------------------------------------------------------
|
|
@@ -108,8 +108,8 @@ const casEntry = adapterRequire.resolve("@anthropic-ai/claude-agent-sdk");
|
|
|
108
108
|
const casInfo = pkgInfoFromEntry(casEntry);
|
|
109
109
|
assert.equal(
|
|
110
110
|
casInfo.version,
|
|
111
|
-
"0.3.
|
|
112
|
-
`@anthropic-ai/claude-agent-sdk must runtime-resolve to 0.3.
|
|
111
|
+
"0.3.197",
|
|
112
|
+
`@anthropic-ai/claude-agent-sdk must runtime-resolve to 0.3.197 from the adapter context (got ${casInfo.version})`,
|
|
113
113
|
);
|
|
114
114
|
const casRequire = createRequire(resolve(casInfo.dir, "package.json"));
|
|
115
115
|
const sdkEntry = casRequire.resolve(ANTHROPIC_SDK);
|
|
@@ -121,19 +121,19 @@ assert.equal(
|
|
|
121
121
|
);
|
|
122
122
|
|
|
123
123
|
// ---------------------------------------------------------------------------
|
|
124
|
-
// (2c) adapter-context wire-SDK + MCP-SDK runtime resolve — the
|
|
125
|
-
// fault surface. Lock text (layer 2) freezes the publish floor; these probe
|
|
124
|
+
// (2c) adapter-context wire-SDK + MCP-SDK runtime resolve — the ACP dep bump's
|
|
125
|
+
// real fault surface. Lock text (layer 2) freezes the publish floor; these probe
|
|
126
126
|
// the LIVE module graph the adapter actually traverses: the adapter must SEE
|
|
127
|
-
// the same wire SDK the backend imports at root (
|
|
127
|
+
// the same wire SDK the backend imports at root (1.1.0), and claude-agent-sdk
|
|
128
128
|
// must SEE its declared MCP peer (1.29.x). Cheap edges, both newly relevant
|
|
129
|
-
// after the 0.
|
|
129
|
+
// after the 0.50→0.54 / 0.29→1.1 bump.
|
|
130
130
|
// ---------------------------------------------------------------------------
|
|
131
131
|
const wireEntry = adapterRequire.resolve("@agentclientprotocol/sdk");
|
|
132
132
|
const wireInfo = pkgInfoFromEntry(wireEntry);
|
|
133
133
|
assert.equal(
|
|
134
134
|
wireInfo.version,
|
|
135
|
-
"
|
|
136
|
-
`@agentclientprotocol/sdk must runtime-resolve to
|
|
135
|
+
"1.1.0",
|
|
136
|
+
`@agentclientprotocol/sdk must runtime-resolve to 1.1.0 from the adapter context (got ${wireInfo.version}) — the adapter and the backend must share one wire SDK`,
|
|
137
137
|
);
|
|
138
138
|
// @modelcontextprotocol/sdk gates its bare specifier behind "exports", so a
|
|
139
139
|
// require.resolve of the package ROOT throws (no resolvable entry) — read the
|
|
@@ -143,12 +143,12 @@ assert.equal(
|
|
|
143
143
|
const mcpPkg = JSON.parse(read("node_modules/@modelcontextprotocol/sdk/package.json")) as { version?: string };
|
|
144
144
|
assert.ok(
|
|
145
145
|
typeof mcpPkg.version === "string" && mcpPkg.version.startsWith("1.29."),
|
|
146
|
-
`@modelcontextprotocol/sdk must be 1.29.x (got ${mcpPkg.version}) — claude-agent-sdk 0.3.
|
|
146
|
+
`@modelcontextprotocol/sdk must be 1.29.x (got ${mcpPkg.version}) — claude-agent-sdk 0.3.197 declares a ^1.29.0 peer`,
|
|
147
147
|
);
|
|
148
148
|
|
|
149
149
|
// ---------------------------------------------------------------------------
|
|
150
150
|
// (3) @agentclientprotocol/sdk value-export surface (silent-rename gate)
|
|
151
|
-
// The
|
|
151
|
+
// The ACP bridge imports these from the wire SDK; a silent upstream
|
|
152
152
|
// rename would not fail typecheck (type-only erasure) but would break the
|
|
153
153
|
// raw turn. Assert the *value* exports exist at runtime.
|
|
154
154
|
// ---------------------------------------------------------------------------
|
|
@@ -32,7 +32,7 @@ import { resolve } from "node:path";
|
|
|
32
32
|
import { pathToFileURL } from "node:url";
|
|
33
33
|
import type { Api, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
|
|
34
34
|
|
|
35
|
-
const sonnet = { id: "claude-sonnet-
|
|
35
|
+
const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
|
|
36
36
|
const opus = { id: "claude-opus-4-8" } as unknown as Model<Api>;
|
|
37
37
|
|
|
38
38
|
type Stream = AsyncIterable<AssistantMessageEvent> & {
|
|
@@ -488,7 +488,7 @@ try {
|
|
|
488
488
|
);
|
|
489
489
|
const d1 = deltaText(t1);
|
|
490
490
|
const iPrep = d1.indexOf("[acp: preparing claude session]");
|
|
491
|
-
const iReady = d1.indexOf("[acp: session ready model=claude-sonnet-
|
|
491
|
+
const iReady = d1.indexOf("[acp: session ready model=claude-sonnet-5]");
|
|
492
492
|
const iSent = d1.indexOf("[acp: sending prompt]");
|
|
493
493
|
assert.ok(
|
|
494
494
|
iPrep >= 0 && iReady > iPrep && iSent > iReady,
|
|
@@ -517,7 +517,7 @@ try {
|
|
|
517
517
|
messages: [
|
|
518
518
|
{ role: "user", content: "real user line", timestamp: 0 },
|
|
519
519
|
mkAssistant([
|
|
520
|
-
{ type: "text", text: "\n[acp: session ready model=claude-sonnet-
|
|
520
|
+
{ type: "text", text: "\n[acp: session ready model=claude-sonnet-5]\n", textSignature: MARKER },
|
|
521
521
|
{ type: "text", text: "real assistant line" },
|
|
522
522
|
]),
|
|
523
523
|
],
|
|
@@ -43,8 +43,8 @@ import {
|
|
|
43
43
|
|
|
44
44
|
const baseInput = (): BridgeConfigInput => ({
|
|
45
45
|
backend: "claude",
|
|
46
|
-
modelId: "claude-sonnet-
|
|
47
|
-
nativeModelId: "claude-sonnet-
|
|
46
|
+
modelId: "claude-sonnet-5",
|
|
47
|
+
nativeModelId: "claude-sonnet-5",
|
|
48
48
|
appendSystemPrompt: "",
|
|
49
49
|
mcpServersHash: "deadbeef",
|
|
50
50
|
settingSources: [],
|
|
@@ -61,7 +61,7 @@ const baseInput = (): BridgeConfigInput => ({
|
|
|
61
61
|
{
|
|
62
62
|
const sig0 = bridgeConfigSignature(baseInput());
|
|
63
63
|
assert.ok(isSha256Hex(sig0), "config signature is a sha256 digest (no raw carrier text on disk)");
|
|
64
|
-
assert.ok(!sig0.includes("claude-sonnet-
|
|
64
|
+
assert.ok(!sig0.includes("claude-sonnet-5"), "config signature is a digest — never embeds the raw modelId/carrier");
|
|
65
65
|
assert.equal(sig0, bridgeConfigSignature(baseInput()), "signature is deterministic");
|
|
66
66
|
// Array copies do not affect equality (same content).
|
|
67
67
|
assert.equal(bridgeConfigSignature({ ...baseInput(), settingSources: [] }), sig0, "empty settingSources stable");
|
|
@@ -178,7 +178,7 @@ const baseInput = (): BridgeConfigInput => ({
|
|
|
178
178
|
|
|
179
179
|
const facts = (over: Partial<SessionCompatFacts> = {}): SessionCompatFacts => ({
|
|
180
180
|
cwd: "/w",
|
|
181
|
-
modelId: "claude-sonnet-
|
|
181
|
+
modelId: "claude-sonnet-5",
|
|
182
182
|
bridgeConfigSignature: bridgeConfigSignature(baseInput()),
|
|
183
183
|
contextMessageSignatures: ["user:text:a"],
|
|
184
184
|
...over,
|
|
@@ -201,7 +201,7 @@ const baseInput = (): BridgeConfigInput => ({
|
|
|
201
201
|
const sig = bridgeConfigSignature(baseInput());
|
|
202
202
|
const compatFacts: SessionCompatFacts = {
|
|
203
203
|
cwd: "/w",
|
|
204
|
-
modelId: "claude-sonnet-
|
|
204
|
+
modelId: "claude-sonnet-5",
|
|
205
205
|
bridgeConfigSignature: sig,
|
|
206
206
|
contextMessageSignatures: ["user:text:a"],
|
|
207
207
|
};
|
|
@@ -305,7 +305,7 @@ const baseInput = (): BridgeConfigInput => ({
|
|
|
305
305
|
{
|
|
306
306
|
const facts: SessionCompatFacts = {
|
|
307
307
|
cwd: "/w",
|
|
308
|
-
modelId: "claude-sonnet-
|
|
308
|
+
modelId: "claude-sonnet-5",
|
|
309
309
|
bridgeConfigSignature: bridgeConfigSignature(baseInput()),
|
|
310
310
|
contextMessageSignatures: ["user:text:a"],
|
|
311
311
|
};
|
|
@@ -90,7 +90,7 @@ assert.ok(nativeThrew, "non-claude backend exposing write natively must throw wh
|
|
|
90
90
|
// ---------------------------------------------------------------------------
|
|
91
91
|
|
|
92
92
|
const baseParams = {
|
|
93
|
-
modelId: "claude-sonnet-
|
|
93
|
+
modelId: "claude-sonnet-5",
|
|
94
94
|
tools: DEFAULT_CLAUDE_TOOLS,
|
|
95
95
|
permissionAllow: DEFAULT_CLAUDE_PERMISSION_ALLOW,
|
|
96
96
|
disallowedTools: DEFAULT_CLAUDE_DISALLOWED_TOOLS,
|
|
@@ -101,7 +101,7 @@ const baseParams = {
|
|
|
101
101
|
|
|
102
102
|
const meta = buildClaudeSessionMeta(baseParams);
|
|
103
103
|
const opts = (meta.claudeCode as { options: Record<string, unknown> }).options;
|
|
104
|
-
assert.equal(opts.model, "claude-sonnet-
|
|
104
|
+
assert.equal(opts.model, "claude-sonnet-5", "model must propagate");
|
|
105
105
|
assert.deepEqual(opts.tools, [...DEFAULT_CLAUDE_TOOLS], "tools must propagate (copied)");
|
|
106
106
|
assert.deepEqual(
|
|
107
107
|
(opts.settings as { permissions: { allow: string[] } }).permissions.allow,
|
|
@@ -132,7 +132,7 @@ try {
|
|
|
132
132
|
{ p: "entwurf", m: "claude-opus-4-8", title: "Review substrate smoke", tags: ["entwurf", "review"] },
|
|
133
133
|
{ p: "openai-codex", m: "gpt-5.5", title: "async resume check", tags: ["entwurf", "smoke"] },
|
|
134
134
|
{ p: "entwurf", m: "gemini-3.1-pro-preview", title: "vision team", tags: ["entwurf"] },
|
|
135
|
-
{ p: "entwurf", m: "claude-sonnet-
|
|
135
|
+
{ p: "entwurf", m: "claude-sonnet-5", title: "manual session", tags: [] as string[] },
|
|
136
136
|
];
|
|
137
137
|
for (const c of cases) {
|
|
138
138
|
const sid = generateSessionId(new Date(2026, 5, 3, 19, 12, 45));
|
|
@@ -252,7 +252,7 @@ try {
|
|
|
252
252
|
tags: ["entwurf"],
|
|
253
253
|
});
|
|
254
254
|
eq(parseSessionName(goodName)?.model, recorded, "name model mirrors recorded model");
|
|
255
|
-
const driftName = "20260603T191245-deadbe==entwurf/claude-sonnet-
|
|
255
|
+
const driftName = "20260603T191245-deadbe==entwurf/claude-sonnet-5--x__entwurf";
|
|
256
256
|
ok(parseSessionName(driftName)?.model !== recorded, "model drift is detectable for fail-fast");
|
|
257
257
|
|
|
258
258
|
// ---- T-collision: header scan is authority; spawn pre-check ----
|
|
@@ -440,12 +440,12 @@ try {
|
|
|
440
440
|
bigFile,
|
|
441
441
|
`${JSON.stringify({ type: "session", id: "20260603T210000-bbbbbb", cwd: "/b" })}\n` +
|
|
442
442
|
msg({ content: bigBody, model: "m-big", provider: "p-big", usage: { cost: { total: 1 } } }) +
|
|
443
|
-
msg({ content: "final", model: "claude-sonnet-
|
|
443
|
+
msg({ content: "final", model: "claude-sonnet-5", provider: "entwurf", usage: { cost: { total: 0.5 } } }),
|
|
444
444
|
);
|
|
445
445
|
const a2 = analyzeSessionFileLike(bigFile);
|
|
446
446
|
eq(a2.turns, 2, "analyze(big): turns correct across chunk boundaries");
|
|
447
447
|
eq(a2.lastAssistantText, "final", "analyze(big): trailing turn intact after multi-chunk line");
|
|
448
|
-
eq(a2.lastModel, "claude-sonnet-
|
|
448
|
+
eq(a2.lastModel, "claude-sonnet-5", "analyze(big): trailing model intact");
|
|
449
449
|
eq(Math.round(a2.cost * 100) / 100, 1.5, "analyze(big): cost summed across large line");
|
|
450
450
|
|
|
451
451
|
// ---- T-identity: resume identity authority = FIRST model_change ----
|
|
@@ -513,21 +513,21 @@ try {
|
|
|
513
513
|
const cleanName = buildSessionName({
|
|
514
514
|
sessionId: idE,
|
|
515
515
|
provider: "entwurf",
|
|
516
|
-
model: "claude-sonnet-
|
|
516
|
+
model: "claude-sonnet-5",
|
|
517
517
|
rawTitle: "clean resume",
|
|
518
518
|
tags: ["entwurf", "async"],
|
|
519
519
|
});
|
|
520
520
|
fs.writeFileSync(
|
|
521
521
|
fileE,
|
|
522
522
|
sessionLine(idE, "/identity/e") +
|
|
523
|
-
mc("entwurf", "claude-sonnet-
|
|
523
|
+
mc("entwurf", "claude-sonnet-5") +
|
|
524
524
|
infoLine(cleanName) +
|
|
525
|
-
msg({ content: "ok", model: "claude-sonnet-
|
|
525
|
+
msg({ content: "ok", model: "claude-sonnet-5", provider: "entwurf" }),
|
|
526
526
|
);
|
|
527
527
|
noThrow(() => readSessionIdentity(fileE), "identity: clean session does not throw");
|
|
528
528
|
const recE = readSessionIdentity(fileE);
|
|
529
529
|
eq(recE?.provider, "entwurf", "identity(clean): provider");
|
|
530
|
-
eq(recE?.modelId, "claude-sonnet-
|
|
530
|
+
eq(recE?.modelId, "claude-sonnet-5", "identity(clean): modelId mirrors name");
|
|
531
531
|
|
|
532
532
|
// 6. no model_change → null (caller refuses with its own no-recorded-model result).
|
|
533
533
|
const idF = "20260603T220000-6666ff";
|
|
@@ -583,7 +583,7 @@ try {
|
|
|
583
583
|
);
|
|
584
584
|
eq(
|
|
585
585
|
readSessionIdentity(fileE, { requireEntwurf: true })?.modelId,
|
|
586
|
-
"claude-sonnet-
|
|
586
|
+
"claude-sonnet-5",
|
|
587
587
|
"requireEntwurf: accepted Entwurf session returns first-model_change identity",
|
|
588
588
|
);
|
|
589
589
|
|
|
@@ -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")
|
|
@@ -210,7 +210,7 @@ async function makeHarness(opts: MakeHarnessOpts = {}): Promise<Harness> {
|
|
|
210
210
|
// Test models.
|
|
211
211
|
const NATIVE_A: ModelLike = { provider: "openai-codex", id: "gpt-5.4" };
|
|
212
212
|
const NATIVE_B: ModelLike = { provider: "openai-codex", id: "gpt-5.5" };
|
|
213
|
-
const PSA_SONNET: ModelLike = { provider: "entwurf", id: "claude-sonnet-
|
|
213
|
+
const PSA_SONNET: ModelLike = { provider: "entwurf", id: "claude-sonnet-5" };
|
|
214
214
|
const PSA_OPUS: ModelLike = { provider: "entwurf", id: "claude-opus-4-8" };
|
|
215
215
|
|
|
216
216
|
let passed = 0;
|
|
@@ -250,7 +250,7 @@ await run("2. native → entwurf: revert + warning notify", async () => {
|
|
|
250
250
|
assert.equal(h.notifyCalls.length, 1);
|
|
251
251
|
assert.equal(h.notifyCalls[0].level, "warning");
|
|
252
252
|
assert.match(h.notifyCalls[0].message, /locked to openai-codex\/gpt-5\.4/);
|
|
253
|
-
assert.match(h.notifyCalls[0].message, /entwurf\/claude-sonnet-
|
|
253
|
+
assert.match(h.notifyCalls[0].message, /entwurf\/claude-sonnet-5/);
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
await run("3. entwurf → native: revert + warning notify", async () => {
|
|
@@ -74,7 +74,7 @@ function rmInstall(...segs: string[]): void {
|
|
|
74
74
|
fs.rmSync(path.join(tmpAgent, ...segs), { recursive: true, force: true });
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
const ACP: ResolvedTarget = { provider: "entwurf", model: "claude-sonnet-
|
|
77
|
+
const ACP: ResolvedTarget = { provider: "entwurf", model: "claude-sonnet-5", explicitOnly: false };
|
|
78
78
|
const eArg = (r: { args: string[] }): string | undefined => {
|
|
79
79
|
const i = r.args.indexOf("-e");
|
|
80
80
|
return i >= 0 ? r.args[i + 1] : undefined;
|
|
@@ -211,7 +211,7 @@ check("project-scope source (unseen in user settings) fails fast on remote", ()
|
|
|
211
211
|
// (resume callers fail-fast; no -e injected).
|
|
212
212
|
check("resume recorded entwurf + remote + no source → unresolvedAcpIntent", () => {
|
|
213
213
|
setSource(null);
|
|
214
|
-
const r = getEntwurfExplicitExtensions("claude-sonnet-
|
|
214
|
+
const r = getEntwurfExplicitExtensions("claude-sonnet-5", true, "entwurf");
|
|
215
215
|
assert.equal(r.unresolvedAcpIntent, true);
|
|
216
216
|
assert.equal(eArg(r), undefined);
|
|
217
217
|
assert.ok(r.warnings.length > 0, "should warn about the unresolved bridge");
|
|
@@ -220,7 +220,7 @@ check("resume recorded entwurf + remote + no source → unresolvedAcpIntent", ()
|
|
|
220
220
|
// 13. recorded provider=entwurf + LOCAL → self-root resolves, no fail-fast.
|
|
221
221
|
check("resume recorded entwurf + local resolves via self-root", () => {
|
|
222
222
|
setSource(null);
|
|
223
|
-
const r = getEntwurfExplicitExtensions("claude-sonnet-
|
|
223
|
+
const r = getEntwurfExplicitExtensions("claude-sonnet-5", false, "entwurf");
|
|
224
224
|
assert.ok(!r.unresolvedAcpIntent, "local self-root should resolve");
|
|
225
225
|
assert.equal(eArg(r), REPO_ROOT);
|
|
226
226
|
assert.equal(r.provider, "entwurf");
|
|
@@ -242,7 +242,7 @@ check("resume Codex-via-ACP opt-in + remote + no source → unresolvedAcpIntent"
|
|
|
242
242
|
// fail-fast — because the legacy pi-claude-code-use bridge may exist.
|
|
243
243
|
check("resume Claude-only heuristic + remote + no source stays warning-only", () => {
|
|
244
244
|
setSource(null);
|
|
245
|
-
const r = getEntwurfExplicitExtensions("claude-sonnet-
|
|
245
|
+
const r = getEntwurfExplicitExtensions("claude-sonnet-5", true, undefined);
|
|
246
246
|
assert.ok(!r.unresolvedAcpIntent, "Claude heuristic must not fail-fast");
|
|
247
247
|
assert.ok(r.warnings.length > 0, "should warn");
|
|
248
248
|
assert.equal(eArg(r), undefined);
|
|
@@ -117,7 +117,7 @@ PY
|
|
|
117
117
|
EXPECTED_STATUSLINE="$REPO/scripts/meta-bridge-statusline.sh"
|
|
118
118
|
if [ "$STATUSLINE_CMD" = "$EXPECTED_STATUSLINE" ]; then ok "statusLine.command is repo-owned: $STATUSLINE_CMD"; else bad "statusLine.command drifted (got '$STATUSLINE_CMD', expected '$EXPECTED_STATUSLINE')"; fi
|
|
119
119
|
if [ -x "$EXPECTED_STATUSLINE" ]; then ok "statusline executable"; else bad "statusline script not executable: $EXPECTED_STATUSLINE"; fi
|
|
120
|
-
SAMPLE_STATUSLINE_OUT="$(printf '%s' '{"session_id":"doctor-no-record","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-sonnet-
|
|
120
|
+
SAMPLE_STATUSLINE_OUT="$(printf '%s' '{"session_id":"doctor-no-record","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-sonnet-5"},"context_window":{"context_window_size":200000,"used_percentage":1,"current_usage":{"input_tokens":1}}}' | "$EXPECTED_STATUSLINE" 2>/dev/null || true)"
|
|
121
121
|
if [ "$(printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | wc -l | tr -d ' ')" = "2" ] && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '1p' | grep -q 'tmp' && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -q '🪛' && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -q ' cc | s'; then ok "statusline synthetic execution emits two rows (work context + identity)"; else bad "statusline synthetic execution failed or omitted two-row work/identity marker"; fi
|
|
122
122
|
else
|
|
123
123
|
bad "cannot validate statusline without python3"
|
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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)"
|
|
@@ -80,6 +80,8 @@ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
|
|
|
80
80
|
("showTurnDuration", ["showTurnDuration"], False),
|
|
81
81
|
("terminalProgressBarEnabled", ["terminalProgressBarEnabled"], False),
|
|
82
82
|
("useAutoModeDuringPlan", ["useAutoModeDuringPlan"], False),
|
|
83
|
+
("enableWorkflows", ["enableWorkflows"], False),
|
|
84
|
+
("workflowKeywordTriggerEnabled", ["workflowKeywordTriggerEnabled"], False),
|
|
83
85
|
]
|
|
84
86
|
|
|
85
87
|
|
|
@@ -264,20 +266,42 @@ def desired_marketplace(asm: Path) -> dict[str, Any]:
|
|
|
264
266
|
return {"source": {"source": "directory", "path": str(asm.resolve())}}
|
|
265
267
|
|
|
266
268
|
|
|
269
|
+
def is_installed_package(repo: Path) -> bool:
|
|
270
|
+
# An installed (npm / pnpm) layout always ends in node_modules/@junghanacs/entwurf
|
|
271
|
+
# (pnpm global nests it under .pnpm/<hash>/node_modules/@junghanacs/entwurf). A dev
|
|
272
|
+
# clone never lives under node_modules/@junghanacs/entwurf, so the trailing three
|
|
273
|
+
# path components cleanly distinguish the two WITHOUT a PATH lookup (`command -v
|
|
274
|
+
# entwurf-bridge` could resolve a stale GLOBAL bin from a clone host and is wrong here).
|
|
275
|
+
parts = repo.resolve().parts
|
|
276
|
+
return parts[-3:] == ("node_modules", "@junghanacs", "entwurf")
|
|
277
|
+
|
|
278
|
+
|
|
267
279
|
def desired_mcp(repo: Path) -> dict[str, Any]:
|
|
280
|
+
env = {
|
|
281
|
+
"ENTWURF_BRIDGE_EXTERNAL_AGENT_ID": "external-mcp/claude-code",
|
|
282
|
+
# Anonymous sends are forbidden on the Claude Code install path: a send
|
|
283
|
+
# with no pi-session identity AND no meta-sender marker is refused, not
|
|
284
|
+
# delivered as an unidentified external. The SessionStart hook writes
|
|
285
|
+
# the marker (parent-pid keyed), so a normally-opened session always has
|
|
286
|
+
# an authoritative garden-id sender.
|
|
287
|
+
"ENTWURF_BRIDGE_REQUIRE_META_SENDER": "1",
|
|
288
|
+
}
|
|
289
|
+
if is_installed_package(repo):
|
|
290
|
+
# Installed package: wire the STABLE `entwurf-bridge` bin shim that npm/pnpm
|
|
291
|
+
# place on PATH. Baking repo/mcp/entwurf-bridge/start.sh would embed the pnpm
|
|
292
|
+
# store path (.pnpm/@junghanacs+entwurf@<ver>_<peer-hash>/...), which moves
|
|
293
|
+
# on any peer/version change and leaves a dead MCP command. The bare bin name
|
|
294
|
+
# tracks whatever version is currently installed — the right behaviour for a
|
|
295
|
+
# managed install host. start.sh's own position-based dual-mode then runs the
|
|
296
|
+
# dist boot under node_modules. (0.12.2 fix; 0.12.1 shipped only the clone path.)
|
|
297
|
+
return {"type": "stdio", "command": "entwurf-bridge", "args": [], "env": env}
|
|
298
|
+
# Dev clone: pin to THIS clone's launcher (an absolute resolved path), never the
|
|
299
|
+
# global bin, so a clone host stays bound to its own source tree.
|
|
268
300
|
return {
|
|
269
301
|
"type": "stdio",
|
|
270
302
|
"command": "bash",
|
|
271
303
|
"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
|
-
},
|
|
304
|
+
"env": env,
|
|
281
305
|
}
|
|
282
306
|
|
|
283
307
|
|
|
@@ -519,7 +543,7 @@ def main() -> int:
|
|
|
519
543
|
parser = argparse.ArgumentParser(description="entwurf meta-bridge state manager")
|
|
520
544
|
parser.add_argument(
|
|
521
545
|
"command",
|
|
522
|
-
choices=["prepare", "apply", "preflight-uninstall", "uninstall", "check", "managed-keys"],
|
|
546
|
+
choices=["prepare", "apply", "preflight-uninstall", "uninstall", "check", "managed-keys", "desired-mcp"],
|
|
523
547
|
)
|
|
524
548
|
parser.add_argument("--repo", default=Path(__file__).resolve().parents[1], type=Path)
|
|
525
549
|
parser.add_argument("--asm", default=None, type=Path)
|
|
@@ -539,6 +563,11 @@ def main() -> int:
|
|
|
539
563
|
check(repo, asm)
|
|
540
564
|
elif args.command == "managed-keys":
|
|
541
565
|
print(json.dumps(managed_keys(), indent=2))
|
|
566
|
+
elif args.command == "desired-mcp":
|
|
567
|
+
# Debug/guard surface: print the user-scope MCP entry desired_mcp() would
|
|
568
|
+
# write for --repo. Lets a deterministic guard assert the installed-vs-clone
|
|
569
|
+
# dual-mode without spinning up a real `claude` CLI. --repo need not exist.
|
|
570
|
+
print(json.dumps(desired_mcp(repo), indent=2))
|
|
542
571
|
except StateError as exc:
|
|
543
572
|
print(f"meta-bridge-state: {exc}", file=sys.stderr)
|
|
544
573
|
return 1
|