Skip to content

Commit eb3d9ae

Browse files
chore(PR-7320): port random discard cost to current main
Resolve maintainer-caused test-census conflict, correct current review evidence, and retain only the reachable replacement-delivery continuation. Co-authored-by: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com>
2 parents e6cb558 + 4923b4e commit eb3d9ae

167 files changed

Lines changed: 6448 additions & 476 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/engine-implementer/SKILL.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Spawn a `general-purpose` agent and instruct it to invoke `/engine-planner`. The
7070

7171
**Spawn inputs:** task description; in-scope file/subsystem hints; any prior reviewer findings (none on first round).
7272

73-
Do not author or edit the plan in this thread. If the returned plan is missing sections or is superficial, send the same inputs plus an explicit "missing sections" note to a **fresh** planning agent — do not patch it yourself.
73+
Do not author or edit the plan in this thread — surgical-fix mode (below) is the one exception, and only under its three measured conditions. If the returned plan is missing sections or is superficial, send the same inputs plus an explicit "missing sections" note to a **fresh** planning agent — do not patch it yourself.
7474

7575
### Step 2 — Review the plan until clean (unbounded loop)
7676

@@ -88,6 +88,32 @@ If the reviewer returns gaps, spawn a **fresh** planning agent (Step 1 inputs pl
8888

8989
Each review must run in a fresh agent context — never reuse the previous reviewer's context.
9090

91+
#### Surgical-fix mode — when the design is settled and the findings are spot drift
92+
93+
The loop above assumes findings move the **design**. Once they stop doing that, re-running it makes the artifact worse: a fresh planner rewrites prose to absorb each finding, prose is where spot findings live, so every round manufactures the next round's findings.
94+
95+
**When all three hold, switch modes** — measure them, do not judge them:
96+
97+
1. The design is unchanged for ≥2 consecutive rounds (compare the named entries themselves — which steps, sub-steps, enum variants, and call sites each round names, because a 1:1 substitution holds every count constant; **not** a count and **not** line count; an in-place rewrite that preserves every name survives this comparison and is caught only by the whole-artifact re-review below).
98+
2. The last round's findings are all **spot** — a stale number, a stale coordinate, a claim contradicted by a neighbouring section, a missing restatement of a control the plan already specifies, a sentence never swept. None changes what the implementation does.
99+
3. Each finding names a coordinate **and** its replacement text. If any finding requires *deciding* something, it is a design finding: stay in the loop.
100+
101+
**Do not add a fourth condition based on falling churn.** Round-over-round churn shrinks while a loop turns unproductive: smaller repairs to a growing record. It measures edit size, not convergence, and gating on it blocks the switch precisely when the switch is warranted.
102+
103+
**The corroborating signal, if you want one, is the fraction of a round's findings whose defect originated in the *previous* round's repairs.** It climbs as the loop starts feeding on itself, but not monotonically — so treat a high fraction as evidence for the switch, never as the trigger.
104+
105+
**In surgical-fix mode the orchestrator applies the findings itself**, as check-and-replace edits — the one narrow exception to "the orchestrator never authors content." It is *applying* adjudicated text, not authoring; the moment a fix needs a decision, dispatch a planner instead. Requirements:
106+
107+
- **Two-sided verification per edit:** before the edit, the quoted old string is present at the finding's named coordinate — a quote that is not there is a stale coordinate, not an applicable fix; after the edit, the text the replacement adds is present exactly once and sits where the old string was, and the old string is absent — except that when the replacement contains the old string, that string survives by construction and the added text is the sole gate; count occurrences, not matching lines, 1:1 per fragment, not a lucky aggregate.
108+
- **State the sweep's boundary.** A changelog entry that quotes the struck text will match your own grep for it. Population, predicate, scan direction, and whether the matched line counts — write them down; every enumeration defect is an unstated predicate rather than a bad measurement.
109+
- **Fix the neighbours the fix breaks.** A finding's repair frequently contradicts a section that classified the old form. Sweep by mechanism, not by coordinate.
110+
- **Then re-review the WHOLE artifact**, fresh context — not just the repaired sections, per `$bug-triage`'s targeted-re-review rule. Repeat apply → whole-artifact re-review until a round returns zero gaps; any finding that requires *deciding* something ends surgical mode and returns to the unbounded loop above. Surgical mode replaces the planner-rewrite rounds, never the final independent check.
111+
- **Record the mode switch, its three measurements, the spot-vs-design classification of each round's findings, each attempted edit's two-sided verification result (pass or fail) and sweep boundary, and why the mode ends** in `<git-common-dir>/engine-implementer-runs/<run-id>/surgical-mode-switch`, never in the plan text the fresh re-reviewer and the executor read — recording it there hands the one remaining independent check a prior verdict. It is a process record of this loop, not provenance: it carries no candidate identity and duplicates no receipt field, so the canonical-receipt rule above does not reach it. Append one numbered entry per round, never overwrite: ending surgical mode and re-entering it later continues the same numbered sequence, and clobbering an earlier entry loses the exit that entry recorded. Every exit is then auditable rather than asserted. Each entry carries that round's classification, edit results, and sweep boundaries; only a round that enters the mode records the switch and its three measurements, and only a round that ends the mode records why.
112+
113+
**This does not contradict `$bug-triage`'s fixpoint gate.** That gate requires whole-plan re-review because *"revisions routinely INTRODUCE new gaps in untouched-looking areas"* — planner **rewrites** do. A check-and-replace at a named coordinate does not rewrite, which is why it is the safe tool once the design has stopped moving. `$review-engine-plan` ends its loop with *"or the caller stops the process"* and states no criteria; this section is those criteria, and it lives here because the orchestrator is that caller.
114+
115+
This is not a licence for "two rounds and ship". The unbounded loop remains the default and the burden of proof is on leaving it: no measurement, no switch. Surgical mode is scoped to this Step 2 plan-review loop only — Step 6's implementation-review loop never uses it, because there the artifact is a committed candidate that only an executor may edit under the frozen-scope and receipt contract.
116+
91117
### Step 3 — Dispatch implementation
92118

93119
Spawn the `engine-implementation-executor` agent.
@@ -129,7 +155,7 @@ Every round keeps the original `BASE_SHA` and frozen scope paths. Every checkpoi
129155

130156
### Step 7 — Final acceptance
131157

132-
Accept only when the plan-review loop is clean, the reviewer emitted a matching external receipt SHA256, final acceptance independently revalidates the receipt and every recorded artifact, parser evidence and completion checks pass, and the fresh implementation review returns zero findings. The receipt validation/hash are acceptance evidence, never completion-check rows or receipt artifacts. Immediately run `git -C "$IMPLEMENTATION_WORKTREE" rev-parse HEAD` and compare its output to `CANDIDATE_SHA`; if it differs, the review is stale and the current head must repeat the checkpoint-to-review sequence. Do not treat review of an ancestor as review of current work.
158+
Accept only when the plan-review loop is clean, the reviewer emitted a matching external receipt SHA256, final acceptance independently revalidates the receipt and every recorded artifact, parser evidence and completion checks pass, every surgical-fix mode round has the complete numbered entry Step 2 requires, and the fresh implementation review returns zero findings. The receipt validation/hash and those entries are acceptance evidence, never completion-check rows or receipt artifacts. Immediately run `git -C "$IMPLEMENTATION_WORKTREE" rev-parse HEAD` and compare its output to `CANDIDATE_SHA`; if it differs, the review is stale and the current head must repeat the checkpoint-to-review sequence. Do not treat review of an ancestor as review of current work.
133159

134160
### Post-acceptance PR handoff (non-gating)
135161

@@ -152,7 +178,7 @@ If later work invalidates the approved plan or architecture, return to plan revi
152178

153179
Return after final acceptance:
154180

155-
1. Plan-review rounds (count) and final clean result.
181+
1. Plan-review rounds (count), whether surgical-fix mode was used, and final clean result.
156182
2. What changed, grouped by subsystem and file.
157183
3. Key architectural decisions.
158184
4. `BASE_SHA`, accepted `CANDIDATE_SHA`, frozen scope paths, and run-artifact root.

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ client/public/parser-warning-patterns.json
5656
# Atomic-write staging files from gen-card-data.sh (cleaned on normal exit,
5757
# but may linger after SIGKILL / power loss).
5858
client/public/*.tmp
59+
# The same, for callers that stage through mktemp and so carry a random tail
60+
# after `.tmp` (gen-scryfall-token-images.sh). The template cannot be
61+
# `.XXXXXX.tmp` instead: BSD/macOS mktemp rejects anything after the X run.
62+
client/public/*.tmp.*
63+
# scryfall_finalize_download stages its rename diagnostic beside the target;
64+
# gen-scryfall-token-images.sh is the first caller to aim it at a tracked dir.
65+
client/public/*.mv-error.*
5966

6067
# Coverage
6168
client/coverage/

Tiltfile

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@ enabled = config.parse().get('enable', [])
2222
# restarts the watching resources mid-build.
2323
TMP_IGNORE = ['**/*.tmp.*']
2424

25+
# probe-pin isolates through `unshare --map-root-user --mount` (util-linux) and runs its target
26+
# under `timeout` (GNU coreutils). macOS ships neither, and there is no Darwin equivalent of an
27+
# unprivileged mount namespace to port to — so both probe-pin resources abort on a Darwin host
28+
# no matter what the tree contains. Gate their auto_init rather than let them boot straight into
29+
# a permanent red: a gate that is red on every change teaches everyone to stop reading the
30+
# colour, which costs more than the gate earns. They stay VISIBLE and clickable, like every
31+
# other opt-in resource here, so the refusal is still one click away when someone wants to see
32+
# it. `os.name` is consulted FIRST and short-circuits, so a Windows host never reaches `uname`
33+
# — which it does not ship, and which would fail Tiltfile LOAD rather than one resource.
34+
IS_LINUX = (
35+
os.name == 'posix'
36+
and str(local('uname -s', quiet = True, echo_off = True)).strip() == 'Linux'
37+
)
38+
39+
# auto_init alone would NOT be enough: it governs only the STARTUP run, and the default
40+
# TRIGGER_MODE_AUTO re-runs a resource whenever its deps change. Both probe-pin resources watch
41+
# 'crates/probe-pin/', so off Linux the very next edit there would drag them back into the red
42+
# that auto_init just avoided. Off-Linux they must stop watching too, not merely stop booting.
43+
PROBE_PIN_TRIGGER = TRIGGER_MODE_AUTO if IS_LINUX else TRIGGER_MODE_MANUAL
44+
2545
# Must stay a SUPERSET of what `scripts/engine-source-hash.sh` hashes as the engine cache
2646
# key (src + data + build.rs + Cargo.toml). `data/` is `include_str!`d into the binary and
2747
# `build.rs`/`Cargo.toml` change what gets compiled, so a change to any of them changes the
@@ -272,15 +292,22 @@ local_resource('probe-pin-check',
272292
# TMP_IGNORE is a FILENAME glob ('**/*.tmp.*') and does not match a tmp/ DIRECTORY, so the
273293
# Tier-2 tests' scratch writes under tests/fixtures/tmp/ would retrigger this resource.
274294
ignore = TMP_IGNORE + ['**/tmp/**'],
275-
auto_init = 'lint' in enabled,
295+
auto_init = 'lint' in enabled and IS_LINUX,
296+
trigger_mode = PROBE_PIN_TRIGGER,
276297
allow_parallel = True,
277298
labels = ['lint'],
278299
)
279300

280301
# The Tier-2 suite is #[ignore]d because GitHub's runners deny unprivileged user namespaces
281302
# (unshare -> /proc/self/uid_map EPERM), so GH CI never executes a real mount. THIS resource is
282-
# what keeps that honest: the local venue does have the capability, so Tier 2 runs here on every
283-
# change. Without it, "ignored in CI" quietly becomes "never run anywhere".
303+
# what keeps that honest: a LINUX local venue does have the capability, so Tier 2 runs there on
304+
# every change. Without it, "ignored in CI" quietly becomes "never run anywhere".
305+
#
306+
# On a non-Linux host, "never run anywhere" is not a hedge — it is the literal state. CI cannot
307+
# mount, and Darwin has no `unshare` to try, so Tier 2's claims are carried entirely by whatever
308+
# Linux venue last executed them. IS_LINUX only stops this resource auto-starting into a red it
309+
# can never clear; it does not make that gap smaller. The tests are deliberately NOT cfg'd out,
310+
# so a manual run here still prints probe-pin's own named refusal rather than a zero-test green.
284311
#
285312
# `-- --ignored` runs ONLY the ignored tests, which is exactly this suite. A non-zero exit turns
286313
# the resource red like any other gate — a real Tier-2 gate, not a fire-and-forget reporter.
@@ -298,7 +325,8 @@ local_resource('probe-pin-e2e',
298325
'CARGO_TARGET_DIR=target/probe-pin-e2e cargo test -p probe-pin --test isolation_e2e -- --ignored'],
299326
deps = ['crates/probe-pin/'],
300327
ignore = TMP_IGNORE + ['**/tmp/**'],
301-
auto_init = 'lint' in enabled,
328+
auto_init = 'lint' in enabled and IS_LINUX,
329+
trigger_mode = PROBE_PIN_TRIGGER,
302330
allow_parallel = True,
303331
labels = ['lint'],
304332
)

client/src/adapter/__tests__/draftPodAdapter.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const mockHostOnEvent = vi.fn((_handler: (event: Record<string, unknown>) => voi
4848
const mockHostInitialize = vi.fn(async () => {});
4949
const mockHostStartDraft = vi.fn(async () => {});
5050
const mockHostSubmitHostPick = vi.fn(async () => mockView("Drafting"));
51+
const mockHostSubmitHostPickWithDraftEffect = vi.fn(async () => mockView("Drafting"));
5152
const mockHostSubmitHostDeck = vi.fn(async () => mockView("Deckbuilding"));
5253
const mockHostGetHostView = vi.fn(async () => mockView("Lobby"));
5354
const mockHostKickPlayer = vi.fn();
@@ -64,6 +65,7 @@ vi.mock("../p2p-draft-host", () => ({
6465
initialize: mockHostInitialize,
6566
startDraft: mockHostStartDraft,
6667
submitHostPick: mockHostSubmitHostPick,
68+
submitHostPickWithDraftEffect: mockHostSubmitHostPickWithDraftEffect,
6769
submitHostDeck: mockHostSubmitHostDeck,
6870
getHostView: mockHostGetHostView,
6971
kickPlayer: mockHostKickPlayer,
@@ -83,6 +85,7 @@ vi.mock("../p2p-draft-host", () => ({
8385
const mockGuestOnEvent = vi.fn((_handler: (event: Record<string, unknown>) => void) => vi.fn());
8486
const mockGuestInitialize = vi.fn(async () => {});
8587
const mockGuestSubmitPick = vi.fn(async () => {});
88+
const mockGuestSubmitPickWithDraftEffect = vi.fn(async () => {});
8689
const mockGuestSubmitDeck = vi.fn(async () => {});
8790
const mockGuestLeave = vi.fn(async () => {});
8891

@@ -92,6 +95,7 @@ vi.mock("../p2p-draft-guest", () => ({
9295
onEvent: mockGuestOnEvent,
9396
initialize: mockGuestInitialize,
9497
submitPick: mockGuestSubmitPick,
98+
submitPickWithDraftEffect: mockGuestSubmitPickWithDraftEffect,
9599
submitDeck: mockGuestSubmitDeck,
96100
leave: mockGuestLeave,
97101
view: null,
@@ -112,6 +116,7 @@ function mockView(status: string): DraftPlayerView {
112116
pass_direction: "Left",
113117
current_pack: null,
114118
pool: [],
119+
draft_effects: [],
115120
pool_groups: {
116121
color_groups: [],
117122
type_groups: [],
@@ -294,6 +299,21 @@ describe("DraftPodHostAdapter", () => {
294299
expect(view.status).toBe("Drafting");
295300
});
296301

302+
it("delegates draft-effect picks and returns view", async () => {
303+
await adapter.initialize({
304+
poolInput: { type: "Set", data: { set_pool_json: "{}" } },
305+
kind: "Premier",
306+
podSize: 8,
307+
hostDisplayName: "Host",
308+
tournamentFormat: "Swiss",
309+
podPolicy: "Competitive",
310+
});
311+
312+
const view = await adapter.submitPickWithDraftEffect("cogwork-1", ["card-1", "card-2"]);
313+
expect(mockHostSubmitHostPickWithDraftEffect).toHaveBeenCalledWith("cogwork-1", ["card-1", "card-2"]);
314+
expect(view.status).toBe("Drafting");
315+
});
316+
297317
it("delegates submitDeck and returns view", async () => {
298318
await adapter.initialize({
299319
poolInput: { type: "Set", data: { set_pool_json: "{}" } },
@@ -500,6 +520,13 @@ describe("DraftPodGuestAdapter", () => {
500520
expect(mockGuestSubmitPick).toHaveBeenCalledWith("card-456");
501521
});
502522

523+
it("delegates draft-effect picks to P2PDraftGuest", async () => {
524+
await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" });
525+
526+
await adapter.submitPickWithDraftEffect("cogwork-1", ["card-1", "card-2"]);
527+
expect(mockGuestSubmitPickWithDraftEffect).toHaveBeenCalledWith("cogwork-1", ["card-1", "card-2"]);
528+
});
529+
503530
it("delegates submitDeck to P2PDraftGuest", async () => {
504531
await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" });
505532

client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -581,17 +581,34 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => {
581581
).toThrow("P2P supports 2-6 players");
582582
});
583583

584-
it("enables multiplayer-mode enforcement on the engine at init time", async () => {
585-
// P2PHostAdapter owns an authoritative WASM engine locally; flipping
586-
// the engine's multiplayer flag during initialize() ensures any stray
587-
// restore_game_state call is refused in the Rust layer.
584+
it("enables multiplayer-mode enforcement at game start, not at lobby open", async () => {
585+
// The engine's multiplayer flag is process-wide and nothing ever clears it,
586+
// so an open host lobby must not set it — it is claimed only when the host
587+
// actually takes the engine, on the line before `initializeGame`, which is
588+
// where `initialize_debug_permissions` reads it.
588589
const { adapter } = makeHost(2);
589590
expect(mockSetMultiplayerMode).not.toHaveBeenCalled();
590591

591592
await adapter.initialize();
592593

594+
expect(mockSetMultiplayerMode).not.toHaveBeenCalled();
595+
596+
await adapter.applySeatMutation({
597+
type: "SetKind",
598+
data: {
599+
seatIndex: 1,
600+
kind: {
601+
type: "Ai",
602+
data: { difficulty: "Medium", deck: { type: "Random" } },
603+
},
604+
},
605+
});
606+
await adapter.initializeGame();
607+
593608
expect(mockSetMultiplayerMode).toHaveBeenCalledTimes(1);
594609
expect(mockSetMultiplayerMode).toHaveBeenCalledWith(true);
610+
expect(mockSetMultiplayerMode.mock.invocationCallOrder[0])
611+
.toBeLessThan(mockInitializeGame.mock.invocationCallOrder[0]);
595612
});
596613

597614
it("does not reinitialize the host during the lobby-to-game handoff", async () => {
@@ -601,7 +618,7 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => {
601618
await adapter.initialize();
602619

603620
expect(mockInitialize).toHaveBeenCalledTimes(1);
604-
expect(mockSetMultiplayerMode).toHaveBeenCalledTimes(1);
621+
expect(mockSetMultiplayerMode).not.toHaveBeenCalled();
605622
});
606623

607624
it("fences a stale host when a same-session resume claims a new incarnation", async () => {

0 commit comments

Comments
 (0)