@treeseed/dsh-chapters 0.2.1 → 0.2.2-dev.2

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.
Files changed (64) hide show
  1. package/cordis.patch.yml +7 -0
  2. package/docs/backlog.md +1566 -0
  3. package/docs/contract.md +281 -0
  4. package/docs/development.md +122 -0
  5. package/docs/host-protected-head-ask.md +80 -0
  6. package/docs/project-state.md +266 -0
  7. package/docs/verify.md +12 -0
  8. package/lib/arrival.js +24 -0
  9. package/lib/arrival.js.map +1 -1
  10. package/lib/aux-message.d.ts +17 -0
  11. package/lib/aux-message.js +20 -0
  12. package/lib/aux-message.js.map +1 -0
  13. package/lib/client.js +536 -227
  14. package/lib/client.js.map +1 -1
  15. package/lib/commands.js +107 -1
  16. package/lib/commands.js.map +1 -1
  17. package/lib/continue-core.d.ts +6 -0
  18. package/lib/continue-core.js +18 -13
  19. package/lib/continue-core.js.map +1 -1
  20. package/lib/engine-core.d.ts +3 -0
  21. package/lib/engine-core.js +4 -3
  22. package/lib/engine-core.js.map +1 -1
  23. package/lib/engine.d.ts +9 -0
  24. package/lib/engine.js +66 -4
  25. package/lib/engine.js.map +1 -1
  26. package/lib/index.d.ts +5 -0
  27. package/lib/index.js +199 -8
  28. package/lib/index.js.map +1 -1
  29. package/lib/injections.js +10 -0
  30. package/lib/injections.js.map +1 -1
  31. package/lib/prompt-loop.d.ts +73 -0
  32. package/lib/prompt-loop.js +141 -0
  33. package/lib/prompt-loop.js.map +1 -0
  34. package/lib/prompt-runner.d.ts +44 -0
  35. package/lib/prompt-runner.js +85 -0
  36. package/lib/prompt-runner.js.map +1 -0
  37. package/lib/prompt-state.d.ts +124 -0
  38. package/lib/prompt-state.js +96 -0
  39. package/lib/prompt-state.js.map +1 -0
  40. package/lib/prompt.d.ts +32 -2
  41. package/lib/prompt.js +31 -9
  42. package/lib/prompt.js.map +1 -1
  43. package/lib/registry.d.ts +7 -0
  44. package/lib/registry.js +2 -0
  45. package/lib/registry.js.map +1 -1
  46. package/lib/render.d.ts +22 -1
  47. package/lib/render.js +40 -7
  48. package/lib/render.js.map +1 -1
  49. package/lib/repair-run.d.ts +27 -0
  50. package/lib/repair-run.js +85 -2
  51. package/lib/repair-run.js.map +1 -1
  52. package/lib/rules.js +8 -0
  53. package/lib/rules.js.map +1 -1
  54. package/lib/save-core.js +4 -4
  55. package/lib/save-core.js.map +1 -1
  56. package/lib/store.js +11 -0
  57. package/lib/store.js.map +1 -1
  58. package/lib/tools.d.ts +3 -0
  59. package/lib/tools.js +17 -4
  60. package/lib/tools.js.map +1 -1
  61. package/lib/version.d.ts +1 -1
  62. package/lib/version.js +1 -1
  63. package/lib/version.js.map +1 -1
  64. package/package.json +20 -16
package/docs/backlog.md CHANGED
@@ -404,3 +404,1569 @@ facts in `docs/contract.md` § Composer Ghost Layer). When the workspace deps mo
404
404
  (`isSurfaceEligibleType`), read from the kernel's own exports.
405
405
  4. The pull command stays as a manual verb (`suggest` re-arms the ghost); the anti-dup client
406
406
  row keeps the wire body singular either way.
407
+
408
+ - **#105 / seed-vs-protected-head (blocking for `chapters_continue` on 0.2.x).** Every child we create
409
+ seeds a `user/message` at `seq 0` (invariant 2), which is a `SURFACE_TYPE`, so the host's header
410
+ `system/message` can never claim the protected first surface head — measured: 50/53 logs have the header
411
+ first, the only 2 exceptions are our children, and one is refused by the UI. Ask of the host team:
412
+ `docs/host-protected-head-ask.md`. Mitigation candidate (b), **gated on a measurement, not a guess**:
413
+ spike `spikes/seed-protected-head/` — one boot, fake provider, zero model tokens; create a child seeded
414
+ with a non-surface synthetic event (the `plan/mode` precedent) and one seeded with the notice, run one
415
+ step each, then reload through the host's own reader. Ship (b) behind a config flag only if the state
416
+ seed is admitted AND the header lands as the first surface event. Related: #77, #58, #101, #61.
417
+ - **#113 / the composer ghost is REMOVED (maintainer decision 2026-10-25), `/chapters-prompt` survives as a
418
+ deliberate command.** The chip re-fired per step inside one turn, errored on empty threads, and its overlay
419
+ was unreadable and overlapped the placeholder. What landed instead: turn-scoped generation (`src/prompt.ts`
420
+ keys on `turn/end`, not the last surface event), a `quiet` class on "nothing to offer" refusals, and a
421
+ multi-line labelled block from `/chapters-prompt suggest` so the prompt is shown whole with its model,
422
+ source and cap (the same shape `/chapters-status` gets an expander for). Site §28 and
423
+ `docs/contract.md` § Composer Ghost Layer still describe the removed overlay — they need a pass, and if the
424
+ ghost is ever revived it must answer all four defects with measurements, not a restyle.
425
+
426
+ ## Tape generation + acceptance (state at 2026-10-26, end of the debugging round)
427
+
428
+ **Resolved, with evidence:**
429
+ - The Alibaba token-plan credential is **valid**: `GET …/compatible-mode/v1/models` → `http=200`. My earlier
430
+ `401 Invalid API-key` reports were **my invalid model id** (`qwen3.8-flash`, then a broken extraction that
431
+ yielded `"json"`), because this gateway reports a bad model as a key error. Do not re-diagnose the key.
432
+ - File/`~/.bashrc`/harness-process key fingerprints are identical (`5ff7ab0a`, 114 chars) — the credential
433
+ path works; an agent tool shell still can't inherit it, so `.dshdev-local/e2e.env` is the supported route.
434
+ - `qwen3.8-flash-next` (the fixture name in the tracked seed) is **not** in the plan's `/models` list; that is
435
+ exactly what `E2E_UPSTREAM_MODEL` exists to translate.
436
+
437
+ **Next, in order:**
438
+ 1. Pick a real id from `/models` (a flash-tier one, e.g. from the list returned), verify one
439
+ `chat/completions` call answers `200`, and record which id.
440
+ 2. In harness mode set `E2E_UPSTREAM=https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1`
441
+ and `E2E_UPSTREAM_MODEL=<that id>`, then `LEG=rules MODE=record` — the shortest leg.
442
+ 3. Read a **fresh** session tree (mtime-since-start) for `assistant/message`. Two prior readings were wrong:
443
+ one used an Oct 23 stale dir, another found none because sessions land under the port-keyed home only if the
444
+ model answered at all.
445
+ 4. Only then revisit: the live-mode `composer did not take focus (active: API key)` wall, and the missing
446
+ `Fork with chapters` door. Both may be downstream of a provider that never answered — do not treat either as
447
+ a UI bug until step 3 shows an assistant message existing.
448
+ 5. Standing diagnosability fixes owed: boot must assert a provider answered before journeys poll; failure
449
+ artifacts must include a DOM (`error-context.md` has none, which is how I claimed "present but not visible"
450
+ from a call log).
451
+
452
+ ### The bar renders nothing because its pull never returns (2026-10-26, measured)
453
+
454
+ Proven by instrumenting rather than reasoning, each step eliminating a hypothesis:
455
+
456
+ - the profile row reaches the plugin: `[chapters-config] promptModel delivered = "qwen-token-plan/qwen3.8-flash"`
457
+ — so the server-side enable rule is satisfied;
458
+ - the component **is mounted** by the host and receives real props: `[client:prompt] mount; sid=session-…
459
+ exec=function inputActions=object status=off` — so the dock registration and slot name are correct;
460
+ - the initial `status=off` then hides the row (`barVisible` false), which is the *by-design* default;
461
+ - but the mount-time `refresh()` produced **no** dispatch, **no** catch, and **no** `finally` — it awaits
462
+ `exec(...)` and never returns, and nothing reached the host (the server log has no `chapters-prompt` line).
463
+
464
+ So the remaining fault is the command wire from the dock: `runCommandText(remote, sessionId, line)` resolves
465
+ for the assistant-action doors (the fork leg clicks it successfully) and hangs when fired at mount. Ordering
466
+ hypothesis: at mount the session may not be attached for a command dispatch yet, or the doors' path awaits a
467
+ different signal. Fix, in this order:
468
+ 1. bound the pull (`Promise.race` with a timeout) so a hung wire becomes a **delivered** failure, not silence —
469
+ the bare `catch {}` that swallowed tonight's diagnosis is already replaced with `console.warn` (#61);
470
+ 2. fire the first pull on the same edge the doors succeed on (after a completed turn / idle), not at mount;
471
+ 3. only then decide whether `off`-until-first-answer stays the default or the bar shows `Analyzing…` while the
472
+ first pull is in flight. The maintainer's approved shape says *Analyzing… then Recommended Next Step*, so if
473
+ the pull is reliable, `off` is the wrong default for a configured session.
474
+
475
+ Rig gaps found on the way, worth their own rows: `E2E_KEEP_HOME=1` is documented but no longer implemented in
476
+ `tests/e2e/*` — so a passing boot deletes the only copy of the profile it wrote and post-hoc inspection is
477
+ impossible; and `typecheck:e2e`'s `TS2769` count is structural (one per `projects` entry, because Playwright's
478
+ `Project` type has no `env`), so it must move with the number of legs, not be treated as a regression.
479
+
480
+ ### Why the bar is invisible: a refusal is stored as `off` (2026-10-26, measured end to end)
481
+
482
+ The chain, each link proven rather than assumed:
483
+ - dock registration correct → component mounts with real props (`exec=function`, `inputActions=object`);
484
+ - pull resolves (no timeout, no catch) → the wire works;
485
+ - reply read verbatim: `suggested next step: off` + `model: qwen-token-plan/qwen3.8-flash (config)`;
486
+ - `parseBarState` parses that block correctly and dispatches `off`; `barVisible(off)` is false → row hidden.
487
+
488
+ So the client is right and the profile row arrives. The fault is on the server: `prompt-runner.ts` /
489
+ `prompt.ts` map a **quiet refusal** to `status: 'off'` ("a quiet refusal (`nothing to recommend yet`) is
490
+ `off`, not `failed`"). That single choice makes three different things indistinguishable to the human and to
491
+ the bar: suggestions switched off, nothing to recommend yet, and suggestions **unable to run at all**
492
+ (`refuse('prompt suggestions cannot run: ' + reason)` when `resolvePromptRoute` finds no route). In this rig
493
+ the model is configured, so what the bar is actually reporting is a route-resolution failure wearing the
494
+ costume of "off".
495
+
496
+ Fix, in this order, rows first:
497
+ 1. Only "there is nothing to recommend yet" may store `off`. A route/transport failure stores `failed` with
498
+ its reason, because the human must be able to tell a disabled feature from a broken one — the rule
499
+ `off` means off and `unavailable` means unavailable, already adopted for `/chapters-rules` in #109.
500
+ 2. Add a unit row per refusal class over the runner's write, so the mapping cannot collapse again.
501
+ 3. Then re-run `LEG=prompt` and read the reply: `analyzing`/`ready` means the bar renders; `failed` means it
502
+ renders *with the reason*, and the remaining bug is the host route discovery (`hostRoute` in `src/index.ts`)
503
+ — a different, narrower problem.
504
+
505
+ ### Next unknown, isolated: did the suggested-next-step door register at all? (2026-16 17:20)
506
+
507
+ Eliminated by measurement, one link at a time: listener arg order (`(session, event)` — matches
508
+ `makeSignatureListener`'s published shape), `enabled()` (`prompt.effective().spec !== ''`, and the profile row
509
+ is delivered), the read/write store (both the registry row's `promptSuggestion`), and the merge order
510
+ (`statusOf` contributes only `{autorun, fired}` — it cannot clobber the status).
511
+
512
+ What is left: `ctx.on('session/event', …)` at `src/index.ts:684` may not be the bus the *realm* doors use
513
+ (`src/engine.ts:560/574/585` register inside the realm class on `this.ctx`). The registration prints
514
+ "dsh-chapters: suggested-next-step door armed" — or "REFUSED" — through `ctx.logger`, and the rig deletes the
515
+ home (and any per-port log inside it) at teardown, so tonight's run cannot answer it. `E2E_KEEP_HOME=1` is
516
+ documented but no longer implemented anywhere in `tests/e2e/`.
517
+
518
+ Therefore, in order:
519
+ 1. Deliver the rig's own logs: on teardown, copy the boot's stdout and the per-port diagnostics file to
520
+ `var/e2e-<port>-diagnostics.log` BEFORE the home is deleted, and re-implement or delete the
521
+ `E2E_KEEP_HOME=1` doc claim. A rig that discards the only evidence of a boot is the #61 bug in my own tooling.
522
+ 1b. **The delivery attempt itself failed**: the teardown copy ran but found nothing at
523
+ `rigStoreRoot(port)/diagnostics.log` — the store root is not directly under the home (the journey's workspace is), so the path must be derived from the boot's actual store root, not assumed. Prove it by listing the home's tree inside the copy step once, then point the copy at the real file.
524
+ 2. Re-run `LEG=prompt MODE=record` and read whether the door armed. If REFUSED, the event bus/registration is
525
+ the bug and the fix is to register where the working doors register.
526
+ 3. If armed but no claim, instrument `promptRunner`'s three early returns (`ev.type`, `enabled`, `eligible`)
527
+ with the reason each one took, delivered to the diagnostics sink, and re-read.
528
+
529
+ ### Where the corpus work stands (2026-10-26, end of session)
530
+
531
+ Recorded green against the maintainer's route: `rules` `explore-dev` `fork` `arrival` `fanout` `enrich`
532
+ `plans` `suite` `heavy` `prompt` `prompt-off`. Bar leg passes 4/4 in record AND in replay; OFF leg 1/1 in both.
533
+ Open, in order:
534
+ 1. one whole-chain `MODE=replay` green run (CI's gate) — in progress;
535
+ 2. `npm run corpus.stamp` + `corpus.check` so `CORPUS_HOST.distilledAt` matches the tapes (it still says
536
+ 2026-10-23 while every tape is from 2026-10-26);
537
+ 3. `coverage:e2e` refresh on the new plane;
538
+ 4. #126 (host: no composer draft API for docks) — until it lands, the bar explains the refusal and Run
539
+ submits through `/chapters-prompt run`;
540
+ 5. then cut `0.2.2-dev.1` and hand the maintainer the install/restart.
541
+
542
+ ### The orphan reaper covers the host port but not the tape-proxy port (2026-10-26, measured twice)
543
+
544
+ **Correction, a day late and worth the edit:** when I wrote the sentence above the reaper was NOT in the tree.
545
+ PR #124 was still open, `tests/e2e/boot-reap.ts` did not exist, and `boot.ts` still only advised `pkill` — so
546
+ nothing was reaped, host port or proxy port. My diagnosis described a component I believed in rather than one I
547
+ checked for. It merged as `a8ff124`; the chain now running gets to find out whether it actually works, against a
548
+ live orphan holding 41733. The proxy-port gap below stands on its own evidence (two EADDRINUSE faults today:
549
+ 42739 and 42733).
550
+ `tests/e2e/boot-reap.ts` reaps repo-owned `dsh web --port <n>` holders before a boot. The proxy binds
551
+ `<rig port> + 1000` (`42731`-`42740`) and is covered by nothing, so a leg killed mid-boot leaves a proxy
552
+ holding that port and the **next** chain dies at once:
553
+
554
+ Error: listen EADDRINUSE: address already in use 127.0.0.1:42733
555
+ (earlier today, the same fault on 42739)
556
+
557
+ Both were mine — a 250s `timeout` on a leg whose teardown then never ran. Manual reaping works (kill the
558
+ repo-owned holder), but a rig that manufactures its own next failure is the #108 class: the run that got
559
+ killed already knew the port was its own.
560
+
561
+ Fix, rows first: extend `orphanBootPids` to take a port set (host port AND proxy port) and a matcher for the
562
+ proxy's own command line, keep every existing constraint (repo-root in argv, never self, never an ancestor),
563
+ and have the boot reap before `startModelProxy` binds, reporting which pid it signalled. A row must fail
564
+ against the current code to prove the gap: a holder of the proxy port is not reaped today.
565
+
566
+ ### CI acceptance is red where the local chain is green — and its annotation is empty (2026-10-27)
567
+
568
+ Local: `E2E_MODEL=replay npm run test:e2e` → **10 projects, EXIT=0**, including `prompt` 4 passed and
569
+ `prompt-off`. CI on the same commit: deterministic + preset-rows **success**, acceptance **failure**, and the
570
+ annotation says only `ci-replay: tape replay exited 1` with **none** of the detail lines the script is written
571
+ to attach (`✘|Error:|expect(|Received:|Expected:|[0-9]+ failed`, plus the `error-context.md` heads).
572
+
573
+ That emptiness is itself the clue: `scripts/ci-replay.sh` tees the chain into `var/ci-replay.log` and greps it,
574
+ so "no matches" means the log contained no test verdicts at all — a failure at boot/setup, not an assertion.
575
+ Prime candidates, in the order to test:
576
+ 1. run `bash scripts/ci-replay.sh` locally with its CI env (`E2E_HOME_ROOT=/tmp/dsh-e2e`,
577
+ `E2E_TAPE_DIFF=$PWD/var/e2e-tape-diffs`) and read the real stop;
578
+ 2. the store tripwire: `assert-store-untouched.mjs snapshot`/`verify`. On my machine `.dsh-chapters/` already
579
+ exists (this session's own store), so the pair cannot fail here — only a pristine checkout can show whether
580
+ the rig writes into the repo root;
581
+ 3. host install in CI: the script installs the pinned `@deepseek-ai/dsh` project-scoped in its own prefix and
582
+ `dsh --version` must match `tests/e2e/rig-pins.json`; a mismatch throws before any test runs, which fits
583
+ "no verdicts in the log" exactly.
584
+ 4. the new fatal-on-miss guard: it aborts a leg on a `MISS` written after install. Locally green means no miss
585
+ for my `$HOME`; CI's `/home/runner` paths could produce one if WORKSPACE-BLIND does not cover a new field.
586
+
587
+ Do not "fix" any of these by loosening a gate. Reproduce, read the line, fix that.
588
+
589
+ ### `heavy` cannot replay because its record run never completed (2026-10-27, measured)
590
+
591
+ CI-shaped chain: `suite` **4 passed (37.2s)** — the header fix is confirmed — then `heavy` aborts at
592
+ `MISS tape=heavy entries=23 messages=4 closest=0009.json commonPrefix=2 divergeAt=msg2`.
593
+
594
+ That is not a matcher bug and not the header. `heavy`'s re-distill **failed its own leg** (step 4,
595
+ `oversized-turn.spec.ts:161`: post-compaction session answers `STILL-HERE` within 300s) after three
596
+ `OVERFLOW refused … est=145382–163026 tokens window=32000 — rig-enforced` lines, and its tape froze at 23
597
+ entries from +300s onward. A tape distilled by a failing journey is not a recording of the journey; replaying
598
+ it and calling the miss the tape's fault would be backwards.
599
+
600
+ Order of work:
601
+ 1. make `heavy`'s **record** run green again on the current build (it was green under the harness-profile
602
+ shape this morning, and the leg's own design expects the rig to refuse over-window requests, so the
603
+ difference to chase is what the synthesized `local` row does *not* carry that the ambient row did —
604
+ `retryPolicy.maxRetries: 0` is the first candidate, and `E2E_HARNESS_PROFILE=1` is the toggle that isolates
605
+ it in one run);
606
+ 2. only then re-distill `heavy` and expect replay to pass;
607
+ 3. if step 1 shows the refusal really is the product's problem (post-compaction surface still over window),
608
+ that is a product fix with a red row, not a distillation retry.
609
+
610
+ Also true now and worth keeping: `prompt-off` distills (1 passed, 20.5s) and is in the distiller's `ORDER`;
611
+ the `prompt` tape is 14 entries where it had been 546, because `distill-tapes.sh` clears each project's tape
612
+ directory first and my ad-hoc `MODE=record` loops had been accumulating instead.
613
+
614
+ ### The corpus tree is currently MIXED across header shapes — do not commit it as the re-anchor (2026-10-27)
615
+
616
+ Where each leg's tape stands after the placement fix, and what it implies:
617
+
618
+ | leg | distilled under | state |
619
+ |---|---|---|
620
+ | `suite`, `arrival`, `enrich`, `fanout`, `rules`, `fork`, `prompt`, `prompt-off` | CI shape (provider `local`) | re-distilled green; replays green for `suite` (4 passed, 37.2s) |
621
+ | `heavy` | **harness profile** (`E2E_HARNESS_PROFILE=1`) | passes only in that shape (1 passed, 53.5s); its tape is therefore NOT CI-replayable |
622
+
623
+ So `heavy` is the last leg to land, and it needs a **row fix, not a re-run**: the synthesized `local` provider
624
+ row in `boot.ts` sets `retryPolicy: { mode: normal, maxRetries: 0 }`, which the ambient row does not set, and
625
+ that difference alone flips heavy from pass to fail. The decision, in order:
626
+
627
+ 1. remove the `maxRetries: 0` override from the synthesized row (host default) and re-distill **heavy alone**;
628
+ 2. if the leg then passes in CI shape, re-distill any leg whose behavior depended on that retry suppression —
629
+ the honest default is to re-distill the whole set once, in one command, so the tree has exactly one shape;
630
+ 3. only then `corpus:stamp` + `corpus:check`, and commit the corpus as one coherent change with the chain green.
631
+
632
+ Two traps here worth naming: committing a mixed tree would make CI red in a way that looks like flaky tapes (the
633
+ `commonPrefix=0` signature again), and "it passed when I changed the toggle" is not evidence that the rig's
634
+ default is right — it is evidence that the two shapes differ, which is the thing to eliminate rather than route
635
+ around.
636
+
637
+ ### CI's acceptance red is expected while the committed corpus is mixed — do not debug it (2026-10-27)
638
+
639
+ The tree in `HEAD` was distilled across two header shapes (eight legs under provider `local`, `heavy` under
640
+ the harness row), and CI replays whatever is committed. So its `acceptance — ten tape-replay legs` job fails
641
+ for a reason already diagnosed, and its annotation cannot tell me anything new. The sequence that resolves it
642
+ is fixed by the diagnosis, not by urgency: uniform re-distill (running) → one coherent corpus commit → CI-shaped
643
+ chain green locally → read CI again. A red job that was predicted from known state is a checkpoint, not a bug.
644
+
645
+ ### A re-distill must be one pass over a FROZEN regime (learned the hard way, 2026-10-27)
646
+
647
+ What happened, in order: I committed a global change to the rig's retry regime, launched the uniform distill
648
+ under it, discovered the change was wrong for `arrival`, reverted it to a per-leg pin mid-flight, and then
649
+ expected the half-distilled corpus to replay. It could not. `suite` recorded 7 entries **with retries on** and
650
+ replayed **with retries off**, so it missed at `msg3` — a corpus whose legs were distilled under three different
651
+ regimes, and every failure downstream of that looks like a matcher bug or a product bug.
652
+
653
+ The rule, plainly: the regime set (ports, thresholds, floors, retry policy, provider identity, and now
654
+ `hostRetries`) is frozen *before* the corpus is distilled, in one uninterrupted pass, and the pass is what gets
655
+ committed together with `CORPUS_HOST`/`BUILD_STAMP`. A regime change discovered mid-distill means the distill is
656
+ restarted, not patched leg-by-leg — mixing tapes from different regimes is precisely the "mixed tree" failure I
657
+ documented an hour earlier, and I walked into it anyway because the temptation was to fix one leg at a time.
658
+
659
+ Current state after this lesson: heavy passes with its declared `hostRetries: true` (distill exit=0, 19
660
+ entries), arrival's 122-entry junk tape was discarded and restored from git, and one uniform pass over the final
661
+ regimes is running.
662
+
663
+ ### `arrival` needed a second distill pass — live-model nondeterminism, recorded (2026-10-27)
664
+
665
+ First pass under the frozen regimes: `arrival: exit=1`, 41 recorded requests, failing its own assertion with
666
+ `model never found the buried token via the artifact tool` (`Expected substring: "EMBED-CENTER-4417"`). Second
667
+ pass, same build, same regime: `exit=0`, 10 entries. Nothing in the rig changed between them.
668
+
669
+ So the leg's outcome depends on the model actually choosing `chapters_artifact` and reading the right offset —
670
+ which is the thing the leg exists to test, and also why one capture is not a guarantee. Handling, deliberately:
671
+ the corpus now carries a tape distilled from a **passing** run (the rule), and this note is the record that the
672
+ leg needed two attempts — not a claim that it is stable. If it fails a third time, that is a product/leg issue to
673
+ file with both logs, and re-rolling until green stops being an acceptable answer.
674
+
675
+ Also from the same pass: `prompt-off` was in the distiller's `ORDER` but its commit landed after the batch had
676
+ started reading the script, so its tape is being re-distilled separately rather than left stale — a mixed-regime
677
+ corpus is the failure mode I already documented and then still managed to create.
678
+
679
+ ### CI still fails at the knowledge loop while the identical local chain is green (2026-10-27)
680
+
681
+ State of evidence, so the next round starts from facts:
682
+
683
+ - Local, in CI's exact shape (`E2E_MODEL=replay npm run test:e2e`, no provider, no credential): **all ten legs
684
+ green, `CHAIN_EXIT=0`**, and the corpus is committed as one change (`2df6704`) with `corpus:stamp`/`check`
685
+ agreeing at `lib=8bb44fc2`.
686
+ - CI run `40465462766`: step 8 (`tape replay`) fails, step 7/9 (store snapshot/verify) pass, and the uploaded
687
+ artifact shows the failure is in `suite`'s knowledge loop: `test-results/a-knowledge-loop-…`, plus
688
+ `var/e2e-tape-misses.log` and a `var/e2e-tape-diffs/000.json`.
689
+ - The annotation carries no detail lines even after widening the grep, so nothing matched — the grep needs the
690
+ `2026-…` journal form and the `distill:`-style lines too, which is a diagnostics gap to close, not a mystery.
691
+ - The artifact's diff pairs a 2-message incoming against a *title* candidate, because `storedCandidates` selects
692
+ by equal message count, not by kind. So it cannot show the divergence: the next probe must compare CI's
693
+ `incoming[0]` against the committed **conversation** tape entry's `prefix[0]` directly, character by
694
+ character, and print the type of each element first (my attempt died on exactly that assumption).
695
+
696
+ Leading possibilities, none yet evidenced: the runner's model adapter defaults differ (CI's `dsh --version` is
697
+ the same pin but the header can still carry a machine-specific token the blinds do not cover), or the header's
698
+ skill/tool catalog on a pristine home differs from this machine's installed plugins — which would be a
699
+ normalization gap of the same family as AGENTS-BLIND and the workspace/path blinds, and the right fix is a
700
+ narrow blind with a red row, never a widened matcher.
701
+
702
+ ### The local-vs-CI header gap is a **build identity** difference, not a config leak (2026-10-27, three hypotheses killed)
703
+
704
+ Evidence, in the order it arrived:
705
+
706
+ 1. `ls -l dev/dsh-host-lock/node_modules/@deepseek-ai/dsh` was a real directory, and CI's install command was
707
+ run verbatim here (`npm ci --prefix dev/dsh-host-lock --no-save`, then `dsh --version` → `0.2.0-rc.2`).
708
+ Re-distilling `suite` after that still records a **7,276-char** header. So "my host install drifted from
709
+ CI's" is dead.
710
+ 2. The copy filter in `boot.ts` was allowing `rel === 'storages'` and `storages/workspace.json` from the
711
+ developer's `.dshdev-local`. Excluding them was edited, re-distilled, and measured: still 7,276. **The edit
712
+ was reverted** rather than left in the tree as an unverified "fix" — a change kept because it seemed
713
+ plausible is how a rig accumulates cargo cult.
714
+ 3. Section-by-section comparison of the two headers: **no heading differs at all**. The divergence is prose
715
+ inside the same sections — CI says `…Prefer showing the primary results within your final response…`, the
716
+ local header at the same offset says `…Tokens prefixed with @ are paths the user explicitly referenced…`.
717
+ Same document structure, different text: that is two **builds** of the client UI packages, not two
718
+ configurations.
719
+
720
+ So the machine-specific input is which `@deepseek-ai/*` bundle the boot actually resolves. Both
721
+ `~/node_modules/@deepseek-ai/` and the lock hold 289 packages, and the home dir additionally contains a linked
722
+ `dsh` dev build — the exact shape #89's note describes as "a linked dev build wearing the pin's version string".
723
+ The next probe is therefore resolution, not regime: have the boot print, once, the **resolved file path** of the
724
+ package that contributes the header (the conversation/client-ui module), and compare that path on the two
725
+ machines. If it is `~/node_modules/…` locally, the fix is to make the rig resolve strictly inside
726
+ `dev/dsh-host-lock` (an explicit loader path or a barrier directory), and the corpus re-distills once after that.
727
+ Until that is measured, do NOT re-distill again hoping for a shorter header, and do not add a blind to mask it —
728
+ masking a header that differs by build would hide the fact that the acceptance run and the recorded corpus were
729
+ produced by different software, which is the one thing the tape system exists to prevent.
730
+
731
+ ### ROOT CAUSE of local-vs-CI header divergence: the plugin reads this repository's rules tier at boot (2026-10-27)
732
+
733
+ The local header carries a `## Chapters memory and rules` section at offset 6,389 of 7,276 chars:
734
+
735
+ > "Before starting a task, run `chapters_rules` … project rules your team approved, plus session rules…"
736
+
737
+ CI's 2,783-char header contains **no such section**, and the words `rules`/`chapters` do not appear in it. The
738
+ section is contributed because `.dsh-chapters/` exists **in the repository under test** — it holds the approved
739
+ project rules for this project. A GitHub runner checks out a repo with no rules tier (the CI workflow even
740
+ asserts the directory never appears), so the host composes a shorter system header, message 0 of every request
741
+ differs, and every tape fails to match. `verify-committed.sh` reported `.dsh-chapters` as PRESENT locally while
742
+ I was still hunting the cause.
743
+
744
+ Five hypotheses were eliminated to get here, all of them plausible, none measured first: host-install drift
745
+ (re-ran CI's exact `npm ci --prefix dev/dsh-host-lock`; still 7,276), copied `storages/` (edited the filter,
746
+ re-distilled, reverted the change as unverified), header sections (none differ), the once-per-repo
747
+ `var/e2e-profile-base` (deleted and rebuilt; still 7,276), and inherited `DSH_PROFILE_DIR` (real leak-surface,
748
+ fixed as `rigChildEnv` with two rows, but not the cause).
749
+
750
+ The fix is isolation, not normalization, and it must NOT be a blind that masks the section — masking it would
751
+ hide that the acceptance run and the corpus were produced by different repository state:
752
+ 1. decide what the rig's workspace must be for the plugin's own store/rules domain. `#61` pinned
753
+ `artifactStoreRoot` per port, but the rules tier still resolves to the workspace's `.dsh-chapters/`;
754
+ 2. either run each leg in a pristine workspace (no rules tier, no chapters) — matching CI and what a real user
755
+ gets on a fresh project — or seed an identical rules fixture into the rig workspace so local and CI compose
756
+ the same header. Either way the choice is made once, in the rig, before the corpus is re-distilled;
757
+ 3. then one frozen-regime distill, then the CI-shaped chain, then CI is expected to match without any change to
758
+ a test.
759
+
760
+ Until then the corpus in `HEAD` is unreplayable on CI, and the local chain's green is not the same claim.
761
+
762
+ ### Narrowed: the section is contributed at boot with no rules on disk in either home (2026-10-27)
763
+
764
+ Two more measurements, both cheap and both local:
765
+
766
+ - **Present from the first request.** All 14 conversation-header entries in the `suite` tape carry the section
767
+ (one aux entry at 1,692 chars is the only header without it). So it is composed at boot, not mined mid-run —
768
+ enrichment timing is ruled out.
769
+ - **No rules exist to read.** The suite boot's home (`/tmp/dsh-e2e/e2e-home-41731`) has no `knowledge/` mirror
770
+ and no `rules/` directory at all. The only rules file anywhere under the rig roots is
771
+ `e2e-home-41735/chapters/rules/dev2/001-security-…md`, which is the **rules leg's own fixture** on its own
772
+ port — exactly where it belongs, and unrelated to `suite`.
773
+
774
+ So: locally the plugin contributes its rules/chapters instruction section when there is nothing on disk to
775
+ back it; a CI boot contributes no such section. That is either (a) the section's gate ignoring the empty-store
776
+ case and emitting the boilerplate anyway, or (b) a genuine per-boot difference in which plugin rows mount. Both
777
+ are answerable by the plugin's own report — `/chapters-status` prints the store root, the mirror root and the
778
+ rule counts in every state, including unlinked — so the next probe is to run that command inside a rig boot on
779
+ each machine and compare its output, then write a red row against whichever side is wrong. No blind, no
780
+ matcher change, until the gate is understood.
781
+
782
+ ### CONFIRMED with a positive control: the repo rules tier was the header divergence, and the store-root fix removed it (2026-10-27)
783
+
784
+ The `diagnostic('rules-section', …counts…)` line I added after the empty-guard is the measurement, and the
785
+ batch supplied both halves of it:
786
+
787
+ port 41735 (rules leg) contributing: project=1 session=0 categories=1 projectKey=b08a5d89-…
788
+ port 41731 (suite) (no line)
789
+ port 41739 (prompt) (no line)
790
+
791
+ The control proves the instrument fires when the section is contributed, so its absence on `suite` and `prompt`
792
+ means those boots genuinely do not compose the section any more — which is the effect of the resolver fix:
793
+ `mined-rule capture` and `/chapters-repair` were doing `join(cwd, artifactStoreRoot)`, so the rig's **absolute**
794
+ per-port root became a path *inside the repository under test*, and the rules tier sitting in this repo was
795
+ being read into message 0 of every request. A GitHub checkout has no such tier, hence 2,783 chars there against
796
+ 7,276 here, hence every CI replay miss with a locally green chain.
797
+
798
+ All ten legs then re-distilled green under the frozen regimes (`FROZEN_EXIT=0`), and the claim being tested now
799
+ is the only one that matters: the same corpus replaying under `npm run test:e2e:replay` with no provider and no
800
+ credential, run twice, and then on CI.
801
+
802
+ ### Current truth of the pipeline (2026-10-27, after the confirmed header fix)
803
+
804
+ - All ten legs **distill** green under the frozen regimes (`FROZEN_EXIT=0`).
805
+ - `npm run test:e2e:replay` locally: **suite green, heavy reproducibly red** — `MISS tape=heavy entries=25
806
+ messages=3 closest=0001.json tied=[0001,0003,0004,…]`, identical in two consecutive runs. The chain uses
807
+ `&&`, so nothing past `heavy` has a replay verdict at all; no claim is made about them.
808
+ - Therefore the corpus is **not** committed and `CORPUS_HOST` is **not** stamped. The earlier commit
809
+ `2df6704` is stale against current code (it predates the store-root and selector-hook fixes) and I am leaving
810
+ it in history with this note rather than amending it.
811
+ - Next measurement, not another theory: run `LEG=heavy MODE=replay` with `E2E_TAPE_DIFF` and compare
812
+ `incoming[2]` against the **same-kind** stored candidate (the previous diff pairing was useless because
813
+ `storedCandidates` selects by message count, so a conversation request was matched against a title request —
814
+ that selection should be filtered by role/kind before comparison, which is itself a small rig fix).
815
+
816
+ ### `heavy`'s replay divergence is compaction TIMING, and the mechanism line is in the sink (2026-10-27)
817
+
818
+ `var/e2e-engine-errors-41733.log` around the replay:
819
+
820
+ [realm:engine:decline] relief declined while the surface is over the trigger (surface=47256 trigger=16000 window=32000 …)
821
+ [realm:engine:compactIfNeeded] decided: compaction … (trigger="context-overflow")
822
+
823
+ So relief was declined at the pressure check and the overflow path compacted a request later than the recorded
824
+ run did — the surface then carries a checkpoint notice where the tape carries reading turns, and the request
825
+ diverges at message 2 of 5. `heavy` is the leg deliberately parked ON the compaction boundary (threshold 0.5,
826
+ arrival floor pinned to 1M so arrival cannot relieve it), which is why it is the only leg where timing is
827
+ visible at all; every other leg replays fine.
828
+
829
+ This is NOT a blinding job: masking a compaction that fired at a different step would let the tape certify a
830
+ conversation that did not happen. Two honest options, in order of preference:
831
+
832
+ 1. **Make the boundary deterministic in the rig.** The record and replay paths must reach the pressure check at
833
+ the same request index. Whatever makes replay arrive at a different surface size — instant tape answers
834
+ skipping wall-clock gaps, the decline branch, or an aux call ordering — should be pinned by a row asserting
835
+ "the heavy session compacts at request N in both modes", measured from the sink, not argued.
836
+ 2. **Re-scope the leg's boundary** so it sits between two compaction points instead of on one, with the claim it
837
+ makes restated to match (compaction happened, surface recovered, session stayed healthy). The current
838
+ assertions already claim exactly those three things; the brittleness is in where the reads are placed.
839
+
840
+ Option 1 is the right one if achievable, because it preserves what the leg proves. Either way the corpus stays
841
+ unstamped and `heavy` stays red in the chain until it is fixed — no skip, no widening.
842
+
843
+ ### Autorun's idle edge: measured to the edge of what the host exposes (2026-10-27)
844
+
845
+ Props the dock actually receives (printed once, then the probe was removed):
846
+ `usePanelInfo, useSessions, useSessionStatus, useWorkspaces, useResource, useSessionRetainInfo, sessionId,
847
+ inputActions, useSession, useConversation, useInput, useTrajectory, useChat, useProjection,
848
+ renderFactorySlot, exec, session, input`.
849
+
850
+ So `useSessionStatus` IS provided — my first two guesses (scalar prop, absent hook) were both wrong. But
851
+ calling it with an identity selector yields an object with **no enumerable keys** (`statusKeys=` empty), so
852
+ `deriveRunning` has nothing to fold and `running` stays `undefined`, which keeps Autorun disabled. The leg now
853
+ asserts arm-ability and stays red on that assertion: the honest state is "not shipped, and the reason is
854
+ measured", not "works" and not "removed because inconvenient".
855
+
856
+ Next attempts, cheapest first: (1) `useSession` / `useChat` / `useConversation` are alternates carrying status
857
+ in this host family — try the same identity-selector read on each and print key counts once; (2) if all are
858
+ opaque, the dock hook contract needs a selector the host understands (a projection name, like the goal bar's
859
+ `useProjection("goal")`), and `docs/contract.md` § "What a dock component is handed" should record which;
860
+ (3) only if none work, `#126` grows a second paragraph: the dock cannot observe idle or write the composer, so
861
+ Autorun is not arm-able on 0.2.0-rc.2 and the bar ships with that stated.
862
+
863
+ ### Correction: `heavy`'s compaction boundary IS deterministic; the suspect is notice content (2026-10-27)
864
+
865
+ I wrote that replay "compacted one request later than the recorded run". The sink says otherwise — record at
866
+ 08:43:45 and replay at 08:46:02 log the **same** surfaces in the same order:
867
+
868
+ relief declined … (surface=33900 trigger=16000 window=32000 ratio=0.5): no safe…
869
+ relief declined … (surface=47256 trigger=16000 window=32000 ratio=0.5): no safe…
870
+
871
+ Identical numbers mean the engine reached the same decisions in both modes, so a timing/boundary theory was
872
+ wrong and option 1 ("pin the boundary") is not the fix. What remains, consistent with every byte diff so far,
873
+ is that the **checkpoint notice text itself** differs run to run (how many chapters are listed, the archive
874
+ count) — and that notice is part of the surface, so it changes est tokens for everything after it and the
875
+ request sequence stops matching the tape's, even though each individual volatile piece is now folded for
876
+ *MATCHING*. Matching and surface-size are different consumers of the same bytes: blinding the count lets a
877
+ request match; it does not make the surface the same length.
878
+
879
+ Therefore the right question is product-side and narrow: should the checkpoint notice's chapter list be
880
+ byte-stable for a given session state (fixed cap, stable ordinals), or is `heavy` asking for determinism the
881
+ notice never promised? Answer with a red row that measures two runs' surface sizes from the same session state,
882
+ not by adding another blind.
883
+
884
+ ### Ties are the disease, not the cure: `heavy` now ties FIVE entries and still misses (2026-10-27)
885
+
886
+ After re-distilling cleanly on the current build (`suite` 33, `heavy` 27, `arrival` 18, `enrich` 3 — the first
887
+ batch had been contaminated by strays I killed too late), the strict chain gives:
888
+
889
+ MISS tape=heavy entries=27 messages=7 closest=0003.json tied=[0003,0004,0005,0006,0007,…]
890
+
891
+ `suite` passes. `heavy` sits exactly on the compaction boundary, so its surface contains a checkpoint notice
892
+ whose chapter list grows with every extra compaction. My two folds (archive count, TOC line count) make those
893
+ surfaces **equal as match keys**, which is what produced five tied candidates — and when several tape entries
894
+ tie, the proxy may serve bytes recorded against a different surface size, so the following request diverges.
895
+ The pair run passed only because that capture happened to be small (4 entries) and unambiguous.
896
+
897
+ Conclusion, stated as the constraint it is: more masking makes matching weaker and replays less faithful, and
898
+ `heavy` cannot be made deterministic by blinding its way through. Two real options remain, both needing a red
899
+ row first:
900
+
901
+ 1. **Byte-stability in the notice itself** — render the chapter list from a deterministic cap and stable
902
+ ordinals so identical session state yields identical bytes on any machine. This reduces ties at the source
903
+ instead of papering over them, and it is a product property users get too (a notice that changes for no
904
+ reason is a cache-cost bug, not a cosmetic one).
905
+ 2. **Move `heavy`'s reads off the boundary** so the journey's surfaces are not decision-sensitive. The leg's
906
+ three claims (compaction happened, surface recovered, session stayed healthy) survive unchanged; only the
907
+ placement of the oversized reads moves. This is NOT deleting, skipping, or widening the assertion — if any
908
+ variant of it would let a real compaction failure pass, it is the wrong fix and must be refused.
909
+
910
+ Also recorded because it was my error and it cost a batch: strays from an aborted chain were still holding the
911
+ per-leg ports while the first four legs re-distilled, which is why `suite` produced a 6-entry tape that missed
912
+ its own replay. Check `pgrep -f "playwright test"` is empty BEFORE starting a distill, not after.
913
+
914
+ ### The header divergence is Node's ANCESTOR WALK, and `HOME` cannot fix it (2026-10-27)
915
+
916
+ Measured, in order:
917
+
918
+ - The boot spawns `dsh web` with `cwd: ROOT` (the repository under development). Node resolves bare
919
+ specifiers by walking ancestors of the importing file, and the walk from `/home/adrian/Projects/dsh-chapters`
920
+ passes `/home/adrian/node_modules` — **289 `@deepseek-ai/*` packages, including a linked `dsh` dev build**
921
+ (home `dsh-agent` = `0.2.0-rc.2`, repo root `dsh-agent` = `0.1.5-rc.2`, lock = `0.2.0-rc.2`).
922
+ - A CI checkout walks `/home/runner/work/dsh-chapters/dsh-chapters` → `/home/runner/work` → `/home/runner` →
923
+ `/home` → `/` and finds no `node_modules` at any of those levels. Same pin, same version string, different
924
+ available bundles → different mounted plugin set → different system header: 7,276 chars here against 2,783
925
+ there, diverging 141 characters in (right after the identity/model line), with **no section headings
926
+ differing at all** — prose in the same slots.
927
+ - Two fixes I already ruled out by measurement: re-running CI's exact `npm ci --prefix dev/dsh-host-lock`
928
+ (unchanged, still 7,276), and `npm ci` at the repo root (unchanged, still 7,276). Setting `HOME` to a scratch
929
+ directory would also do nothing: the walk is over the **path**, not the environment variable.
930
+ - This is the same class #85 described for a home under `var/` — "a linked dev build wearing the pin's version
931
+ string" — and that fix moved the *home* to `/tmp` but left `cwd: ROOT`, so the walk still reaches the
932
+ developer's tree. #89 fixed the **binary**; the **module resolution path** was never closed.
933
+
934
+ Two honest ways to close it, both needing a red row that pins the composed header length (or better: pins the
935
+ mounted-plugin list) so a drift fails loudly instead of producing a machine-local corpus:
936
+
937
+ 1. **Distill in a CI-shaped location.** Run the rig from a scratch clone whose ancestors contain no
938
+ `node_modules` (e.g. under `/tmp`, with the same `npm ci` steps `scripts/ci-replay.sh` runs). The corpus then
939
+ has the header every replay environment can reproduce. Cost: distilling outside the working copy, which my
940
+ per-port absolute store roots already support (#69) — and the workspace-relative `read` paths a leg uses
941
+ must still resolve, which they do for a clone of the same commit.
942
+ 2. **Cut the walk at the repo.** Give the boot an isolated module root — e.g. spawn with `cwd` set to a scratch
943
+ workspace and pass the repo path to the leg explicitly — so no ancestor above the repo can supply a bundle.
944
+ This is the cleaner long-term rig property (the boot should never be able to see `~/node_modules`), but it
945
+ changes what `cwd` means for every leg and so needs the leg-side workspaces audited first.
946
+
947
+ Until one of them lands, **no corpus committed from this machine is CI-replayable**, and the honest status is:
948
+ local strict chain is what a local machine can prove (nine legs green, `heavy` red on ties), while CI fails at
949
+ `suite` with `commonPrefix=0` — the same number, a different meaning from the earlier proxy fault: the header,
950
+ not the endpoint.
951
+
952
+ ### The CI-shaped probe was inconclusive — recorded as inconclusive (2026-10-27)
953
+
954
+ Ran `suite` replay from a clean clone at `/var/tmp/dsh-probe` (no `node_modules` in its ancestor path, the
955
+ property CI has). Outcome: **4 failed**, but the failures were `example.spec.ts` ("has title", "get started
956
+ link") and `discovery.spec.ts` — the page-load scaffolding — and there was **no `MISS tape=` line at all**. So
957
+ the clone's browser/app environment was broken before the tape matcher could say anything, and the run neither
958
+ confirms nor refutes the ancestor-walk theory. Two facts it did establish:
959
+
960
+ - A fresh clone could not boot at all until `4b66a29` (the `.dshdev-local` ENOENT stack trace is the red
961
+ evidence for that fix), so this experiment was not runnable before it.
962
+ - `E2E_PROVIDER=harness` in a clean clone refuses correctly (no ambient profile → no route), which is the
963
+ intended guard, and CI's shape needs no provider because replay never authenticates.
964
+
965
+ What would settle it, cheaply and in one run: reproduce the page-load failure first (most likely the Playwright
966
+ browser path — CI installs browsers into the workspace cache and the rig's `e2e-one.sh` may expect them there),
967
+ then rerun the clone's `suite` replay and read whether `msg0` diverges. The claim on the table stays exactly as
968
+ recorded above: same pinned host, different available bundles, different composed header — a hypothesis with
969
+ strong code-level support and no A/B yet, so no committed tape set may be called CI-replayable on its strength.
970
+
971
+ ### CONFIRMED by A/B: the same tape replays green here and misses in a CI-shaped clone (2026-10-27)
972
+
973
+ Same commit, same 33-entry `suite` tape, two locations:
974
+
975
+ - `/home/adrian/Projects/dsh-chapters` (ancestors include `/home/adrian/node_modules`, 289 `@deepseek-ai/*`
976
+ packages): `LEG=suite MODE=replay` → **4 passed**.
977
+ - `/var/tmp/dsh-probe` (a clean clone; no `node_modules` at any ancestor level; same pinned host installed with
978
+ CI's own `npm ci` commands, same `npm run build`, same Playwright browser build copied into `var/ms-playwright`):
979
+ `LEG=suite MODE=replay` → **`MISS tape=suite entries=33 … tied=[every entry]`** — which is precisely CI's
980
+ signature, reproduced locally without a push.
981
+
982
+ So the ancestor walk is not a hypothesis any more: the machine that records a corpus changes message 0, and a
983
+ tape recorded here is unreplayable where the runner has no developer tree. Two practical consequences, both
984
+ already paid for elsewhere in this repo: the rig's `WORKSPACE-BLIND` fold is what lets tapes from either path
985
+ compare at all, and the distill must therefore run in a CI-shaped location, not in the working copy.
986
+
987
+ Also worth keeping: my first two probe runs failed for reasons that had nothing to do with the theory — no
988
+ browsers in the clone (`var/ms-playwright` is gitignored and the workspace cache is per-checkout) and a `cp -a`
989
+ whose error I had hidden behind `2>/dev/null`. Both looked like "the experiment refutes the theory" until I read
990
+ the error text. An inconclusive run and a refutation are different results, and only one of them is interesting.
991
+
992
+ ### The scratch distiller reaches the model but `suite` stalls in the clone — next thing to diagnose (2026-10-27)
993
+
994
+ What the CI-shaped run proved and what it did not:
995
+
996
+ - **Proved:** the ancestor walk is the header cause (green here / `MISS … tied=[every entry]` there for one
997
+ 33-entry tape), the credential chain works once `.dshdev-local/e2e.env` is placed in the clone, and the
998
+ sync-back guard did its job: the distill never exited 0, so nothing was copied over the working corpus and the
999
+ committed tapes are intact at 33 files.
1000
+ - **Did not:** produce a corpus. `suite` recorded 5 entries in ~3 minutes with clean `status=200` responses, then
1001
+ went silent — no model traffic for 11 minutes while Playwright sat inside test 1. The clone's server log shows
1002
+ the last turn ending normally and this line from the bar:
1003
+
1004
+ [chapters-prompt] prompt: not enabled (no defining model) — round skipped
1005
+
1006
+ which is a *different* machine-local difference: the working copy has a `promptModel` row in its ambient
1007
+ profile, the clone composes without one. So the clone is not yet a faithful recording environment for legs
1008
+ whose journey touches the suggestion bar, and I stopped the run rather than let it burn a timeout.
1009
+
1010
+ Order for the next attempt, cheapest first: (1) make the route/prompt-model configuration part of the rig's
1011
+ seeded profile rather than ambient state, so clone and working copy compose the same thing — this is the same
1012
+ class as everything else today, authority in tracked seeds; (2) confirm with `suite` alone that the fork door
1013
+ appears in the clone; (3) only then run the full ten legs there, then the strict chain there, then stamp and
1014
+ commit. Do not assume a stall means "slow": the journal said the last request finished 11 minutes earlier.
1015
+
1016
+ ### Clone stall: located to `a-knowledge-loop.spec.ts:41`, but NOT yet explained (2026-10-27)
1017
+
1018
+ Established by measurement, including two of my own wrong inferences corrected in place:
1019
+
1020
+ - The clone's `suite` leg stalls at `a-knowledge-loop.spec.ts:41` after 5 tape entries; the last model traffic is
1021
+ `DONE status=200` and the server's last line is `turn/end sighted seq=35`, so the turn completed and the journey
1022
+ moved past it. Silence then lasts 10+ minutes with no further requests.
1023
+ - **Wrong inference #1 (mine, from an hour ago):** `[chapters-prompt] prompt: not enabled (no defining model)` is
1024
+ printed by BOTH the local and the clone boot, so `promptModel` is not the clone difference. I had written it up
1025
+ as the next fix; it is not, and that correction is in the commit that followed.
1026
+ - **Wrong inference #2 (partly):** I expected my new bounded door assertion to fail fast and name the door. It has
1027
+ not produced a failure, so the stall is not obviously the door click — it is somewhere later in the journey
1028
+ (the pool push/search steps are the remaining candidates with long polls), or the assertion is being satisfied
1029
+ and the wait is downstream.
1030
+
1031
+ What would settle it, and is the next action: run the clone's suite leg with Playwright's reporter verbose plus a
1032
+ shorter per-test timeout (or split the spec's steps), and read which `expect.poll` is live when it hangs — the
1033
+ sink and journal already show there is no model traffic, so it is a browser-side wait. Do NOT re-run the full
1034
+ ten-leg scratch distill until that is answered; a batch that stalls on leg 1 for 5,400s produces nothing and the
1035
+ sync-back guard (correctly) refuses to copy anything.
1036
+
1037
+ Also true and worth keeping: the guard worked as designed — the earlier scratch failure synced nothing back and
1038
+ the committed corpus is intact at 33 suite files, because I restored it with `git checkout` after the first
1039
+ destructive version of the script.
1040
+
1041
+ ### Clone stall, labeled: the knowledge loop's FIRST turn never ends; 3 of 4 suite tests pass there (2026-10-27)
1042
+
1043
+ Running the clone's `suite` with a short per-test timeout and the line reporter produced the label the hang
1044
+ never would:
1045
+
1046
+ Error: expect(received).toBe(expected) > 841 | await expect.poll(turnSeen, { timeout: turnMs, … },
1047
+ 'this session's own log shows turn/end')
1048
+ 1 failed · 3 passed (12.3m)
1049
+
1050
+ So in the CI-shaped clone the other three suite specs pass, and the knowledge loop dies waiting for `turn/end`
1051
+ on its FIRST turn. Supporting measurements, all from the clone's own artifacts:
1052
+
1053
+ - Model traffic is healthy up to a point: `DONE status=200` for requests growing `msgs=7 → 10 → 13` (the read
1054
+ loop), last response 47,568 bytes at 11:56:59, then **no further requests for ~11 minutes** while the test
1055
+ waited. So the stall is not a slow endpoint and not a refused overflow.
1056
+ - The engine sink's last lines are `compactIfNeeded entered (trigger="pressure")` followed by
1057
+ `decided: none` — pressure was seen and relief declined, then silence.
1058
+ - Ruled out since: `promptModel` (both boots print the same `not enabled` line), port collision between the
1059
+ clone and the working copy (`ss -ltn` shows nothing on 417xx now, and the legs use per-project ports), and
1060
+ "the door click hangs" (the bounded door assertion I added never fired — the journey does not reach it).
1061
+
1062
+ Next probe, which is the only thing that can answer this: run the clone leg with the throwaway home PRESERVED
1063
+ (the boot deletes `E2E_HOME` at teardown — that is why every diagnosis so far has been second-hand), then read
1064
+ that session's own log for the last event before the silence: whether a tool call is mid-flight (a `git push`
1065
+ against the http-git remote, or a `read` on a path that exists in the working copy but not in the clone) or the
1066
+ engine is waiting on the declined relief. My suspicion, stated as suspicion and not as a finding: the journey's
1067
+ later steps use workspace paths, and a clone at `/var/tmp/dsh-rig` has a different absolute root, so a step that
1068
+ resolves a path against the ORIGINAL working copy would block. That is testable in one preserved-home run.
1069
+
1070
+ ### The clone's knowledge loop DID complete — the stall is in the rig's own session adoption, not the product (2026-10-27)
1071
+
1072
+ Read directly from the surviving throwaway home (`/tmp/dsh-e2e/e2e-home-41731`, 44 events, `session-57b99de0…`,
1073
+ the same id the engine sink named during the stalled run):
1074
+
1075
+ user/message seq 10: "Which file defines the chapter composer merge rule? …" ← the needle, present
1076
+ … tool/result seq 37 …
1077
+ turn/end seq 43: {"turn":1,"reason":{"kind":"completed"}} ← the turn, completed
1078
+
1079
+ And the assertion still reported `Expected: true / Received: false` after `Timeout 720000ms exceeded` at
1080
+ `tests/e2e/session.ts:841` (`turnSeen`). Two sessions exist in that home: the journey above, and a 6-event one
1081
+ whose last events are a `chapters-prompt … state` command run. `turnSeen` needs
1082
+ `user/message` with `seq > preSeq` carrying the needle **in the log it is reading**, so the predicate was pointed
1083
+ at the wrong session (or read an empty log) for twelve minutes while the work finished elsewhere.
1084
+
1085
+ Why this matters more than the clone question: `newSessionWithTurn` is the rig's shared entry helper, used by
1086
+ almost every leg. A poll that can silently watch the wrong session is a false-red generator — and the #77
1087
+ comment in that same function records it happening before, on cold homes, with a passing run nobody could see.
1088
+ The fix is rig-side and testable: when the poll gives up, the diagnostic must print the ids it tried, what
1089
+ `listRecentSessionIds()` returned, and the byte length of `sessionLogTextById` for each — the four-facts message
1090
+ that #61a promised exists for exactly this situation, and which did NOT appear in this run's output. Finding out
1091
+ why the diagnostic was swallowed is the first step; it is the instrument this repo's own rule demands ("a
1092
+ detected failure is a delivered failure").
1093
+
1094
+ Sequence I will follow: (1) restore/extend that failure diagnostic so it always prints the per-id evidence;
1095
+ (2) add a row for the two-session case (needle in session B, spec holding session A ⇒ helper adopts B);
1096
+ (3) then re-run the clone's `suite` — a CI-shaped corpus is the goal, and the clone is not CI-shaped-broken, it
1097
+ is being mis-read by my own harness code.
1098
+
1099
+ ### Confirmed: the session-scan fix makes the clone's `suite` pass (4 passed, 1.7m, 38 entries) — and it exposes the next decision (2026-10-27)
1100
+
1101
+ Same clone, same command, one change (`sessDirs` returning absolute paths and unioning every workspace dir in
1102
+ the boot's home): the journey that had completed unseen for 720 seconds now finishes in 1.7 minutes. So the
1103
+ stall was the rig's own blind read, exactly as the preserved home showed.
1104
+
1105
+ **The consequence to decide before the next big run.** A corpus recorded in the clone replays there, and CI is
1106
+ CI-shaped — but my working tree is NOT, because the pinned host the boot spawns lives at
1107
+ `ROOT/dev/dsh-host-lock/node_modules/.bin/dsh`, and Node's ancestor walk from inside the repository reaches
1108
+ `/home/adrian/node_modules` (289 `@deepseek-ai` packages). That is what made the header 7,276 chars here and
1109
+ 2,783 in a clone. So:
1110
+
1111
+ - If I record in the clone and sync back, **CI goes green and `npm run test:e2e:replay` in the working copy
1112
+ goes red** — a corpus that only replays where it was not recorded.
1113
+ - The right fix is to remove the asymmetry, not choose a side: put the rig's host install OUTSIDE the repository
1114
+ (e.g. install the pinned lock under `/tmp/dsh-e2e/host-<pin>` and spawn that binary), so the working copy and a
1115
+ CI runner resolve the same bundles and compose the same header. `resolveHostBinary` already reports which
1116
+ binary it used and whether it is pinned (#89), so the authority exists; what is missing is an install location
1117
+ whose ancestors are clean.
1118
+ - Interim honesty: until that lands, "chain green" must name its location. A local green and a CI green are
1119
+ currently different measurements, and I will not call them the same result.
1120
+
1121
+ ### The runner mounts 7 tools and it is STABLE — not a race (2026-10-27, three-request dump from CI itself)
1122
+
1123
+ CI's artifact `var/e2e-header-suite.log` now contains more than one request:
1124
+
1125
+ chars=2893 tools=7 ← the conversation request (its aux sibling is `chars=396 tools=0`)
1126
+ chars=396 tools=0
1127
+
1128
+ and locally the same two lines read `chars=7396 tools=34` / `chars=396 tools=0`. **The aux header is identical on
1129
+ both machines**, so the host and its prompt machinery agree; only the agent assembly differs, and it differs
1130
+ consistently rather than drifting — which rules out the mount-timing race I raised as the leading alternative.
1131
+
1132
+ Every environment level I can measure is equal, with numbers, not narrative:
1133
+
1134
+ | level | this host | CI runner |
1135
+ |---|---|---|
1136
+ | host lock deps | `declared=79 missing=0` | `declared=79 missing=0` |
1137
+ | installed at lock root | `deepseek-ai-packages=289`, `installed-top-level=179` | same |
1138
+ | repo-root `@deepseek-ai` | 34 dirs (18 undeclared by package.json) | report uploaded (same script) |
1139
+ | profile base tree | `node_modules=2 deepseek-ai=0 @treeseed=1` | built by the same `bootstrap-dev-profile.sh` |
1140
+ | host provenance | lock resolves to `registry.npmjs.org/…/dsh-0.2.0-rc.2.tgz`, integrity matches `npm view` | same tarball |
1141
+ | pinned host install | `npm ci --prefix dev/dsh-host-lock` (CI's own command) | same |
1142
+
1143
+ Refuted as causes, each in one run: ancestor `node_modules` walk (a clean clone at `/var/tmp`, with zero set
1144
+ difference in installed packages, composes the LONG header), `$HOME` state (reran with `HOME=/var/tmp/clean-home`
1145
+ — unchanged), external host install location (`E2E_HOST_DIR`, 2 rows, unchanged), header prose or section
1146
+ headings (zero CI-only sentences: CI is a strict subset), and the "profile plugins decide it" story once the
1147
+ profile-base counts came back equal.
1148
+
1149
+ **What this means for the objective, stated without softening:** a corpus recorded on any machine that mounts 34
1150
+ tools cannot be replayed by a runner that mounts 7, because message 0 is the tape key. So either (a) the
1151
+ assembly difference is found and the rig mounts identically everywhere — the remaining candidates are the
1152
+ session's `permission/preset` / `sandbox/mode` / `approval/policy` kernel events and anything the profile's
1153
+ `settings.yaml` seeds differently, which is a code read of the host's assembly path rather than another
1154
+ environment A/B; or (b) the corpus is recorded BY the runner, which needs the route credential as a repository
1155
+ secret — a maintainer action and the exact blocker issue #56 records ("no credential for the runner"), so I am
1156
+ naming it as your decision rather than working around it. I have not added a blind to mask the header, and I do
1157
+ not intend to: masking it would certify a corpus recorded by different software than the one that replays it.
1158
+
1159
+ ### The two machines do NOT mount the same plugin list — and the id that differs is untracked (2026-10-27)
1160
+
1161
+ The boot now reports its own mounted tree (`var/e2e-home-plugins-<port>.log`), so this is read from artifacts,
1162
+ not inferred:
1163
+
1164
+ CI: patchPluginIds=preset-chapters,agent-preset-registry,'@treeseed/dsh-chapters',llm-pi-ai,
1165
+ agent-default-model,llm-gate,fetch-timeouts,ui-settings-general
1166
+ local: patchPluginIds=preset-chapters,agent-preset-registry,'@treeseed/dsh-chapters',llm-pi-ai,
1167
+ agent-default-model,llm-gate,fetch-timeouts
1168
+
1169
+ `ui-settings-general` appears in **no tracked file**: `tests/e2e/fixtures/dev-profile-cordis.patch.yml` (0),
1170
+ `.dshdev-local/profiles/web/cordis.patch.yml` (0), and it is present in the AMBIENT `~/.dsh/profiles/web/
1171
+ cordis.patch.yml` (2 occurrences). So a booted home's plugin list can be inherited from developer state, and the
1172
+ two machines' assemblies are not the same input — which is precisely the class that has produced every
1173
+ local-green/CI-red result in this session.
1174
+
1175
+ Everything else is now eliminated with numbers rather than argument:
1176
+
1177
+ - installed bytes: `topVersion=0.2.0-rc.2`, `checked=289 mismatched=0 allIntegrityMatch=true` — on BOTH machines
1178
+ (each installed package's embedded dist integrity equals its lockfile entry).
1179
+ - host's own `request/header`: `config` and `adapterDefaults` differ in **0 keys**; the aux header is `chars=396
1180
+ tools=0` on both; the conversation header is `chars=7396 tools=34` here vs `chars=2893 tools=7` there, and it is
1181
+ STABLE across requests, so it is not a mount race.
1182
+ - theories killed by a run this round: `CI=true`/`GITHUB_ACTIONS` (no effect), `NODE_PATH` (not set locally at
1183
+ all), undeclared repo-root packages (34 on both, and they are hoisted transitives), the pre-boot
1184
+ `profile-base-node_modules=0` reading (an order artifact — the boot reports its own tree now instead).
1185
+
1186
+ Next step, and it is a rig fix with a row, not another A/B: make a booted home's plugin list derive ONLY from the
1187
+ tracked seed — assert in a row that `patchPluginIds` equals a fixed expected list, so a machine that injects an
1188
+ extra ambient id fails instead of quietly recording a corpus no runner can replay. Then re-check the tool count
1189
+ from the header dump: if registering `ui-settings-general` (or any ambient id) is what moves 34 to 7, the
1190
+ asymmetry is closed by construction and the corpus becomes portable.
1191
+
1192
+ One honest process note: the previous commit claimed an integrity scan that had NOT been applied (a silent
1193
+ python-heredoc failure I read past). The scan exists now, verified with `checked=289 mismatched=0`, and the
1194
+ correction is in that commit's message. The probe run with `env -u NODE_PATH` also failed at boot
1195
+ (`e2e boot failed within 60s (port 41737)`), which was a port/env transient and is NOT evidence about tools —
1196
+ recorded so nobody reads a failed run as a result.
1197
+
1198
+ ### Like-for-like at last: same project, two machines, different mounts (2026-10-27)
1199
+
1200
+ Correcting my own earlier comparison (CI's `suite` against my `fork` — not the same project, so the plugin-id
1201
+ difference I announced was not yet proven):
1202
+
1203
+ | `suite` booted on | header | tools | plugin ids |
1204
+ |---|---|---|---|
1205
+ | this host | `chars=7396` | 34 | `preset-chapters, agent-preset-registry, @treeseed/dsh-chapters, llm-pi-ai, agent-default-model, llm-gate, fetch-timeouts` |
1206
+ | the runner | `chars=2893` | 7 | same **plus `ui-settings-general`** |
1207
+
1208
+ So for the same project the runner mounts one MORE plugin and gets 27 FEWER tools. `ui-settings-general` is in no
1209
+ tracked file (seed 0, dev-home 0, ambient `~/.dsh` 2) — so the file the boot ends up with is being modified by
1210
+ something other than our seeds, most plausibly the host writing its own defaults into
1211
+ `profiles/web/cordis.patch.yml` at startup when the home lacks them.
1212
+
1213
+ Consequence and next measurement, in that order: the boot's mount report should be taken TWICE — once at the
1214
+ moment the URL appears and once after the journey's first turn — so we can see whether the id list is what the
1215
+ seed wrote or what the host rewrote, and correspondingly whether tools grow or shrink. If the host rewrites the
1216
+ patch, the rig must pin it (write, verify by re-read, and refuse to boot if the ids differ from the tracked
1217
+ list) — that is the row this whole investigation has been driving at: **a booted home's plugin list is a rig
1218
+ fact, not ambient state.**
1219
+
1220
+ ### The profile patch is rewritten DURING the run — so single-reading comparisons were unreliable (2026-10-27)
1221
+
1222
+ Same file, same boot, two moments: `count=43` at URL-ready, `count=44` after the journey. The host expands or
1223
+ repairs `profiles/web/cordis.patch.yml` mid-flight.
1224
+
1225
+ Therefore, correcting myself again: my claim that "the runner mounts `ui-settings-general` and this host does
1226
+ not" was drawn from a single boot-time reading on each side and is **not** a reliable machine comparison —
1227
+ locally that id appears only after the run. The genuine, still-unexplained asymmetry is unchanged and remains
1228
+ the thing to chase: for the same project, `tools=34 / chars=7396` here against `tools=7 / chars=2893` on the
1229
+ runner, with the same 21 `tool-*` rows present in both boot-time patches and `config`/`adapterDefaults`
1230
+ identical.
1231
+
1232
+ Next read, from CI's own two-phase artifact: does the runner's patch also grow mid-run, and does its tool count
1233
+ at request 1 match its boot-time id set or the later one? If the runner's id set is stable at 43 and tools stay
1234
+ 7, the failure is runtime mounting (silent), and the rig must surface it — e.g. by having the plugin print the
1235
+ registered tool names at boot into the per-port diagnostics, which the artifact already uploads.
1236
+
1237
+ ### Elimination table: what the 34-vs-7 tool gap is NOT (2026-10-27, all measured)
1238
+
1239
+ The gap is real and stable, and it is the reason a locally recorded corpus cannot replay on the runner (message 0
1240
+ is the tape key). Everything below was a hypothesis I raised and then killed with a run rather than an argument:
1241
+
1242
+ | hypothesis | how it was refuted |
1243
+ |---|---|
1244
+ | host install is a dev build wearing the pin | lock `resolved:` = `registry.npmjs.org/…/dsh-0.2.0-rc.2.tgz`, integrity equals `npm view`; both report `topVersion=0.2.0-rc.2` |
1245
+ | deps differ on disk | both: `declared=79 missing=0`, `deepseek-ai-packages=289`, `installed-top-level=179` |
1246
+ | package CONTENT differs | both: `checked=289 mismatched=0 allIntegrityMatch=true` |
1247
+ | undeclared repo-root packages | both report `repo-root-deepseek-ai=34`, 18 undeclared (hoisted transitives) |
1248
+ | profile plugin list differs by machine | two-phase boot report: runner `44 at boot / 44 late`; here `43 at boot / 44 late` — same final set, and `ui-settings-general` appears mid-run on both |
1249
+ | mounted tool rows missing on the runner | both patched profiles contain the same 21 `tool-*` rows (`tool-bash, tool-fs, tool-goal, tool-jobs, tool-subagent*, tool-web, tool-workflow, …`) |
1250
+ | ancestor `node_modules` walk | a clean clone at `/var/tmp` (no `@deepseek-ai` at any ancestor) still composes `7396/34` |
1251
+ | `$HOME` state | reran with `HOME=/var/tmp/clean-home`: unchanged |
1252
+ | ambient-profile leak (`DSH_PROFILE_DIR`) | `rigChildEnv` strips it (2 rows); unchanged |
1253
+ | external host install location | `E2E_HOST_DIR` preferred when set (2 rows); unchanged |
1254
+ | CI environment flags | `CI=true GITHUB_ACTIONS=true`: unchanged |
1255
+ | `NODE_PATH` | not set in this environment at all |
1256
+ | record-vs-replay mode | local `suite` replay in CI shape AND in harness shape both dump `7396/34` |
1257
+ | host config / adapter defaults | the host's own `request/header` event: `config` and `adapterDefaults` differ in **0 keys**; aux header identical (`396/0`) on both |
1258
+ | mount timing race | the runner's tool count is 7 on every captured request, and its patch is 44 ids at both phases |
1259
+
1260
+ What is left: the runner boots the identical host, identical bytes, identical profile, and registers only our
1261
+ plugin's 7 tools, while this host registers 34 — with no error anywhere in the boot's output (CI's server log is
1262
+ 292 bytes, just the URL line). The next instrument worth building is therefore not another environment A/B:
1263
+ capture the mount FAILURE itself, e.g. have the rig ask the host to report per-plugin load results (the loader
1264
+ knows which `tool-*` rows it resolved) and fail the boot when a `tool-*` row in the profile does not appear in
1265
+ the registered tool list. That is a red row with a real assertion, it converts a silent gap into a loud one, and
1266
+ it is the version of "a detected failure is a delivered failure" that this incident needs.
1267
+
1268
+ Until that gap is closed, no corpus recorded here is replayable by the runner, so item (2) of the objective
1269
+ cannot be satisfied by re-distilling harder. Local-only items that ARE still actionable: `heavy`'s ties,
1270
+ `coverage:e2e`, and the Autorun-displaces-goal pixel.
1271
+
1272
+ ### `heavy`'s divergence, profiled: same notice at msg1, different turn AFTER it (2026-10-27)
1273
+
1274
+ Captured with `E2E_TAPE_DIFF` (message counts equal, so the pairing is valid this time):
1275
+
1276
+ ```
1277
+ LIVE (5 msgs): system 7276 | notice 1005 | assistant 77 | user 104 | user 314
1278
+ TAPE0 (5 msgs): system 7276 | notice 1005 | assistant 300 | tool 15 | user 314
1279
+ TAPE1 (5 msgs): system 7276 | notice 1005 | assistant 300 | tool 15 | user 314
1280
+ TAPE2 (5 msgs): system 7276 | notice 1005 | assistant 300 | tool 15 | user 314(?)
1281
+ ```
1282
+
1283
+ What that says, and what it rules out:
1284
+
1285
+ - **msg0 and msg1 are byte-identical across all candidates** — the header and the checkpoint notice match exactly,
1286
+ so this is not the header, not a volatile count, not the TOC fold, and not the ordering race I closed last
1287
+ round (that race was real; it was not this).
1288
+ - The divergence is **one turn later**: the tape's surface continues with the reading assistant turn plus its
1289
+ tool result; the live surface continues with the final `STILL-HERE` turn. Same notice, so both runs DID compact
1290
+ — but at different message indices, i.e. the compaction boundary sits one assistant/tool round apart between
1291
+ record and replay even with served bytes identical.
1292
+ - Therefore the remaining mechanism is in **when the engine decides to compact**, given an identical surface: the
1293
+ only inputs left are est-token drift (which depends on bytes that already match) and the bounded-loop/relief
1294
+ path (`relief declined … surface over trigger`, then a later `context-overflow` compaction — the sink shows the
1295
+ same two surface sizes in both modes, so a *later* decision point is plausible while the decline itself is
1296
+ identical).
1297
+
1298
+ Next instrument, and it is small: assert the compaction index directly. During distill, record the message index
1299
+ at which each `compaction/summary` event lands for this session; during replay, assert the same indices before
1300
+ the journey proceeds. If replay can be made to compact at the recorded index, the leg is deterministic and the
1301
+ tape matches; if it cannot, the boundary is genuinely non-replayable and `heavy`'s oversized reads must move off
1302
+ it — restating the leg's claim only as "compaction happened, surface recovered, session stayed healthy", which
1303
+ is what it already asserts, with the reads placed between boundaries. Either outcome is decided by the
1304
+ assertion, not by another fold, and no blind over `msg2` is acceptable: it is the only message that still carries
1305
+ information about where the boundary fell.
1306
+
1307
+ ### Where the corpus stands after the 0.9 change (2026-10-27)
1308
+
1309
+ `heavy` is fixed and pair-verified twice under the new trigger (4 entries, replay 1 passed 29.0s), and its
1310
+ capture is committed with the pins that produced it. The uniform ten-leg pass that followed did NOT converge,
1311
+ and the honest reading is two separate items, not one mystery:
1312
+
1313
+ - `plans` failed its distill (`exit=1` at `tests/e2e/plans-approved-plan…`), the leg that runs against the
1314
+ scripted stub — so the stub path needs its own look before any batch is trusted.
1315
+ - `suite` missed against a tape distilled MINUTES earlier in the same pass: `MISS tape=suite entries=8
1316
+ messages=5 commonPrefix=2 divergeAt=msg2 got="{\"role\":\"assistant\",\"content\":null,…`. A `content: null`
1317
+ assistant turn (tool-calls-only) where the capture had content is live-model variation inside the recorded
1318
+ journey, the same class as `arrival`'s "never found the buried token" and `fanout`'s interleave. That means
1319
+ the chain is not green yet and **the corpus stays unstamped** — I am not committing a second mixed set, and I
1320
+ am not re-rolling `suite` until green either: if it needs more than one attempt, that is a flake finding to
1321
+ file with both logs, per the rule I wrote after `arrival`.
1322
+
1323
+ So the sequence is: fix the `plans` stub path, get one clean ten-leg pass where every leg distills green, then
1324
+ the chain twice locally, then stamp and commit as one change. The runner-side blocker (7 registered tools) is
1325
+ separate and unchanged.
1326
+
1327
+ ### RETRACTION: `heavy` is NOT fixed by the 0.9 trigger — a fresh capture misses again (2026-10-27)
1328
+
1329
+ I wrote earlier this session that moving `heavy` to `threshold 0.9` fixed it, "pair-verified twice". The clean
1330
+ ten-leg pass disproves that: `heavy: exit=0 tapeEntries=7` distilled green, and the strict chain immediately
1331
+ after reports `MISS tape=heavy entries=7 messages=6 tied=[0003…0006]`. The two earlier passes were real but they
1332
+ were two samples of a journey whose compaction boundary is not fixed by construction — exactly the same failure
1333
+ shape I documented for `arrival` (model never found the buried token) and `fanout` (a 36-request interleave),
1334
+ just with a boundary instead of a retrieval.
1335
+
1336
+ So the honest position:
1337
+
1338
+ - `threshold: '0.9'` stays (it aligns heavy with every other leg and the pins/drift rows argue for it), but it
1339
+ is NOT a determinism fix and I will not describe it as one.
1340
+ - The committed corpus is unchanged: this pass's captures were discarded (`git checkout` + `git clean` on
1341
+ `tests/fixtures/model-tape`), because `fanout` also failed its capture (`exit=1`), so a commit now would
1342
+ record a mixed tree a second time.
1343
+ - What heavy actually needs is a boundary fixed by construction: assert the compaction INDEX at distill time and
1344
+ require it at replay (or place the oversized reads strictly between boundaries). Either is a real design
1345
+ change with a red row. Guessing at a threshold and calling a green pair "fixed" is the failure mode this file
1346
+ keeps catching me doing.
1347
+
1348
+ ### CI state after the mount-fault work (2026-10-27): deterministic green at zero margin, acceptance red by name
1349
+
1350
+ Run `40554042382` on `fix/token-plan-provider-row`:
1351
+
1352
+ typecheck · build · unit + integration (coverage gate 85/85/85) success Branches 85% (5007/5890)
1353
+ does the pinned host support preset declaration rows? success
1354
+ acceptance — ten tape-replay legs, no LLM failure MOUNT FAULT
1355
+
1356
+ Two things worth holding onto:
1357
+
1358
+ 1. **The gate is at exactly 85%, with zero margin.** It passed because nine host-fact rows that used to
1359
+ *abstain* on the runner now run (they looked only in `$HOME/node_modules`; they try `dev/dsh-host-lock`
1360
+ first), and one real contract row covers the pool-miss → local-store fallback. That is the honest kind of
1361
+ coverage gain, but a future contributor can flip the gate by adding a single uncovered branch. If it fails
1362
+ for someone: the fix is another real branch row, not a threshold edit.
1363
+ 2. **Acceptance fails for the named reason, not a mystery.** The runner composes a 7-tool session where this
1364
+ host composes 34 with identical installs, identical profile ids, identical `config`/`adapterDefaults`, so the
1365
+ corpus recorded here cannot key-match there. The rig refuses loudly (`MOUNT FAULT`) instead of writing tapes
1366
+ nobody else can replay. The two exits stay open and both are named on #56: fix headless preset mounting of
1367
+ `@deepseek-ai/dsh-tool-*` (host-side, outside this repo), or distill on the runner with the route credential
1368
+ as a repository secret (a maintainer action). #127 therefore stays unmerged: agents may merge to `staging`
1369
+ only after the acceptance job passes.
1370
+
1371
+ ### Sandbox provisioning did NOT explain the tool gap — both hypotheses refuted in one run (2026-10-27)
1372
+
1373
+ I theorized the runner's 7 tools came from missing bubblewrap (bash/fs/pwsh mount through `dsh-bash-sandbox`,
1374
+ `dsh-fs-sandbox`, `dsh-pwsh-sandbox`). The job installed `bubblewrap`, then lifted Ubuntu's AppArmor restriction
1375
+ on unprivileged user namespaces, and the same artifact that proved the problem now disproves the theory:
1376
+
1377
+ before: bwrap version=not-installed userNamespaceTest=failed: spawnSync bwrap ENOENT
1378
+ after: bwrap version=bubblewrap 0.9.0 userNamespaceTest=ok
1379
+ both: chars=2893 tools=7 names=chapters_artifact,chapters_continue,chapters_fork,…(ours only)
1380
+
1381
+ So bubblewrap is present and functional, and the session still registers only our plugin's tools. Provisioning
1382
+ stays in the workflow — a runner that can't sandbox is a worse test bed regardless, and the probe is worth
1383
+ keeping — but it is NOT the cause, and I am not going to describe it as one because it was my hypothesis.
1384
+
1385
+ That closes the environment class: installed bytes (integrity-verified), profile ids (two-phase mount report),
1386
+ `config`/`adapterDefaults` (0 differing keys), aux header (identical), variable environment (`env -i` boot),
1387
+ `$HOME`/ancestor `node_modules` (clean clone composes the long header), AppArmor/userns (now `ok`), and CI flags.
1388
+ What remains is inside the host's own preset/tool assembly — which package registers `read`/`bash` and under
1389
+ what condition — and that is a host-code question, not a rig or provisioning one. The next person to look at it
1390
+ should start from `dsh-agent-preset` and the `agent-preset-registry` row, not from my environment again.
1391
+
1392
+ Meanwhile the facts that are settled and useful: CI's deterministic job is **green** (branch gate 85.0%,
1393
+ 1,257+ rows, 0 fail) and the corpus is committed as one change with the strict chain green twice locally. The
1394
+ acceptance job stays red with a named cause, and #127 stays open because acceptance must pass before a merge.
1395
+
1396
+ ### The tool gap, narrowed to the host's assembly, with the last two local explanations closed (2026-10-27)
1397
+
1398
+ After bubblewrap was provisioned and namespaces verified (`userNamespaceTest=ok`), the runner still composes
1399
+ `tools=7 / chars=2893` against `tools=34 / chars=7396` here. Two more classes now have evidence against them:
1400
+
1401
+ - **Our preset pruned rows on CI.** `buildPresetRows()` returns `dropped[]` and `src/preset.ts:274` logs
1402
+ `preset "<id>" registered — dropped N row(s)`. That line is absent from CI's per-port server log — and also
1403
+ absent locally, because the local log holds the same two `[chapters-prompt]` lines and nothing more, so the
1404
+ absence proves nothing either way. The drop path is therefore **not evidenced**, only unfalsified; treat it as
1405
+ a hypothesis, not a finding.
1406
+ - **Our plugin is not mounted on the runner.** Refuted: CI's server log carries our own runtime lines
1407
+ (`[chapters-prompt] turn/end sighted session=session-23f5877b…`, `prompt: not enabled (no defining model) —
1408
+ round skipped`), so our plugin is alive there and contributes exactly the seven `chapters_*` tools.
1409
+
1410
+ So the remaining question is squarely host-side: what makes a session assembled from the `chapters` preset
1411
+ expose the standard tool surface on this machine and not on a GitHub runner, given identical packages,
1412
+ identical profile rows (44 ids, the same 21 `tool-*` rows), identical `config`/`adapterDefaults`, working
1413
+ sandboxing, and no logged pruning. Two ways forward, both cheap to the person who can run them:
1414
+
1415
+ 1. run the acceptance chain on a machine with an interactive host install (the maintainer's laptop) and compare
1416
+ the header dump — this reproduces the 34-tool case and can be bisected with `preset`/`agentPresets`
1417
+ introspection the host already owns;
1418
+ 2. or make acceptance able to distill on the runner (route credential as a repository secret), which sidesteps
1419
+ the asymmetry entirely: the corpus is then recorded by whatever the runner assembles.
1420
+
1421
+ I am not adding a header blind to make CI green, and I am not claiming the cause. Everything else in the
1422
+ objective's pipeline is measured and stated above.
1423
+
1424
+ ### The pin-linking fix works for CI and invalidates the local corpus — stated plainly (2026-10-27)
1425
+
1426
+ `linkPinnedToolPackages()` (pre-spawn) was verified to do what it claims: a CI-shaped clone went from `tools=7 /
1427
+ chars=2880` to `tools=34 / chars=7378`, which is the mount asymmetry closed. But the same change alters what a
1428
+ DEVELOPMENT host resolves — 24 rows now come from the pinned bundle instead of the developer's home tree, and
1429
+ those builds are not byte-identical in their prompt contributions. Consequences, measured today rather than
1430
+ argued:
1431
+
1432
+ - The committed corpus (which was chain-green twice earlier today, pre-link) now fails locally: `suite` ✓
1433
+ `heavy` ✓, then `arrival` ✗ with `model never found the buried token via the artifact tool` — a retrieval that
1434
+ reached its assertion but not its result, i.e. the capture no longer describes what this composition does.
1435
+ - A fresh ten-leg capture on the linked build was attempted: nine legs distill green, `fork` failed once with
1436
+ boot-readiness (`e2e boot failed within 120077ms`, retried → exit=0, 2 entries), and the resulting corpus
1437
+ failed at `suite` with the door never rendering — a capture in which the model simply did not take the
1438
+ continuation path, the same nondeterminism already filed for `arrival` and `fanout`.
1439
+ - The tapes are therefore RESTORED to `HEAD` and nothing was committed from these runs. No stamp move, no mixed
1440
+ tree, no claim.
1441
+
1442
+ The decision this leaves, and it is a real one rather than a preference: either (a) accept a longer capture
1443
+ campaign on the linked build — re-distilling per leg until each capture is faithful, filing any leg that needs
1444
+ more than two attempts as the flake it is — or (b) make the rig's composition independent of which bundle copy
1445
+ resolves by pinning the *prompt-relevant* surface (e.g. declaring the preset's plugin rows as repo devDependencies
1446
+ at the pin, which is what I attempted and reverted when 15 existing `0.1.5-rc.x` devDeps made the peer graph
1447
+ conflict). (a) is the honest path and (b) is the clean one; doing (b) properly means aligning the repo's harness
1448
+ devDependencies to the single pin, which is a larger, riskier change that deserves its own issue and CI cycle
1449
+ rather than being folded into this branch.
1450
+
1451
+ ### Correction: replay already refuses to serve on a partial prefix (#56, 2026-10-27)
1452
+
1453
+ Last round I named "replay serves nearest-prefix entries" as the acceptance blocker and queued a fix for it.
1454
+ Reading `findReplay` before writing that fix disproved it: it skips any entry whose `prefix.length !== inc.length`
1455
+ and then requires every element to be equal, ties resolved by newest `recordedAt`. There is no nearest-prefix
1456
+ serving to refuse — the hypothesis was wrong, and it was written into the plan as though it were established.
1457
+
1458
+ What CI's artifact actually shows for `suite`: a 4-message request whose `msg0`/`msg1` match the capture and whose
1459
+ `msg2` carries `{"name":"glob","arguments":"{\"pattern\":\"src/**/*.ts\"}"}` where the capture has
1460
+ `{"name":"read","arguments":"{\"file_path\":\"src/compose.ts\",\"limit\":120}"}`. Since serving is exact-match,
1461
+ the only readings left are (a) the assistant turn the runner *recorded into its own history* came from a different
1462
+ tie-resolved entry than this machine's run did, or (b) the two machines' requests differ at an earlier position
1463
+ than the journal's "first miss" implies, because an earlier 503 was absorbed. (a) is checkable: the tie-break is
1464
+ newest `recordedAt`, so two entries that both match a 2-message prefix can serve differently — and `suite`'s
1465
+ capture has 6 entries where more than one shares a 2-message prefix.
1466
+
1467
+ Next read, in order, and no matcher change until one of them lands: (1) for the runner's first request, list the
1468
+ tape entries whose prefix matches it exactly and show their `recordedAt` — if more than one, the tie-break is the
1469
+ mechanism and the fix is a deterministic tie (path/length, then newest) with a row, not a stricter gate; (2) if
1470
+ exactly one matched, then the divergence is upstream of msg2 and the diff dump needs to be taken at request one,
1471
+ not at the first miss.
1472
+
1473
+ ### `coverage:e2e` under one-boot-per-journey now fails in `prompt` on a sidebar wait (#56, #129, 2026-10-27)
1474
+
1475
+ With `test:e2e:replay` routed through `scripts/chain-replay.sh`, the coverage run gets nine legs green and then
1476
+ `prompt` times out in the rig's own view-adoption step:
1477
+
1478
+ Test timeout of 300000ms exceeded — locator.click waiting for
1479
+ locator('[role="treeitem"]').filter({ hasText: 'Reply with exactly: OK' }).first() at session.ts:945
1480
+
1481
+ The earlier failure mode there was `identity mismatch: dir claims session session-2ba8…, spec targets 2ba8…`, and
1482
+ that was a real bug — one session, two spellings (the durable log keeps the `session-` prefix, the view does
1483
+ not) — now fixed by `sameSessionId()` with a red-first row. What remains is different: the session's tree row
1484
+ never carries the question text while V8 coverage is instrumenting the boot.
1485
+
1486
+ Deliberately NOT done: raising the timeout until it passes. A click that waits for a label that never appears is
1487
+ a signal about what the view is showing, and a longer wait converts a real finding into a green row — the exact
1488
+ "green tests, dead product" failure this branch exists to refuse. The next read is the sidebar's actual contents
1489
+ at that moment (dump `role=treeitem` texts plus `dsh.sessions.current` under `E2E_COVERAGE=1`), which will say
1490
+ whether the row is unlabeled, labeled with a stale title, or absent because the title response served under
1491
+ coverage belongs to a different session.
1492
+
1493
+ ### The `prompt`/coverage stall is an actionability wait, with a suspicious aux-title volume behind it (#56, #129)
1494
+
1495
+ Read from the artifact rather than assumed:
1496
+
1497
+ - the row is **found and visible**; the 300s goes to `own.click()` at `tests/e2e/session.ts:945`, and Playwright's
1498
+ only line is `waiting for locator('[role="treeitem"]').filter({ hasText: 'Reply with exactly: OK' })` — the
1499
+ classic never-becomes-stable signature, i.e. the sidebar is re-rendering under the click.
1500
+ - the leg's proxy journal holds **2,966** lines, of which **190** are single-message requests served in 1–2 ms
1501
+ each — the shape of an auxiliary title call repeating over and over. If the tree's labels are being rewritten
1502
+ continuously, that is what makes the row unactionable, and it would explain why the leg passes under plain
1503
+ per-journey replay (`prompt` ok in both `K1` and `K2`) and fails only while V8 coverage is instrumenting the
1504
+ boot, where timing and render cadence change.
1505
+
1506
+ Not yet claimed: the 190 figure is a correlation, not a diagnosis. The confirming measurement is small and
1507
+ specific — count single-message requests per leg for one passing (no-coverage) `prompt` run and one coverage run,
1508
+ and dump `session/title` events from the run's session log with their timestamps. If the passing run shows a
1509
+ handful and the coverage run shows ~190, the title loop is the mechanism and the fix belongs to how the rig
1510
+ stabilizes the view before clicking (or how the title call is deduplicated), not to the timeout. If both show
1511
+ ~190, the loop is normal and the real problem is elsewhere in the render path.
1512
+
1513
+ What is deliberately not done: raising the click's timeout until the leg passes. That would convert a render
1514
+ instability that a real user could hit into a green row.
1515
+
1516
+ ### Correction: there is no title loop — I counted a cumulative journal as if it were one run (#56, 2026-10-27)
1517
+
1518
+ The 190 single-message requests came from grepping `var/e2e-upstream.log` for `tape=prompt`, and that file
1519
+ **accumulates across every run of every leg**. Measured properly, with the journal length captured before and
1520
+ after one run:
1521
+
1522
+ - `prompt` replay, no coverage: **14** new journal lines, **5** single-message requests. Leg green.
1523
+ - `prompt` replay, `E2E_COVERAGE=1`, same single-leg invocation: **14** new lines, **5** single-message. Leg green.
1524
+
1525
+ So the aux-call volume is identical with and without instrumentation, and the title-loop hypothesis is dead — it
1526
+ was never a diagnosis, it was a correlation I read out of the wrong denominator, and the record said "not yet
1527
+ claimed" for good reason. What survives is the failure itself: in the full ten-leg coverage run `prompt` timed out
1528
+ on `own.click()` waiting for the sidebar row to become stable, while the same leg passes both plain per-journey
1529
+ replay and single-leg replay under coverage. That points at load/timing when ten boots run back to back with V8
1530
+ instrumentation writing coverage for each — not at the plugin's title path.
1531
+
1532
+ Useful rule for whoever picks this up: the journal is shared and cumulative; measure a delta, not a grep. The next
1533
+ step is to reproduce the whole coverage run and dump the row's actual state at the timeout (its text and whether
1534
+ it is re-rendering), rather than raising the timeout.
1535
+
1536
+ ### What the armed diagnostic saw when `prompt` fails under full coverage: the view had **zero** rows (#56, #129)
1537
+
1538
+ Second full `coverage:e2e` run with the new failure diagnostic in place: `suite heavy arrival enrich fanout rules
1539
+ fork plans` ok, **`prompt` FAILED**, `prompt-off` ok (`COVPJ4=1`). The diagnostic printed:
1540
+
1541
+ e2e: tree-row click failed for "Reply with exact…" — locator.click: Test timeout of 300000ms exceeded.
1542
+ current session: null
1543
+ rows (0):
1544
+
1545
+ That is not a flaky click and not a re-render race: at that moment the sidebar offered **no session rows at all**
1546
+ and `dsh.sessions.current` was unset, so the row the `isVisible()` check had just accepted was gone by the time the
1547
+ click resolved. The failing test also moved — this time it was the third prompt test
1548
+ (`clicking the expanded prompt puts the WHOLE prompt in the composer for editing`, 5.0m), not the first — and no
1549
+ browser crash, `pageerror`, `Target closed`, or out-of-memory line appears in the leg log or the server log for that
1550
+ boot. A three-leg instrumented run (`suite fork prompt`) passed everything, so the condition needs the long
1551
+ sequence; what it looks like is the view losing its session list partway through a nine-boot run, not the bar's own
1552
+ behavior.
1553
+
1554
+ Next read, deliberately narrow: extend the same diagnostic to print the page's URL, title, and body length plus
1555
+ `JSON.stringify(window.dsh && Object.keys(window.dsh))` at the failure — if the app is still there but the list is
1556
+ empty, that is a store/restore problem under instrumentation; if the document itself is gone, it is the boot dying
1557
+ under memory pressure, and the fix belongs to how coverage is collected per boot rather than to the spec. Still
1558
+ not doing: raising the click timeout.
1559
+
1560
+ ### CORRECTION, mine: `noAuxModel` was never inert, and I wrote that three times today (#129, 2026-10-27)
1561
+
1562
+ Commit messages written earlier today (`fix: make noAuxModel real at the pressure decision…`) claim the key "is read
1563
+ by NOTHING in `src/`" and that I made it real in `src/engine.ts`. Both false. `noAuxModel` is a **rig alias**,
1564
+ translated at profile-write time into `auto: false` + `elicitedPlot: false` (`tests/e2e/rig-preset-pin.ts:79`), and
1565
+ the engine declares `auto: z.boolean()` (`src/engine.ts:287`). My search was for the alias's name inside `src/`, and
1566
+ a translation layer will never satisfy it. The no-op is reverted (`fix: remove the no-op "gate"…`), the wrong guard
1567
+ test is deleted, and the six-leg pin set that actually works is restored — plane 1,108/1,108, typecheck 0.
1568
+
1569
+ What survives, measured independently of that bad theory and still the open work: the asymmetry is real in the
1570
+ durable logs — `arrival` ran **one** compaction event on the CI runner and **zero** on this host against the same
1571
+ corpus (`var/e2e-session-logs/`, run 40611340353), while `heavy` ran two on both. The six legs that pin the switch
1572
+ pass on CI; the four that must not pin it (`heavy`, `suite`, `arrival`, `fanout`) are exactly where the misses land.
1573
+ So the remaining fix is a **deterministic trigger** for the machinery those four legs exist to exercise — an explicit
1574
+ event at a pinned turn or size rather than a threshold crossing on a header that differs slightly between machines.
1575
+
1576
+ ### Correction to my own commit message: `threshold 0.5` does not make `suite` compact earlier — it declines
1577
+
1578
+ `test: give suite a threshold margin wide enough to fire on every machine` argued the checkpoint would now fire
1579
+ "well before the divergence point on both machines". The engine's own sink says otherwise: at that ratio the leg's
1580
+ requests are logged as `relief declined while the surface is over the trigger (surface=22119 trigger=16000
1581
+ window=32000 …)` — repeatedly — and the new capture contains **no** checkpoint notice at all. So the capture became
1582
+ checkpoint-free because relief is being *refused*, not because it moved earlier. Two consequences, neither of which I
1583
+ get to skip: (1) the claim in that commit message is wrong and is corrected here; (2) if `suite`'s journey no longer
1584
+ exercises compaction, whatever the leg was written to prove about pressure relief is not being proved by it any more —
1585
+ which needs its own answer, not a re-wording.
1586
+
1587
+ Measured facts that do hold: `suite` at `0.5` unpinned captures (5 entries) and replays green twice on this host; the
1588
+ same leg pinned does not boot at all (two attempts, one log line, no URL) — see #129. Tree restored to the working
1589
+ regime; plane 1,108/1,108.
1590
+
1591
+ ### Plot elicitation is load-bearing for multi-turn convergence — suppressing it makes small-model journeys loop (#130, two samples)
1592
+
1593
+ I applied the `elicitedPlot: false` lever (the one that fixed `suite` on CI) to the other two aux-sensitive legs and
1594
+ watched both fail the same way **at record**:
1595
+
1596
+ - `arrival` sample 1: exit=1, 13 entries, `model never found the buried token via the artifact tool`.
1597
+ - `arrival` sample 2: capture never finished — 42 → 63 → 68 → **72** requests in 20 minutes, the model re-reading and
1598
+ re-searching instead of converging, killed at the 1500 s ceiling.
1599
+ - `fanout`: exit=1 after **61** entries, the same shape.
1600
+
1601
+ So the aux plot turn is not cosmetic overhead for these journeys — it is the mechanism that carries the operative
1602
+ note forward and lets a small model keep its thread across many turns. Remove it and they thrash. `suite` is the
1603
+ exception because its journey is short and its assertion (fork door) does not need the loop to close.
1604
+
1605
+ Consequence for acceptance, stated as a constraint rather than a to-do: for `heavy`, `arrival` and `fanout` the fix
1606
+ cannot be "suppress the aux turn so both machines match", because the turn is what makes those legs work. Either the
1607
+ corpus must **record** the plot turn on every machine (which means understanding why the runner elicits it and this
1608
+ host does not, at the same threshold and the same 32K window), or those legs' prompts must be reshaped to converge
1609
+ without it — and reshaping a leg's prompt changes what it exercises, so that has to be a deliberate decision with its
1610
+ own docs, not a quiet edit. Both `heavy` and `fanout` currently diverge at `commonPrefix=2` (the first assistant
1611
+ turn) with relief live, which is the checkpoint-shape family, and `arrival` diverges as a 1-message plot request.
1612
+
1613
+ Reverted: both legs back to the committed regime, two samples each, no pin shipped around a loop.
1614
+
1615
+ ### `arrival` is capture-nondeterministic across regimes — the leg tests retrieval luck, not the mechanism (#130)
1616
+
1617
+ Three regimes, same runaway, which is what rules out my two theories in one measurement:
1618
+
1619
+ | regime | outcome |
1620
+ |---|---|
1621
+ | threshold 0.9, plot elicitation ON (committed) | captured fine repeatedly today: 9, 14, 19 entries |
1622
+ | threshold 0.9, `elicitedPlot: false` | exit=1 at 13 entries once; then **never finished** — 42→63→68→72 requests in 20 min |
1623
+ | threshold **0.98**, plot ON | **42 requests at +600 s**, the same runaway; killed at the ceiling |
1624
+
1625
+ If the trigger margin and the aux turn were the causes, 0.98-with-plot-on would behave like the committed regime. It
1626
+ did not. What is actually varying is whether the small model happens to find the buried token: the leg reads a 373,861-
1627
+ byte `var/e2e-book.md` (3,964 lines) and asks it to locate `EMBED-CENTER-4417` near chapter 21 through the artifact
1628
+ reference, so success depends on retrieval luck rather than on the mechanism the leg names — *one oversized read
1629
+ deferring to an artifact at arrival, never prefilled*. When luck fails, the model re-reads and re-searches and the
1630
+ capture spirals to 40–70 requests; when it holds, the same leg finishes in 9.
1631
+
1632
+ The fix is a test-design decision, not a rig knob, and it has to be explicit because it touches what the leg proves:
1633
+ keep the mechanism (the read must still exceed `toolResultArtifactTokens` so arrival defers it) and make the RETRIEVAL
1634
+ tractable — place the buried token within the first few chapters of a smaller book, so the artifact query answers a
1635
+ bounded question instead of a needle-in-haystack one. Then guard the part that must NOT change with a row: the book
1636
+ still exceeds the deferral threshold on both machines, so the stub pair still happens. Shrinking the haystack is not
1637
+ weakening the assertion; asking a 32K model to grep 4,000 lines through a stub was.
1638
+
1639
+ Not done, deliberately: another re-roll of the same capture (I have three samples now), a raised timeout, or a pinned
1640
+ `noAuxModel` on this leg. All of them hide the design problem.
1641
+
1642
+ ### Dropping the injection row shrinks the surface — which is exactly what two legs were secretly relying on (#129)
1643
+
1644
+ Campaign on the no-injection build: `arrival` 10, `enrich` 3, `fanout` 14, `rules` 2, `fork` 2, `plans` 3 all captured
1645
+ green, while `suite` (4 entries) and `heavy` (15 entries) failed. `heavy`'s failure names the mechanism:
1646
+
1647
+ > 131 | }, { timeout: 480_000, intervals: [5000] }, 'engine chapters recorded for THIS session').toBe(true)
1648
+
1649
+ The injected `AGENTS.md` block is thousands of tokens of surface. Remove it and both legs stop reaching their pressure
1650
+ trigger inside the journey — `heavy` never records engine chapters, and `suite`'s fork door (which comes from a real
1651
+ continuation) never renders. So the row removal is not "free determinism": it is correct in principle (a
1652
+ machine-varying development artifact must not arbitrate a tape) and it exposes that these two legs were reaching
1653
+ their threshold **with borrowed bytes**, which is the same defect as depending on a runner's header size, one layer
1654
+ down.
1655
+
1656
+ The honest consequence, in order: `heavy` and `suite` must generate their own pressure — the leg's prompt or fixture
1657
+ carries the bulk, not an injected document — and the engine must actually PERFORM relief at the ratio the rig pins
1658
+ (the `relief declined while the surface is over the trigger` line in #130 means a low ratio does not guarantee relief
1659
+ happens, it may just refuse). Until both are true, the no-injection profile stays and these two legs are re-designed,
1660
+ not re-rolled. Nothing is committed from a partial campaign.
1661
+
1662
+ ### What the Autorun-displaces-goal pixel actually requires, from the code as it stands (#113, objective item 3)
1663
+
1664
+ Read at 2026-10-28 while looking for the assertion, so the next reader starts from facts:
1665
+
1666
+ - `src/client/prompt-dock.ts:380` renders the toggle as `label: state.autorun === 'armed' ? 'Autorun off' : 'Autorun'`
1667
+ with glyphs `⟳`/`⟲`, and disables it with a title when the host publishes no idle signal — **that** affordance is
1668
+ what `prompt` asserts today (arm-ability, `4 passed`).
1669
+ - Displacement is a **server fact**, not a dock rendering: `PromptStore.setAutorun(sessionId, on)` returns
1670
+ `{ autorun, fired, displaced }` (`src/prompt-state.ts:160-162`, "true when arming this loop also disarmed the
1671
+ goal"), and `tests/unit/prompt-goal-interplay.test.ts` covers it. The dock has no element that says "the goal's
1672
+ next turn was taken" — so a leg cannot assert displacement by looking at the bar alone.
1673
+ - What a browser leg CAN assert, in order of directness: (1) the host's own goal control showing `activation:
1674
+ disarmed` after the bar's Autorun is clicked while a goal is active; (2) the next turn being driven by the bar's
1675
+ suggestion rather than the goal's objective, observable in the session's durable log; (3) `Run` carrying
1676
+ `intent: 'goal-edit'` (the dock titles it "Goal edits need a human turn…", `prompt-dock.ts:375-377`) to prove the
1677
+ split the design claims.
1678
+ - Prerequisite that is NOT free: making a goal active inside a rig session. The profile mounts
1679
+ `@deepseek-ai/dsh-command-goal`, so `/goal …` in the composer is the route, but goal activation requires a
1680
+ **direct human turn** in the host (`prompt-dock.ts:376` says so about edits, and `goal-edit` intent exists for it)
1681
+ — so the leg's choreography is: send `/goal`, wait for the host to report active+armed, click the bar's Autorun,
1682
+ then assert on the goal control and the durable log. Expect this to need a recorded journey of its own, and to be
1683
+ captured alone under `E2E_PROVIDER=harness`.
1684
+
1685
+ ### Two measured facts about the displacement leg, from the capture that did not finish (#113, objective item 3)
1686
+
1687
+ Written instead of shipped, because the leg is red and a red leg on the branch helps nobody. Both facts came from a
1688
+ live recording run (`E2E_PROVIDER=harness`, `prompt` leg, 44 tape entries) with the test I added and then reverted:
1689
+
1690
+ 1. **The goal bar's activation control is real and findable.** After a human turn of `/goal Ship the widget and verify
1691
+ it in the browser`, the host renders `[data-goal-bar]` AND a page-level control labelled `Pause goal` — the
1692
+ assertion `the goal is armed: its own control offers Pause` **passed**. The label must be located page-wide
1693
+ (`button[aria-label="Pause goal"], button:has-text("Pause goal")`), not inside the element that carries the
1694
+ attribute: the host routes the action through its own `Action` component (`client.js` has `"action.pause":
1695
+ "Pause goal"` feeding `label: t("action.pause")`), and scoping to `[data-goal-bar]` found nothing — that mistake
1696
+ cost one capture.
1697
+ 2. **The blocker is our own bar, and it is a product question.** The next assertion failed: after clicking Autorun,
1698
+ `button:has-text("Autorun off")` never appeared. The dock dispatches `{ t: 'autorun', on: !armed }` locally and then
1699
+ `refresh()` (`prompt-dock.ts:390`), so if the server's read comes back `disarmed`, the label snaps straight back to
1700
+ `Autorun` and the user sees their own click refused. That is exactly the `state.autorun` split the interface lock
1701
+ describes (`server, process-local` like the goal's `activation`, `docs/contract.md:680`) — so either arming needs a
1702
+ turn boundary before it reports armed (and the leg must wait for one), or the write is not landing. I do not know
1703
+ which, and the capture that would tell me is the next experiment, not this note.
1704
+
1705
+ Consequence for the objective: item (3)'s displacement pixel and item (4)'s browser-level interplay remain unshown,
1706
+ and the reason is now narrow and specific — our bar's armed state does not visibly persist — rather than "the host has
1707
+ no affordance", which the first fact disproves.
1708
+
1709
+ ### `auto: false` is too coarse: it turns off arrival deferral along with relief, which is why two legs cannot be pinned (#129, #130)
1710
+
1711
+ Re-tested today, **after** #131 was fixed, in case the artifact cycle had been the whole story. It was not: with the
1712
+ pin applied, `arrival` again fails `at least one arrival stub pair (one oversized read)` and `fanout` again fails
1713
+ `parent synthesis lost paper A — SYNTHESIS, not delivery`. Identical messages, different build. The reason is in the
1714
+ module's own header — `src/arrival.ts:7`: the arrival stub pipeline **"Runs at the head of compactIfNeeded/compactNow"**
1715
+ — so the realm key that suppresses model-involving relief (`auto: false`) also suppresses the size-deterministic
1716
+ deferral that these legs exist to exercise. `noAuxModel` is therefore one switch for two behaviors.
1717
+
1718
+ Why this matters for the CI divergence rather than just for the rig: `fanout`'s machine dependence is now explained
1719
+ exactly — the child's first message is a checkpoint notice here and the plain delegated prompt on the runner, i.e. the
1720
+ **parent** relieved at a different moment — and the only two ways to make that agree are (1) split the keys so relief
1721
+ can be off while deferral stays on (real work: a second realm key and a call-site change in the engine), or (2) let the
1722
+ legs reach relief at a leg-controlled point instead of a threshold crossing (bulk the leg generates itself, per the
1723
+ maintainer's "synthetic bulk" rule). Both are design changes with their own risk; neither is a knob I can set tonight,
1724
+ and the third option — pinning anyway — deletes the behavior the legs test, which measurement has now shown twice.
1725
+
1726
+ Reverted: pins and the allowlist row back to the committed set, corpus restored. The rows held the line — both failed
1727
+ the moment I claimed a regime the capture refutes — and that is the reason the re-test is short rather than hopeful.
1728
+
1729
+ ### The key-split question, bounded to one fact: which `applyArrivalStubs` site goes silent under `auto: false` (#129, #130)
1730
+
1731
+ Checked rather than assumed, because this branch has been fooled by that twice today. `auto` is **not** another dead
1732
+ key: the host's `BasicCompactionConfig` reads it — `dsh-compaction-basic/lib/index.js` validates it as a boolean and
1733
+ defaults it to `true` — so `auto: false` really does suppress the host's relief, and the docs' "one realm key, two
1734
+ behaviors" claim stands.
1735
+
1736
+ What is NOT settled is the one fact a split needs: `src/engine.ts` calls `applyArrivalStubs` at two sites — ~862, in
1737
+ the event-driven arrival listener (`floorTokens: toolResultArtifactTokens`), and ~944, inside the pressure path
1738
+ (`floorTokens: ceiling`). Under the pin, `arrival` loses its stub pair. Which site went silent, and why? The answer
1739
+ decides where a second key must live. Getting it requires one pinned capture plus a grep of that run's session log for
1740
+ arrival markers — a measurement, not another theory.
1741
+
1742
+ Stated so the next session inherits a limit, not a license: **do not implement the split before that is known.** A
1743
+ switch wired to the wrong site is the #130 mistake wearing a new costume — a key the rig writes and nothing consumes —
1744
+ and there is already a guard-shaped lesson about that in this file.
1745
+
1746
+ ### Second correction on the same claim: the `arrival` stub-pair failure is NOT caused by the pin (#129)
1747
+
1748
+ Two commits ago I wrote that `auto: false` suppresses arrival deferral, "verified" by a pinned capture that failed
1749
+ `at least one arrival stub pair (one oversized read)`. Checking the same day's other logs before leaving that in the
1750
+ record, it is wrong in its attribution: the identical failure appears with **no pin at all** —
1751
+
1752
+ | run | pin state | outcome |
1753
+ |---|---|---|
1754
+ | `camp2` (unpinned) | — | `arrival: exit=0`, 9 entries |
1755
+ | `camp3` (unpinned) | — | **`arrival: exit=1`, 3 entries** |
1756
+ | `camp5` (unpinned) | — | `arrival: exit=0`, 14 entries |
1757
+ | probe just now | `auto: false, elicitedPlot: false` | `arrival: exit=1`, 4 entries, 0 stub markers |
1758
+
1759
+ Three unpinned captures of `arrival` failed at record earlier today (one of them while diagnosing #131), so the pin
1760
+ is not the differentiator and "one realm key, two behaviors" is **not** established by this evidence. What is
1761
+ established, and survives: the host really does read `auto` (`BasicCompactionConfig` validates it, default `true`),
1762
+ and `#arrive` is called only from inside the relief path (`src/engine.ts:1024` and `:1083`), matching that module's
1763
+ own header — so a relief-side switch *can* reach deferral, which is why the split key is still an open design
1764
+ question. It is not established that flipping it caused these failures.
1765
+
1766
+ The ordinary explanation is the one I keep reaching for last: the leg's oversized read depends on what the model asks
1767
+ for. It fails intermittently, pinned or not, which is exactly the flake class #131's fix reduced but did not remove.
1768
+ The pinned run adds no information, and I should have looked at the unpinned logs before writing a mechanism.
1769
+
1770
+ Method, again, in writing: **before attributing a failure to a change, find the failure in a run that predates the
1771
+ change.** I have now made this error three times today (the fake `noAuxModel` gate, the coarse-key claim, and this
1772
+ attribution); the guard against it is cheap and is called a log search.
1773
+
1774
+ ### Retracted: the margin change did not make `fanout` deterministic — the same build captured 10 entries and, 40 minutes later, 20 (#132)
1775
+
1776
+ One-run against one-run, which is the only comparison that means anything (I made the cumulative-sink mistake twice
1777
+ today and the second time it nearly became a conclusion):
1778
+
1779
+ | measurement | this host (one capture) | runner (one capture, run 40648703376) |
1780
+ |---|---|---|
1781
+ | `realm:engine:arrive` entries | 24 | 3 |
1782
+ | `relief declined` | 11 | 1 |
1783
+ | `decided: compaction` | 6 | 0 |
1784
+ | tape entries produced | **20** | 10 (committed) |
1785
+ | capture outcome | exit=0 | (CI replays, never records) |
1786
+
1787
+ Two things follow, and both cut against what I claimed an hour ago. **(a)** `fanout`'s *capture* is not stable on this
1788
+ host: the committed corpus is a 10-entry roll and a fresh roll under the identical regime produced 20. So the "41 → 10
1789
+ entries is an efficiency win" line was a short roll, not determinism, and I am withdrawing it. **(b)** the runner's
1790
+ journey is much *shorter* than mine — 3 arrival entries and zero completed reliefs, where one local run does 24 and
1791
+ six. That is not "the crossing landed one turn later"; it is a different number of model turns in the same leg, which
1792
+ is what a prefix miss at `messages=14` looks like from the inside.
1793
+
1794
+ Therefore the remaining acceptance work is not a rig knob at all. It is the thing the maintainer's rule already says:
1795
+ these legs must not depend on how many turns a model feels like taking. A leg whose journey length varies will not
1796
+ replay anywhere, and no margin, threshold, or fold fixes that — the journeys have to be shortened and bounded so the
1797
+ turn count is a property of the spec, not of the roll. Restoring the corpus to the committed set (my 20-entry roll is
1798
+ discarded, not "re-anchored" on a coin flip).
1799
+
1800
+ ### Next-session brief: make the turn count a property of the spec, then re-anchor (#132, #129)
1801
+
1802
+ State of the branch when this session ends, all of it measured rather than asserted:
1803
+
1804
+ - **Green and committed:** plane 1,114/1,114 with 0 skipped; `typecheck` and `typecheck:client` 0; `typecheck:e2e`
1805
+ at its 68 baseline; full one-boot-per-journey chain ten-for-ten (`MG=0`); `coverage:e2e` `CVM=0` at **78.2%**
1806
+ on the current corpus; corpus stamped and `corpus:check` agreeing. Products of this session, all with red-first
1807
+ rows: **#131** artifact answers no longer arrival-stubbed (two legs' "flakiness" was this), **#113** the Autorun
1808
+ control actually arms (it was inert) and **goal displacement is observed in Chromium** (`Pause goal` →
1809
+ `Resume goal`, replayed model-free at 5 passed twice), `scripts/chain-replay.sh` (one boot per journey, now what
1810
+ `npm run test:e2e:replay` means), `linkPinnedToolPackages()` before spawn (runner assembles 34 tools, was 7),
1811
+ per-leg logs + session transcripts + per-port diagnostics in the CI artifact, and the `arrival` prompt fixed to
1812
+ ask for the `limit` the host actually accepts (which took that leg off CI's list at least once).
1813
+ - **Red, and honestly so:** CI acceptance on `heavy arrival fanout`. `arrival` has flipped green→red between
1814
+ identical-code runs; `fanout`'s capture is 10 or 20 entries on **this** host minutes apart. So the tapes are not
1815
+ portable because journeys are open-ended, and the diagnosis is settled: relief timing was a symptom, **model
1816
+ turn count is the cause**.
1817
+ - **The work, concretely:** bound each affected leg so the number of model turns is fixed by the spec, then
1818
+ re-anchor. For `fanout` that means two subagents, one delegated read each, and the parent asked for the
1819
+ **shortest faithful synthesis** — assert the stub pair, both markers reaching the parent's answer, and nothing
1820
+ about how many queries it took; if a roll can still vary in turn count, the leg should drive the pieces it can
1821
+ drive (deterministic fixtures, generated bulk) and stop relying on the model to stop early. `heavy` and
1822
+ `arrival` get the same treatment: one bounded read, one bounded artifact query, assertions on the durable log's
1823
+ facts rather than on the model's economy.
1824
+ - **Do not**: pin `noAuxModel` on those legs (it deletes deferral, measured three times); fold injected or
1825
+ checkpoint content out of matching (it collapses positional prefixes and mis-serves turns — tried, reverted);
1826
+ re-anchor a leg off a single green roll (that is how two fake fixes happened); raise a timeout to pass a leg
1827
+ whose subject is timing.
1828
+
1829
+ ### `heavy` is the last acceptance miss; the runtime-fold theory is already dead, tested against the runner's bytes (#132)
1830
+
1831
+ Where this stands, measured rather than narrated. Run 40661878031 fails `heavy` and `prompt`; `prompt` now survives
1832
+ **nine messages** (`commonPrefix=9 divergeAt=msg9`) where it used to die at 2-4, so the bounded journeys plus the
1833
+ injection-row removal moved the whole problem deeper into the conversation — which is what those changes were for.
1834
+ `heavy` still misses at `commonPrefix=2 divergeAt=msg2`.
1835
+
1836
+ The obvious-looking explanation was that the runner keeps a residue of the runtime-context / `<system-reminder>`
1837
+ injections that this host folds to nothing, shifting every index. I took the runner's own transcript (uploaded now by
1838
+ `scripts/chain-replay.sh`) and normalized those two events through the real fold:
1839
+
1840
+ seq 11 raw chars 484 -> normalized chars 0
1841
+ seq 12 raw chars 3285 -> normalized chars 0
1842
+
1843
+ Both fold to empty, i.e. both are pure injections and drop from matching on either machine. **Theory dead, in one
1844
+ command, before it became a change.**
1845
+
1846
+ What the runner's transcript does show: `[system, user(prompt), user(runtime), user(reminder), …]` and, at the point
1847
+ where the tape has an assistant turn reporting `First call returned (output capped at line 600; found
1848
+ MARKER-ALPHA-7731…)`, the runner's `msg2` is the assistant answering the follow-up probe (`STILL-HERE`). So the two
1849
+ histories differ in **which assistant turn comes second**, not in message roles or counts. The next read is
1850
+ therefore structural, not fold-related: line up the runner's event order against the tape's entry order for
1851
+ `heavy` and find the point where the journeys diverge in *sequence* — the probe turn is the suspect, since its
1852
+ position depends on when the first turn ends. If the sequence differs only around the probe, the leg's probe
1853
+ needs to be gated on a durable-log fact rather than on a poll, which is a spec change with its own row.
1854
+
1855
+ ### My own error, caught by the runner: the prefix-symmetry fix DOES require a re-record (#132)
1856
+
1857
+ The commit said "this changes matching only — served bytes untouched — so no re-record is needed: CI either agrees
1858
+ or it names what's left." CI named what's left: **my own claim**. `normalizeStoredPrefix()` both substitutes *and*
1859
+ drops empty elements, and dropping changes a prefix's **length**. A tape recorded before it has different effective
1860
+ key shapes than a live request matched against it — so the corpus and the matcher must be re-anchored together, not
1861
+ one without the other. The substitution half was length-preserving; the filter half is not, and I reasoned from one
1862
+ to the other.
1863
+
1864
+ Runner evidence, `000.json` in run 40664455803: the live history is `[system, checkpoint-notice,
1865
+ assistant("STILL-HERE"), user("Reply with exactly the single word: STILL-HERE."), …]` — the follow-up probe arriving
1866
+ at index 3 where the tape has it later. Under the new filter, the runner's first main request normalizes shorter than
1867
+ the recorded entry expected, so a legitimate turn is served where the journey hadn't reached it. Not model
1868
+ behavior, not a fold residue: a matcher change shipped without the re-anchor it implied.
1869
+
1870
+ Action: re-anchor the corpus on the symmetric matcher and re-verify. Rule extracted, added to the rig list: **any
1871
+ change that alters prefix LENGTH is a corpus change, not a matcher change** — length-preserving folds may ship alone,
1872
+ length-changing ones never do.
1873
+
1874
+ ### #135 — the goal-off browser leg needs a rig capability, not a weaker assertion
1875
+
1876
+ Four capture attempts, four different failure classes; the full account is in `docs/project-state.md` (2026-10-28,
1877
+ later) and on the issue. What the chase established, measured:
1878
+
1879
+ - A goal round **is a turn**, and every turn ends, so rounds cascade — no click timing or polling bounds them (one
1880
+ tape reached `Round: 12/256`).
1881
+ - The product's own bound (`maxGoalRounds`, quiescent at `Round: 1/1`) is the right shape, but the only way a leg can
1882
+ set it today is by asking the model to call `create_goal(max_goal_rounds: 1)` — and in capture the model answered in
1883
+ prose instead of calling the tool. Setup that needs model cooperation cannot be a tape.
1884
+ - No HTTP seam exists: grepping every installed `@deepseek-ai/*` lib for a route containing `goal` returns nothing. The
1885
+ human `/goal` command takes no rounds argument, and `goal-round-driver` has no config block in `cordis.patch.yml`.
1886
+
1887
+ Both candidates were then investigated, and (1) is **not available**: the host's goal state travels in the durable
1888
+ event stream (`goal/change`, `goal/changed` in `@deepseek-ai/dsh-goal`), so pre-seeding it would mean fabricating a host
1889
+ event — forbidden by invariant 1. The `goal` row that DOES sit in a boot home's storage
1890
+ (`storages/session_projcache/sessions/<id>.json`, keys `{seq, val:{current, seenGoalIds, failure}, ver}`) is our own
1891
+ mirror, with no round budget. There is no plugin-facing goal API (`ctx.get('…goal…')`, `registerCapability`, `provides:`
1892
+ all empty), no HTTP route mentioning `goal` in any installed lib, no config block on `goal-round-driver`, and no rounds
1893
+ argument on `/goal`. So today the only way to set a bounded budget is the model calling `create_goal(max_goal_rounds: 1)`
1894
+ — which capture attempt 4 proved a tape cannot depend on.
1895
+
1896
+ Decision, **retracted in its last clause**: the claim stays **unit-covered**, §28 keeps not claiming the pixel, and the leg stays **unwritten, not skipped** — but NOT because the change "belongs upstream". It does not. `ctx.goals.create(agent, request)` is plugin-callable with `maxGoalRounds` in the request (`examples/dsh/packages/goal/goal/src/index.ts:303`, `types.ts:28-30`), so the seam is ours to build as a rig fixture with its own row id; only the *profile-patch override of the base `goal` row* is closed off. See the follow-up below and #138, which was rewritten from "host ask" to "build the rig seam".
1897
+
1898
+ ### #135 follow-up — a rig-reachable round budget does not exist, and staging's displacement leg is timing-fragile
1899
+
1900
+ The obvious knob is real: `@deepseek-ai/dsh-goal` ships `config = z.object({ defaultMaxGoalRounds: z.number().default(256) })`,
1901
+ and `resolveCreateGoal(request, defaultMaxGoalRounds)` uses `request.maxGoalRounds ?? defaultMaxGoalRounds`. But
1902
+ overriding it from a boot's profile patch **disables the feature the leg needs**: inserting
1903
+
1904
+ - id: goal
1905
+ name: '@deepseek-ai/dsh-goal'
1906
+ config:
1907
+ defaultMaxGoalRounds: 1
1908
+
1909
+ makes `/goal` stop creating a goal, and the leg fails at `the host goal bar appears once a goal exists`. Attribution was
1910
+ by experiment, not by reading: staging's own spec with staging's own 23-entry tape, **pin active → 1 failed / 4
1911
+ passed**; **pin removed → 5 passed**. Nothing was committed; the transform, its wiring and both rows that pinned it are
1912
+ gone, and the tree is staging-equal with `corpus:check` agreeing.
1913
+
1914
+ Two collateral facts worth keeping, because they are how I got here:
1915
+
1916
+ - The first pinned capture reached **326 tape entries in ten minutes**, which is what made me stop and inspect a real
1917
+ boot patch instead of trusting a green unit row. My rows had fed the transform a synthetic patch that *contained* a
1918
+ `goal` row; a real boot's patch contains none (measured: `preset-chapters`, `agent-preset-registry`, `llm-pi-ai`,
1919
+ `agent-default-model`, `llm-gate`, `fetch-timeouts`). Stub-of-the-fictional-shape again, this time mine.
1920
+ - **The merged `prompt` leg is portable by luck.** A docs-only PR (#137, zero non-docs delta) turned acceptance red with
1921
+ `MISS tape=prompt entries=23 … storedCandidates: []` while the runner's history held `<goal_round> … Round: 1/256`
1922
+ through `Round: 4/256` in a window my capture recorded with none. Run 40676609079 was green because the runner's
1923
+ latency happened to reproduce my machine's round count. The displacement *behavior* is real and was observed; the
1924
+ leg that carries it cannot currently be replayed dependably.
1925
+
1926
+ What that leaves: making the goal legs portable needs a **host** capability — a rounds argument on `/goal`, or a
1927
+ plugin-facing seam to open a bounded goal — which is #138. Not a weaker assertion, and not a deleted test: the leg
1928
+ stays as merged, with its fragility named instead of hidden.
1929
+
1930
+ #### Two variants measured, and they fail DIFFERENTLY (so the conclusion is narrower than "the config is bad")
1931
+
1932
+ Run as throwaway mutations of `tests/e2e/boot.ts` against staging's own spec and staging's own 23-entry tape — the
1933
+ cheapest possible attribution, ~4 minutes each, nothing committed:
1934
+
1935
+ | boot patch | outcome |
1936
+ |---|---|
1937
+ | `- id: goal` row **with** `config: { defaultMaxGoalRounds: 1 }` | `/goal` creates no goal: the leg fails at `the host goal bar appears once a goal exists` → **1 failed / 4 passed** |
1938
+ | `- id: goal` row **with no config at all** | all 5 tests pass, but the run still aborts: `RIG FAULT … MISS tape=prompt entries=23 messages=1 closest=0000.json` — an extra request the tape has no entry for, at the FIRST message |
1939
+ | row absent (staging as-is) | **5 passed**, rc 0 |
1940
+
1941
+ So a duplicated `goal` row in a boot's profile patch is not a neutral act on this host, configured or not: the layering
1942
+ adds a request (visible at `messages=1`, before any goal is involved), and adding the config key goes further and stops
1943
+ the command from creating a goal. **The rig cannot override the goal plugin's row.** #138's ask is therefore specifically
1944
+ a host seam — a `/goal` rounds argument or a plugin-facing bounded-goal call — not a patch-shape puzzle, and I should not
1945
+ spend another evening rediscovering it.
1946
+
1947
+ #### Attempt 5, and it is the one that closes the design space: re-ordering the leg does not bound rounds either
1948
+
1949
+ Hypothesis: if the worker loop is armed BEFORE the goal exists, the driver never gets a `turn/end` to fire at, so the
1950
+ history carries zero rounds. Measured by capturing the re-ordered leg: **`exit=0`, 194 tape entries, and 76 of them
1951
+ contain `goal_round`.** The gate I set for myself ("no round markers in the tape, or I do not ship it") fired, so the
1952
+ change was reverted instead of pushed — spec and corpus back to staging's set (23 files, `corpus:check` agreeing at
1953
+ `lib=b67e373defcad83a`, replay `5 passed`, tree clean).
1954
+
1955
+ Five distinct arrangements, all measured: clear-after-a-turn; clear-immediately; poll-until-a-round; arm-the-loop-first;
1956
+ and bounding via the model's `create_goal`. None bounds the driver, because creating a goal starts a self-sustaining
1957
+ loop of turns and replay answers every one of them instantly. **Conclusion, with the receipts: a goal-bearing tape leg
1958
+ is not achievable on this host without a round bound, and no leg-internal arrangement supplies one.** That is #138, and
1959
+ it is a host ask, not a rig task. `git clean` was needed to clear the 171 untracked tape files the capture left behind —
1960
+ restoring tracked files does not remove generated ones, and 194 files where 23 belong was the tell.
1961
+
1962
+ ### #130 attribution, and the two claims it leaves standing
1963
+
1964
+ Bounded both of our unbounded relief-path awaits (`src/engine.ts:915`, `:999`) against a 1.5 s budget and ran a row
1965
+ against a stubbed adapter: **the step still consumed the whole 4 s race**, so our awaits are real but not the binding
1966
+ constraint — the wait is inside `super.compactIfNeeded`, i.e. the host awaiting the adapter on the pressure path. The
1967
+ change was reverted rather than shipped on an unproven causal claim (plane 1,115/1,115, `typecheck` 0, clean tree).
1968
+
1969
+ Methodological note that cost me a wasted attempt: an adapter stub that **never settles** measures nothing, because the
1970
+ host's own base engine calls the same stub and hangs with it. **Slow but eventual** (8 s) is the shape that separates
1971
+ our awaits from the host's. Same lesson family as the fictional-shape stubs AGENTS warns about — caught before it became
1972
+ a commit this time.