@junghanacs/entwurf 0.18.0 → 0.18.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 +4 -4
- package/CHANGELOG.md +172 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +9 -5
- package/VERIFY.md +3 -3
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +46 -12
- package/docs/setup-clean-host.md +35 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/package.json +9 -9
- package/pi-extensions/entwurf-control.ts +12 -2
- 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 +3 -3
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/run.sh +125 -56
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-gate-qualification.ts +62 -12
- package/scripts/check-release-gate-outcomes.ts +160 -3
- package/scripts/mutants/bridge-command-boot.json +3 -3
- package/scripts/mutants/gate-qualification.json +28 -0
- package/scripts/mutants/pack-install.json +14 -3
- package/scripts/mutants/release-gate.json +22 -0
- package/scripts/raw-acp-compaction-measure/README.md +77 -0
- package/scripts/raw-acp-compaction-measure/probe.ts +196 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
|
@@ -25,7 +25,15 @@
|
|
|
25
25
|
// "internal prerequisite" case, Cortex being the one that was measured).
|
|
26
26
|
// 7. every LIVE smoke is either wired into the aggregate or excluded for a
|
|
27
27
|
// reason the docs actually state — the protocol cannot vouch for a step the
|
|
28
|
-
// gate never lists
|
|
28
|
+
// gate never lists;
|
|
29
|
+
// 8. the moved check-gate-qualification stays reachable on its owners, and the
|
|
30
|
+
// CI step qualifies the FULL floor;
|
|
31
|
+
// 9. every gate a committed mutant NAMES also runs inside `check:full` (or says
|
|
32
|
+
// in its own prose why it deliberately does not) — a gate reachable only
|
|
33
|
+
// through qualification's control-pre puts a whole defect class behind the
|
|
34
|
+
// 28-minute body;
|
|
35
|
+
// 10. the CI push trigger is filtered to BRANCH refs, so pushing a release tag
|
|
36
|
+
// does not rebuild a SHA its branch run already built.
|
|
29
37
|
//
|
|
30
38
|
// Cells 5-6 SPAWN the real subcommands rather than reasoning about them: the
|
|
31
39
|
// whole defect was an assumption about what a step would do, so an assumption is
|
|
@@ -329,7 +337,7 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
329
337
|
// check-gate-qualification left the default check chains (operator
|
|
330
338
|
// inner-loop cost, 2026-08 subtraction). That move is a gate/release
|
|
331
339
|
// contract: the step must stay REACHABLE on the axes that now own it — the
|
|
332
|
-
// CI check job on every push and release_gate as its own MUST step — and
|
|
340
|
+
// CI check job on every branch push and release_gate as its own MUST step — and
|
|
333
341
|
// must not silently return to the default chain. Without this cell,
|
|
334
342
|
// deleting the release_gate qualification block or the CI line leaves every
|
|
335
343
|
// focused gate green while a cut quietly loses its discriminating-power
|
|
@@ -374,7 +382,7 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
374
382
|
!qualGateBody.includes('results+=("FAIL check-gate-qualification")')
|
|
375
383
|
)
|
|
376
384
|
holes.push("the release_gate qualification step does not wire PASS/FAIL into the MUST counters");
|
|
377
|
-
if (!verifyDoc.includes("in the CI `check` job on every push, and as a release-gate MUST step"))
|
|
385
|
+
if (!verifyDoc.includes("in the CI `check` job on every branch push, and as a release-gate MUST step"))
|
|
378
386
|
holes.push("VERIFY.md no longer names the owners of the moved qualification step");
|
|
379
387
|
assert.ok(
|
|
380
388
|
holes.length === 0,
|
|
@@ -406,6 +414,153 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
406
414
|
);
|
|
407
415
|
}
|
|
408
416
|
|
|
417
|
+
// ===========================================================================
|
|
418
|
+
// 9. Every gate a mutant manifest NAMES runs inside `pnpm run check:full`
|
|
419
|
+
//
|
|
420
|
+
// The measurement behind this (#99 B-3/B-4): across 549 CI runs the mutant-
|
|
421
|
+
// EXECUTION half of qualification never once produced a SURVIVED / WRONG-REASON /
|
|
422
|
+
// MUTANT-STALE / HANG. What it did catch twice was a gate that was already red on a
|
|
423
|
+
// clean tree — CONTROL-PRE — and that class is caught 5.4 minutes earlier, and for
|
|
424
|
+
// free, by any gate the deterministic floor already runs. So a mutant-named gate
|
|
425
|
+
// that sits OUTSIDE `check:full` is the one place where a baseline break can only
|
|
426
|
+
// be found by paying the 28-minute body. `check-omp-birth-hook` was exactly that
|
|
427
|
+
// gate, reachable in the whole repo only through qualification's control-pre.
|
|
428
|
+
//
|
|
429
|
+
// The exclusion arm is not a loophole: `scripts/check-setup-qualification.sh` is a
|
|
430
|
+
// mutation-attribution oracle the manifests invoke DIRECTLY and its own header says
|
|
431
|
+
// it is deliberately outside every tier. Like cell 7, the excuse is checked against
|
|
432
|
+
// the text an operator can read, never against a list only this gate believes in.
|
|
433
|
+
//
|
|
434
|
+
// ONE PREMISE, stated because it is invisible from here: the script-path join below
|
|
435
|
+
// ignores MODE FLAGS in a gate argv. The `--attribution-self-test` gate therefore
|
|
436
|
+
// counts as covered by the `check-gate-manifests` arm only because
|
|
437
|
+
// `checkVitestAttribution()` runs UNCONDITIONALLY, before that flag is read
|
|
438
|
+
// (`scripts/check-gate-qualification.ts`). Move that call behind a flag and this
|
|
439
|
+
// cell stays green while saying something false.
|
|
440
|
+
// ===========================================================================
|
|
441
|
+
{
|
|
442
|
+
const pkg = JSON.parse(readFileSync(join(REPO_DIR, "package.json"), "utf8")) as { scripts: Record<string, string> };
|
|
443
|
+
// The floor is composed (#70): `check:full` names group scripts, and a group script
|
|
444
|
+
// may name further ones. Expand transitively rather than hard-coding the tier list.
|
|
445
|
+
const expand = (name: string, seen: Set<string>): string => {
|
|
446
|
+
if (seen.has(name)) return "";
|
|
447
|
+
seen.add(name);
|
|
448
|
+
const body = pkg.scripts[name] ?? "";
|
|
449
|
+
let out = body;
|
|
450
|
+
for (const ref of body.matchAll(/pnpm run ([\w:.-]+)/g)) out += ` ${expand(ref[1], seen)}`;
|
|
451
|
+
const elapsed = /check-elapsed\.sh\s+[\w:.-]+\s+(.+)$/.exec(body);
|
|
452
|
+
if (elapsed) for (const g of elapsed[1].trim().split(/\s+/)) out += ` ${expand(g, seen)}`;
|
|
453
|
+
return out;
|
|
454
|
+
};
|
|
455
|
+
const floorText = expand("check:full", new Set());
|
|
456
|
+
const floorSubcommands = new Set([...floorText.matchAll(/\.\/run\.sh ([\w:.-]+)/g)].map((m) => m[1]));
|
|
457
|
+
|
|
458
|
+
// run.sh case arms, so a gate invoked by SCRIPT PATH can be joined to the floor
|
|
459
|
+
// subcommand that runs that same script.
|
|
460
|
+
const arms = new Map<string, string>();
|
|
461
|
+
{
|
|
462
|
+
let names: string[] = [];
|
|
463
|
+
let body: string[] = [];
|
|
464
|
+
for (const line of readFileSync(join(REPO_DIR, "run.sh"), "utf8").split("\n")) {
|
|
465
|
+
const head = /^ {2}([\w|:.-]+)\)$/.exec(line);
|
|
466
|
+
if (head) {
|
|
467
|
+
names = head[1].split("|");
|
|
468
|
+
body = [];
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (line === " ;;") {
|
|
472
|
+
for (const n of names) arms.set(n, body.join("\n"));
|
|
473
|
+
names = [];
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (names.length > 0) body.push(line);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const gates = new Map<string, string[]>();
|
|
481
|
+
for (const rel of globSync("scripts/mutants/*.json", { cwd: REPO_DIR }).sort()) {
|
|
482
|
+
const doc = JSON.parse(readFileSync(join(REPO_DIR, rel), "utf8")) as { mutants: Array<{ gate: string[] }> };
|
|
483
|
+
for (const m of doc.mutants) gates.set(m.gate.join(" "), m.gate);
|
|
484
|
+
}
|
|
485
|
+
assert.ok(gates.size >= 40, `expected the committed mutant gate set, found ${gates.size} distinct gate argvs`);
|
|
486
|
+
|
|
487
|
+
// argv → the file that states, in prose an operator reads, why it is outside the floor.
|
|
488
|
+
const DOCUMENTED_OUTSIDE: Record<string, [file: string, sentence: string]> = {
|
|
489
|
+
"bash scripts/check-setup-qualification.sh": [
|
|
490
|
+
"scripts/check-setup-qualification.sh",
|
|
491
|
+
"run.sh subcommand and NOT in any check tier",
|
|
492
|
+
],
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const unreached: string[] = [];
|
|
496
|
+
for (const [key, argv] of gates) {
|
|
497
|
+
if (argv[0] === "bash" && argv[1] === "run.sh") {
|
|
498
|
+
if (!floorSubcommands.has(argv[2])) unreached.push(`${key} — run.sh ${argv[2]} is in no check:full group`);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const scriptPath = argv.find((t) => /^scripts\/.+\.(sh|ts|py)$/.test(t));
|
|
502
|
+
if (scriptPath !== undefined && [...floorSubcommands].some((sub) => (arms.get(sub) ?? "").includes(scriptPath))) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const excused = DOCUMENTED_OUTSIDE[key];
|
|
506
|
+
if (excused === undefined) {
|
|
507
|
+
unreached.push(`${key} — no check:full subcommand invokes ${scriptPath ?? "(no script path in its argv)"}`);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const [file, sentence] = excused;
|
|
511
|
+
if (!readFileSync(join(REPO_DIR, file), "utf8").includes(sentence)) {
|
|
512
|
+
unreached.push(`${key} claims a documented exclusion, but ${file} no longer says "${sentence}"`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
assert.ok(
|
|
516
|
+
unreached.length === 0,
|
|
517
|
+
"[QK:MUTANT-GATES-INSIDE-FULL-FLOOR] every gate a committed mutant names must also run inside `pnpm run " +
|
|
518
|
+
"check:full`, or state its exclusion where an operator reads it. A gate reachable ONLY through " +
|
|
519
|
+
"qualification's control-pre makes the 28-minute mutant body the only thing that can notice it going red on " +
|
|
520
|
+
"a clean tree — the one CI class that body has actually caught, and the one the 5.4-minute floor catches for " +
|
|
521
|
+
`free everywhere else. Unreached: ${unreached.join("; ")}`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ===========================================================================
|
|
526
|
+
// 10. A TAG push rebuilds nothing
|
|
527
|
+
//
|
|
528
|
+
// Measured over this repo's whole history (#99 B-1): 66 of 66 semver tag-push runs
|
|
529
|
+
// rebuilt a SHA some other run had already built, and no tag run ever reported a
|
|
530
|
+
// fact its branch run did not — the single non-green one failed at the same step as
|
|
531
|
+
// its main run, two seconds later. That is 28% of a release window's runner minutes
|
|
532
|
+
// buying a badge on a ref. The exact-SHA evidence a release quotes is the BRANCH
|
|
533
|
+
// run, which is also the run `entwurf-release` land mode reports.
|
|
534
|
+
//
|
|
535
|
+
// So `on.push` must carry a REF FILTER that selects branches. `branches:` alone is
|
|
536
|
+
// what makes a tag push create no run at all; a `tags:` key would put them back.
|
|
537
|
+
// ===========================================================================
|
|
538
|
+
{
|
|
539
|
+
const ciYml = readFileSync(join(REPO_DIR, ".github/workflows/ci.yml"), "utf8");
|
|
540
|
+
const onAt = ciYml.indexOf("\non:\n");
|
|
541
|
+
const envAt = ciYml.indexOf("\nenv:\n");
|
|
542
|
+
assert.ok(onAt !== -1 && envAt > onAt, "located the workflow `on:` block");
|
|
543
|
+
const onLines = ciYml.slice(onAt, envAt).split("\n");
|
|
544
|
+
const pushAt = onLines.findIndex((l) => /^ {2}push:\s*$/.test(l));
|
|
545
|
+
const nextKeyAt = onLines.findIndex((l, i) => i > pushAt && /^ {2}\S/.test(l));
|
|
546
|
+
const pushBody = onLines
|
|
547
|
+
.slice(pushAt + 1, nextKeyAt === -1 ? undefined : nextKeyAt)
|
|
548
|
+
.filter((l) => l.trim() !== "" && !/^\s*#/.test(l));
|
|
549
|
+
const gaps: string[] = [];
|
|
550
|
+
if (pushAt === -1) gaps.push("the `on:` block has no `push:` trigger");
|
|
551
|
+
if (!pushBody.some((l) => /^ {4}branches(-ignore)?:/.test(l)))
|
|
552
|
+
gaps.push(`the push trigger carries no branch ref filter (body: ${JSON.stringify(pushBody)})`);
|
|
553
|
+
if (pushBody.some((l) => /^ {4}tags(-ignore)?:/.test(l)))
|
|
554
|
+
gaps.push("the push trigger names tags — a tag push would create a second run for a SHA already built");
|
|
555
|
+
assert.ok(
|
|
556
|
+
gaps.length === 0,
|
|
557
|
+
"[QK:CI-TAG-PUSH-NOT-REBUILT] the CI `push` trigger must be filtered to BRANCH refs, so pushing a release tag " +
|
|
558
|
+
"creates no run: 66/66 semver tag runs in this repo's history rebuilt an already-built SHA and none ever " +
|
|
559
|
+
"reported a fact its branch run had not, while costing 28% of a release window's runner minutes. Dropping " +
|
|
560
|
+
`the filter does not add evidence, it duplicates it. Broken: ${gaps.join("; ")}`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
409
564
|
// ===========================================================================
|
|
410
565
|
// The integrated mux lifecycle is a MUST, and the focused fresh-call LIVE is not
|
|
411
566
|
//
|
|
@@ -501,5 +656,7 @@ console.log(
|
|
|
501
656
|
"LIVE smoke is either wired into release_gate or excluded by a sentence the docs still carry; and the moved " +
|
|
502
657
|
"check-gate-qualification stays reachable on its owners (absent from the default chain, exactly once in CI, " +
|
|
503
658
|
"exactly once as a release-gate MUST step) and the CI step qualifies the FULL floor, which runs before it; and " +
|
|
659
|
+
"every gate a committed mutant names is itself inside check:full or states its exclusion in prose an operator " +
|
|
660
|
+
"reads; and the CI push trigger is filtered to branch refs, so a release tag creates no duplicate run; and " +
|
|
504
661
|
"the operator's CONFIGURED bridge invocation is booted exactly once through run_step, before the ACP LIVE tier",
|
|
505
662
|
);
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"subject": "scripts/agy-bridge.sh",
|
|
20
20
|
"find": [" if command_boots \"$invocation\"; then"],
|
|
21
21
|
"replace": [" if true; then"],
|
|
22
|
-
"gate": ["bash", "
|
|
22
|
+
"gate": ["bash", "run.sh", "smoke-agy-install-state"],
|
|
23
23
|
"timeoutSeconds": 300,
|
|
24
24
|
"signature": "[QK:AGY-DOCTOR-BOOT-NEGATIVE]",
|
|
25
25
|
"signatureSource": "scripts/smoke-agy-install-state.sh"
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"subject": "scripts/agy-bridge-config.py",
|
|
44
44
|
"find": [" args = server.get(\"args\", [])"],
|
|
45
45
|
"replace": [" args = []"],
|
|
46
|
-
"gate": ["bash", "
|
|
46
|
+
"gate": ["bash", "run.sh", "smoke-agy-install-state"],
|
|
47
47
|
"timeoutSeconds": 300,
|
|
48
48
|
"signature": "[QK:AGY-DOCTOR-PROBES-ARGS]",
|
|
49
49
|
"signatureSource": "scripts/smoke-agy-install-state.sh"
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"subject": "scripts/agy-bridge-config.py",
|
|
55
55
|
"find": [" env = server.get(\"env\", {})"],
|
|
56
56
|
"replace": [" env = {}"],
|
|
57
|
-
"gate": ["bash", "
|
|
57
|
+
"gate": ["bash", "run.sh", "smoke-agy-install-state"],
|
|
58
58
|
"timeoutSeconds": 300,
|
|
59
59
|
"signature": "[QK:AGY-DOCTOR-PROBES-ENV]",
|
|
60
60
|
"signatureSource": "scripts/smoke-agy-install-state.sh"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"lane": "gate-qualification",
|
|
4
|
+
"mutants": [
|
|
5
|
+
{
|
|
6
|
+
"claim": "MANIFEST-SET-INTEGRITY-REFUSED",
|
|
7
|
+
"title": "a committed manifest points a claim at a gate source that does not carry its token — the cross-manifest contract that makes a KILLED verdict mean anything is broken, and the run must refuse rather than certify kills whose signature nothing can attribute (the 08-20 CI catch, run 32421631735, which died in validateManifestSet in 5s)",
|
|
8
|
+
"subject": "scripts/mutants/probe-ordering.json",
|
|
9
|
+
"find": ["\"signatureSource\": \"scripts/check-probe-ordering.ts\""],
|
|
10
|
+
"replace": ["\"signatureSource\": \"run.sh\""],
|
|
11
|
+
"gate": ["bash", "run.sh", "check-gate-manifests"],
|
|
12
|
+
"timeoutSeconds": 120,
|
|
13
|
+
"signature": "[QK:MANIFEST-SET-INTEGRITY-REFUSED]",
|
|
14
|
+
"signatureSource": "scripts/check-gate-qualification.ts"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"claim": "LANE-INVENTORY-DECLARED",
|
|
18
|
+
"title": "a lane is renamed in its manifest and the declared inventory is not moved with it — the vacuous-green shape where the run reports n/n killed over whatever set happened to be on disk (the 08-21 and 08-28 CI catches, runs 32450955048 and 33154263432, both dead at the inventory assertion in under 5s)",
|
|
19
|
+
"subject": "scripts/mutants/capability-cache.json",
|
|
20
|
+
"find": ["\"lane\": \"capability-cache\","],
|
|
21
|
+
"replace": ["\"lane\": \"capability-cache-renamed\","],
|
|
22
|
+
"gate": ["bash", "run.sh", "check-gate-manifests"],
|
|
23
|
+
"timeoutSeconds": 120,
|
|
24
|
+
"signature": "[QK:LANE-INVENTORY-DECLARED]",
|
|
25
|
+
"signatureSource": "scripts/check-gate-qualification.ts"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -4,14 +4,25 @@
|
|
|
4
4
|
"mutants": [
|
|
5
5
|
{
|
|
6
6
|
"claim": "PACK-INSTALL-PIN-MATCHER-BOUNDED",
|
|
7
|
-
"title": "the pin-leak matcher loses its version boundary and blesses substring lookalikes — @0.
|
|
7
|
+
"title": "the pin-leak matcher loses its version boundary and blesses substring lookalikes — @0.85.10 reads as the pinned 0.85.1 and the gate announces a verified tree it never verified (independent review, 2026-08-25)",
|
|
8
8
|
"subject": "run.sh",
|
|
9
|
-
"find": [" grep '^@earendil-works+
|
|
10
|
-
"replace": [" grep '^@earendil-works+
|
|
9
|
+
"find": [" grep '^@earendil-works+' | grep -Ev '@0\\.85\\.1(_|$)' || true"],
|
|
10
|
+
"replace": [" grep '^@earendil-works+' | grep -v '@0\\.85\\.1' || true"],
|
|
11
11
|
"gate": ["bash", "run.sh", "check-pack-pin-matcher"],
|
|
12
12
|
"timeoutSeconds": 120,
|
|
13
13
|
"signature": "[QK:PACK-INSTALL-PIN-MATCHER-BOUNDED]",
|
|
14
14
|
"signatureSource": "run.sh"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"claim": "PACK-INSTALL-PIN-MATCHER-COVERS-CLOSURE",
|
|
18
|
+
"title": "the pin-leak matcher narrows back to the pi-* families and goes blind to a non-pi member of the same runtime closure — @earendil-works/chord (new in pi 0.85.0, a runtime dependency of pi-coding-agent/pi-agent-core/pi-client/pi-protocol) floats unpinned while the gate prints a verified pin (measured 2026-09-06 on a real 0.85.1 install tree)",
|
|
19
|
+
"subject": "run.sh",
|
|
20
|
+
"find": [" grep '^@earendil-works+' | grep -Ev '@0\\.85\\.1(_|$)' || true"],
|
|
21
|
+
"replace": [" grep '^@earendil-works+pi-' | grep -Ev '@0\\.85\\.1(_|$)' || true"],
|
|
22
|
+
"gate": ["bash", "run.sh", "check-pack-pin-matcher"],
|
|
23
|
+
"timeoutSeconds": 120,
|
|
24
|
+
"signature": "[QK:PACK-INSTALL-PIN-MATCHER-COVERS-CLOSURE]",
|
|
25
|
+
"signatureSource": "run.sh"
|
|
15
26
|
}
|
|
16
27
|
]
|
|
17
28
|
}
|
|
@@ -136,6 +136,28 @@
|
|
|
136
136
|
"signature": "[QK:MUX-LIFECYCLE-IS-RELEASE-MUST]",
|
|
137
137
|
"signatureSource": "scripts/check-release-gate-outcomes.ts"
|
|
138
138
|
},
|
|
139
|
+
{
|
|
140
|
+
"claim": "MUTANT-GATES-INSIDE-FULL-FLOOR",
|
|
141
|
+
"title": "a gate a committed mutant names drops out of check:full — the #99 B-4 shape, where check-omp-birth-hook was reachable in the whole repo only through qualification's control-pre, so the one CI class that half has ever caught (a gate already red on a clean tree) could be found only by paying the 28-minute mutant body",
|
|
142
|
+
"subject": "package.json",
|
|
143
|
+
"find": [" && ./run.sh check-omp-birth-hook"],
|
|
144
|
+
"replace": [""],
|
|
145
|
+
"gate": ["bash", "run.sh", "check-release-gate-outcomes"],
|
|
146
|
+
"timeoutSeconds": 180,
|
|
147
|
+
"signature": "[QK:MUTANT-GATES-INSIDE-FULL-FLOOR]",
|
|
148
|
+
"signatureSource": "scripts/check-release-gate-outcomes.ts"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"claim": "CI-TAG-PUSH-NOT-REBUILT",
|
|
152
|
+
"title": "the CI push trigger loses its branch ref filter, so every release tag rebuilds a SHA its branch run already built — 66/66 historically, 28% of a release window's runner minutes, and not once a fact the branch run had not already reported",
|
|
153
|
+
"subject": ".github/workflows/ci.yml",
|
|
154
|
+
"find": [" branches: ['**']"],
|
|
155
|
+
"replace": [" # the branch ref filter was dropped, so a tag push rebuilds the SHA again"],
|
|
156
|
+
"gate": ["bash", "run.sh", "check-release-gate-outcomes"],
|
|
157
|
+
"timeoutSeconds": 180,
|
|
158
|
+
"signature": "[QK:CI-TAG-PUSH-NOT-REBUILT]",
|
|
159
|
+
"signatureSource": "scripts/check-release-gate-outcomes.ts"
|
|
160
|
+
},
|
|
139
161
|
{
|
|
140
162
|
"claim": "PI-DOCTOR-IS-RELEASE-MUST",
|
|
141
163
|
"title": "the pi-provider boot doctor is quietly dropped from the gate — the operator's CONFIGURED bridge invocation is never booted, so a 127 launcher (the #81 relocated cmd-shim class) stays invisible until smoke-acp-bundled-mcp-live sixteen LIVE steps and real model spend later",
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# raw-acp-compaction-measure — what a compacting turn looks like after claude-agent-acp 0.75.0
|
|
2
|
+
|
|
3
|
+
Measurement receipts for the ONE change in the `claude-agent-acp 0.73.0 → 0.75.1` bump that
|
|
4
|
+
reaches entwurf. Not a gate; nothing here runs in any check tier.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
LIVE=1 node --experimental-strip-types scripts/raw-acp-compaction-measure/probe.ts
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## The change
|
|
11
|
+
|
|
12
|
+
`0.75.0` (#991, `f74a517`, "surface context compaction as an ACP tool lifecycle") stopped
|
|
13
|
+
sending compaction as assistant text and started sending it as a **synthetic tool call**.
|
|
14
|
+
|
|
15
|
+
`[읽음, ~/repos/3rd/claude-agent-acp]` at `v0.73.0 src/acp-agent.ts:3437-3457` a compaction
|
|
16
|
+
result produced two `agent_message_chunk` texts — `"\n\nCompacting completed."` and
|
|
17
|
+
`"Compacting failed<reason>"`. At `v0.75.1` those are gone; `ContextCompactionLifecycle`
|
|
18
|
+
(`git grep -c` in `src/`: **0** at v0.73.0 and v0.74.0, **2** at v0.75.0 and v0.75.1) drives
|
|
19
|
+
`tool_call` / `tool_call_update` notifications instead
|
|
20
|
+
(`dist/acp-agent.js:31`, `:1877`, `:2711-2742`).
|
|
21
|
+
|
|
22
|
+
**What did NOT change:** the post-compaction occupancy refresh. `v0.73.0
|
|
23
|
+
src/acp-agent.ts:3460+` already emitted a `usage_update` at `compact_boundary`, and 0.75.1
|
|
24
|
+
still does (`dist/acp-agent.js:2740-2752`) — only its source moved, from a
|
|
25
|
+
`getContextUsage` control request to `compact_metadata.post_tokens`. So the
|
|
26
|
+
`used_end`-shrinks-mid-turn case that `backend.ts:1286-1288` already names as #96's weak
|
|
27
|
+
floor is **not newly triggered by this bump**.
|
|
28
|
+
|
|
29
|
+
## M1 — one live `/compact` turn, and the same notifications replayed through our mapper
|
|
30
|
+
|
|
31
|
+
`[측정 2026-09-06, oracle, claude-agent-acp 0.75.1, model claude-sonnet-5]`
|
|
32
|
+
`compact stopReason=end_turn`, 2 tool-lifecycle notifications. Verbatim from the probe:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
tool_call kind=think status=in_progress title="Compact conversation"
|
|
36
|
+
meta={"contextCompaction":{"version":1},"claudeCode":{"toolName":"compact"}}
|
|
37
|
+
tool_call_update kind=- status=failed title=null
|
|
38
|
+
meta={"contextCompaction":{"version":1,"error":"Not enough messages to compact."},
|
|
39
|
+
"claudeCode":{"toolName":"compact"}}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Those exact objects, replayed through the PRODUCTION `applyAcpSessionUpdate`
|
|
43
|
+
(`pi-extensions/lib/acp/event-mapper.ts`) — the same function `backend.ts` calls — produce:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
[{"type":"text","text":"\n[tool:start] Compact conversation\n"},
|
|
47
|
+
{"type":"text","text":"\n[tool:failed] Compact conversation — Compaction failed: Not enough messages to compact.\n"}]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### What this establishes
|
|
51
|
+
|
|
52
|
+
1. **No type or contract break.** `renderToolUpdate` (`event-mapper.ts:236-270`) routes every
|
|
53
|
+
`tool_call`/`tool_call_update` regardless of `kind`, so a `kind: "think"` synthetic call
|
|
54
|
+
needs no new branch. The turn ends `end_turn`, not an error.
|
|
55
|
+
2. **`_meta.contextCompaction` reaches nothing.** It is carried on the notification and
|
|
56
|
+
dropped by the mapper, which reads only `toolCallId` / `title` / `status` / `content` /
|
|
57
|
+
`rawOutput`. No accounting path sees it — `usage.acp`, `_meta.quota` and the four pi
|
|
58
|
+
fields are untouched by this lifecycle.
|
|
59
|
+
3. **What an operator now SEES changed.** Where 0.73.0 put `Compacting completed.` in the
|
|
60
|
+
assistant's own text, 0.75.1 puts a `[tool:start]` / `[tool:failed]` notice pair in the
|
|
61
|
+
transcript — that pair is what was OBSERVED. On a compaction that succeeds the second
|
|
62
|
+
notice is `[tool:done]`, which is inferred from the mapper's own status branch
|
|
63
|
+
(`event-mapper.ts:262-266`) and not observed here; see the limits below. Either way,
|
|
64
|
+
that notice pair is the whole reachable delta of this bump.
|
|
65
|
+
|
|
66
|
+
### What this does NOT establish (measured limits, stated rather than rounded off)
|
|
67
|
+
|
|
68
|
+
- **The `completed` branch was not observed.** The seeded session was too short, so the
|
|
69
|
+
vendor answered `"Not enough messages to compact."` and the lifecycle terminated at
|
|
70
|
+
`status: "failed"`. The success path — `status: "completed"` plus
|
|
71
|
+
`_meta.contextCompaction` carrying `trigger` / `preTokens` / `postTokens` / `durationMs`
|
|
72
|
+
(`f74a517` commit message; `contextCompactionMetadataFromBoundary`) — is **inferred from
|
|
73
|
+
source, not measured here.** In the mapper it is the same `status`-transition branch that
|
|
74
|
+
produced `[tool:failed]` above, one literal apart (`[tool:done]`,
|
|
75
|
+
`event-mapper.ts:262-266`).
|
|
76
|
+
- **No organic (auto-trigger) compaction was driven.** Only the explicit `/compact` command.
|
|
77
|
+
- One host, one model, one run.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// RAW MEASUREMENT PROBE — not a gate, not in any check tier.
|
|
2
|
+
//
|
|
3
|
+
// LIVE=1 node --experimental-strip-types scripts/raw-acp-compaction-measure/probe.ts
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. claude-agent-acp 0.75.0 (#991, `f74a517`) stopped surfacing Claude's
|
|
6
|
+
// context compaction as assistant text and started surfacing it as a SYNTHETIC ACP tool
|
|
7
|
+
// lifecycle: a `tool_call` with `kind: "think"`, title `Compact conversation`, and
|
|
8
|
+
// `_meta.contextCompaction` schema v1. Reading the dist says that much
|
|
9
|
+
// (`dist/acp-agent.js:31`, `:1877`, `:2711-2742`); it does not say what an entwurf
|
|
10
|
+
// operator actually SEES, because that depends on our own `applyAcpSessionUpdate`.
|
|
11
|
+
//
|
|
12
|
+
// So this probe drives one real `/compact` turn on the pinned adapter, captures the
|
|
13
|
+
// tool-lifecycle notifications verbatim, and then replays those exact notifications
|
|
14
|
+
// through the PRODUCTION event mapper — the same function backend.ts calls — and prints
|
|
15
|
+
// the pi-side transcript fragment they produce. The point is the join: vendor wire on one
|
|
16
|
+
// side, our rendered notice on the other, in one receipt.
|
|
17
|
+
//
|
|
18
|
+
// It asserts nothing and blocks nothing. The receipt goes in README.md next to it.
|
|
19
|
+
|
|
20
|
+
import { type ChildProcessByStdio, spawn } from "node:child_process";
|
|
21
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { Readable, Writable } from "node:stream";
|
|
26
|
+
import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
|
27
|
+
import { connectAcpClient } from "../../pi-extensions/lib/acp/acp-client.ts";
|
|
28
|
+
import { applyAcpSessionUpdate, createAcpStreamState } from "../../pi-extensions/lib/acp/event-mapper.ts";
|
|
29
|
+
import { terminateChild } from "../lib/acp-child-cleanup.ts";
|
|
30
|
+
|
|
31
|
+
const MODEL_ID = process.env.ENTWURF_ACP_RAW_TURN_MODEL ?? "claude-sonnet-5";
|
|
32
|
+
const OUT = process.env.RAW_COMPACTION_OUT ?? join(tmpdir(), "raw-acp-compaction.json");
|
|
33
|
+
|
|
34
|
+
if (process.env.LIVE !== "1") {
|
|
35
|
+
console.error("[raw-acp-compaction] set LIVE=1 to spend a real turn.");
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
|
|
40
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
41
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
42
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
43
|
+
});
|
|
44
|
+
return Promise.race([p, timeout]).finally(() => {
|
|
45
|
+
if (timer) clearTimeout(timer);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const require = createRequire(import.meta.url);
|
|
50
|
+
const bin = require.resolve("@agentclientprotocol/claude-agent-acp/dist/index.js");
|
|
51
|
+
|
|
52
|
+
const scratch = await mkdtemp(join(tmpdir(), "raw-acp-compaction-"));
|
|
53
|
+
const child: ChildProcessByStdio<Writable, Readable, Readable> = spawn(process.execPath, [bin], {
|
|
54
|
+
cwd: scratch,
|
|
55
|
+
env: { ...process.env },
|
|
56
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
57
|
+
}) as ChildProcessByStdio<Writable, Readable, Readable>;
|
|
58
|
+
|
|
59
|
+
const stderrTail: string[] = [];
|
|
60
|
+
child.stderr.on("data", (c) => {
|
|
61
|
+
stderrTail.push(c.toString());
|
|
62
|
+
if (stderrTail.length > 200) stderrTail.shift();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const stream = ndJsonStream(
|
|
66
|
+
Writable.toWeb(child.stdin) as unknown as WritableStream<Uint8Array>,
|
|
67
|
+
Readable.toWeb(child.stdout) as unknown as ReadableStream<Uint8Array>,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Every tool-lifecycle notification, verbatim, in arrival order.
|
|
71
|
+
const toolUpdates: Record<string, unknown>[] = [];
|
|
72
|
+
let agentText = "";
|
|
73
|
+
|
|
74
|
+
const connection = connectAcpClient(
|
|
75
|
+
stream as never,
|
|
76
|
+
{
|
|
77
|
+
sessionUpdate: async (notification: { update?: Record<string, unknown> }) => {
|
|
78
|
+
const u = notification?.update;
|
|
79
|
+
if (!u) return;
|
|
80
|
+
if (u.sessionUpdate === "tool_call" || u.sessionUpdate === "tool_call_update") {
|
|
81
|
+
toolUpdates.push(structuredClone(u));
|
|
82
|
+
}
|
|
83
|
+
if (u.sessionUpdate === "agent_message_chunk") {
|
|
84
|
+
const t = (u.content as { text?: unknown } | undefined)?.text;
|
|
85
|
+
if (typeof t === "string") agentText += t;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
|
89
|
+
readTextFile: async () => {
|
|
90
|
+
throw new Error("unexpected readTextFile");
|
|
91
|
+
},
|
|
92
|
+
writeTextFile: async () => {
|
|
93
|
+
throw new Error("unexpected writeTextFile");
|
|
94
|
+
},
|
|
95
|
+
} as never,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
let failure: Error | null = null;
|
|
99
|
+
try {
|
|
100
|
+
await withTimeout(
|
|
101
|
+
"initialize",
|
|
102
|
+
connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } as never),
|
|
103
|
+
30_000,
|
|
104
|
+
);
|
|
105
|
+
const session = (await withTimeout(
|
|
106
|
+
"newSession",
|
|
107
|
+
connection.newSession({ cwd: scratch, mcpServers: [] } as never),
|
|
108
|
+
60_000,
|
|
109
|
+
)) as { sessionId?: string };
|
|
110
|
+
const sessionId = session?.sessionId;
|
|
111
|
+
if (!sessionId) throw new Error("newSession returned no sessionId");
|
|
112
|
+
|
|
113
|
+
const setConfig = (connection as unknown as { setSessionConfigOption?: unknown }).setSessionConfigOption;
|
|
114
|
+
if (typeof setConfig === "function") {
|
|
115
|
+
await withTimeout(
|
|
116
|
+
"setSessionConfigOption",
|
|
117
|
+
(setConfig as (a: unknown) => Promise<unknown>).call(connection, {
|
|
118
|
+
sessionId,
|
|
119
|
+
configId: "model",
|
|
120
|
+
value: MODEL_ID,
|
|
121
|
+
}),
|
|
122
|
+
30_000,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// One tiny turn so the transcript has something to compact.
|
|
127
|
+
await withTimeout(
|
|
128
|
+
"seed prompt",
|
|
129
|
+
connection.prompt({
|
|
130
|
+
sessionId,
|
|
131
|
+
prompt: [{ type: "text", text: "Reply with exactly SEED and nothing else." }],
|
|
132
|
+
} as never),
|
|
133
|
+
180_000,
|
|
134
|
+
);
|
|
135
|
+
const seedText = agentText;
|
|
136
|
+
agentText = "";
|
|
137
|
+
const beforeCompact = toolUpdates.length;
|
|
138
|
+
|
|
139
|
+
// The compaction turn. `/compact` is the vendor's own trigger for the lifecycle
|
|
140
|
+
// under measurement (`dist/acp-agent.js:2054`, `:2103`).
|
|
141
|
+
const compactResult = (await withTimeout(
|
|
142
|
+
"compact prompt",
|
|
143
|
+
connection.prompt({ sessionId, prompt: [{ type: "text", text: "/compact" }] } as never),
|
|
144
|
+
300_000,
|
|
145
|
+
)) as { stopReason?: string };
|
|
146
|
+
|
|
147
|
+
const compactionUpdates = toolUpdates.slice(beforeCompact);
|
|
148
|
+
|
|
149
|
+
// Replay the captured notifications through the PRODUCTION mapper.
|
|
150
|
+
const notices: string[] = [];
|
|
151
|
+
const state = createAcpStreamState(
|
|
152
|
+
{ push: (ev: unknown) => notices.push(JSON.stringify(ev)) } as never,
|
|
153
|
+
{ api: "entwurf", provider: "entwurf", model: MODEL_ID } as never,
|
|
154
|
+
{ timestamp: 0 },
|
|
155
|
+
);
|
|
156
|
+
for (const u of compactionUpdates) applyAcpSessionUpdate(state, u);
|
|
157
|
+
|
|
158
|
+
const receipt = {
|
|
159
|
+
measuredAt: new Date().toISOString(),
|
|
160
|
+
adapter: JSON.parse(
|
|
161
|
+
(await import("node:fs")).readFileSync(
|
|
162
|
+
require.resolve("@agentclientprotocol/claude-agent-acp/package.json"),
|
|
163
|
+
"utf8",
|
|
164
|
+
),
|
|
165
|
+
).version,
|
|
166
|
+
model: MODEL_ID,
|
|
167
|
+
seedText: seedText.trim().slice(0, 80),
|
|
168
|
+
compactStopReason: compactResult?.stopReason,
|
|
169
|
+
compactionToolUpdates: compactionUpdates,
|
|
170
|
+
mappedContent: state.output.content,
|
|
171
|
+
mappedStreamEvents: notices,
|
|
172
|
+
};
|
|
173
|
+
await writeFile(OUT, `${JSON.stringify(receipt, null, "\t")}\n`, "utf8");
|
|
174
|
+
console.log(`[raw-acp-compaction] receipt -> ${OUT}`);
|
|
175
|
+
console.log(` adapter: ${receipt.adapter}`);
|
|
176
|
+
console.log(` compact stopReason: ${receipt.compactStopReason}`);
|
|
177
|
+
console.log(` tool updates: ${compactionUpdates.length}`);
|
|
178
|
+
for (const u of compactionUpdates) {
|
|
179
|
+
console.log(
|
|
180
|
+
` ${String(u.sessionUpdate)} kind=${String(u.kind ?? "-")} status=${String(u.status ?? "-")} title=${JSON.stringify(u.title ?? null)} meta=${JSON.stringify(u._meta ?? null)}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
console.log(` pi-side content blocks: ${JSON.stringify(state.output.content)}`);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
failure = err instanceof Error ? err : new Error(String(err));
|
|
186
|
+
console.error(`[raw-acp-compaction] stderr tail:\n${stderrTail.slice(-20).join("")}`);
|
|
187
|
+
} finally {
|
|
188
|
+
connection.close?.();
|
|
189
|
+
await terminateChild(child);
|
|
190
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (failure) {
|
|
194
|
+
console.error(`[raw-acp-compaction] FAILED: ${failure.message}`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// LIVE=1 ./run.sh smoke-acp-raw-turn-live
|
|
4
4
|
//
|
|
5
5
|
// What this proves (and ONLY this): the pinned Claude ACP adapter
|
|
6
|
-
// (@agentclientprotocol/claude-agent-acp@0.
|
|
6
|
+
// (@agentclientprotocol/claude-agent-acp@0.75.1) spawns, speaks the ACP wire
|
|
7
7
|
// protocol over stdio NDJSON, and returns one real model turn. It is the
|
|
8
8
|
// bytes-flow proof that the S2a dep surface is not just installable but
|
|
9
9
|
// actually drivable — before any provider/overlay/streamSimple code (S2b+).
|