@treeseed/dsh-chapters 0.2.1 → 0.2.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 (66) hide show
  1. package/cordis.patch.yml +7 -0
  2. package/docs/backlog.md +1616 -0
  3. package/docs/contract.md +293 -0
  4. package/docs/development.md +183 -5
  5. package/docs/host-protected-head-ask.md +80 -0
  6. package/docs/project-state.md +319 -0
  7. package/docs/verify.md +37 -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-note.d.ts +35 -0
  24. package/lib/engine-note.js +32 -0
  25. package/lib/engine-note.js.map +1 -0
  26. package/lib/engine.d.ts +10 -0
  27. package/lib/engine.js +97 -6
  28. package/lib/engine.js.map +1 -1
  29. package/lib/index.d.ts +5 -0
  30. package/lib/index.js +199 -8
  31. package/lib/index.js.map +1 -1
  32. package/lib/injections.js +10 -0
  33. package/lib/injections.js.map +1 -1
  34. package/lib/prompt-loop.d.ts +73 -0
  35. package/lib/prompt-loop.js +141 -0
  36. package/lib/prompt-loop.js.map +1 -0
  37. package/lib/prompt-runner.d.ts +44 -0
  38. package/lib/prompt-runner.js +85 -0
  39. package/lib/prompt-runner.js.map +1 -0
  40. package/lib/prompt-state.d.ts +124 -0
  41. package/lib/prompt-state.js +96 -0
  42. package/lib/prompt-state.js.map +1 -0
  43. package/lib/prompt.d.ts +32 -2
  44. package/lib/prompt.js +31 -9
  45. package/lib/prompt.js.map +1 -1
  46. package/lib/registry.d.ts +7 -0
  47. package/lib/registry.js +2 -0
  48. package/lib/registry.js.map +1 -1
  49. package/lib/render.d.ts +22 -1
  50. package/lib/render.js +40 -7
  51. package/lib/render.js.map +1 -1
  52. package/lib/repair-run.d.ts +27 -0
  53. package/lib/repair-run.js +85 -2
  54. package/lib/repair-run.js.map +1 -1
  55. package/lib/rules.js +8 -0
  56. package/lib/rules.js.map +1 -1
  57. package/lib/save-core.js +4 -4
  58. package/lib/save-core.js.map +1 -1
  59. package/lib/store.js +11 -0
  60. package/lib/store.js.map +1 -1
  61. package/lib/tools.d.ts +3 -0
  62. package/lib/tools.js +17 -4
  63. package/lib/tools.js.map +1 -1
  64. package/lib/version.d.ts +1 -1
  65. package/lib/version.js +1 -1
  66. package/package.json +20 -16
package/docs/contract.md CHANGED
@@ -406,6 +406,56 @@ Entry `apply(ctx, config)`. Build with `tsc -p tsconfig.json` and **build before
406
406
  UI (later phase) additionally needs `dsh.client` and a `tsdown.client.config.ts` — see
407
407
  `dsh-session-fork/package.json` for the shape.
408
408
 
409
+ ### An armed goal has no rest state — measured, with the lines that prove it
410
+
411
+ Verified while trying to make a goal-bearing tape leg deterministic (#138). The round driver gates on
412
+ `phase === 'active' && activation === 'armed'` plus agent idleness
413
+ (`@deepseek-ai/dsh-goal-round-driver` `src/index.ts:105-117`), and a round is itself a turn — so every round ends, and
414
+ the next is scheduled. There is no moment in which an armed goal is also at rest:
415
+
416
+ - **spending the budget ends the goal.** At `:182-188`, `roundsStarted >= maxGoalRounds` calls
417
+ `ctx.goals.block(agent, ref, { code: 'round-limit', message: 'Goal reached its configured limit of N rounds.' })`.
418
+ - **the strip then disappears.** `@deepseek-ai/dsh-client-ui-goal` `GoalBar.tsx:85-86`: *"Loading, absent, and complete
419
+ goals have no strip at all"*, and the guard excludes blocked/complete. So the control a test wants to observe
420
+ (`Pause goal` → `Resume goal`) is not rendered in the state the budget produces.
421
+ - **the per-goal budget is a plugin argument, not a user one.** `ctx.goals.create(agent, { objective, maxGoalRounds })`
422
+ (`@deepseek-ai/dsh-goal` `src/index.ts:303`, `types.ts:28-30`) — the human `/goal` command passes no budget
423
+ (`dsh-command-goal` usage is `goal <objective>, /goal clear`; `command-goal/src/index.ts:150` calls `create` without
424
+ one), so the default 256 applies.
425
+
426
+ Consequences for this repo, both measured: a goal-bearing tape leg agrees with a runner only while the runner's latency
427
+ happens to reproduce the capture's round count (staging's `prompt` leg failed twice on docs-only pushes:
428
+ `MISS tape=prompt entries=23 storedCandidates: []`, runs 40697174778 / 40706037817); and bounding the budget to 1 makes
429
+ the goal vanish before a leg can act on it (17-entry tape, `Round: 1/`, no second round, `the host goal bar appears once
430
+ a goal exists`). Upstream's own test fixture takes the third path — create then `ctx.goals.disarm(agent)`, noting
431
+ *"An armed goal makes the shipped goal-round-driver start a round at idle, racing the test driver's disposal"* — which
432
+ is the disarmed state, not the armed one a displacement claim needs.
433
+
434
+ What is needed to test this in a tape: a way to open an **armed** goal whose driver will not fire until released, or a
435
+ non-terminal exhausted state that still renders the control (#138). Until then the goal/suggestion interplay is
436
+ verified live under a wall-clock bound (`tests/e2e/prompt-goal-live.spec.ts`), not by the model-free acceptance job.
437
+
438
+ ### A profile patch may not re-declare a host plugin's row — measured on 0.2.0-rc.2
439
+
440
+ `profiles/web/cordis.patch.yml` layers by row `id`, and the rig relies on that for its **own** rows
441
+ (`preset-chapters`, `agent-preset-registry`, `llm-pi-ai`, `agent-default-model`, `llm-gate`,
442
+ `fetch-timeouts`). Copying that trick onto a row that belongs to the installed bundle is not neutral. Three variants,
443
+ each run against an unchanged spec and corpus so the only variable was the patch:
444
+
445
+ | profile patch | result |
446
+ |---|---|
447
+ | `- id: goal` + `config: { defaultMaxGoalRounds: 1 }` | the `goal` plugin stops working: `/goal` creates no goal, so `[data-goal-bar]` never renders |
448
+ | `- id: goal` with **no** config | every test passes, but the boot issues a request no tape has: `MISS tape=prompt entries=23 messages=1 closest=0000.json` — at the FIRST message, before any goal exists |
449
+ | no `goal` row in the profile patch | clean run (`5 passed`, rc 0) |
450
+
451
+ Read it as: duplicating a host row changes the mounted plugin set (extra registration → an extra request), and adding a
452
+ `config:` block to that duplicate replaces the base row rather than merging into it, taking the plugin's real config
453
+ surface with it. So the patch is a way to configure **rows you own**, not a way to reach into a host plugin's settings.
454
+ The concrete consequence: `@deepseek-ai/dsh-goal` does export
455
+ `config = z.object({ defaultMaxGoalRounds: z.number().default(256) })` and `resolveCreateGoal(request,
456
+ defaultMaxGoalRounds)` honors it, but no boot can pin it from the profile — which is why goal-round bounding is a host
457
+ ask (#138) and not a rig bug to fix.
458
+
409
459
  ### Presets are declaration rows now (host ≥ 0.1.7-alpha.1) — measured, with the incident
410
460
 
411
461
  A user preset used to be a directory `$DSH_HOME/.agent-presets/<id>/` holding `preset.yml` +
@@ -512,6 +562,11 @@ not stylistic:
512
562
  a plain function callable **without an agent turn** — build that seam in Phase 1, not Phase 5, or the
513
563
  UI and automatic paths both force a rewrite.
514
564
 
565
+ > **SUPERSEDED as a surface by § "Suggested Next Step bar" below** (#113, chip removed in #114). The facts in
566
+ > this section stay authoritative and are the reason the replacement is a docked row: the overlay geometry,
567
+ > the non-writable placeholder, and `inputActions.setDraft`/`submit` were all measured here first. Reviving an
568
+ > overlay must answer all four defects #113 recorded, with measurements.
569
+
515
570
  ## Composer Ghost Layer — measured host facts (#71, verified 2026-10-21 against `examples/dsh` @ 0.2.1-rc.2; runtime pin 0.2.0-rc.2, compile deps still 0.1.5-rc.2)
516
571
 
517
572
  The prompt-recommendation feature renders a ghost layer in the composer. What the host does
@@ -593,3 +648,241 @@ Two corollaries, both bought once:
593
648
  - An injected port that is optional and stops being wired fails as a symptom, not as an error:
594
649
  `tests/unit/client-doors.test.ts` has a row that fails if `src/client/index.ts` drops
595
650
  `uiWorkspace` from `inject` or stops passing it.
651
+
652
+ ## `ContinuePorts.mutateState` — the row write seam (#101)
653
+
654
+ `ContinuePorts` exposes **both** `getState`/`putState` and `mutateState(sessionId, fn)`, and the split is
655
+ load-bearing, not historical:
656
+
657
+ - `getState` is for **reads** (ancestor walks, root resolution) and for nothing else.
658
+ - `putState` survives only for **brand-new rows** — a child row that no other writer can hold yet
659
+ (`linkChild(freshSession(childId), …)` at `continue-core.ts:527`).
660
+ - **Every write to an EXISTING row goes through `mutateState`**, because `store.put` replaces the whole
661
+ value: a `getState`→`putState` pair loses any commit another door made in between. The chapter allocator
662
+ (`reserve`, `reservePlan`) runs *after* hundreds of fs writes, and the commit runs after `agents.create`
663
+ plus the artifact work — both are windows. AGENTS records the incident class: an enrichment put at
664
+ 04:53:52.841 rewound a compaction commit from 04:53:50.915, deleting its manifest, reservation,
665
+ `finalized` entry and chapter record while its 960 KB file survived on disk. Files present + records
666
+ gone is the signature, and `chapters-repair` then looks like a phantom-maker.
667
+
668
+ `fn` must apply its change to the row it is handed and may not return a captured snapshot — a
669
+ `mutate(id, () => snapshot)` is a stale `put` with better branding (fixed once already in `repair-run.ts`
670
+ for the hollow-payload supersede).
671
+
672
+ ## Suggested Next Step bar
673
+
674
+ > **Host fact, measured 2026-10-28 (the draft gesture's limit, and where it actually lives).** The composer surface a
675
+ > dock receives carries `captureInsertion, insertText, setDraft, persistDraft, addAttachments, removeAttachment,
676
+ > pruneAttachments, submit` — every verb is present and callable — and a programmatic write still throws
677
+ > `Cannot read properties of undefined (reading 'draftRev')` with the composer text left empty. Reproduced identically
678
+ > on host `0.2.0-rc.2` and `0.2.1-rc.4` (`var/e2e-prompt-draft-dump.json`, `LEG=prompt MODE=record`), which is the
679
+ > evidence behind #126 staying a host ask: **`draftRev` is not referenced anywhere in `ui-conversation/src`** in either
680
+ > tag — the identifier lives in `ui-commands/src/client/popup.ts` — so the write path's missing revision object is not
681
+ > the module whose `setDraft` implementation changed between those versions. Do not infer a fix from that module's diff;
682
+ > the source read looked convincing and the browser said otherwise. The shipped client therefore refuses visibly and
683
+ > keeps Run as the working gesture (`docs/verify.md`'s "delivered, not assumed").
684
+
685
+
686
+ **What a dock component is handed (measured from the host's own contract table,
687
+ `dsh-cordis-client-runner/lib/client.js`, entry `key: "conversation.input.dock"`).** It is a `list` region,
688
+ `scope: session`. Register options are `id` (required), `order` (optional, ascending, default 0), and `label`.
689
+ The component's props are **hooks**, not scalars: `useSessions`, `useSessionStatus`, `useSessionRetainInfo`,
690
+ `useWorkspaces`, `useChat`, `useConversation`, `useInput`, `inputActions`, `useSession`, `sessionId`,
691
+ `useProjection`, `useTrajectory`. Shipped occupants: `todo` (order 0), `goal` (10), `queue` (20).
692
+
693
+ Why this is written down: the bar's Autorun gate was written against a **scalar** `props.running` that no host
694
+ version supplies, so Autorun reported itself unavailable on a host that exposes the very signal through
695
+ `useSessionStatus`/`useChat`. Reading the contract table beats remembering a prop name. `inputActions` here is
696
+ `{ captureInsertion, insertText, setDraft, addAttachments, removeAttachment, pruneAttachments, submit }`, and
697
+ the composer-write limitation in #126 is measured against that same kit.
698
+
699
+ **Verbs (interface lock, #113).** `/chapters-prompt` accepts `model …|clear`, `off`, `on`, `state`, `autorun on|off`,
700
+ and **`run`** — submit the offer currently on screen as a **human-authored user turn**, through the same follow-up
701
+ path autorun uses (`agent.followup(suggestPromptMessage(…))`). Run goes through the command plane rather than the
702
+ composer's `inputActions.setDraft` because a dock cannot rely on writing the host editor: measured, `setDraft` is
703
+ accepted and the composer still reads empty, and the goal bar's own `setDraft` is plain React state, not a
704
+ composer write. A plugin's only dependable wire to its own server plane is the command — which is why the bar is
705
+ push-shaped and pull-fed.
706
+
707
+ **The dock's status hook is a SELECTOR hook (measured, 2026-10-27).** `useSessionStatus` is declared
708
+ `SnapshotSelectorHook<SessionStatusSnapshot>` in `dsh-client-ui-session`'s client types, so it is called as
709
+ `useSessionStatus(selector)` — passing a session id hands the host a string where it wants a function and it
710
+ fails `TypeError: l is not a function`. Two consequences for anyone wiring the bar: the call must be hoisted
711
+ above the component's early `return null` (a hook below that guard changes the hook count and React aborts the
712
+ whole slot, `#310`), and the snapshot's field names are not ours to assume — the dock folds it through
713
+ `deriveRunning`, which maps `boolean`, status words, `{status|state|phase}`, and `{running}`, and returns
714
+ **`undefined` for anything unrecognized**, because `undefined` (unknown) and `false` (idle) are different
715
+ safety answers: unknown must never start a turn.
716
+
717
+ **State vocabulary (interface lock, #113).** `/chapters-prompt state` reports one of:
718
+ `off` — switched off by the human (a stored override), or no defining model is configured, so the bar renders
719
+ nothing; `idle` — **enabled and awaiting its first recommendation** (no completed turn yet); `analyzing` — a
720
+ generation is in flight for the current turn; `ready` — an offer is on screen with `text`; `failed` — the
721
+ generation could not run, with the reason. `idle` exists because collapsing "nothing yet" into `off` made a
722
+ working feature indistinguishable from a disabled one: measured in the rig, the bar replied
723
+ `suggested next step: off` while the profile row read `model: qwen-token-plan/qwen3.8-flash (config)`.
724
+ The bar stays hidden on `off` only, and re-reads while the state is `idle`/`analyzing`/`ready`.
725
+ — interface lock (#113, builds on the measured goal-bar facts above)
726
+
727
+ A composer dock row that, once per **completed turn**, generates a fresh prompt suggestion and offers
728
+ **Run · Autorun · Off**. Modelled on the goal bar because the goal bar is tested and already fits — and the
729
+ mechanical parallel is exact: *a goal re-injects the same directive every turn until the model stops; a
730
+ suggestion re-derives a new directive every turn until the human stops it.*
731
+
732
+ **Measured anchors for every claim below are in the section above** (dock slot, ordering, anatomy, hiding
733
+ rules, `hooks:` observables, `remote.commands.execute` as the only plugin-reachable call path).
734
+
735
+ ### State, and who owns it
736
+
737
+ | field | owner | values |
738
+ |---|---|---|
739
+ | `status` | server, persisted per session | `off` \| `analyzing` \| `ready` \| `failed` |
740
+ | `text` | server | the suggestion, verbatim, never truncated (`promptMaxChars` is a **refusal** cap) |
741
+ | `turnSeq` | server | the `turn/end` seq the suggestion was generated **for** — the identity of an offer |
742
+ | `autorun` | **server, process-local** (exactly like the goal's `activation`) | `armed` \| `disarmed` |
743
+ | `expanded` | client, per-mount | `false` collapsed one line · `true` full text shown |
744
+
745
+ The durable pair (`status`, `turnSeq`) is the server's one-generation-per-turn key; the process-local pair
746
+ (`autorun`, `expanded`) is never persisted — same split the goal bar uses between `goal.phase` and
747
+ `activation`.
748
+
749
+ **Correction, found while wiring the loop and kept visible because the first version of this table was
750
+ wrong.** `autorun` is *not* client-local: the whole point of the feature is that a suggestion drives the
751
+ next real turn, and a turn is started host-side (`agent.followup`), so the flag has to live where the loop
752
+ does. It is **server-side process-local state**, disarmed on mount and on `agent/created`, re-armed only by
753
+ an explicit human action through the command — the host goal driver's `WeakMap` semantics copied on purpose,
754
+ including the safety property: **a loop cannot resurrect itself after a restart.** `expanded` stays purely
755
+ client-local, because only a browser cares.
756
+
757
+ ### Dock placement
758
+ `conversation.input.dock`, `order: 30` — after todo `0`, goal `10`, queue `20` (ascending sort), so it sits
759
+ **under all other dock entries and directly above the composer card**, which is what "right above the chat
760
+ input" means in this host. It is a docked row, never an overlay, so it cannot cover the placeholder.
761
+
762
+ ### Label and visibility (two fields decide both, as with the goal)
763
+ - `analyzing` → **`Analyzing situation…`**
764
+ - `ready` → **`Recommended Next Step`**
765
+ - `failed` → **`Recommended Next Step`** *plus* an error span (`role="alert"`) naming the reason and code — a
766
+ detected failure must be a delivered failure (#61); nothing is silently blank.
767
+ - `off`, or a **quiet** refusal (nothing to recommend yet) → renders **nothing**. A refusal is not an error,
768
+ and shouting it was defect #2 of the removed ghost.
769
+ - Also renders nothing while the session zone is absent, and keeps a just-turned-off guard so it cannot
770
+ flicker back before the server state catches up (the goal's `clearedGoalId` trick).
771
+
772
+ ### Interactions
773
+ 1. **Click the prompt (collapsed)** → expand; full text shown in the same strip.
774
+ 2. **Click the prompt (expanded)** → `inputActions.setDraft(text)`: the draft is *replaced*, editable, and
775
+ nothing is sent. No `Edit`-button fallback unless a browser pass proves the click is captured by the host
776
+ editor — and if so the fallback is the goal's inline swap, which is already the house pattern.
777
+ 3. **Run** → `setDraft(text)` then `submit()`, in that order, so what goes out is visible in the composer
778
+ first and lands in the durable log as an ordinary **human** message.
779
+ 4. **Autorun / Autorun off** → toggles the process-local `armed`/`disarmed`. While armed, after each
780
+ completed turn the bar pulls, and on `ready` performs the Run path by itself.
781
+ 5. **Off** → server `status: 'off'`; generation stops, the row disappears, the configured model is kept so
782
+ turning it back on is one click.
783
+
784
+ ### Goal composition (per the maintainer's spec, which corrects my first plan)
785
+ Autorun **runs alongside** an active goal — it does not stand aside. The suggested prompt is always generated
786
+ *with the goal in view*: the server reads the newest `goal/change` from the session and feeds the live
787
+ objective into the recommendation, with the directive that the next step must advance an active goal.
788
+ **When the goal completes (or is cleared/paused out of `active`), autorun disarms itself** — the suggestion
789
+ loop yields the stage back to the human at the moment the purpose it was serving is finished. A human prompt
790
+ also disarms autorun, while generation continues on later turn boundaries; re-arming is a human act.
791
+
792
+ ### Enabling
793
+ `promptModel` remains the single switch (empty ⇒ no bar, no calls, no cost) — the model *is* the enable.
794
+ `promptAutorunDefault` (bool, **false**) decides only whether a fresh session starts armed; nothing autoruns
795
+ that a human did not opt into.
796
+
797
+ ### Where the goal's loop actually lives, and what that changes for us (measured 2026-10-25)
798
+
799
+ The second probe corrected two things I had assumed, and one thing I had written:
800
+
801
+ - **The goal loop has no timer.** `dsh-goal-round-driver` chains turns off `agent/status === "idle"` and
802
+ `goal/changed`, coalesced into one serialized task per agent, and starts the next turn with
803
+ `agent.followup(createUserMessage({…, source:{kind:'goal', goalId, revision, round}}))`. It stops **silently**
804
+ unless `phase === "active" && activation === "armed"`.
805
+ - **`activation` is process-local and starts disarmed.** On mount the driver disarms every agent, and
806
+ `agent/created` disarms new ones; only a human `ctx.goals.resume` re-arms. A loop cannot survive a restart
807
+ on its own.
808
+ - **A plugin may read the goal through the host, not just the log**: `ctx.get('goals').get(agent)` → `GoalView`
809
+ (adds `roundsStarted` + `activation`), or `ctx.sessionProjections.stateOf(session, 'goal')`. `readActiveGoal`
810
+ remains for log-only paths (repair, distill, tests).
811
+ - **Trap that governs our authorship**: two invariant companions re-fold the goal stream and
812
+ `deepEqual`-check every `source.kind:'goal'` message against the package-owned `<goal_round>` template. So
813
+ **we never author as `goal`** — impersonating that kind is a loud invariant failure by design.
814
+
815
+ **Decision this forces (and the one I'm making, since it is a semantics choice, not a style one).** Run and
816
+ Autorun are different acts and must not share a mechanism:
817
+
818
+ | control | mechanism | durable effect | survives a closed tab? |
819
+ |---|---|---|---|
820
+ | **Run** (a button the human pressed) | client `setDraft` + `submit` | an ordinary **human** `user/message` (`source.kind:'user'`) | no — and correctly so |
821
+ | **Autorun** (a loop the human opted into) | server-side `agent.followup(...)` with our **own** producer kind `plugin:dsh-chapters` | a plugin-authored message, screened like a notice, never counted as human input | the loop itself is process-local and **disarms on restart**, mirroring the goal |
822
+
823
+ That split is what makes it goal-shaped *and* honest: the suggestion reaches the model with the session's full
824
+ tool capability (it is a real turn, not a browser trick), while nothing we generate can masquerade as the
825
+ human typing it. Autorun defaulting off, and being off after any restart, is a safety property inherited from
826
+ the goal driver rather than a limitation to apologize for.
827
+
828
+ ### Goal ↔ suggestion authority and precedence — measured 2026-10-25, host 0.2.0-rc.2
829
+
830
+ Read this before touching either loop. The two features start turns, and the host reserves *some* turns for
831
+ humans. Facts, with anchors in `dsh-tool-goal/lib/index.js`:
832
+
833
+ - **`hasDirectHumanInput(ctx, execution)`** = the agent is a runtime root **and** the open turn contains
834
+ `user/message` with `data.source.kind === 'user'` (:49-52). **`requireDirectHuman`** (:62-65) gates goal
835
+ `create`/`edit`/`pause`/`resume` — a model cannot re-cut a goal without a human turn in progress.
836
+ - **`isMatchingGoalRound`** (:54-56) additionally grants *completion* authority to the exact admitted goal
837
+ round (`source.kind === 'goal'` with matching `goalId/revision/round`), so the worker may close the goal it
838
+ was handed — but not re-author it.
839
+
840
+ Consequences for this plugin, in order of how much they matter:
841
+
842
+ 1. **A `Run`n suggestion is a human turn, so it CAN re-cut the goal.** The composer's `setDraft`+`submit`
843
+ produces `source.kind: 'user'`. This is the director path the maintainer is building: the big model writes
844
+ *"edit the goal to X, because step 2 of the plan showed Y"*, the human presses Run, and the worker now has
845
+ authority to edit the goal. Nothing about our authorship needs to change for this to work.
846
+ 2. **An autorun suggestion is NOT a human turn, so it MUST NOT try to edit the goal.** Our loop authors under
847
+ `plugin:dsh-chapters` (see above: faking `kind:'user'` here would be a privilege escalation — it would hand
848
+ an automated loop the authority the host deliberately reserves). So the host's refusal is the correct
849
+ behaviour, and the feature must respect it rather than route around it.
850
+ 3. **Therefore an offer carries an intent, and intent decides whether autorun may act on it.** The suggestion
851
+ model prefixes its answer: `GOAL:` = this step changes the standing objective → **never auto-run**; the bar
852
+ holds it for the human's keystroke and says why. Anything else = a worker step → auto-run is allowed.
853
+ The prefix is stripped before the prompt reaches the composer or the followup, so the worker never sees
854
+ protocol scaffolding.
855
+ 4. **Exactly one thing may start a turn at a boundary.** The host goal driver queues a round on
856
+ `agent/status === 'idle'` when `phase === 'active' && activation === 'armed'`. If our loop also followed
857
+ up, the session would start two turns per idle edge — a race against the driver's own reservation fence.
858
+ So when the service reports the goal `active` **and** `armed`, the suggestion loop **yields**: it keeps
859
+ generating offers (that is the director's eye on the work) and runs none of them. It runs again when the
860
+ goal is `paused`, `blocked`, `complete`, disarmed, or absent.
861
+ Activation is process-local and reachable only through the service (`ctx.get('goals').get(agent)` →
862
+ `GoalView.activation`), never from the log — that read is the point, not a convenience.
863
+ 5. **A goal edit invalidates the offer made under the old revision.** `revision` bumps exactly +1 per
864
+ accepted mutation, so the loop compares the revision it generated against and refuses to run a stale one
865
+ instead of pushing last cycle's directive into a re-cut objective.
866
+
867
+ This is the shape of the pair: the **goal** is the thing the human owns and re-cuts, the **suggestion** is the
868
+ thing the director model re-derives every turn. Autorun drives the worker; the keystroke drives the objective.
869
+
870
+ ### Displacement, not yielding — the maintainer's rule for two autoruns in one session (2026-10-25)
871
+
872
+ My first pass had the suggestion loop **yield** whenever a goal was `active`+`armed`. That was wrong against
873
+ the spec: *"the autorun for the suggested prompt … can either introduce or **replace** the autorun by the goal
874
+ if it is running."* Yielding made the two features mutually exclusive, which is the opposite of built for each
875
+ other — with a goal armed, a director's autorun would have quietly done nothing at all.
876
+
877
+ The rule now: **arming prompt autorun displaces the goal's continuation, and the suggestion carries the goal.**
878
+ - Mechanism: `GoalService.disarm(agent)` (`dsh-goal/lib/index.js:622-627`) — *"without changing durable goal
879
+ phase or revision"*, a later human-authorized `resume` records the new activation edge. That is precisely a
880
+ cooperative brake: the objective stays authoritative in the log, the driver simply stops re-injecting it, so
881
+ **exactly one loop starts a turn** at each boundary, and it is whichever the human armed last.
882
+ - The goal is not lost by being disarmed: every suggestion is generated *from* `goal/change` (objective +
883
+ revision) and the directive still says the next step must advance it. The director's words replace the host's
884
+ repeated round; the objective is unchanged.
885
+ - If `disarm` is unavailable (no `goals` service on this host) arming still succeeds, and the loop yields the
886
+ turn — so a missing seam degrades to the safe behaviour rather than double-firing.
887
+ - Re-arming the goal (`/goal resume`, or the bar's next human Run of a `GOAL:` step) is a human act, matching
888
+ the host's own rule that a model may not resume a paused goal (`GOAL_TOOL_RESUME_PAUSED`).
@@ -217,6 +217,53 @@ Re-record discipline (learned the hard way 2026-09-22/23):
217
217
  the missing host-plane route) that every deterministic layer had missed because each side
218
218
  only ever tested against its own convention. The e2e is the integration witness — keep it.
219
219
 
220
+ ### Reading a dead boot (#165, #166)
221
+
222
+ When a rig boot never announces its URL, `tests/e2e/boot.ts` throws `bootFailureMessage()` from
223
+ `tests/e2e/boot-failure.ts`, and the message carries: the measured wait, the port, the name of the knob that raises the
224
+ window for diagnosis (`E2E_BOOT_TIMEOUT_MS` — readiness to serve, never a product claim), and the **child's own tail**.
225
+ That tail is the whole value: on 2026-10-28 the single line `[chapters-prompt] turn/end door armed` distinguished "the
226
+ child mounted and stalled before listening" from "the child crashed", and separated the real cause from four wrong
227
+ theories I had already published.
228
+
229
+ Three shapes must not be blurred, and are pinned by `tests/unit/boot-failure-message.test.ts`: a tail with no lines reads
230
+ `(empty)`; a log that could not be read reads `unreadable or never written` (absence is not emptiness); and output is
231
+ bounded at 1,200 characters with any removal marked `…[redacted]`, so suppression is never mistaken for silence. The
232
+ redaction is not cosmetic: the child prints its own boot URL **carrying a session token**, and the old verbatim slice
233
+ copied it into the Playwright error, the per-leg log and CI annotations. Read `var/e2e-server-<port>.log` and
234
+ `var/e2e-boot-<port>.json` as sensitive for the same reason — I `cat`-ed one during tonight's diagnosis.
235
+
236
+ **One boot per journey still applies** (site §24, #129): a chain failure after leg one is an environment symptom, not a
237
+ tape symptom. Ruled out on this machine by check rather than hope: dangling tool links, orphan servers, port squatting,
238
+ `ulimit`, inotify instances, free memory, lock files. If a lone leg passes and a chain does not, stop and read the boot
239
+ message before touching a test.
240
+
241
+ ### What `coverage:e2e` promises, and what it cannot deliver
242
+
243
+ `coverage:e2e` runs the replay chain under `E2E_COVERAGE=1` (a V8 profile per boot, aggregated by
244
+ `scripts/e2e-coverage.mjs`). **It is a coverage report, not an acceptance gate, and it cannot carry the browser timing
245
+ guarantees acceptance has.** Measured 2026-10-28 on one tree with load recorded (3.16–4.30 average on 20 CPUs, two other
246
+ live harnesses on this host, ~68 GB free):
247
+
248
+ | attempt | legs that failed | signature |
249
+ |---|---|---|
250
+ | 1 | `suite` | `button[aria-label="Fork with chapters"]:visible` — `Timeout: 120000ms` |
251
+ | 2 | `prompt` | `locator.click` — `Test timeout of 300000ms exceeded` |
252
+ | 3 | eight of nine | mixed |
253
+ | 4 | `fanout` | `new-session click never set dsh.sessions.current` |
254
+
255
+ The controlled A/B fixes the cause: `LEG=suite MODE=replay` → **`4 passed`**; the same command with `E2E_COVERAGE=1` →
256
+ the same 120 s door timeout. So it is V8 instrumentation cost on the server process, landing on whichever leg happens to
257
+ be slow — not a stale corpus or a broken product. Profile volume is not it (19 MB / 4 files for a failing boot), which is
258
+ why narrowing what gets instrumented is the only lever, and that changes what the percentage means.
259
+
260
+ **Rules that follow.** Two ceilings are involved (120 s `toBeVisible`, 300 s test timeout) and **neither is to be raised**:
261
+ widening a wait to satisfy an instrumentation mode is `AGENTS.md`'s "never widen a test to pass". Read the e2e number as
262
+ a report; the blocking coverage verdict is CI's deterministic gate (statements/branches/functions against 85, in the merge
263
+ job). And when quoting an e2e figure, quote the tree and the machine conditions with it — that is how 77.94%, 77.97%,
264
+ 78.05% and 78.34% came to be cited interchangeably when each belongs to a different corpus. See #158, and site §24 for the
265
+ user-facing version.
266
+
220
267
  ### The coverage ledgers (added 2026-09-23 by the coverage audit; gated same day)
221
268
 
222
269
  - `npm run coverage:ci` — **the gate CI enforces.** `c8 --check-coverage
@@ -800,13 +847,144 @@ correct. The corpus spanning them is not.
800
847
 
801
848
  Rules that follow, in the order they bite:
802
849
 
803
- 1. **Change `src/` → rebuild → re-distill ALL nine legs.** A partial re-record is a mixed corpus, and a
804
- mixed corpus fails at whichever step the builds disagreed — usually inside compaction, where message
805
- counts are the thing being matched.
806
- 2. **`PROVENANCE.md` per leg is necessary, not sufficient.** It records the host and regime; it does not
807
- say "this leg predates three plugin commits". When in doubt about build drift, re-record everything.
850
+ 1. **Two questions, two answers — `npm run corpus:check` (#156).** A rebuild changes the *bundle*; that alone does **not**
851
+ make the corpus stale, it makes the *stamp* stale: the check prints an `ADVISORY` and the replay still runs, then
852
+ `npm run corpus:stamp` re-records `BUILD_STAMP.json` (`lib` + `promptAnchor`) once the legs are green. What does
853
+ invalidate a corpus is the **prompt-byte anchor** moving — tool and command `description`s, dock `label`s, the preset
854
+ persona, and the realm keys whose values reach the model (`thresholdRatio`, `retain*`, `headroom*`, artifact/defer
855
+ tokens, …). Then `verdictFor` refuses with `REFUSED: model-facing bytes moved …` and **all** affected projects must be
856
+ re-distilled before anything is trusted, because a partial re-record yields a mixed corpus and matching happens across
857
+ the whole set. The rule in `AGENTS.md` — "re-record when PRODUCT prompt bytes change" — is now a computed quantity
858
+ rather than a judgement call; logging keys, store paths and `maxBytes` are deliberately outside the anchor, and a
859
+ manifest file that contributes no tokens fails its own row, so a silently-dead extraction rule cannot masquerade as
860
+ protection.
861
+ 2. **`PROVENANCE.md` per leg is necessary, not sufficient.** It records the host and regime; it cannot
862
+ say "this leg predates three plugin commits". The stamp is what says that, and #156's split is what stops the stamp from
863
+ crying wolf on every rebuild.
808
864
  3. **Test-only changes (`tests/e2e/**`) need no re-record** — normalization folds apply at load to both
809
865
  sides. Product-behavior changes do. The distinction is exactly AGENTS' "re-record when PRODUCT prompt
810
866
  bytes change", generalized: anything that changes what the plugin *sends*.
811
867
  4. If a green replay is ever obtained on a corpus whose legs have different build stamps, treat it as
812
868
  unexplained rather than as evidence.
869
+
870
+ ## Harness-native acceptance (`E2E_PROVIDER=harness`)
871
+
872
+ The rig used to have exactly one model route: a tracked profile block pinning `local` at
873
+ `http://localhost:8080/v1`, because a GitHub runner has no credentials and can start a llama.cpp server. On
874
+ this machine that made acceptance a second-class citizen — the model was almost never up, live legs
875
+ `test.skip`-ed themselves (which reads like a pass), and re-distilling meant standing up an inference server
876
+ before you could look at UI.
877
+
878
+ `E2E_PROVIDER=harness` takes the route from **the profile this host is actually running** — read from
879
+ `$DSH_HOME/profiles/web/cordis.patch.yml` (override with `E2E_AMBIENT_PROFILE`) — and the credential from, in
880
+ order:
881
+
882
+ 1. **the parent environment**, `QWEN_TOKEN_PLAN_API_KEY` — the same way this harness instance got it. When you
883
+ run a leg from your own shell, this is the only step.
884
+ 2. `.dshdev-local/e2e.env`, untracked and gitignored, parsed **without executing it** (`readEnvFile` — values
885
+ are data, never a shell).
886
+ 3. otherwise the boot **refuses**, naming the remedy. No fallback to `local`, no silent skip, no guessed route.
887
+
888
+ What it deliberately keeps: the **pinned 32K stress window**. `harnessProviderPatch` rewrites
889
+ `contextWindow` rather than inheriting the endpoint's 196K, because the whole pressure/cache journey exists to
890
+ observe `context-overflow` — a hosted window would swallow a 33K prompt and the leg would measure a boot where
891
+ nothing is wrong.
892
+
893
+ Commands (all of them print one `[provider] …` line naming provider, model, window and where the key came
894
+ from, with the credential's length but never its value):
895
+
896
+ | command | mode | what it's for |
897
+ |---|---|---|
898
+ | `npm run e2e:harness:live` | live | a real look at the UI on the model the product runs against |
899
+ | `npm run e2e:harness:heal` | replay + fallback | tape hits replay, **misses go live and are appended** — a self-healing pass instead of a full re-record (the switch existed in code but was never surfaced) |
900
+ | `npm run corpus:heal -- --project=<leg>` | replay + fallback | one leg, when you know which one drifted |
901
+ | `npm run corpus:record -- --project=<leg>` | record | re-distill one leg after a deliberate product-prompt change |
902
+
903
+ Why this is more than convenience: the `#90 REASONING-BLIND` entry in the tape's blind list masks
904
+ `reasoning_content` shape differences. Tapes distilled through a different provider than the product uses will
905
+ always need masks; distilling through the product's own route removes a class of divergence instead of hiding
906
+ it. Stated as strong inference from the measurement, to be confirmed the first time a harness-native
907
+ re-distill stops needing that mask.
908
+
909
+ Note on what an agent shell can see: the harness **withholds** `QWEN_TOKEN_PLAN_API_KEY` from tool processes
910
+ (measured — the harness's own process env has it, the agent's child shell does not). So a distill or live run
911
+ from inside an agent session needs the `.dshdev-local/e2e.env` path, or the operator runs it. Reading the key
912
+ out of the parent process's environment is technically possible for the same user and is deliberately **not**
913
+ done: the withholding looks intentional, and reaching around it to move a credential into a tool shell would be
914
+ exactly the kind of quiet workaround this project documents itself out of.
915
+
916
+ ## The provider identity is part of the tape key
917
+
918
+ A tape is matched on the **request**, and the request's first message is the composed system header — which
919
+ states the provider and model. So a corpus distilled with one provider row cannot replay under another, and the
920
+ failure does not look like configuration: it looks like a corrupt tape, because matching dies at
921
+ `commonPrefix=0` and every leg then reports its own downstream symptom (no assistant message, no fork door, no
922
+ plan card, a post-compaction turn that never answers).
923
+
924
+ Measured the hard way on 2026-10-27: re-distilling the corpus with `E2E_PROVIDER=harness` while hosted CI
925
+ replays in the tracked-local shape produced `MISS tape=suite … tied=[every entry]` on CI and a green chain
926
+ locally — the same command, two different headers. The fix is placement, not normalization:
927
+
928
+ - **the host profile stays on the fixture provider** (`local/qwen3.8-flash-next`) in record and replay, so the
929
+ header is identical on every machine and in CI;
930
+ - **the real endpoint lives in the tape proxy** — `E2E_UPSTREAM` derived from the installed provider module and
931
+ `E2E_UPSTREAM_MODEL` translating the fixture name — so the model answering is the maintainer's while the
932
+ bytes being recorded are the rig's;
933
+ - `E2E_HARNESS_PROFILE=1` restores the profile rewrite for **live** runs, where there is no tape to stay
934
+ identical with and the point is genuinely to exercise the ambient provider.
935
+
936
+ Two consequences worth holding onto: the `reasoning`/`retryPolicy` fields of the synthesized row change product
937
+ behavior (`maxRetries: 0` versus the host default), so "same model, different row" is not a neutral edit; and
938
+ a re-distill must run the leg to **green** — a tape distilled by a failing journey is an incomplete capture, and
939
+ replaying it back as a miss blames the artifact for a run that never finished.
940
+
941
+ ## Two rig rules from the 2026-10-27 mount chase
942
+
943
+ **A boot must resolve what its profile declares, before the host starts.** The booted profile's preset names 28
944
+ `@deepseek-ai/*` plugin packages, and none of them is a dependency of this repository. On a development host they
945
+ resolve out of `~/node_modules`; on a CI runner they resolve nowhere, `pruneUnresolvableRows()` drops them
946
+ without complaint, and the session composes a 7-tool surface where a laptop gets 34. Because the system header
947
+ is message 0 of every request — the thing tapes match on — that asymmetry makes a locally recorded corpus
948
+ unreplayable in CI, which is what every `commonPrefix=0` on this branch actually was. `linkPinnedToolPackages()`
949
+ now symlinks the pinned install's packages into `node_modules/@deepseek-ai` **before** `spawn`, skipping anything
950
+ the repo already provides (never replace a hoisted dependency with a symlink and risk the build under test), and
951
+ `var/e2e-tool-links-<port>.log` records `rows/linked/skippedAlreadyPresent/missingFromPin` for every boot. The
952
+ ordering is not ceremonial: linking after the URL line was measured to change nothing, because the host has
953
+ already mounted by then.
954
+
955
+ **Normalize, THEN diff.** Comparing two machines' RAW header dumps showed an 18-character gap — exactly the
956
+ length of the two checkout paths — and produced a duplicate blind for a fold that already existed
957
+ (`<DSH-CHECKOUT>`, `model-proxy.ts:159`). The #89 ordering row caught the collision the moment it shipped; the
958
+ procedure error was mine and is the one to remember: run the text through `substituteAll` before concluding a
959
+ fold is missing. One predicate per semantic decision, and a fold that already exists is not a missing fold.
960
+
961
+ ## A recorded journey must not depend on when the host compacts
962
+
963
+ **Symptom that gives it away:** the acceptance run misses a *different set of legs* each time, while message 0 keeps
964
+ matching. That is not an unblinded identity token and not a matcher bug. It means the two machines ran the same
965
+ journey and both compacted — at different moments. A checkpoint rewrites the surface, so the same assistant turn
966
+ lands at message index 6 here and index 2 there, and the surface is the tape key.
967
+
968
+ **The rule:** a leg that is NOT testing the machinery pins `noAuxModel`. It is a rig-side alias, translated at
969
+ profile-write time into the realm keys the engine really reads — `auto: false` + `elicitedPlot: false`,
970
+ `tests/e2e/rig-preset-pin.ts:79`, declared at `src/engine.ts:287` — so the request sequence in the tape is the whole
971
+ story regardless of how large a runner's header happens to be.
972
+
973
+ **The exceptions are measured, not preferred:** `heavy`, `suite`, `arrival` and `fanout` stay unpinned, because
974
+ pinning them deletes the behavior under test — a pinned `suite` never renders its fork door, a pinned `arrival`
975
+ never gets its stub pair, a pinned `fanout` loses parent synthesis, and `heavy`'s journey *is* the pressure one. Same
976
+ family as setting the arrival floor below the chunk size and calling the passing row a result. The drift row
977
+ (`only the pressure journeys leave model-involving machinery enabled`, `tests/unit/rig-pins.test.ts`) names offenders
978
+ in its failure text and asserts those four stay unpinned.
979
+
980
+ **And the method trap I walked into, recorded so it costs the next reader nothing:** I searched `src/` for the string
981
+ `noAuxModel`, found none, and concluded the switch was decorative — then shipped a "fix" folding a check into
982
+ `overTrigger`, a local that is computed and dropped, plus a guard test asserting the plugin must contain the alias's
983
+ literal name. Absence of a NAME in a source directory is not absence of an EFFECT: read what the rig writes into the
984
+ profile it boots, not what it calls the field on the way in.
985
+
986
+ **Why the transcript has to travel with the artifact:** this took three runs to see because the workspace-only
987
+ artifact never contained the booted home's session logs. `scripts/chain-replay.sh` now copies each leg's
988
+ `session.v4.jsonl.zstd` files and registry into `var/e2e-session-logs/<leg>-<port>` (six files, under 4 MB each) so
989
+ the runner's own history sits next to the tape diffs and the misses. When a miss claims "the conversation had
990
+ already taken a different shape", the answer is in that transcript — read it, don't theorize about it.
@@ -0,0 +1,80 @@
1
+ # Ask of the host team: the protected system head and a plugin-seeded first message (#105)
2
+
3
+ **Filed 2026-10-24 against `dsh 0.2.1` / session format v4.** Reproduced on the maintainer's own data:
4
+ a `chapters_continue` child (`ch-dc3be3ab-c3d9-4afc-bc53-9994c06600d6`) refuses to open in the UI with
5
+ `SessionFormatError: system/message requires a protected first surface head`, while its 2,924-line log
6
+ decodes cleanly. Nothing about that file is damaged.
7
+
8
+ ## The two kernel rules that cannot both hold for our children
9
+
10
+ 1. **The seed contract**, established by measurement against the installed kernel (`spikes/probe/FINDINGS.md`)
11
+ and recorded in `AGENTS.md` invariant 2: a continuation is
12
+ `agents.create({ sessionId, seed: [noticeEvent] })` with **one synthetic `user/message` at `seq 0`**, a
13
+ producer-owned `source.kind` (`plugin:dsh-chapters`, `form: 'snapshot'`), no `plugin` field (format v4
14
+ retired it and refuses it at row admission), and the kernel's own `permission/preset` / `sandbox/mode` /
15
+ `approval/policy` events appended **after** the seed. Seeding from the parent's events is refused as
16
+ capability-gated, and is the failure mode this plugin exists to avoid.
17
+ 2. **The protected head**, as read out of `@deepseek-ai/dsh-session-format-v2-to-v3/lib/index.js`:
18
+
19
+ ```js
20
+ const SURFACE_TYPES = new Set(["system/message", "user/message", "assistant/message", "tool/result"])
21
+ // inside restoreReleasedV3Artifact's event walk:
22
+ if (system) {
23
+ …
24
+ if (hasSurface && head === void 0)
25
+ throw new SessionFormatError("system/message requires a protected first surface head")
26
+ }
27
+ …
28
+ if (SURFACE_TYPES.has(event.type)) hasSurface = true
29
+ ```
30
+
31
+ `user/message` **is** a surface type. So a kernel-sanctioned seed at `seq 0` sets `hasSurface` before the
32
+ host composes and writes its own `system/message` header at the first step — and at that moment the header
33
+ can no longer claim the protected head, because `head` is only assigned to a `system/message` that arrives
34
+ while `hasSurface` is still false.
35
+
36
+ ## The statistics that make this a rule collision, not a corrupt file
37
+
38
+ 53 durable logs on this machine, first surface event preceding the header:
39
+
40
+ | shape | sessions |
41
+ |---|---|
42
+ | header **is** the first surface event | 50 |
43
+ | a `user/message src=plugin:dsh-chapters` at `seq 0` precedes it | **2** |
44
+
45
+ Both of the 2 are children this plugin created (`ch-dc3be3ab…`, 2,924 events — refused; `ch-c2a7ee0f…`,
46
+ 105 events — same shape, not yet opened). Also observed: that child's header carries `isSeeded: false`.
47
+
48
+ ## What we are asking for
49
+
50
+ **Preferred:** for the protected-head rule, do not treat a `user/message` whose `source.kind` is
51
+ producer-owned plugin (`form: 'snapshot'`) at `seq 0` as a surface event that pre-empts the head. The rule
52
+ exists to protect the system head from being shadowed or replaced; a plugin snapshot that the kernel
53
+ itself invited to `seq 0` is not a competing user turn.
54
+
55
+ **Acceptable alternative:** admit the mid-run header by letting the first `system/message` claim the head
56
+ when the preceding surface events are all plugin-produced seeds, and record the head position as it is
57
+ written.
58
+
59
+ **What would NOT fix it:** telling us to seed nothing. Then a continuation starts with no TOC, which is the
60
+ whole feature. And we cannot write the header ourselves — invariant 3, and a per-session system contribution
61
+ changes every session's header in the profile.
62
+
63
+ ## Why we are not just changing our own shape unilaterally
64
+
65
+ We could seed a non-surface synthetic event instead (the `plan/mode` seed in `docs/plans-carriage.md` § 10
66
+ proves the kernel accepts plugin-authored STATE events in a seed) and deliver the TOC as an appended message
67
+ at the child's first step. We are **not** building that on an assumption: it needs a measurement first —
68
+ does a state-only seed get admitted, and does the header then land as the first surface event so the session
69
+ loads? The spike design is in `spikes/seed-protected-head/` (see `docs/backlog.md`); one boot, zero model
70
+ tokens, a fake provider supplying the turn. If the measurement says the kernel admits it, we ship it behind
71
+ a config flag and the ask above becomes belt-and-braces. If it says otherwise, the ask is the only path, and
72
+ we would rather know that before a user loses a child.
73
+
74
+ ## Side observation, offered as a fact rather than a claim
75
+
76
+ We tried to reproduce the throw by calling `restoreReleasedV3Artifact` directly with the real bytes. Every
77
+ variant — the refused child *and* healthy sessions — failed earlier at `assertV3EventAdmission` with
78
+ `"system message requires plugin source"`. So that module's admission gate is not the path that validated
79
+ this v4 log, even though its message text is what the UI shows. Whatever the actual v4 validator is, the
80
+ statistics above and the rule text are consistent; a pointer to the right entry point would help us pin it.