@junghanacs/entwurf 0.23.0 → 0.24.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 +3 -1
- package/BASELINE.md +2 -0
- package/CHANGELOG.md +307 -0
- package/FAQ.md +47 -0
- package/README.md +5 -3
- package/VERIFY.md +43 -2
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +97 -18
- package/docs/setup-clean-host.md +8 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +33 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +7 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +13 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -1
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/package.json +15 -13
- package/pi-extensions/entwurf-control.ts +56 -0
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +20 -10
- package/pi-extensions/lib/acp/context.ts +30 -10
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/session-store.ts +2 -2
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/callback-env.ts +78 -0
- package/pi-extensions/lib/compaction-send-guard.ts +4 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +56 -22
- package/pi-extensions/lib/herdr-fresh-call.ts +12 -4
- package/pi-extensions/lib/herdr-placement.ts +13 -4
- package/pi-extensions/lib/mux-fresh-call.ts +23 -8
- package/pi-extensions/meta-bridge-omp.ts +35 -48
- package/run.sh +327 -246
- package/scripts/check-acp-backend-preflight.ts +117 -9
- package/scripts/check-acp-carrier-augment.ts +6 -4
- package/scripts/check-acp-event-mapper.ts +17 -6
- package/scripts/check-acp-prompt-builder.ts +17 -9
- package/scripts/check-acp-prompt-lifecycle.ts +23 -6
- package/scripts/check-acp-session-reuse.ts +64 -51
- package/scripts/check-acp-session-store.ts +31 -8
- package/scripts/check-acp-stop-reason.ts +20 -2
- package/scripts/check-acp-stream-hooks.ts +22 -5
- package/scripts/check-acp-usage-accounting.ts +42 -18
- package/scripts/check-entwurf-bridge-boot.ts +16 -1
- package/scripts/check-fresh-call-dispatch.ts +4 -5
- package/scripts/check-gate-qualification.ts +45 -5
- package/scripts/check-herdr-activation.ts +73 -0
- package/scripts/check-herdr-fresh-call.ts +22 -6
- package/scripts/check-herdr-sandbox.ts +14 -2
- package/scripts/check-install-container.sh +1 -1
- package/scripts/check-pi-launch.ts +361 -0
- package/scripts/check-release-gate-outcomes.ts +7 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/mutation-qualify.ts +1 -1
- package/scripts/mutants/acp-backend-preflight.json +20 -0
- package/scripts/mutants/bridge-boot-resume.json +26 -0
- package/scripts/mutants/herdr-activation.json +16 -0
- package/scripts/mutants/herdr-fresh-call.json +11 -0
- package/scripts/mutants/herdr-plugin-build.json +1 -1
- package/scripts/mutants/mux-fresh-call.json +21 -8
- package/scripts/mutants/omp-fresh.json +5 -9
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-session-reuse-live.ts +23 -5
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +56 -28
- package/scripts/smoke-herdr-raw-install-live.sh +877 -0
- package/scripts/smoke-omp-fresh-live.ts +4 -2
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-provider-state.sh +2 -2
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -391
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- package/scripts/check-socket-discovery.ts +0 -401
|
@@ -14,6 +14,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
|
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import type { Context } from "@earendil-works/pi-ai";
|
|
17
|
+
import { normalizeContext } from "@earendil-works/pi-ai";
|
|
17
18
|
|
|
18
19
|
const sha256 = (v: string) => createHash("sha256").update(v).digest("hex");
|
|
19
20
|
const isSha256Hex = (v: string) => /^[0-9a-f]{64}$/.test(v);
|
|
@@ -114,13 +115,16 @@ const baseInput = (): BridgeConfigInput => ({
|
|
|
114
115
|
stopReason: "stop" as const,
|
|
115
116
|
timestamp: 0,
|
|
116
117
|
}) satisfies Context["messages"][number];
|
|
117
|
-
|
|
118
|
+
// Built through `normalizeContext` — the 0.86 provider-path shape. With no
|
|
119
|
+
// systemPrompt and no tools it adds no leading system message, so the exact
|
|
120
|
+
// three-entry expectation below is the same list production hashes.
|
|
121
|
+
const ctx = normalizeContext({
|
|
118
122
|
messages: [
|
|
119
123
|
{ role: "user", content: "hello", timestamp: 0 },
|
|
120
124
|
assistant("hi"),
|
|
121
125
|
{ role: "user", content: [{ type: "image", data: "RAWIMAGEBYTES", mimeType: "image/png" }], timestamp: 0 },
|
|
122
126
|
],
|
|
123
|
-
};
|
|
127
|
+
});
|
|
124
128
|
const sigs = contextMessageSignatures(ctx);
|
|
125
129
|
// every entry is a sha256 digest of the pre-hash role:content form.
|
|
126
130
|
assert.ok(sigs.every(isSha256Hex), "every message signature is a sha256 digest");
|
|
@@ -139,21 +143,21 @@ const baseInput = (): BridgeConfigInput => ({
|
|
|
139
143
|
|
|
140
144
|
// toolResult folds in toolName + isError → same text, different tool/flag = different sig.
|
|
141
145
|
const trContent = [{ type: "text" as const, text: "out" }];
|
|
142
|
-
const trBase
|
|
146
|
+
const trBase = normalizeContext({
|
|
143
147
|
messages: [
|
|
144
148
|
{ role: "toolResult", toolCallId: "t1", toolName: "bash", isError: false, content: trContent, timestamp: 0 },
|
|
145
149
|
],
|
|
146
|
-
};
|
|
147
|
-
const trErr
|
|
150
|
+
});
|
|
151
|
+
const trErr = normalizeContext({
|
|
148
152
|
messages: [
|
|
149
153
|
{ role: "toolResult", toolCallId: "t1", toolName: "bash", isError: true, content: trContent, timestamp: 0 },
|
|
150
154
|
],
|
|
151
|
-
};
|
|
152
|
-
const trOther
|
|
155
|
+
});
|
|
156
|
+
const trOther = normalizeContext({
|
|
153
157
|
messages: [
|
|
154
158
|
{ role: "toolResult", toolCallId: "t1", toolName: "edit", isError: false, content: trContent, timestamp: 0 },
|
|
155
159
|
],
|
|
156
|
-
};
|
|
160
|
+
});
|
|
157
161
|
assert.notEqual(
|
|
158
162
|
contextMessageSignatures(trBase)[0],
|
|
159
163
|
contextMessageSignatures(trErr)[0],
|
|
@@ -164,6 +168,25 @@ const baseInput = (): BridgeConfigInput => ({
|
|
|
164
168
|
contextMessageSignatures(trOther)[0],
|
|
165
169
|
"toolResult toolName changes the signature",
|
|
166
170
|
);
|
|
171
|
+
|
|
172
|
+
// 0.86 shape: `normalizeContext` folds systemPrompt+tools into a LEADING
|
|
173
|
+
// `role:"system"` message, so the carrier now rides INSIDE `messages` and is
|
|
174
|
+
// hashed like any other turn. Two consequences this cell locks:
|
|
175
|
+
// - a systemPrompt drift changes signature[0] → the prefix check declares the
|
|
176
|
+
// candidate incompatible → no reuse of a session built under the old prompt;
|
|
177
|
+
// - the same user turn under a system prompt is NOT a prefix of the same turn
|
|
178
|
+
// without one, so a 0.85-shaped record cannot silently be reused at 0.86.
|
|
179
|
+
const sameUser = [{ role: "user" as const, content: "hello", timestamp: 0 }];
|
|
180
|
+
const bare = contextMessageSignatures(normalizeContext({ messages: [...sameUser] }));
|
|
181
|
+
const withPrompt = contextMessageSignatures(normalizeContext({ systemPrompt: "carrier A", messages: [...sameUser] }));
|
|
182
|
+
const withOtherPrompt = contextMessageSignatures(
|
|
183
|
+
normalizeContext({ systemPrompt: "carrier B", messages: [...sameUser] }),
|
|
184
|
+
);
|
|
185
|
+
assert.equal(bare.length, 1, "no systemPrompt and no tools → no leading system message");
|
|
186
|
+
assert.equal(withPrompt.length, 2, "a systemPrompt becomes a leading system message that is hashed too");
|
|
187
|
+
assert.notEqual(withPrompt[0], withOtherPrompt[0], "a systemPrompt drift changes the leading signature");
|
|
188
|
+
assert.ok(!hasPrefix(withPrompt, bare), "a prompt-carrying transcript is not a prefix of a bare one");
|
|
189
|
+
assert.ok(!hasPrefix(withPrompt, withOtherPrompt), "a drifted carrier breaks prefix compatibility");
|
|
167
190
|
}
|
|
168
191
|
|
|
169
192
|
// ---------------------------------------------------------------------------
|
|
@@ -52,7 +52,8 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs
|
|
|
52
52
|
import { tmpdir } from "node:os";
|
|
53
53
|
import { resolve } from "node:path";
|
|
54
54
|
import { pathToFileURL } from "node:url";
|
|
55
|
-
import type { Api, AssistantMessageEvent,
|
|
55
|
+
import type { Api, AssistantMessageEvent, Model } from "@earendil-works/pi-ai";
|
|
56
|
+
import { normalizeContext } from "@earendil-works/pi-ai";
|
|
56
57
|
|
|
57
58
|
const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
|
|
58
59
|
|
|
@@ -83,6 +84,20 @@ function makeFakeChild() {
|
|
|
83
84
|
};
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
// The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
|
|
88
|
+
// every turn, and since 0.86 that declaration reaches a provider as the leading system
|
|
89
|
+
// message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
|
|
90
|
+
// truthfulness preflight. A fixture that declares NO tools is therefore not "a turn with the
|
|
91
|
+
// defaults" — it is a turn where the operator excluded everything, and the preflight rejects it
|
|
92
|
+
// before the prompt is ever sent. So every provider-path context here is built through
|
|
93
|
+
// `normalizeContext`, never cast `as Context`: the cast is exactly what hid this shape from
|
|
94
|
+
// typecheck when the 0.86 pin landed.
|
|
95
|
+
const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
|
|
96
|
+
name,
|
|
97
|
+
description: "",
|
|
98
|
+
parameters: {} as never,
|
|
99
|
+
}));
|
|
100
|
+
|
|
86
101
|
const EMPTY_MCP_HASH = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
|
|
87
102
|
const DEFAULT_RESOLVED_CONFIG: any = {
|
|
88
103
|
settingSources: [],
|
|
@@ -267,7 +282,10 @@ try {
|
|
|
267
282
|
const events = await collect(
|
|
268
283
|
backend.streamAcpTurn(
|
|
269
284
|
sonnet,
|
|
270
|
-
{
|
|
285
|
+
normalizeContext({
|
|
286
|
+
tools: PI_DECLARED_TOOLS,
|
|
287
|
+
messages: [{ role: "user", content: `drive ${label}`, timestamp: 0 }],
|
|
288
|
+
}),
|
|
271
289
|
{ sessionId: `stop-${label}` },
|
|
272
290
|
makeHarness(recordDir, cell.wire),
|
|
273
291
|
) as Stream,
|
|
@@ -32,7 +32,8 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs
|
|
|
32
32
|
import { tmpdir } from "node:os";
|
|
33
33
|
import { resolve } from "node:path";
|
|
34
34
|
import { pathToFileURL } from "node:url";
|
|
35
|
-
import type { Api, AssistantMessageEvent,
|
|
35
|
+
import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
|
|
36
|
+
import { normalizeContext } from "@earendil-works/pi-ai";
|
|
36
37
|
|
|
37
38
|
const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
|
|
38
39
|
|
|
@@ -133,10 +134,26 @@ async function collect(stream: Stream): Promise<AssistantMessageEvent[]> {
|
|
|
133
134
|
const sealed = (events: AssistantMessageEvent[]) =>
|
|
134
135
|
events.filter((e) => e.type === "done" || e.type === "error") as any[];
|
|
135
136
|
|
|
136
|
-
|
|
137
|
+
// The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
|
|
138
|
+
// every turn, and since 0.86 that declaration reaches a provider as the leading system
|
|
139
|
+
// message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
|
|
140
|
+
// truthfulness preflight. A fixture that declares NO tools is therefore not "a turn with the
|
|
141
|
+
// defaults" — it is a turn where the operator excluded everything, and the preflight rejects it
|
|
142
|
+
// before the prompt is ever sent. So every provider-path context here is built through
|
|
143
|
+
// `normalizeContext`, never cast `as Context`: the cast is exactly what hid this shape from
|
|
144
|
+
// typecheck when the 0.86 pin landed.
|
|
145
|
+
const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
|
|
146
|
+
name,
|
|
147
|
+
description: "",
|
|
148
|
+
parameters: {} as never,
|
|
149
|
+
}));
|
|
137
150
|
|
|
138
|
-
|
|
139
|
-
|
|
151
|
+
const userCtx = (text: string): TranscriptContext =>
|
|
152
|
+
normalizeContext({ tools: PI_DECLARED_TOOLS, messages: [{ role: "user", content: text, timestamp: 0 }] });
|
|
153
|
+
|
|
154
|
+
function reuseCtx(prior: string, latest: string): TranscriptContext {
|
|
155
|
+
return normalizeContext({
|
|
156
|
+
tools: PI_DECLARED_TOOLS,
|
|
140
157
|
messages: [
|
|
141
158
|
{ role: "user", content: prior, timestamp: 0 },
|
|
142
159
|
{
|
|
@@ -158,7 +175,7 @@ function reuseCtx(prior: string, latest: string): Context {
|
|
|
158
175
|
} as unknown as Message,
|
|
159
176
|
{ role: "user", content: latest, timestamp: 0 },
|
|
160
177
|
],
|
|
161
|
-
}
|
|
178
|
+
});
|
|
162
179
|
}
|
|
163
180
|
|
|
164
181
|
// EVERY cell registers onResponse; the exemption claim at the end judges the sum.
|
|
@@ -50,8 +50,15 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs
|
|
|
50
50
|
import { tmpdir } from "node:os";
|
|
51
51
|
import { resolve } from "node:path";
|
|
52
52
|
import { pathToFileURL } from "node:url";
|
|
53
|
-
import type {
|
|
54
|
-
|
|
53
|
+
import type {
|
|
54
|
+
Api,
|
|
55
|
+
AssistantMessage,
|
|
56
|
+
AssistantMessageEvent,
|
|
57
|
+
Message,
|
|
58
|
+
Model,
|
|
59
|
+
TranscriptContext,
|
|
60
|
+
} from "@earendil-works/pi-ai";
|
|
61
|
+
import { isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
|
|
55
62
|
import { calculateContextTokens } from "@earendil-works/pi-coding-agent";
|
|
56
63
|
|
|
57
64
|
const claude = { id: "claude-sonnet-5" } as unknown as Model<Api>;
|
|
@@ -72,7 +79,7 @@ interface WireUsage {
|
|
|
72
79
|
* One `_meta.quota.token_count` row. The field NAMES deliberately differ from
|
|
73
80
|
* `PromptResponse.usage`: cache reads are `cachedInputTokens` here because the
|
|
74
81
|
* shape is shared with codex-acp, and `cachedWriteTokens` is Claude's extra
|
|
75
|
-
* sibling (read at claude-agent-acp 0.
|
|
82
|
+
* sibling (read at claude-agent-acp 0.79.0 `dist/acp-agent.js:6579-6588`).
|
|
76
83
|
* Reading a quota row with the `usage` field names silently yields zeros, so the
|
|
77
84
|
* fixture below spells the vendor's names out rather than reusing `WireUsage`.
|
|
78
85
|
*/
|
|
@@ -137,7 +144,7 @@ function makeFakeChild() {
|
|
|
137
144
|
*
|
|
138
145
|
* Each `prompt` call consumes the next TurnScript: it first pushes that turn's
|
|
139
146
|
* `usage_update` notification (the wire the running cost total actually arrives
|
|
140
|
-
* on — read at claude-agent-acp 0.
|
|
147
|
+
* on — read at claude-agent-acp 0.79.0 `dist/acp-agent.js:3504-3519`), then answers the
|
|
141
148
|
* prompt with that turn's `PromptResponse.usage` (the wire the turn aggregate
|
|
142
149
|
* arrives on). Both orderings are the real one: the notification precedes the
|
|
143
150
|
* response, because the SDK emits it from the `result` message that ENDS the turn.
|
|
@@ -216,7 +223,22 @@ function sealedMessage(events: AssistantMessageEvent[]): AssistantMessage {
|
|
|
216
223
|
return seal[0].message as AssistantMessage;
|
|
217
224
|
}
|
|
218
225
|
|
|
219
|
-
|
|
226
|
+
// The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
|
|
227
|
+
// every turn, and since 0.86 that declaration reaches a provider as the leading system
|
|
228
|
+
// message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
|
|
229
|
+
// truthfulness preflight. A fixture that declares NO tools is therefore not "a turn with the
|
|
230
|
+
// defaults" — it is a turn where the operator excluded everything, and the preflight rejects it
|
|
231
|
+
// before the prompt is ever sent. So every provider-path context here is built through
|
|
232
|
+
// `normalizeContext`, never cast `as Context`: the cast is exactly what hid this shape from
|
|
233
|
+
// typecheck when the 0.86 pin landed.
|
|
234
|
+
const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
|
|
235
|
+
name,
|
|
236
|
+
description: "",
|
|
237
|
+
parameters: {} as never,
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
const userCtx = (text: string): TranscriptContext =>
|
|
241
|
+
normalizeContext({ tools: PI_DECLARED_TOOLS, messages: [{ role: "user", content: text, timestamp: 0 }] });
|
|
220
242
|
|
|
221
243
|
const zeroUsage = () => ({
|
|
222
244
|
input: 0,
|
|
@@ -228,8 +250,9 @@ const zeroUsage = () => ({
|
|
|
228
250
|
});
|
|
229
251
|
|
|
230
252
|
/** A reuse-shaped context: prior user, assistant, new user. */
|
|
231
|
-
function reuseCtx(prior: string, latest: string):
|
|
232
|
-
return {
|
|
253
|
+
function reuseCtx(prior: string, latest: string): TranscriptContext {
|
|
254
|
+
return normalizeContext({
|
|
255
|
+
tools: PI_DECLARED_TOOLS,
|
|
233
256
|
messages: [
|
|
234
257
|
{ role: "user", content: prior, timestamp: 0 },
|
|
235
258
|
{
|
|
@@ -244,11 +267,11 @@ function reuseCtx(prior: string, latest: string): Context {
|
|
|
244
267
|
} as unknown as Message,
|
|
245
268
|
{ role: "user", content: latest, timestamp: 0 },
|
|
246
269
|
],
|
|
247
|
-
}
|
|
270
|
+
});
|
|
248
271
|
}
|
|
249
272
|
|
|
250
273
|
/** A third turn on the same reused session. */
|
|
251
|
-
function reuseCtx3(a: string, b: string, c: string):
|
|
274
|
+
function reuseCtx3(a: string, b: string, c: string): TranscriptContext {
|
|
252
275
|
const assistant = {
|
|
253
276
|
role: "assistant",
|
|
254
277
|
content: [{ type: "text", text: "ok" }],
|
|
@@ -259,7 +282,8 @@ function reuseCtx3(a: string, b: string, c: string): Context {
|
|
|
259
282
|
stopReason: "stop",
|
|
260
283
|
timestamp: 0,
|
|
261
284
|
} as unknown as Message;
|
|
262
|
-
return {
|
|
285
|
+
return normalizeContext({
|
|
286
|
+
tools: PI_DECLARED_TOOLS,
|
|
263
287
|
messages: [
|
|
264
288
|
{ role: "user", content: a, timestamp: 0 },
|
|
265
289
|
assistant,
|
|
@@ -267,7 +291,7 @@ function reuseCtx3(a: string, b: string, c: string): Context {
|
|
|
267
291
|
assistant,
|
|
268
292
|
{ role: "user", content: c, timestamp: 0 },
|
|
269
293
|
],
|
|
270
|
-
}
|
|
294
|
+
});
|
|
271
295
|
}
|
|
272
296
|
|
|
273
297
|
const TMP_EMIT = ".tmp-verify/acp-usage-accounting";
|
|
@@ -393,8 +417,8 @@ try {
|
|
|
393
417
|
// AGENT LOOP only. `_meta.quota.model_usage` comes from `result.modelUsage` and
|
|
394
418
|
// also counts Task subagents, sidechains and INTERNAL CALLS SUCH AS COMPACTION;
|
|
395
419
|
// the vendor states its rows "can total more than `token_count`" and are "the
|
|
396
|
-
// fuller picture, not a decomposition of it" (read at claude-agent-acp 0.
|
|
397
|
-
// `dist/acp-agent.js:
|
|
420
|
+
// fuller picture, not a decomposition of it" (read at claude-agent-acp 0.79.0
|
|
421
|
+
// `dist/acp-agent.js:6554-6560`).
|
|
398
422
|
//
|
|
399
423
|
// The wide one is required, not merely nicer, because the DENOMINATOR already
|
|
400
424
|
// has that scope: turn cost is the adjacent diff of the backend's running total,
|
|
@@ -608,8 +632,8 @@ try {
|
|
|
608
632
|
// identity that produces the bound is a property of the MAIN AGENT LOOP's
|
|
609
633
|
// cache breakpoints. `_meta.quota.model_usage` is a WIDER scope: the vendor
|
|
610
634
|
// states those rows also count Task subagents, sidechains, and INTERNAL
|
|
611
|
-
// CALLS SUCH AS COMPACTION (read at claude-agent-acp 0.
|
|
612
|
-
// `dist/acp-agent.js:
|
|
635
|
+
// CALLS SUCH AS COMPACTION (read at claude-agent-acp 0.79.0
|
|
636
|
+
// `dist/acp-agent.js:6551-6571`). Mixing the two scopes inflates the bound
|
|
613
637
|
// through both remaining terms that mention cacheWrite:
|
|
614
638
|
// max(0, occupancy − cacheWrite) shrinks as wide cacheWrite grows, so
|
|
615
639
|
// less is subtracted; min(rawBound, cacheWrite) rises with it.
|
|
@@ -749,9 +773,9 @@ try {
|
|
|
749
773
|
// CELL 3 — a turn with NO cost notification holds the baseline.
|
|
750
774
|
//
|
|
751
775
|
// Measured upstream: the result-path `usage_update` carries cost (read at
|
|
752
|
-
// claude-agent-acp 0.
|
|
776
|
+
// claude-agent-acp 0.79.0 `dist/acp-agent.js:3504-3519`), while other
|
|
753
777
|
// `usage_update` paths can carry `used` without cost (for example the
|
|
754
|
-
// rate-limit path at `:
|
|
778
|
+
// rate-limit path at `:4336-4344`). A live thinkpad ledger shows such turns
|
|
755
779
|
// really occur. The honest handling is to HOLD the baseline so the amount lands
|
|
756
780
|
// in the NEXT diff: misattributed by turn, exact by session. Rebaselining to 0
|
|
757
781
|
// there would double-count the whole prefix.
|
|
@@ -831,7 +855,7 @@ try {
|
|
|
831
855
|
// CELL 4 — a DECREASING session total is never silently absorbed.
|
|
832
856
|
//
|
|
833
857
|
// `conversation_reset` switches the session to a fresh transcript (read at
|
|
834
|
-
// claude-agent-acp 0.
|
|
858
|
+
// claude-agent-acp 0.79.0 `dist/acp-agent.js:4349-4356`), but whether that
|
|
835
859
|
// changes `total_cost_usd` is an SDK-internal value we cannot observe here.
|
|
836
860
|
// A diff can therefore go negative in a session we are still holding.
|
|
837
861
|
// Absorbing it quietly would both misreport the turn and destroy the only
|
|
@@ -224,6 +224,20 @@ async function main(): Promise<void> {
|
|
|
224
224
|
!tools.some((t) => ["entwurf", "entwurf_resume", "entwurf_send"].includes(String(t?.name))),
|
|
225
225
|
);
|
|
226
226
|
|
|
227
|
+
const callback = tools.find((t) => t?.name === "entwurf_callback");
|
|
228
|
+
ok(
|
|
229
|
+
"[QK:BRIDGEBOOT-CALLBACK-REGISTERED] G1h: entwurf_callback is registered on the runtime tools/list surface — not merely imported",
|
|
230
|
+
!!callback,
|
|
231
|
+
);
|
|
232
|
+
const callbackSchema = callback?.inputSchema ?? {};
|
|
233
|
+
const callbackProps = callbackSchema.properties ?? {};
|
|
234
|
+
const callbackRequired = Array.isArray(callbackSchema.required) ? callbackSchema.required.map(String) : [];
|
|
235
|
+
ok(
|
|
236
|
+
"[QK:BRIDGEBOOT-CALLBACK-ZERO-ARG] G1h: entwurf_callback takes EXACTLY zero inputs — a target or nonce parameter here would be a model-supplied address, which Hard Rule 5 refuses",
|
|
237
|
+
Object.keys(callbackProps).length === 0 && callbackRequired.length === 0,
|
|
238
|
+
`--- inputSchema ---\n${JSON.stringify(callbackSchema)}`,
|
|
239
|
+
);
|
|
240
|
+
|
|
227
241
|
// G1f — the public surface is an EXACT set, judged last so the named assertions above
|
|
228
242
|
// keep their own diagnosis. Every check before this one is existential: each names one
|
|
229
243
|
// verb it cares about, so a verb nobody named could be dropped (entwurf_fresh_call was
|
|
@@ -233,6 +247,7 @@ async function main(): Promise<void> {
|
|
|
233
247
|
// from the server: an oracle computed from the subject proves nothing.
|
|
234
248
|
const publicSurface = tools.map((t) => String(t?.name)).sort();
|
|
235
249
|
const expectedSurface = [
|
|
250
|
+
"entwurf_callback",
|
|
236
251
|
"entwurf_fresh_call",
|
|
237
252
|
"entwurf_inbox_read",
|
|
238
253
|
"entwurf_peers",
|
|
@@ -242,7 +257,7 @@ async function main(): Promise<void> {
|
|
|
242
257
|
"entwurf_v2",
|
|
243
258
|
];
|
|
244
259
|
ok(
|
|
245
|
-
"[QK:BRIDGEBOOT-PUBLIC-SURFACE-EXACT-SET] G1f: the runtime tools/list surface is EXACTLY the
|
|
260
|
+
"[QK:BRIDGEBOOT-PUBLIC-SURFACE-EXACT-SET] G1f: the runtime tools/list surface is EXACTLY the eight shipped garden verbs — no missing verb, no undecided extra, no duplicate",
|
|
246
261
|
publicSurface.length === expectedSurface.length && expectedSurface.every((n, i) => publicSurface[i] === n),
|
|
247
262
|
`--- want ---\n${expectedSurface.join(",")}\n--- got ---\n${publicSurface.join(",")}`,
|
|
248
263
|
);
|
|
@@ -170,14 +170,13 @@ async function main(): Promise<void> {
|
|
|
170
170
|
|
|
171
171
|
// ── one nonce, into whichever rail ───────────────────────────────────────────────────
|
|
172
172
|
const nonceSpawn = recordingSpawn();
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
const dispatchNonce = "mux-fresh-call-deadbeefdeadbeefdeadbeef";
|
|
174
|
+
await dispatchFreshCall(REQUEST, HERDR_ENV, nonceSpawn.spawn, dispatchNonce);
|
|
175
|
+
const tabCall = nonceSpawn.calls.find((call) => call[1] === "tab" && call[2] === "create");
|
|
175
176
|
ok(
|
|
176
177
|
"[QK:FCD-ONE-NONCE] ONE nonce is minted per call and handed to the selected rail — two mints would mean the sibling calls back with a tag the caller never recorded",
|
|
177
178
|
dispatchCode.split("mintNonce(").length - 1 === 1 &&
|
|
178
|
-
(
|
|
179
|
-
startCall.some((token) => token.includes("fresh-call-dispatch-test-nonce")) ||
|
|
180
|
-
nonceSpawn.calls.length > 0),
|
|
179
|
+
(tabCall === undefined || tabCall.some((token) => token.includes(dispatchNonce)) || nonceSpawn.calls.length > 0),
|
|
181
180
|
);
|
|
182
181
|
|
|
183
182
|
// ── the composition root holds no synchronous child authority ────────────────────────
|
|
@@ -49,6 +49,7 @@ import * as path from "node:path";
|
|
|
49
49
|
import { fileURLToPath } from "node:url";
|
|
50
50
|
import {
|
|
51
51
|
classifyMutantRun,
|
|
52
|
+
countOccurrences,
|
|
52
53
|
createRepoSnapshot,
|
|
53
54
|
ManifestError,
|
|
54
55
|
type MutantManifest,
|
|
@@ -820,6 +821,7 @@ let manifestCount: number;
|
|
|
820
821
|
// manifest lost in a merge must name itself here — "0/0 killed" is not a pass.
|
|
821
822
|
// Extend this inventory and the manifests TOGETHER, never silently.
|
|
822
823
|
const EXPECTED_LANE_MUTANTS: Record<string, number> = {
|
|
824
|
+
"acp-backend-preflight": 1,
|
|
823
825
|
"acp-augment": 10,
|
|
824
826
|
"acp-cortex": 14,
|
|
825
827
|
"acp-launch-namespace": 2,
|
|
@@ -829,7 +831,7 @@ let manifestCount: number;
|
|
|
829
831
|
"acp-stream-hooks": 10,
|
|
830
832
|
"acp-usage-accounting": 12,
|
|
831
833
|
"agy-permission": 6,
|
|
832
|
-
"bridge-boot-resume":
|
|
834
|
+
"bridge-boot-resume": 5,
|
|
833
835
|
"bridge-command-boot": 9,
|
|
834
836
|
"capability-cache": 3,
|
|
835
837
|
"codex-app-server-launch": 9,
|
|
@@ -839,6 +841,7 @@ let manifestCount: number;
|
|
|
839
841
|
"control-socket-disconnect": 4,
|
|
840
842
|
"copilot-birth": 19,
|
|
841
843
|
"copilot-launch": 14,
|
|
844
|
+
"pi-launch": 9,
|
|
842
845
|
"copilot-receive": 20,
|
|
843
846
|
"entwurf-peers": 1,
|
|
844
847
|
"fresh-call-dispatch": 12,
|
|
@@ -847,10 +850,10 @@ let manifestCount: number;
|
|
|
847
850
|
"herdr-placement": 13,
|
|
848
851
|
"herdr-plugin": 11,
|
|
849
852
|
"herdr-plugin-profile": 14,
|
|
850
|
-
"herdr-activation":
|
|
853
|
+
"herdr-activation": 25,
|
|
851
854
|
"herdr-plugin-build": 13,
|
|
852
855
|
"herdr-runtime-bootstrap": 31,
|
|
853
|
-
"herdr-fresh-call":
|
|
856
|
+
"herdr-fresh-call": 38,
|
|
854
857
|
"herdr-supply": 11,
|
|
855
858
|
"meta-facts": 4,
|
|
856
859
|
"peer-facts": 8,
|
|
@@ -858,7 +861,7 @@ let manifestCount: number;
|
|
|
858
861
|
"meta-identity": 4,
|
|
859
862
|
"meta-retire": 3,
|
|
860
863
|
"mux-boundary": 16,
|
|
861
|
-
"mux-fresh-call":
|
|
864
|
+
"mux-fresh-call": 59,
|
|
862
865
|
"mux-launcher-fence": 8,
|
|
863
866
|
"mux-parent-artifact": 3,
|
|
864
867
|
"pack-install": 2,
|
|
@@ -901,6 +904,42 @@ let manifestCount: number;
|
|
|
901
904
|
(err instanceof Error ? err.message : String(err)),
|
|
902
905
|
);
|
|
903
906
|
}
|
|
907
|
+
// FIND MATCHES ITS SUBJECT, EXACTLY ONCE — the body's own precondition, hoisted into the head.
|
|
908
|
+
// `validateManifestSet` above proves the subject is TRACKED; it never opens it. So a change
|
|
909
|
+
// that edits a subject line a mutant quotes leaves the whole deterministic floor green and
|
|
910
|
+
// dies an hour later in CI or the release gate as MUTANT-STALE. That is exactly how the
|
|
911
|
+
// 0.23.2 pi bump landed: `pack_install_leaked_pi`'s pin regex moved 0.85.1 → 0.86.0 in
|
|
912
|
+
// run.sh, `scripts/mutants/pack-install.json` kept quoting the old line, and two claims came
|
|
913
|
+
// back NOT KILLED after ~50 minutes of mutant execution. The check costs one read per
|
|
914
|
+
// mutant and uses `countOccurrences` — the SAME function the body applies at
|
|
915
|
+
// mutation-qualify.ts:743 — so head and body cannot disagree about what "matches" means.
|
|
916
|
+
// This is a STALENESS check, not a kill-proof: it says the mutant still has a subject to
|
|
917
|
+
// corrupt, never that corrupting it is caught. That remains the body's verdict.
|
|
918
|
+
const stale: string[] = [];
|
|
919
|
+
for (const man of manifests) {
|
|
920
|
+
for (const m of man.mutants) {
|
|
921
|
+
const subjectAbs = path.join(REPO_DIR, m.subject);
|
|
922
|
+
let source: string;
|
|
923
|
+
try {
|
|
924
|
+
source = fs.readFileSync(subjectAbs, "utf8");
|
|
925
|
+
} catch (err) {
|
|
926
|
+
stale.push(`${man.lane}/${m.claim}: subject ${m.subject} unreadable (${(err as Error).message})`);
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
const n = countOccurrences(source, m.find.join("\n"));
|
|
930
|
+
if (n !== 1) stale.push(`${man.lane}/${m.claim}: find matched ${n}× in ${m.subject} (expected exactly 1)`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
assert.deepEqual(
|
|
934
|
+
stale,
|
|
935
|
+
[],
|
|
936
|
+
"[QK:MUTANT-FIND-MATCHES-SUBJECT] every mutant's `find` must match its subject exactly once. A find that " +
|
|
937
|
+
"matches 0× is a mutant whose production line moved out from under it — the body can write nothing, the " +
|
|
938
|
+
"verdict is MUTANT-STALE, and the claim is silently unproven; a find that matches 2+× cannot say WHICH " +
|
|
939
|
+
"occurrence it corrupts. Either way the manifest and the source have to move together. Found:\n" +
|
|
940
|
+
stale.join("\n"),
|
|
941
|
+
);
|
|
942
|
+
|
|
904
943
|
manifestCount = manifests.length;
|
|
905
944
|
console.log(`[${SURFACE}] ${selected.length} mutants across ${manifestCount} lanes (no tiers — full set every run)`);
|
|
906
945
|
}
|
|
@@ -908,7 +947,8 @@ let manifestCount: number;
|
|
|
908
947
|
if (MANIFESTS_ONLY) {
|
|
909
948
|
console.log(
|
|
910
949
|
`[check-gate-manifests] ok — runner self-test green, ${selected.length} committed mutants across ` +
|
|
911
|
-
`${manifestCount} lanes validated against the origin index,
|
|
950
|
+
`${manifestCount} lanes validated against the origin index, every one of their \`find\` strings still ` +
|
|
951
|
+
"matching its subject exactly once, and the lane inventory matching its declared " +
|
|
912
952
|
"contract. ZERO mutants were executed and this repo was never snapshotted: the body " +
|
|
913
953
|
"(check-gate-qualification) owns that, unchanged.",
|
|
914
954
|
);
|
|
@@ -205,6 +205,21 @@ const {
|
|
|
205
205
|
COMPONENT_STATES,
|
|
206
206
|
} = mod;
|
|
207
207
|
|
|
208
|
+
/** The pi pin package.json declares — the SSOT `run.sh pi_supported_range` derives its range from. */
|
|
209
|
+
const PI_PIN = (
|
|
210
|
+
JSON.parse(fs.readFileSync(path.join(REPO, "package.json"), "utf8")) as {
|
|
211
|
+
devDependencies: Record<string, string>;
|
|
212
|
+
}
|
|
213
|
+
).devDependencies["@earendil-works/pi-coding-agent"];
|
|
214
|
+
|
|
215
|
+
/** A `pi` stand-in that reports `version` and nothing else — enough for a `--version` floor probe. */
|
|
216
|
+
function fakePi(dir: string, name: string, version: string): string {
|
|
217
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
218
|
+
const file = path.join(dir, name);
|
|
219
|
+
fs.writeFileSync(file, `#!/bin/sh\necho ${version}\n`, { mode: 0o755 });
|
|
220
|
+
return file;
|
|
221
|
+
}
|
|
222
|
+
|
|
208
223
|
function world(tag: string): NodeJS.ProcessEnv {
|
|
209
224
|
const home = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), `entwurf-hac-${tag}-`)));
|
|
210
225
|
for (const d of [".config", ".state", ".data", ".cache", "pi-agent", ".claude"]) {
|
|
@@ -220,6 +235,13 @@ function world(tag: string): NodeJS.ProcessEnv {
|
|
|
220
235
|
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
221
236
|
PI_CODING_AGENT_DIR: path.join(home, "pi-agent"),
|
|
222
237
|
CLAUDE_CONFIG_DIR: path.join(home, ".claude"),
|
|
238
|
+
// PI_BIN is a sandbox root like the four XDG vars above, not a convenience. Since #119
|
|
239
|
+
// `install-user-scope` probes `pi --version` against the same closed range setup enforces,
|
|
240
|
+
// so leaving this unset would let the OPERATOR'S installed pi decide whether these cells
|
|
241
|
+
// pass — the gate would go red on a developer machine sitting one minor behind the pin and
|
|
242
|
+
// green on another, for reasons none of these cells are about. It is pinned IN RANGE here;
|
|
243
|
+
// the one cell whose subject IS the floor overrides it per run.
|
|
244
|
+
PI_BIN: fakePi(path.join(home, "bin"), "pi", PI_PIN),
|
|
223
245
|
};
|
|
224
246
|
}
|
|
225
247
|
|
|
@@ -330,6 +352,57 @@ function ledgerWith(
|
|
|
330
352
|
);
|
|
331
353
|
}
|
|
332
354
|
|
|
355
|
+
// ── 1b. the plugin door enforces the SAME pi floor the setup door does ────────
|
|
356
|
+
// #119: `setup` refuses an out-of-range pi by name before it writes any Pi wiring, but
|
|
357
|
+
// `install-user-scope` — the door herdr plugin activation actually goes through
|
|
358
|
+
// (plugins/herdr/lib/build.mjs → scripts/herdr-plugin-activate.mjs → `run.sh
|
|
359
|
+
// install-user-scope --plugin-runtime …`) — had no version check at all. Two doors onto ONE
|
|
360
|
+
// registration telling different truths is the whole defect, so the oracle here is the
|
|
361
|
+
// out-of-range/in-range PAIR: the refusal alone would also be satisfied by a door that refuses
|
|
362
|
+
// everything. Absent pi is deliberately NOT asserted as a third state — this verb invents no
|
|
363
|
+
// presence verdict, setup owns that one.
|
|
364
|
+
{
|
|
365
|
+
const env = world("pi-floor");
|
|
366
|
+
const bin = path.join(env.HOME as string, "bin");
|
|
367
|
+
// The supported range is DERIVED from the package.json devDep pin (run.sh
|
|
368
|
+
// `pi_supported_range`), so the fixtures are derived from it too — a hardcoded "0.85.1" here
|
|
369
|
+
// would silently stop being out-of-range the day the pin moves past it.
|
|
370
|
+
const [maj, min] = PI_PIN.split(".").map(Number);
|
|
371
|
+
const belowFloor = `${maj}.${min - 1}.0`;
|
|
372
|
+
const settingsPath = path.join(env.PI_CODING_AGENT_DIR as string, "settings.json");
|
|
373
|
+
fs.writeFileSync(settingsPath, '{"theme":"dark"}\n');
|
|
374
|
+
const before = fs.readFileSync(settingsPath, "utf8");
|
|
375
|
+
|
|
376
|
+
const stale = sh({ ...env, PI_BIN: fakePi(bin, "stale-pi", belowFloor) }, [
|
|
377
|
+
"install-user-scope",
|
|
378
|
+
"--plugin-runtime",
|
|
379
|
+
runtimeRootOf(env),
|
|
380
|
+
]);
|
|
381
|
+
const afterRefusal = fs.readFileSync(settingsPath, "utf8");
|
|
382
|
+
const current = sh({ ...env, PI_BIN: fakePi(bin, "current-pi", PI_PIN) }, [
|
|
383
|
+
"install-user-scope",
|
|
384
|
+
"--plugin-runtime",
|
|
385
|
+
runtimeRootOf(env),
|
|
386
|
+
]);
|
|
387
|
+
const afterAccept = readJson(settingsPath) as { packages?: string[] };
|
|
388
|
+
|
|
389
|
+
ok(
|
|
390
|
+
"[QK:HAC-PI-FLOOR-BOTH-DOORS] the plugin activation door enforces the SAME closed pi range as the " +
|
|
391
|
+
"setup door: a below-floor pi is a named FAIL that writes zero settings bytes (not a SKIP, not a " +
|
|
392
|
+
"silent write), while an in-range pi still registers — one registration reached through two doors " +
|
|
393
|
+
"may not report two different verdicts, and the range both read is DERIVED from one package.json pin " +
|
|
394
|
+
`(pin=${PI_PIN} stale=${belowFloor}/exit=${stale.status} named=${(stale.stderr || "").includes("outside the supported range")} ` +
|
|
395
|
+
`untouched=${before === afterRefusal} current=exit=${current.status}/packages=${JSON.stringify(afterAccept.packages)})`,
|
|
396
|
+
stale.status !== 0 &&
|
|
397
|
+
(stale.stderr || "").includes("[install-user-scope] pi: FAIL") &&
|
|
398
|
+
(stale.stderr || "").includes("outside the supported range") &&
|
|
399
|
+
before === afterRefusal &&
|
|
400
|
+
current.status === 0 &&
|
|
401
|
+
Array.isArray(afterAccept.packages) &&
|
|
402
|
+
afterAccept.packages.length === 1,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
333
406
|
// ── 2. the plugin command is DERIVED from the runtime root, never supplied ─────
|
|
334
407
|
{
|
|
335
408
|
const env = world("derived");
|
|
@@ -270,7 +270,7 @@ const START_ERR = JSON.stringify({
|
|
|
270
270
|
});
|
|
271
271
|
|
|
272
272
|
const CALLER = "20260914T174741-2e9ba9";
|
|
273
|
-
const NONCE = "
|
|
273
|
+
const NONCE = "mux-fresh-call-0123456789abcdef01234567";
|
|
274
274
|
const HERDR_ENV = { HERDR_ENV: "1", HERDR_BIN_PATH: "/home/operator/.local/bin/herdr", HERDR_PANE_ID: "w7:p1" };
|
|
275
275
|
|
|
276
276
|
type ScriptedReply =
|
|
@@ -344,9 +344,11 @@ async function main(): Promise<void> {
|
|
|
344
344
|
const code = MODULE_SRC.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
345
345
|
const specifiers = [...MODULE_SRC.matchAll(/^import\s[^;]*?from\s+"([^"]+)";$/gm)].map((m) => m[1]);
|
|
346
346
|
ok(
|
|
347
|
-
"[QK:HFC-RAIL-IMPORT-FENCE] the herdr rail imports only node builtins
|
|
347
|
+
"[QK:HFC-RAIL-IMPORT-FENCE] the herdr rail imports only node builtins, the neutral composition leaf, and the callback-env pair — one mux or entwurf import here would re-couple the two rails and make either one undeletable",
|
|
348
348
|
specifiers.length > 0 &&
|
|
349
|
-
specifiers.every(
|
|
349
|
+
specifiers.every(
|
|
350
|
+
(s) => s.startsWith("node:") || s === "./fresh-call-composition.ts" || s === "./callback-env.ts",
|
|
351
|
+
) &&
|
|
350
352
|
!/from\s+"\.\/(mux-|entwurf-)/.test(code),
|
|
351
353
|
);
|
|
352
354
|
// tmux is NAMED in this module, and only in one place: the refusal of a tmux seat input.
|
|
@@ -453,15 +455,29 @@ async function main(): Promise<void> {
|
|
|
453
455
|
// ── argv grammar ─────────────────────────────────────────────────────────────────────
|
|
454
456
|
// Pure builders are checked before full launch paths so a framing mutant is attributed to
|
|
455
457
|
// its own contract rather than making an earlier integration success cell fail first.
|
|
456
|
-
const
|
|
458
|
+
const tabCallback = {
|
|
459
|
+
target: "20260101T010101-aaaaaa",
|
|
460
|
+
nonce: "mux-fresh-call-deadbeefdeadbeefdeadbeef",
|
|
461
|
+
};
|
|
462
|
+
const tabArgs = buildHerdrTabCreateArgs({ workspaceId: "w7", cwd: "/repo/dir", callback: tabCallback });
|
|
463
|
+
// The SPECIFIC claim runs before the umbrella one. `ok` throws, so the first red assertion is
|
|
464
|
+
// the only one a mutant run ever prints: with the exact-argv cell first, the callback-pair
|
|
465
|
+
// mutant died on the whole-argv claim below and never reached its own — a WRONG-REASON that
|
|
466
|
+
// qualification is exactly there to catch. Order is the fix; both cells keep their subject,
|
|
467
|
+
// and the argv cell keeps its own mutant, which drops `--no-focus` and this cell cannot see.
|
|
468
|
+
ok(
|
|
469
|
+
"[QK:HFC-CALLBACK-ENV] the launcher-computed callback pair rides as two more --env assignments beside the scrub",
|
|
470
|
+
tabArgs.includes("ENTWURF_CALLBACK_TARGET=20260101T010101-aaaaaa") &&
|
|
471
|
+
tabArgs.includes("ENTWURF_CALLBACK_NONCE=mux-fresh-call-deadbeefdeadbeefdeadbeef"),
|
|
472
|
+
);
|
|
457
473
|
ok(
|
|
458
474
|
"[QK:HFC-TAB-ARGV] one placement policy — a NEW TAB in the caller's own workspace, named explicitly so herdr cannot default it to the focused one, focus left alone, cwd carried literally, and BOTH identity carriers scrubbed by explicit repeated --env",
|
|
459
475
|
tabArgs.join(" ") ===
|
|
460
|
-
"tab create --workspace w7 --no-focus --cwd /repo/dir --env PI_SESSION_ID= --env PI_AGENT_ID=",
|
|
476
|
+
"tab create --workspace w7 --no-focus --cwd /repo/dir --env PI_SESSION_ID= --env PI_AGENT_ID= --env ENTWURF_CALLBACK_TARGET=20260101T010101-aaaaaa --env ENTWURF_CALLBACK_NONCE=mux-fresh-call-deadbeefdeadbeefdeadbeef",
|
|
461
477
|
);
|
|
462
478
|
ok(
|
|
463
479
|
"a cwd-free tab create omits --cwd entirely rather than sending an empty value, and the rail adds no layout axis of its own — no label, no ratio, no direction",
|
|
464
|
-
!buildHerdrTabCreateArgs({ workspaceId: "w7" }).includes("--cwd") &&
|
|
480
|
+
!buildHerdrTabCreateArgs({ workspaceId: "w7", callback: tabCallback }).includes("--cwd") &&
|
|
465
481
|
!tabArgs.includes("--label") &&
|
|
466
482
|
!tabArgs.includes("--ratio") &&
|
|
467
483
|
!tabArgs.includes("--direction"),
|