@junghanacs/entwurf 0.13.0 → 0.14.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.
- package/AGENTS.md +52 -17
- package/BASELINE.md +60 -206
- package/CHANGELOG.md +64 -0
- package/CONTRIBUTING.md +14 -10
- package/DELIVERY.md +118 -261
- package/README.md +91 -431
- package/VERIFY.md +74 -95
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -3
- package/demo/demo.sh +2 -5
- package/docs/acp-backend-rail.md +236 -0
- package/docs/external-mcp-host.md +131 -0
- package/docs/fresh-cut-policy.md +99 -0
- package/docs/setup-clean-host.md +124 -328
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
- package/mcp/entwurf-bridge/src/index.ts +176 -67
- package/mcp/entwurf-bridge/start.sh +2 -2
- package/mcp/entwurf-bridge/test.sh +23 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
- package/package.json +25 -11
- package/pi-extensions/entwurf-control.ts +220 -71
- package/pi-extensions/lib/acp/acp-client.ts +22 -0
- package/pi-extensions/lib/acp/augment.ts +42 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
- package/pi-extensions/lib/acp/backend.ts +366 -38
- package/pi-extensions/lib/acp/config.ts +1 -1
- package/pi-extensions/lib/acp/engraving.ts +43 -1
- package/pi-extensions/lib/acp/event-mapper.ts +16 -13
- package/pi-extensions/lib/acp/models.ts +4 -4
- package/pi-extensions/lib/acp/overlay.ts +11 -9
- package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
- package/pi-extensions/lib/entwurf-core.ts +15 -15
- package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
- package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
- package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
- package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
- package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
- package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
- package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
- package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
- package/pi-extensions/lib/meta-session.ts +93 -5
- package/pi-extensions/lib/mux-fresh-call.ts +328 -0
- package/pi-extensions/lib/mux-launch.ts +267 -0
- package/pi-extensions/lib/mux-placement.ts +387 -0
- package/pi-extensions/lib/mux-resume-call.ts +221 -0
- package/pi-extensions/lib/resume-launch-identity.ts +162 -0
- package/pi-extensions/lib/session-id.js +8 -5
- package/pi-extensions/lib/socket-discovery.ts +3 -3
- package/prompts/engraving.md +13 -5
- package/run.sh +688 -282
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-carrier-augment.ts +246 -10
- package/scripts/check-acp-cortex.ts +5 -5
- package/scripts/check-acp-overlay.ts +13 -3
- package/scripts/check-acp-prompt-lifecycle.ts +565 -0
- package/scripts/check-acp-stop-reason.ts +342 -0
- package/scripts/check-acp-stream-hooks.ts +504 -0
- package/scripts/check-elapsed.sh +25 -0
- package/scripts/check-entwurf-bridge-boot.ts +51 -4
- package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
- package/scripts/check-entwurf-control-rpc.ts +4 -3
- package/scripts/check-entwurf-resume-args.ts +72 -70
- package/scripts/check-entwurf-session-identity.ts +14 -10
- package/scripts/check-entwurf-v2-contract.ts +34 -59
- package/scripts/check-entwurf-v2-decider.ts +17 -177
- package/scripts/check-entwurf-v2-lock.ts +5 -2
- package/scripts/check-entwurf-v2-matrix.ts +3 -53
- package/scripts/check-entwurf-v2-production.ts +2 -91
- package/scripts/check-entwurf-v2-release.ts +10 -105
- package/scripts/check-entwurf-v2-runner.ts +4 -85
- package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
- package/scripts/check-entwurf-v2-send.ts +0 -28
- package/scripts/check-entwurf-v2-surface.ts +198 -128
- package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
- package/scripts/check-fresh-cut-gate.sh +1 -1
- package/scripts/check-gate-qualification.ts +101 -7
- package/scripts/check-install-container.sh +10 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-keyset-overlap.py +1 -1
- package/scripts/check-meta-facts.ts +249 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-meta-session.ts +169 -0
- package/scripts/check-mux-launch-tmux.ts +316 -0
- package/scripts/check-mux-launch.ts +288 -0
- package/scripts/check-mux-launcher-fence.ts +264 -0
- package/scripts/check-mux-parent-artifact.ts +195 -0
- package/scripts/check-mux-placement-tmux.ts +322 -0
- package/scripts/check-mux-placement.ts +323 -0
- package/scripts/check-mux-resume-call.ts +282 -0
- package/scripts/check-probe-cli-shim.ts +25 -22
- package/scripts/check-probe-ordering.ts +121 -81
- package/scripts/check-release-gate-outcomes.ts +452 -0
- package/scripts/check-resume-launch-identity.ts +244 -0
- package/scripts/check-socket-discovery.ts +1 -1
- package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
- package/scripts/inventory-verification-surface.ts +349 -0
- package/scripts/lib/claude-launcher-fence.ts +322 -0
- package/scripts/lib/live-skip.ts +33 -0
- package/scripts/lib/mutation-qualify.ts +109 -3
- package/scripts/lib/probe-acp-turn.ts +33 -12
- package/scripts/lib/step-outcome.sh +88 -0
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-augment.json +106 -0
- package/scripts/mutants/acp-cortex.json +2 -2
- package/scripts/mutants/acp-overlay.json +17 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
- package/scripts/mutants/acp-stop-reason.json +80 -0
- package/scripts/mutants/acp-stream-hooks.json +158 -0
- package/scripts/mutants/bridge-boot-resume.json +45 -0
- package/scripts/mutants/meta-facts.json +50 -0
- package/scripts/mutants/meta-identity.json +36 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +185 -0
- package/scripts/mutants/mux-launcher-fence.json +123 -0
- package/scripts/mutants/mux-parent-artifact.json +39 -0
- package/scripts/mutants/mux-resume-call.json +148 -0
- package/scripts/mutants/probe-ordering.json +6 -1021
- package/scripts/mutants/release-gate.json +140 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +74 -23
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
- package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +8 -14
- package/scripts/smoke-acp-long-turn-live.ts +185 -0
- package/scripts/smoke-acp-mcp-live.ts +2 -2
- package/scripts/smoke-acp-memory-containment-live.ts +2 -2
- package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
- package/scripts/smoke-acp-overlay-live.ts +2 -2
- package/scripts/smoke-acp-provider-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +3 -3
- package/scripts/smoke-acp-session-reuse-live.ts +2 -2
- package/scripts/smoke-acp-skill-live.ts +2 -2
- package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
- package/scripts/smoke-acp-v2-send-live.ts +4 -3
- package/scripts/smoke-agy-native-push-live.ts +10 -20
- package/scripts/smoke-claude-native-resume-live.sh +13 -3
- package/scripts/smoke-entwurf-chain-live.ts +352 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-meta-install-state.sh +4 -0
- package/scripts/smoke-mux-fresh-call-live.ts +365 -0
- package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
- package/scripts/smoke-pi-attach.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
- package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
- package/scripts/check-acp-sdk-surface.ts +0 -236
- package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
- package/scripts/check-entwurf-v2-spawn.ts +0 -399
- package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// Deterministic gate for the pi 0.84 streamSimple HOOK contract on the ACP rail
|
|
2
|
+
// (#63; upstream pi-mono #7372 → doc-only PR #7576).
|
|
3
|
+
//
|
|
4
|
+
// What the contract is, measured, not assumed:
|
|
5
|
+
//
|
|
6
|
+
// onPayload maps TRUTHFULLY: the provider request on this rail is the ACP
|
|
7
|
+
// `session/prompt` params, so the hook receives the EXACT
|
|
8
|
+
// `{sessionId, prompt}` object after the wire content is fully
|
|
9
|
+
// built (augment / reuse delta included) and immediately before
|
|
10
|
+
// `connection.prompt`; a returned replacement becomes the wire
|
|
11
|
+
// params. Integrity is fail-closed: a replacement must be a
|
|
12
|
+
// non-null, non-array object keeping the bootstrapped sessionId
|
|
13
|
+
// and carrying a non-empty prompt array — prompt rewriting is
|
|
14
|
+
// upstream-granted power, session identity and this rail's own
|
|
15
|
+
// non-empty-prompt invariant are not.
|
|
16
|
+
// onResponse is an EXPLICIT LOCAL NON-HTTP EXEMPTION: pi hard-types it as
|
|
17
|
+
// HTTP `{status, headers}` and ACP's terminal result arrives only
|
|
18
|
+
// after the session-update body was consumed — any invocation
|
|
19
|
+
// would fabricate HTTP evidence. This gate pins the ABSENCE
|
|
20
|
+
// behaviorally across success, error, and abort turns.
|
|
21
|
+
//
|
|
22
|
+
// Every cell drives `streamAcpTurn` against a fake ACP child/connection with
|
|
23
|
+
// handlers registered for BOTH hooks, and judges what actually reached the wire.
|
|
24
|
+
//
|
|
25
|
+
// backend.ts imports its siblings with `.js` suffixes (the root/jiti runtime
|
|
26
|
+
// convention), so — like check-acp-prompt-lifecycle — we tsc-emit the project
|
|
27
|
+
// and import the COMPILED backend.js whose `.js` imports resolve to real siblings.
|
|
28
|
+
|
|
29
|
+
import { strict as assert } from "node:assert";
|
|
30
|
+
import { execFileSync } from "node:child_process";
|
|
31
|
+
import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs";
|
|
32
|
+
import { tmpdir } from "node:os";
|
|
33
|
+
import { resolve } from "node:path";
|
|
34
|
+
import { pathToFileURL } from "node:url";
|
|
35
|
+
import type { Api, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
|
|
36
|
+
|
|
37
|
+
const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
|
|
38
|
+
|
|
39
|
+
type Stream = AsyncIterable<AssistantMessageEvent>;
|
|
40
|
+
|
|
41
|
+
interface Deferred<T> {
|
|
42
|
+
promise: Promise<T>;
|
|
43
|
+
resolve: (value: T) => void;
|
|
44
|
+
reject: (err: unknown) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function deferred<T>(): Deferred<T> {
|
|
48
|
+
let res!: (value: T) => void;
|
|
49
|
+
let rej!: (err: unknown) => void;
|
|
50
|
+
const promise = new Promise<T>((a, b) => {
|
|
51
|
+
res = a;
|
|
52
|
+
rej = b;
|
|
53
|
+
});
|
|
54
|
+
return { promise, resolve: res, reject: rej };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeFakeChild() {
|
|
58
|
+
const pipe = () => ({ destroy() {}, unref() {} });
|
|
59
|
+
return {
|
|
60
|
+
pid: undefined as number | undefined,
|
|
61
|
+
exitCode: null as number | null,
|
|
62
|
+
signalCode: null as NodeJS.Signals | null,
|
|
63
|
+
stdin: pipe(),
|
|
64
|
+
stdout: pipe(),
|
|
65
|
+
stderr: { on() {}, destroy() {}, unref() {} },
|
|
66
|
+
kill() {
|
|
67
|
+
return true;
|
|
68
|
+
},
|
|
69
|
+
unref() {},
|
|
70
|
+
once() {},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const EMPTY_MCP_HASH = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
|
|
75
|
+
const DEFAULT_RESOLVED_CONFIG: any = {
|
|
76
|
+
settingSources: [],
|
|
77
|
+
strictMcpConfig: true,
|
|
78
|
+
showToolNotifications: true,
|
|
79
|
+
mcpServers: [],
|
|
80
|
+
mcpServersHash: EMPTY_MCP_HASH,
|
|
81
|
+
tools: ["Read"],
|
|
82
|
+
skillPlugins: [],
|
|
83
|
+
permissionAllow: ["Read(*)"],
|
|
84
|
+
disallowedTools: [],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** One backend world whose fake connection CAPTURES the raw wire prompt params. */
|
|
88
|
+
function makeHarness(recordDir: string) {
|
|
89
|
+
const children: ReturnType<typeof makeFakeChild>[] = [];
|
|
90
|
+
const promptCalls: Array<{ sessionId: string; prompt: Array<{ type: string; text: string }> }> = [];
|
|
91
|
+
|
|
92
|
+
const makeConnection = (_handlers: any) => ({
|
|
93
|
+
initialize: async () => ({ agentCapabilities: {} }),
|
|
94
|
+
newSession: async () => ({ sessionId: "ACP-1" }),
|
|
95
|
+
setSessionConfigOption: async () => ({}),
|
|
96
|
+
prompt: async (params: any) => {
|
|
97
|
+
// Deep-cloned: the capture must be the bytes that crossed the seam, not a
|
|
98
|
+
// live reference a later mutation could rewrite.
|
|
99
|
+
promptCalls.push(structuredClone(params));
|
|
100
|
+
return { stopReason: "end_turn" };
|
|
101
|
+
},
|
|
102
|
+
cancel: async () => {},
|
|
103
|
+
close: () => {},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
children,
|
|
108
|
+
promptCalls,
|
|
109
|
+
deps: {
|
|
110
|
+
resolveLaunch: () => ({ command: "node", args: ["fake"] }),
|
|
111
|
+
ensureOverlay: () => {},
|
|
112
|
+
spawnChild: () => {
|
|
113
|
+
const c = makeFakeChild();
|
|
114
|
+
children.push(c);
|
|
115
|
+
return c;
|
|
116
|
+
},
|
|
117
|
+
createConnection: (_child: any, handlers: any) => makeConnection(handlers),
|
|
118
|
+
lifecyclePolicy: () => "process-scoped",
|
|
119
|
+
loadConfig: () => DEFAULT_RESOLVED_CONFIG,
|
|
120
|
+
now: () => "2026-08-07T00:00:00Z",
|
|
121
|
+
sessionDir: recordDir,
|
|
122
|
+
abortGraceMs: 40,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function collect(stream: Stream): Promise<AssistantMessageEvent[]> {
|
|
128
|
+
const events: AssistantMessageEvent[] = [];
|
|
129
|
+
for await (const ev of stream) events.push(ev);
|
|
130
|
+
return events;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const sealed = (events: AssistantMessageEvent[]) =>
|
|
134
|
+
events.filter((e) => e.type === "done" || e.type === "error") as any[];
|
|
135
|
+
|
|
136
|
+
const userCtx = (text: string): Context => ({ messages: [{ role: "user", content: text, timestamp: 0 }] }) as Context;
|
|
137
|
+
|
|
138
|
+
function reuseCtx(prior: string, latest: string): Context {
|
|
139
|
+
return {
|
|
140
|
+
messages: [
|
|
141
|
+
{ role: "user", content: prior, timestamp: 0 },
|
|
142
|
+
{
|
|
143
|
+
role: "assistant",
|
|
144
|
+
content: [{ type: "text", text: "ok" }],
|
|
145
|
+
api: "x",
|
|
146
|
+
provider: "x",
|
|
147
|
+
model: "x",
|
|
148
|
+
usage: {
|
|
149
|
+
input: 0,
|
|
150
|
+
output: 0,
|
|
151
|
+
cacheRead: 0,
|
|
152
|
+
cacheWrite: 0,
|
|
153
|
+
totalTokens: 0,
|
|
154
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
155
|
+
},
|
|
156
|
+
stopReason: "stop",
|
|
157
|
+
timestamp: 0,
|
|
158
|
+
} as unknown as Message,
|
|
159
|
+
{ role: "user", content: latest, timestamp: 0 },
|
|
160
|
+
],
|
|
161
|
+
} as Context;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// EVERY cell registers onResponse; the exemption claim at the end judges the sum.
|
|
165
|
+
let onResponseCalls = 0;
|
|
166
|
+
const onResponse = () => {
|
|
167
|
+
onResponseCalls++;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const TMP_EMIT = ".tmp-verify/acp-stream-hooks";
|
|
171
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
172
|
+
const recordDir = mkdtempSync(resolve(tmpdir(), "acp-stream-hooks-"));
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
|
|
176
|
+
stdio: "pipe",
|
|
177
|
+
});
|
|
178
|
+
const promptsOut = resolve(TMP_EMIT, "pi-extensions/lib/acp/prompts");
|
|
179
|
+
mkdirSync(promptsOut, { recursive: true });
|
|
180
|
+
copyFileSync("pi-extensions/lib/acp/prompts/engraving.md", resolve(promptsOut, "engraving.md"));
|
|
181
|
+
const backend = (await import(pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href)) as any;
|
|
182
|
+
|
|
183
|
+
// ----------------------------------------------------------------------
|
|
184
|
+
// CELL 1 — NEW turn: the hook sees the EXACT wire params; undefined keeps them.
|
|
185
|
+
// ----------------------------------------------------------------------
|
|
186
|
+
{
|
|
187
|
+
const h = makeHarness(recordDir);
|
|
188
|
+
const seen: any[] = [];
|
|
189
|
+
const events = await collect(
|
|
190
|
+
backend.streamAcpTurn(
|
|
191
|
+
sonnet,
|
|
192
|
+
userCtx("NONCE-FIRST-TURN"),
|
|
193
|
+
{
|
|
194
|
+
sessionId: "hooks-new",
|
|
195
|
+
onPayload: (payload: unknown) => {
|
|
196
|
+
seen.push(structuredClone(payload));
|
|
197
|
+
return undefined;
|
|
198
|
+
},
|
|
199
|
+
onResponse,
|
|
200
|
+
},
|
|
201
|
+
h.deps,
|
|
202
|
+
) as Stream,
|
|
203
|
+
);
|
|
204
|
+
assert.equal(sealed(events)[0]?.type, "done", "the pass-through turn completes normally");
|
|
205
|
+
assert.ok(
|
|
206
|
+
seen.length === 1 && JSON.stringify(seen[0]) === JSON.stringify(h.promptCalls[0]),
|
|
207
|
+
"[QK:ACPHOOK-PAYLOAD-EXACT-WIRE] before_provider_request must run exactly once and receive the EXACT ACP " +
|
|
208
|
+
"session/prompt params that then cross the wire — same bootstrapped sessionId, same fully-built prompt " +
|
|
209
|
+
"(augment included) — and an undefined return must leave those params untouched; a skipped hook or a " +
|
|
210
|
+
"divergent payload means extensions observe a fiction",
|
|
211
|
+
);
|
|
212
|
+
assert.ok(
|
|
213
|
+
JSON.stringify(seen[0].prompt).includes("NONCE-FIRST-TURN"),
|
|
214
|
+
"the observed payload really carries this turn's user content",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ----------------------------------------------------------------------
|
|
219
|
+
// CELL 2 — NEW turn: a returned replacement becomes the wire params.
|
|
220
|
+
// ----------------------------------------------------------------------
|
|
221
|
+
{
|
|
222
|
+
const h = makeHarness(recordDir);
|
|
223
|
+
const events = await collect(
|
|
224
|
+
backend.streamAcpTurn(
|
|
225
|
+
sonnet,
|
|
226
|
+
userCtx("NONCE-ORIGINAL"),
|
|
227
|
+
{
|
|
228
|
+
sessionId: "hooks-replace",
|
|
229
|
+
onPayload: (payload: any) => ({
|
|
230
|
+
sessionId: payload.sessionId,
|
|
231
|
+
prompt: [{ type: "text", text: "NONCE-REPLACED-WIRE" }],
|
|
232
|
+
}),
|
|
233
|
+
onResponse,
|
|
234
|
+
},
|
|
235
|
+
h.deps,
|
|
236
|
+
) as Stream,
|
|
237
|
+
);
|
|
238
|
+
assert.equal(sealed(events)[0]?.type, "done", "the replaced turn completes normally");
|
|
239
|
+
assert.equal(h.promptCalls.length, 1, "exactly one prompt crossed the wire");
|
|
240
|
+
const wire = JSON.stringify(h.promptCalls[0]);
|
|
241
|
+
assert.ok(
|
|
242
|
+
wire.includes("NONCE-REPLACED-WIRE") && !wire.includes("NONCE-ORIGINAL"),
|
|
243
|
+
"[QK:ACPHOOK-REPLACEMENT-ON-WIRE] a replacement returned by before_provider_request must BE the wire params — " +
|
|
244
|
+
"the upstream contract says 'use any returned replacement payload', and ignoring it is exactly the " +
|
|
245
|
+
"pi-vertex defect (#7372) that silently breaks payload-hooking extensions",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ----------------------------------------------------------------------
|
|
250
|
+
// CELL 3 — REUSE turn parity: same boundary, delta params, replacement honored.
|
|
251
|
+
// ----------------------------------------------------------------------
|
|
252
|
+
{
|
|
253
|
+
const h = makeHarness(recordDir);
|
|
254
|
+
const seen: any[] = [];
|
|
255
|
+
let replaceReuse = false;
|
|
256
|
+
const options = {
|
|
257
|
+
sessionId: "hooks-reuse",
|
|
258
|
+
onPayload: (payload: any) => {
|
|
259
|
+
seen.push(structuredClone(payload));
|
|
260
|
+
if (!replaceReuse) return undefined;
|
|
261
|
+
return { sessionId: payload.sessionId, prompt: [{ type: "text", text: "NONCE-REUSE-REPLACED" }] };
|
|
262
|
+
},
|
|
263
|
+
onResponse,
|
|
264
|
+
};
|
|
265
|
+
const first = await collect(backend.streamAcpTurn(sonnet, userCtx("NONCE-TURN-ONE"), options, h.deps) as Stream);
|
|
266
|
+
assert.equal(sealed(first)[0]?.type, "done", "turn 1 (new) completes");
|
|
267
|
+
replaceReuse = true;
|
|
268
|
+
const second = await collect(
|
|
269
|
+
backend.streamAcpTurn(sonnet, reuseCtx("NONCE-TURN-ONE", "NONCE-TURN-TWO"), options, h.deps) as Stream,
|
|
270
|
+
);
|
|
271
|
+
assert.equal(sealed(second)[0]?.type, "done", "turn 2 (reuse) completes");
|
|
272
|
+
assert.equal(h.children.length, 1, "turn 2 reused the live session — no second child");
|
|
273
|
+
const reuseSeen = JSON.stringify(seen[1] ?? null);
|
|
274
|
+
const reuseWire = JSON.stringify(h.promptCalls[1] ?? null);
|
|
275
|
+
assert.ok(
|
|
276
|
+
seen.length === 2 &&
|
|
277
|
+
reuseSeen.includes("NONCE-TURN-TWO") &&
|
|
278
|
+
!reuseSeen.includes("NONCE-TURN-ONE") &&
|
|
279
|
+
reuseWire.includes("NONCE-REUSE-REPLACED") &&
|
|
280
|
+
!reuseWire.includes("NONCE-TURN-TWO"),
|
|
281
|
+
"[QK:ACPHOOK-REUSE-PARITY] the reuse turn owes the hook the SAME truthful boundary as a new turn — the payload " +
|
|
282
|
+
"it sees is the actual delta-only wire params for the live session, and its replacement crosses the wire; " +
|
|
283
|
+
"a hook that only fires on cold turns would hide every resident-session request from extensions",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ----------------------------------------------------------------------
|
|
288
|
+
// CELL 4 — a rejecting hook fails the turn LOUD, nothing is sent.
|
|
289
|
+
// (Ordered before the refusal cells: a silent-fallback defect at the call
|
|
290
|
+
// site must die here, at its own claim, before an integrity cell can trip.)
|
|
291
|
+
// ----------------------------------------------------------------------
|
|
292
|
+
{
|
|
293
|
+
const h = makeHarness(recordDir);
|
|
294
|
+
const events = await collect(
|
|
295
|
+
backend.streamAcpTurn(
|
|
296
|
+
sonnet,
|
|
297
|
+
userCtx("hook rejection"),
|
|
298
|
+
{
|
|
299
|
+
sessionId: "hooks-reject",
|
|
300
|
+
onPayload: () => Promise.reject(new Error("NONCE-HOOK-EXPLODED")),
|
|
301
|
+
onResponse,
|
|
302
|
+
},
|
|
303
|
+
h.deps,
|
|
304
|
+
) as Stream,
|
|
305
|
+
);
|
|
306
|
+
const seal = sealed(events)[0];
|
|
307
|
+
assert.ok(
|
|
308
|
+
seal?.type === "error" &&
|
|
309
|
+
String(seal.error?.errorMessage ?? "").includes("NONCE-HOOK-EXPLODED") &&
|
|
310
|
+
h.promptCalls.length === 0,
|
|
311
|
+
"[QK:ACPHOOK-REJECTION-FAILS-LOUD] pi's own runner already swallows extension handler errors, so a rejection " +
|
|
312
|
+
"reaching this seam is a real failure — it must seal the turn loudly with the original message and send " +
|
|
313
|
+
"nothing, never be silently caught into a fallback send",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ----------------------------------------------------------------------
|
|
318
|
+
// CELL 5 — a non-object replacement is refused LOUD, nothing is sent.
|
|
319
|
+
// ----------------------------------------------------------------------
|
|
320
|
+
{
|
|
321
|
+
const h = makeHarness(recordDir);
|
|
322
|
+
const events = await collect(
|
|
323
|
+
backend.streamAcpTurn(
|
|
324
|
+
sonnet,
|
|
325
|
+
userCtx("invalid replacement"),
|
|
326
|
+
{ sessionId: "hooks-invalid", onPayload: () => 42, onResponse },
|
|
327
|
+
h.deps,
|
|
328
|
+
) as Stream,
|
|
329
|
+
);
|
|
330
|
+
const seal = sealed(events)[0];
|
|
331
|
+
assert.ok(
|
|
332
|
+
seal?.type === "error" &&
|
|
333
|
+
String(seal.error?.errorMessage ?? "").includes("non-object replacement") &&
|
|
334
|
+
h.promptCalls.length === 0,
|
|
335
|
+
"[QK:ACPHOOK-INVALID-REPLACEMENT-REFUSED] a replacement that is not a non-null, non-array object must seal " +
|
|
336
|
+
"the turn as a loud error BEFORE anything reaches the wire — silently falling back to the original params " +
|
|
337
|
+
"would hide a broken extension exactly where it thinks it is rewriting requests",
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ----------------------------------------------------------------------
|
|
342
|
+
// CELL 6 — a sessionId-tampering replacement is refused LOUD, nothing is sent.
|
|
343
|
+
// ----------------------------------------------------------------------
|
|
344
|
+
{
|
|
345
|
+
const h = makeHarness(recordDir);
|
|
346
|
+
const events = await collect(
|
|
347
|
+
backend.streamAcpTurn(
|
|
348
|
+
sonnet,
|
|
349
|
+
userCtx("tampered sessionId"),
|
|
350
|
+
{
|
|
351
|
+
sessionId: "hooks-tamper",
|
|
352
|
+
onPayload: (payload: any) => ({ sessionId: "SOMEONE-ELSE", prompt: payload.prompt }),
|
|
353
|
+
onResponse,
|
|
354
|
+
},
|
|
355
|
+
h.deps,
|
|
356
|
+
) as Stream,
|
|
357
|
+
);
|
|
358
|
+
const seal = sealed(events)[0];
|
|
359
|
+
assert.ok(
|
|
360
|
+
seal?.type === "error" &&
|
|
361
|
+
String(seal.error?.errorMessage ?? "").includes("changed the ACP sessionId") &&
|
|
362
|
+
h.promptCalls.length === 0,
|
|
363
|
+
"[QK:ACPHOOK-SESSIONID-TAMPER-REFUSED] a replacement that changes the bootstrapped sessionId must refuse the " +
|
|
364
|
+
"turn before the wire — entwurf cannot truthfully deliver to an ACP session it did not open, and silently " +
|
|
365
|
+
"sending there would corrupt session identity while reporting success",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ----------------------------------------------------------------------
|
|
370
|
+
// CELL 7 — a replacement that empties the prompt is refused LOUD, nothing sent.
|
|
371
|
+
// ----------------------------------------------------------------------
|
|
372
|
+
{
|
|
373
|
+
const h = makeHarness(recordDir);
|
|
374
|
+
const events = await collect(
|
|
375
|
+
backend.streamAcpTurn(
|
|
376
|
+
sonnet,
|
|
377
|
+
userCtx("emptied prompt"),
|
|
378
|
+
{
|
|
379
|
+
sessionId: "hooks-empty",
|
|
380
|
+
onPayload: (payload: any) => ({ sessionId: payload.sessionId, prompt: [] }),
|
|
381
|
+
onResponse,
|
|
382
|
+
},
|
|
383
|
+
h.deps,
|
|
384
|
+
) as Stream,
|
|
385
|
+
);
|
|
386
|
+
const seal = sealed(events)[0];
|
|
387
|
+
assert.ok(
|
|
388
|
+
seal?.type === "error" &&
|
|
389
|
+
String(seal.error?.errorMessage ?? "").includes("non-empty prompt array") &&
|
|
390
|
+
h.promptCalls.length === 0,
|
|
391
|
+
"[QK:ACPHOOK-EMPTY-PROMPT-REFUSED] a replacement whose prompt is missing, non-array, or empty must refuse " +
|
|
392
|
+
"the turn by name before the wire — this rail already owns a non-empty-prompt invariant on the built " +
|
|
393
|
+
"params, and a replacement must not be able to undo it into an empty ACP send",
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ----------------------------------------------------------------------
|
|
398
|
+
// CELL 8 — abort raised WHILE the hook is awaited wins before the wire (NEW turn).
|
|
399
|
+
// ----------------------------------------------------------------------
|
|
400
|
+
{
|
|
401
|
+
const h = makeHarness(recordDir);
|
|
402
|
+
const gate = deferred<undefined>();
|
|
403
|
+
const controller = new AbortController();
|
|
404
|
+
const turn = collect(
|
|
405
|
+
backend.streamAcpTurn(
|
|
406
|
+
sonnet,
|
|
407
|
+
userCtx("abort during hook"),
|
|
408
|
+
{
|
|
409
|
+
sessionId: "hooks-abort",
|
|
410
|
+
signal: controller.signal,
|
|
411
|
+
onPayload: () => gate.promise,
|
|
412
|
+
onResponse,
|
|
413
|
+
},
|
|
414
|
+
h.deps,
|
|
415
|
+
) as Stream,
|
|
416
|
+
);
|
|
417
|
+
// Let the turn reach the awaited hook, abort, THEN let the hook resolve.
|
|
418
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
419
|
+
controller.abort();
|
|
420
|
+
gate.resolve(undefined);
|
|
421
|
+
const events = await turn;
|
|
422
|
+
const seal = sealed(events)[0];
|
|
423
|
+
assert.ok(
|
|
424
|
+
seal?.type === "error" && seal.reason === "aborted" && h.promptCalls.length === 0,
|
|
425
|
+
"[QK:ACPHOOK-ABORT-DURING-HOOK-WINS] an abort raised while onPayload is awaited must win before the wire " +
|
|
426
|
+
"write — the turn seals aborted and NO prompt is sent, because a slow handler must never turn a cancelled " +
|
|
427
|
+
"turn into a delivered one",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ----------------------------------------------------------------------
|
|
432
|
+
// CELL 9 — abort during the awaited hook wins on the REUSE path too: the
|
|
433
|
+
// resident session's bootstrap prompt is the control, and no SECOND prompt
|
|
434
|
+
// may cross the wire.
|
|
435
|
+
// ----------------------------------------------------------------------
|
|
436
|
+
{
|
|
437
|
+
const h = makeHarness(recordDir);
|
|
438
|
+
const gate = deferred<undefined>();
|
|
439
|
+
const controller = new AbortController();
|
|
440
|
+
let deferReuseHook = false;
|
|
441
|
+
const options: Record<string, unknown> = {
|
|
442
|
+
sessionId: "hooks-reuse-abort",
|
|
443
|
+
signal: controller.signal,
|
|
444
|
+
onPayload: () => (deferReuseHook ? gate.promise : undefined),
|
|
445
|
+
onResponse,
|
|
446
|
+
};
|
|
447
|
+
const first = await collect(
|
|
448
|
+
backend.streamAcpTurn(sonnet, userCtx("NONCE-REUSE-ABORT-ONE"), options, h.deps) as Stream,
|
|
449
|
+
);
|
|
450
|
+
assert.equal(sealed(first)[0]?.type, "done", "turn 1 (new) establishes the resident session");
|
|
451
|
+
assert.equal(h.promptCalls.length, 1, "the bootstrap prompt is the control send");
|
|
452
|
+
deferReuseHook = true;
|
|
453
|
+
const turn = collect(
|
|
454
|
+
backend.streamAcpTurn(
|
|
455
|
+
sonnet,
|
|
456
|
+
reuseCtx("NONCE-REUSE-ABORT-ONE", "NONCE-REUSE-ABORT-TWO"),
|
|
457
|
+
options,
|
|
458
|
+
h.deps,
|
|
459
|
+
) as Stream,
|
|
460
|
+
);
|
|
461
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
462
|
+
controller.abort();
|
|
463
|
+
gate.resolve(undefined);
|
|
464
|
+
const events = await turn;
|
|
465
|
+
const seal = sealed(events)[0];
|
|
466
|
+
assert.ok(
|
|
467
|
+
seal?.type === "error" && seal.reason === "aborted" && h.promptCalls.length === 1 && h.children.length === 1,
|
|
468
|
+
"[QK:ACPHOOK-REUSE-ABORT-WINS] an abort raised while the REUSE turn's onPayload is awaited must win before " +
|
|
469
|
+
"the wire write — the turn seals aborted and the resident session's bootstrap prompt stays the ONLY send; " +
|
|
470
|
+
"the reuse path owes the same abort-over-slow-handler guarantee as a cold turn",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ----------------------------------------------------------------------
|
|
475
|
+
// CELL 10 — the onResponse exemption, judged across every cell above.
|
|
476
|
+
// ----------------------------------------------------------------------
|
|
477
|
+
assert.equal(
|
|
478
|
+
onResponseCalls,
|
|
479
|
+
0,
|
|
480
|
+
"[QK:ACPHOOK-ONRESPONSE-NEVER-CALLED] onResponse was registered on every turn above — success, replacement, " +
|
|
481
|
+
"reuse, refusals, rejection, and both abort paths — and must have been invoked ZERO times: ACP has no " +
|
|
482
|
+
"truthful HTTP {status, headers} and its terminal result arrives after the body was consumed, so any " +
|
|
483
|
+
"invocation is fabricated evidence; the exemption is deliberate and this is its behavioral pin",
|
|
484
|
+
);
|
|
485
|
+
} finally {
|
|
486
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
487
|
+
try {
|
|
488
|
+
// A leftover EMPTY parent dir reads as IMPURE tree drift in the
|
|
489
|
+
// qualification harness; a concurrent sibling gate's emit keeps it alive
|
|
490
|
+
// and this rmdir simply fails.
|
|
491
|
+
rmdirSync(".tmp-verify");
|
|
492
|
+
} catch {
|
|
493
|
+
// non-empty or already gone — fine either way
|
|
494
|
+
}
|
|
495
|
+
rmSync(recordDir, { recursive: true, force: true });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
console.log(
|
|
499
|
+
"[check-acp-stream-hooks] ok — before_provider_request receives the exact ACP session/prompt wire params on new " +
|
|
500
|
+
"AND reuse turns and its replacement is honored fail-closed (non-null non-array object, sessionId invariant, " +
|
|
501
|
+
"non-empty prompt array, loud refusal with zero sends otherwise, rejection loud, abort-during-hook wins on " +
|
|
502
|
+
"BOTH paths), and after_provider_response is a deliberate non-HTTP exemption invoked zero times across " +
|
|
503
|
+
"success/error/abort",
|
|
504
|
+
);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# check-elapsed — total wall-time reporter for the tiered check entrypoints (#70).
|
|
3
|
+
#
|
|
4
|
+
# Usage: bash scripts/check-elapsed.sh <label> <pnpm-script>...
|
|
5
|
+
#
|
|
6
|
+
# Runs each named pnpm script IN ORDER (fail-fast), then prints exactly one
|
|
7
|
+
# summary line — total elapsed wall seconds plus the exit code — on success AND
|
|
8
|
+
# on failure, and exits with the first failing script's code. Tier MEMBERSHIP
|
|
9
|
+
# lives in package.json (the executable SSOT); this wrapper owns only the
|
|
10
|
+
# wall-clock line. It is deliberately not a profiler: no per-step timing, no
|
|
11
|
+
# cache, no history, no thresholds. The core tier's ≤60s acceptance is an
|
|
12
|
+
# operator measurement on the reference host (oracle), never a hard fail here —
|
|
13
|
+
# an arbitrary CI host's variance must not turn wall time into a flaky gate.
|
|
14
|
+
set -u
|
|
15
|
+
|
|
16
|
+
label="$1"
|
|
17
|
+
shift
|
|
18
|
+
start=$(date +%s)
|
|
19
|
+
code=0
|
|
20
|
+
for script in "$@"; do
|
|
21
|
+
pnpm run "$script" || { code=$?; break; }
|
|
22
|
+
done
|
|
23
|
+
elapsed=$(($(date +%s) - start))
|
|
24
|
+
echo "[${label}] total elapsed ${elapsed}s exit ${code}"
|
|
25
|
+
exit "$code"
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* runtime contract that the source-shape gate `check-entwurf-v2-surface` cannot:
|
|
6
6
|
*
|
|
7
7
|
* - G1a: the server actually BOOTS. start.sh static-imports the whole v2 fence
|
|
8
|
-
* graph (entwurf-v2-surface → runner → production → decider/lock/send/
|
|
8
|
+
* graph (entwurf-v2-surface → runner → production → decider/lock/send/
|
|
9
9
|
* mailbox/control-rpc) at top level under strip-types; a strip-types-hostile
|
|
10
10
|
* construct (enum / namespace / parameter property / `import =`) anywhere in
|
|
11
11
|
* that graph would crash the server at boot. A parseable tools/list (id:2)
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* Scope boundary (D1=A안): this gate owns boot + current tool registration/schema.
|
|
20
20
|
* The broad protocol/negative suite stays in check-bridge/test.sh. Only a
|
|
21
21
|
* `tools/list` is sent — no `tools/call` — so there is NO lock/filesystem side
|
|
22
|
-
* effect and no auth/model is needed; it lives in `pnpm check`.
|
|
22
|
+
* effect and no auth/model is needed; it lives in `pnpm run check:full`.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { spawn } from "node:child_process";
|
|
@@ -175,8 +175,8 @@ async function main(): Promise<void> {
|
|
|
175
175
|
ok(`G1b: entwurf_v2 schema requires '${r}'`, required.includes(r), `--- inputSchema ---\n${rawSchema}`);
|
|
176
176
|
}
|
|
177
177
|
ok(
|
|
178
|
-
"G1b: entwurf_v2 intent enum == {fire-and-forget
|
|
179
|
-
setEq(props.intent?.enum, ["fire-and-forget"
|
|
178
|
+
"G1b: entwurf_v2 intent enum == {fire-and-forget} (owned-outcome withdrawn: visible-first cut)",
|
|
179
|
+
setEq(props.intent?.enum, ["fire-and-forget"]),
|
|
180
180
|
`--- inputSchema ---\n${rawSchema}`,
|
|
181
181
|
);
|
|
182
182
|
ok(
|
|
@@ -190,6 +190,30 @@ async function main(): Promise<void> {
|
|
|
190
190
|
`--- inputSchema ---\n${rawSchema}`,
|
|
191
191
|
);
|
|
192
192
|
|
|
193
|
+
// G1e — the S1 resume verb, judged on the RUNTIME surface rather than on source wiring. A
|
|
194
|
+
// source-level check would still pass if the registration were deleted and only the import
|
|
195
|
+
// remained; this is the oracle that says the tool is actually registered and its schema has
|
|
196
|
+
// the shape claimed.
|
|
197
|
+
//
|
|
198
|
+
// What it does NOT prove: that a host will ACCEPT that schema. This gate reads the emitted
|
|
199
|
+
// JSON, it does not run a provider's validator — which is exactly why 7-M-fix's unescaped `[`
|
|
200
|
+
// got past every gate here while breaking real sessions. Host/provider acceptance is an
|
|
201
|
+
// uncovered axis, tracked as issue #61, and nothing below should be read as standing in for it.
|
|
202
|
+
const resume = tools.find((t) => t?.name === "entwurf_resume_call");
|
|
203
|
+
ok(
|
|
204
|
+
"[QK:BRIDGEBOOT-RESUME-CALL-REGISTERED] G1e: entwurf_resume_call is registered on the runtime tools/list surface — not merely imported",
|
|
205
|
+
!!resume,
|
|
206
|
+
);
|
|
207
|
+
const resumeSchema = resume?.inputSchema ?? {};
|
|
208
|
+
const rawResume = JSON.stringify(resumeSchema);
|
|
209
|
+
const resumeProps = resumeSchema.properties ?? {};
|
|
210
|
+
const resumeRequired = Array.isArray(resumeSchema.required) ? resumeSchema.required.map(String) : [];
|
|
211
|
+
ok(
|
|
212
|
+
"[QK:BRIDGEBOOT-RESUME-CALL-TARGET-ONLY] G1e: entwurf_resume_call takes EXACTLY one required input, target — a model, task or prompt knob here would be a second way to decide what a resumed citizen is, when the record already decided",
|
|
213
|
+
setEq(Object.keys(resumeProps), ["target"]) && setEq(resumeRequired, ["target"]),
|
|
214
|
+
`--- inputSchema ---\n${rawResume}`,
|
|
215
|
+
);
|
|
216
|
+
|
|
193
217
|
ok(
|
|
194
218
|
"G1c: native registration fallback is present",
|
|
195
219
|
tools.some((t) => t?.name === "entwurf_register_native"),
|
|
@@ -199,6 +223,29 @@ async function main(): Promise<void> {
|
|
|
199
223
|
!tools.some((t) => ["entwurf", "entwurf_resume", "entwurf_send"].includes(String(t?.name))),
|
|
200
224
|
);
|
|
201
225
|
|
|
226
|
+
// G1f — the public surface is an EXACT set, judged last so the named assertions above
|
|
227
|
+
// keep their own diagnosis. Every check before this one is existential: each names one
|
|
228
|
+
// verb it cares about, so a verb nobody named could be dropped (entwurf_fresh_call was
|
|
229
|
+
// exactly that) or an undecided one added, with the gate still green. "A narrow tool
|
|
230
|
+
// surface is discipline" (AGENTS North Star) is a statement about the WHOLE set, and
|
|
231
|
+
// only equality can carry it. The expected list is written here rather than derived
|
|
232
|
+
// from the server: an oracle computed from the subject proves nothing.
|
|
233
|
+
const publicSurface = tools.map((t) => String(t?.name)).sort();
|
|
234
|
+
const expectedSurface = [
|
|
235
|
+
"entwurf_fresh_call",
|
|
236
|
+
"entwurf_inbox_read",
|
|
237
|
+
"entwurf_peers",
|
|
238
|
+
"entwurf_register_native",
|
|
239
|
+
"entwurf_resume_call",
|
|
240
|
+
"entwurf_self",
|
|
241
|
+
"entwurf_v2",
|
|
242
|
+
];
|
|
243
|
+
ok(
|
|
244
|
+
"[QK:BRIDGEBOOT-PUBLIC-SURFACE-EXACT-SET] G1f: the runtime tools/list surface is EXACTLY the seven shipped garden verbs — no missing verb, no undecided extra, no duplicate",
|
|
245
|
+
publicSurface.length === expectedSurface.length && expectedSurface.every((n, i) => publicSurface[i] === n),
|
|
246
|
+
`--- want ---\n${expectedSurface.join(",")}\n--- got ---\n${publicSurface.join(",")}`,
|
|
247
|
+
);
|
|
248
|
+
|
|
202
249
|
console.log(`\ncheck-entwurf-bridge-boot: ${passed} checks passed`);
|
|
203
250
|
}
|
|
204
251
|
|
|
@@ -21,9 +21,11 @@
|
|
|
21
21
|
// - `await import("x")` (dynamic) → INTENDED lazy boundary → NOT followed
|
|
22
22
|
//
|
|
23
23
|
// The dynamic-import exemption is deliberate: B-2 makes the pi-coding-agent
|
|
24
|
-
// `preflight` a lazy `await import("./entwurf-preflight.ts")
|
|
25
|
-
//
|
|
26
|
-
//
|
|
24
|
+
// `preflight` a lazy `await import("./entwurf-preflight.ts")`, so it is allowed to
|
|
25
|
+
// pull pi at runtime behind that lazy edge. The branch that used to reach it — the
|
|
26
|
+
// owned-outcome resume verdict — is gone, which makes the exemption WIDER than its
|
|
27
|
+
// current need, not narrower: nothing on the v2 dispatch path pulls pi now. The
|
|
28
|
+
// runtime boot smoke (separate half) is the final authority that
|
|
27
29
|
// peers/self/list/mailbox-deliver come up pi-free.
|
|
28
30
|
|
|
29
31
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -135,8 +137,7 @@ if (violations.length > 0) {
|
|
|
135
137
|
}
|
|
136
138
|
console.error(
|
|
137
139
|
"\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()
|
|
139
|
-
" on the owned-outcome resume branch only.",
|
|
140
|
+
" type-only import, a pi-side module the bridge does not reach, or a lazy `await import()`.",
|
|
140
141
|
);
|
|
141
142
|
process.exit(1);
|
|
142
143
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* receiver-stuck backstop the settled-guard preserves).
|
|
18
18
|
* 5. get_info runtime helper parses/formats cwd/model/idle once for every caller.
|
|
19
19
|
*
|
|
20
|
-
* No model / auth / pi process — only `net.Server` on a tmp socket, so it rides `pnpm check`.
|
|
20
|
+
* No model / auth / pi process — only `net.Server` on a tmp socket, so it rides `pnpm run check:full`.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import assert from "node:assert/strict";
|
|
@@ -191,8 +191,9 @@ async function main(): Promise<void> {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
// ── 6. formatSenderInfoBlock — THE <sender_info> synthesis (#50 C3) ─────────
|
|
194
|
-
//
|
|
195
|
-
// the
|
|
194
|
+
// ONE formatter, and since the visible-first cut exactly one consumer (the live
|
|
195
|
+
// receiver) — the dormant resume prompt that shared it is gone. The exact string
|
|
196
|
+
// shape stays a contract so a future VISIBLE resume renders through it, not beside it.
|
|
196
197
|
{
|
|
197
198
|
const base = {
|
|
198
199
|
sessionId: "20260613T091000-98363c",
|