@junghanacs/entwurf 0.18.1 → 0.19.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 +2 -2
- package/CHANGELOG.md +248 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +8 -6
- package/VERIFY.md +3 -3
- package/docs/external-mcp-host.md +2 -2
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +18 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +76 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +54 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resolve-tmux-session.js +114 -0
- package/mcp/entwurf-bridge/src/index.ts +22 -7
- package/package.json +1 -1
- package/pi-extensions/entwurf-control.ts +27 -3
- package/pi-extensions/lib/mux-fresh-call.ts +98 -11
- package/pi-extensions/lib/mux-placement.ts +65 -10
- package/pi-extensions/lib/resolve-tmux-session.ts +137 -0
- package/scripts/check-fresh-cut-gate.sh +54 -2
- package/scripts/check-gate-qualification.ts +5 -5
- package/scripts/check-mux-launch-tmux.ts +77 -2
- package/scripts/check-mux-launch.ts +17 -0
- package/scripts/check-mux-placement-tmux.ts +61 -1
- package/scripts/check-mux-placement.ts +33 -0
- package/scripts/check-release-gate-outcomes.ts +224 -9
- package/scripts/ci-qualify-decide.sh +159 -0
- package/scripts/fixtures/qualify-replay.json +149 -0
- package/scripts/mutants/fresh-cut.json +26 -0
- package/scripts/mutants/mux-boundary.json +24 -0
- package/scripts/mutants/mux-fresh-call.json +81 -0
- package/scripts/mutants/omp-birth.json +16 -3
- package/scripts/mutants/release-gate.json +45 -1
- package/scripts/omp-bridge-doctor.sh +11 -1
- package/scripts/smoke-mux-fresh-call-live.ts +175 -3
- package/scripts/smoke-omp-bridge-state.sh +5 -2
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
* is still listed on an immediate re-read — which is why no post-launch presence check is
|
|
25
25
|
* performed and why the precondition runs before the window exists
|
|
26
26
|
* - a precondition refusal opens NO window at all
|
|
27
|
+
* - the #105 seat, through the real `freshCall`: an absent seat refuses and creates nothing,
|
|
28
|
+
* an existing one puts the window in THAT session with a receipt naming the resolved
|
|
29
|
+
* target, and the resulting handle closes through `closeWindow` from outside that session
|
|
27
30
|
*/
|
|
28
31
|
|
|
29
32
|
import assert from "node:assert/strict";
|
|
@@ -97,7 +100,7 @@ function alive(pid: string): boolean {
|
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
function main(): void {
|
|
103
|
+
async function main(): Promise<void> {
|
|
101
104
|
if (spawnSync("tmux", ["-V"], { encoding: "utf8" }).status !== 0) {
|
|
102
105
|
skipLive(LABEL, "tmux is not installed — install tmux to run the launch acceptance");
|
|
103
106
|
}
|
|
@@ -298,6 +301,78 @@ function main(): void {
|
|
|
298
301
|
originalPanes.every((p) => panes().includes(p)),
|
|
299
302
|
);
|
|
300
303
|
ok("restored: focus never moved", windows().find((w) => w.endsWith("|1")) === activeBefore);
|
|
304
|
+
|
|
305
|
+
// ── the fresh-call composition, against a real server (#105) ──────────────────
|
|
306
|
+
// The seat's deterministic gate can prove the argv, the leaf and the refusals, but the
|
|
307
|
+
// two things that only exist once tmux has answered — WHICH session the window landed
|
|
308
|
+
// in, and what the receipt says about it — had no oracle independent of the production
|
|
309
|
+
// source. This cell is that oracle: the same hermetic runtime above, a SECOND session
|
|
310
|
+
// on this same private server, and the real `freshCall`, read through its return value
|
|
311
|
+
// and the server's own inventory.
|
|
312
|
+
{
|
|
313
|
+
const { freshCall } = await import("../pi-extensions/lib/mux-fresh-call.ts");
|
|
314
|
+
const { closeWindow } = await import("../pi-extensions/lib/mux-placement.ts");
|
|
315
|
+
const call = (placementInput?: { tmuxSession: string }) =>
|
|
316
|
+
freshCall(
|
|
317
|
+
{
|
|
318
|
+
backend: "pi",
|
|
319
|
+
model: "fixture/model",
|
|
320
|
+
task: "fixture task",
|
|
321
|
+
placement: placementInput,
|
|
322
|
+
callerGardenId: "20260101T000000-fixture",
|
|
323
|
+
},
|
|
324
|
+
inherited,
|
|
325
|
+
);
|
|
326
|
+
const inventory = (): string => fxLines("list-windows", "-a", "-F", "#{session_id}|#{window_id}").join(" ");
|
|
327
|
+
|
|
328
|
+
// (i) an ABSENT seat: named refusal, and the server is byte-identical afterwards.
|
|
329
|
+
const beforeAbsent = inventory();
|
|
330
|
+
const absent = call({ tmuxSession: "nosuchseat" });
|
|
331
|
+
ok(
|
|
332
|
+
"seat: an absent seat refuses as tmux-session-missing and creates NOTHING — no window, no session",
|
|
333
|
+
!absent.ok && absent.reason === "tmux-session-missing" && inventory() === beforeAbsent,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// (ii) an EXISTING seat on the same server: the window lands THERE, the caller's own
|
|
337
|
+
// session is untouched, and the receipt names the resolved target rather than the caller.
|
|
338
|
+
assert.equal(fx("new-session", "-d", "-s", `${SESSION}-seat`).status, 0, "fixture seat session");
|
|
339
|
+
const seatId = fxLines("list-windows", "-t", `=${SESSION}-seat`, "-F", "#{session_id}")[0];
|
|
340
|
+
const callerWindowsBefore = fxLines("list-windows", "-t", placement.sessionId, "-F", "#{window_id}").join(" ");
|
|
341
|
+
const seated = call({ tmuxSession: `${SESSION}-seat` });
|
|
342
|
+
assert.ok(seated.ok, `the seated fresh call must succeed: ${seated.ok ? "" : seated.reason}`);
|
|
343
|
+
const receipt = seated.receipt;
|
|
344
|
+
ok(
|
|
345
|
+
"seat: the receipt reports the RESOLVED target session and echoes the REQUESTED name — not the caller's session",
|
|
346
|
+
receipt.sessionId === seatId &&
|
|
347
|
+
receipt.sessionId !== placement.sessionId &&
|
|
348
|
+
receipt.tmuxSession === `${SESSION}-seat`,
|
|
349
|
+
);
|
|
350
|
+
ok(
|
|
351
|
+
"seat: tmux agrees — the window is in the seat, and the caller's own session is byte-identical",
|
|
352
|
+
fxLines("list-windows", "-t", seatId, "-F", "#{window_id}").includes(receipt.windowId) &&
|
|
353
|
+
fxLines("list-windows", "-t", placement.sessionId, "-F", "#{window_id}").join(" ") === callerWindowsBefore,
|
|
354
|
+
);
|
|
355
|
+
ok(
|
|
356
|
+
"seat: an omitted seat still lands in the caller's own session and the receipt names none",
|
|
357
|
+
(() => {
|
|
358
|
+
const own = call();
|
|
359
|
+
if (!own.ok) return false;
|
|
360
|
+
const here = own.receipt.sessionId === placement.sessionId && own.receipt.tmuxSession === undefined;
|
|
361
|
+
process.kill(Number(own.receipt.panePid), "SIGKILL");
|
|
362
|
+
return here;
|
|
363
|
+
})(),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// (iii) the close side: that handle closes through the production verb, in a session
|
|
367
|
+
// that is NOT the caller's — the reason close binds to the server half.
|
|
368
|
+
ok(
|
|
369
|
+
"seat: the placed window closes through its own handle and tmux stops listing it",
|
|
370
|
+
closeWindow(receipt, inherited) === "closed" &&
|
|
371
|
+
!fxLines("list-windows", "-a", "-F", "#{window_id}").includes(receipt.windowId),
|
|
372
|
+
);
|
|
373
|
+
fx("kill-session", "-t", seatId);
|
|
374
|
+
ok("seat: the fixture is back to one session", sessionCount() === 1);
|
|
375
|
+
}
|
|
301
376
|
} finally {
|
|
302
377
|
fx("kill-server");
|
|
303
378
|
if (fs.existsSync(SOCKET)) fs.rmSync(SOCKET, { force: true });
|
|
@@ -313,4 +388,4 @@ function main(): void {
|
|
|
313
388
|
console.log(`\n${LABEL}: ${passed} checks passed`);
|
|
314
389
|
}
|
|
315
390
|
|
|
316
|
-
main();
|
|
391
|
+
await main();
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* MUX-LAUNCH-NO-SHELL-ARGV the fixed runtime is passed after `--`, never as a string
|
|
20
20
|
* MUX-LAUNCH-NO-CARRIER argv is the append shape plus the runtime, nothing else
|
|
21
21
|
* MUX-LAUNCH-CORE-IMPORT-FREE the launch module and entwurf delivery never import each other
|
|
22
|
+
* RESOLVE-TMUX-SESSION-IMPORT-FREE the session-lookup leaf imports nothing at all
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
25
|
import assert from "node:assert/strict";
|
|
@@ -270,6 +271,22 @@ function main(): void {
|
|
|
270
271
|
"boundary: the placement leaf does not import the launch module — the leaf stays deletable on its own",
|
|
271
272
|
!importsLaunch("pi-extensions/lib/mux-placement.ts"),
|
|
272
273
|
);
|
|
274
|
+
// ── the session-lookup leaf imports NOTHING (docs §11, #105) ─────────────────
|
|
275
|
+
// It is the one leaf whose entire safety argument is that it cannot acquire an
|
|
276
|
+
// opinion: no tmux of its own (the runner is injected), no mux module, no entwurf
|
|
277
|
+
// core, and not even a node builtin. §11 states that; nothing held it, and the first
|
|
278
|
+
// `import { runTmux } from "./mux-placement.ts"` would quietly turn a decision leaf
|
|
279
|
+
// into a second place that can run tmux.
|
|
280
|
+
ok(
|
|
281
|
+
"[QK:RESOLVE-TMUX-SESSION-IMPORT-FREE] the session-lookup leaf imports nothing at all — not mux, not entwurf core, not even a node builtin — so the only power it has is the runner its caller injects",
|
|
282
|
+
importsOf(fs.readFileSync("pi-extensions/lib/resolve-tmux-session.ts", "utf8")).length === 0,
|
|
283
|
+
);
|
|
284
|
+
ok(
|
|
285
|
+
"boundary: the fresh-call composition is the ONLY shipped source that imports that leaf",
|
|
286
|
+
PRODUCTION_SOURCES.filter((m) =>
|
|
287
|
+
importsOf(fs.readFileSync(m, "utf8")).some((spec) => spec.includes("resolve-tmux-session")),
|
|
288
|
+
).join(",") === FRESH_CALL_MODULE,
|
|
289
|
+
);
|
|
273
290
|
// Prose is not behaviour: the module header names identity vocabulary precisely to say
|
|
274
291
|
// it owns none of it, so this assertion reads CODE with the comments stripped. A check
|
|
275
292
|
// that failed on its own documentation would push the boundary out of the docs.
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* - handles are stable @window/%pane
|
|
28
28
|
* - the rc=0 trap is real, and inspectPlacement refuses it instead of guessing
|
|
29
29
|
* - close reports `closed` for a live window and `already-gone` after a natural exit
|
|
30
|
+
* - a window placed in ANOTHER session of the same server closes through its own handle,
|
|
31
|
+
* while a handle from another SERVER is still refused (#105 close-side binding)
|
|
30
32
|
*/
|
|
31
33
|
|
|
32
34
|
import assert from "node:assert/strict";
|
|
@@ -259,9 +261,10 @@ function main(): void {
|
|
|
259
261
|
/context changed/,
|
|
260
262
|
"appendWindow must refuse a placement from another session",
|
|
261
263
|
);
|
|
264
|
+
// Close binds to the SERVER half (#105), so its refusal names that half by its own word.
|
|
262
265
|
assert.throws(
|
|
263
266
|
() => closeWindow({ ...w4, serverPid: "999999" }, inherited),
|
|
264
|
-
/
|
|
267
|
+
/server changed/,
|
|
265
268
|
"closeWindow must refuse a handle from another server",
|
|
266
269
|
);
|
|
267
270
|
ok("binding: append/close refuse a foreign server or session before mutating", windows().length === 4);
|
|
@@ -305,6 +308,63 @@ function main(): void {
|
|
|
305
308
|
originalPanes.every((p) => panes().includes(p)),
|
|
306
309
|
);
|
|
307
310
|
ok("restored: focus never moved", windows().find((w) => w.endsWith("|1")) === activeBefore);
|
|
311
|
+
|
|
312
|
+
// ── close binds to the SERVER, not the session (#105) ─────────────────────────
|
|
313
|
+
// Since #105 a launched window may live in a session that is not the caller's, so the
|
|
314
|
+
// close side had to stop requiring the session half. This is that decision judged
|
|
315
|
+
// against a real server: a SECOND session, a window opened into it from the caller's
|
|
316
|
+
// pane the way the fresh-call composition does, and a close through the same handle.
|
|
317
|
+
// Both foreign-server refusal and the two close outcomes above stay exactly as they
|
|
318
|
+
// were — the session half is what changed, and only for close.
|
|
319
|
+
assert.equal(fx("new-session", "-d", "-s", `${SESSION}-b`).status, 0, "fixture second session");
|
|
320
|
+
ok("cross-session: the fixture now holds two sessions", sessionCount() === 2);
|
|
321
|
+
const otherSessionId = fxLines("list-windows", "-t", `=${SESSION}-b`, "-F", "#{session_id}")[0];
|
|
322
|
+
ok("cross-session: the second session resolves to a native id by exact name", /^\$[0-9]+$/.test(otherSessionId));
|
|
323
|
+
const placedRow = fxLines(
|
|
324
|
+
"new-window",
|
|
325
|
+
"-d",
|
|
326
|
+
"-a",
|
|
327
|
+
"-t",
|
|
328
|
+
`${otherSessionId}:{end}`,
|
|
329
|
+
"-P",
|
|
330
|
+
"-F",
|
|
331
|
+
"#{window_id}|#{window_index}|#{pane_id}|#{pane_pid}",
|
|
332
|
+
)[0].split("|");
|
|
333
|
+
const placed = {
|
|
334
|
+
serverPid: placement.serverPid,
|
|
335
|
+
sessionId: otherSessionId,
|
|
336
|
+
windowId: placedRow[0],
|
|
337
|
+
windowIndex: placedRow[1],
|
|
338
|
+
paneId: placedRow[2],
|
|
339
|
+
panePid: placedRow[3],
|
|
340
|
+
};
|
|
341
|
+
ok(
|
|
342
|
+
"cross-session: the window really landed in the OTHER session, and the caller's session is untouched",
|
|
343
|
+
fxLines("list-windows", "-t", otherSessionId, "-F", "#{window_id}").includes(placed.windowId) &&
|
|
344
|
+
!fxLines("list-windows", "-t", placement.sessionId, "-F", "#{window_id}").includes(placed.windowId),
|
|
345
|
+
);
|
|
346
|
+
ok(
|
|
347
|
+
"cross-session: the caller's own session still shows windows 1,2 and its focus is unmoved",
|
|
348
|
+
fxLines("list-windows", "-t", placement.sessionId, "-F", "#{window_id}").length === 2 &&
|
|
349
|
+
windows().find((w) => w.endsWith("|1")) === activeBefore,
|
|
350
|
+
);
|
|
351
|
+
// The predicate change, stated as the two facts that had to move together.
|
|
352
|
+
assert.throws(
|
|
353
|
+
() => closeWindow({ ...placed, serverPid: "999999" }, inherited),
|
|
354
|
+
/server changed/,
|
|
355
|
+
"closeWindow must still refuse a handle from another server",
|
|
356
|
+
);
|
|
357
|
+
ok(
|
|
358
|
+
"cross-session: a window in another session of the SAME server closes through its own handle — the release lifecycle smoke's close path reaches a placed window",
|
|
359
|
+
closeWindow(placed, inherited) === "closed",
|
|
360
|
+
);
|
|
361
|
+
ok(
|
|
362
|
+
"cross-session: it is gone from the other session, and nothing else moved",
|
|
363
|
+
!fxLines("list-windows", "-a", "-F", "#{window_id}").includes(placed.windowId) &&
|
|
364
|
+
fxLines("list-windows", "-t", placement.sessionId, "-F", "#{window_id}").length === 2,
|
|
365
|
+
);
|
|
366
|
+
fx("kill-session", "-t", otherSessionId);
|
|
367
|
+
ok("cross-session: the fixture is back to one session", sessionCount() === 1);
|
|
308
368
|
} finally {
|
|
309
369
|
fx("kill-server");
|
|
310
370
|
if (fs.existsSync(SOCKET)) fs.rmSync(SOCKET, { force: true });
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* MUX-TMUX-FAILURE-LOUD a nonzero/signalled tmux run is raised, never read as a fact
|
|
23
23
|
* MUX-APPEND-END-DETACHED append is `-d -a -t <session_id>:{end}`, no carrier
|
|
24
24
|
* MUX-CONTEXT-BOUND-MUTATION a mutation matches the server pid AND the session id
|
|
25
|
+
* MUX-CLOSE-SERVER-BOUND a close binds to the server half only — same server, any session
|
|
25
26
|
* MUX-CLOSE-BY-WINDOW-ID close targets `@window`, and absence needs positive proof
|
|
26
27
|
*/
|
|
27
28
|
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
isDecimal,
|
|
39
40
|
isPaneId,
|
|
40
41
|
isSameContext,
|
|
42
|
+
isSameServer,
|
|
41
43
|
isSessionId,
|
|
42
44
|
isWindowId,
|
|
43
45
|
parsePlacement,
|
|
@@ -254,6 +256,37 @@ function main(): void {
|
|
|
254
256
|
);
|
|
255
257
|
})(),
|
|
256
258
|
);
|
|
259
|
+
// ── a close binds to the server, and the session half is covered by id uniqueness ────
|
|
260
|
+
// `appendWindow` and `closeWindow` are answering different questions, so they bind to
|
|
261
|
+
// different halves of the same fact. Append asks "is this target still the caller's own
|
|
262
|
+
// seat?" and needs both. Close asks "is this handle still the window it was born as?" —
|
|
263
|
+
// and since #105 a launched window may legitimately live in a session that is not the
|
|
264
|
+
// caller's, so requiring the session half would refuse a legitimate close. `[측정
|
|
265
|
+
// 2026-09-07]` window ids are handed out monotonically and never recycled within one
|
|
266
|
+
// server's life (after `@2` was killed the next was `@3`; after a whole session holding
|
|
267
|
+
// `@4`/`@5` was killed the next was `@6`), so on a matching server the `@id` alone
|
|
268
|
+
// identifies the window. What must still be refused is a handle from a DIFFERENT or
|
|
269
|
+
// restarted server, where the same id names something else.
|
|
270
|
+
ok(
|
|
271
|
+
"[QK:MUX-CLOSE-SERVER-BOUND] a close-side context matches on the server pid ALONE — another session on the same server is accepted, and every foreign server is refused however familiar its session id looks",
|
|
272
|
+
(() => {
|
|
273
|
+
const origin = { serverPid: "8150", sessionId: "$11" };
|
|
274
|
+
return (
|
|
275
|
+
isSameServer(origin, { serverPid: "8150", sessionId: "$11" }) &&
|
|
276
|
+
isSameServer(origin, { serverPid: "8150", sessionId: "$12" }) &&
|
|
277
|
+
!isSameServer(origin, { serverPid: "9999", sessionId: "$11" }) &&
|
|
278
|
+
!isSameServer(origin, { serverPid: "9999", sessionId: "$12" })
|
|
279
|
+
);
|
|
280
|
+
})(),
|
|
281
|
+
);
|
|
282
|
+
ok(
|
|
283
|
+
"binding: the two predicates are different contracts — append's refuses a foreign session that close's accepts, so neither may be expressed in terms of the other",
|
|
284
|
+
(() => {
|
|
285
|
+
const origin = { serverPid: "8150", sessionId: "$11" };
|
|
286
|
+
const otherSession = { serverPid: "8150", sessionId: "$12" };
|
|
287
|
+
return !isSameContext(origin, otherSession) && isSameServer(origin, otherSession);
|
|
288
|
+
})(),
|
|
289
|
+
);
|
|
257
290
|
ok(
|
|
258
291
|
"binding: a window handle carries the context it was born in",
|
|
259
292
|
(() => {
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
|
|
45
45
|
import { strict as assert } from "node:assert";
|
|
46
46
|
import { execFileSync } from "node:child_process";
|
|
47
|
-
import { globSync, readFileSync } from "node:fs";
|
|
47
|
+
import { copyFileSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
48
|
+
import { tmpdir } from "node:os";
|
|
48
49
|
import { basename, join } from "node:path";
|
|
49
50
|
import { fileURLToPath } from "node:url";
|
|
50
51
|
import { LIVE_SKIP_EXIT, LIVE_SKIP_MARKER } from "./lib/live-skip.ts";
|
|
@@ -337,17 +338,20 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
337
338
|
// check-gate-qualification left the default check chains (operator
|
|
338
339
|
// inner-loop cost, 2026-08 subtraction). That move is a gate/release
|
|
339
340
|
// contract: the step must stay REACHABLE on the axes that now own it — the
|
|
340
|
-
// CI check job on
|
|
341
|
+
// CI check job (on the pushes #103's filter still sends it) and release_gate
|
|
342
|
+
// as its own MUST step — and
|
|
341
343
|
// must not silently return to the default chain. Without this cell,
|
|
342
344
|
// deleting the release_gate qualification block or the CI line leaves every
|
|
343
345
|
// focused gate green while a cut quietly loses its discriminating-power
|
|
344
346
|
// step.
|
|
345
347
|
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
348
|
+
// THREE claims, because each is an independent contract. 8a is REACHABILITY
|
|
349
|
+
// (absent from the default chain; present exactly once in CI and exactly once
|
|
350
|
+
// as a wired release_gate MUST step; named in VERIFY). 8b is what the CI step
|
|
351
|
+
// qualifies (the FULL floor, before the qualification run), added by #70. 8c is
|
|
352
|
+
// what the RELEASE path ACCEPTS as evidence for one SHA — that the body step
|
|
353
|
+
// actually ran there (#103). Each carries its own replant — one mutant must
|
|
354
|
+
// never stand in for another.
|
|
351
355
|
// ===========================================================================
|
|
352
356
|
{
|
|
353
357
|
// The default chain is tiered (#70): `check` (core) and `check:full` compose the
|
|
@@ -382,7 +386,11 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
382
386
|
!qualGateBody.includes('results+=("FAIL check-gate-qualification")')
|
|
383
387
|
)
|
|
384
388
|
holes.push("the release_gate qualification step does not wire PASS/FAIL into the MUST counters");
|
|
385
|
-
if (
|
|
389
|
+
if (
|
|
390
|
+
!verifyDoc.includes(
|
|
391
|
+
"in the CI `check` job on a branch push that touched the qualification surface, and as a release-gate MUST step",
|
|
392
|
+
)
|
|
393
|
+
)
|
|
386
394
|
holes.push("VERIFY.md no longer names the owners of the moved qualification step");
|
|
387
395
|
assert.ok(
|
|
388
396
|
holes.length === 0,
|
|
@@ -412,6 +420,210 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
412
420
|
"downgrade/omission axis; ordering is directly asserted by this same oracle. " +
|
|
413
421
|
`Broken: check:full at index ${ciFloorAt}, qualification at index ${ciQualAt}.`,
|
|
414
422
|
);
|
|
423
|
+
|
|
424
|
+
// 8c. The RELEASE oracle requires that body to have actually run at the release
|
|
425
|
+
// SHA -- its own contract, not a branch of 8a. 8a is REACHABILITY (the step
|
|
426
|
+
// exists in the CI job and in release_gate); this is about what the release
|
|
427
|
+
// path ACCEPTS as evidence for one SHA. `verify-exact-ci.sh` read only three
|
|
428
|
+
// job names, and a job conclusion says nothing about whether the step inside
|
|
429
|
+
// it ran: the moment ci.yml can skip that step (#103 piece 2), a green oracle
|
|
430
|
+
// would certify a SHA whose qualification body never executed. That is the
|
|
431
|
+
// "green with no evidence" class this repo fails closed on, which is why the
|
|
432
|
+
// oracle gets the fourth axis BEFORE the skip exists.
|
|
433
|
+
//
|
|
434
|
+
// This is a TEXT oracle, the same grade as check-install-surface S7g. A
|
|
435
|
+
// behavioural oracle would need either a second file (S7a/S7g bind the
|
|
436
|
+
// release surface to one script read from the index) or an injectable
|
|
437
|
+
// RUN_JSON seam -- and that seam would be a way to launder release evidence
|
|
438
|
+
// past gh. Both cost more than the axis is worth here.
|
|
439
|
+
const ciOracle = readFileSync(join(REPO_DIR, ".claude/skills/entwurf-release/scripts/verify-exact-ci.sh"), "utf8");
|
|
440
|
+
const axis: string[] = [];
|
|
441
|
+
if (!ciOracle.includes('QUAL_JOB = "check"') || !ciOracle.includes('"Run ./run.sh check-gate-qualification"'))
|
|
442
|
+
axis.push("the oracle does not name the check job's check-gate-qualification step");
|
|
443
|
+
if (!ciOracle.includes('qual != "success"'))
|
|
444
|
+
axis.push("the oracle does not guard that step's conclusion against 'success'");
|
|
445
|
+
if (!ciOracle.includes('qual == "skipped"'))
|
|
446
|
+
axis.push("the oracle does not classify a SKIPPED body as a failure of its own");
|
|
447
|
+
assert.ok(
|
|
448
|
+
axis.length === 0,
|
|
449
|
+
"[QK:RELEASE-SHA-QUALIFIED-IN-CI] the exact-SHA CI oracle must require the qualification BODY to have run " +
|
|
450
|
+
"at the release SHA -- the `check` job's `Run ./run.sh check-gate-qualification` step concluding 'success', " +
|
|
451
|
+
"with skipped named as its own failure. Three green job names do not prove the step inside one of them " +
|
|
452
|
+
`executed. Broken: ${axis.join("; ")}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ===========================================================================
|
|
457
|
+
// 8d. The qualification filter COVERS every mutant subject (#103 piece 2)
|
|
458
|
+
//
|
|
459
|
+
// The body no longer runs on every branch push; a decision script reads the
|
|
460
|
+
// push range and answers. Its whole safety argument is that the path set is
|
|
461
|
+
// DERIVED from the committed manifests rather than copied into a list, so a
|
|
462
|
+
// new mutant subject cannot land outside the filter and quietly stop being
|
|
463
|
+
// re-proven in CI. This asserts that property behaviourally: every subject
|
|
464
|
+
// and signatureSource in scripts/mutants/*.json, fed to the script as a
|
|
465
|
+
// one-file change, must decide `run_body=true`. The oracle is the manifests
|
|
466
|
+
// themselves, read here independently of the script.
|
|
467
|
+
// ===========================================================================
|
|
468
|
+
{
|
|
469
|
+
const decider = join(REPO_DIR, "scripts/ci-qualify-decide.sh");
|
|
470
|
+
const paths = new Set<string>();
|
|
471
|
+
for (const file of globSync("scripts/mutants/*.json", { cwd: REPO_DIR })) {
|
|
472
|
+
const manifest = JSON.parse(readFileSync(join(REPO_DIR, file), "utf8")) as {
|
|
473
|
+
mutants?: { subject?: string; signatureSource?: string }[];
|
|
474
|
+
};
|
|
475
|
+
for (const mutant of manifest.mutants ?? []) {
|
|
476
|
+
if (mutant.subject) paths.add(mutant.subject);
|
|
477
|
+
if (mutant.signatureSource) paths.add(mutant.signatureSource);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
assert.ok(paths.size > 50, `read only ${paths.size} mutant paths from the manifests`);
|
|
481
|
+
const uncovered = [...paths].filter((path) => {
|
|
482
|
+
const out = execFileSync("bash", [decider, "--files-from", "-"], {
|
|
483
|
+
cwd: REPO_DIR,
|
|
484
|
+
encoding: "utf8",
|
|
485
|
+
input: `${path}\n`,
|
|
486
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
487
|
+
});
|
|
488
|
+
return out.trim() !== "run_body=true";
|
|
489
|
+
});
|
|
490
|
+
assert.ok(
|
|
491
|
+
uncovered.length === 0,
|
|
492
|
+
"[QK:QUALIFY-FILTER-COVERS-SUBJECTS] the CI qualification filter must run the body for a change to ANY " +
|
|
493
|
+
"committed mutant subject or signature source — it derives that set from scripts/mutants/*.json for " +
|
|
494
|
+
"exactly this reason, so a path it cannot see is a claim that silently stops being re-proven in CI. " +
|
|
495
|
+
`Uncovered (${uncovered.length} of ${paths.size}): ${uncovered.slice(0, 8).join(", ")}`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ===========================================================================
|
|
500
|
+
// 8e. The filter still runs the body for every RED the body has ever produced
|
|
501
|
+
//
|
|
502
|
+
// Five reds in 549 runs (#99 stage-2). The first reading of them used the
|
|
503
|
+
// tip COMMIT and concluded the filter would have missed four; replaying the
|
|
504
|
+
// real two-dot push range — what GitHub actually compares — showed all five
|
|
505
|
+
// hit. That reversal is the whole evidentiary basis for this filter.
|
|
506
|
+
//
|
|
507
|
+
// It is asserted from RECORDED file lists, not from live history, and that
|
|
508
|
+
// is a constraint rather than a convenience: check-gate-qualification runs
|
|
509
|
+
// every gate inside a snapshot with its own fresh git baseline, where these
|
|
510
|
+
// 2026-07/08 commits do not exist. A cell that read history there would be
|
|
511
|
+
// CONTROL-RED for the whole lane — measured, not predicted (this cell did
|
|
512
|
+
// exactly that on 2026-09-06 and cost the release-gate lane its 17 kills).
|
|
513
|
+
// The fixture carries what history said, measured once, in a repo where the
|
|
514
|
+
// objects are present.
|
|
515
|
+
//
|
|
516
|
+
// It has its own replant, and the reason is the SHAPE of the input: 8d feeds
|
|
517
|
+
// ONE path per call, this feeds a whole push at once. A matcher that only
|
|
518
|
+
// looked at the first entry would leave 8d green — every single-file call is
|
|
519
|
+
// its own first entry — while every one of these five pushes opens with a
|
|
520
|
+
// non-surface path (DELIVERY.md, AGENTS.md, NEXT.md, AGENTS.md,
|
|
521
|
+
// demo/demo-baseline.sh). Invisible to 8d, fatal here.
|
|
522
|
+
//
|
|
523
|
+
// The two-dot READING itself is proven separately, below.
|
|
524
|
+
// ===========================================================================
|
|
525
|
+
{
|
|
526
|
+
const decider = join(REPO_DIR, "scripts/ci-qualify-decide.sh");
|
|
527
|
+
const fixture = JSON.parse(readFileSync(join(REPO_DIR, "scripts/fixtures/qualify-replay.json"), "utf8")) as {
|
|
528
|
+
runs: { runId: string; before: string; head: string; files: string[]; tipOnlyFiles: string[] }[];
|
|
529
|
+
};
|
|
530
|
+
assert.equal(fixture.runs.length, 5, "the replay fixture holds all five historical qualification reds");
|
|
531
|
+
const wrong: string[] = [];
|
|
532
|
+
for (const run of fixture.runs) {
|
|
533
|
+
assert.ok(run.files.length > 0, `run ${run.runId} has no recorded push range`);
|
|
534
|
+
const out = execFileSync("bash", [decider, "--files-from", "-"], {
|
|
535
|
+
cwd: REPO_DIR,
|
|
536
|
+
encoding: "utf8",
|
|
537
|
+
input: `${run.files.join("\n")}\n`,
|
|
538
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
539
|
+
}).trim();
|
|
540
|
+
if (out !== "run_body=true") wrong.push(`run ${run.runId} (${run.files.length} files) decided ${out}`);
|
|
541
|
+
}
|
|
542
|
+
assert.ok(
|
|
543
|
+
wrong.length === 0,
|
|
544
|
+
"[QK:QUALIFY-FILTER-REPLAYS-PAST-CATCHES] every qualification RED in this repo's CI history must still " +
|
|
545
|
+
"run the body under the filter, over the two-dot push range GitHub compares — the measurement that " +
|
|
546
|
+
"overturned the tip-commit reading and justified filtering at all. This reads a whole push at once, " +
|
|
547
|
+
"which 8d cannot: each of these five opens with a non-surface path, so a matcher that stopped after " +
|
|
548
|
+
"the first entry would pass every single-file check 8d makes and fail here. " +
|
|
549
|
+
`Broken: ${wrong.join("; ")}`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ===========================================================================
|
|
554
|
+
// 8f. The filter reads the two-dot PUSH RANGE, not the tip commit
|
|
555
|
+
//
|
|
556
|
+
// The fixture above proves the matcher's verdict on recorded file lists; it
|
|
557
|
+
// cannot prove which git range produced them, because it calls no git. This
|
|
558
|
+
// does, hermetically: a throwaway repo shaped like the real defect — a push
|
|
559
|
+
// of two commits whose TIP is docs-only while the commit under it touched a
|
|
560
|
+
// mutant subject. The two-dot range sees the code; the tip alone does not.
|
|
561
|
+
// That is the exact misreading #99 corrected, and it is what four of the
|
|
562
|
+
// five recorded pushes look like when read the wrong way.
|
|
563
|
+
//
|
|
564
|
+
// Hermetic on purpose: no repo history, so it runs identically inside the
|
|
565
|
+
// qualification snapshot.
|
|
566
|
+
// ===========================================================================
|
|
567
|
+
{
|
|
568
|
+
const decider = join(REPO_DIR, "scripts/ci-qualify-decide.sh");
|
|
569
|
+
const tmp = mkdtempSync(join(tmpdir(), "entwurf-qualify-range-"));
|
|
570
|
+
// core.hooksPath is set globally on this operator's machine; a fixture repo
|
|
571
|
+
// must not run their hooks.
|
|
572
|
+
const git = (...args: string[]) =>
|
|
573
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "user.email=g@e", "-c", "user.name=g", ...args], {
|
|
574
|
+
cwd: tmp,
|
|
575
|
+
stdio: "ignore",
|
|
576
|
+
});
|
|
577
|
+
try {
|
|
578
|
+
git("init", "-q", "-b", "main");
|
|
579
|
+
// The decider reads the repo it SITS IN, so the fixture gets a real copy
|
|
580
|
+
// of it and its own one-entry manifest: no seam, no env override, and the
|
|
581
|
+
// manifest-reading path is exercised too.
|
|
582
|
+
mkdirSync(join(tmp, "scripts/mutants"), { recursive: true });
|
|
583
|
+
mkdirSync(join(tmp, "pi-extensions/lib"), { recursive: true });
|
|
584
|
+
copyFileSync(decider, join(tmp, "scripts/ci-qualify-decide.sh"));
|
|
585
|
+
writeFileSync(
|
|
586
|
+
join(tmp, "scripts/mutants/fixture.json"),
|
|
587
|
+
`${JSON.stringify(
|
|
588
|
+
{
|
|
589
|
+
schemaVersion: 1,
|
|
590
|
+
lane: "fixture",
|
|
591
|
+
mutants: [{ claim: "FIXTURE", subject: "pi-extensions/lib/fixture-subject.ts" }],
|
|
592
|
+
},
|
|
593
|
+
null,
|
|
594
|
+
"\t",
|
|
595
|
+
)}\n`,
|
|
596
|
+
);
|
|
597
|
+
writeFileSync(join(tmp, "seed.txt"), "seed\n");
|
|
598
|
+
git("add", "-A");
|
|
599
|
+
git("commit", "-qm", "seed");
|
|
600
|
+
const base = execFileSync("git", ["rev-parse", "HEAD"], { cwd: tmp, encoding: "utf8" }).trim();
|
|
601
|
+
writeFileSync(join(tmp, "pi-extensions/lib/fixture-subject.ts"), "// a mutant subject\n");
|
|
602
|
+
git("add", "-A");
|
|
603
|
+
git("commit", "-qm", "code commit (mid-push)");
|
|
604
|
+
writeFileSync(join(tmp, "README.md"), "docs only\n");
|
|
605
|
+
git("add", "-A");
|
|
606
|
+
git("commit", "-qm", "docs commit (tip)");
|
|
607
|
+
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: tmp, encoding: "utf8" }).trim();
|
|
608
|
+
const decide = (from: string) =>
|
|
609
|
+
execFileSync("bash", [join(tmp, "scripts/ci-qualify-decide.sh"), from, head], {
|
|
610
|
+
cwd: tmp,
|
|
611
|
+
encoding: "utf8",
|
|
612
|
+
env: { ...process.env, CI_EVENT_NAME: "push", CI_FORCED: "false" },
|
|
613
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
614
|
+
}).trim();
|
|
615
|
+
const overRange = decide(base);
|
|
616
|
+
const overTip = decide(`${head}~1`);
|
|
617
|
+
assert.ok(
|
|
618
|
+
overRange === "run_body=true" && overTip === "run_body=false",
|
|
619
|
+
"[QK:QUALIFY-FILTER-READS-PUSH-RANGE] the filter must diff the whole two-dot push range, not the tip " +
|
|
620
|
+
"commit: a push whose tip is docs-only can still carry a mutant subject underneath it, which is how " +
|
|
621
|
+
"four of the five historical reds look like docs pushes when read tip-first. " +
|
|
622
|
+
`Broken: range=${overRange}, tip-only=${overTip} (tip-only must be false, or this fixture proves nothing).`,
|
|
623
|
+
);
|
|
624
|
+
} finally {
|
|
625
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
626
|
+
}
|
|
415
627
|
}
|
|
416
628
|
|
|
417
629
|
// ===========================================================================
|
|
@@ -655,7 +867,10 @@ console.log(
|
|
|
655
867
|
"a run.sh wrapper declining its own prerequisite (including the measured LIVE=1 no-cortex-connection cell); and every " +
|
|
656
868
|
"LIVE smoke is either wired into release_gate or excluded by a sentence the docs still carry; and the moved " +
|
|
657
869
|
"check-gate-qualification stays reachable on its owners (absent from the default chain, exactly once in CI, " +
|
|
658
|
-
"exactly once as a release-gate MUST step) and the CI step qualifies the FULL floor, which runs before it
|
|
870
|
+
"exactly once as a release-gate MUST step) and the CI step qualifies the FULL floor, which runs before it, " +
|
|
871
|
+
"while the exact-SHA release oracle requires that BODY step to have concluded success at the release SHA, " +
|
|
872
|
+
"and the CI filter that decides when that body runs covers every mutant subject and still runs it for all " +
|
|
873
|
+
"five historical reds; and " +
|
|
659
874
|
"every gate a committed mutant names is itself inside check:full or states its exclusion in prose an operator " +
|
|
660
875
|
"reads; and the CI push trigger is filtered to branch refs, so a release tag creates no duplicate run; and " +
|
|
661
876
|
"the operator's CONFIGURED bridge invocation is booted exactly once through run_step, before the ACP LIVE tier",
|