@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.
- package/cordis.patch.yml +7 -0
- package/docs/backlog.md +1616 -0
- package/docs/contract.md +293 -0
- package/docs/development.md +183 -5
- package/docs/host-protected-head-ask.md +80 -0
- package/docs/project-state.md +319 -0
- package/docs/verify.md +37 -0
- package/lib/arrival.js +24 -0
- package/lib/arrival.js.map +1 -1
- package/lib/aux-message.d.ts +17 -0
- package/lib/aux-message.js +20 -0
- package/lib/aux-message.js.map +1 -0
- package/lib/client.js +536 -227
- package/lib/client.js.map +1 -1
- package/lib/commands.js +107 -1
- package/lib/commands.js.map +1 -1
- package/lib/continue-core.d.ts +6 -0
- package/lib/continue-core.js +18 -13
- package/lib/continue-core.js.map +1 -1
- package/lib/engine-core.d.ts +3 -0
- package/lib/engine-core.js +4 -3
- package/lib/engine-core.js.map +1 -1
- package/lib/engine-note.d.ts +35 -0
- package/lib/engine-note.js +32 -0
- package/lib/engine-note.js.map +1 -0
- package/lib/engine.d.ts +10 -0
- package/lib/engine.js +97 -6
- package/lib/engine.js.map +1 -1
- package/lib/index.d.ts +5 -0
- package/lib/index.js +199 -8
- package/lib/index.js.map +1 -1
- package/lib/injections.js +10 -0
- package/lib/injections.js.map +1 -1
- package/lib/prompt-loop.d.ts +73 -0
- package/lib/prompt-loop.js +141 -0
- package/lib/prompt-loop.js.map +1 -0
- package/lib/prompt-runner.d.ts +44 -0
- package/lib/prompt-runner.js +85 -0
- package/lib/prompt-runner.js.map +1 -0
- package/lib/prompt-state.d.ts +124 -0
- package/lib/prompt-state.js +96 -0
- package/lib/prompt-state.js.map +1 -0
- package/lib/prompt.d.ts +32 -2
- package/lib/prompt.js +31 -9
- package/lib/prompt.js.map +1 -1
- package/lib/registry.d.ts +7 -0
- package/lib/registry.js +2 -0
- package/lib/registry.js.map +1 -1
- package/lib/render.d.ts +22 -1
- package/lib/render.js +40 -7
- package/lib/render.js.map +1 -1
- package/lib/repair-run.d.ts +27 -0
- package/lib/repair-run.js +85 -2
- package/lib/repair-run.js.map +1 -1
- package/lib/rules.js +8 -0
- package/lib/rules.js.map +1 -1
- package/lib/save-core.js +4 -4
- package/lib/save-core.js.map +1 -1
- package/lib/store.js +11 -0
- package/lib/store.js.map +1 -1
- package/lib/tools.d.ts +3 -0
- package/lib/tools.js +17 -4
- package/lib/tools.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- 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`).
|
package/docs/development.md
CHANGED
|
@@ -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. **
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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.
|