@junghanacs/entwurf 0.23.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -1
- package/BASELINE.md +1 -0
- package/CHANGELOG.md +134 -0
- package/VERIFY.md +41 -0
- package/docs/acp-backend-rail.md +96 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- 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/package.json +8 -7
- 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 +4 -4
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/tool-surface.ts +2 -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/run.sh +192 -196
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-gate-qualification.ts +1 -0
- package/scripts/check-pi-launch.ts +358 -0
- package/scripts/check-release-gate-outcomes.ts +7 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/mutants/herdr-plugin-build.json +1 -1
- 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/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +849 -0
- 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
|
@@ -72,7 +72,7 @@ interface WireUsage {
|
|
|
72
72
|
* One `_meta.quota.token_count` row. The field NAMES deliberately differ from
|
|
73
73
|
* `PromptResponse.usage`: cache reads are `cachedInputTokens` here because the
|
|
74
74
|
* shape is shared with codex-acp, and `cachedWriteTokens` is Claude's extra
|
|
75
|
-
* sibling (read at claude-agent-acp 0.
|
|
75
|
+
* sibling (read at claude-agent-acp 0.79.0 `dist/acp-agent.js:6579-6588`).
|
|
76
76
|
* Reading a quota row with the `usage` field names silently yields zeros, so the
|
|
77
77
|
* fixture below spells the vendor's names out rather than reusing `WireUsage`.
|
|
78
78
|
*/
|
|
@@ -137,7 +137,7 @@ function makeFakeChild() {
|
|
|
137
137
|
*
|
|
138
138
|
* Each `prompt` call consumes the next TurnScript: it first pushes that turn's
|
|
139
139
|
* `usage_update` notification (the wire the running cost total actually arrives
|
|
140
|
-
* on — read at claude-agent-acp 0.
|
|
140
|
+
* on — read at claude-agent-acp 0.79.0 `dist/acp-agent.js:3504-3519`), then answers the
|
|
141
141
|
* prompt with that turn's `PromptResponse.usage` (the wire the turn aggregate
|
|
142
142
|
* arrives on). Both orderings are the real one: the notification precedes the
|
|
143
143
|
* response, because the SDK emits it from the `result` message that ENDS the turn.
|
|
@@ -393,8 +393,8 @@ try {
|
|
|
393
393
|
// AGENT LOOP only. `_meta.quota.model_usage` comes from `result.modelUsage` and
|
|
394
394
|
// also counts Task subagents, sidechains and INTERNAL CALLS SUCH AS COMPACTION;
|
|
395
395
|
// 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:
|
|
396
|
+
// fuller picture, not a decomposition of it" (read at claude-agent-acp 0.79.0
|
|
397
|
+
// `dist/acp-agent.js:6554-6560`).
|
|
398
398
|
//
|
|
399
399
|
// The wide one is required, not merely nicer, because the DENOMINATOR already
|
|
400
400
|
// has that scope: turn cost is the adjacent diff of the backend's running total,
|
|
@@ -608,8 +608,8 @@ try {
|
|
|
608
608
|
// identity that produces the bound is a property of the MAIN AGENT LOOP's
|
|
609
609
|
// cache breakpoints. `_meta.quota.model_usage` is a WIDER scope: the vendor
|
|
610
610
|
// 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:
|
|
611
|
+
// CALLS SUCH AS COMPACTION (read at claude-agent-acp 0.79.0
|
|
612
|
+
// `dist/acp-agent.js:6551-6571`). Mixing the two scopes inflates the bound
|
|
613
613
|
// through both remaining terms that mention cacheWrite:
|
|
614
614
|
// max(0, occupancy − cacheWrite) shrinks as wide cacheWrite grows, so
|
|
615
615
|
// less is subtracted; min(rawBound, cacheWrite) rises with it.
|
|
@@ -749,9 +749,9 @@ try {
|
|
|
749
749
|
// CELL 3 — a turn with NO cost notification holds the baseline.
|
|
750
750
|
//
|
|
751
751
|
// Measured upstream: the result-path `usage_update` carries cost (read at
|
|
752
|
-
// claude-agent-acp 0.
|
|
752
|
+
// claude-agent-acp 0.79.0 `dist/acp-agent.js:3504-3519`), while other
|
|
753
753
|
// `usage_update` paths can carry `used` without cost (for example the
|
|
754
|
-
// rate-limit path at `:
|
|
754
|
+
// rate-limit path at `:4336-4344`). A live thinkpad ledger shows such turns
|
|
755
755
|
// really occur. The honest handling is to HOLD the baseline so the amount lands
|
|
756
756
|
// in the NEXT diff: misattributed by turn, exact by session. Rebaselining to 0
|
|
757
757
|
// there would double-count the whole prefix.
|
|
@@ -831,7 +831,7 @@ try {
|
|
|
831
831
|
// CELL 4 — a DECREASING session total is never silently absorbed.
|
|
832
832
|
//
|
|
833
833
|
// `conversation_reset` switches the session to a fresh transcript (read at
|
|
834
|
-
// claude-agent-acp 0.
|
|
834
|
+
// claude-agent-acp 0.79.0 `dist/acp-agent.js:4349-4356`), but whether that
|
|
835
835
|
// changes `total_cost_usd` is an SDK-internal value we cannot observe here.
|
|
836
836
|
// A diff can therefore go negative in a session we are still holding.
|
|
837
837
|
// Absorbing it quietly would both misreport the turn and destroy the only
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-pi-launch — deterministic gate for the MANAGED pi launch (`entwurf pi`, #118 홉 2).
|
|
3
|
+
* Hermetic: no real pi, no network, no model turn, no write outside its own temp root.
|
|
4
|
+
*
|
|
5
|
+
* SAME ORACLE AS check-copilot-launch, and deliberately the same one: the subject is a
|
|
6
|
+
* process REPLACEMENT, so the truth is a FAKE VENDOR — a real executable named `pi` on a
|
|
7
|
+
* sandbox PATH that reports the argv, environment and pid it was actually handed. Nothing
|
|
8
|
+
* below is asserted by reading the launcher's source, and the launcher is driven through
|
|
9
|
+
* its PUBLIC address (`run.sh pi`) because the dispatcher's own argv handling is part of
|
|
10
|
+
* the contract. Reusing that harness shape rather than importing it is the honest split:
|
|
11
|
+
* the two launchers share a posture, not a body, and a shared fixture would have to grow a
|
|
12
|
+
* vendor-name parameter that neither production path has.
|
|
13
|
+
*
|
|
14
|
+
* WHY THE PRECONDITION SET IS EMPTY, measured rather than assumed. `entwurf copilot` checks
|
|
15
|
+
* six receiver-state facts before it execs, because setting its flag is a PROMISE of a
|
|
16
|
+
* doorbell that may not exist. `entwurf pi` promises nothing pi does not already own: on a
|
|
17
|
+
* host where the entwurf extension is not registered, `pi --entwurf-control` refuses itself
|
|
18
|
+
* with `Error: Unknown option: --entwurf-control` and exit 1 (measured, pi 0.85.1). A
|
|
19
|
+
* pre-check here would duplicate that refusal and go stale the day pi renames it.
|
|
20
|
+
*
|
|
21
|
+
* WHY ONE RECURSION FENCE AND NOT TWO. The sentinel closes the only real loop — a PATH
|
|
22
|
+
* executable named `pi` that shells back to `entwurf pi`. Copilot's second fence (resolve
|
|
23
|
+
* the binary, refuse our own entrypoints) closes a case that is NOT a loop here, and the
|
|
24
|
+
* cell below measures that rather than asserting it: a PATH `pi` symlinked to run.sh is
|
|
25
|
+
* exec'd as `run.sh --entwurf-control …`, an unknown verb this dispatcher already refuses.
|
|
26
|
+
*
|
|
27
|
+
* WHY THE FLAG IS ADDED AND NEVER DEDUPLICATED. `pit`/`pius`-style operator wrappers already
|
|
28
|
+
* pass `--entwurf-control`; passing it twice was measured byte-identical to passing it once
|
|
29
|
+
* (pi 0.85.1, extension registered). The launcher therefore injects exactly ONE and leaves
|
|
30
|
+
* the operator's copies alone — an argv scan would be code earning nothing.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import assert from "node:assert/strict";
|
|
34
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
35
|
+
import {
|
|
36
|
+
chmodSync,
|
|
37
|
+
existsSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
mkdtempSync,
|
|
40
|
+
readFileSync,
|
|
41
|
+
rmSync,
|
|
42
|
+
symlinkSync,
|
|
43
|
+
writeFileSync,
|
|
44
|
+
} from "node:fs";
|
|
45
|
+
import { tmpdir } from "node:os";
|
|
46
|
+
import * as path from "node:path";
|
|
47
|
+
import { fileURLToPath } from "node:url";
|
|
48
|
+
|
|
49
|
+
const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
50
|
+
const RUN_SH = path.join(REPO, "run.sh");
|
|
51
|
+
|
|
52
|
+
let passed = 0;
|
|
53
|
+
function ok(label: string, cond: boolean, detail = ""): void {
|
|
54
|
+
assert.ok(cond, detail ? `${label}\n${detail}` : label);
|
|
55
|
+
console.log(` ok ${label}`);
|
|
56
|
+
passed++;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── the flag SSOT, read as text ──────────────────────────────────────────────
|
|
60
|
+
// The shell cannot import a TypeScript constant, so the launcher spells the flag as a
|
|
61
|
+
// literal and this gate is what keeps the two in step. The SSOT is the constant; every
|
|
62
|
+
// argv-composing site below must carry exactly its value.
|
|
63
|
+
const storeSrc = readFileSync(path.join(REPO, "pi-extensions", "lib", "acp", "session-store.ts"), "utf8");
|
|
64
|
+
const flagMatch = /export const ENTWURF_CONTROL_FLAG = "([^"]+)";/.exec(storeSrc);
|
|
65
|
+
assert.ok(flagMatch, "ENTWURF_CONTROL_FLAG is not declared in pi-extensions/lib/acp/session-store.ts");
|
|
66
|
+
const FLAG = flagMatch[1];
|
|
67
|
+
|
|
68
|
+
const root = mkdtempSync(path.join(tmpdir(), "entwurf-pi-launch."));
|
|
69
|
+
try {
|
|
70
|
+
const bin = path.join(root, "bin");
|
|
71
|
+
mkdirSync(bin, { recursive: true });
|
|
72
|
+
|
|
73
|
+
// The fake vendor, installed under the REAL name `pi`. There is no "which command
|
|
74
|
+
// stands in for the vendor" switch: a production env seam that can redirect an exec is
|
|
75
|
+
// an authority, not a test convenience.
|
|
76
|
+
const vendor = path.join(bin, "pi");
|
|
77
|
+
writeFileSync(
|
|
78
|
+
vendor,
|
|
79
|
+
`#!/usr/bin/env bash
|
|
80
|
+
echo "PID=$$"
|
|
81
|
+
echo "SENTINEL=[\${ENTWURF_PI_LAUNCH_ACTIVE-<unset>}]"
|
|
82
|
+
for a in "$@"; do printf 'ARG<%s>\\n' "$a"; done
|
|
83
|
+
exit "\${FAKE_PI_EXIT:-0}"
|
|
84
|
+
`,
|
|
85
|
+
);
|
|
86
|
+
chmodSync(vendor, 0o755);
|
|
87
|
+
|
|
88
|
+
// A PATH with no `pi` anywhere on it — built by dropping every real entry that holds
|
|
89
|
+
// one, rather than by emptying PATH (the launcher still needs bash and friends).
|
|
90
|
+
// The sandbox is TOTAL, the same way check-copilot-launch's is. The launcher writes
|
|
91
|
+
// nothing itself, but it EXECS a vendor, and a fixture that leaves the operator's HOME
|
|
92
|
+
// and XDG roots reachable is one careless vendor away from touching them. Naming every
|
|
93
|
+
// root explicitly is also what #119 asks of each test rather than of each gate.
|
|
94
|
+
const sandboxEnv = {
|
|
95
|
+
HOME: path.join(root, "home"),
|
|
96
|
+
XDG_DATA_HOME: path.join(root, "xdg", "data"),
|
|
97
|
+
XDG_CONFIG_HOME: path.join(root, "xdg", "config"),
|
|
98
|
+
XDG_STATE_HOME: path.join(root, "xdg", "state"),
|
|
99
|
+
XDG_CACHE_HOME: path.join(root, "xdg", "cache"),
|
|
100
|
+
PI_CODING_AGENT_DIR: path.join(root, "pi-agent"),
|
|
101
|
+
};
|
|
102
|
+
for (const d of Object.values(sandboxEnv)) mkdirSync(d, { recursive: true });
|
|
103
|
+
|
|
104
|
+
const pathWithoutVendor = (process.env.PATH ?? "")
|
|
105
|
+
.split(":")
|
|
106
|
+
.filter((d) => d !== "" && !existsSync(path.join(d, "pi")))
|
|
107
|
+
.join(":");
|
|
108
|
+
|
|
109
|
+
interface Run {
|
|
110
|
+
status: number | null;
|
|
111
|
+
out: string;
|
|
112
|
+
args: string[];
|
|
113
|
+
sentinel: string;
|
|
114
|
+
pid: string;
|
|
115
|
+
}
|
|
116
|
+
function launch(args: string[], extraEnv: Record<string, string> = {}, withVendor = true): Run {
|
|
117
|
+
const r = spawnSync("bash", [RUN_SH, "pi", ...args], {
|
|
118
|
+
cwd: root,
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
env: {
|
|
121
|
+
...process.env,
|
|
122
|
+
...sandboxEnv,
|
|
123
|
+
PATH: withVendor ? `${bin}:${pathWithoutVendor}` : pathWithoutVendor,
|
|
124
|
+
ENTWURF_PI_LAUNCH_ACTIVE: undefined as unknown as string,
|
|
125
|
+
FAKE_PI_EXIT: undefined as unknown as string,
|
|
126
|
+
...extraEnv,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
|
|
130
|
+
const argv: string[] = [];
|
|
131
|
+
for (const line of out.split("\n")) {
|
|
132
|
+
const m = /^ARG<([\s\S]*)>$/.exec(line);
|
|
133
|
+
if (m) argv.push(m[1]);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
status: r.status,
|
|
137
|
+
out,
|
|
138
|
+
args: argv,
|
|
139
|
+
sentinel: /^SENTINEL=\[(.*)\]$/m.exec(out)?.[1] ?? "<no-launch>",
|
|
140
|
+
pid: /^PID=(.*)$/m.exec(out)?.[1] ?? "",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── 1. the flag literal agrees with its SSOT, everywhere argv is composed ──
|
|
145
|
+
{
|
|
146
|
+
// CODE only. The branch explains itself at length and quotes the flag four times in
|
|
147
|
+
// prose; a literal check that read those goes green on a launcher whose actual exec
|
|
148
|
+
// line carries a typo — measured, and it is why the comments are dropped first.
|
|
149
|
+
const branch = /^ {2}pi\)$[\s\S]*?^ {4};;$/m.exec(readFileSync(RUN_SH, "utf8"))?.[0] ?? "";
|
|
150
|
+
const launcher = branch
|
|
151
|
+
.split("\n")
|
|
152
|
+
.filter((line) => !/^\s*#/.test(line))
|
|
153
|
+
.join("\n");
|
|
154
|
+
// Deliberately narrow: this cell owns the LITERAL and nothing else. Asserting the
|
|
155
|
+
// whole exec line here would swallow every other claim below — a mutation of the
|
|
156
|
+
// argv order, the quoting or the exec itself would all die on this assertion first,
|
|
157
|
+
// and each mutant is supposed to name exactly one broken thing. The token boundary
|
|
158
|
+
// is what makes a near-miss like `--entwurf-controll` a failure rather than a
|
|
159
|
+
// substring hit.
|
|
160
|
+
ok(
|
|
161
|
+
"[QK:PILAUNCH-FLAG-SSOT] the run.sh launcher spells the flag exactly as ENTWURF_CONTROL_FLAG declares it — the shell cannot import the constant, so the literal is checked as a whole token",
|
|
162
|
+
new RegExp(`(^|\\s)${FLAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`, "m").test(launcher),
|
|
163
|
+
`FLAG=${FLAG}\n--- launcher branch, comments dropped ---\n${launcher}`,
|
|
164
|
+
);
|
|
165
|
+
// EXHAUSTIVE, not a list. The first cut named five files and claimed "every
|
|
166
|
+
// argv-composing site"; cross-review found eight more that the list did not know
|
|
167
|
+
// about. They all happened to be spelled correctly, so there was no false success —
|
|
168
|
+
// but a drift OUTSIDE the list would have passed silently, which is the same gate
|
|
169
|
+
// rot in a slower form. So the sweep walks every tracked source file and requires
|
|
170
|
+
// that every `--entwurf-…` token it finds IS this flag. A typo, a rename, or a
|
|
171
|
+
// second spelling anywhere becomes a named red, and a new call site needs no edit
|
|
172
|
+
// here to be covered.
|
|
173
|
+
//
|
|
174
|
+
// Prose is excluded and that exclusion is the load-bearing part: comments and
|
|
175
|
+
// markdown quote the flag constantly, and a checker that read them would be a spell
|
|
176
|
+
// checker for documentation rather than a guard on what pi actually receives. Only
|
|
177
|
+
// lines that can compose argv are read.
|
|
178
|
+
const tracked = execFileSync(
|
|
179
|
+
"git",
|
|
180
|
+
["ls-files", "--", "scripts", "pi-extensions", "mcp", "demo", "plugins", "run.sh"],
|
|
181
|
+
{
|
|
182
|
+
cwd: REPO,
|
|
183
|
+
encoding: "utf8",
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
.split("\n")
|
|
187
|
+
.filter((f) => f.length > 0 && /\.(ts|mjs|js|sh)$/.test(f));
|
|
188
|
+
|
|
189
|
+
// Narrowed to the DRIFT class, and the first run is why. A pattern of `--entwurf-…`
|
|
190
|
+
// swept up `--entwurf-bootstrap` (scripts/check-omp-birth-hook.ts), which is a real and
|
|
191
|
+
// unrelated flag — this cell is not the registry of every entwurf flag. What it owns is
|
|
192
|
+
// that no VARIANT of the control flag exists: `--entwurf-controll`, `--entwurf-control-x`
|
|
193
|
+
// and friends. A wholesale rename to a different word is a different change, and the SSOT
|
|
194
|
+
// cell above catches it by reading the constant.
|
|
195
|
+
const FLAGLIKE = new RegExp(`${FLAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[A-Za-z0-9-]*`, "g");
|
|
196
|
+
const wrong: string[] = [];
|
|
197
|
+
let sites = 0;
|
|
198
|
+
for (const rel of tracked) {
|
|
199
|
+
const body = readFileSync(path.join(REPO, rel), "utf8");
|
|
200
|
+
body.split("\n").forEach((line, i) => {
|
|
201
|
+
// A whole-line comment in any of the three syntaxes these files use. An inline
|
|
202
|
+
// trailing comment is NOT stripped: `#` and `//` both appear inside real argv
|
|
203
|
+
// strings here, and guessing where a comment starts would drop live code.
|
|
204
|
+
if (/^\s*(\/\/|#|\*|\/\*)/.test(line)) return;
|
|
205
|
+
for (const m of line.matchAll(FLAGLIKE)) {
|
|
206
|
+
sites++;
|
|
207
|
+
if (m[0] !== FLAG) wrong.push(`${rel}:${i + 1}: ${m[0]}`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
ok(
|
|
212
|
+
`[QK:PILAUNCH-FLAG-SITES] every control-flag PREFIX VARIANT in tracked code is exactly the constant (${sites} occurrences swept, prose excluded)`,
|
|
213
|
+
wrong.length === 0,
|
|
214
|
+
`FLAG=${FLAG}\ndisagreeing:\n${wrong.join("\n")}`,
|
|
215
|
+
);
|
|
216
|
+
// A sweep that found nothing would also report zero disagreements, so the count is
|
|
217
|
+
// asserted too: the launcher alone guarantees at least one.
|
|
218
|
+
ok(
|
|
219
|
+
"[QK:PILAUNCH-FLAG-SWEEP-REACHES] the sweep actually reached code that names the flag",
|
|
220
|
+
sites > 1,
|
|
221
|
+
`sites=${sites}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── 2. the dispatcher verb must not reach the vendor ──────────────────────
|
|
226
|
+
{
|
|
227
|
+
const r = launch(["--model", "x"]);
|
|
228
|
+
ok(
|
|
229
|
+
"[QK:PILAUNCH-VERB-DROPPED] the dispatcher verb never reaches the vendor — without the shift, `pi` arrives as a prompt argument",
|
|
230
|
+
r.status === 0 && !r.args.includes("pi"),
|
|
231
|
+
`argv: ${JSON.stringify(r.args)}`,
|
|
232
|
+
);
|
|
233
|
+
ok(
|
|
234
|
+
"[QK:PILAUNCH-FLAG-FIRST] the injected flag is the FIRST token, so the control socket is stood up before anything the operator asked for",
|
|
235
|
+
r.args[0] === FLAG,
|
|
236
|
+
`argv: ${JSON.stringify(r.args)}`,
|
|
237
|
+
);
|
|
238
|
+
ok(
|
|
239
|
+
"[QK:PILAUNCH-NOTHING-ELSE-INJECTED] the launcher adds the flag and NOTHING else — operator taste is not a launcher decision (Rule 9)",
|
|
240
|
+
JSON.stringify(r.args) === JSON.stringify([FLAG, "--model", "x"]),
|
|
241
|
+
`argv: ${JSON.stringify(r.args)}`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── 3. operator argv crosses byte-identical ───────────────────────────────
|
|
246
|
+
{
|
|
247
|
+
const operator = ["--model", "a b", "", "-", "--", "한글 인자", " spaced "];
|
|
248
|
+
const r = launch(operator);
|
|
249
|
+
ok(
|
|
250
|
+
"[QK:PILAUNCH-ARGV-BYTES] every operator element crosses byte-identical — empty strings, embedded spaces, a bare dash, the terminator and non-ASCII",
|
|
251
|
+
JSON.stringify(r.args) === JSON.stringify([FLAG, ...operator]),
|
|
252
|
+
`expected: ${JSON.stringify([FLAG, ...operator])}\nactual: ${JSON.stringify(r.args)}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── 4. the flag is ADDED, never deduplicated ──────────────────────────────
|
|
257
|
+
{
|
|
258
|
+
const r = launch([FLAG, "--model", "x"]);
|
|
259
|
+
ok(
|
|
260
|
+
"[QK:PILAUNCH-FLAG-NOT-DEDUPED] an operator who already passes the flag gets it twice, because pi was measured to eat the duplicate and a scan would earn nothing",
|
|
261
|
+
JSON.stringify(r.args) === JSON.stringify([FLAG, FLAG, "--model", "x"]),
|
|
262
|
+
`argv: ${JSON.stringify(r.args)}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── 5. the vendor's exit status is this command's exit status ─────────────
|
|
267
|
+
// THIS CELL CARRIES NO MUTANT, and the reason is measured rather than conceded: run.sh
|
|
268
|
+
// is `set -euo pipefail` (run.sh:14), so every mutation that stops the vendor status
|
|
269
|
+
// from reaching the caller also stops `exec` from replacing the process. The non-exec
|
|
270
|
+
// form still exits 7, because set -e aborts before a planted `exit 0` can run. A mutant
|
|
271
|
+
// that kills two claims is not an attributable mutant, so this stands on its assertion
|
|
272
|
+
// alone until that coupling changes.
|
|
273
|
+
// Asserted BEFORE the pid cell on purpose: dropping `exec` for a plain call still
|
|
274
|
+
// propagates the status, while running the vendor and then exiting 0 breaks both. With
|
|
275
|
+
// the status checked first each of those two mutations lands on exactly one claim.
|
|
276
|
+
{
|
|
277
|
+
const r = launch([], { FAKE_PI_EXIT: "7" });
|
|
278
|
+
ok(
|
|
279
|
+
"[QK:PILAUNCH-EXIT-PASSTHROUGH] the vendor's exit status is the launch's exit status",
|
|
280
|
+
r.status === 7,
|
|
281
|
+
`status=${r.status}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── 6. exec, not fork: the pid survives ───────────────────────────────────
|
|
286
|
+
{
|
|
287
|
+
const r = spawnSync("bash", ["-c", `echo "SHELL_PID=$$"; exec bash "${RUN_SH}" pi`], {
|
|
288
|
+
cwd: root,
|
|
289
|
+
encoding: "utf8",
|
|
290
|
+
env: {
|
|
291
|
+
...process.env,
|
|
292
|
+
...sandboxEnv,
|
|
293
|
+
PATH: `${bin}:${pathWithoutVendor}`,
|
|
294
|
+
ENTWURF_PI_LAUNCH_ACTIVE: undefined as unknown as string,
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
const shellPid = /^SHELL_PID=(\d+)$/m.exec(r.stdout ?? "")?.[1];
|
|
298
|
+
const vendorPid = /^PID=(\d+)$/m.exec(r.stdout ?? "")?.[1];
|
|
299
|
+
ok(
|
|
300
|
+
"[QK:PILAUNCH-EXEC-KEEPS-PID] the vendor REPLACES this process — a fork would leave run.sh as a parent that supervises nothing",
|
|
301
|
+
Boolean(shellPid) && shellPid === vendorPid,
|
|
302
|
+
`shell=${shellPid} vendor=${vendorPid}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── 7. an absent vendor is a NAMED refusal, never a quiet success ─────────
|
|
307
|
+
{
|
|
308
|
+
const r = launch([], {}, false);
|
|
309
|
+
ok(
|
|
310
|
+
"[QK:PILAUNCH-VENDOR-ABSENT] with no `pi` on PATH the launch refuses by name and non-zero, and never reports success",
|
|
311
|
+
r.status !== 0 && r.out.includes("no 'pi' executable found on PATH") && r.pid === "",
|
|
312
|
+
`status=${r.status}\n${r.out}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── 8. the recursion fence, and the sentinel that carries it ──────────────
|
|
317
|
+
{
|
|
318
|
+
const armed = launch([]);
|
|
319
|
+
ok(
|
|
320
|
+
"[QK:PILAUNCH-SENTINEL-EXPORTED] the sentinel is EXPORTED, so a PATH `pi` that shells back here sees it on re-entry",
|
|
321
|
+
armed.sentinel === "1",
|
|
322
|
+
`sentinel=${armed.sentinel}`,
|
|
323
|
+
);
|
|
324
|
+
const r = launch([], { ENTWURF_PI_LAUNCH_ACTIVE: "1" });
|
|
325
|
+
ok(
|
|
326
|
+
"[QK:PILAUNCH-RECURSION-FENCE] a re-entered launch refuses by name instead of spinning forever behind a hung terminal",
|
|
327
|
+
r.status !== 0 && r.out.includes("recursive managed launch detected") && r.pid === "",
|
|
328
|
+
`status=${r.status}\n${r.out}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── 9. the case copilot's SECOND fence covers, measured here instead ───────
|
|
333
|
+
{
|
|
334
|
+
const loopBin = path.join(root, "loopbin");
|
|
335
|
+
mkdirSync(loopBin, { recursive: true });
|
|
336
|
+
symlinkSync(RUN_SH, path.join(loopBin, "pi"));
|
|
337
|
+
const r = spawnSync("bash", [RUN_SH, "pi"], {
|
|
338
|
+
cwd: root,
|
|
339
|
+
encoding: "utf8",
|
|
340
|
+
timeout: 30_000,
|
|
341
|
+
env: {
|
|
342
|
+
...process.env,
|
|
343
|
+
...sandboxEnv,
|
|
344
|
+
PATH: `${loopBin}:${pathWithoutVendor}`,
|
|
345
|
+
ENTWURF_PI_LAUNCH_ACTIVE: undefined as unknown as string,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
ok(
|
|
349
|
+
"[QK:PILAUNCH-SYMLINK-NOT-A-LOOP] a PATH `pi` symlinked to run.sh is exec'd as an unknown verb and fails closed — this is why the second fence copilot carries is not needed here",
|
|
350
|
+
r.status !== 0 && r.signal === null,
|
|
351
|
+
`status=${r.status} signal=${r.signal}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
console.log(`\ncheck-pi-launch: ${passed} checks passed`);
|
|
356
|
+
} finally {
|
|
357
|
+
rmSync(root, { recursive: true, force: true });
|
|
358
|
+
}
|
|
@@ -316,6 +316,13 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
316
316
|
// history that earns a place in the gate blocking every cut. On-demand, with the exclusion
|
|
317
317
|
// stated where an operator reads it.
|
|
318
318
|
"smoke-herdr-plugin-build-live": ["VERIFY.md", "The release aggregate does not require the plugin build smoke"],
|
|
319
|
+
// #118 홉 1, third on the same rail and for the same two reasons: herdr is OPTIONAL, so an
|
|
320
|
+
// aggregate that required this would block a cut on a host that does not have the rail at
|
|
321
|
+
// all; and first evidence precedes promotion — this smoke's acceptance runs are hours old,
|
|
322
|
+
// which is evidence the axis works rather than the run history that earns a place in the
|
|
323
|
+
// gate blocking every cut. It also needs a Docker daemon and the public network, neither of
|
|
324
|
+
// which the aggregate owns. On-demand, with the exclusion stated where an operator reads it.
|
|
325
|
+
"smoke-herdr-raw-install-live": ["VERIFY.md", "The plugin's FIRST USER PATH is its own on-demand axis"],
|
|
319
326
|
};
|
|
320
327
|
|
|
321
328
|
const allLive = globSync("scripts/smoke-*live*.{ts,sh}", { cwd: REPO_DIR })
|
|
@@ -69,7 +69,22 @@ function listWorkSurface(pathspec: string[]): string[] {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
const legacyFiles = listWorkSurface(["scripts/"]);
|
|
72
|
-
|
|
72
|
+
// The framework axis is every vitest-managed lane, and since #119 V2 that is TWO locations:
|
|
73
|
+
// the migration lane under test/, and tests written beside the behaviour they certify. The
|
|
74
|
+
// pathspecs are `:(glob)` so `**` means "at any depth" and nothing but a test file enters —
|
|
75
|
+
// pi-extensions/ and plugins/ are product trees, and pulling them in whole would put 20k lines
|
|
76
|
+
// of product into a VERIFICATION denominator.
|
|
77
|
+
//
|
|
78
|
+
// This is what keeps the combined total honest through the V3 migration: a gate moving from
|
|
79
|
+
// scripts/ to a file beside its subject moves lines BETWEEN axes, and only a drop in the
|
|
80
|
+
// combined number is subtraction. Before this, such a gate left the denominator entirely and
|
|
81
|
+
// every migrated line read as a deletion.
|
|
82
|
+
const frameworkFiles = listWorkSurface([
|
|
83
|
+
"test/",
|
|
84
|
+
"vitest.config.ts",
|
|
85
|
+
":(glob)pi-extensions/**/*.test.ts",
|
|
86
|
+
":(glob)plugins/herdr/**/*.test.mjs",
|
|
87
|
+
]);
|
|
73
88
|
|
|
74
89
|
function countLines(rel: string): number {
|
|
75
90
|
const body = fs.readFileSync(path.join(REPO_DIR, rel), "utf8");
|
|
@@ -83,6 +98,20 @@ const H_TEXT = /readFileSync[^\n]*(?:pi-extensions|mcp\/|\.ts["'`]|SOURCE|SRC)|\
|
|
|
83
98
|
const H_PROC = /\b(?:spawn|spawnSync|execFile|execFileSync|execSync|fork)\s*\(|subprocess\.(?:run|Popen|check_)/;
|
|
84
99
|
const H_NET = /\b(?:http|net)\.createServer|\.listen\(/;
|
|
85
100
|
const H_FS = /from\s+["']node:fs["']|require\(["']node:fs["']\)/;
|
|
101
|
+
// H_IMPORTS asks for an ASCENDING path into a product tree, because every gate under scripts/
|
|
102
|
+
// has to climb to reach one. A test written beside its subject never climbs: it imports
|
|
103
|
+
// `./subject.ts`. The distinction is the file's own location, not its spelling — a lane living
|
|
104
|
+
// inside pi-extensions/ or plugins/ that imports a relative sibling is importing product by
|
|
105
|
+
// construction. Applied ONLY to those lanes: scripts/check-*.ts also import `./lib/*`, and that
|
|
106
|
+
// is the verification surface importing itself, which is a different fact the mutant inventory
|
|
107
|
+
// already counts as infra-subject.
|
|
108
|
+
const H_IMPORTS_SIBLING = /(?:from\s+["']|import\(\s*["'])\.{1,2}\//;
|
|
109
|
+
// Wider than the framework-axis glob (`plugins/herdr/**`) on purpose, and the asymmetry only
|
|
110
|
+
// runs one way: the glob decides what ENTERS the denominator, this decides how a file already
|
|
111
|
+
// in it is read. A test under some other plugins/ package would have to be admitted by the
|
|
112
|
+
// glob first, so the looser shape here cannot inflate a count — it only keeps the classifier
|
|
113
|
+
// from throwing on the day that glob widens.
|
|
114
|
+
const BESIDE_BEHAVIOUR = /^(pi-extensions|plugins)\/.*\.test\.(ts|mjs)$/;
|
|
86
115
|
// H_LIVE asks whether the file's OWN CODE reads the LIVE switch — not whether the
|
|
87
116
|
// three letters appear. The naive `\bLIVE=1\b` form classified
|
|
88
117
|
// scripts/check-release-gate-outcomes.ts as real-live because that gate QUOTES the
|
|
@@ -145,6 +174,45 @@ const CLASS_OVERRIDES: Record<string, { cls: SemanticClass; reason: string }> =
|
|
|
145
174
|
cls: "source-topology",
|
|
146
175
|
reason: "asserts the scrubbed parent-transcript fixture's recorded shape — an artifact contract, no product run",
|
|
147
176
|
},
|
|
177
|
+
// #119 V1 measured the classifier THROWING on these four at HEAD 5062147 — the
|
|
178
|
+
// assertion below did its job and named real drift, in three distinct shapes. None
|
|
179
|
+
// of them is reachable by widening H_IMPORTS: there is no literal `../plugins/`
|
|
180
|
+
// import anywhere under scripts/ or test/ (measured, 0 matches), so a `plugins`
|
|
181
|
+
// alternative in that predicate would match nothing and only claim coverage it
|
|
182
|
+
// does not have.
|
|
183
|
+
//
|
|
184
|
+
// Shape 1 — the herdr gates reach their subject through a RUNTIME-ASSEMBLED
|
|
185
|
+
// dynamic import, `await import(pathToFileURL(LEAF).href)`. H_IMPORTS matches a
|
|
186
|
+
// literal specifier by design (a computed one cannot be read as text without
|
|
187
|
+
// executing the gate), so these two are override territory, not predicate
|
|
188
|
+
// territory.
|
|
189
|
+
"scripts/check-herdr-plugin-profile.ts": {
|
|
190
|
+
cls: "behavioral-contract",
|
|
191
|
+
reason:
|
|
192
|
+
"dynamically imports plugins/herdr/lib/integration-profile.mjs and source-reads that same leaf — executes product code, no process",
|
|
193
|
+
},
|
|
194
|
+
"scripts/check-herdr-runtime-bootstrap.ts": {
|
|
195
|
+
cls: "behavioral-contract",
|
|
196
|
+
reason:
|
|
197
|
+
"dynamically imports the herdr runtime leaf and its plugin re-export and drives them against mkdtemp HOME/XDG fixtures — executes product code, spawns nothing",
|
|
198
|
+
},
|
|
199
|
+
// Shape 2 — the reads go through a local `read(rel)` helper, so H_TEXT's
|
|
200
|
+
// line-level form (readFileSync on the same line as the path literal) cannot see
|
|
201
|
+
// them. No product is executed: this is a manifest/installer/CI structure gate.
|
|
202
|
+
"scripts/check-herdr-supply.ts": {
|
|
203
|
+
cls: "source-topology",
|
|
204
|
+
reason:
|
|
205
|
+
"reads the supply manifest, the CI installer and the workflow as text through a read() helper; runs no product",
|
|
206
|
+
},
|
|
207
|
+
// Shape 3 — a vitest lane whose SUBJECT is the verification surface itself
|
|
208
|
+
// (scripts/lib), not pi-extensions/ or mcp/. The mutant inventory below already
|
|
209
|
+
// counts that as infra-subject; the class axis needs the same honesty rather than
|
|
210
|
+
// a widened "product import" predicate that would blur the two.
|
|
211
|
+
"test/tmux-coordinate-row.test.ts": {
|
|
212
|
+
cls: "pure-unit",
|
|
213
|
+
reason:
|
|
214
|
+
"executes the scripts/lib/tmux-coordinate-row leaf — an INFRA-subject lane; no fs, no text read, no process",
|
|
215
|
+
},
|
|
148
216
|
};
|
|
149
217
|
|
|
150
218
|
/**
|
|
@@ -187,9 +255,13 @@ function classify(rel: string, axis: "legacy" | "framework"): Row {
|
|
|
187
255
|
const body = isShell ? fs.readFileSync(path.join(REPO_DIR, rel), "utf8") : effectiveBody(rel);
|
|
188
256
|
const base = path.basename(rel);
|
|
189
257
|
|
|
258
|
+
// One notion of "imports product", used by BOTH axes below so the style table and the class
|
|
259
|
+
// table can never disagree about the same file.
|
|
260
|
+
const importsProduct = H_IMPORTS.test(body) || (BESIDE_BEHAVIOUR.test(rel) && H_IMPORTS_SIBLING.test(body));
|
|
261
|
+
|
|
190
262
|
const style: Style = (() => {
|
|
191
263
|
if (isShell) return "shell";
|
|
192
|
-
const axes = [
|
|
264
|
+
const axes = [importsProduct, H_TEXT.test(body), H_PROC.test(body)];
|
|
193
265
|
const n = axes.filter(Boolean).length;
|
|
194
266
|
if (n >= 2) return "mixed";
|
|
195
267
|
if (axes[0]) return "imports-product";
|
|
@@ -207,11 +279,11 @@ function classify(rel: string, axis: "legacy" | "framework"): Row {
|
|
|
207
279
|
? "package-install"
|
|
208
280
|
: isShell || H_PROC.test(body) || H_NET.test(body)
|
|
209
281
|
? "hermetic-integration"
|
|
210
|
-
: H_TEXT.test(body) && !
|
|
282
|
+
: H_TEXT.test(body) && !importsProduct
|
|
211
283
|
? "source-topology"
|
|
212
|
-
:
|
|
284
|
+
: importsProduct && (H_TEXT.test(body) || H_FS.test(body))
|
|
213
285
|
? "behavioral-contract"
|
|
214
|
-
:
|
|
286
|
+
: importsProduct
|
|
215
287
|
? "pure-unit"
|
|
216
288
|
: null;
|
|
217
289
|
if (cls === null) {
|
|
@@ -224,7 +296,7 @@ function classify(rel: string, axis: "legacy" | "framework"): Row {
|
|
|
224
296
|
|
|
225
297
|
// ── buckets ──────────────────────────────────────────────────────────────────
|
|
226
298
|
const legacyGates = legacyFiles.filter((f) => /^scripts\/(check-|smoke-)/.test(f));
|
|
227
|
-
const frameworkGates = frameworkFiles.filter((f) =>
|
|
299
|
+
const frameworkGates = frameworkFiles.filter((f) => /\.test\.(ts|mjs)$/.test(f));
|
|
228
300
|
const lib = legacyFiles.filter((f) => f.startsWith("scripts/lib/"));
|
|
229
301
|
const mutantManifests = legacyFiles.filter((f) => f.startsWith("scripts/mutants/") && f.endsWith(".json"));
|
|
230
302
|
const fixtures = legacyFiles.filter((f) => f.startsWith("scripts/fixtures/"));
|
|
@@ -254,14 +326,16 @@ rule predicates (re-derive any number from these):
|
|
|
254
326
|
H_PROC ${H_PROC}
|
|
255
327
|
H_NET ${H_NET}
|
|
256
328
|
H_FS ${H_FS}
|
|
329
|
+
H_IMPORTS_SIBLING ${H_IMPORTS_SIBLING} (only for ${BESIDE_BEHAVIOUR})
|
|
257
330
|
H_LIVE ${H_LIVE}
|
|
258
331
|
H_LIVE is applied to the CODE-ONLY projection (comments and inert literals blanked;
|
|
259
332
|
shell double-quoted expansions kept), so a gate that merely QUOTES "LIVE=1" is not
|
|
260
333
|
real-live. Two-tier gates are listed under the class breakdown.
|
|
261
334
|
semantic classes, first match wins: override → real-live (name -live | H_LIVE on code) →
|
|
262
335
|
package-install (name pack|install) → hermetic-integration (.sh | H_PROC | H_NET) →
|
|
263
|
-
source-topology (H_TEXT ∧ ¬
|
|
264
|
-
pure-unit (
|
|
336
|
+
source-topology (H_TEXT ∧ ¬importsProduct) → behavioral-contract (importsProduct ∧ (H_TEXT ∨ H_FS)) →
|
|
337
|
+
pure-unit (importsProduct) → ERROR (unclassified is asserted zero)
|
|
338
|
+
importsProduct = H_IMPORTS, plus H_IMPORTS_SIBLING for a BESIDE_BEHAVIOUR lane
|
|
265
339
|
test/*.test.ts is classified together with the ./helpers/* bodies it imports.
|
|
266
340
|
`);
|
|
267
341
|
|
|
@@ -275,9 +349,11 @@ console.log(` scripts/lib/: ${lib.length} files, ${total(lib
|
|
|
275
349
|
console.log(` scripts/mutants/: ${mutantManifests.length} manifests`);
|
|
276
350
|
console.log(` scripts/fixtures/: ${fixtures.length} files`);
|
|
277
351
|
console.log(` other: ${legacyOther.length} files, ${total(legacyOther)} lines`);
|
|
278
|
-
console.log(`framework axis (test/ + vitest.config.ts): ${frameworkFiles.length} files, ${frameworkTotal} lines`);
|
|
279
352
|
console.log(
|
|
280
|
-
`
|
|
353
|
+
`framework axis (vitest-managed, both locations): ${frameworkFiles.length} files, ${frameworkTotal} lines`,
|
|
354
|
+
);
|
|
355
|
+
console.log(
|
|
356
|
+
` lanes (test/** + beside behaviour): ${frameworkGates.length} files, ${sum(rows.filter((r) => r.axis === "framework"))} lines`,
|
|
281
357
|
);
|
|
282
358
|
console.log(` helpers/config: ${frameworkSupport.length} files, ${total(frameworkSupport)} lines`);
|
|
283
359
|
console.log(
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"find": [
|
|
111
111
|
"\tprogress.step(",
|
|
112
112
|
"\t\t`fetching and installing the Entwurf runtime from ${describeRequest(plan.requested)} — this is the long step ` +",
|
|
113
|
-
"\t\t\t\"(npm
|
|
113
|
+
"\t\t\t\"(npm acquires the locked artifact; expect minutes of silence)\",",
|
|
114
114
|
"\t);"
|
|
115
115
|
],
|
|
116
116
|
"replace": ["\tprogress.step(\"working\");"],
|