Skip to content

feat(cli): full conversation preview in the trajectory browser - #382

Closed
forrestjgq wants to merge 1196 commits into
mainfrom
feat/trajectory_full_preview
Closed

forrestjgq wants to merge 1196 commits into
mainfrom
feat/trajectory_full_preview

Conversation

@forrestjgq

Copy link
Copy Markdown
Contributor

Summary

Space on an attempt row in the raven trajectory browser previously
showed two truncated per-turn preview lines. This PR replaces it with an
interactive full-conversation viewer:

  • Data layer raven/trajectory/conversation.py: rebuilds an attempt's
    span snapshot into a causally ordered stream of labeled records (User
    input, LLM input/output, Tool input/output, skill, memory, and
    subagent events). Event-time semantics (inputs at span start, outputs
    at span end) plus nesting depth from parentSpanId keep the causal
    order. Full content comes from artifact files (paths restricted to
    the trace store, streamed 512 KiB cap per file); span previews are
    only a visible fallback. ERROR spans and expected-but-unreadable
    payloads always yield a placeholder record. LLM inputs omit only the
    item-by-item verified common message prefix against the previous call
    (omissions and rewrites are announced); tool_calls of unexecuted
    tools keep their full arguments.
  • Rendering: kind-colored labels aligned to one column, hanging-indent
    bodies with word-aware CJK-safe wrapping, degraded/error/meta note
    lines, single-pass turn separators (interleaved traces are never
    regrouped), and a stacked layout on narrow terminals that never drops
    characters.
  • Interactive viewer (prompt_toolkit full-screen, entered for every
    preview on a terminal): scrolling, a muted bottom bar with an
    'h for help' hint, filter/collapsed state and the scroll range (laid
    out by display width, never overflowing any terminal), a scrollable
    help page reachable in full on small screens, a global collapse (s:
    bodies over five display lines fold to five plus an ellipsis; error
    and degradation notes never fold), and a label filter (%: real input
    line, case-insensitive, empty clears; filtered-out turn groups lose
    their separators). Resizes re-wrap and re-judge folding live; the
    content caret is hidden (only the filter input shows one). q/Esc
    returns to the list; Ctrl+C cancels the whole browser from any viewer
    state. A rebuild failure falls back to the legacy per-turn preview;
    without a terminal the preview degrades to direct printing.
  • Every dynamic field passes the sanitization gates (control
    characters, rich markup, and ANSI never reach the terminal). Adds the
    Conversation Record term to CONTEXT.md.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

  • uv run pytest tests/test_trajectory_conversation.py -q -> 45 passed

  • uv run pytest tests/test_cli_preview_viewer.py -q -> 23 passed
    (assertions run against really rendered screens)

  • uv run pytest tests/test_cli_trajectory_browse.py -q -> 153 passed

  • uv run pytest -q (full) -> 7232 passed, 46 skipped; 11 failed +
    20 errors reproduce identically (same node ids) on the base branch in
    a clean worktree - environment baseline, not introduced here

  • uv run ruff check / ruff format --check on changed files -> clean

  • make check-large-files -> pass

  • Manual: rendered 6 real local attempts at widths 72/120/30 with zero
    over-width lines (largest attempt 7060 lines in 0.35s); drove the
    real TUI in a pty at normal and 44x12 sizes (viewer, help paging,
    collapse, %llm filtering, Ctrl+C cancel)

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

Risk

  • Read/display side only; no persisted format changes. The untrusted
    input surface (artifact content, typed filter input) is contained by
    the store-prefix path check, the 512 KiB streamed cap, and per-field
    sanitization.

  • User-visible change: the Space preview is now a full-screen viewer;
    the legacy rendering remains as the automatic fallback when the
    rebuild fails, and non-terminal environments print directly.

  • Rollback: revert this PR, or restore the previous _preview_screen
    call site to get the old preview back.

  • Security impact considered

  • Backward compatibility considered

  • Rollback path is clear for risky changes

Related Issues

N/A

0xKT and others added 30 commits September 2, 2026 15:52
The ported render-service test pins poppler's word set equal to
pdfium's. Poppler versions drift on where a word ends ("bottom" versus
"bot" plus "tom"), so on a machine with a newer poppler the identical
fork test fails identically -- version noise, not a read error, found
by the real-machine batch the moment LibreOffice landed on this
machine and the capability skips woke up. The pin keeps its strict
form where the backends agree; where they disagree it first proves
both backends read the same page characters, then records an honest
skip naming the drift. Different content stays a hard fail.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
## Summary

Three of the wizard's recommended memory models are no longer the right answer.
`Qwen/Qwen3-Reranker-4B` has been retired from OpenRouter, and the memory-LLM
capability floor and the multimodal recommendation each named a generation
behind what is served today. They now read `qwen/qwen3-reranker-8b`,
`qwen/qwen3.8-flash` and `google/gemini-3.7-flash`. The embedding role is
unchanged.

The memory-LLM floor is deliberately not a GPT-5.x id. EverOS's OpenAI provider
puts `temperature` in every request unconditionally -- `openai_provider.chat`
falls back to its own default rather than omitting the field -- and the GPT-5.x
reasoning family rejects it:

```python
request: dict[str, Any] = {
    "model": model or self._model,
    "messages": [m.model_dump() for m in messages],
    "temperature": (temperature if temperature is not None else self._temperature),
}
```

This matters more than a text refresh reads. Since !458 the model field starts
on the role's example, so these ids are what Enter submits rather than prose on
screen, and a retired id costs a verification round trip to find out.

**The catalog moves with them.** A recommendation's English text is its
`raven.i18n` key, and `t()` answers with the English source when a key is
missing, so refreshing an id in the roles table alone drops that line's Chinese
with nothing failing. `raven/i18n/zh.py` carries the three matching keys, and
two tests now pin the pairing: every role's recommendation has to resolve in the
catalog, and has to name the example the field starts on. The first turns red on
exactly the mistake described here -- a mistake the catalog is already carrying
elsewhere, since 21 of its 479 keys have no verbatim occurrence left under
`raven/` (an upper bound: a key built by concatenation or handed to `t()` in a
variable is invisible to that scan).

## Where this comes from

Ported from the GitHub-side change that raised these ids, rather than left for
the branches to reconcile. Upstream carries them in the bilingual-tuple shape
this branch replaced, so resolving it later means splitting each string across
two files by hand, and taking only the half that conflicts textually is the
silent failure above.

That upstream change has two other parts, both deliberately left out of this MR:
an Azure OpenAI provider default, which is not this subsystem, and an inotify
headroom gate for EverOS spawns, whose file has moved to
`plugins-dist/everos-memory/raven_everos/server.py` on this branch and so needs
relocating rather than applying. Both still want doing; neither belongs here.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `HOME=$(mktemp -d) TERM=dumb uv run --all-extras pytest -q -p no:randomly`:
  17989 passed, 72 skipped, 0 failed
- `uv run pytest tests/test_cli_onboard_commands.py tests/test_i18n_boundary.py
  tests/test_config_update_everos.py -q`: 360 passed
- Mutation check, each reversal run against the suite: refreshing the id in the
  roles table while leaving `zh.py` alone turns
  `test_every_recommendation_reaches_the_catalog` and the rerank pin red, and
  pointing a role's example at a model its recommendation does not name turns
  `test_every_role_recommends_the_model_its_field_starts_on` red.
- `uv run ruff check` and `uv run ruff format --check` on all three changed
  files: clean

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No docs name these ids, and the change coins no domain term, so there was
nothing to update.

## Risk

- Security impact considered: none. The change is three model ids, their
  translations, and three assertions. No credential, endpoint or config path is
  touched.
- Backward compatibility considered: no schema change and no migration. An
  existing configured role keeps its stored model; these ids are only what a
  fresh run offers. A user who accepted the old rerank default still has it, and
  reconfiguring is what moves them.
- Rollback path is clear for risky changes: revert the commit. Reverting the
  source without the catalog would strand the Chinese, which is what the new
  test is there to catch.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
## Summary

Three of the wizard's recommended memory models are no longer the right answer.
`Qwen/Qwen3-Reranker-4B` has been retired from OpenRouter, and the memory-LLM
capability floor and the multimodal recommendation each named a generation
behind what is served today. They now read `qwen/qwen3-reranker-8b`,
`qwen/qwen3.8-flash` and `google/gemini-3.7-flash`. The embedding role is
unchanged.

The memory-LLM floor is deliberately not a GPT-5.x id. EverOS's OpenAI provider
puts `temperature` in every request unconditionally -- `openai_provider.chat`
falls back to its own default rather than omitting the field -- and the GPT-5.x
reasoning family rejects it:

```python
request: dict[str, Any] = {
    "model": model or self._model,
    "messages": [m.model_dump() for m in messages],
    "temperature": (temperature if temperature is not None else self._temperature),
}
```

This matters more than a text refresh reads. Since !458 the model field starts
on the role's example, so these ids are what Enter submits rather than prose on
screen, and a retired id costs a verification round trip to find out.

**The catalog moves with them.** A recommendation's English text is its
`raven.i18n` key, and `t()` answers with the English source when a key is
missing, so refreshing an id in the roles table alone drops that line's Chinese
with nothing failing. `raven/i18n/zh.py` carries the three matching keys, and
two tests now pin the pairing: every role's recommendation has to resolve in the
catalog, and has to name the example the field starts on. The first turns red on
exactly the mistake described here -- a mistake the catalog is already carrying
elsewhere, since 21 of its 479 keys have no verbatim occurrence left under
`raven/` (an upper bound: a key built by concatenation or handed to `t()` in a
variable is invisible to that scan).

## Where this comes from

Ported from the GitHub-side change that raised these ids, rather than left for
the branches to reconcile. Upstream carries them in the bilingual-tuple shape
this branch replaced, so resolving it later means splitting each string across
two files by hand, and taking only the half that conflicts textually is the
silent failure above.

That upstream change has two other parts, both deliberately left out of this MR:
an Azure OpenAI provider default, which is not this subsystem, and an inotify
headroom gate for EverOS spawns, whose file has moved to
`plugins-dist/everos-memory/raven_everos/server.py` on this branch and so needs
relocating rather than applying. Both still want doing; neither belongs here.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `HOME=$(mktemp -d) TERM=dumb uv run --all-extras pytest -q -p no:randomly`:
  17989 passed, 72 skipped, 0 failed
- `uv run pytest tests/test_cli_onboard_commands.py tests/test_i18n_boundary.py
  tests/test_config_update_everos.py -q`: 360 passed
- Mutation check, each reversal run against the suite: refreshing the id in the
  roles table while leaving `zh.py` alone turns
  `test_every_recommendation_reaches_the_catalog` and the rerank pin red, and
  pointing a role's example at a model its recommendation does not name turns
  `test_every_role_recommends_the_model_its_field_starts_on` red.
- `uv run ruff check` and `uv run ruff format --check` on all three changed
  files: clean

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No docs name these ids, and the change coins no domain term, so there was
nothing to update.

## Risk

- Security impact considered: none. The change is three model ids, their
  translations, and three assertions. No credential, endpoint or config path is
  touched.
- Backward compatibility considered: no schema change and no migration. An
  existing configured role keeps its stored model; these ids are only what a
  fresh run offers. A user who accepted the old rerank default still has it, and
  reconfiguring is what moves them.
- Rollback path is clear for risky changes: revert the commit. Reverting the
  source without the catalog would strand the Chinese, which is what the new
  test is there to catch.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
The web surface hands the host agent home to a product session as its
working directory, and the engine refuses a cwd that contains its own
home -- so every product, seating its engine home under the host
workspace via product_state_root, refused that dispatch. The fork
fixed its side (2505d47); this is the trunk half, as a shared
library seat: product_acp_home() homes each engine in the raven data
directory, never handed out as a cwd, guarded by a containment check
that asks the filesystem rather than the spelling (samefile over
existing ancestors, so an APFS case-variant workspace cannot smuggle
the home back inside), falling beside the host home when an operator
points the workspace at an ancestor, refusing loudly -- naming the
product's documented *_ACP_HOME override -- when nowhere is safe.
Work stays where the work is: repos, instance buckets and rendered
configs keep their state-root seats untouched. Four launcher families
pin containment in three directions: host-home cwd accepted, the
credentials directory still refused, the engine home itself refused.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
## Summary

The turn asker is now bound for a `SUBAGENT`-origin relay turn whose
conversation has a live `turn.subscribe`, so a sub-agent question spawned
from a delegated relay (a DAG announce re-entering the user's own
conversation) reaches the person watching instead of being declined in
milliseconds by a silent branch. The gate's proxy changes from turn
origin alone to turn origin plus "is anyone watching this conversation".

Why: on 2026-08-28 a DAG announce opened a SUBAGENT relay turn; the
raven-code sub-agent's write authorization `ask_user` was declined with
no log trace, blocking the run. The TUI had been restarted and no message
sent afterwards, which rules out the last-sender owner registry as the
watched-signal -- it is claimed only by `turn.send` -- so the signal is
the subscription registry, where opening a conversation counts as
watching. A `has_subscribers` query is added to the subscription emitter
(the runner already holds it), and the two no-asker decline branches now
log a warning, because the incident left no host-log trace.

Decisions recorded: the gateway spine deliberately keeps its USER-only
gate (a channel session has no watched-signal to gate on, and widening
there would regress abandoned-channel questions to 600s stalls); sentinel
`action_origin` turns were verified to already run with USER origin, so
no change was needed there.

Revision after review: `has_subscribers` is connection-liveness bound.
An abrupt websocket disconnect never unregisters the subscription, so a
registry-only predicate would open the gate on a stale entry and stall an
unattended relay for the broker's full 600s. The subscription records the
connection that registered it, and a bind/unbind liveness registry in
`raven/rpc/connection.py` is what tells a live watcher from a stale
entry. The `Co-authored-by` trailers were removed from the branch
commits: the commits were produced by a DeepSeek-powered session, and
crediting Claude with that model id would be a factual error in the
attribution record.

## Type

- [x] Fix

## Verification

- [x] Relevant tests pass locally
  - full unit suite (bare): `uv run pytest tests/ -q` -> 12864 passed, 41 skipped, 1 transient failure (`test_subagent_raven_launcher.py::test_the_folder_ships_exactly_one_roster_manifest`) that re-runs green in isolation, does not reproduce in the installer/launcher pair, and is untouched by this change's diff
  - clean-base baseline (detached worktree at the target branch tip, `--all-extras`): 18008 passed, 69 skipped, 0 failed
  - after the review fix: 5 suites 187 passed; transport cluster (`test_rpc_transport` / `test_rpc_turn_subscribe` / `test_rpc_turn_send` / `test_rpc_bootstrap`) 86 passed
  - the rebase is a pure replay (`git range-diff` reports all commits unchanged) and the new base touched none of the changed files
- [x] Relevant lint / type checks pass locally
  - `make lint-python` clean (format check passes)
  - `scripts/check_commit_messages.py` over the branch range exits 0
- [ ] User-facing docs or screenshots: not needed -- no user-facing surface changed; the questions render through the existing clarify sheet. A design document is included under `docs/specs/`.

## Risk

- [x] Security impact considered - the widened gate reads server-side subscription state, not caller input; CRON, HEARTBEAT and SENTINEL origins remain excluded; an unattended conversation still declines instantly (the liveness registry closes the gate on an abrupt disconnect, so no stall is reintroduced for runs nobody watches).
- [x] Backward compatibility considered - the only behavior change is SUBAGENT relay turns in watched conversations: their sub-agent questions now reach the watcher (bounded by the broker's 600s fail-safe) instead of defaulting instantly. All other turn shapes behave as before.
- [x] Rollback path is clear - revert the gate condition in the turn runner to the origin-only form; everything else is additive.

## Related Issues

N/A
## Summary

The turn asker is now bound for a `SUBAGENT`-origin relay turn whose
conversation has a live `turn.subscribe`, so a sub-agent question spawned
from a delegated relay (a DAG announce re-entering the user's own
conversation) reaches the person watching instead of being declined in
milliseconds by a silent branch. The gate's proxy changes from turn
origin alone to turn origin plus "is anyone watching this conversation".

Why: on 2026-08-28 a DAG announce opened a SUBAGENT relay turn; the
raven-code sub-agent's write authorization `ask_user` was declined with
no log trace, blocking the run. The TUI had been restarted and no message
sent afterwards, which rules out the last-sender owner registry as the
watched-signal -- it is claimed only by `turn.send` -- so the signal is
the subscription registry, where opening a conversation counts as
watching. A `has_subscribers` query is added to the subscription emitter
(the runner already holds it), and the two no-asker decline branches now
log a warning, because the incident left no host-log trace.

Decisions recorded: the gateway spine deliberately keeps its USER-only
gate (a channel session has no watched-signal to gate on, and widening
there would regress abandoned-channel questions to 600s stalls); sentinel
`action_origin` turns were verified to already run with USER origin, so
no change was needed there.

Revision after review: `has_subscribers` is connection-liveness bound.
An abrupt websocket disconnect never unregisters the subscription, so a
registry-only predicate would open the gate on a stale entry and stall an
unattended relay for the broker's full 600s. The subscription records the
connection that registered it, and a bind/unbind liveness registry in
`raven/rpc/connection.py` is what tells a live watcher from a stale
entry. The `Co-authored-by` trailers were removed from the branch
commits: the commits were produced by a DeepSeek-powered session, and
crediting Claude with that model id would be a factual error in the
attribution record.

## Type

- [x] Fix

## Verification

- [x] Relevant tests pass locally
  - full unit suite (bare): `uv run pytest tests/ -q` -> 12864 passed, 41 skipped, 1 transient failure (`test_subagent_raven_launcher.py::test_the_folder_ships_exactly_one_roster_manifest`) that re-runs green in isolation, does not reproduce in the installer/launcher pair, and is untouched by this change's diff
  - clean-base baseline (detached worktree at the target branch tip, `--all-extras`): 18008 passed, 69 skipped, 0 failed
  - after the review fix: 5 suites 187 passed; transport cluster (`test_rpc_transport` / `test_rpc_turn_subscribe` / `test_rpc_turn_send` / `test_rpc_bootstrap`) 86 passed
  - the rebase is a pure replay (`git range-diff` reports all commits unchanged) and the new base touched none of the changed files
- [x] Relevant lint / type checks pass locally
  - `make lint-python` clean (format check passes)
  - `scripts/check_commit_messages.py` over the branch range exits 0
- [ ] User-facing docs or screenshots: not needed -- no user-facing surface changed; the questions render through the existing clarify sheet. A design document is included under `docs/specs/`.

## Risk

- [x] Security impact considered - the widened gate reads server-side subscription state, not caller input; CRON, HEARTBEAT and SENTINEL origins remain excluded; an unattended conversation still declines instantly (the liveness registry closes the gate on an abrupt disconnect, so no stall is reintroduced for runs nobody watches).
- [x] Backward compatibility considered - the only behavior change is SUBAGENT relay turns in watched conversations: their sub-agent questions now reach the watcher (bounded by the broker's 600s fail-safe) instead of defaulting instantly. All other turn shapes behave as before.
- [x] Rollback path is clear - revert the gate condition in the turn runner to the origin-only form; everything else is additive.

## Related Issues

N/A
## Summary

An acp sub-agent test is a measurement, not just a verdict: `_test_acp` connects,
reads the handshake and records the capability snapshot the roster derives
`stateful` from. Nothing then re-composed the live agent table, so the
measurement reached the store and stopped there.

From that moment the two readers disagree. `subagents.list` re-reads the store
per request and reports the new capability, so the agent appears in the
`/new-instance` picker, whose filter is `enabled && stateful === true`. But
`create_instance` reads the row the table materialized, and refuses the very
agent the picker just offered, saying it is stateless. An agent first measured
before it could resume stayed unaddressable however many times it was
re-measured; only a restart or an unrelated config write ever fixed it.

Observed on the vendored `raven-ppt` sub-agent. Its stored snapshot says
`sessions: one-shot only` and was taken about ninety minutes before the commit
that added `sessionCapabilities.resume` and `loadSession` to that fork. A live
handshake now answers `sessions: resume, load`. A stale snapshot is
deliberately used rather than dropped, because dropping one degrades to
stateless, the most damaging default available -- so the false `resume=false`
was pinned in place until something both re-measured and re-composed.

Key decisions:

- **The declared door, not a narrower private path.** The generation freeze law
  on the `RavenRuntime` paper names exactly three doors that reconcile a
  member's data plane mid-generation, and the agents table is `loop.apply_agents`.
  `raven/rpc/methods/subagents.py` is already a sanctioned operator of that door
  in the door-roster guard, so no roster widening and no ruling is needed. The
  manager also has a narrower `refresh_agents` that re-applies the configs it
  already holds, avoiding the door's config re-read; it was deliberately not
  used, because reaching a member's internal verb from a surface routes around
  the declared door. The guard does not currently spell `refresh_agents`, so
  that reach would have passed the gate without being legal.
- **The same door a finished build takes.** A vendored build hot-applies after a
  filesystem change with no config write, and the law names that as the same
  durable truth. A capability snapshot is durable truth written outside the
  config file in exactly the same way.
- **Scoped to where a snapshot was written.** An acp kind on a configured entry
  is the condition `_test_acp` records under, so the trigger mirrors the write
  rather than approximating it. A cli test writes no snapshot, and a preset is a
  template no config claims, so neither reaches the door.
- **The wiring is pinned, not just the function.** The re-compose lives in the
  registration wrapper, and `subagents_test` takes the loop as a keyword. So
  registering the bare handler leaves every real caller on
  `agent_loop_factory=None`, `_hot_apply` returns on its first line, and this
  whole change is inert -- with the suite green, because the other tests call
  `subagents_test` directly and hand it the factory themselves, and
  `test_rpc_registration.py` asserts only that the name is registered. One test
  dispatches a real frame through the registered name and asserts the table was
  re-composed. `register_subagents_methods` has never pinned any of its six
  wrappers; what is specific here is that the live push is the entire change, so
  an unwrapped registration is not a degraded fix but no fix.
- **Guarded, so a table failure cannot mask a real verdict.** The snapshot is on
  disk before the door runs, and the next hot-apply picks it up. Raising would
  report a successful measurement as a failed test, and the door re-reads a
  config file any other client may be writing.

Only the gateway plane needed the change: the served page reaches these same
handlers, so one fix covers both surfaces.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Commands and results, all from a worktree at the target branch tip:

    uv run --frozen --python 3.12 --all-extras pytest -q tests/test_rpc_subagents.py \
      tests/test_subagent_acp.py tests/test_core_runtime_swap.py
    260 passed

    make test-python          # branch
    1 failed, 18013 passed, 69 skipped

    make test-python          # target branch tip, detached worktree
    1 failed, 18007 passed, 69 skipped

Identical failing-ID sets, so 0 introduced. The one failure is
`tests/test_cli_doctor_commands.py::test_doctor_answers_where_the_memories_are`,
red on the target branch tip as well; it is inherited, not from this change.
The +6 is exactly the six tests added here.

    make lint-imports
    Contracts: 8 kept, 0 broken.

    make lint-python
    All checks passed! / 1735 files already formatted

    uv run --frozen --python 3.12 python scripts/check_commit_messages.py <range>
    exit 0

The tests are not theatre. Every mutation of the change goes red:

    remove the re-compose entirely   -> 2 failed
    widen the gate to every kind     -> 1 failed
    widen the gate to every source   -> 1 failed
    let a failing re-compose raise   -> 1 failed
    unwrap the registration          -> 1 failed

Unmutated: 250 passed. The three negative tests stay green by design and are
paired with the positive one. `test_a_refresh_makes_a_newly_resumable_agent_addressable`
drives a real `SubagentManager` and a real `SnapshotStore` and asserts
`declared_stateful` flips only after the table is re-composed, which is the value
`create_instance` actually gates on.

The last mutation is the one review caught: it was green at 249 passed before the
wiring test existed, which meant the fix could be made inert by a one-word edit
with nothing failing. Under it now the wiring test goes red on its own assertion,
`assert len(applied) == 1` becoming `assert 0 == 1`.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No docs change: this restores the behaviour `refresh_agents` and the build path
already document, and coins no domain term.

## Risk

User-visible change: pressing Test on a configured acp sub-agent now re-composes
the agent table, so a capability the agent gained since its last measurement
takes effect without a restart. A test that measures an agent as no longer
resumable now takes effect the same way, which is the honest direction. Nothing
else about the test verdict, its wire shape or its persisted record changes.

The door re-reads the config file, which a concurrent client may be writing. That
is why the call is guarded and pinned by a test: a malformed entry written by
someone else logs a warning and the test still returns its real verdict.

Rollback is one hunk: drop the `if result.kind == "acp" and source == "config"`
block in `subagents_test`. The parameter and the registration wrapper are inert
without it.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

Raised in review and deliberately left out of scope: `register_subagents_methods`
pins none of its six wrappers, so unwrapping `subagents.add` or `subagents.build`
is equally green. Those skip only the live push over a durable config write, which
makes the case for pinning them a different argument from this one; it is not
closed here.

Checked and deliberately not re-pinned by a new test: that the door keeps a
discovered row on the table. `_hot_apply` passes only the config rows, and
`AgentRegistry.apply` composes the vendored and builtin seeds itself -- its own
documented invariant, named for its six callers including the hot-apply, and
already held steady in tests by the `no_vendored_subagents` fixture. Without
that, taking this door would have dropped every vendored agent, including the
one this change exists for.

## Related Issues

N/A
## Summary

An acp sub-agent test is a measurement, not just a verdict: `_test_acp` connects,
reads the handshake and records the capability snapshot the roster derives
`stateful` from. Nothing then re-composed the live agent table, so the
measurement reached the store and stopped there.

From that moment the two readers disagree. `subagents.list` re-reads the store
per request and reports the new capability, so the agent appears in the
`/new-instance` picker, whose filter is `enabled && stateful === true`. But
`create_instance` reads the row the table materialized, and refuses the very
agent the picker just offered, saying it is stateless. An agent first measured
before it could resume stayed unaddressable however many times it was
re-measured; only a restart or an unrelated config write ever fixed it.

Observed on the vendored `raven-ppt` sub-agent. Its stored snapshot says
`sessions: one-shot only` and was taken about ninety minutes before the commit
that added `sessionCapabilities.resume` and `loadSession` to that fork. A live
handshake now answers `sessions: resume, load`. A stale snapshot is
deliberately used rather than dropped, because dropping one degrades to
stateless, the most damaging default available -- so the false `resume=false`
was pinned in place until something both re-measured and re-composed.

Key decisions:

- **The declared door, not a narrower private path.** The generation freeze law
  on the `RavenRuntime` paper names exactly three doors that reconcile a
  member's data plane mid-generation, and the agents table is `loop.apply_agents`.
  `raven/rpc/methods/subagents.py` is already a sanctioned operator of that door
  in the door-roster guard, so no roster widening and no ruling is needed. The
  manager also has a narrower `refresh_agents` that re-applies the configs it
  already holds, avoiding the door's config re-read; it was deliberately not
  used, because reaching a member's internal verb from a surface routes around
  the declared door. The guard does not currently spell `refresh_agents`, so
  that reach would have passed the gate without being legal.
- **The same door a finished build takes.** A vendored build hot-applies after a
  filesystem change with no config write, and the law names that as the same
  durable truth. A capability snapshot is durable truth written outside the
  config file in exactly the same way.
- **Scoped to where a snapshot was written.** An acp kind on a configured entry
  is the condition `_test_acp` records under, so the trigger mirrors the write
  rather than approximating it. A cli test writes no snapshot, and a preset is a
  template no config claims, so neither reaches the door.
- **The wiring is pinned, not just the function.** The re-compose lives in the
  registration wrapper, and `subagents_test` takes the loop as a keyword. So
  registering the bare handler leaves every real caller on
  `agent_loop_factory=None`, `_hot_apply` returns on its first line, and this
  whole change is inert -- with the suite green, because the other tests call
  `subagents_test` directly and hand it the factory themselves, and
  `test_rpc_registration.py` asserts only that the name is registered. One test
  dispatches a real frame through the registered name and asserts the table was
  re-composed. `register_subagents_methods` has never pinned any of its six
  wrappers; what is specific here is that the live push is the entire change, so
  an unwrapped registration is not a degraded fix but no fix.
- **Guarded, so a table failure cannot mask a real verdict.** The snapshot is on
  disk before the door runs, and the next hot-apply picks it up. Raising would
  report a successful measurement as a failed test, and the door re-reads a
  config file any other client may be writing.

Only the gateway plane needed the change: the served page reaches these same
handlers, so one fix covers both surfaces.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Commands and results, all from a worktree at the target branch tip:

    uv run --frozen --python 3.12 --all-extras pytest -q tests/test_rpc_subagents.py \
      tests/test_subagent_acp.py tests/test_core_runtime_swap.py
    260 passed

    make test-python          # branch
    1 failed, 18013 passed, 69 skipped

    make test-python          # target branch tip, detached worktree
    1 failed, 18007 passed, 69 skipped

Identical failing-ID sets, so 0 introduced. The one failure is
`tests/test_cli_doctor_commands.py::test_doctor_answers_where_the_memories_are`,
red on the target branch tip as well; it is inherited, not from this change.
The +6 is exactly the six tests added here.

    make lint-imports
    Contracts: 8 kept, 0 broken.

    make lint-python
    All checks passed! / 1735 files already formatted

    uv run --frozen --python 3.12 python scripts/check_commit_messages.py <range>
    exit 0

The tests are not theatre. Every mutation of the change goes red:

    remove the re-compose entirely   -> 2 failed
    widen the gate to every kind     -> 1 failed
    widen the gate to every source   -> 1 failed
    let a failing re-compose raise   -> 1 failed
    unwrap the registration          -> 1 failed

Unmutated: 250 passed. The three negative tests stay green by design and are
paired with the positive one. `test_a_refresh_makes_a_newly_resumable_agent_addressable`
drives a real `SubagentManager` and a real `SnapshotStore` and asserts
`declared_stateful` flips only after the table is re-composed, which is the value
`create_instance` actually gates on.

The last mutation is the one review caught: it was green at 249 passed before the
wiring test existed, which meant the fix could be made inert by a one-word edit
with nothing failing. Under it now the wiring test goes red on its own assertion,
`assert len(applied) == 1` becoming `assert 0 == 1`.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No docs change: this restores the behaviour `refresh_agents` and the build path
already document, and coins no domain term.

## Risk

User-visible change: pressing Test on a configured acp sub-agent now re-composes
the agent table, so a capability the agent gained since its last measurement
takes effect without a restart. A test that measures an agent as no longer
resumable now takes effect the same way, which is the honest direction. Nothing
else about the test verdict, its wire shape or its persisted record changes.

The door re-reads the config file, which a concurrent client may be writing. That
is why the call is guarded and pinned by a test: a malformed entry written by
someone else logs a warning and the test still returns its real verdict.

Rollback is one hunk: drop the `if result.kind == "acp" and source == "config"`
block in `subagents_test`. The parameter and the registration wrapper are inert
without it.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

Raised in review and deliberately left out of scope: `register_subagents_methods`
pins none of its six wrappers, so unwrapping `subagents.add` or `subagents.build`
is equally green. Those skip only the live push over a durable config write, which
makes the case for pinning them a different argument from this one; it is not
closed here.

Checked and deliberately not re-pinned by a new test: that the door keeps a
discovered row on the table. `_hot_apply` passes only the config rows, and
`AgentRegistry.apply` composes the vendored and builtin seeds itself -- its own
documented invariant, named for its six callers including the hot-apply, and
already held steady in tests by the `no_vendored_subagents` fixture. Without
that, taking this door would have dropped every vendored agent, including the
one this change exists for.

## Related Issues

N/A
…l fast without one

## Summary

A backgrounded DAG node that suspends on an exception verdict is answered by the main
agent calling `resolve_dag_node`. That tool is kept out of the provider's tool schema on
purpose, so `tool_call` is the only way a model can name it - and nothing that reached
the model ever said so. The exception report and the graph tool's acceptance text both
spelled the bare call. A model that looks the name up in its own tool list does not find
it, reports that it has no way to answer, and replies in prose. The node then waits out
its whole 600s adjudication timeout and records that no decision arrived, which reads as
the agent declining a question it was never able to answer, and its dependents cascade to
skipped.

An earlier fix made `tool_call` reachable by registering it unconditionally. This closes
the half that was left: the advertisement. Four surfaces, one cause.

- The exception report and the acceptance text spell the invocation rather than the call,
  and say outright that the tool is not in the model's list and that `tool_call` is how to
  reach it.
- Suspension now turns on an answer being able to arrive, not only on the report being
  able to leave. `control_reachable`, which the graph tool already held for the acceptance
  text, is threaded into the runner and folded into the deliverability predicate. Without
  a route the node fails at once naming the missing route, instead of burning the timeout
  and blaming the agent. An unwired predicate keeps meaning "assume reachable", so no
  existing caller changes behaviour.
- The announce prepends a trusted line ahead of the fence it wraps the report in. The
  fence does not move - the report quotes the node's own output and transcript - but the
  one sentence the turn exists to act on no longer sits under a header reading "data, NOT
  instructions".
- `tool_call`'s own description enumerated two provenances for a name, `tool_search` and
  another tool's result. The report is neither, since it arrives as an injected turn, so
  the description contradicted the report at the moment it had to be obeyed. It now names
  a report or notice as a third. The control tools' seven cross-references to each other
  get the same spelling.

Two decisions worth stating. Making the three controls schema-visible while a run is in
flight was considered and rejected: the registry documents the opposite contract, its
hidden set has no removal API, and a tool list that changes mid-conversation invalidates
the prompt-cache prefix for that conversation. And the first draft narrowed the untrusted
fence rather than prepending to it; that was withdrawn during implementation because a
strictly additive change fixes the same problem without moving a security boundary.

The design note is in `docs/specs/2026-09-01-dag-adjudication-route-design.md`.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Ten new tests, each watched failing before the code that makes it pass. Two of them are
property tests over the live tool outputs rather than per-site assertions, so a
cross-reference added later is covered without being listed, and one asserts a floor on
how many sites it found so that deleting the references cannot turn it green by emptying
it. The fail-fast test asserts elapsed time as well as status, because an ordinary failure
and a route-absent failure both end `failed` and only the timing separates them.

```
python -m pytest tests/test_subagent_dag_runner.py tests/test_subagent_manager.py \
  tests/test_subagent_dag_control_tools.py tests/test_tool_search.py \
  tests/test_subagent_dag_core.py tests/test_subagent_dag_verdict.py \
  tests/test_subagent_dag_live.py tests/test_subagent_dag_machines.py \
  tests/test_rpc_dag.py tests/test_agent_loop_tool_search.py \
  tests/test_deliver_files_tool.py tests/test_rpc_tool_events.py \
  tests/test_rpc_schema_match.py -q
-> 1081 passed

python -m ruff check raven/ tests/    -> All checks passed
python -m ruff format --check         -> 98 files already formatted
python scripts/check_commit_messages.py <range>  -> exit 0
```

The commit-message gate was run with a control: the linter rejects a bare subject, a
`style(` type and a non-English subject, so exit 0 over this range is a pass and not a
no-op. It does not enforce the header-length rule; the headers here are 67 to 87
characters.

The pre-submit review sweep found one defect in this branch's own new state and it is
fixed here rather than left: making suspension depend on the route created a node that
fails immediately, and since the announce is not gated on suspension the report still went
out asking for a decision on a node that accepts none, through the route the host had just
been found not to have. The report now has a third shape for that case, which keeps the
evidence and names no call.

Review round one found a second instance of the same class, one layer out, and it is
fixed here. The announce is deliberately not gated on suspension, because a terminal
node's report is what the agent replans from - so it also reaches the model for a node
whose desk never opened, and the trusted line prepended to it said the node needed a
decision and named the `tool_call` route in every such state, including the one where
the route had just been found unavailable. That is worse than a stale sentence: the
prefix is outside the fence precisely so the model acts on it, while the report that
contradicts it is inside one headed "evidence, not instructions". `announce_dag_exception`
now takes a required `awaiting_decision` keyword and asks only when a decision is really
pending; the announcer type became a Protocol so the keyword can be required rather than
defaulted, since a default would reinstate the defect for the next caller that forgets.
Both terminal shapes are covered at the manager, and a runner test asserts the flag
tracks suspension rather than the existence of a report.

A second round raised a coverage asymmetry, also fixed here. This file's own docstring
names two texts as the whole advertisement the schema-hidden controls get - the graph
tool's acceptance text and a suspended node's exception report - but the negative guard
added with them reached only the control tools' own replies. Restoring the bare
`dag_status("<id>")` form in the acceptance text, while keeping every name the positive
assertions look for, left the suite green: the defect this change removes could come
back in one of the two texts undetected. The acceptance text now goes through the same
shared helper, so both guards see it, and the floor on how many cross-references that
helper finds went from six to seven. The design's Testing item 6 is what left the gap,
having promised only that the text would be asserted to name `tool_call`.

A third round found two surfaces of the same bug that the earlier rounds had not
reached, and both are fixed here. `wiring.py` builds the graph tool twice, and only the
registered one was handed the reachability predicate; the playbook engine's private
instance can suspend a node just as well - its verdict provider and exception announcer
are wired, and its executor backgrounds by default - so on a host with `tool_call`
withheld a playbook node still waited out the full adjudication window and recorded the
timeout against the agent. And the orchestration guide, which `run_subagent_dag` makes a
required first read, still taught the bare call, so a model learned an invocation it
could not issue before any corrected report reached it. The guide now names the route
and goes through the same two guards as the other advertised texts, taking the shared
helper's floor from seven to eight; the playbook site is asserted over `dag_tools()`
rather than by name, since pinning one site is what let the other drift.

An architecture pass then found that the roster feeding those guards had never
included the exception report itself - the text a suspended node hands the model, and
the one this change is named after. Its coverage was positive only, so a bare call
could sit beside the correct invocation and satisfy every assertion that reached it;
injecting one left both DAG suites green. The report renders without a tool instance,
so it joins the roster the way the guide did, taking the floor from eight to nine, and
the same injection now fails. The design note also gained the paragraph it was missing:
it was marked implemented over a scope naming the file whose announcer signature this
change alters, without mentioning `awaiting_decision` once.

A second architecture pass raised three more, all fixed. `tool_call`'s two authored
strings disagreed about where a name may come from - the tool-level description named
the report provenance this change adds, the `name` parameter's description did not, and
both ship to the provider in one definition, so a model filling that argument in read
the narrower rule. The provenance test read only the first, which is how they drifted;
it now reads both. The no-route branch replaced `errors[node.id]` rather than adding to
it, and that dict becomes the run record's own `error` field, which the manifest carries
and `dag_status` renders, so the record kept why nobody could be asked and lost why the
node failed for anyone asking about the node; it now carries both, with the injected
report unchanged. And one citation in the design note supported a claim
about static schema hiding by pointing at lines documenting the reversible off switch:
correct when written, moved by this branch's own rebases onto the neighbouring contract.
Auditing the rest found four more stale the same way, so every citation in the note now
names a symbol instead of a line.

A final round narrowed a claim rather than a behaviour. The comment and test docstring
written with the fix above said the run record was the only durable home for both
reasons; the injected report is durable in its own right, since a delegated turn carrying
it persists with its text intact. The wording now distinguishes the record's node-facing
`error` field, which the manifest carries and `dag_status` renders, from the conversation
turn, and no code changed with it.

One limit is deliberate: the guide is static text while reachability is decided per
host, so it names the route even where `tool_call` is withheld. Nothing follows from
that, because a node on such a host no longer suspends and the instruction is never
reached.

Two further review notes are deliberately not acted on. Converging `_route_available`
with the equivalent inline rule in the graph tool is an optional cleanup: the two agree
on every input, each is covered for the absent, false and raising predicate, and the
reviewer who raised it withdrew the architectural framing after checking that the
repository's one-truth rule is scoped to config-with-cargo declarations. And this branch
collides textually with another in flight that derives the same boolean under a different
name; both reviewers' conclusion is that whichever lands second resolves it, toward one
required keyword feeding both consumers.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

Model-facing text changes on four surfaces, so a prompt-sensitive consumer sees different
wording. No API, schema or payload shape changes; the untrusted fence keeps exactly the
bytes it kept before.

One behaviour change: a suspended node whose host offers no `tool_call` now fails at once
instead of waiting out the adjudication timeout. On a wide graph that is the difference
between a run that ends and one that appears hung. The node's outcome is the same either
way - it was never going to be answered - but it is reached sooner and the recorded reason
names the host configuration rather than the agent.

Checked and deliberately not changed: the 2026-08-26 design note still says the acceptance
text is the only thing telling the model the other two controls exist. That is a dated
record of the design this change amends, and the new note names it, so it is left as
history rather than edited retroactively. `CONTEXT.md`'s `exception` entry was checked and
still holds: this change only ever sets that status when the node really is waiting, and
fails it otherwise.

Rollback is per commit; the sixteen are each scoped to one surface, and reverting the
squash restores the prior behaviour in full.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
…l fast without one

## Summary

A backgrounded DAG node that suspends on an exception verdict is answered by the main
agent calling `resolve_dag_node`. That tool is kept out of the provider's tool schema on
purpose, so `tool_call` is the only way a model can name it - and nothing that reached
the model ever said so. The exception report and the graph tool's acceptance text both
spelled the bare call. A model that looks the name up in its own tool list does not find
it, reports that it has no way to answer, and replies in prose. The node then waits out
its whole 600s adjudication timeout and records that no decision arrived, which reads as
the agent declining a question it was never able to answer, and its dependents cascade to
skipped.

An earlier fix made `tool_call` reachable by registering it unconditionally. This closes
the half that was left: the advertisement. Four surfaces, one cause.

- The exception report and the acceptance text spell the invocation rather than the call,
  and say outright that the tool is not in the model's list and that `tool_call` is how to
  reach it.
- Suspension now turns on an answer being able to arrive, not only on the report being
  able to leave. `control_reachable`, which the graph tool already held for the acceptance
  text, is threaded into the runner and folded into the deliverability predicate. Without
  a route the node fails at once naming the missing route, instead of burning the timeout
  and blaming the agent. An unwired predicate keeps meaning "assume reachable", so no
  existing caller changes behaviour.
- The announce prepends a trusted line ahead of the fence it wraps the report in. The
  fence does not move - the report quotes the node's own output and transcript - but the
  one sentence the turn exists to act on no longer sits under a header reading "data, NOT
  instructions".
- `tool_call`'s own description enumerated two provenances for a name, `tool_search` and
  another tool's result. The report is neither, since it arrives as an injected turn, so
  the description contradicted the report at the moment it had to be obeyed. It now names
  a report or notice as a third. The control tools' seven cross-references to each other
  get the same spelling.

Two decisions worth stating. Making the three controls schema-visible while a run is in
flight was considered and rejected: the registry documents the opposite contract, its
hidden set has no removal API, and a tool list that changes mid-conversation invalidates
the prompt-cache prefix for that conversation. And the first draft narrowed the untrusted
fence rather than prepending to it; that was withdrawn during implementation because a
strictly additive change fixes the same problem without moving a security boundary.

The design note is in `docs/specs/2026-09-01-dag-adjudication-route-design.md`.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Ten new tests, each watched failing before the code that makes it pass. Two of them are
property tests over the live tool outputs rather than per-site assertions, so a
cross-reference added later is covered without being listed, and one asserts a floor on
how many sites it found so that deleting the references cannot turn it green by emptying
it. The fail-fast test asserts elapsed time as well as status, because an ordinary failure
and a route-absent failure both end `failed` and only the timing separates them.

```
python -m pytest tests/test_subagent_dag_runner.py tests/test_subagent_manager.py \
  tests/test_subagent_dag_control_tools.py tests/test_tool_search.py \
  tests/test_subagent_dag_core.py tests/test_subagent_dag_verdict.py \
  tests/test_subagent_dag_live.py tests/test_subagent_dag_machines.py \
  tests/test_rpc_dag.py tests/test_agent_loop_tool_search.py \
  tests/test_deliver_files_tool.py tests/test_rpc_tool_events.py \
  tests/test_rpc_schema_match.py -q
-> 1081 passed

python -m ruff check raven/ tests/    -> All checks passed
python -m ruff format --check         -> 98 files already formatted
python scripts/check_commit_messages.py <range>  -> exit 0
```

The commit-message gate was run with a control: the linter rejects a bare subject, a
`style(` type and a non-English subject, so exit 0 over this range is a pass and not a
no-op. It does not enforce the header-length rule; the headers here are 67 to 87
characters.

The pre-submit review sweep found one defect in this branch's own new state and it is
fixed here rather than left: making suspension depend on the route created a node that
fails immediately, and since the announce is not gated on suspension the report still went
out asking for a decision on a node that accepts none, through the route the host had just
been found not to have. The report now has a third shape for that case, which keeps the
evidence and names no call.

Review round one found a second instance of the same class, one layer out, and it is
fixed here. The announce is deliberately not gated on suspension, because a terminal
node's report is what the agent replans from - so it also reaches the model for a node
whose desk never opened, and the trusted line prepended to it said the node needed a
decision and named the `tool_call` route in every such state, including the one where
the route had just been found unavailable. That is worse than a stale sentence: the
prefix is outside the fence precisely so the model acts on it, while the report that
contradicts it is inside one headed "evidence, not instructions". `announce_dag_exception`
now takes a required `awaiting_decision` keyword and asks only when a decision is really
pending; the announcer type became a Protocol so the keyword can be required rather than
defaulted, since a default would reinstate the defect for the next caller that forgets.
Both terminal shapes are covered at the manager, and a runner test asserts the flag
tracks suspension rather than the existence of a report.

A second round raised a coverage asymmetry, also fixed here. This file's own docstring
names two texts as the whole advertisement the schema-hidden controls get - the graph
tool's acceptance text and a suspended node's exception report - but the negative guard
added with them reached only the control tools' own replies. Restoring the bare
`dag_status("<id>")` form in the acceptance text, while keeping every name the positive
assertions look for, left the suite green: the defect this change removes could come
back in one of the two texts undetected. The acceptance text now goes through the same
shared helper, so both guards see it, and the floor on how many cross-references that
helper finds went from six to seven. The design's Testing item 6 is what left the gap,
having promised only that the text would be asserted to name `tool_call`.

A third round found two surfaces of the same bug that the earlier rounds had not
reached, and both are fixed here. `wiring.py` builds the graph tool twice, and only the
registered one was handed the reachability predicate; the playbook engine's private
instance can suspend a node just as well - its verdict provider and exception announcer
are wired, and its executor backgrounds by default - so on a host with `tool_call`
withheld a playbook node still waited out the full adjudication window and recorded the
timeout against the agent. And the orchestration guide, which `run_subagent_dag` makes a
required first read, still taught the bare call, so a model learned an invocation it
could not issue before any corrected report reached it. The guide now names the route
and goes through the same two guards as the other advertised texts, taking the shared
helper's floor from seven to eight; the playbook site is asserted over `dag_tools()`
rather than by name, since pinning one site is what let the other drift.

An architecture pass then found that the roster feeding those guards had never
included the exception report itself - the text a suspended node hands the model, and
the one this change is named after. Its coverage was positive only, so a bare call
could sit beside the correct invocation and satisfy every assertion that reached it;
injecting one left both DAG suites green. The report renders without a tool instance,
so it joins the roster the way the guide did, taking the floor from eight to nine, and
the same injection now fails. The design note also gained the paragraph it was missing:
it was marked implemented over a scope naming the file whose announcer signature this
change alters, without mentioning `awaiting_decision` once.

A second architecture pass raised three more, all fixed. `tool_call`'s two authored
strings disagreed about where a name may come from - the tool-level description named
the report provenance this change adds, the `name` parameter's description did not, and
both ship to the provider in one definition, so a model filling that argument in read
the narrower rule. The provenance test read only the first, which is how they drifted;
it now reads both. The no-route branch replaced `errors[node.id]` rather than adding to
it, and that dict becomes the run record's own `error` field, which the manifest carries
and `dag_status` renders, so the record kept why nobody could be asked and lost why the
node failed for anyone asking about the node; it now carries both, with the injected
report unchanged. And one citation in the design note supported a claim
about static schema hiding by pointing at lines documenting the reversible off switch:
correct when written, moved by this branch's own rebases onto the neighbouring contract.
Auditing the rest found four more stale the same way, so every citation in the note now
names a symbol instead of a line.

A final round narrowed a claim rather than a behaviour. The comment and test docstring
written with the fix above said the run record was the only durable home for both
reasons; the injected report is durable in its own right, since a delegated turn carrying
it persists with its text intact. The wording now distinguishes the record's node-facing
`error` field, which the manifest carries and `dag_status` renders, from the conversation
turn, and no code changed with it.

One limit is deliberate: the guide is static text while reachability is decided per
host, so it names the route even where `tool_call` is withheld. Nothing follows from
that, because a node on such a host no longer suspends and the instruction is never
reached.

Two further review notes are deliberately not acted on. Converging `_route_available`
with the equivalent inline rule in the graph tool is an optional cleanup: the two agree
on every input, each is covered for the absent, false and raising predicate, and the
reviewer who raised it withdrew the architectural framing after checking that the
repository's one-truth rule is scoped to config-with-cargo declarations. And this branch
collides textually with another in flight that derives the same boolean under a different
name; both reviewers' conclusion is that whichever lands second resolves it, toward one
required keyword feeding both consumers.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

Model-facing text changes on four surfaces, so a prompt-sensitive consumer sees different
wording. No API, schema or payload shape changes; the untrusted fence keeps exactly the
bytes it kept before.

One behaviour change: a suspended node whose host offers no `tool_call` now fails at once
instead of waiting out the adjudication timeout. On a wide graph that is the difference
between a run that ends and one that appears hung. The node's outcome is the same either
way - it was never going to be answered - but it is reached sooner and the recorded reason
names the host configuration rather than the agent.

Checked and deliberately not changed: the 2026-08-26 design note still says the acceptance
text is the only thing telling the model the other two controls exist. That is a dated
record of the design this change amends, and the new note names it, so it is left as
history rather than edited retroactively. `CONTEXT.md`'s `exception` entry was checked and
still holds: this change only ever sets that status when the node really is waiting, and
fails it otherwise.

Rollback is per commit; the sixteen are each scoped to one surface, and reverting the
squash restores the prior behaviour in full.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
## Summary

Align Playbook generation, loading, and execution preflight with the DAG runtime validation rules.

This change reuses shared static DAG validation, rejects invalid or unused node inputs before execution, and prevents Playbook parameters from being duplicated as node inputs. It also validates filled node fields and preserves the existing shared-instance rules for skills and MCPs.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `uv run pytest tests/test_playbook_*.py tests/test_agent_loop_playbook_entry.py tests/test_cli_playbook_commands.py tests/test_rpc_playbooks.py tests/test_subagent_dag_core.py tests/test_subagent_dag_runner.py tests/test_subagent_dag_control_tools.py tests/test_subagent_dag_machines.py tests/test_subagent_dag_mcp_scope.py tests/test_subagent_dag_verdict.py tests/test_subagent_manager.py tests/test_subagent_prompt_template.py tests/test_rpc_schema_match.py -q -x`: 1116 passed
- `uv run --frozen --python 3.12 --extra dev ruff check raven evolver agents plugins-dist tests scripts`: passed
- `uv run --frozen --python 3.12 --extra dev ruff format --check raven evolver agents plugins-dist tests scripts`: 1386 files already formatted
- `git diff --check origin/refactor/raven_v0_2_0...HEAD`: passed
- `PR_TITLE='fix(playbook): align generation with DAG validation' make check-pr-title`: passed
- `COMMIT_RANGE=origin/refactor/raven_v0_2_0...HEAD make check-commits check-large-files`: passed
- Documentation and screenshots are not required because this change only affects internal validation behavior.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

The change may reject Playbooks that were previously accepted during generation or loading but would later fail during DAG execution. Existing runtime-valid Playbooks remain compatible. The change can be rolled back by reverting it.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
## Summary

Align Playbook generation, loading, and execution preflight with the DAG runtime validation rules.

This change reuses shared static DAG validation, rejects invalid or unused node inputs before execution, and prevents Playbook parameters from being duplicated as node inputs. It also validates filled node fields and preserves the existing shared-instance rules for skills and MCPs.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `uv run pytest tests/test_playbook_*.py tests/test_agent_loop_playbook_entry.py tests/test_cli_playbook_commands.py tests/test_rpc_playbooks.py tests/test_subagent_dag_core.py tests/test_subagent_dag_runner.py tests/test_subagent_dag_control_tools.py tests/test_subagent_dag_machines.py tests/test_subagent_dag_mcp_scope.py tests/test_subagent_dag_verdict.py tests/test_subagent_manager.py tests/test_subagent_prompt_template.py tests/test_rpc_schema_match.py -q -x`: 1116 passed
- `uv run --frozen --python 3.12 --extra dev ruff check raven evolver agents plugins-dist tests scripts`: passed
- `uv run --frozen --python 3.12 --extra dev ruff format --check raven evolver agents plugins-dist tests scripts`: 1386 files already formatted
- `git diff --check origin/refactor/raven_v0_2_0...HEAD`: passed
- `PR_TITLE='fix(playbook): align generation with DAG validation' make check-pr-title`: passed
- `COMMIT_RANGE=origin/refactor/raven_v0_2_0...HEAD make check-commits check-large-files`: passed
- Documentation and screenshots are not required because this change only affects internal validation behavior.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

The change may reject Playbooks that were previously accepted during generation or loading but would later fail during DAG execution. Existing runtime-valid Playbooks remain compatible. The change can be rolled back by reverting it.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
…integrity fixes

## Summary

Moves the vendored `subagents/raven-research/Raven-X` checkout from the
unmerged `feat/dr_sufficiency_gate` tip onto upstream `main` (a903a424) and
re-derives the whole local delta as six file-disjoint patches, then fixes
the two report-integrity defects found by the product-feedback review of a
live 31-minute research run.

The port: the old base and `main` had diverged, so this is a real three-way
merge. The sufficiency-gate branch now rides as its own patch until
upstream merges it (drop it then), and the re-partition also captures the
previously unrecorded per-session MCP drift a naive revendor would have
dropped. Conflict resolutions keep the finer side where both lines repaired
the same function; a pristine-base-plus-six-patches round trip reproduces
the tree byte-for-byte in both directions and in multiple application
orders. The folder README documents the new base, the patch table, and
what the port changed for us.

The report fixes, both post-hoc (no landed metric moves):

- `cited_fence_tags`: a disclosure counter for answers that cite the
  security fence's `web_fetch #...` nonce instead of a URL. The observed
  run wrote 75 of its 87 citation handles that way and the grounding check
  rendered "nothing to check" over an answer that had opened a page behind
  every reference. The nonce never reaches the ledger, so this is
  disclosure on the `cited_schemeless` model, not resolution.
- An unreviewed banner: a shipped answer no reviewer saw (`budget_spent`,
  `unavailable`, or a salvage) used to disclose that as one word in the
  trail head; the state now leads the trail block.

As a config-side experiment with no patch cost, `identityOverride` (and
its `soul.md` twin, pinned equal by the launcher test) gains two reply
rules: derive computable quantities with the formula shown, and end
option-choice questions with a recommendation. Because those rules and the
newly-on search snippets change what the model reads, the flow label moves
to `dr@3.5-filetools-askuser-derive` (vendored AGENTS.md 0.2); the base
label registry matches base labels only, so the folder README's label row
carries the supersession record for the old suffix.

A review round on the local commits found one dropped gate-branch test
(restored byte-identical, and its file joins the gate patch) and one
misleading README ownership sentence (rewritten to match the ownership
table). A second round on the MR corrected three more things: the
fence-tag disclosure now quotes the tool the answer actually named
(a `web_search` tag no longer renders as `web_fetch`), the parity test's
universal `max_iterations` excuse is replaced by per-config reasons that
name the exhaustion window a lowered cap opens, and the reviewer's two
mutation-killing pin tests (the fence-tag hex width, the schemeless
dedup exclusion) are taken nearly verbatim.

A third round held two of those fixes to their own standard. The label move
was a suffix the validators could not refuse, so both loaders now carry an
exact-profile supersession table: the fork's `DRFlowConfig` and the trunk
launcher's `FlowConfig` refuse `dr@3.5-filetools-askuser` on an enabled
flow and name its successor, while the base-label table stays as it is
(adding `dr@3.5` there would refuse upstream's own measured profiles). And
the parity guard now reaches the config the launcher actually serves: a
host-side test composes the fast, deep and ultra modes through the fork's
own `build_session_modes` and compares them with the measured arm on
effective values, with a reason for every difference and the 20 and 30
iteration caps named as the exhaustion window they open. Doing that surfaced
one more gap: the trunk launcher's research-flow slice wrote neither search
snippet key while the fork ran both on, so the twins ran two distributions
under one label. The slice now carries both, and the whole slice is pinned
to the fork's `drFlow` minus the two keys that differ by design.

A fourth round, an architecture pass, found the other half of that: the
revendor moved eight of the fork's schema defaults (upstream's flip of the
snippet, rubric, verbatim-head and three breaker knobs to on) and the trunk
twin's `FlowConfig` kept the old ones, against its own "same defaults"
docstring. The served config pins all eight, so the deployed launcher did
not change; a profile that leaves one unpinned would have. The twin's
defaults now match the fork's, and the parity test pins them field for
field, together with both retired-label tables, which nothing had held
equal before (the slice test compares written values, and a default only
shows where the slice is silent). Seven existing trunk tests that had
relied on the old off-defaults now switch those gates off explicitly; the
README's verification paragraph names the `-derive` label it verified.

A fifth round found the third layer of the same twin: this MR's fence-tag
fix (a disclosure counter for answers that cite the security fence's
`web_fetch #...` nonce instead of a URL, and the unreviewed banner that
leads the trail block) landed on the fork's appendix module and not on the
trunk twin's, so the launcher this repo ships still said "nothing to check"
over an answer whose every reference was a fence tag. The twin's appendix
module is now the fork's whole module body - these two fixes and upstream's
08-28/09-01 appendix work (scheme-less disclosure, the never-surfaced split
of unopened citations, research seconds, thin-page counters) - with the
fork's regression tests ported, and a test pins the two module bodies equal
with docstrings and comments stripped, so the third layer of the twin is
held the way the values and the defaults now are. Only prose differs: the
twin avoids literal CJK in code and names the trunk's seams. Two existing
twin tests changed wording with the module (the head says "unique query
strings", a never-listed citation is called out as appearing nowhere in
the run); their assertions on the counters are unchanged.

The same sweep, run over the other fork module this revendor changed that
has a twin, found the reviewer's unreported item and fixed it: the trunk's
`unwrap_untrusted` required the fence's close marker to be the last line,
but the budget observer appends its note to the newest tool result after
fencing, so the research gates got the whole fenced string back, refused
it as "not a page", and counted every annotated successful fetch as a
failed one (the fetch gate's release valve never opened, and its own
counter is zero either way). `unwrap_untrusted` now scans for the marker
from the end, as upstream's does, and drops what follows it; the twin's
envelope predicate parses with `raw_decode` as the fork's does. This is
the one change under `raven/` on this branch: `raven/security/trust.py`,
whose only trunk callers are the two research gates, in its own commit so
it can be judged or dropped on its own.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- Vendored suite, isolated HOME, from `subagents/raven-research/Raven-X`:
  `uv run pytest -q -p no:cacheprovider` -> 5914 passed, 84 skipped. The
  pass counts are stable across every run; the process exit is not - four
  runs on this side exited 0 and the two at the current head exited 139,
  the same segfault at interpreter shutdown both reviewers observed, after
  the identical all-passed summary. Treated as a host teardown flake, not a
  test failure, and disclosed here rather than claimed green.
- Host-side research tests: `uv run pytest tests/test_agents_research_*.py
  tests/test_config_product_render.py tests/test_vendored_invariants.py
  tests/test_vendored_subagents.py -q` -> 549 passed, 1 skipped
  (case-sensitive-filesystem skip, pre-existing). This includes the new
  `tests/test_agents_research_flow_parity.py` (7 tests), which runs the
  fork's loader in a subprocess and needs no fork venv. Its fast-mode
  verify excuse names the two timeout leaves, not the `verify.` prefix,
  and a test pins that switching the reviewer or its rubrics off is
  caught. Its last two tests hold the trunk twin's class defaults and
  retired-label tables to the fork's; red-checked by setting one twin
  default back (fails naming `search.include_snippets`) and by editing
  only the trunk-side profile table (fails on the table diff).
- Fifth round, same selection -> 570 passed, 1 skipped.
  `tests/test_agents_research_process_appendix.py` passes 67 (46 + 21
  ported or added). Red-checked: with the fence-tag pattern made
  unmatchable the three fence tests fail; with the unreviewed-outcome
  table emptied the two banner tests fail; with one twin constant moved by
  one (`_THIN_PAGE_CHARS` 400 -> 401) the module-body pin fails and a
  comment-only edit leaves it green. The reviewer's repro (one successful
  fetch, an answer of only `(web_fetch #aa11bb22)`) renders the fence-tag
  disclosure with `cited_fence_tags: 1` and no "nothing to check" line.
- Fence-plus-note seam: `uv run pytest tests/test_security_*.py
  tests/test_subagent_dag_*.py tests/test_subagent_handoff.py` together
  with the selection above -> 1128 passed, 1 skipped, 4 failed; the four
  are `test_security_network.py`'s NAT64 (`64:ff9b:1::`) cases and fail
  identically on this host without the branch (pre-existing, not
  introduced). Red-checked: with the last-line rule restored in
  `unwrap_untrusted`, the two new trust tests and the two new gate tests
  fail (the gate test feeds the production byte shape, fence plus budget
  note, in both directions); with the fix all 43 pass in those two files.
- Whole host suite, branch against merge base `9bc06091` in a second
  worktree on the same environment, failing IDs diffed: branch 5 failed /
  17805 passed / 78 skipped, base 5 failed / 17771 passed / 78 skipped,
  the five IDs identical on both sides (`test_cli_doctor_commands.py`'s
  memories path and the four NAT64 cases in `test_security_network.py`).
  0 introduced, 0 fixed by accident, 34 tests added.
- Patch round trip in an out-of-repo copy: pristine a903a424 plus all six
  patches equals the checkout (`diff -r` zero output), and reverse-applying
  all six returns the pristine tree, verified in more than one application
  order.
- `scripts/check_vendored_subagents.py`, `scripts/check_vendored_invariants.py`
  and `scripts/check_commit_messages.py origin/refactor/raven_v0_2_0..HEAD`
  all pass.
- Revert-the-fix: with the appendix change reverted, 6 of the 7 original
  new appendix tests go red (the seventh is the negative case, paired with
  the positive ones). The two review-round pin tests were each verified to
  turn exactly their own mutant red (narrowed hex width, dropped dedup
  exclusion; 1 failed, 63 passed per direction). The file passes 64 as
  merged. The parity change was red-checked the same way: 3 configs fail
  with the universal excuse removed and no per-config reasons, 12 pass as
  committed.
- The restored gate test is byte-identical to the pre-revendor version and
  passes against the current tree (file passes 21).
- Third-round red checks: disabling the profile refusal turns its test red
  in each loader (1 failed per side); the host parity guard was reddened
  three ways - dropping the fast `max_iterations` excuse (fails naming the
  mode and field), dropping the sufficiency excuse, and switching the
  shipped config's spin breaker off (red in all three modes). Direct loader
  probes: `dr@3.5-filetools-askuser` is refused with the flow on and loads
  with it off, in both `DRFlowConfig` and `FlowConfig`.
- `ruff check` is clean on both changed appendix files; `ruff format`
  drift there predates this branch and the vendored tree deliberately does
  not gate formatting on `raven/` (see its pyproject note).

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- Research behaviour is unchanged: both report fixes are render/disclosure
  side, every landed counter and the grounding rate keep their meaning, and
  `pyproject.toml`/`uv.lock` are byte-identical across the base swap.
- The identity experiment changes the prompt head, so the first turn after
  deploy re-bills the cached prefix once; revert is a config edit.
- Upstream's new `profiles/` directory ships vendor gateway hostnames and
  one intranet address (upstream content, not introduced here). Fine for a
  private repo; confirm before syncing this tree anywhere public.
- Rollback: revert the merge commit; the vendored folder is self-contained
  and TREE_HASHES pins its state.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
…integrity fixes

## Summary

Moves the vendored `subagents/raven-research/Raven-X` checkout from the
unmerged `feat/dr_sufficiency_gate` tip onto upstream `main` (a903a424) and
re-derives the whole local delta as six file-disjoint patches, then fixes
the two report-integrity defects found by the product-feedback review of a
live 31-minute research run.

The port: the old base and `main` had diverged, so this is a real three-way
merge. The sufficiency-gate branch now rides as its own patch until
upstream merges it (drop it then), and the re-partition also captures the
previously unrecorded per-session MCP drift a naive revendor would have
dropped. Conflict resolutions keep the finer side where both lines repaired
the same function; a pristine-base-plus-six-patches round trip reproduces
the tree byte-for-byte in both directions and in multiple application
orders. The folder README documents the new base, the patch table, and
what the port changed for us.

The report fixes, both post-hoc (no landed metric moves):

- `cited_fence_tags`: a disclosure counter for answers that cite the
  security fence's `web_fetch #...` nonce instead of a URL. The observed
  run wrote 75 of its 87 citation handles that way and the grounding check
  rendered "nothing to check" over an answer that had opened a page behind
  every reference. The nonce never reaches the ledger, so this is
  disclosure on the `cited_schemeless` model, not resolution.
- An unreviewed banner: a shipped answer no reviewer saw (`budget_spent`,
  `unavailable`, or a salvage) used to disclose that as one word in the
  trail head; the state now leads the trail block.

As a config-side experiment with no patch cost, `identityOverride` (and
its `soul.md` twin, pinned equal by the launcher test) gains two reply
rules: derive computable quantities with the formula shown, and end
option-choice questions with a recommendation. Because those rules and the
newly-on search snippets change what the model reads, the flow label moves
to `dr@3.5-filetools-askuser-derive` (vendored AGENTS.md 0.2); the base
label registry matches base labels only, so the folder README's label row
carries the supersession record for the old suffix.

A review round on the local commits found one dropped gate-branch test
(restored byte-identical, and its file joins the gate patch) and one
misleading README ownership sentence (rewritten to match the ownership
table). A second round on the MR corrected three more things: the
fence-tag disclosure now quotes the tool the answer actually named
(a `web_search` tag no longer renders as `web_fetch`), the parity test's
universal `max_iterations` excuse is replaced by per-config reasons that
name the exhaustion window a lowered cap opens, and the reviewer's two
mutation-killing pin tests (the fence-tag hex width, the schemeless
dedup exclusion) are taken nearly verbatim.

A third round held two of those fixes to their own standard. The label move
was a suffix the validators could not refuse, so both loaders now carry an
exact-profile supersession table: the fork's `DRFlowConfig` and the trunk
launcher's `FlowConfig` refuse `dr@3.5-filetools-askuser` on an enabled
flow and name its successor, while the base-label table stays as it is
(adding `dr@3.5` there would refuse upstream's own measured profiles). And
the parity guard now reaches the config the launcher actually serves: a
host-side test composes the fast, deep and ultra modes through the fork's
own `build_session_modes` and compares them with the measured arm on
effective values, with a reason for every difference and the 20 and 30
iteration caps named as the exhaustion window they open. Doing that surfaced
one more gap: the trunk launcher's research-flow slice wrote neither search
snippet key while the fork ran both on, so the twins ran two distributions
under one label. The slice now carries both, and the whole slice is pinned
to the fork's `drFlow` minus the two keys that differ by design.

A fourth round, an architecture pass, found the other half of that: the
revendor moved eight of the fork's schema defaults (upstream's flip of the
snippet, rubric, verbatim-head and three breaker knobs to on) and the trunk
twin's `FlowConfig` kept the old ones, against its own "same defaults"
docstring. The served config pins all eight, so the deployed launcher did
not change; a profile that leaves one unpinned would have. The twin's
defaults now match the fork's, and the parity test pins them field for
field, together with both retired-label tables, which nothing had held
equal before (the slice test compares written values, and a default only
shows where the slice is silent). Seven existing trunk tests that had
relied on the old off-defaults now switch those gates off explicitly; the
README's verification paragraph names the `-derive` label it verified.

A fifth round found the third layer of the same twin: this MR's fence-tag
fix (a disclosure counter for answers that cite the security fence's
`web_fetch #...` nonce instead of a URL, and the unreviewed banner that
leads the trail block) landed on the fork's appendix module and not on the
trunk twin's, so the launcher this repo ships still said "nothing to check"
over an answer whose every reference was a fence tag. The twin's appendix
module is now the fork's whole module body - these two fixes and upstream's
08-28/09-01 appendix work (scheme-less disclosure, the never-surfaced split
of unopened citations, research seconds, thin-page counters) - with the
fork's regression tests ported, and a test pins the two module bodies equal
with docstrings and comments stripped, so the third layer of the twin is
held the way the values and the defaults now are. Only prose differs: the
twin avoids literal CJK in code and names the trunk's seams. Two existing
twin tests changed wording with the module (the head says "unique query
strings", a never-listed citation is called out as appearing nowhere in
the run); their assertions on the counters are unchanged.

The same sweep, run over the other fork module this revendor changed that
has a twin, found the reviewer's unreported item and fixed it: the trunk's
`unwrap_untrusted` required the fence's close marker to be the last line,
but the budget observer appends its note to the newest tool result after
fencing, so the research gates got the whole fenced string back, refused
it as "not a page", and counted every annotated successful fetch as a
failed one (the fetch gate's release valve never opened, and its own
counter is zero either way). `unwrap_untrusted` now scans for the marker
from the end, as upstream's does, and drops what follows it; the twin's
envelope predicate parses with `raw_decode` as the fork's does. This is
the one change under `raven/` on this branch: `raven/security/trust.py`,
whose only trunk callers are the two research gates, in its own commit so
it can be judged or dropped on its own.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- Vendored suite, isolated HOME, from `subagents/raven-research/Raven-X`:
  `uv run pytest -q -p no:cacheprovider` -> 5914 passed, 84 skipped. The
  pass counts are stable across every run; the process exit is not - four
  runs on this side exited 0 and the two at the current head exited 139,
  the same segfault at interpreter shutdown both reviewers observed, after
  the identical all-passed summary. Treated as a host teardown flake, not a
  test failure, and disclosed here rather than claimed green.
- Host-side research tests: `uv run pytest tests/test_agents_research_*.py
  tests/test_config_product_render.py tests/test_vendored_invariants.py
  tests/test_vendored_subagents.py -q` -> 549 passed, 1 skipped
  (case-sensitive-filesystem skip, pre-existing). This includes the new
  `tests/test_agents_research_flow_parity.py` (7 tests), which runs the
  fork's loader in a subprocess and needs no fork venv. Its fast-mode
  verify excuse names the two timeout leaves, not the `verify.` prefix,
  and a test pins that switching the reviewer or its rubrics off is
  caught. Its last two tests hold the trunk twin's class defaults and
  retired-label tables to the fork's; red-checked by setting one twin
  default back (fails naming `search.include_snippets`) and by editing
  only the trunk-side profile table (fails on the table diff).
- Fifth round, same selection -> 570 passed, 1 skipped.
  `tests/test_agents_research_process_appendix.py` passes 67 (46 + 21
  ported or added). Red-checked: with the fence-tag pattern made
  unmatchable the three fence tests fail; with the unreviewed-outcome
  table emptied the two banner tests fail; with one twin constant moved by
  one (`_THIN_PAGE_CHARS` 400 -> 401) the module-body pin fails and a
  comment-only edit leaves it green. The reviewer's repro (one successful
  fetch, an answer of only `(web_fetch #aa11bb22)`) renders the fence-tag
  disclosure with `cited_fence_tags: 1` and no "nothing to check" line.
- Fence-plus-note seam: `uv run pytest tests/test_security_*.py
  tests/test_subagent_dag_*.py tests/test_subagent_handoff.py` together
  with the selection above -> 1128 passed, 1 skipped, 4 failed; the four
  are `test_security_network.py`'s NAT64 (`64:ff9b:1::`) cases and fail
  identically on this host without the branch (pre-existing, not
  introduced). Red-checked: with the last-line rule restored in
  `unwrap_untrusted`, the two new trust tests and the two new gate tests
  fail (the gate test feeds the production byte shape, fence plus budget
  note, in both directions); with the fix all 43 pass in those two files.
- Whole host suite, branch against merge base `9bc06091` in a second
  worktree on the same environment, failing IDs diffed: branch 5 failed /
  17805 passed / 78 skipped, base 5 failed / 17771 passed / 78 skipped,
  the five IDs identical on both sides (`test_cli_doctor_commands.py`'s
  memories path and the four NAT64 cases in `test_security_network.py`).
  0 introduced, 0 fixed by accident, 34 tests added.
- Patch round trip in an out-of-repo copy: pristine a903a424 plus all six
  patches equals the checkout (`diff -r` zero output), and reverse-applying
  all six returns the pristine tree, verified in more than one application
  order.
- `scripts/check_vendored_subagents.py`, `scripts/check_vendored_invariants.py`
  and `scripts/check_commit_messages.py origin/refactor/raven_v0_2_0..HEAD`
  all pass.
- Revert-the-fix: with the appendix change reverted, 6 of the 7 original
  new appendix tests go red (the seventh is the negative case, paired with
  the positive ones). The two review-round pin tests were each verified to
  turn exactly their own mutant red (narrowed hex width, dropped dedup
  exclusion; 1 failed, 63 passed per direction). The file passes 64 as
  merged. The parity change was red-checked the same way: 3 configs fail
  with the universal excuse removed and no per-config reasons, 12 pass as
  committed.
- The restored gate test is byte-identical to the pre-revendor version and
  passes against the current tree (file passes 21).
- Third-round red checks: disabling the profile refusal turns its test red
  in each loader (1 failed per side); the host parity guard was reddened
  three ways - dropping the fast `max_iterations` excuse (fails naming the
  mode and field), dropping the sufficiency excuse, and switching the
  shipped config's spin breaker off (red in all three modes). Direct loader
  probes: `dr@3.5-filetools-askuser` is refused with the flow on and loads
  with it off, in both `DRFlowConfig` and `FlowConfig`.
- `ruff check` is clean on both changed appendix files; `ruff format`
  drift there predates this branch and the vendored tree deliberately does
  not gate formatting on `raven/` (see its pyproject note).

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- Research behaviour is unchanged: both report fixes are render/disclosure
  side, every landed counter and the grounding rate keep their meaning, and
  `pyproject.toml`/`uv.lock` are byte-identical across the base swap.
- The identity experiment changes the prompt head, so the first turn after
  deploy re-bills the cached prefix once; revert is a config edit.
- Upstream's new `profiles/` directory ships vendor gateway hostnames and
  one intranet address (upstream content, not introduced here). Fine for a
  private repo; confirm before syncing this tree anywhere public.
- Rollback: revert the merge commit; the vendored folder is self-contained
  and TREE_HASHES pins its state.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
## Summary

Config edits now take effect without a restart, landing on the next turn (or, for permission tightening, on the very next tool call). This implements the hot-reload design across six lanes:

- Credentials: `binding_for_session` re-asks the provider pool on every ask (1.6 ms round trip, measured), so an edited or rotated apiKey serves the session's next turn. A credential that can no longer build a provider keeps the current binding instead of failing the turn. The default lane adopts the pool's binding through `set_default_binding`, so out-of-turn consumers (subagents, context engine, consolidator) follow the same edit.
- Turn freeze: `ToolRegistry.turn_scope`, entered at `run_turn` alongside `use_binding`, freezes the advertised tool array for the turn. An MCP handshake finishing in the background joins the next turn instead of rebuilding the prompt-cache prefix mid-turn; `_mcp_tool_notices` already explains the absence to the model. Only arrivals are masked: the operator off switch stays live per assembly, unregistrations pass through, session-overlay tools are exempt, and sub-agent registries are unaffected (per-instance ContextVar).
- Routing: `ModelRouter` resolves `routing.profile` and the default fallback model through live readers per route call; unusable values fall back to boot values.
- Permissions: `ExecTool` re-reads `tools.exec.extra_deny_patterns` before every classification, both directions. A pattern that does not compile rejects the whole edit and keeps the current policy; the deny set is swapped on the policy in place so registered approval families survive.
- Out-of-band MCP edits: `reconcile_mcp_from_live` runs at the turn boundary next to `prewarm_mcp`, applying a hand-edited `tools.mcpServers` on the next turn. This replaces the client-side reload polling as the detection path. A missing section is "no answer" (servers attached over RPC are kept); an empty section is a real answer and detaches.
- Tool credentials: media tools and `web_search` register unconditionally and are withheld while unconfigured (`_unconfigured_tool_names`, read live). Credentials resolve at call time through live readers, so adding a key surfaces the tool and serves the call with no restart. The rule that an OpenRouter chat key alone never enables billed media tools is preserved, moved from the registration gate to the withheld predicate. `deep_research` needed nothing: it already promotes mid-session.

Design decisions a reviewer should know:

- There is no `TurnSnapshot` class. After site verification the only thing still needing a per-turn freeze was the tool array, so the freeze lives on the registry as a third scope next to `session_scope_for`, and `run_turn` enters three context managers side by side.
- Two guard tests were refined, both along their own stated escape hatches: the provider-resolution scanner now exempts read calls (`get` / `_get_nested`) of the model key, and the vendored `web-search-gated-on-its-key` invariant drops the trunk main loop from its roster because the trunk guarantee moved to the withheld predicate, pinned by behaviour tests; fork checks are unchanged.
- Test contract migration: `tests/test_tool_capabilities.py` now asserts against `offers_by_name` instead of `has`, since registration stopped being the gate.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `uv run --frozen --all-extras pytest -q`: 13915 passed, 0 failed, 33 skipped (all pre-existing parametrized provider-matrix skips with stated reasons)
- `make lint-python` (ruff check + format gate) and `uv run --frozen lint-imports`: clean
- `uv run python scripts/check_vendored_invariants.py`: clean
- 28 new or migrated tests across registry / session model / MCP sync / routing / shell / media / capabilities; reverse-verified by stashing the source changes: 26 go red on the original tree (2 pin fallback behaviour that coincides with the old code, by design)

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

Behaviour changes a user can see:

- Unconfigured media tools and web_search are now registered-but-withheld instead of unregistered. Anything equating "registered" with "offered" would over-report; the RPC surfaces checked go through `offers_by_name`, which answers as before.
- A hand edit of `config.json` (mcpServers, tool keys, deny patterns, routing) now takes effect on the next turn on every frontend, not only on frontends that poll the reload RPC.
- Rollback: revert the single commit; no config format or persisted state changed.

- [x] Security impact considered (deny-list edits fail closed: a non-compiling pattern keeps the current policy; tightening binds the next tool call)
- [x] Backward compatibility considered (constructor-supplied config remains the fallback lane whenever the live file has no matching section, which keeps eval harnesses and file-less embedders unchanged)
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
## Summary

Config edits now take effect without a restart, landing on the next turn (or, for permission tightening, on the very next tool call). This implements the hot-reload design across six lanes:

- Credentials: `binding_for_session` re-asks the provider pool on every ask (1.6 ms round trip, measured), so an edited or rotated apiKey serves the session's next turn. A credential that can no longer build a provider keeps the current binding instead of failing the turn. The default lane adopts the pool's binding through `set_default_binding`, so out-of-turn consumers (subagents, context engine, consolidator) follow the same edit.
- Turn freeze: `ToolRegistry.turn_scope`, entered at `run_turn` alongside `use_binding`, freezes the advertised tool array for the turn. An MCP handshake finishing in the background joins the next turn instead of rebuilding the prompt-cache prefix mid-turn; `_mcp_tool_notices` already explains the absence to the model. Only arrivals are masked: the operator off switch stays live per assembly, unregistrations pass through, session-overlay tools are exempt, and sub-agent registries are unaffected (per-instance ContextVar).
- Routing: `ModelRouter` resolves `routing.profile` and the default fallback model through live readers per route call; unusable values fall back to boot values.
- Permissions: `ExecTool` re-reads `tools.exec.extra_deny_patterns` before every classification, both directions. A pattern that does not compile rejects the whole edit and keeps the current policy; the deny set is swapped on the policy in place so registered approval families survive.
- Out-of-band MCP edits: `reconcile_mcp_from_live` runs at the turn boundary next to `prewarm_mcp`, applying a hand-edited `tools.mcpServers` on the next turn. This replaces the client-side reload polling as the detection path. A missing section is "no answer" (servers attached over RPC are kept); an empty section is a real answer and detaches.
- Tool credentials: media tools and `web_search` register unconditionally and are withheld while unconfigured (`_unconfigured_tool_names`, read live). Credentials resolve at call time through live readers, so adding a key surfaces the tool and serves the call with no restart. The rule that an OpenRouter chat key alone never enables billed media tools is preserved, moved from the registration gate to the withheld predicate. `deep_research` needed nothing: it already promotes mid-session.

Design decisions a reviewer should know:

- There is no `TurnSnapshot` class. After site verification the only thing still needing a per-turn freeze was the tool array, so the freeze lives on the registry as a third scope next to `session_scope_for`, and `run_turn` enters three context managers side by side.
- Two guard tests were refined, both along their own stated escape hatches: the provider-resolution scanner now exempts read calls (`get` / `_get_nested`) of the model key, and the vendored `web-search-gated-on-its-key` invariant drops the trunk main loop from its roster because the trunk guarantee moved to the withheld predicate, pinned by behaviour tests; fork checks are unchanged.
- Test contract migration: `tests/test_tool_capabilities.py` now asserts against `offers_by_name` instead of `has`, since registration stopped being the gate.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `uv run --frozen --all-extras pytest -q`: 13915 passed, 0 failed, 33 skipped (all pre-existing parametrized provider-matrix skips with stated reasons)
- `make lint-python` (ruff check + format gate) and `uv run --frozen lint-imports`: clean
- `uv run python scripts/check_vendored_invariants.py`: clean
- 28 new or migrated tests across registry / session model / MCP sync / routing / shell / media / capabilities; reverse-verified by stashing the source changes: 26 go red on the original tree (2 pin fallback behaviour that coincides with the old code, by design)

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

Behaviour changes a user can see:

- Unconfigured media tools and web_search are now registered-but-withheld instead of unregistered. Anything equating "registered" with "offered" would over-report; the RPC surfaces checked go through `offers_by_name`, which answers as before.
- A hand edit of `config.json` (mcpServers, tool keys, deny patterns, routing) now takes effect on the next turn on every frontend, not only on frontends that poll the reload RPC.
- Rollback: revert the single commit; no config format or persisted state changed.

- [x] Security impact considered (deny-list edits fail closed: a non-compiling pattern keeps the current policy; tightening binds the next tool call)
- [x] Backward compatibility considered (constructor-supplied config remains the fallback lane whenever the live file has no matching section, which keeps eval harnesses and file-less embedders unchanged)
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
## Summary

An approval docking under a running graph went below the fold: the reader was
asked for a decision they could not see, in a rack that had already run out of
room.

The rack is the one place every tenant passes through, so it is the only one that
can say "something is being asked here" without a tenant having to know about the
others. It says exactly that — a count, per conversation, of the sheets that ask
— and leaves what to do about it to whoever is in the way. A sheet marks itself
by setting `dataset.asks` before it docks; the approval sheet does.

The graph folds itself for as long as a question stands and opens again when it
is answered -- but only if the fold was ours to take, and only if it still is.
That record lives in the store, which owns the flag and is the one place every
reader path to it goes through: `takeFold` records, `releaseFold` gives back, and
`fold` drops the record. A reader is put back where they left it, not where the
code would prefer.

## Verification

On the running page, raising a **real** approval sheet over a running graph:

```
graph docked                data-fold=false
approval raised             data-fold=true    (.csheet.perm present)
approval answered           data-fold=false
```

```
npx vitest run src/features/dag/ src/features/composer/   12 files, 197 tests passed
npx vitest run (ui-web)                                   89 files, 1233 tests passed
make lint-ui                                              generated.ts matches the contract; tsc clean
```

Mutation-checked in both halves, because they can fail independently: removing
the fold turns two dag cases red, and removing `dataset.asks` from the approval
sheet turns the approve case red. The second one was added *because* the first
mutation left it green — the dag cases build their own marked element, so nothing
pinned that the real sheet marks itself.

## A test-only trap this change found

`sheets._resetForTests` deliberately does **not** clear the watchers. One is
registered when its island's module loads, which happens once for a whole test
file, so clearing them would unwire the first reset and leave every test after it
watching nothing. The tests went red on exactly that before the reset was
corrected.

## Swept

`mr-review-patterns` pre-submit sweep.

- **3d (several lanes, one sink)** — the rack now notifies on both `add` and
  `remove`, and `remove` notifies *after* the teardown, so a tenant that closes
  itself from its own teardown is already gone from the bucket when the count is
  taken.
- **9 (error handling)** — a watcher that throws is caught and swallowed: a
  courtesy must not take the rack down.
- **3c (state left its owner)** — the count is per conversation key, which is
  what the rack already files sheets under; no new namespace.
- **1, 2, 3, 3b, 3e, 4b–4d, 5, 5b, 6, 6b, 6c, 7, 8, 10** — not applicable.

## After review

Two findings, both mine to have caught.

**The clarification never marked itself.** The rack's contract names both kinds
of asking sheet and only the approval carried the mark, so `askingIn` counted
zero on the clarification path and a running graph could still push that question
below the fold -- the very failure this change exists to stop, on the half the
first description waved through as out of scope. Marked now, at the same point
the approval marks itself, and covered on both sides: the sheet carries the mark,
and the graph steps aside for a clarification as it does for an approval.

**Ownership was inferred from the flag.** Reproduced by the reviewer: a question
folds the graph, the reader unfolds it and folds it again, and answering forced
it open. Their second decision looks exactly like the one taken on their behalf
-- `folded` is true either way -- so restoring on the flag alone threw it away,
which is the opposite of what the comment promised. The record moved into the
store, which owns the flag and is the one place every reader path to it goes
through: `takeFold` records, `releaseFold` gives back, and `fold` drops the
record. The double toggle is a case now, driven through `fold` rather than by
writing the flag, since writing it is a state the product cannot reach.

Verification at this head:

```
npm test --prefix ui-web        89 files, 1236 tests passed
npm run type-check --prefix ui-web   passed
npm run build --prefix ui-web        passed
npm run gen:check --prefix ui-web    generated client matches (158 methods)
```

Both fixes mutation-checked: removing the clarification's mark reds its own case
and nothing else, and not dropping the ownership record reds the double toggle.

## After review, round two

Three findings and one note, one commit each.

**A claim outlived the run it was about -- `68040eb6`.** `TAKEN` was dropped by
`fold`, `releaseFold` and the test seam, but not by the two paths that end a run.
Reproduced on the running page through the route the UI can actually reach --
dismissing the sheet while a question stands:

```
                                    before    after
graph running                       open      open
question docks                      folded    folded
reader dismisses the graph sheet    (gone)    (gone)
a new run appears, question still up  OPEN      folded   <- the finding
question answered                   open      open
```

Worth being exact about the other half: `set` is cleared for consistency rather
than from a reproduction. I could not reach it through the UI, because every
other path that re-asks `takeFold` passes through a moment with nothing asking,
and `releaseFold` clears the claim there -- two approvals cannot even coexist,
`dropClass` retires the first. A run replaced is still a run gone, and the claim
has no business surviving it, so both are dropped; only one of the two has a
browser reproduction behind it and the unit tests cover both.

**The tenant roster was a convention -- `cd2e8a3e`.** Pinned by a test, and by
walking the tree rather than reading a list: a check over the listed modules can
only confirm what someone already wrote down, and the case worth catching is the
module nobody listed. Mutation-checked three ways -- a tenant that forgets the
mark, a declared module dropped from the list, and a new module that docks
without being declared. The third is the reviewer's case, and it is the one a
list-driven check missed when I wrote it that way first.

**The watcher leaned on vitest -- `982dd6ff`.** Registration stays at module
scope, which is right for one island with a page lifetime; the unsubscribe is
kept so the test seam can drop and re-arm it, instead of isolation resting on
each test file getting a fresh module registry. Mutation-checked: dropping
without re-arming reds the four fold cases.

**The !445 merge-order dependency is discharged.** It was a real blocker while it
stood: `settle()` wrote `d.folded = true`, which `TAKEN` cannot see, so a run
finishing while a question stood would have `releaseFold` unfold a graph the
completion deliberately folded -- and a background DAG can outlive the turn that
launched it, so the overlap is ordinary rather than exotic.

!445 merged on 2026-09-02, and this branch is rebased onto that target. Checked on
the target rather than inferred from the merge: `settle()` no longer writes the
fold, and its `interrupted` mapping is there. Only one writer of the flag remains
on the reader's side, and all four `TAKEN` drops are present.

Then driven on the running page, which is the case the dependency was about --
a run completing while the question still stands:

```
graph running, unfolded          fold = false   done = false
question docks, graph steps aside fold = true   done = false
run completes, question still up  fold = true   done = TRUE
question answered                 fold = false  <- where the reader left it
```

The completion no longer has an opinion about the fold, so answering returns the
graph to the reader's own state instead of to one the completion imposed. The
other two behaviours re-verified on the new base as well: the plain
step-aside/step-back, and the stale-claim case (dismiss the graph while a question
stands, then a new run appears -- it steps aside).

## Rebased onto the merged target

It was 67 commits behind, so its own gates would not have meant what they looked
like. Rebased; `git range-diff` shows five of six commits `=`. The sixth carries
the `DagSheet.test.tsx` resolution -- both sides had appended cases at the same
place, kept both, and each of the six test names is present exactly once
afterwards, the target's two included.

```
ui-web  vitest 90 files / 1271 tests   type-check clean
        gen:check  160 methods
lint-imports                8 kept, 0 broken
constitutional guards       164 passed
```

Failing IDs diffed against the base rather than counts: 0 introduced.

## `make ci` on this base

CONTRIBUTING asks for it, so here is what it actually does on this machine —
including the parts it cannot finish, named rather than omitted:

```
lint-python, lint-ui, lint-tui, lint-bridge,
check-vendored-subagents, check-vendored-invariants   all pass
test-python            13886 passed, 5 failed  -- and make stops here
build                  pass
check-core-wheel       pass
check-large-files      pass
test-ui                1250 passed
test-tui               82 files fail to import
```

The five `test-python` failures are pre-existing on this base and reproduce on an
untouched branch: four `test_security_network` cases that need outbound DNS, and
`test_doctor_answers_where_the_memories_are`, which asserts against a path the
terminal wrapped.

`test-tui` fails on this box for a reason outside the repository: `ui-tui/node_modules`
is a symlink into another checkout whose `hermes-ink` workspace package is not
built, so 82 files fail at
`Cannot find module './dist/entry-exports.js' imported from /root/main-live/...`.
This branch touches no file under `ui-tui/`, `rpc-schema/` or `bridge/`.
## Summary

An approval docking under a running graph went below the fold: the reader was
asked for a decision they could not see, in a rack that had already run out of
room.

The rack is the one place every tenant passes through, so it is the only one that
can say "something is being asked here" without a tenant having to know about the
others. It says exactly that — a count, per conversation, of the sheets that ask
— and leaves what to do about it to whoever is in the way. A sheet marks itself
by setting `dataset.asks` before it docks; the approval sheet does.

The graph folds itself for as long as a question stands and opens again when it
is answered -- but only if the fold was ours to take, and only if it still is.
That record lives in the store, which owns the flag and is the one place every
reader path to it goes through: `takeFold` records, `releaseFold` gives back, and
`fold` drops the record. A reader is put back where they left it, not where the
code would prefer.

## Verification

On the running page, raising a **real** approval sheet over a running graph:

```
graph docked                data-fold=false
approval raised             data-fold=true    (.csheet.perm present)
approval answered           data-fold=false
```

```
npx vitest run src/features/dag/ src/features/composer/   12 files, 197 tests passed
npx vitest run (ui-web)                                   89 files, 1233 tests passed
make lint-ui                                              generated.ts matches the contract; tsc clean
```

Mutation-checked in both halves, because they can fail independently: removing
the fold turns two dag cases red, and removing `dataset.asks` from the approval
sheet turns the approve case red. The second one was added *because* the first
mutation left it green — the dag cases build their own marked element, so nothing
pinned that the real sheet marks itself.

## A test-only trap this change found

`sheets._resetForTests` deliberately does **not** clear the watchers. One is
registered when its island's module loads, which happens once for a whole test
file, so clearing them would unwire the first reset and leave every test after it
watching nothing. The tests went red on exactly that before the reset was
corrected.

## Swept

`mr-review-patterns` pre-submit sweep.

- **3d (several lanes, one sink)** — the rack now notifies on both `add` and
  `remove`, and `remove` notifies *after* the teardown, so a tenant that closes
  itself from its own teardown is already gone from the bucket when the count is
  taken.
- **9 (error handling)** — a watcher that throws is caught and swallowed: a
  courtesy must not take the rack down.
- **3c (state left its owner)** — the count is per conversation key, which is
  what the rack already files sheets under; no new namespace.
- **1, 2, 3, 3b, 3e, 4b–4d, 5, 5b, 6, 6b, 6c, 7, 8, 10** — not applicable.

## After review

Two findings, both mine to have caught.

**The clarification never marked itself.** The rack's contract names both kinds
of asking sheet and only the approval carried the mark, so `askingIn` counted
zero on the clarification path and a running graph could still push that question
below the fold -- the very failure this change exists to stop, on the half the
first description waved through as out of scope. Marked now, at the same point
the approval marks itself, and covered on both sides: the sheet carries the mark,
and the graph steps aside for a clarification as it does for an approval.

**Ownership was inferred from the flag.** Reproduced by the reviewer: a question
folds the graph, the reader unfolds it and folds it again, and answering forced
it open. Their second decision looks exactly like the one taken on their behalf
-- `folded` is true either way -- so restoring on the flag alone threw it away,
which is the opposite of what the comment promised. The record moved into the
store, which owns the flag and is the one place every reader path to it goes
through: `takeFold` records, `releaseFold` gives back, and `fold` drops the
record. The double toggle is a case now, driven through `fold` rather than by
writing the flag, since writing it is a state the product cannot reach.

Verification at this head:

```
npm test --prefix ui-web        89 files, 1236 tests passed
npm run type-check --prefix ui-web   passed
npm run build --prefix ui-web        passed
npm run gen:check --prefix ui-web    generated client matches (158 methods)
```

Both fixes mutation-checked: removing the clarification's mark reds its own case
and nothing else, and not dropping the ownership record reds the double toggle.

## After review, round two

Three findings and one note, one commit each.

**A claim outlived the run it was about -- `68040eb6`.** `TAKEN` was dropped by
`fold`, `releaseFold` and the test seam, but not by the two paths that end a run.
Reproduced on the running page through the route the UI can actually reach --
dismissing the sheet while a question stands:

```
                                    before    after
graph running                       open      open
question docks                      folded    folded
reader dismisses the graph sheet    (gone)    (gone)
a new run appears, question still up  OPEN      folded   <- the finding
question answered                   open      open
```

Worth being exact about the other half: `set` is cleared for consistency rather
than from a reproduction. I could not reach it through the UI, because every
other path that re-asks `takeFold` passes through a moment with nothing asking,
and `releaseFold` clears the claim there -- two approvals cannot even coexist,
`dropClass` retires the first. A run replaced is still a run gone, and the claim
has no business surviving it, so both are dropped; only one of the two has a
browser reproduction behind it and the unit tests cover both.

**The tenant roster was a convention -- `cd2e8a3e`.** Pinned by a test, and by
walking the tree rather than reading a list: a check over the listed modules can
only confirm what someone already wrote down, and the case worth catching is the
module nobody listed. Mutation-checked three ways -- a tenant that forgets the
mark, a declared module dropped from the list, and a new module that docks
without being declared. The third is the reviewer's case, and it is the one a
list-driven check missed when I wrote it that way first.

**The watcher leaned on vitest -- `982dd6ff`.** Registration stays at module
scope, which is right for one island with a page lifetime; the unsubscribe is
kept so the test seam can drop and re-arm it, instead of isolation resting on
each test file getting a fresh module registry. Mutation-checked: dropping
without re-arming reds the four fold cases.

**The !445 merge-order dependency is discharged.** It was a real blocker while it
stood: `settle()` wrote `d.folded = true`, which `TAKEN` cannot see, so a run
finishing while a question stood would have `releaseFold` unfold a graph the
completion deliberately folded -- and a background DAG can outlive the turn that
launched it, so the overlap is ordinary rather than exotic.

!445 merged on 2026-09-02, and this branch is rebased onto that target. Checked on
the target rather than inferred from the merge: `settle()` no longer writes the
fold, and its `interrupted` mapping is there. Only one writer of the flag remains
on the reader's side, and all four `TAKEN` drops are present.

Then driven on the running page, which is the case the dependency was about --
a run completing while the question still stands:

```
graph running, unfolded          fold = false   done = false
question docks, graph steps aside fold = true   done = false
run completes, question still up  fold = true   done = TRUE
question answered                 fold = false  <- where the reader left it
```

The completion no longer has an opinion about the fold, so answering returns the
graph to the reader's own state instead of to one the completion imposed. The
other two behaviours re-verified on the new base as well: the plain
step-aside/step-back, and the stale-claim case (dismiss the graph while a question
stands, then a new run appears -- it steps aside).

## Rebased onto the merged target

It was 67 commits behind, so its own gates would not have meant what they looked
like. Rebased; `git range-diff` shows five of six commits `=`. The sixth carries
the `DagSheet.test.tsx` resolution -- both sides had appended cases at the same
place, kept both, and each of the six test names is present exactly once
afterwards, the target's two included.

```
ui-web  vitest 90 files / 1271 tests   type-check clean
        gen:check  160 methods
lint-imports                8 kept, 0 broken
constitutional guards       164 passed
```

Failing IDs diffed against the base rather than counts: 0 introduced.

## `make ci` on this base

CONTRIBUTING asks for it, so here is what it actually does on this machine —
including the parts it cannot finish, named rather than omitted:

```
lint-python, lint-ui, lint-tui, lint-bridge,
check-vendored-subagents, check-vendored-invariants   all pass
test-python            13886 passed, 5 failed  -- and make stops here
build                  pass
check-core-wheel       pass
check-large-files      pass
test-ui                1250 passed
test-tui               82 files fail to import
```

The five `test-python` failures are pre-existing on this base and reproduce on an
untouched branch: four `test_security_network` cases that need outbound DNS, and
`test_doctor_answers_where_the_memories_are`, which asserts against a path the
terminal wrapped.

`test-tui` fails on this box for a reason outside the repository: `ui-tui/node_modules`
is a symlink into another checkout whose `hermes-ink` workspace package is not
built, so 82 files fail at
`Cannot find module './dist/entry-exports.js' imported from /root/main-live/...`.
This branch touches no file under `ui-tui/`, `rpc-schema/` or `bridge/`.
## Summary

The web model picker could not switch models: clicking a model toasted a switch failure.

The picker called `config.set` with only `{ key: 'model', value }`. Since model
selection was reworked, the backend requires a `provider` -- a bare model id does
not name whose credential serves it (`openrouter` and `anthropic` both serve the
same id and bill different accounts), and it refuses to guess from the prefix. So
every web switch was rejected with `config.set model needs a provider`. The
frontend was never updated to the new contract; the TUI already sends the provider
(`/model <id> --provider <slug>`), so only the web was affected.

The picker already has the answer: the column a model is chosen from is its
provider. This threads that provider slug through `choose()` and `persist()` and
sends it, and also sends `session_id` (the open conversation) so the switch is
scoped to that conversation and leaves `agents.defaults` alone -- matching the
TUI's session-scoped `/model` (no `--default`). The wire schema already declares
`provider`, `session_id` and `scope` for `config.set`, so no contract change.

Frontend only; no backend change (the backend contract was already correct).

## Type

- [x] Fix

## Verification

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally

Commands run in `ui-web/`:
- `npm run type-check` -- clean.
- `npx vitest run` -- 90 files, 1258 passed.
- Reverse-verified the two new picker pins: forcing an empty provider turns them
  red at exactly the provider assertions (`['minimax']`, `['anthropic']`); they
  pass once the column's slug is sent.

## Risk

- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

User-visible: web model switching works again, and (like the TUI) is scoped to the
open conversation rather than changing the global default. Rollback: revert the
single commit; behavior returns to the current "cannot switch" state.

## Related Issues

N/A (reported via product feedback; no tracking issue).
## Summary

The web model picker could not switch models: clicking a model toasted a switch failure.

The picker called `config.set` with only `{ key: 'model', value }`. Since model
selection was reworked, the backend requires a `provider` -- a bare model id does
not name whose credential serves it (`openrouter` and `anthropic` both serve the
same id and bill different accounts), and it refuses to guess from the prefix. So
every web switch was rejected with `config.set model needs a provider`. The
frontend was never updated to the new contract; the TUI already sends the provider
(`/model <id> --provider <slug>`), so only the web was affected.

The picker already has the answer: the column a model is chosen from is its
provider. This threads that provider slug through `choose()` and `persist()` and
sends it, and also sends `session_id` (the open conversation) so the switch is
scoped to that conversation and leaves `agents.defaults` alone -- matching the
TUI's session-scoped `/model` (no `--default`). The wire schema already declares
`provider`, `session_id` and `scope` for `config.set`, so no contract change.

Frontend only; no backend change (the backend contract was already correct).

## Type

- [x] Fix

## Verification

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally

Commands run in `ui-web/`:
- `npm run type-check` -- clean.
- `npx vitest run` -- 90 files, 1258 passed.
- Reverse-verified the two new picker pins: forcing an empty provider turns them
  red at exactly the provider assertions (`['minimax']`, `['anthropic']`); they
  pass once the column's slug is sent.

## Risk

- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

User-visible: web model switching works again, and (like the TUI) is scoped to the
open conversation rather than changing the global default. Rollback: revert the
single commit; behavior returns to the current "cannot switch" state.

## Related Issues

N/A (reported via product feedback; no tracking issue).
## Summary

A DAG node that falls short of its judge suspends and waits for the main agent to
decide whether to continue or abandon it. That works for a backgrounded run, whose
submitting turn has already returned, and structurally cannot work for a blocking
one: the call is still on the stack and a conversation's lane is serial, so the turn
that would call `resolve_dag_node` cannot start until this one ends. The blocking
lane therefore asked the person watching the call instead, over the confirm gate's
round trip.

This replaces that lane with a route to the agent. A blocking `run_subagent_dag`
returns the first suspended node's report as its own tool result, and
`resolve_dag_node` then blocks for the next report or for the run's final result. The
graph keeps running throughout, and the agent handles the report inside the turn it
already owns.

Three decisions shape it:

- An outbox carries a run's events to whichever tool call is waiting on it. It hands
  over one report at a time, buffers what arrives with no taker, and keeps the final
  result sticky so a later take still sees it. A report it has handed over stays owed
  until a decision retires it: taking a report is not deciding it, the node is still
  open on the desk, and a turn that ends holding one leaves it to be re-sent exactly
  like one nobody took.
- A run is bound while the turn that started it is alive and released when that turn
  ends. While bound there is no adjudication deadline at all: the live turn is the
  liveness signal, and the agent may be asking the user something only they know. The
  clock starts at the release, and a report the turn left unanswered is re-sent as an
  injected message, exactly as a backgrounded run's would be. A cancelled turn drops
  its unanswered reports instead of re-sending them.
- Cancelling the waiting tool call hard-cancels the run. A background graph still
  survives its caller.

The adjudication window changes for a related reason, and stands on its own: it now
measures silence rather than the whole round, restarting on every decision. Reports
reach the agent one turn at a time because a lane is serial, so a single deadline for
the round charged each node for the time its report spent queued behind another
node's, and far enough down the queue a node timed out having never been asked. The
bound worst case is still one window per decision rather than one per node.

The report the runner hands to an announcer carries one keyword, `awaiting_decision`,
saying whether the node is actually waiting on a decision. It is required and has no
default: the wrong value is invisible at the call site, so a default would hand the next
caller that forgets a terminal report announced as a question.

Who acts on that keyword is the outbox, because the answer depends on the lane and the
outbox is the only object that knows the lane. Bound, a notification is dropped: the
blocking call is still there and the run's summary is what it will be handed, so
announcing the same news again would put an unanswerable question beside it. Released,
both kinds announce -- there is no such call any more, and a dropped notification would
reach nobody until the whole graph finished, which is the one thing a released run is not
meant to do differently from a backgrounded one.

Also fixed, because the feature's only real call path runs through it: the `tool_call`
meta-tool forwarded a target's name but not its arguments to the blocking verdict, so
`resolve_dag_node.blocking_for` always saw an empty mapping and answered False. The
registry then applied its default ceiling to a wait that has none, and that
cancellation reads inside the tool as the user stopping the agent, which aborted the
run. Every other target's verdict ignores the extra argument.

Three things came out of review and a pass over the failure paths around this lane.

A decision on one node re-sends what the call is still owed. Two nodes can suspend at
once; one report goes to the blocking call and the other queues, and the agent may
decide either -- the report it holds names what is blocked behind it and `dag_status`
lists the rest. Deciding the queued one parked the call with nothing able to wake it:
the runner waits for every open node and begins waiting only once nothing else can run,
so no report could be produced; the run cannot finish while a node is open, so no final
could arrive; and a bound run has no deadline. Cancelling was the only exit, and that
aborts the whole graph. `Outbox.take` now settles the handed tray's debt to whoever
asks next, after the queue and a landed final.

A failed report delivery is retried, on both legs that have one. The announcer is a
transport, and an injected turn can lose a race with a gateway restart or a busy submit
queue; one raise used to fail the node outright and name the transport as its outcome.
Three attempts with a growing backoff, bounded because a bound run has no adjudication
deadline. It is safe only while a raise means nothing was delivered -- the injection is
the last step that can fail, and the marker emit after it swallows its own failure -- so
that invariant is pinned rather than assumed.

A post-release `put_final` was the third, reported once the second landed. It awaited the
final announcer directly, from the run's detached task with nothing around it, and that
task's done callbacks only drop the run's indexes -- neither retrieves
`task.exception()` -- so a raise there lost the run's result and left an
unretrieved-exception warning. The backgrounded lane catches and logs the same failure,
so the two lanes disagreed about a failure they share, on the lane this branch
introduced. It now takes the step the drain's buffered final already takes.

`release(flush=True)`'s drain is the second leg and was reported as a blocker after the
first landed: it announced once, the event is popped from its tray before the announce
and nothing re-drains, so a single transient dropped a report for good -- and releasing
starts the adjudication deadline, so that node could then only expire unseen. The retry
therefore lives in `dag_adjudication`, which imports nothing from raven and is the layer
both legs can share; the outbox cannot import the runner without a cycle. The drain runs
every owed event through it, both trays and the buffered final with them. Retaining a
failed event instead was the other option offered and does not work: nothing re-drains
after release, so a retained event is a leak rather than a later attempt.

The judge's wall clock has to contain its own retry ladder. `verdict_timeout_seconds`
bounded one judge call at 30s, close enough to a few slow calls that it truncated the
ladder inside it (`chat_with_retry`: four attempts, roughly 7s of jittered backoff)
instead of bounding it. A truncation lands on the fail-open, so an unjudged node is
recorded as accomplished and its output reaches its dependents -- the failure this
judgement step exists to catch. Raised to 180s.

Removed with the old lane: the person-asking adjudication route, the desk's report
storage, and the per-node answered-in-turn bookkeeping. `AskUserTool.ask_direct`
itself stays, with its other callers untouched. The superseded design doc keeps its
paragraphs and gains a marker naming what is withdrawn and where the replacement is.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Every command below was run at the head of this branch.

- `uv run --frozen --python 3.12 --all-extras pytest tests/ -q --ignore=tests/integration`
  -> `18240 passed, 69 skipped`, plus the environment-dependent failure named below. `--all-extras` is load-bearing: without it
  the workspace member holding roughly 4300 tests is absent and the run still reports
  green.
- The nine test files this branch changes, run together: `654 passed`.
- One test in `tests/test_cli_doctor_commands.py` fails here and is not this branch's.
  It reads a path back out of rich-wrapped stdout, so it depends on the temp directory
  the harness hands it: on the same tree and the same commit it passes with an explicit
  `--basetemp` and fails on the default one, and it has been green and red across runs
  of identical trees today. This branch touches none of the three files it exercises and
  their content is byte-identical to the base, so the merge base fails it for the same
  reason. An earlier revision of this description called it an ordering flake under
  parallel collection; that was wrong, and the basetemp experiment is what disproved it.
- Two invariants this lane rested on unstated are now pinned, each proved by the mutation
  it exists to catch: holding a dispatch slot across the adjudication wait, and adding a
  yield between the resolve and the take. Both first drafts were weaker than they read.
  The slot test passed against its own mutant, sampling the gate before the mutant could
  take it, and is reshaped to settle first. The yield test drove the low-level pair
  itself, so the yield it claimed to guard could be added to the model-facing
  `ResolveDagNodeTool.execute` and leave it green; it now runs the decision through that
  real control tool over the real outbox, and goes red on that exact insertion with the
  message a lost result produces -- the node reported as about to run again instead of
  the finished run's summary. The comment at `_retire` names the test that actually
  drives the call site.
- The re-send fix is load-bearing three ways: removing it puts the three discriminating
  cases red (outbox unit, the `resolve_node`/`await_run` pair, and the real control tool)
  and nothing else; an off-by-one on the delivery bound is caught by the exact attempt
  count; and moving the re-send ahead of the queue turns the ordering guard red. That
  ordering guard is a guard, not a discriminator -- removing the re-send leaves it green,
  and two older cases already read the same order -- and its docstring says so.
- The claim at the outbox's retirement is structural, so its guard is too: in
  `ResolveDagNodeTool.execute` the first await after the resolve must be the take.
  `await asyncio.sleep(0)` there turns it red where the delivery regression stays green,
  because a single tick does not let the run finish and the outbox is still indexed. The
  behavioural test holds the other half -- a yield long enough to lose the race -- and
  each text now claims only what its test holds.
- The error end of `run_turn` releases and flushes, which nothing asserted: an
  `except BaseException` setting `flush = False` used to pass. It is the exit with a
  decision in it, since an ordinary error is not the user stopping the agent.
- The post-release final has three cases and only two discriminate: reverting the routing
  turns the escape and the transient red, while the stopped case stays green because the
  previous code honoured `stopped` too. That one guards the risk the routing introduces --
  that the shared step might not honour it -- and goes red when the step stops honouring
  it, alongside the older case reading the same rule. Its docstring says which it is.
- The drain's retry is load-bearing by the same substitution: putting the single announce
  back turns the three release cases red (both trays, and the bound one that also pins that
  a dead event does not strand the next) and nothing else.
- The delivery retry rests on the announce being all-or-nothing, so that is pinned in the
  manager suite rather than assumed: adding a raising step after the injection turns
  `test_a_failing_delivery_marker_does_not_fail_the_announce` red, which is the test that
  stands between a retry and a double injection.
- The lane-routing fix is load-bearing by an exact substitution: swapping the order of
  the released check and the verdict check inside `put_report` -- which is precisely the
  defect -- puts the two cases written for it red and nothing else.
- The report-retention fix was shown to be load-bearing by neutering it and re-running:
  disabling the re-send puts three tests red, including the single-node case, where
  there is no sibling report to stand in for the lost one; replacing the per-node
  retirement with a blanket one puts the two-node case red. That second substitution
  left the whole file green until the two-node case was written, so the first attempt at
  pinning the keying is not among the evidence. The remaining new cases are guards that
  hold either way and say so.
- `ruff format --check` and `ruff check` over the Python files this branch touches
  -> already formatted, all checks passed.
- `scripts/check_commit_messages.py <base>..HEAD` -> exit 0, and the messages carry no
  non-ASCII character.
- `uv run --frozen --python 3.12 lint-imports` -> 8 contracts kept, 0 broken. Worth
  stating because the pipeline does not run it: CI here is the Python unit suite only,
  so the layering contracts are checked locally or not at all.
- Rebased four times. Two replayed clean (the playbook/DAG-validation commits, and the
  raven-research subagent commits, which touch no file this branch does). The fourth hit
  one real conflict, in `AgentLoop.run_turn`: the base's config-preferences commit added a
  turn-boundary `_maybe_promote_deep_research()` and a third `turn_scope()` manager to the
  same `with`, while this branch wrapped that block in the try/finally that releases a
  foreground run. Composed rather than chosen -- the promotion sits inside the try, so the
  base's "before the freeze" ordering holds and this branch's "every way a turn ends passes
  here" still does too. Both sides' tests were run together over the composed region
  (`787 passed`), because a semantic conflict there raises nothing on its own.
- Rebased onto a base that now carries the sibling MR (`!465`), which extends the same
  `announce_dag_exception` seam. Thirty-seven conflict hunks, and one of them had to be
  composed rather than chosen: `deliverable` reads
  `announce_exception is not None and answerable`, because taking this branch's side
  alone would have left `answerable` unread and turned that MR's fail-fast into dead
  code -- a node with no `tool_call` route would suspend and wait out its whole timeout
  again. Fifteen hunks were the keyword collision the two branches had already aligned
  on, which is why they collapsed to deleting a duplicate declaration.
- Rebase proof at the current head: `git range-diff` reports all replayed commits as
  `=`, so the replay changed no content. The commits the base gained in the last round
  touch no file this branch touches (overlap: zero), and the earlier round's five
  overlapping files did not conflict either.
- Driven live over `raven acp` against an isolated agent home: a blocking call returns
  a node's report while the graph runs, the bound `resolve_dag_node` waits and hands
  over the next event, the forwarded `tool_call` carries `blocking: true`, the release
  fires when the turn ends, and the continuation prompt lands on disk in the expected
  shape.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

User-visible behaviour change, in one place: a `background: false` call that
previously prompted the person now returns a report to the model, and the model is
expected to answer it with `resolve_dag_node`. A host with no such call in flight is
unaffected, and a backgrounded run behaves exactly as before.

The new wait has no timer by construction, which is the point of it, so the guards
that bound it are worth naming: the tool's own blocking verdict keeps the registry
ceiling off the call, the run is released when its turn ends whether or not it was
answered, and cancelling the call cancels the run. A node whose report is never
answered fails on the window after the release rather than waiting forever.

One default moves: `subagent_dag.verdict_timeout_seconds`, 30s to 180s. A judge call
that hangs now delays its node for up to three minutes rather than thirty seconds,
which is the cost of not truncating the retry ladder into the fail-open. The same
field also bounds `describe_failure`, the wording-only call for a node that has
already failed, so that one inherits the longer ceiling; splitting the two knobs is
left alone rather than decided here.

The delivery retry adds up to about 1.5s of backoff before a node is failed on an
undeliverable report, on the path where the transport is already broken. The release
drain pays the same per undeliverable event, in the turn's teardown, where a failure was
already logged rather than raised.

Rollback is a revert of the range. Nothing is persisted in a new format and no
on-disk layout changed, so a revert needs no migration.

## Related Issues

N/A

## Checked and deliberately not changed

- The report an agent receives is assembled at suspension time and states the
  window as `deciding within Ns, restarted by each decision`. It does not restate
  the remaining budget, which would be stale by the time it is read.
- `_await_adjudications` reads its budget before the wait and re-reads the clock
  after it; the bound check is likewise taken after the wait, not before.
- The skill guide's body was rewritten for the new lane, but that skill injects its
  description only, so the body reaches a model through `read_skill` alone. The
  load-bearing instruction therefore travels in runtime text: the tail appended to
  every returned report names `resolve_dag_node`, the paused deadline, and the
  re-send. The guide body is supplementary.
- Two `ruff` findings exist at the merge base, in `demos/oncall_heat2d/arena/heat2d.py`
  (formatting) and `benchmarks/appworld/tool.py` (import order). Both are outside this
  diff and are not fixed here.

## Gaps

- The web and TUI panels were not observed for this change. The transport used for
  the live run has no panel, and the graph panel is fed by live events only, so
  seeing it costs a full interactive run. The events themselves are unchanged by this
  branch.
- The live run predates the later commits on this branch, so "a resolve returns the
  run's final summary" and the re-send of a handed-but-undecided report are covered by
  tests and not by an observed session.
## Summary

A DAG node that falls short of its judge suspends and waits for the main agent to
decide whether to continue or abandon it. That works for a backgrounded run, whose
submitting turn has already returned, and structurally cannot work for a blocking
one: the call is still on the stack and a conversation's lane is serial, so the turn
that would call `resolve_dag_node` cannot start until this one ends. The blocking
lane therefore asked the person watching the call instead, over the confirm gate's
round trip.

This replaces that lane with a route to the agent. A blocking `run_subagent_dag`
returns the first suspended node's report as its own tool result, and
`resolve_dag_node` then blocks for the next report or for the run's final result. The
graph keeps running throughout, and the agent handles the report inside the turn it
already owns.

Three decisions shape it:

- An outbox carries a run's events to whichever tool call is waiting on it. It hands
  over one report at a time, buffers what arrives with no taker, and keeps the final
  result sticky so a later take still sees it. A report it has handed over stays owed
  until a decision retires it: taking a report is not deciding it, the node is still
  open on the desk, and a turn that ends holding one leaves it to be re-sent exactly
  like one nobody took.
- A run is bound while the turn that started it is alive and released when that turn
  ends. While bound there is no adjudication deadline at all: the live turn is the
  liveness signal, and the agent may be asking the user something only they know. The
  clock starts at the release, and a report the turn left unanswered is re-sent as an
  injected message, exactly as a backgrounded run's would be. A cancelled turn drops
  its unanswered reports instead of re-sending them.
- Cancelling the waiting tool call hard-cancels the run. A background graph still
  survives its caller.

The adjudication window changes for a related reason, and stands on its own: it now
measures silence rather than the whole round, restarting on every decision. Reports
reach the agent one turn at a time because a lane is serial, so a single deadline for
the round charged each node for the time its report spent queued behind another
node's, and far enough down the queue a node timed out having never been asked. The
bound worst case is still one window per decision rather than one per node.

The report the runner hands to an announcer carries one keyword, `awaiting_decision`,
saying whether the node is actually waiting on a decision. It is required and has no
default: the wrong value is invisible at the call site, so a default would hand the next
caller that forgets a terminal report announced as a question.

Who acts on that keyword is the outbox, because the answer depends on the lane and the
outbox is the only object that knows the lane. Bound, a notification is dropped: the
blocking call is still there and the run's summary is what it will be handed, so
announcing the same news again would put an unanswerable question beside it. Released,
both kinds announce -- there is no such call any more, and a dropped notification would
reach nobody until the whole graph finished, which is the one thing a released run is not
meant to do differently from a backgrounded one.

Also fixed, because the feature's only real call path runs through it: the `tool_call`
meta-tool forwarded a target's name but not its arguments to the blocking verdict, so
`resolve_dag_node.blocking_for` always saw an empty mapping and answered False. The
registry then applied its default ceiling to a wait that has none, and that
cancellation reads inside the tool as the user stopping the agent, which aborted the
run. Every other target's verdict ignores the extra argument.

Three things came out of review and a pass over the failure paths around this lane.

A decision on one node re-sends what the call is still owed. Two nodes can suspend at
once; one report goes to the blocking call and the other queues, and the agent may
decide either -- the report it holds names what is blocked behind it and `dag_status`
lists the rest. Deciding the queued one parked the call with nothing able to wake it:
the runner waits for every open node and begins waiting only once nothing else can run,
so no report could be produced; the run cannot finish while a node is open, so no final
could arrive; and a bound run has no deadline. Cancelling was the only exit, and that
aborts the whole graph. `Outbox.take` now settles the handed tray's debt to whoever
asks next, after the queue and a landed final.

A failed report delivery is retried, on both legs that have one. The announcer is a
transport, and an injected turn can lose a race with a gateway restart or a busy submit
queue; one raise used to fail the node outright and name the transport as its outcome.
Three attempts with a growing backoff, bounded because a bound run has no adjudication
deadline. It is safe only while a raise means nothing was delivered -- the injection is
the last step that can fail, and the marker emit after it swallows its own failure -- so
that invariant is pinned rather than assumed.

A post-release `put_final` was the third, reported once the second landed. It awaited the
final announcer directly, from the run's detached task with nothing around it, and that
task's done callbacks only drop the run's indexes -- neither retrieves
`task.exception()` -- so a raise there lost the run's result and left an
unretrieved-exception warning. The backgrounded lane catches and logs the same failure,
so the two lanes disagreed about a failure they share, on the lane this branch
introduced. It now takes the step the drain's buffered final already takes.

`release(flush=True)`'s drain is the second leg and was reported as a blocker after the
first landed: it announced once, the event is popped from its tray before the announce
and nothing re-drains, so a single transient dropped a report for good -- and releasing
starts the adjudication deadline, so that node could then only expire unseen. The retry
therefore lives in `dag_adjudication`, which imports nothing from raven and is the layer
both legs can share; the outbox cannot import the runner without a cycle. The drain runs
every owed event through it, both trays and the buffered final with them. Retaining a
failed event instead was the other option offered and does not work: nothing re-drains
after release, so a retained event is a leak rather than a later attempt.

The judge's wall clock has to contain its own retry ladder. `verdict_timeout_seconds`
bounded one judge call at 30s, close enough to a few slow calls that it truncated the
ladder inside it (`chat_with_retry`: four attempts, roughly 7s of jittered backoff)
instead of bounding it. A truncation lands on the fail-open, so an unjudged node is
recorded as accomplished and its output reaches its dependents -- the failure this
judgement step exists to catch. Raised to 180s.

Removed with the old lane: the person-asking adjudication route, the desk's report
storage, and the per-node answered-in-turn bookkeeping. `AskUserTool.ask_direct`
itself stays, with its other callers untouched. The superseded design doc keeps its
paragraphs and gains a marker naming what is withdrawn and where the replacement is.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Every command below was run at the head of this branch.

- `uv run --frozen --python 3.12 --all-extras pytest tests/ -q --ignore=tests/integration`
  -> `18240 passed, 69 skipped`, plus the environment-dependent failure named below. `--all-extras` is load-bearing: without it
  the workspace member holding roughly 4300 tests is absent and the run still reports
  green.
- The nine test files this branch changes, run together: `654 passed`.
- One test in `tests/test_cli_doctor_commands.py` fails here and is not this branch's.
  It reads a path back out of rich-wrapped stdout, so it depends on the temp directory
  the harness hands it: on the same tree and the same commit it passes with an explicit
  `--basetemp` and fails on the default one, and it has been green and red across runs
  of identical trees today. This branch touches none of the three files it exercises and
  their content is byte-identical to the base, so the merge base fails it for the same
  reason. An earlier revision of this description called it an ordering flake under
  parallel collection; that was wrong, and the basetemp experiment is what disproved it.
- Two invariants this lane rested on unstated are now pinned, each proved by the mutation
  it exists to catch: holding a dispatch slot across the adjudication wait, and adding a
  yield between the resolve and the take. Both first drafts were weaker than they read.
  The slot test passed against its own mutant, sampling the gate before the mutant could
  take it, and is reshaped to settle first. The yield test drove the low-level pair
  itself, so the yield it claimed to guard could be added to the model-facing
  `ResolveDagNodeTool.execute` and leave it green; it now runs the decision through that
  real control tool over the real outbox, and goes red on that exact insertion with the
  message a lost result produces -- the node reported as about to run again instead of
  the finished run's summary. The comment at `_retire` names the test that actually
  drives the call site.
- The re-send fix is load-bearing three ways: removing it puts the three discriminating
  cases red (outbox unit, the `resolve_node`/`await_run` pair, and the real control tool)
  and nothing else; an off-by-one on the delivery bound is caught by the exact attempt
  count; and moving the re-send ahead of the queue turns the ordering guard red. That
  ordering guard is a guard, not a discriminator -- removing the re-send leaves it green,
  and two older cases already read the same order -- and its docstring says so.
- The claim at the outbox's retirement is structural, so its guard is too: in
  `ResolveDagNodeTool.execute` the first await after the resolve must be the take.
  `await asyncio.sleep(0)` there turns it red where the delivery regression stays green,
  because a single tick does not let the run finish and the outbox is still indexed. The
  behavioural test holds the other half -- a yield long enough to lose the race -- and
  each text now claims only what its test holds.
- The error end of `run_turn` releases and flushes, which nothing asserted: an
  `except BaseException` setting `flush = False` used to pass. It is the exit with a
  decision in it, since an ordinary error is not the user stopping the agent.
- The post-release final has three cases and only two discriminate: reverting the routing
  turns the escape and the transient red, while the stopped case stays green because the
  previous code honoured `stopped` too. That one guards the risk the routing introduces --
  that the shared step might not honour it -- and goes red when the step stops honouring
  it, alongside the older case reading the same rule. Its docstring says which it is.
- The drain's retry is load-bearing by the same substitution: putting the single announce
  back turns the three release cases red (both trays, and the bound one that also pins that
  a dead event does not strand the next) and nothing else.
- The delivery retry rests on the announce being all-or-nothing, so that is pinned in the
  manager suite rather than assumed: adding a raising step after the injection turns
  `test_a_failing_delivery_marker_does_not_fail_the_announce` red, which is the test that
  stands between a retry and a double injection.
- The lane-routing fix is load-bearing by an exact substitution: swapping the order of
  the released check and the verdict check inside `put_report` -- which is precisely the
  defect -- puts the two cases written for it red and nothing else.
- The report-retention fix was shown to be load-bearing by neutering it and re-running:
  disabling the re-send puts three tests red, including the single-node case, where
  there is no sibling report to stand in for the lost one; replacing the per-node
  retirement with a blanket one puts the two-node case red. That second substitution
  left the whole file green until the two-node case was written, so the first attempt at
  pinning the keying is not among the evidence. The remaining new cases are guards that
  hold either way and say so.
- `ruff format --check` and `ruff check` over the Python files this branch touches
  -> already formatted, all checks passed.
- `scripts/check_commit_messages.py <base>..HEAD` -> exit 0, and the messages carry no
  non-ASCII character.
- `uv run --frozen --python 3.12 lint-imports` -> 8 contracts kept, 0 broken. Worth
  stating because the pipeline does not run it: CI here is the Python unit suite only,
  so the layering contracts are checked locally or not at all.
- Rebased four times. Two replayed clean (the playbook/DAG-validation commits, and the
  raven-research subagent commits, which touch no file this branch does). The fourth hit
  one real conflict, in `AgentLoop.run_turn`: the base's config-preferences commit added a
  turn-boundary `_maybe_promote_deep_research()` and a third `turn_scope()` manager to the
  same `with`, while this branch wrapped that block in the try/finally that releases a
  foreground run. Composed rather than chosen -- the promotion sits inside the try, so the
  base's "before the freeze" ordering holds and this branch's "every way a turn ends passes
  here" still does too. Both sides' tests were run together over the composed region
  (`787 passed`), because a semantic conflict there raises nothing on its own.
- Rebased onto a base that now carries the sibling MR (`!465`), which extends the same
  `announce_dag_exception` seam. Thirty-seven conflict hunks, and one of them had to be
  composed rather than chosen: `deliverable` reads
  `announce_exception is not None and answerable`, because taking this branch's side
  alone would have left `answerable` unread and turned that MR's fail-fast into dead
  code -- a node with no `tool_call` route would suspend and wait out its whole timeout
  again. Fifteen hunks were the keyword collision the two branches had already aligned
  on, which is why they collapsed to deleting a duplicate declaration.
- Rebase proof at the current head: `git range-diff` reports all replayed commits as
  `=`, so the replay changed no content. The commits the base gained in the last round
  touch no file this branch touches (overlap: zero), and the earlier round's five
  overlapping files did not conflict either.
- Driven live over `raven acp` against an isolated agent home: a blocking call returns
  a node's report while the graph runs, the bound `resolve_dag_node` waits and hands
  over the next event, the forwarded `tool_call` carries `blocking: true`, the release
  fires when the turn ends, and the continuation prompt lands on disk in the expected
  shape.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

User-visible behaviour change, in one place: a `background: false` call that
previously prompted the person now returns a report to the model, and the model is
expected to answer it with `resolve_dag_node`. A host with no such call in flight is
unaffected, and a backgrounded run behaves exactly as before.

The new wait has no timer by construction, which is the point of it, so the guards
that bound it are worth naming: the tool's own blocking verdict keeps the registry
ceiling off the call, the run is released when its turn ends whether or not it was
answered, and cancelling the call cancels the run. A node whose report is never
answered fails on the window after the release rather than waiting forever.

One default moves: `subagent_dag.verdict_timeout_seconds`, 30s to 180s. A judge call
that hangs now delays its node for up to three minutes rather than thirty seconds,
which is the cost of not truncating the retry ladder into the fail-open. The same
field also bounds `describe_failure`, the wording-only call for a node that has
already failed, so that one inherits the longer ceiling; splitting the two knobs is
left alone rather than decided here.

The delivery retry adds up to about 1.5s of backoff before a node is failed on an
undeliverable report, on the path where the transport is already broken. The release
drain pays the same per undeliverable event, in the turn's teardown, where a failure was
already logged rather than raised.

Rollback is a revert of the range. Nothing is persisted in a new format and no
on-disk layout changed, so a revert needs no migration.

## Related Issues

N/A

## Checked and deliberately not changed

- The report an agent receives is assembled at suspension time and states the
  window as `deciding within Ns, restarted by each decision`. It does not restate
  the remaining budget, which would be stale by the time it is read.
- `_await_adjudications` reads its budget before the wait and re-reads the clock
  after it; the bound check is likewise taken after the wait, not before.
- The skill guide's body was rewritten for the new lane, but that skill injects its
  description only, so the body reaches a model through `read_skill` alone. The
  load-bearing instruction therefore travels in runtime text: the tail appended to
  every returned report names `resolve_dag_node`, the paused deadline, and the
  re-send. The guide body is supplementary.
- Two `ruff` findings exist at the merge base, in `demos/oncall_heat2d/arena/heat2d.py`
  (formatting) and `benchmarks/appworld/tool.py` (import order). Both are outside this
  diff and are not fixed here.

## Gaps

- The web and TUI panels were not observed for this change. The transport used for
  the live run has no panel, and the graph panel is fed by live events only, so
  seeing it costs a full interactive run. The events themselves are unchanged by this
  branch.
- The live run predates the later commits on this branch, so "a resolve returns the
  run's final summary" and the re-send of a handed-but-undecided report are covered by
  tests and not by an observed session.
…icks

## Summary

`web_search` and `web_fetch` gain a vendor the deployment picks, on both sides
of the vendoring seam.

The vendored research sub-agent gets four new backends: Tavily, Exa and
Firecrawl serve search and pages, Brave Search serves search only. Each is a
spec-table entry, a request builder and a response normaliser folded into the
Serper shape the render path already reads, so nothing downstream branches per
vendor. The Serper and Jina wire traffic is byte-identical to before, which is
why no `drFlow.version` bump is owed: the default distribution is untouched
and the provider field sits under `tools.web`, outside the label.

The host raven then gets the same layout, because the choice was previously
reachable only by hand-editing the sub-agent folder's config.
`tools.web.providers.<vendor>.apiKey` holds one key per vendor, and
`tools.web.search.provider` / `tools.web.fetch.provider` select the backends.
A vendor serving both tools is pasted once. The pre-vendor
`tools.web.search.apiKey` and `tools.web.jinaApiKey` are still read, after the
vendor slot, so no existing config changes meaning; new writes go to the
vendor slot.

Every entrance follows the choice: the onboarding wizard asks for the vendor
before the key (`--search-provider`, `--fetch-provider`, `--search-api-key`
and `--fetch-api-key` are the scripted form, and the two legacy flags keep
their meaning), the settings page locates the key slot from the selected
vendor and offers the pick beside the field, `settings.set` accepts the two
provider keys and the eight slots, `raven doctor` names the selected vendor's
slot and sign-up page, and `~/.raven/env` mirrors every vendor key. A page
reader selected without its key falls back to Jina at registration and the log
says so, because `web_fetch` is always offered; `web_search` keeps the gate
that withholds it entirely.

Three defects found while reviewing the above, each fixed with a red-checked
test:

- A status error rendered httpx's own message, which repeats the request URL.
  SerpApi authenticates by query parameter, so a 401 handed the key to the
  model and to the log. All three copies of these tools now report the vendor
  and the status only, and the search ledger's row -- which lands on disk --
  records the status without the request.
- The launcher copied the host's vendor choice without its key, so a host that
  degrades to Jina and runs produced a sub-agent that exits on its own gate. A
  choice is inherited only when this checkout can run it.
- `--search-provider bing` reached the config writer and surfaced as a
  validation traceback. Both flags are checked against the vendor tables
  before anything is written.

One surface the choice does not reach, stated because it looks like it should:
the in-repo research pilot (`agents/raven-research/plugins/research-flow/`)
keeps its own Serper and Jina tools, keyed by
`plugins.config["research-flow"].search.apiKey` with a `SERPER_API_KEY`
fallback, and has no provider field. That island predates this branch -- the
wizard never wrote that key either -- so nothing regresses, but a deployment
that picks Tavily still needs a Serper key for the research plugin's own
`web_search`. Giving the plugin the vendor table means porting the four
backends into a second, differently shaped implementation; it is not in this
change.

One name, one vocabulary gap and the stale counts fixed alongside: the loop's Serper carrier is
`search_api_key` rather than `brave_api_key`, because `brave` is now a vendor
of its own and the old name pointed at the wrong one; the runtime `CONTEXT.md`
gains Web Vendor and the two provider picks, which the vendored fork's context
defined and the trunk did not; and the prose that counted the mirror at two
keys is corrected in all three places it was written -- the onboarding note,
the `settings.set` comment that gates the refresh, and the fixture and test
docstrings that describe it. The mirror test itself covered only those two
pre-vendor leaves, which is the set the mirror held before this branch, so it
now runs over every key in `MIRRORED_KEYS`: the vendor half of the case list is
derived from the same mapping the mirror is built from, and a separate
assertion pins the case list against that tuple. The in-repo research plugin's
own web module said its two fixed endpoints were what the built-ins use
"exactly as the built-ins do", which this branch made false; it now says the
built-ins route through a chosen vendor, and what that costs a host on another
one.

Two things checked across the copies and deliberately left alone. The vendored
fork keeps upstream's `brave_api_key` spelling: the patch changes what fills it
(`selected_search_key`) but not the name, and the F2 hazard behind the trunk
rename -- a per-vendor lookup reading a field named for a different vendor --
does not exist there, because that launcher hands `api_key` and `provider` to
one tool explicitly. Renaming would turn ten upstream context lines into
additions in the patches for no behaviour change. Separately,
`benchmarks/clawbench/stream.py` still calls `AgentLoop(brave_api_key=...)`;
that call is already dead at the merge target, where `__init__` takes only the
wiring bundles and rejects `max_iterations`, `web_proxy` and `exec_config`
alongside it. It is pre-existing rot in a tree that is in neither
`PYTHON_LINT_TARGETS` nor `testpaths`, and renaming one of its seven invalid
keyword arguments would not make it run.

Review found one defect this branch introduced that is worth naming here,
because it is a credential and not a behaviour: SerpApi takes its key as a
query parameter, so it rides in every request URL, and httpx logs each request
at INFO on the success path. With the gateway sink at its default level an
ordinary search wrote the key verbatim into `gateway.log`, which is created at
the process umask (`0644` here, `0664` on a reviewer's box) while `config.json`
and the env mirror are deliberately `0600`. The branch had already closed the
model-facing half of exactly this hazard, in a comment twenty-eight lines above
the line that leaked; the log half was open.

The redaction now sits on loguru's patcher in `cli/_log_file.py` rather than on
the stdlib bridge, because that bridge carries third-party records only and
raven formats URLs through loguru directly -- the `web_fetch` error paths
render a URL that is user-supplied and can carry a key of its own. One hook
covers both lanes, in the file that already refuses `diagnose=True` for the
same reason, and the URL keeps its host, path, query and status.

Worth knowing for whoever reads this next: whether that record is emitted at
all is currently decided by a third-party import side effect. `litellm`'s
`_logging` module raises the `httpx` logger to WARNING when it is imported, and
`raven.providers.factory` does not import litellm eagerly, so the answer
depends on which provider a deployment loads. Re-levelling that logger was the
alternative to redacting and was rejected for the same reason: a security
property must not rest on it.

## Type

- [x] Feature
- [ ] Fix
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Rebased twice: onto the v0.2.0 tip after the vendored port landed, so the patch
set here is the port's six rather than the four this branch was exported
against, and again onto `4e7aa21e` after six further commits landed on the
target.

That second rebase was not a replay. `feat(*): config preferences land on the
turn boundary` moved the `web_search` gate off registration and onto the
withheld axis: the tool is registered whatever the config holds and kept out of
the offered set while nothing resolves a key, which is what lets a key pasted
mid-run surface it without a restart. Its predicate named `SERPER_API_KEY` and
its live reader read the pre-vendor `tools.web.search` leaf -- the whole story
before this branch, and wrong with a vendor selected: a deployment on Tavily,
keyed by `TAVILY_API_KEY` alone, had `web_search` withheld from every turn. The
predicate now asks the registered tool, which resolves the selected vendor's
key from the live reader or that vendor's own variable, so one answer serves
instead of two that have to agree. The reader keeps the live file read for the
pre-vendor leaf, Serper's alone and therefore consulted only when Serper is the
selection, and falls back to the boot table otherwise -- so a key pasted into
another vendor's slot reaches the tool on the next process rather than the next
call, which is the target's limit and not a new one. The defect was reproduced
by restoring the vendor-blind predicate and watching
`test_the_selected_vendors_key_is_what_offers_web_search` go red on the
env-var case.

Three of this branch's own claims were re-checked against what that base added
rather than assumed to survive it. Two tests were written against the
registration gate and now assert the offer; the `ext.list` row for an
unconfigured `web_search` names the vendor slot rather than the pre-vendor leaf;
and the `CONTEXT.md` entry that used one word for both mechanisms now separates
withholding from declining to register, because the sub-agent backend keeps the
older gate and the vendored-invariant guard still requires that of it. The live
reader also still read the attribute this branch renamed, so the no-section
lane raised instead of answering. The two
patches whose file sets this branch touches -- `web_search_providers.patch` and
`acp_session_modes.patch`, which owns `raven/config/schema.py` under the new
partition -- are re-derived from pristine upstream `a903a424` rather than
merged: a patch file is the whole base-to-checkout diff of its files, so a text
merge of two derivations describes neither tree. The derivation was checked by
rebuilding the tip's own `web_search_providers.patch` with it first, byte for
byte, before it was used here.

All six were then applied to a pristine `a903a424` in an out-of-repo scratch
copy and the result diffed against the checkout:

```
git apply -p1 --check <each of the six>    all clean
diff -rq <replay> <checkout>               no output
```

The MCP-bridge caveat this description used to carry is gone: the port records
that surface in `acp_per_session_loops.patch`, so the replay now reproduces the
whole tree rather than every file the patches own.

Host unit suite at the head, and the same suite at the merge target in a
separate worktree on the same interpreter, so the comparison is failing IDs
rather than counts:

```
uv run pytest -q
  branch: 5 failed, 18269 passed, 68 skipped
  target: 5 failed, 18182 passed, 68 skipped
  failing IDs: identical, 0 introduced by this branch
```

Five failures on both sides, disclosed rather than fixed here:
`test_doctor_answers_where_the_memories_are`, and four NAT64 cases in
`test_security_network.py` (`64:ff9b:1::7f00:1` and `64:ff9b:1::a9fe:a9fe`,
hostname and redirect each).

The vendored checkout, its own suite in its own environment:

```
uv run pytest -q tests            (in subagents/raven-research/Raven-X)
  5899 passed, 78 skipped
```

That process then exits 139 during interpreter teardown, after the summary
line. It is not attributed and reproduces on the target; the suite is green as
a set of tests and not as a process.

Front ends and gates, all at the head:

```
npx vitest run                    (in ui-web)   91 files, 1301 passed
make lint-python                                ruff check + format, 1738 files
make lint-imports                               8 contracts kept, 0 broken
make lint-ui                                    gen:check (160 methods), type-check
make lint-tui                                   eslint, lint:rpc, lint:i18n, type-check
make check-vendored-subagents                   pass
make check-vendored-invariants                  pass
make check-large-files                          pass
scripts/check_commit_messages.py                pass, 33 commits
```

`make lint-bridge` did not run: this machine has no `tsc` on PATH and no
`bridge/node_modules`. The branch changes no file under `bridge/`.

Every defect listed above was reproduced before being fixed, and each fix was
reverted afterwards to watch its test go red -- for the log redaction, removing
the patcher line turns six cases in `test_cli_log_file.py` red, and removing
`extra="forbid"` turns the typo case red. One of the two new schema cases,
`test_forbidding_extras_keeps_both_key_spellings`, passes either way by design:
it is the guard's other direction, there to catch a change that makes `forbid`
reject the config file's own snake_case spelling. `tsc --listFiles` was checked
to name this worktree's own `SettingsPage.tsx`, so the front-end results are
about the code in this branch and not another checkout's copy.

The httpx test sets that logger's level itself and restores it. Inheriting it
made every assertion in the case vacuously true whenever a sibling test had
imported litellm -- the file passed alone and failed beside
`test_cli_gateway_commands.py` -- which is how the import side effect above
came to light. The `assert "HTTP Request" in text` guard is what caught it.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

Behaviour with no config change: none. The defaults stay Serper and Jina, the
requests they send are unchanged, and the pre-vendor key leaves are still
read, so an existing deployment sees the same tools with the same traffic.

`settings.set` gains the two provider keys and the eight vendor slots. That
surface is reachable from any RPC client with no confirmation step, so it now
lets a caller switch which vendor the tools call. It does not let a caller
choose where a key is sent: each vendor's endpoint is a literal in the tool,
and a key only ever reaches its own vendor. The credential slots are the same
low-risk shape as the two keys already on that whitelist.

Log content changes: a URL query parameter named like a credential
(`api_key`, `key`, `token`, `secret`, `password`, `access_token`, `auth`) is
written as `<redacted>` in the persisted file and in the stderr mirror. Nothing
in the tree parses those files -- the only other references to them are paths
and levels -- but an operator's own grep for a full URL will now match a
redacted one. The redaction removes the URL-borne credential specifically; it
does not make the log a safe place for a secret in general, and the file's mode
is unchanged.

`tools.web.providers` now rejects an unknown member instead of dropping it, so
a hand-edited `providers.tavili` fails config validation with the key named
rather than leaving Tavily unkeyed and its tool silently withheld. The section
is new in this branch, so no existing file can start failing, and neither
writer can produce one: `settings.set` refuses a key off its allowlist and
`set_web_provider_key` rejects an unknown vendor before validating. Rolling
that commit back restores the silent-drop behaviour.

Rollback is per commit; nothing migrates on disk, and a config written by this
branch is read by the previous one for everything except the new fields, which
the previous schema drops on load.

Two things checked this round and deliberately not fixed here. The same
credential-in-log defect exists in the A side:
`subagents/raven-research/Raven-X/raven/agent/tools/web.py:1087` passes
`api_key` in the SerpApi query params, and that fork's own `cli/_log_file.py`
has the same stdlib funnel with no redaction. It is on the merge target too, so
this branch did not introduce it, and closing it means a seventh patch over
pristine `a903a424` in a file none of the six touch plus a
`subagents/TREE_HASHES` refresh -- widening this MR into the frozen tree for a
defect it did not create. The other four vendored forks ship no SerpApi path,
and the B-side plugin runs on the installed raven, so it takes this fix.
Separately, the `gateway.log` mode is left as the sink creates it; tightening
it affects anything reading those logs as a non-owner and belongs in its own
change.

## Related Issues

N/A
…icks

## Summary

`web_search` and `web_fetch` gain a vendor the deployment picks, on both sides
of the vendoring seam.

The vendored research sub-agent gets four new backends: Tavily, Exa and
Firecrawl serve search and pages, Brave Search serves search only. Each is a
spec-table entry, a request builder and a response normaliser folded into the
Serper shape the render path already reads, so nothing downstream branches per
vendor. The Serper and Jina wire traffic is byte-identical to before, which is
why no `drFlow.version` bump is owed: the default distribution is untouched
and the provider field sits under `tools.web`, outside the label.

The host raven then gets the same layout, because the choice was previously
reachable only by hand-editing the sub-agent folder's config.
`tools.web.providers.<vendor>.apiKey` holds one key per vendor, and
`tools.web.search.provider` / `tools.web.fetch.provider` select the backends.
A vendor serving both tools is pasted once. The pre-vendor
`tools.web.search.apiKey` and `tools.web.jinaApiKey` are still read, after the
vendor slot, so no existing config changes meaning; new writes go to the
vendor slot.

Every entrance follows the choice: the onboarding wizard asks for the vendor
before the key (`--search-provider`, `--fetch-provider`, `--search-api-key`
and `--fetch-api-key` are the scripted form, and the two legacy flags keep
their meaning), the settings page locates the key slot from the selected
vendor and offers the pick beside the field, `settings.set` accepts the two
provider keys and the eight slots, `raven doctor` names the selected vendor's
slot and sign-up page, and `~/.raven/env` mirrors every vendor key. A page
reader selected without its key falls back to Jina at registration and the log
says so, because `web_fetch` is always offered; `web_search` keeps the gate
that withholds it entirely.

Three defects found while reviewing the above, each fixed with a red-checked
test:

- A status error rendered httpx's own message, which repeats the request URL.
  SerpApi authenticates by query parameter, so a 401 handed the key to the
  model and to the log. All three copies of these tools now report the vendor
  and the status only, and the search ledger's row -- which lands on disk --
  records the status without the request.
- The launcher copied the host's vendor choice without its key, so a host that
  degrades to Jina and runs produced a sub-agent that exits on its own gate. A
  choice is inherited only when this checkout can run it.
- `--search-provider bing` reached the config writer and surfaced as a
  validation traceback. Both flags are checked against the vendor tables
  before anything is written.

One surface the choice does not reach, stated because it looks like it should:
the in-repo research pilot (`agents/raven-research/plugins/research-flow/`)
keeps its own Serper and Jina tools, keyed by
`plugins.config["research-flow"].search.apiKey` with a `SERPER_API_KEY`
fallback, and has no provider field. That island predates this branch -- the
wizard never wrote that key either -- so nothing regresses, but a deployment
that picks Tavily still needs a Serper key for the research plugin's own
`web_search`. Giving the plugin the vendor table means porting the four
backends into a second, differently shaped implementation; it is not in this
change.

One name, one vocabulary gap and the stale counts fixed alongside: the loop's Serper carrier is
`search_api_key` rather than `brave_api_key`, because `brave` is now a vendor
of its own and the old name pointed at the wrong one; the runtime `CONTEXT.md`
gains Web Vendor and the two provider picks, which the vendored fork's context
defined and the trunk did not; and the prose that counted the mirror at two
keys is corrected in all three places it was written -- the onboarding note,
the `settings.set` comment that gates the refresh, and the fixture and test
docstrings that describe it. The mirror test itself covered only those two
pre-vendor leaves, which is the set the mirror held before this branch, so it
now runs over every key in `MIRRORED_KEYS`: the vendor half of the case list is
derived from the same mapping the mirror is built from, and a separate
assertion pins the case list against that tuple. The in-repo research plugin's
own web module said its two fixed endpoints were what the built-ins use
"exactly as the built-ins do", which this branch made false; it now says the
built-ins route through a chosen vendor, and what that costs a host on another
one.

Two things checked across the copies and deliberately left alone. The vendored
fork keeps upstream's `brave_api_key` spelling: the patch changes what fills it
(`selected_search_key`) but not the name, and the F2 hazard behind the trunk
rename -- a per-vendor lookup reading a field named for a different vendor --
does not exist there, because that launcher hands `api_key` and `provider` to
one tool explicitly. Renaming would turn ten upstream context lines into
additions in the patches for no behaviour change. Separately,
`benchmarks/clawbench/stream.py` still calls `AgentLoop(brave_api_key=...)`;
that call is already dead at the merge target, where `__init__` takes only the
wiring bundles and rejects `max_iterations`, `web_proxy` and `exec_config`
alongside it. It is pre-existing rot in a tree that is in neither
`PYTHON_LINT_TARGETS` nor `testpaths`, and renaming one of its seven invalid
keyword arguments would not make it run.

Review found one defect this branch introduced that is worth naming here,
because it is a credential and not a behaviour: SerpApi takes its key as a
query parameter, so it rides in every request URL, and httpx logs each request
at INFO on the success path. With the gateway sink at its default level an
ordinary search wrote the key verbatim into `gateway.log`, which is created at
the process umask (`0644` here, `0664` on a reviewer's box) while `config.json`
and the env mirror are deliberately `0600`. The branch had already closed the
model-facing half of exactly this hazard, in a comment twenty-eight lines above
the line that leaked; the log half was open.

The redaction now sits on loguru's patcher in `cli/_log_file.py` rather than on
the stdlib bridge, because that bridge carries third-party records only and
raven formats URLs through loguru directly -- the `web_fetch` error paths
render a URL that is user-supplied and can carry a key of its own. One hook
covers both lanes, in the file that already refuses `diagnose=True` for the
same reason, and the URL keeps its host, path, query and status.

Worth knowing for whoever reads this next: whether that record is emitted at
all is currently decided by a third-party import side effect. `litellm`'s
`_logging` module raises the `httpx` logger to WARNING when it is imported, and
`raven.providers.factory` does not import litellm eagerly, so the answer
depends on which provider a deployment loads. Re-levelling that logger was the
alternative to redacting and was rejected for the same reason: a security
property must not rest on it.

## Type

- [x] Feature
- [ ] Fix
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Rebased twice: onto the v0.2.0 tip after the vendored port landed, so the patch
set here is the port's six rather than the four this branch was exported
against, and again onto `4e7aa21e` after six further commits landed on the
target.

That second rebase was not a replay. `feat(*): config preferences land on the
turn boundary` moved the `web_search` gate off registration and onto the
withheld axis: the tool is registered whatever the config holds and kept out of
the offered set while nothing resolves a key, which is what lets a key pasted
mid-run surface it without a restart. Its predicate named `SERPER_API_KEY` and
its live reader read the pre-vendor `tools.web.search` leaf -- the whole story
before this branch, and wrong with a vendor selected: a deployment on Tavily,
keyed by `TAVILY_API_KEY` alone, had `web_search` withheld from every turn. The
predicate now asks the registered tool, which resolves the selected vendor's
key from the live reader or that vendor's own variable, so one answer serves
instead of two that have to agree. The reader keeps the live file read for the
pre-vendor leaf, Serper's alone and therefore consulted only when Serper is the
selection, and falls back to the boot table otherwise -- so a key pasted into
another vendor's slot reaches the tool on the next process rather than the next
call, which is the target's limit and not a new one. The defect was reproduced
by restoring the vendor-blind predicate and watching
`test_the_selected_vendors_key_is_what_offers_web_search` go red on the
env-var case.

Three of this branch's own claims were re-checked against what that base added
rather than assumed to survive it. Two tests were written against the
registration gate and now assert the offer; the `ext.list` row for an
unconfigured `web_search` names the vendor slot rather than the pre-vendor leaf;
and the `CONTEXT.md` entry that used one word for both mechanisms now separates
withholding from declining to register, because the sub-agent backend keeps the
older gate and the vendored-invariant guard still requires that of it. The live
reader also still read the attribute this branch renamed, so the no-section
lane raised instead of answering. The two
patches whose file sets this branch touches -- `web_search_providers.patch` and
`acp_session_modes.patch`, which owns `raven/config/schema.py` under the new
partition -- are re-derived from pristine upstream `a903a424` rather than
merged: a patch file is the whole base-to-checkout diff of its files, so a text
merge of two derivations describes neither tree. The derivation was checked by
rebuilding the tip's own `web_search_providers.patch` with it first, byte for
byte, before it was used here.

All six were then applied to a pristine `a903a424` in an out-of-repo scratch
copy and the result diffed against the checkout:

```
git apply -p1 --check <each of the six>    all clean
diff -rq <replay> <checkout>               no output
```

The MCP-bridge caveat this description used to carry is gone: the port records
that surface in `acp_per_session_loops.patch`, so the replay now reproduces the
whole tree rather than every file the patches own.

Host unit suite at the head, and the same suite at the merge target in a
separate worktree on the same interpreter, so the comparison is failing IDs
rather than counts:

```
uv run pytest -q
  branch: 5 failed, 18269 passed, 68 skipped
  target: 5 failed, 18182 passed, 68 skipped
  failing IDs: identical, 0 introduced by this branch
```

Five failures on both sides, disclosed rather than fixed here:
`test_doctor_answers_where_the_memories_are`, and four NAT64 cases in
`test_security_network.py` (`64:ff9b:1::7f00:1` and `64:ff9b:1::a9fe:a9fe`,
hostname and redirect each).

The vendored checkout, its own suite in its own environment:

```
uv run pytest -q tests            (in subagents/raven-research/Raven-X)
  5899 passed, 78 skipped
```

That process then exits 139 during interpreter teardown, after the summary
line. It is not attributed and reproduces on the target; the suite is green as
a set of tests and not as a process.

Front ends and gates, all at the head:

```
npx vitest run                    (in ui-web)   91 files, 1301 passed
make lint-python                                ruff check + format, 1738 files
make lint-imports                               8 contracts kept, 0 broken
make lint-ui                                    gen:check (160 methods), type-check
make lint-tui                                   eslint, lint:rpc, lint:i18n, type-check
make check-vendored-subagents                   pass
make check-vendored-invariants                  pass
make check-large-files                          pass
scripts/check_commit_messages.py                pass, 33 commits
```

`make lint-bridge` did not run: this machine has no `tsc` on PATH and no
`bridge/node_modules`. The branch changes no file under `bridge/`.

Every defect listed above was reproduced before being fixed, and each fix was
reverted afterwards to watch its test go red -- for the log redaction, removing
the patcher line turns six cases in `test_cli_log_file.py` red, and removing
`extra="forbid"` turns the typo case red. One of the two new schema cases,
`test_forbidding_extras_keeps_both_key_spellings`, passes either way by design:
it is the guard's other direction, there to catch a change that makes `forbid`
reject the config file's own snake_case spelling. `tsc --listFiles` was checked
to name this worktree's own `SettingsPage.tsx`, so the front-end results are
about the code in this branch and not another checkout's copy.

The httpx test sets that logger's level itself and restores it. Inheriting it
made every assertion in the case vacuously true whenever a sibling test had
imported litellm -- the file passed alone and failed beside
`test_cli_gateway_commands.py` -- which is how the import side effect above
came to light. The `assert "HTTP Request" in text` guard is what caught it.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

Behaviour with no config change: none. The defaults stay Serper and Jina, the
requests they send are unchanged, and the pre-vendor key leaves are still
read, so an existing deployment sees the same tools with the same traffic.

`settings.set` gains the two provider keys and the eight vendor slots. That
surface is reachable from any RPC client with no confirmation step, so it now
lets a caller switch which vendor the tools call. It does not let a caller
choose where a key is sent: each vendor's endpoint is a literal in the tool,
and a key only ever reaches its own vendor. The credential slots are the same
low-risk shape as the two keys already on that whitelist.

Log content changes: a URL query parameter named like a credential
(`api_key`, `key`, `token`, `secret`, `password`, `access_token`, `auth`) is
written as `<redacted>` in the persisted file and in the stderr mirror. Nothing
in the tree parses those files -- the only other references to them are paths
and levels -- but an operator's own grep for a full URL will now match a
redacted one. The redaction removes the URL-borne credential specifically; it
does not make the log a safe place for a secret in general, and the file's mode
is unchanged.

`tools.web.providers` now rejects an unknown member instead of dropping it, so
a hand-edited `providers.tavili` fails config validation with the key named
rather than leaving Tavily unkeyed and its tool silently withheld. The section
is new in this branch, so no existing file can start failing, and neither
writer can produce one: `settings.set` refuses a key off its allowlist and
`set_web_provider_key` rejects an unknown vendor before validating. Rolling
that commit back restores the silent-drop behaviour.

Rollback is per commit; nothing migrates on disk, and a config written by this
branch is read by the previous one for everything except the new fields, which
the previous schema drops on load.

Two things checked this round and deliberately not fixed here. The same
credential-in-log defect exists in the A side:
`subagents/raven-research/Raven-X/raven/agent/tools/web.py:1087` passes
`api_key` in the SerpApi query params, and that fork's own `cli/_log_file.py`
has the same stdlib funnel with no redaction. It is on the merge target too, so
this branch did not introduce it, and closing it means a seventh patch over
pristine `a903a424` in a file none of the six touch plus a
`subagents/TREE_HASHES` refresh -- widening this MR into the frozen tree for a
defect it did not create. The other four vendored forks ship no SerpApi path,
and the B-side plugin runs on the installed raven, so it takes this fix.
Separately, the `gateway.log` mode is left as the sink creates it; tightening
it affects anything reading those logs as a non-owner and belongs in its own
change.

## Related Issues

N/A
… boundaries

## Summary

Raven v0.2.0: the runtime is layered by binding time, and the boundaries are
machine-enforced. This is the single MR for the release; main was frozen for
its duration and every wave below was rehearsed in a detached worktree before
landing, so the branch is 302 commits (245 at the original writing; the rest are the
product-migration waves below plus colleague fixes synced in) that each pass
the full suite and the import-linter contracts.

What a reviewer will find, by layer:

- **Kernel (L0 spine) and papers (L1 contracts).** `raven/spine` is the
  turn-and-delivery kernel (1,360 lines, importing nothing outside itself).
  Closed to drive-by edits rather than literally untouched: four commits reach
  it here, for a dropped-deliverable count, the `organ_degraded` event a
  degraded turn needs, two internals that became private names, and comments
  that stopped naming surface modules by path. The seven shapes every shelf
  implements moved into `raven/contracts` as papers with a two-tier ledger
  (contract vs factory_loop), and the import-linter contract "the kernel stands
  alone" pins spine + contracts + tracing as a set that imports nothing else --
  the closure the raven-core wheel ships: `make build-core` builds the kernel
  as its own wheel from that contract's roster, and an integration smoke
  installs it into a clean venv and imports it alone. The contract tier also
  carries a version (`CONTRACTS_VERSION`) pinned by a surface digest, so a
  paper's shape cannot move silently -- exercised live when the disk-layout
  paper (contracts/path_policy.py) landed and the version moved to 2: the
  home rule's vocabulary is declared once, raven.home implements it, eight
  hand-rolled RAVEN_HOME resolutions now call it, and the three paths that
  ignored a relocated home (bridge install, OAuth credentials, env file)
  follow it. The security batch closed out entirely: guarded_fetch now
  connects each hop to the address it judged (one resolution serves verdict
  and connection, ending the DNS-rebinding window) with Host/SNI/cert on
  the hostname; config.unset removes an override so the default answers
  again; and RAVEN_BROWSER_BLOCK_PRIVATE offers deployments the tighter
  browser stance without changing the default. The papers also carry their
  own line budget beside spine's, the factory_loop tier (the hook
  vocabulary) declares a surface version pinned by a fingerprint test, and
  the plugin-facing shapes outside contracts/ (ServiceLocator,
  RuntimeHandles, PluginManifest) get a contribution-surface ledger of
  their own.
- **Assembly root (L2 core).** One door, `raven/core/runtime.py:build_runtime`,
  assembles a running agent from the two config trees; the three CLI entrances
  bring only their transport-side wiring (`TurnPolicy`, `HostWiring`). The
  4,986-line agent loop is split into a thin shell plus four concern mixins
  and its 45 constructor keywords become five wiring bundles.
- **Generations.** The gateway rebuilds its runtime from config and swaps it in
  at the loop's turn boundary instead of restarting: build N+1 first, stop N,
  dispose N in a pinned order. One guarded entry (`SwapCoordinator`) serializes
  every trigger; channels, cron, the sentinel and the control plane survive the
  swap.
- **Gateway control plane.** The endpoint the retired ui-webui front end left
  behind becomes what it had already become in practice: the daemon's window
  for other raven processes. Six methods (`gateway.channels.live/qr/start`,
  `gateway.status/reload/shutdown`), loopback only, per-boot token published in
  the gateway lock, charter pinned by a guard. `raven gateway reload|status|stop`
  drive it from the CLI. `raven/web_rpc`, its 45-method dialect, its OpenRPC
  document and the `gateway.web` config table are removed (a stale table is
  dropped on load once, with a notice).
- **Shelves (L3) and admission.** Every cargo boards through a door that checks
  its declaration: plugin config slices, tools, channel configs. Channel config
  lives with the adapter -- each `spec.py` declares fields, defaults, secrecy,
  choices and nesting; the twelve central per-channel config classes are gone,
  `channels.<name>` is dynamic, and `raven channels set/show` validate through
  the same door the gateway dispenses through. Existing config files load
  unchanged (camelCase keys resolve to their declarations).
- **One extension door.** AgentLoop's three legacy callback parameters
  (`on_user_inbound`, `decision_consumer`, `response_modifier`) retired; the
  hook chain is the loop's one extension point and the stacks wrap their own
  callbacks. Same-name collisions resolved to domain-true names (plugins
  `ManifestOrigin`, sentinel `PlannerContextAssembler`, everos `ProbeVerdict`,
  doctor `LlmProbeResult`, rpc `TurnUsage`, dialects `DialectResult`,
  `market/vetting.py`); the curated model shortlist moved to packaged TOML;
  a connect failure now classifies as network, not a provider 5xx.
- **The agents pilot ground.** A top-level agents/ directory (name
  provisional by ruling) holds product definitions built on installed
  raven, A/B against the frozen vendored subagents/. The first product,
  Raven-Research-NG, is the vendored research agent minus its fork
  checkout: the launcher renders config and execs raven's own acp entry,
  identity is a workspace asset, and a sixth import-linter contract keeps
  the runtime from importing the pilots back. Pure addition; acceptance
  is transport-face equivalence, and the A/B ran on this machine before
  the push (both sides live over ACP stdio, matched models): cancel
  semantics are exactly equal (both answer the in-flight prompt with
  stopReason "cancelled", zero chunks after), and every shape difference
  is a trunk addition the fork lacks (configOptions on session/new,
  available_commands_update, session_info_update, usage_update). The
  real-search turn and the full field-by-field memory comparison need a
  search credential this machine does not hold and run in the post-merge
  regression batch, by ruling.
- **The loop's extension door, finished.** The three iteration phases the
  loop_hooks paper promised now fire, plus a terminal_answerless phase; the
  decision vocabulary gains rollback-and-resample (with injected messages
  and allowlisted generation overrides) and per-iteration tool withholding.
  Plugins contribute hooks ([[plugin.contributes.hooks]]), so product code
  steers the loop from a plugin directory; ACP session modes are built
  (acp.modes, session/set_mode, SessionModeState on every session
  response) on a per-session loop policy. Five general grants close the
  gap the pilot's port demanded: `plugins.dirs` (a product names its own
  plugin roots), the service locator lends plugins the connection's
  provider (the first RuntimeServices field), hooks may leave the model a
  note on the last message (`append_note`) and rewrite the inbound text on
  the way in, `context.dropSegments` lets a product omit host prompt
  segments it replaces, and `unwrap_untrusted` gives the fence its inverse.
  Together these are what the research pilot needs to rebuild its fork's
  orchestration without a fork -- and nothing in the kernel names research.
  A second pass finished the paper's promises to its first user: the hook
  context carries one turn-scoped metadata dict (seeded at inbound, shared
  by every phase), the session history as filed, and the rewrite grant on
  the way in -- the model reads the rewritten text while the session files
  what the user actually said; an observers entry a hook records is stamped
  onto the turn's last substantive assistant message at persist.
- **The pilot's harness, as a plugin.** The vendored fork's deep-research
  flow (draft review, sufficiency and forced-finalize judges, spin breaker,
  fetch gate and floor, budget notes, report shape, the ask_user gate, the
  research memo and process appendix, and research-grade web tools) is
  rebuilt under agents/raven-research/plugins/research-flow/ on those seams
  alone; its launcher renders the boarding, and the fork's own behavioural
  tests ride along, ported (sixteen files) as the equivalence evidence.
- **The generation is sealed.** RavenRuntime is frozen: a composition change
  is generation N+1 through the swap path, never an in-place mutation, and
  build_runtime's steps carry the composition phase names (COLLECT, ADMIT,
  BIND, START, FREEZE). A checklist guard pins the roster: every
  generation-scoped organ names its dispose call and dispose retires nothing
  unlisted, so growing the generation forces both halves -- and discard
  stays a no-op by assertion.
- **The first plugin through the public door.** The EverOS memory backend
  leaves the wheel for plugins-dist/everos-memory and is discovered through
  the raven.plugins entry-point group -- the group was empty until now, so
  the source every third-party plugin must use finally carries one. The
  default configuration is unchanged and a missing plugin degrades loudly.
- **The pilot's equivalence pack is complete.** All seventeen of the fork's
  behavioural test files are ported (135 of the 148 tests in the largest one,
  the other thirteen recorded as dropped with reasons), and an adversarial
  parity ledger accounts for every feature of the fork's flow against the
  rebuilt plugin, feature by feature. Acting on that ledger fixed the nine
  places the port was shaped right and behaving wrong -- one launch-breaking:
  the rendered search key reached the trunk's config surface and never the
  plugin's own slice, so the documented .env route produced an agent that
  answered every search with "API key not configured".
- **The ACP client family moves onto its shelf.** The client (raven driving
  somebody else's agent), the acp_agent backend that speaks it and the
  dialects that translate other vendors' tool records leave raven/agent for
  the top-level `acp_client/` -- the one physical split the target tree
  always named. The package joins the inner-layer seats, both import-linter
  contracts follow the address, and the two package names now read as the
  two sides of one protocol.
- **Late binding is a contribution shape, and the wheel has its own shelf.**
  A contributed tool that needs what only the assembled loop owns declares
  `bind_runtime(handles)` and receives the frozen `RuntimeHandles` grants
  (the session dir, the sub-agent registry named as the big power it is,
  the paused switch, the playbook funnel) once the loop finishes
  assembling; a binder may raise BindDeclinedError to leave the table
  quietly -- the late-bound twin of a factory returning None. The first
  resident of the new bundled shelf (`raven/plugins/bundled/`) is the
  playbook pair: the loop still assembles the funnel (one dispatch gate,
  one quota, one announce path) and the plugin serves it, so
  `playbooks.enabled: false` turns the feature exactly as far off as
  before.
- **The launcher library.** What every product launcher would repeat --
  secret-slot merging, host LLM inheritance, state-root pinning, once-only
  workspace seeding, acp.modes assembly from overlay files, stale-render
  sweeping by pid liveness, the owner-only rendered write -- moves into
  `raven/config/product_render.py`, each machine pinned by its own test;
  the research launcher keeps only its tables and its judgement.
- **Seats and guards.** `CONTEXT.md` records where every package sits (Layer
  Seats) and which four are deliberately unseated pending a ruling; the
  entrance guard and the linter carry the same lists. Addresses moved:
  `plugin` -> `plugins`, `plughub` -> `market`, gateway plumbing into
  `raven/gateway`, the seven assembly stacks into `raven/core`.

Fixes carried in with the layering (each with a regression test): a bound ACP
sub-agent row no longer reads "running" after its process dies; spawn record
outcome is first-wins; scheduler shutdown gets a grace window; `raven web
--stop` reaches a gateway that runs its teardown; a file-set nested channel
table (e.g. `channels.mochat.mention`) no longer crashes the adapter; camelCase
config keys are no longer missed by the admission door; the weixin adapter
keeps its rediscovered base_url as runtime state instead of writing into a
frozen config; the onboarding scancode flow and `raven channels login` build
adapters through the admission door like the gateway does; with
`gateway.web.enabled` IM cron replies were dropped into a hub with no outlet --
proactive output now always rides the gateway spine.

Deferred by ruling, recorded here so nothing reads as an omission:

- ~310-line provider table TOML-ization: dissolved (the table is hand-curated
  by design).
- rpc/models.py split: rejected after rehearsal (388 interlocked classes);
  onboard_commands / onboard_everos splits: deferred.
- Organ-granularity swap: the whole-runtime generation is the 0.2.0
  deliverable (the composition phase names and the FREEZE seal landed, see
  the summary).

## The product migration (0902 addition)

The three vendored products under `subagents/` (oncall, code, ppt) now run as
product definitions on the installed runtime -- `agents/<product>/` carries a
stdlib-only launcher, the product config, the roster row, and the product's
own plugins; the ppt deck engine ships as an installable distribution
(`plugins-dist/ppt-engine`, entry-point discovered, Apache-2.0 with its AGPL
dependency ringfenced in NOTICES). Three generic kernel seams landed first
(ToolGate, SessionObserver, compaction gate; CONTRACTS_VERSION=5), and the
kernel schema validator learned JSON-Schema type lists on the way.

Equivalence is judged by user-visible capability and every deviation is
ledgered: each line carries an independent rank-A audit (oncall 73 rows /
code 93 / ppt 69), a two-lane transport-parity A/B with frame logs, and a
closure document whose exception ledger is independently citable. Four
fail-open holes were caught by independent adversarial review before landing
and fixed (malformed-config sentinel, enabled-coercion, slice-raise swallowed
by the stack, secret-slot fallback missing one consumer). A real-machine
batch ran 16 axes green, including a live end-to-end deck build rendered
through LibreOffice. The fork test suites rode along: the default suite grew
to ~18k tests, all green.

Reviewer notes: the ppt engine and its test family carry functional Chinese
(caption markers, deck fixtures) under a maintainer-signed exemption pinned
to exactly those two path prefixes -- the ruling and the per-file ledger are
in the wave commit message and the migration closure documents. The vendored
`subagents/` trees are unchanged by this work and await a signed retirement
wave. Commit-lint currently flags several synced colleague commits
(non-ASCII bodies on duplicated twins); the duplication cleanup owns that.
## Summary

raven-design -- the fifth and final vendored product -- migrates from a full-repo
fork under subagents/ to a product definition on the installed runtime, following
the same five-step pipeline as the oncall, code, ppt and research lines. Four
commits, each rehearsed to a full green suite before landing:

- 766132e2 scaffold: the five-file launcher (render the product config, seat state
  under the state root and the engine home in the data directory, image-key
  waterfall carried from the fork), the .env.example the fork cited but never
  shipped, a minimal design-engine workspace member, and a 22-test launcher family.
- e00cf8eb engine: plugins-dist/design-engine as an installable wheel -- 128
  sources on a sha256-pinned copy manifest (renderer 22 modules, 15+1 domain
  skills, 39 reference plates all under the size cap, AGPL ringfenced in the
  wheel's NOTICES), the Visual Domain Selector seated on the inbound hook with
  four guardrails as code, task_state as tools plus a per-iteration projection,
  a fail-closed sentinel for the config slice, and one separable kernel fence:
  a hook's rewrite no longer enters persisted history through the cancelled-turn
  door (the pin bites without it; ppt's materials hook is healed by the same
  two lines).
- 3e85f1f1 swap: the serve lane execs the installed runtime behind a loud wheel
  preflight; the corpus mounts path-keyed; the task-state root renders; the tool
  face is pinned at both ends (fork intent matched row for row, nine trunk-born
  tools held out by name, the session-naming side call carried under the ppt
  precedent's host-gain adjudication); the reply regains the fork's git-changes
  tail on the after_send seat.
- 441ce65e audit gap: the independent 71-row rank-A audit found exactly one gap --
  the fork's inheritance-branch riders (parent model / reasoning effort) had
  retired silently; the shared inherit_llm recovers them at render time with the
  launch-time residual ledgered, healing all four sibling products' inheritance
  branches.

Evidence trail (in the migration records): three-way survey, an amended verdict
(tri-party reviewed), two adversarial wave reviews (three HIGHs and five MEDs
found and absorbed before landing), the 71-row audit, a 469-test conservation
ledger (94 ported / 17 re-driven / 309 trunk-covered / 48 superseded / 1 carded),
and an 11-axis dual-lane A/B run with frame logs (fork CLI lane vs migrated ACP
face; real turns, live selector calls, real render chain through chromium and
soffice). Measured context: the fork's per-turn cold process took 47.6s where the
pooled migrated face takes 9.6s.

MERGE NOTE: please merge with a merge commit (no squash) -- the conservation
ledger, audit and A/B records anchor their evidence to the four wave commits by
hash, and a squash would sever those anchors.

## Type

- [x] Feature

## Verification

- uv run pytest tests -q (canonical --all-extras stack, at the rebased tip):
  full suite green -- see the MR pipeline for the exact tail on CI.
- make lint-python: All checks passed; uv run lint-imports: 8 contracts kept,
  0 broken (every wave, at landing).
- make check-large-files: exit 0 over the whole wave range (39 plates all under
  the 1 MiB cap, dual-seat exemption landed with the wave).
- Dual-lane A/B: 6 EQUAL or EQUIVALENT / 2 ADJUDICATED / 1 B-ONLY / 1
  DIVERGENT-BOUNDED (carded as a trunk transport trait) / 2 UNRUNNABLE recorded
  with reasons; frame logs retained.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

- The kernel touch is two lines in the broken-turn save path (pass through the
  original inbound), separable and pinned both ways; it fixes a latent leak that
  also affected the ppt line.
- The engine wheel is opt-in (workspace dev extra / explicit install); runtime
  never imports product directories (import-linter enforced), so the blast
  radius of product code is the product itself.
- Rollback: revert the four commits; the vendored fork under subagents/ remains
  untouched and the roster row can be re-pointed at it.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
## Summary

raven-design -- the fifth and final vendored product -- migrates from a full-repo
fork under subagents/ to a product definition on the installed runtime, following
the same five-step pipeline as the oncall, code, ppt and research lines. Four
commits, each rehearsed to a full green suite before landing:

- 766132e2 scaffold: the five-file launcher (render the product config, seat state
  under the state root and the engine home in the data directory, image-key
  waterfall carried from the fork), the .env.example the fork cited but never
  shipped, a minimal design-engine workspace member, and a 22-test launcher family.
- e00cf8eb engine: plugins-dist/design-engine as an installable wheel -- 128
  sources on a sha256-pinned copy manifest (renderer 22 modules, 15+1 domain
  skills, 39 reference plates all under the size cap, AGPL ringfenced in the
  wheel's NOTICES), the Visual Domain Selector seated on the inbound hook with
  four guardrails as code, task_state as tools plus a per-iteration projection,
  a fail-closed sentinel for the config slice, and one separable kernel fence:
  a hook's rewrite no longer enters persisted history through the cancelled-turn
  door (the pin bites without it; ppt's materials hook is healed by the same
  two lines).
- 3e85f1f1 swap: the serve lane execs the installed runtime behind a loud wheel
  preflight; the corpus mounts path-keyed; the task-state root renders; the tool
  face is pinned at both ends (fork intent matched row for row, nine trunk-born
  tools held out by name, the session-naming side call carried under the ppt
  precedent's host-gain adjudication); the reply regains the fork's git-changes
  tail on the after_send seat.
- 441ce65e audit gap: the independent 71-row rank-A audit found exactly one gap --
  the fork's inheritance-branch riders (parent model / reasoning effort) had
  retired silently; the shared inherit_llm recovers them at render time with the
  launch-time residual ledgered, healing all four sibling products' inheritance
  branches.

Evidence trail (in the migration records): three-way survey, an amended verdict
(tri-party reviewed), two adversarial wave reviews (three HIGHs and five MEDs
found and absorbed before landing), the 71-row audit, a 469-test conservation
ledger (94 ported / 17 re-driven / 309 trunk-covered / 48 superseded / 1 carded),
and an 11-axis dual-lane A/B run with frame logs (fork CLI lane vs migrated ACP
face; real turns, live selector calls, real render chain through chromium and
soffice). Measured context: the fork's per-turn cold process took 47.6s where the
pooled migrated face takes 9.6s.

MERGE NOTE: please merge with a merge commit (no squash) -- the conservation
ledger, audit and A/B records anchor their evidence to the four wave commits by
hash, and a squash would sever those anchors.

## Type

- [x] Feature

## Verification

- uv run pytest tests -q (canonical --all-extras stack, at the rebased tip):
  full suite green -- see the MR pipeline for the exact tail on CI.
- make lint-python: All checks passed; uv run lint-imports: 8 contracts kept,
  0 broken (every wave, at landing).
- make check-large-files: exit 0 over the whole wave range (39 plates all under
  the 1 MiB cap, dual-seat exemption landed with the wave).
- Dual-lane A/B: 6 EQUAL or EQUIVALENT / 2 ADJUDICATED / 1 B-ONLY / 1
  DIVERGENT-BOUNDED (carded as a trunk transport trait) / 2 UNRUNNABLE recorded
  with reasons; frame logs retained.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

- The kernel touch is two lines in the broken-turn save path (pass through the
  original inbound), separable and pinned both ways; it fixes a latent leak that
  also affected the ppt line.
- The engine wheel is opt-in (workspace dev extra / explicit install); runtime
  never imports product directories (import-linter enforced), so the blast
  radius of product code is the product itself.
- Rollback: revert the four commits; the vendored fork under subagents/ remains
  untouched and the roster row can be re-pointed at it.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
## Summary

Follow-on wave on top of the v0.2.0 layering that landed in !431. Main still
stands at that release tip (`aa273e586`), so the merge base is unchanged and
this merges clean without a rebase.

Merged without squashing, so the three sub-MRs stay legible as their own merge
commits on main:

- !463 `feat(agent)`: a blocking `run_subagent_dag` returns the first suspended
  node's report as its own tool result, and `resolve_dag_node` then blocks for
  the next report or for the run's final result. An outbox carries a run's
  events to whichever tool call is waiting on it, a run is bound to the turn
  that started it (no adjudication deadline while bound, the live turn is the
  liveness signal), and cancelling the waiting call hard-cancels the run. The
  adjudication window now measures silence rather than the whole round.
- !471 `feat(*)`: `web_search` and `web_fetch` take a vendor the deployment
  picks, on both sides of the vendoring seam. Tavily, Exa and Firecrawl serve
  search and pages, Brave Search serves search only. The host reads
  `tools.web.providers.<vendor>.apiKey`, with `tools.web.search.provider` and
  `tools.web.fetch.provider` selecting the backends; the pre-vendor
  `tools.web.search.apiKey` and `tools.web.jinaApiKey` are still read after the
  vendor slot, so no existing config changes meaning. Serper and Jina wire
  traffic is byte-identical, so no `drFlow.version` bump is owed.
- !489 `feat(agents)`: raven-design, the fifth and final vendored product,
  migrates from a full-repo fork under `subagents/` to a product definition on
  the installed runtime, with `plugins-dist/design-engine` shipped as an
  installable wheel on a sha256-pinned copy manifest.

238 files changed, 31703 insertions, 957 deletions.

One nonblocking follow-up is carried, not fixed here: a foreground soft-cancel
clears only `_handed` before draining `_buffer`, so a buffered `Report` can
still be announced as a live decision after the run has ended. The run is
already closed and the operator can ignore the stale notice, which is why it
was downgraded. The follow-up is to discard `_buffer` alongside `_handed` on
cancel.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Each sub-MR was verified on its own pipeline before it landed on this branch,
and !463, !471 and !489 all merged green. Review reached no blockers at
`f196f5c2`; the final commit `34b1dc03` (!489) landed after that round.

This MR's own pipeline (2820017043) was still running when the merge was taken:
`page` and `lint-python` had passed, `tests` had not finished. The merge went
ahead without waiting for it, by explicit maintainer decision. No test command
was run by hand for this merge.

- [ ] Relevant tests pass locally
- [ ] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

User-visible changes: two new config keys select web search and fetch vendors
(old keys keep their meaning), the onboarding wizard and settings page gain the
vendor pick, and raven-design now runs on the installed runtime instead of its
fork. A blocking DAG call now hands node reports to the agent instead of
prompting the person watching the call.

Rollback: this lands as a single merge commit on main, so `git revert -m 1` on
it reverses the whole wave. The source branch is retained.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
…enforced

## Summary

Governance hardening batch: the collaboration rulebook catches up with the
post-layering tree, the repository's comments and records are brought up to
its own standards, and the standards themselves become enforced gates.

Five commits, in series:

1. docs: the rulebook catches up -- AGENTS.md gains the source-language rule
   with the owner-signed CJK zones, a portable PR check replaces the
   GitHub-only wording, README's repo-layout table becomes the canonical
   scope list, CONTEXT-MAP gains its third route, and CONTEXT.md records the
   migration vocabulary.
2. docs: 133 surgical comment fixes across 83 files -- comments now say why,
   and only why; edit markers and restated-what comments are gone.
3. docs: stale orphans leave -- 5 dead documents deleted, 4 dated records
   stamped as historical, the zh README shrinks to a translated landing
   page, and the living-docs guard widens its glob.
4. ci: the honor system grows teeth -- scripts/check_source_language.py
   (diff-based, three passes), a commitlint scope enum computed from the
   tree, tests/test_readme_scope_canon.py, and the CI/make wiring for all
   three.
5. fix: a second pass brings the batch current with what landed on main
   after it was drafted -- the design engine's two owner-signed zones enter
   the gate and the rulebook, the scope enum learns the fifth product and
   the wheel distributions, CONTEXT.md records the third distributed member
   and the Visual Domain Selector, and one zh fixture added by a follow-on
   wave is respelled to English with its placeholder semantics preserved.

The batch is docs and CI only: no runtime behavior changes. The one source
edit outside docs/CI is the fixture respell in commit 5, verified by its own
test family.

## Type

- [x] CI / tooling

## Verification

- Full suite on the batch tree: `uv run --frozen --all-extras pytest tests -q`
  -> 18513 passed, 40 skipped, exit 0.
- `make lint-python` -> All checks passed; `uv run lint-imports` -> 0 broken.
- Guard families after landing commit 5: `uv run pytest
  tests/test_playbook_generator.py tests/test_commit_lint.py
  tests/test_living_docs.py tests/test_readme_scope_canon.py
  tests/test_source_language_check.py -q` -> 55 passed.
- Language-gate self-run over the batch's own range and over main's history
  since the batch base: exit 0 (one genuine pre-existing violation found and
  fixed in commit 5).
- Commitlint over all five batch subjects: 5/5 pass; `make check-large-files`
  -> exit 0.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

No runtime code paths change. The new CI gates can reject future commits by
design; if a gate misfires, reverting the squash commit removes the gates
and the rulebook text together. Existing CJK carriers in main's history are
exempt: the gate is diff-based and only judges new changes.

## Related Issues

N/A
…enforced

## Summary

Governance hardening batch: the collaboration rulebook catches up with the
post-layering tree, the repository's comments and records are brought up to
its own standards, and the standards themselves become enforced gates.

Five commits, in series:

1. docs: the rulebook catches up -- AGENTS.md gains the source-language rule
   with the owner-signed CJK zones, a portable PR check replaces the
   GitHub-only wording, README's repo-layout table becomes the canonical
   scope list, CONTEXT-MAP gains its third route, and CONTEXT.md records the
   migration vocabulary.
2. docs: 133 surgical comment fixes across 83 files -- comments now say why,
   and only why; edit markers and restated-what comments are gone.
3. docs: stale orphans leave -- 5 dead documents deleted, 4 dated records
   stamped as historical, the zh README shrinks to a translated landing
   page, and the living-docs guard widens its glob.
4. ci: the honor system grows teeth -- scripts/check_source_language.py
   (diff-based, three passes), a commitlint scope enum computed from the
   tree, tests/test_readme_scope_canon.py, and the CI/make wiring for all
   three.
5. fix: a second pass brings the batch current with what landed on main
   after it was drafted -- the design engine's two owner-signed zones enter
   the gate and the rulebook, the scope enum learns the fifth product and
   the wheel distributions, CONTEXT.md records the third distributed member
   and the Visual Domain Selector, and one zh fixture added by a follow-on
   wave is respelled to English with its placeholder semantics preserved.

The batch is docs and CI only: no runtime behavior changes. The one source
edit outside docs/CI is the fixture respell in commit 5, verified by its own
test family.

## Type

- [x] CI / tooling

## Verification

- Full suite on the batch tree: `uv run --frozen --all-extras pytest tests -q`
  -> 18513 passed, 40 skipped, exit 0.
- `make lint-python` -> All checks passed; `uv run lint-imports` -> 0 broken.
- Guard families after landing commit 5: `uv run pytest
  tests/test_playbook_generator.py tests/test_commit_lint.py
  tests/test_living_docs.py tests/test_readme_scope_canon.py
  tests/test_source_language_check.py -q` -> 55 passed.
- Language-gate self-run over the batch's own range and over main's history
  since the batch base: exit 0 (one genuine pre-existing violation found and
  fixed in commit 5).
- Commitlint over all five batch subjects: 5/5 pass; `make check-large-files`
  -> exit 0.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

No runtime code paths change. The new CI gates can reject future commits by
design; if a gate misfires, reverting the squash commit removes the gates
and the rulebook text together. Existing CJK carriers in main's history are
exempt: the gate is diff-based and only judges new changes.

## Related Issues

N/A
0xKT and others added 24 commits September 9, 2026 08:26
## Summary

Reported from the page: the sense of steps had gone. It had.

While a turn runs, the reader watches a sequence -- one row per step, each naming what it did. The instant the answer lands, `collapse` puts all of it behind one shut line reading `steps`. Measured in a real browser on a five-step turn:

| | folds | visible steps | visible rows |
|---|---|---|---|
| running | 0 | **5** | 5 |
| answer landed (before) | 1, shut | **0** | **0** |
| after clicking the fold | 1, open | 5 | 5 |

Everything the page said about the work became `steps  12s  ›`. And nothing on that row says five things are under it, so there was not even a reason to click.

## Type

- [x] Fix
- [ ] Feature

## Shut is right, for the other turns

The shut default is not arbitrary and this does not remove it. The comment on the fold body names the measurement it came from: a forty-turn session built 7361 nodes of which 6400 sat in shut fold bodies. That weight is a *session's* worth of history, and it is untouched here -- replay still builds every fold shut, and so does the demo canvas.

What changes is the one turn the reader is looking at, on both paths it can arrive by:

- **just watched** -- `collapse` takes a `live` flag, and `finishTurn` is its only caller.
- **just reopened** -- `openLastFold` opens the fold of the turn a replayed conversation ends on, from `mount.history`, the whole-conversation repaint. Without it, a refresh shut the turn whose steps had been on screen a second earlier, which is the same complaint one keystroke later. Only when the last turn is the one that fold belongs to: the scan stops at a question or a delivery exactly as `collapse`'s does, so a conversation whose final turn answered with no work of its own opens nothing.

Measured on a forty-turn replay in a browser: **39 shut, 1 open.**

**One open fold at a time.** The previous turn's shuts as the next opens, so the accumulation the shut default protects against cannot come back a turn at a time.

**A fold the reader touched stops being the runtime's.** `FoldData.auto` marks the ones opened automatically; `toggleFold` clears it; `shutAutoFolds` only shuts folds that still carry it. Without that half the fix would be the same rudeness in the other direction -- shutting, under the reader, something they had deliberately opened.

## Verification

```
make test-ui   98 files, 1,461 passed
make lint-ui   gen:check 162 methods, tsc --noEmit
make build-ui  dist/index.html 1,310,594 bytes
PYTHONPATH=. python3 scripts/check_commit_messages.py origin/main..HEAD   rc=0
npx commitlint --from origin/main --to HEAD                               rc=0
```

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed -- no new string; `gui.fold.steps` and `gui.fold.aria` are unchanged

Eight cases, and eleven mutations each killing at least one:

| mutation | cases red |
|---|---|
| fold always shut (the old behaviour) | 5 |
| live fold opens but is not marked `auto` | 1 |
| do not shut the previous turn's fold | 2 |
| shut the reader's own folds too | 2 |
| `toggleFold` does not clear `auto` | 1 |
| `finishTurn` does not pass `live` | 5 |
| replay treated as live | 1 |
| `shutAutoFolds` clears the mark without shutting | 2 |
| replay opens no fold at all | 2 |
| the fold replay opens is not marked `auto` | 1 |
| replay scans back past a workless last turn | 1 |

**One mutation survived my first pass**: `toggleFold` not clearing `auto`. Neither case that looked like it should catch it could -- a replayed fold is already not the runtime's, and shutting an auto fold looks identical whoever did it. The case that distinguishes is the reader shutting *this turn's own* fold and opening it again; it is now written, and named in its own comment as the case the other two cannot see.

**Driven in a real browser** on this branch's own build. Live:

| scenario | after the second turn |
|---|---|
| reader never touched turn 1 | 2 folds, 1 open; turn 1's narration gone, turn 2's on screen |
| reader shut turn 1 and opened it again | 2 folds, **2** open |

Replay:

| conversation | folds | open |
|---|---|---|
| forty turns, the last one did work | 40 | the 40th only |
| two turns, the last one only answered | 1 | none |
| one turn | 1 | it |

No console errors on any of them. The first live fixture also confirms the steps are on screen with nothing clicked: `steps | 4s | look at the a log | read | read | the pool is the problem`.

**Two existing cases moved**, and it is worth saying why rather than leaving it to the diff. `a shut body is not built` sized its fixtures on a single-turn conversation, whose only fold is now the one that opens. The property they pin is still true and still worth pinning, so they replay two turns and measure the **first** -- the one that stays shut. Sizing the open one would have measured the opposite property and passed for the wrong reason.

## Risk

- [x] Security impact considered -- a boolean on a client-side view model. Nothing crosses the wire, nothing is persisted
- [x] Backward compatibility considered -- `FoldData` gains one required field with exactly one construction site, so `tsc` proves the set is closed. `collapse`'s third parameter is defaulted, and its two other callers (history replay, the demo island) are unchanged by construction. `openLastFold` is a new export with one caller
- [x] Rollback path is clear for risky changes -- revert the two commits; folds go back to always shut

## Related Issues

N/A

## Swept

`mr-review-patterns` pre-submit sweep, over `git diff origin/main...HEAD`.

- **1 (a write location moved)** -- `FoldData.open` gains two writers (`shutAutoFolds` and `openLastFold`, beside creation and `toggleFold`). Grepped every reader: `FoldView` alone renders it (`.tfold.open`, `aria-expanded`, `hidden={!seg.open}`, and the body built only when open). Found a **fourth** writer I had not been looking for -- `writeToggles` restores `f.open` on the agent-stage lane after each repaint, and does not touch `auto`. It cannot interact: that lane is built by `history()`, whose folds are `auto: false`, and `finishTurn` runs on `mainLane()` only, so an auto fold can never reach it. Stated here rather than left silent.
- **2 (a value with several entry points)** -- every writer sits in `store.ts` and goes through `bump(lane, f)`; no second policy. `openLastFold` is called from `mount.history` and deliberately **not** from `store.history`, which the sub-agent stage also calls: that lane repaints per poll and restores the reader's toggles right after, so a fold opened there would be fighting them.
- **3 (a guard)** -- `shutAutoFolds` is checked both directions: what it wrongly shuts (a reader's own fold -- mutation 4) and what it wrongly leaves open (the previous auto fold -- mutation 3).
- **4 (tests)** -- eleven mutations above, one of which survived the first pass and is now named. Two existing cases had to move; both are described above rather than left to the diff. `make test-ui` diffed against the base: 1,454 before, 1,461 after, 0 failing on either side.
- **6 (stale text after a redesign)** -- grepped the whole tracked tree, no `--include` and no path filter, for claims that folds arrive shut, in English and in Chinese. Two hits, both mine: the note above `.tfb`, fixed in `3a5484b7` as its own commit, and the header of `a shut body is not built` ("Every turn of a resumed conversation arrives shut"), which the replay half made false and which moved with that half. `CONTEXT.md`, `CHANGELOG.md` and the prompt assets say nothing about folds.
- **11** -- `git ls-files | grep -c node_modules` is 0; `tsc --listFiles` confirms the toolchain read this worktree's `store.ts`, not a symlinked copy; the commit-message gate was run rather than eyeballed, and caught nothing only because it was run.

Not applicable, with reasons: **1's async/event clauses** (nothing became asynchronous, no wire event, no payload shape); **3b, 3d** (no tool schema, one lane); **3c** (state did not move out of a component -- `auto` is a new field on the object that already held `open`); **3e** (no assertion lost its symbol); **4b, 4c, 4d** (no platform branch, no shell, no rendered number); **5, 5b** (no dependency, no spec); **6b, 6c** (no backup, no generated artifact -- `gen:check` is clean and the RPC surface is untouched); **7, 7b** (nothing on disk, no collection setter); **8** (no asset, no secret, no surface); **9** (no new way for a path to end); **10** (no prompt text).

## Deliberately not fixed

A reconnect replay repaints the conversation from scratch, so the reader's own open folds are lost and the last turn's opens instead. That is not new -- the repaint already discarded every toggle, and this lane has no `writeToggles` to restore them -- and closing it properly means persisting toggles across a repaint, which is a different change.

The other two layers of folding are untouched and I did not widen the scope to them:

- `foldRuns` merges consecutive silent steps under one summary row.
- A step with more than one call hides the calls behind its own row.

Both are reasonable compression of a *visible* sequence, which is a different complaint from the sequence vanishing. If the row itself should say what it holds -- `5 steps, 8 calls, 12s` rather than `steps 12s` -- that is a separate change and I would rather it were argued on its own than smuggled in here.
## Summary

A sub-agent's `ask_user` with choices reached the host as one enum property, so an answer the user typed into the clarify sheet's free-text row that was not exactly one of the options was rejected by the enum check, re-asked `MAX_FIELD_RETRIES` times, and finally handed to the sub-agent as "(user did not answer)". Measured live on 2026-09-07 with a Raven-PPT spawn from the WebUI.

- `raven/acp/questions.py`: the ACP server declares an `answer_custom` string property beside the enum, so a typed answer has a field to travel in, and marks it `_meta.raven.customAnswerFor: "answer"` so the client knows which question it belongs to.
- `raven/acp_client/acp_dialects/base.py`: the default dialect stands in for every agent nobody has measured, so it pairs nothing on a name; it folds a free-text box into its question only when the box declares that question through the marker. Pairing on the `<name>_custom` name stays a measured convention of the Claude Code dialect alone (`claude_code.py` now shares the fold helper). A conforming form with an enum `deployment` and an independent `deployment_custom` notes field keeps both questions. `CONTEXT.md` names this as the one reading the fallback makes beyond the spec.
- `raven/agent/tools/ask_user.py`: options the model wrote as objects (`{option, description, recommended}`) render by their text, and an option that carries only a description reads as that description instead of an empty label.
- `agents/raven-ppt/subagent.json`: the manifest tells the delegating agent to hand Raven-PPT the user's own words rather than a paraphrase; `tests/test_agents_ppt_launcher.py` ledgers this as the third delta against the sealed fork record.

Note for the maintainer: !568 (now on main) moved the fork record this test compares against to `tests/fixtures/vendored_fork/raven-ppt`. The rebase onto 0e202a43 carries that `FORK` path unchanged; this branch only renames the test from two to three ledgered deltas and adds the description assertions, and all 32 launcher tests run against the moved fixture with no skips.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

Rebased onto `origin/main` (0e202a43) with no conflicts, then:

```
uv run --frozen pytest -q tests/test_acp_dialects.py tests/test_acp_elicitation.py \
    tests/test_acp_questions.py tests/test_agents_ppt_launcher.py tests/test_ask_user_tool.py
# 236 passed in 4.61s
uv run --frozen ruff check <the touched files>          # All checks passed!
uv run --frozen ruff format --check <the source files>    # already formatted
```

Live check before the rebase (2026-09-07, a WebUI stack built from this branch, Raven-PPT spawned by the main agent): six questions reached the clarify sheet prefixed with the agent name, a free-text answer that matched none of the options was accepted on the first try and reached the sub-agent verbatim, and the run was then cancelled as planned.

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

New wire surface, all of it emitted by raven's own ACP server: the optional `answer_custom` string property and its `_meta.raven.customAnswerFor` marker. A form from any other agent is read exactly as before, since the default dialect folds only a marked box; an agent's own `<name>_custom` field is never consumed. Rollback is a plain revert of the six commits.

## Related Issues

N/A
## Summary

A sub-agent's `ask_user` with choices reached the host as one enum property, so an answer the user typed into the clarify sheet's free-text row that was not exactly one of the options was rejected by the enum check, re-asked `MAX_FIELD_RETRIES` times, and finally handed to the sub-agent as "(user did not answer)". Measured live on 2026-09-07 with a Raven-PPT spawn from the WebUI.

- `raven/acp/questions.py`: the ACP server declares an `answer_custom` string property beside the enum, so a typed answer has a field to travel in, and marks it `_meta.raven.customAnswerFor: "answer"` so the client knows which question it belongs to.
- `raven/acp_client/acp_dialects/base.py`: the default dialect stands in for every agent nobody has measured, so it pairs nothing on a name; it folds a free-text box into its question only when the box declares that question through the marker. Pairing on the `<name>_custom` name stays a measured convention of the Claude Code dialect alone (`claude_code.py` now shares the fold helper). A conforming form with an enum `deployment` and an independent `deployment_custom` notes field keeps both questions. `CONTEXT.md` names this as the one reading the fallback makes beyond the spec.
- `raven/agent/tools/ask_user.py`: options the model wrote as objects (`{option, description, recommended}`) render by their text, and an option that carries only a description reads as that description instead of an empty label.
- `agents/raven-ppt/subagent.json`: the manifest tells the delegating agent to hand Raven-PPT the user's own words rather than a paraphrase; `tests/test_agents_ppt_launcher.py` ledgers this as the third delta against the sealed fork record.

Note for the maintainer: !568 (now on main) moved the fork record this test compares against to `tests/fixtures/vendored_fork/raven-ppt`. The rebase onto 0e202a43 carries that `FORK` path unchanged; this branch only renames the test from two to three ledgered deltas and adds the description assertions, and all 32 launcher tests run against the moved fixture with no skips.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

Rebased onto `origin/main` (0e202a43) with no conflicts, then:

```
uv run --frozen pytest -q tests/test_acp_dialects.py tests/test_acp_elicitation.py \
    tests/test_acp_questions.py tests/test_agents_ppt_launcher.py tests/test_ask_user_tool.py
# 236 passed in 4.61s
uv run --frozen ruff check <the touched files>          # All checks passed!
uv run --frozen ruff format --check <the source files>    # already formatted
```

Live check before the rebase (2026-09-07, a WebUI stack built from this branch, Raven-PPT spawned by the main agent): six questions reached the clarify sheet prefixed with the agent name, a free-text answer that matched none of the options was accepted on the first try and reached the sub-agent verbatim, and the run was then cancelled as planned.

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

New wire surface, all of it emitted by raven's own ACP server: the optional `answer_custom` string property and its `_meta.raven.customAnswerFor` marker. A form from any other agent is read exactly as before, since the default dialect folds only a marked box; an agent's own `<name>_custom` field is never consumed. Rollback is a plain revert of the six commits.

## Related Issues

N/A
…viewer loads it

## Summary

Opening a delivered PDF from the conversation card or the shelf drew a grey pane with a sad face; the user asked whether Chromium had to be installed on the server. It did not: the server answered every `/file` request 200 with `application/pdf`, `inline` and the CSP sandbox header. The page framed the PDF as `<iframe sandbox="allow-scripts">`, and Chromium refuses its PDF viewer inside any frame that carries the `sandbox` attribute, whatever tokens it grants: the fetch completes and is then blocked by the client (`net::ERR_BLOCKED_BY_CLIENT`).

Measured in a real Chromium (Playwright, full build with the PDF viewer) with four framings of the same deck PDF: sandboxed frame with the server CSP kept and with it stripped, both blocked with "This page has been blocked by Chromium"; unsandboxed frame with the CSP kept and with it stripped, both rendered. The response header is therefore not what blocks the viewer, and it stays.

- `ui-web/src/features/workspace/WorkspacePage.tsx`: the PDF frame carries no `sandbox` attribute; the HTML frame keeps its empty one, since a report the agent wrote should be readable without running its scripts. The viewer runs in Chromium's own extension origin, so the frame being same-origin hands the document nothing of the page's.
- `raven/rpc/files.py`: the `sandbox_for` docstring said the header was what a frame needed; it now says what actually blocks the viewer. No behaviour change.
- `ui-web/src/features/workspace/WorkspacePage.test.tsx`: two tests pin the frame attributes for a PDF and for HTML.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

```
cd ui-web && npx vitest run src/features/workspace/WorkspacePage.test.tsx   # 23 passed (2 new)
uv run --frozen pytest -q tests/test_rpc_files.py                           # 14 passed
```

Live: a WebUI stack built from this branch, driven by headless full Chromium, clicked the PDF card of a delivered 8-page deck; the desk pane now hosts the viewer (a `chrome-extension://.../index.html` frame inside the pane) and the top-level "open in new tab" route renders as before.

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

The PDF document is no longer in an opaque-origin frame, but Chromium renders PDFs in its own extension process and PDF scripts run inside PDFium; the document cannot reach the page's cookie or RPC socket. HTML keeps the sandbox. Rollback is a revert of two commits (the fix and the follow-up CSS comment).

## Related Issues

N/A
…viewer loads it

## Summary

Opening a delivered PDF from the conversation card or the shelf drew a grey pane with a sad face; the user asked whether Chromium had to be installed on the server. It did not: the server answered every `/file` request 200 with `application/pdf`, `inline` and the CSP sandbox header. The page framed the PDF as `<iframe sandbox="allow-scripts">`, and Chromium refuses its PDF viewer inside any frame that carries the `sandbox` attribute, whatever tokens it grants: the fetch completes and is then blocked by the client (`net::ERR_BLOCKED_BY_CLIENT`).

Measured in a real Chromium (Playwright, full build with the PDF viewer) with four framings of the same deck PDF: sandboxed frame with the server CSP kept and with it stripped, both blocked with "This page has been blocked by Chromium"; unsandboxed frame with the CSP kept and with it stripped, both rendered. The response header is therefore not what blocks the viewer, and it stays.

- `ui-web/src/features/workspace/WorkspacePage.tsx`: the PDF frame carries no `sandbox` attribute; the HTML frame keeps its empty one, since a report the agent wrote should be readable without running its scripts. The viewer runs in Chromium's own extension origin, so the frame being same-origin hands the document nothing of the page's.
- `raven/rpc/files.py`: the `sandbox_for` docstring said the header was what a frame needed; it now says what actually blocks the viewer. No behaviour change.
- `ui-web/src/features/workspace/WorkspacePage.test.tsx`: two tests pin the frame attributes for a PDF and for HTML.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

```
cd ui-web && npx vitest run src/features/workspace/WorkspacePage.test.tsx   # 23 passed (2 new)
uv run --frozen pytest -q tests/test_rpc_files.py                           # 14 passed
```

Live: a WebUI stack built from this branch, driven by headless full Chromium, clicked the PDF card of a delivered 8-page deck; the desk pane now hosts the viewer (a `chrome-extension://.../index.html` frame inside the pane) and the top-level "open in new tab" route renders as before.

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

The PDF document is no longer in an opaque-origin frame, but Chromium renders PDFs in its own extension process and PDF scripts run inside PDFium; the document cannot reach the page's cookie or RPC socket. HTML keeps the sandbox. Rollback is a revert of two commits (the fix and the follow-up CSS comment).

## Related Issues

N/A
…at serves the model

## Summary

The Raven-PPT launcher shipped `agents.defaults.contextWindowTokens` as a fixed 1,000,000 and let the host's model catalog only confirm it: the catalog's 1,310,720 for `z-ai/glm-5.3-flash` is the model's card, the configured number was treated as a cap under it, and a model the catalog did not list kept the shipped figure. None of the three is the window the run actually has.

The launcher now resolves the window in this order and writes the first answer, logging its source:

1. `PPT_CONTEXT_WINDOW` from the product's `.env`, pinned by hand.
2. The endpoint that will serve the model. On OpenRouter, `GET /models/{id}/endpoints` lists every provider's own window; the smallest among the providers the request may reach is the number. That set is read off the same `modelOverrides` `extra_body.provider` rows the runtime sends, but only where the row fences the request: an `only` list, or an `order` with `allow_fallbacks` false; an `ignore` list removes its names. An `order` with fallbacks on is a priority OpenRouter abandons when the ordered providers are unavailable, so every serving endpoint would count; the shipped glm-5.3-flash row is therefore a fence, `order: [Z.AI, DeepInfra, Novita]` with `allow_fallbacks: false`, and the window is theirs: 1,048,576, against 1,310,720 on the card and 262,144 at the fp8 Io Net host the fence keeps the run away from. An OpenAI-compatible server answers through `GET /models` with `max_model_len` (vLLM) or `context_length`. The endpoint's answer replaces the configured number in either direction.
3. LiteLLM's table (`litellm.get_model_info`). It knows the routed id (`openrouter/z-ai/glm-5.3-flash` -> 1,310,720, the card number) and not the bare one, and takes about five seconds to import, so it is asked only when the endpoint does not answer. A catalog answer (this one or the host catalog below) may only lower the configured number, never raise it: a probe that timed out must not leave the run trimming against the card's 1,310,720 on endpoints that take 1,048,576.
4. The host's model catalog cache, as before.
5. The shipped number, when nobody answers or when only a catalog answered with a larger figure.

The product `config.json` routes `glm-5.3-flash` to Z.AI, DeepInfra and Novita as the host config does, so the window is sized by the providers that serve the run; the swap ledger in the launcher tests records the row.

Behaviour change to note: the configured number used to act as a cap under the catalog's ceiling (a run that took the 1.3M card as its window once grew to 450k tokens a call). The exact served window is now written instead; for the shipped model that is 1,048,576 against the previous 1,000,000, so the compaction point moves by under five percent. Two routing consequences of the fence that makes this true: a deck call is never routed to the fp8 host (sized at 262,144, a quarter of what the intended providers serve), and a call that finds all three fenced providers unavailable fails over the runtime's retry ladder instead of falling back to a smaller-window provider. A deployment that wants a different window pins it with `PPT_CONTEXT_WINDOW`.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
uv run --frozen ruff check agents/raven-ppt/run.py tests/test_agents_ppt_launcher.py
uv run --frozen pytest tests/test_agents_ppt_launcher.py tests/test_config_product_render.py tests/test_agents_design_launcher.py -q
```

Live, from this machine, with the host's OpenRouter key:

| probe | answer |
|---|---|
| OpenRouter, glm-5.3-flash, the shipped row (fenced to Z.AI / DeepInfra / Novita, fallbacks off) | 1,048,576 (3 endpoints) |
| OpenRouter, glm-5.3-flash, any provider (no fence) | 262,144 (23 endpoints at the second run; the fp8 host) |
| OpenRouter, anthropic/claude-sonnet-5 | 1,000,000 (9 endpoints) |
| vLLM endpoint, qwen3.6-27B | 262,144 (`max_model_len`) |
| LiteLLM, openrouter/z-ai/glm-5.3-flash | 1,310,720 (5.5 s to import) |
| a model nobody serves | None, shipped number kept |

A real `render_config` of the shipped config logged `window: 1048576 for z-ai/glm-5.3-flash, from the smallest of the 3 OpenRouter endpoints serving it, replacing the configured 1000000` at the first revision, and again at the final one with the fenced row (a real `render_config` at 2c1b1ebd printed the same line); with the fence removed the same probe answers 262,144 from 23 endpoints.

Second and third revisions (after the review): `uv run --frozen pytest -q tests/test_agents_ppt_launcher.py`, 43 passed; ruff check and format clean on the touched files.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- The launcher makes one HTTP GET to the LLM gateway at render time (8 s timeout, environment proxies honoured, the run's own key); a gateway that does not answer costs one timeout and falls through to the table and the catalog.
- The rendered window for the shipped model is 1,048,576 (was 1,000,000); `PPT_CONTEXT_WINDOW` pins any number. The shipped glm-5.3-flash row now has `allow_fallbacks: false`: OpenRouter no longer fails a deck call over to a provider outside Z.AI, DeepInfra and Novita, so an outage of all three surfaces through the runtime's retry ladder rather than through a smaller-window host. A failed endpoint probe can only lower the configured number through the catalogs, never raise it.
- Rollback: revert the squash commit (five commits on the branch); nothing is persisted beyond the rendered config, which is rewritten on every launch.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
…at serves the model

## Summary

The Raven-PPT launcher shipped `agents.defaults.contextWindowTokens` as a fixed 1,000,000 and let the host's model catalog only confirm it: the catalog's 1,310,720 for `z-ai/glm-5.3-flash` is the model's card, the configured number was treated as a cap under it, and a model the catalog did not list kept the shipped figure. None of the three is the window the run actually has.

The launcher now resolves the window in this order and writes the first answer, logging its source:

1. `PPT_CONTEXT_WINDOW` from the product's `.env`, pinned by hand.
2. The endpoint that will serve the model. On OpenRouter, `GET /models/{id}/endpoints` lists every provider's own window; the smallest among the providers the request may reach is the number. That set is read off the same `modelOverrides` `extra_body.provider` rows the runtime sends, but only where the row fences the request: an `only` list, or an `order` with `allow_fallbacks` false; an `ignore` list removes its names. An `order` with fallbacks on is a priority OpenRouter abandons when the ordered providers are unavailable, so every serving endpoint would count; the shipped glm-5.3-flash row is therefore a fence, `order: [Z.AI, DeepInfra, Novita]` with `allow_fallbacks: false`, and the window is theirs: 1,048,576, against 1,310,720 on the card and 262,144 at the fp8 Io Net host the fence keeps the run away from. An OpenAI-compatible server answers through `GET /models` with `max_model_len` (vLLM) or `context_length`. The endpoint's answer replaces the configured number in either direction.
3. LiteLLM's table (`litellm.get_model_info`). It knows the routed id (`openrouter/z-ai/glm-5.3-flash` -> 1,310,720, the card number) and not the bare one, and takes about five seconds to import, so it is asked only when the endpoint does not answer. A catalog answer (this one or the host catalog below) may only lower the configured number, never raise it: a probe that timed out must not leave the run trimming against the card's 1,310,720 on endpoints that take 1,048,576.
4. The host's model catalog cache, as before.
5. The shipped number, when nobody answers or when only a catalog answered with a larger figure.

The product `config.json` routes `glm-5.3-flash` to Z.AI, DeepInfra and Novita as the host config does, so the window is sized by the providers that serve the run; the swap ledger in the launcher tests records the row.

Behaviour change to note: the configured number used to act as a cap under the catalog's ceiling (a run that took the 1.3M card as its window once grew to 450k tokens a call). The exact served window is now written instead; for the shipped model that is 1,048,576 against the previous 1,000,000, so the compaction point moves by under five percent. Two routing consequences of the fence that makes this true: a deck call is never routed to the fp8 host (sized at 262,144, a quarter of what the intended providers serve), and a call that finds all three fenced providers unavailable fails over the runtime's retry ladder instead of falling back to a smaller-window provider. A deployment that wants a different window pins it with `PPT_CONTEXT_WINDOW`.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
uv run --frozen ruff check agents/raven-ppt/run.py tests/test_agents_ppt_launcher.py
uv run --frozen pytest tests/test_agents_ppt_launcher.py tests/test_config_product_render.py tests/test_agents_design_launcher.py -q
```

Live, from this machine, with the host's OpenRouter key:

| probe | answer |
|---|---|
| OpenRouter, glm-5.3-flash, the shipped row (fenced to Z.AI / DeepInfra / Novita, fallbacks off) | 1,048,576 (3 endpoints) |
| OpenRouter, glm-5.3-flash, any provider (no fence) | 262,144 (23 endpoints at the second run; the fp8 host) |
| OpenRouter, anthropic/claude-sonnet-5 | 1,000,000 (9 endpoints) |
| vLLM endpoint, qwen3.6-27B | 262,144 (`max_model_len`) |
| LiteLLM, openrouter/z-ai/glm-5.3-flash | 1,310,720 (5.5 s to import) |
| a model nobody serves | None, shipped number kept |

A real `render_config` of the shipped config logged `window: 1048576 for z-ai/glm-5.3-flash, from the smallest of the 3 OpenRouter endpoints serving it, replacing the configured 1000000` at the first revision, and again at the final one with the fenced row (a real `render_config` at 2c1b1ebd printed the same line); with the fence removed the same probe answers 262,144 from 23 endpoints.

Second and third revisions (after the review): `uv run --frozen pytest -q tests/test_agents_ppt_launcher.py`, 43 passed; ruff check and format clean on the touched files.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- The launcher makes one HTTP GET to the LLM gateway at render time (8 s timeout, environment proxies honoured, the run's own key); a gateway that does not answer costs one timeout and falls through to the table and the catalog.
- The rendered window for the shipped model is 1,048,576 (was 1,000,000); `PPT_CONTEXT_WINDOW` pins any number. The shipped glm-5.3-flash row now has `allow_fallbacks: false`: OpenRouter no longer fails a deck call over to a provider outside Z.AI, DeepInfra and Novita, so an outage of all three surfaces through the runtime's retry ladder rather than through a smaller-window host. A failed endpoint probe can only lower the configured number through the catalogs, never raise it.
- Rollback: revert the squash commit (five commits on the branch); nothing is persisted beyond the rendered config, which is rewritten on every launch.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
## Summary

Reported as "it follows at first, then breaks off -- after a few seconds it stops". Measured on the page, that is exactly the shape. Appending to a live thought and reading the gap to the bottom after each append:

| append size | gap trajectory |
|---|---|
| ~3 words | `0 0 0 0 0 0 0 0 0 0` |
| ~60 words | `0 0 113 227 341 455 568 682 796 910` -> 1365px |

It followed **twice**, missed once, and then widened by ~114px per append and never came back. Not "sometimes", not "slowly falling behind": one miss was permanent.

## Type

- [x] Fix
- [ ] Feature

## Why one miss was permanent

The follow was gated on the distance to the bottom, measured **after** the append:

```js
if (cot.scrollHeight - cot.scrollTop - cot.clientHeight < 40) cot.scrollTop = cot.scrollHeight
```

By the time that effect runs the new text is already in the DOM, so the distance is not how far the reader scrolled up -- it is **how tall this one chunk was**. `.cot` is 220px of viewport, so a chunk over 40px, about two lines, cleared the threshold on its own. After that `scrollTop` never moved again while `scrollHeight` kept growing, so the gap could only widen.

That also explains why it looked intermittent: it followed while the model emitted a few words at a time, and stopped the first time it emitted a paragraph.

**Raising the threshold would not fix it.** A large enough chunk clears any fixed number, and the model's chunk size is not ours to bound.

## What it is now

The reader's intent is kept by the reader's own scrolling. A `scroll` listener on the box records whether they are at the bottom; **appending text fires no scroll event**, so the flag holds whatever they last did -- verified against happy-dom directly, an assignment to `scrollTop` raises no event there either. Scrolling up clears it and the box stops following; scrolling back down sets it again and following resumes. The follow itself moved to `useLayoutEffect`, so the box is never painted at the stale offset.

## Verification

```
make test-ui   100 files, 1,492 passed
make lint-ui   gen:check 162 methods, tsc --noEmit
make build-ui  dist/index.html 1,316,406 bytes
```

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots -- no new string

**Driven in a real browser** on this branch's own build:

| case | result |
|---|---|
| 3 words per append | gap stays 0 |
| 60 words per append | gap stays 0 |
| 200 words per append | gap stays 0 |
| reader scrolls to the top mid-stream | left there; gap widens 740 -> 1695 as it should |

No console errors. **The middle case is the reported bug** and the last is the property the old threshold existed to protect -- which my first attempt broke, and the browser caught: sampling after applying the follow makes the flag true forever, so it snatched the reader back to the bottom. That is why the flag is driven by the scroll event and not re-measured.

Five cases, five mutations, each killing at least one:

| mutation | red |
|---|---|
| restore the old measure-after-append | 2 |
| follow unconditionally, ignoring the reader | 3 |
| never attach the scroll listener | 2 |
| drop the `thinkLive` gate | 1 |
| invert the at-bottom comparison | 2 |

Two of the cases had to be rewritten before they were load-bearing, and both were the fixture rather than the code:

- The settled-thought case drove the re-render with `thinkAppend`, which **revives a settled thought** (`thinkLive` goes back to true), so it asserted nothing about the gate. It now re-renders through the step's own prose, and leaves the box at the bottom so `wantsTail` cannot be the reason it stays put.
- Every case appended and asserted in the same tick. `thinkAppend` coalesces through `requestAnimationFrame` -- which is *why* the box can follow a stream at all -- so nothing had rendered yet and the assertions were reading the render before their own input. They now await the frame, through one helper that says why.

## Risk

- [x] Security impact considered -- none; a scroll offset in one box
- [x] Backward compatibility considered -- no contract, no wire, no stored state. One listener added and removed with the component
- [x] Rollback path is clear -- single-commit revert

## Related Issues

N/A

## Swept

`mr-review-patterns` pre-submit sweep, over `git diff origin/main...HEAD`.

- **1 (a write location moved)** -- `cot.scrollTop` has exactly one writer before and after; it moved from `useEffect` to `useLayoutEffect` in the same component. Grepped `.cot` across `ui-web/src`: the style rule and this component, no other reader of its offset.
- **2 (several entry points)** -- the flag has one writer (the listener) and one reader (the layout effect), both local to the component instance.
- **3 (a guard)** -- checked both directions: what it wrongly refuses (a large chunk while the reader is at the bottom -- the bug) and what it wrongly allows (following a reader who scrolled away -- pinned, and it is the mistake my own first attempt made).
- **3c (state moved out of a component)** -- deliberately did NOT: the flag is a `useRef` per instance, so two steps thinking at once cannot share it. An index-keyed store would have been the collision this section warns about.
- **4 (tests)** -- five mutations above; two fixtures rewritten and why is stated rather than left to the diff.
- **9 (error handling)** -- the listener is removed on unmount; `passive: true` since it never calls `preventDefault`.
- **11** -- `git ls-files | grep -c node_modules` is 0; commit-message gate run.

Not applicable, with reasons: **3b, 3d, 3e** (no schema, one lane, no assertion lost its symbol); **4b, 4c, 4d** (no platform branch, no shell, no rendered number); **5, 5b, 6b, 6c** (no dependency, no spec, no backup, no generated artifact); **6 (stale text)** -- the only comment describing this rule is the one being replaced, and grepping the tree for other statements of it found none; **7, 7b, 8, 10** (nothing on disk, no security surface, no prompt).

## Deliberately not fixed

`useLayoutEffect` runs on every render of a live step, which is once per coalesced frame. That is the same cadence the old effect ran at, so this is not new work -- but if a profile ever says the thought box is hot, the cheap next step is a dependency on `seg.think` rather than a bare effect.
## Summary

Reported as "it follows at first, then breaks off -- after a few seconds it stops". Measured on the page, that is exactly the shape. Appending to a live thought and reading the gap to the bottom after each append:

| append size | gap trajectory |
|---|---|
| ~3 words | `0 0 0 0 0 0 0 0 0 0` |
| ~60 words | `0 0 113 227 341 455 568 682 796 910` -> 1365px |

It followed **twice**, missed once, and then widened by ~114px per append and never came back. Not "sometimes", not "slowly falling behind": one miss was permanent.

## Type

- [x] Fix
- [ ] Feature

## Why one miss was permanent

The follow was gated on the distance to the bottom, measured **after** the append:

```js
if (cot.scrollHeight - cot.scrollTop - cot.clientHeight < 40) cot.scrollTop = cot.scrollHeight
```

By the time that effect runs the new text is already in the DOM, so the distance is not how far the reader scrolled up -- it is **how tall this one chunk was**. `.cot` is 220px of viewport, so a chunk over 40px, about two lines, cleared the threshold on its own. After that `scrollTop` never moved again while `scrollHeight` kept growing, so the gap could only widen.

That also explains why it looked intermittent: it followed while the model emitted a few words at a time, and stopped the first time it emitted a paragraph.

**Raising the threshold would not fix it.** A large enough chunk clears any fixed number, and the model's chunk size is not ours to bound.

## What it is now

The reader's intent is kept by the reader's own scrolling. A `scroll` listener on the box records whether they are at the bottom; **appending text fires no scroll event**, so the flag holds whatever they last did -- verified against happy-dom directly, an assignment to `scrollTop` raises no event there either. Scrolling up clears it and the box stops following; scrolling back down sets it again and following resumes. The follow itself moved to `useLayoutEffect`, so the box is never painted at the stale offset.

## Verification

```
make test-ui   100 files, 1,492 passed
make lint-ui   gen:check 162 methods, tsc --noEmit
make build-ui  dist/index.html 1,316,406 bytes
```

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots -- no new string

**Driven in a real browser** on this branch's own build:

| case | result |
|---|---|
| 3 words per append | gap stays 0 |
| 60 words per append | gap stays 0 |
| 200 words per append | gap stays 0 |
| reader scrolls to the top mid-stream | left there; gap widens 740 -> 1695 as it should |

No console errors. **The middle case is the reported bug** and the last is the property the old threshold existed to protect -- which my first attempt broke, and the browser caught: sampling after applying the follow makes the flag true forever, so it snatched the reader back to the bottom. That is why the flag is driven by the scroll event and not re-measured.

Five cases, five mutations, each killing at least one:

| mutation | red |
|---|---|
| restore the old measure-after-append | 2 |
| follow unconditionally, ignoring the reader | 3 |
| never attach the scroll listener | 2 |
| drop the `thinkLive` gate | 1 |
| invert the at-bottom comparison | 2 |

Two of the cases had to be rewritten before they were load-bearing, and both were the fixture rather than the code:

- The settled-thought case drove the re-render with `thinkAppend`, which **revives a settled thought** (`thinkLive` goes back to true), so it asserted nothing about the gate. It now re-renders through the step's own prose, and leaves the box at the bottom so `wantsTail` cannot be the reason it stays put.
- Every case appended and asserted in the same tick. `thinkAppend` coalesces through `requestAnimationFrame` -- which is *why* the box can follow a stream at all -- so nothing had rendered yet and the assertions were reading the render before their own input. They now await the frame, through one helper that says why.

## Risk

- [x] Security impact considered -- none; a scroll offset in one box
- [x] Backward compatibility considered -- no contract, no wire, no stored state. One listener added and removed with the component
- [x] Rollback path is clear -- single-commit revert

## Related Issues

N/A

## Swept

`mr-review-patterns` pre-submit sweep, over `git diff origin/main...HEAD`.

- **1 (a write location moved)** -- `cot.scrollTop` has exactly one writer before and after; it moved from `useEffect` to `useLayoutEffect` in the same component. Grepped `.cot` across `ui-web/src`: the style rule and this component, no other reader of its offset.
- **2 (several entry points)** -- the flag has one writer (the listener) and one reader (the layout effect), both local to the component instance.
- **3 (a guard)** -- checked both directions: what it wrongly refuses (a large chunk while the reader is at the bottom -- the bug) and what it wrongly allows (following a reader who scrolled away -- pinned, and it is the mistake my own first attempt made).
- **3c (state moved out of a component)** -- deliberately did NOT: the flag is a `useRef` per instance, so two steps thinking at once cannot share it. An index-keyed store would have been the collision this section warns about.
- **4 (tests)** -- five mutations above; two fixtures rewritten and why is stated rather than left to the diff.
- **9 (error handling)** -- the listener is removed on unmount; `passive: true` since it never calls `preventDefault`.
- **11** -- `git ls-files | grep -c node_modules` is 0; commit-message gate run.

Not applicable, with reasons: **3b, 3d, 3e** (no schema, one lane, no assertion lost its symbol); **4b, 4c, 4d** (no platform branch, no shell, no rendered number); **5, 5b, 6b, 6c** (no dependency, no spec, no backup, no generated artifact); **6 (stale text)** -- the only comment describing this rule is the one being replaced, and grepping the tree for other statements of it found none; **7, 7b, 8, 10** (nothing on disk, no security surface, no prompt).

## Deliberately not fixed

`useLayoutEffect` runs on every render of a live step, which is once per coalesced frame. That is the same cadence the old effect ran at, so this is not new work -- but if a profile ever says the thought box is hot, the cheap next step is a dependency on `seg.think` rather than a bare effect.
## Summary

`deliver_files` was absent from `DEFAULT_ALLOW_TOOLS`, so every call fell to the ask tier. This adds it, and rewrites the set's comment to say why one entry there is not a read.

The reasoning is about what a prompt on this tool costs. Its recipient is the user themself, and per its own description it is "the only way to hand one over" -- so an approval that lapses does not guard the artifact, it loses it: the digest lands in `turn.lapsed_digests`, the call is refused for the rest of the turn, and the model is told not to retry it and not to route around it. That instruction is correct and the file is still undelivered.

Two things this does not change, both checked:

- **Containment.** `deliver.py` resolves every path with `resolve_path(raw, bound or workspace, _with_current_root(allowed_dirs, bound))` and refuses a `PermissionError`. The tier was never what held the path boundary.
- **Operator control.** This is a default only. `gate.py` consults `user_tier` first and falls back to `default_tier`, so `permissions.tools.deliver_files` set to `ask` or `deny` still wins. Both were reproduced, not read.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
python -m pytest tests/test_permissions_gate.py tests/test_permissions_session.py \
                 tests/test_acp_permissions.py $(git grep -l deliver_files -- tests/)
  416 passed in 6.96s          (re-run at the rebased head)

python -m ruff check raven/permissions/rules.py tests/test_permissions_gate.py
  All checks passed!
python -m ruff format --check <same two files>
  2 files already formatted

python scripts/check_commit_messages.py origin/main..HEAD
  exit 0, 1 commit
```

The new test was watched red before the fix, with the expected reason:

```
AssertionError: assert ToolResult(model_text='Error: This call requires user approval
(ask tier), but this turn is not interactive ...') is None
```

Operator precedence was reproduced through the real gate:

```
default tier            : allow
no rule (new default)   -> ALLOWED (None)
operator sets "ask"     -> REFUSED: This call requires user approval (ask tier)
operator sets "deny"    -> REFUSED: This call is blocked by a deny rule
```

`lint-imports` is the gate GitLab CI does not run, and it is the one that has produced a blocker on this project before:

```
lint-imports
  Contracts: 9 kept, 0 broken.
```

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

Nothing in the tracked tree documented the old behaviour: a whole-tree grep for `deliver_files` alongside approval wording, with no extension or path filter, returns only the comment this MR adds.

## Risk

**User-visible change.** Delivering a finished file no longer raises an approval sheet. On the web and TUI clients the recipient is the reader who asked, so this removes an interruption.

**Disclosed, and deliberately not fixed here.** `DeliverFilesTool` declares no `channels` restriction, and `ToolRegistry.set_channel` advertises everything when none is set -- so the tool is offered on every enabled IM channel too, and after this change it delivers there without asking. The stakes are unchanged for the web and TUI lanes and higher for a group chat.

That belongs in `channels`, not in the permission tier: `raven/contracts/tool.py` already names `deliver_files` as the motivating example for that field ("deliver_files needs the web UI's download box") while the class never sets it. Using the tier to cover a channel-scoping gap would leave both mechanisms wrong. Filing it separately was the deliberate choice; say so if you would rather this MR waited on it.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

Rollback is one line -- drop the set entry -- or, without a deploy, an operator writes `permissions.tools.deliver_files: "ask"`.

## Related Issues

N/A

## Review round 1

One blocking finding from @chandler.zhang, reproduced and fixed in `dd024d2d`.

The module docstring and `CONTEXT.md`'s `Tier` entry both stated the default as "read-only tools run, everything else asks", which this change made false. Both now name the exception, and a test pins the three homes together -- the set, the module contract and the `Tier` entry -- read off the rendered text with whitespace collapsed and sliced to that entry, so neither a rewrap nor a mention elsewhere can void it.

Why the pre-submit sweep missed it: I grepped the identifier and the set name over the whole tracked tree with no extension filter, and neither text contains either. The claim is stated abstractly, and the sweep's own rule is to grep the claim rather than the symbol.

Re-verified at the rebased head (`origin/main` moved twice during review; both replays were pure, `range-diff` all `=`):

```
python -m pytest tests/test_permissions_gate.py tests/test_permissions_session.py \
                 tests/test_acp_permissions.py $(git grep -l deliver_files -- tests/)
  417 passed in 7.14s

python -m ruff check / format --check   clean
lint-imports                            9 kept, 0 broken
scripts/check_commit_messages.py        exit 0
```

Reverting either document turns the new pin red:

```
FAILED test_the_documented_default_tier_names_its_one_exception
  AssertionError: the module contract does not name it
```

## CI, round 2

`a9ccc822`. `lint-python` was red at `dd024d2dff77` on ruff I001 -- the previous commit put a plain `import ... as ...` inside the `from` block. `tests` and `page` were green; only lint failed.

Cause: pytest was re-run over that edit and ruff was not, and ruff had been checked against a list of changed files rather than the job's own targets. `lint-python` runs `ruff check` and `ruff format --check` over `raven evolver agents plugins-dist tests scripts`, so those are not the same check. Both now pass over the full target set, and the other two MRs in this set were re-checked the same way.
## Summary

`deliver_files` was absent from `DEFAULT_ALLOW_TOOLS`, so every call fell to the ask tier. This adds it, and rewrites the set's comment to say why one entry there is not a read.

The reasoning is about what a prompt on this tool costs. Its recipient is the user themself, and per its own description it is "the only way to hand one over" -- so an approval that lapses does not guard the artifact, it loses it: the digest lands in `turn.lapsed_digests`, the call is refused for the rest of the turn, and the model is told not to retry it and not to route around it. That instruction is correct and the file is still undelivered.

Two things this does not change, both checked:

- **Containment.** `deliver.py` resolves every path with `resolve_path(raw, bound or workspace, _with_current_root(allowed_dirs, bound))` and refuses a `PermissionError`. The tier was never what held the path boundary.
- **Operator control.** This is a default only. `gate.py` consults `user_tier` first and falls back to `default_tier`, so `permissions.tools.deliver_files` set to `ask` or `deny` still wins. Both were reproduced, not read.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
python -m pytest tests/test_permissions_gate.py tests/test_permissions_session.py \
                 tests/test_acp_permissions.py $(git grep -l deliver_files -- tests/)
  416 passed in 6.96s          (re-run at the rebased head)

python -m ruff check raven/permissions/rules.py tests/test_permissions_gate.py
  All checks passed!
python -m ruff format --check <same two files>
  2 files already formatted

python scripts/check_commit_messages.py origin/main..HEAD
  exit 0, 1 commit
```

The new test was watched red before the fix, with the expected reason:

```
AssertionError: assert ToolResult(model_text='Error: This call requires user approval
(ask tier), but this turn is not interactive ...') is None
```

Operator precedence was reproduced through the real gate:

```
default tier            : allow
no rule (new default)   -> ALLOWED (None)
operator sets "ask"     -> REFUSED: This call requires user approval (ask tier)
operator sets "deny"    -> REFUSED: This call is blocked by a deny rule
```

`lint-imports` is the gate GitLab CI does not run, and it is the one that has produced a blocker on this project before:

```
lint-imports
  Contracts: 9 kept, 0 broken.
```

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

Nothing in the tracked tree documented the old behaviour: a whole-tree grep for `deliver_files` alongside approval wording, with no extension or path filter, returns only the comment this MR adds.

## Risk

**User-visible change.** Delivering a finished file no longer raises an approval sheet. On the web and TUI clients the recipient is the reader who asked, so this removes an interruption.

**Disclosed, and deliberately not fixed here.** `DeliverFilesTool` declares no `channels` restriction, and `ToolRegistry.set_channel` advertises everything when none is set -- so the tool is offered on every enabled IM channel too, and after this change it delivers there without asking. The stakes are unchanged for the web and TUI lanes and higher for a group chat.

That belongs in `channels`, not in the permission tier: `raven/contracts/tool.py` already names `deliver_files` as the motivating example for that field ("deliver_files needs the web UI's download box") while the class never sets it. Using the tier to cover a channel-scoping gap would leave both mechanisms wrong. Filing it separately was the deliberate choice; say so if you would rather this MR waited on it.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

Rollback is one line -- drop the set entry -- or, without a deploy, an operator writes `permissions.tools.deliver_files: "ask"`.

## Related Issues

N/A

## Review round 1

One blocking finding from @chandler.zhang, reproduced and fixed in `dd024d2d`.

The module docstring and `CONTEXT.md`'s `Tier` entry both stated the default as "read-only tools run, everything else asks", which this change made false. Both now name the exception, and a test pins the three homes together -- the set, the module contract and the `Tier` entry -- read off the rendered text with whitespace collapsed and sliced to that entry, so neither a rewrap nor a mention elsewhere can void it.

Why the pre-submit sweep missed it: I grepped the identifier and the set name over the whole tracked tree with no extension filter, and neither text contains either. The claim is stated abstractly, and the sweep's own rule is to grep the claim rather than the symbol.

Re-verified at the rebased head (`origin/main` moved twice during review; both replays were pure, `range-diff` all `=`):

```
python -m pytest tests/test_permissions_gate.py tests/test_permissions_session.py \
                 tests/test_acp_permissions.py $(git grep -l deliver_files -- tests/)
  417 passed in 7.14s

python -m ruff check / format --check   clean
lint-imports                            9 kept, 0 broken
scripts/check_commit_messages.py        exit 0
```

Reverting either document turns the new pin red:

```
FAILED test_the_documented_default_tier_names_its_one_exception
  AssertionError: the module contract does not name it
```

## CI, round 2

`a9ccc822`. `lint-python` was red at `dd024d2dff77` on ruff I001 -- the previous commit put a plain `import ... as ...` inside the `from` block. `tests` and `page` were green; only lint failed.

Cause: pytest was re-run over that edit and ruff was not, and ruff had been checked against a list of changed files rather than the job's own targets. `lint-python` runs `ruff check` and `ruff format --check` over `raven evolver agents plugins-dist tests scripts`, so those are not the same check. Both now pass over the full target set, and the other two MRs in this set were re-checked the same way.
## Summary

Fix the shared grep tool on main, without Raven-Code migration or workspace-gate changes.

- Add ripgrep-bin >=15.2.0,<16.0.0 as a normal runtime dependency and lock its binary distributions.
- Resolve rg from the running Python environment's scripts directory, then PATH, so direct virtualenv/ACP launches do not require shell activation.
- Keep the Python fallback. When its existing traversal deadline expires, report an incomplete search and retain partial matches in content, files_with_matches and count modes. Never report an unfinished empty search as "No matches found."
- Preserve the tool schema, path restrictions, system-root guard and completed-search behavior. Existing fallback tests still explicitly exercise the fallback after rg is installed.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

On a fresh uv environment from this branch:

- `uv sync --locked --extra dev`
- `uv run --locked --extra dev pytest tests/test_search_tools.py tests/test_file_search_traversal_guard.py tests/test_filesystem_tools.py tests/test_filesystem_write_modes.py tests/test_tool_registry_execute.py tests/test_agents_research_tools.py -q`: 158 passed.
- `uv run --locked ruff check raven/agent/tools/file_search.py tests/test_search_tools.py tests/test_file_search_traversal_guard.py`: passed.
- `uv run --locked ruff format --check raven/agent/tools/file_search.py tests/test_search_tools.py tests/test_file_search_traversal_guard.py`: passed.
- `uv lock --check`, `git diff --check`, and the commit-message, source-language and large-file checks passed.

Regression evidence: before the implementation fix, the new assertions produced 7 failures (the empty-PATH binary lookup and incomplete empty/partial results in three output modes). After the fix they pass. The empty-PATH test executes the real installed rg and forbids the Python fallback; deadline tests use a controlled clock rather than sleeping.

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

Adds the ripgrep-bin binary distribution from PyPI; installation and execution were tested on Linux x86_64. Other platforms require a compatible wheel or source-build support. The bundled executable takes precedence over system rg; the existing rg arguments and ignore behavior are unchanged. The fallback deadline remains cooperative, not an interruptible I/O timeout.

uv regenerated some transitive platform-marker placement in uv.lock; no existing package version was upgraded. Rollback is a revert of this single commit, including the dependency and lockfile changes.

## Related Issues

N/A
## Summary

Fix the shared grep tool on main, without Raven-Code migration or workspace-gate changes.

- Add ripgrep-bin >=15.2.0,<16.0.0 as a normal runtime dependency and lock its binary distributions.
- Resolve rg from the running Python environment's scripts directory, then PATH, so direct virtualenv/ACP launches do not require shell activation.
- Keep the Python fallback. When its existing traversal deadline expires, report an incomplete search and retain partial matches in content, files_with_matches and count modes. Never report an unfinished empty search as "No matches found."
- Preserve the tool schema, path restrictions, system-root guard and completed-search behavior. Existing fallback tests still explicitly exercise the fallback after rg is installed.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

On a fresh uv environment from this branch:

- `uv sync --locked --extra dev`
- `uv run --locked --extra dev pytest tests/test_search_tools.py tests/test_file_search_traversal_guard.py tests/test_filesystem_tools.py tests/test_filesystem_write_modes.py tests/test_tool_registry_execute.py tests/test_agents_research_tools.py -q`: 158 passed.
- `uv run --locked ruff check raven/agent/tools/file_search.py tests/test_search_tools.py tests/test_file_search_traversal_guard.py`: passed.
- `uv run --locked ruff format --check raven/agent/tools/file_search.py tests/test_search_tools.py tests/test_file_search_traversal_guard.py`: passed.
- `uv lock --check`, `git diff --check`, and the commit-message, source-language and large-file checks passed.

Regression evidence: before the implementation fix, the new assertions produced 7 failures (the empty-PATH binary lookup and incomplete empty/partial results in three output modes). After the fix they pass. The empty-PATH test executes the real installed rg and forbids the Python fallback; deadline tests use a controlled clock rather than sleeping.

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

Adds the ripgrep-bin binary distribution from PyPI; installation and execution were tested on Linux x86_64. Other platforms require a compatible wheel or source-build support. The bundled executable takes precedence over system rg; the existing rg arguments and ignore behavior are unchanged. The fallback deadline remains cooperative, not an interruptible I/O timeout.

uv regenerated some transitive platform-marker placement in uv.lock; no existing package version was upgraded. Rollback is a revert of this single commit, including the dependency and lockfile changes.

## Related Issues

N/A
…er each summary

## Summary

Raven-PPT shipped with no `compaction` section, so the host's context compaction was off for deck runs: a 20-page deck once reached 450k tokens a call (62M input tokens over 137 turns), because nothing shrank the transcript short of a ceiling it never met.

Two changes, both in the product and the plugin; nothing on the host moves.

1. `agents/raven-ppt/config.json` turns the host's compaction on: `compaction.enabled: true`, `triggerRatio: 0.85`. Every other threshold (reserved tokens, tail budget, image byte window, tool-result handling) stays the host's default. The authoring skill is `always: true` and lives in the protected system prefix, so it is never compacted away. The swap ledger in the launcher tests records the row.
2. The ppt-engine plugin's `before_iteration` hook appends a deck ledger under each compaction summary through the `append_note` grant, once per summary (the ledger's first line is the marker; a later compaction gets its own). The ledger holds what a generic, code-shaped summary loses for a deck:
   - the user's own words, verbatim: every user message and every `ask_user` answer from the session record (the host's untrusted-data wrapper stripped), because they are the deck's requirements and a summary paraphrases them; capped at 24k characters with an explicit note when the cap is hit;
   - the deck's state as the tools left it on disk, each with its path: brief (language, audience, page budget, notes, forbidden list), template, sources count, figure catalogue, every outline page's claim and layout, the reader's still-open findings (up to 30, with seen counts), published count and the last refusal.

Design decision D34 in `plugins-dist/ppt-engine/docs/ppt-raven-design.md`.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
uv run --frozen ruff check plugins-dist/ppt-engine/raven_ppt/plugin/ledger.py plugins-dist/ppt-engine/raven_ppt/plugin/hook.py tests/test_ppt_engine_ledger.py tests/test_agents_ppt_launcher.py
uv run --frozen pytest tests/test_ppt_engine_ledger.py tests/test_ppt_engine_plugin.py tests/test_agents_ppt_launcher.py -q
# 75 passed
```

Replay on a recorded deck session (the 34-page self-evolving-skills deck from 2026-09-07: 314 messages, about 267k estimated tokens, one `ask_user` with two questions, three later user turns). The host's own `select_split` / `render_transcript` / `build_compacted` were run as the loop runs them, the summary written by `z-ai/glm-5.3-flash` through OpenRouter with the host's `SUMMARY_INSTRUCTIONS`, then the ledger built from the session record and the deck folder on disk:

| requirement in the transcript | in the host's summary alone | in the ledger |
|---|---|---|
| page limit 25-30 (later 33 accepted) | lost | verbatim, plus the brief note the tool wrote |
| all simplified Chinese | lost | verbatim |
| minimal template style (white, one accent, no gradients) | lost | verbatim, plus `brief forbids` lines |
| ask_user answer "33 pages is fine" | lost | verbatim |
| ask_user answer "compact matrix layout" | lost | verbatim |
| source HTML path | lost | verbatim |
| feedback "the cover illustration is ugly" | kept | verbatim |
| output path | kept | verbatim |
| outline (34 pages), 90 open reader findings, template, 17 sources, figure catalogue | not present | listed with paths |

The summary the model produced was 314 -> 22 messages (about 267k -> 13k estimated tokens) and read partly as an echo of the transcript's tail rather than a brief, which is the host's summary quality with this model and outside this MR; the ledger is what makes the deck safe against it.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- Behaviour change: deck runs now compact at about 85 percent of the window; a summary call is paid for when that happens. The ledger adds one note (a few thousand characters, bounded) after each compaction.
- The ledger reads only files the engine's own tools write, every read guarded; a half-written deck yields a shorter ledger, never a failed turn.
- Rollback: revert the squash commit; no persisted state is involved.

## Related Issues

N/A
…er each summary

## Summary

Raven-PPT shipped with no `compaction` section, so the host's context compaction was off for deck runs: a 20-page deck once reached 450k tokens a call (62M input tokens over 137 turns), because nothing shrank the transcript short of a ceiling it never met.

Two changes, both in the product and the plugin; nothing on the host moves.

1. `agents/raven-ppt/config.json` turns the host's compaction on: `compaction.enabled: true`, `triggerRatio: 0.85`. Every other threshold (reserved tokens, tail budget, image byte window, tool-result handling) stays the host's default. The authoring skill is `always: true` and lives in the protected system prefix, so it is never compacted away. The swap ledger in the launcher tests records the row.
2. The ppt-engine plugin's `before_iteration` hook appends a deck ledger under each compaction summary through the `append_note` grant, once per summary (the ledger's first line is the marker; a later compaction gets its own). The ledger holds what a generic, code-shaped summary loses for a deck:
   - the user's own words, verbatim: every user message and every `ask_user` answer from the session record (the host's untrusted-data wrapper stripped), because they are the deck's requirements and a summary paraphrases them; capped at 24k characters with an explicit note when the cap is hit;
   - the deck's state as the tools left it on disk, each with its path: brief (language, audience, page budget, notes, forbidden list), template, sources count, figure catalogue, every outline page's claim and layout, the reader's still-open findings (up to 30, with seen counts), published count and the last refusal.

Design decision D34 in `plugins-dist/ppt-engine/docs/ppt-raven-design.md`.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
uv run --frozen ruff check plugins-dist/ppt-engine/raven_ppt/plugin/ledger.py plugins-dist/ppt-engine/raven_ppt/plugin/hook.py tests/test_ppt_engine_ledger.py tests/test_agents_ppt_launcher.py
uv run --frozen pytest tests/test_ppt_engine_ledger.py tests/test_ppt_engine_plugin.py tests/test_agents_ppt_launcher.py -q
# 75 passed
```

Replay on a recorded deck session (the 34-page self-evolving-skills deck from 2026-09-07: 314 messages, about 267k estimated tokens, one `ask_user` with two questions, three later user turns). The host's own `select_split` / `render_transcript` / `build_compacted` were run as the loop runs them, the summary written by `z-ai/glm-5.3-flash` through OpenRouter with the host's `SUMMARY_INSTRUCTIONS`, then the ledger built from the session record and the deck folder on disk:

| requirement in the transcript | in the host's summary alone | in the ledger |
|---|---|---|
| page limit 25-30 (later 33 accepted) | lost | verbatim, plus the brief note the tool wrote |
| all simplified Chinese | lost | verbatim |
| minimal template style (white, one accent, no gradients) | lost | verbatim, plus `brief forbids` lines |
| ask_user answer "33 pages is fine" | lost | verbatim |
| ask_user answer "compact matrix layout" | lost | verbatim |
| source HTML path | lost | verbatim |
| feedback "the cover illustration is ugly" | kept | verbatim |
| output path | kept | verbatim |
| outline (34 pages), 90 open reader findings, template, 17 sources, figure catalogue | not present | listed with paths |

The summary the model produced was 314 -> 22 messages (about 267k -> 13k estimated tokens) and read partly as an echo of the transcript's tail rather than a brief, which is the host's summary quality with this model and outside this MR; the ledger is what makes the deck safe against it.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- Behaviour change: deck runs now compact at about 85 percent of the window; a summary call is paid for when that happens. The ledger adds one note (a few thousand characters, bounded) after each compaction.
- The ledger reads only files the engine's own tools write, every read guarded; a half-written deck yields a shorter ledger, never a failed turn.
- Rollback: revert the squash commit; no persisted state is involved.

## Related Issues

N/A
…lings

## Summary

A `continue` answer to a suspended DAG node was applied only once the scheduling round
it belonged to had drained. Both signals a round could see -- `cancel` and the replan
`interrupt` -- end it by cancelling its in-flight tasks, so `continue` had no way to
shorten the wait: the re-dispatch sat behind every sibling dispatched alongside the
suspended node, however long those ran and however unrelated they were. Observed as a
node that was told to continue and did not for 45 minutes, while an independent sibling
in the same graph kept working.

This adds the missing third shape. `AdjudicationDesk` gains a `continued` event, fired
by `resolve` for a CONTINUE decision, and `_run_ready_groups` gains a `resume` parameter
that ends the round early and returns the tasks still running instead of reaping them.
The scheduler carries those into the next round through a new `carried` argument, so the
re-dispatch runs alongside them under the same semaphore. The two signals cannot share a
parameter: `interrupt` means stop these nodes, `resume` means stop waiting for them.

Handing tasks back moves their ownership out of the round's `finally`, which is where
every in-flight task was reaped. The two loop exits reachable while any are outstanding
-- the replan branch and the cancellation handler -- now reap through `_reap_carried`.
Without that an uncancelled group task outlives its run, holding a semaphore slot and a
sub-agent process nothing will collect.

Ownership has a second consequence, in the ready set. `_run_node` sets `running` inside
the concurrency gate on purpose, so a node still queued for a slot reads `pending` --
which, from status alone, cannot be told apart from a node nobody has taken. The
scheduler therefore creates each round's group tasks itself and records which node ids
each one owns, and a carried task's nodes stay out of the ready set until it ends.
Without that a sibling carried before it had acquired a slot was dispatched a second
time, doing its work twice and racing its own files.

Three of the commits come from the pre-submit sweep rather than the defect: the twin
assertions the new desk event was missing, an `_await_adjudications` docstring still
saying nothing else in the graph can run during the wait, and the replan design stating
the drained-round rule as absolute when it now holds only for the signals that stop work.

Scoped deliberately to `continue`. `abandon` shares the same wait, but shortening it buys
nothing: an abandoned node's dependents cascade to `skipped` and none could have been
dispatched sooner. A test pins that as a decision rather than an omission.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Full Python suite at the branch head, rebased onto the current `main`:

    uv run --frozen --python 3.12 --all-extras pytest tests/ -q
    19961 passed, 139 skipped, 54 warnings in 386.56s

Zero failures. `--all-extras` is load-bearing: without it the deck-engine tests are not
collected, and running with only `raven` on `PYTHONPATH` resolves `raven_ppt` out of a
different checkout, which reports failures belonging to neither side.

The rebase took on twenty commits and none of them touch the five files changed here;
`git range-diff` against the pre-rebase range reports all four commits replaying
unchanged.

Two regression tests carry the behaviour, both in `tests/test_subagent_dag_runner.py`:

`test_a_continued_node_runs_again_while_a_sibling_is_still_running`
deadlocks without the fix, because only the continued node's second attempt releases the
sibling. Red before it (asyncio TimeoutError at the 15s guard), green after, in 1.97s.

`test_a_carried_group_queued_for_a_slot_is_not_dispatched_twice`
drives two independent nodes through a single slot and asserts the queued sibling runs
once. Without the ready-set guard it records the backend calls `['a', 'b', 'b', 'a']`.

Two more pin the disposal of a carried task, the property that has no owner but the two
`_reap_carried` calls:

`test_an_outer_cancellation_reaps_a_handed_on_task`
`test_a_replan_reaps_a_handed_on_task_before_it_records_them_cancelled`

Both drive one node to a continue and another to an unanswered suspension, so the loop
parks in `_await_adjudications` holding the carried task alone -- outside any round, which
is the only state in which those calls are what cancels it. Each reddens for its own call
site and neither for the other, checked by deleting them one at a time and together. They
are the siblings of `test_run_dag_cancel_skips_unfinished_and_reaps_in_flight_task`, which
proves the same property for a task inside the round.

Gates the GitLab pipeline does not run:

    ruff format --check raven/ tests/                          1487 files already formatted
    ruff check raven/ tests/                                   All checks passed
    lint-imports                                               Contracts: 9 kept, 0 broken
    python scripts/check_source_language.py origin/main...HEAD exit 0
    python scripts/check_commit_messages.py origin/main..HEAD  exit 0
    make check-large-files                                     exit 0

Six commits: the fix, three sweep follow-ups, the ready-set guard, and the reap tests.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No user-facing docs: the change is internal to the DAG scheduler and alters no tool
description, prompt asset or RPC shape.

## Risk

Behaviour change: a continued node is re-dispatched as soon as its answer lands rather
than after its round drains. Nothing else about the decision moves -- the continuation
budget, the message handed to the node, and the node's status transitions are untouched.
`dag_node_updated` for the continued node fires earlier; those events already interleave
across concurrent nodes, so no consumer gains an ordering it did not already tolerate.

Known gaps, disclosed rather than fixed:

- One bounded latency replaces the unbounded one: when a handed-on task is running and a
  second node is suspended without an answer, the loop parks in the adjudication wait and
  does not observe that task completing until an answer lands or the wait times out.
  That state was previously unreachable, and the wait it replaces had no bound at all.

Rollback: revert the four commits. No migration, no persisted state, no configuration.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
…lings

## Summary

A `continue` answer to a suspended DAG node was applied only once the scheduling round
it belonged to had drained. Both signals a round could see -- `cancel` and the replan
`interrupt` -- end it by cancelling its in-flight tasks, so `continue` had no way to
shorten the wait: the re-dispatch sat behind every sibling dispatched alongside the
suspended node, however long those ran and however unrelated they were. Observed as a
node that was told to continue and did not for 45 minutes, while an independent sibling
in the same graph kept working.

This adds the missing third shape. `AdjudicationDesk` gains a `continued` event, fired
by `resolve` for a CONTINUE decision, and `_run_ready_groups` gains a `resume` parameter
that ends the round early and returns the tasks still running instead of reaping them.
The scheduler carries those into the next round through a new `carried` argument, so the
re-dispatch runs alongside them under the same semaphore. The two signals cannot share a
parameter: `interrupt` means stop these nodes, `resume` means stop waiting for them.

Handing tasks back moves their ownership out of the round's `finally`, which is where
every in-flight task was reaped. The two loop exits reachable while any are outstanding
-- the replan branch and the cancellation handler -- now reap through `_reap_carried`.
Without that an uncancelled group task outlives its run, holding a semaphore slot and a
sub-agent process nothing will collect.

Ownership has a second consequence, in the ready set. `_run_node` sets `running` inside
the concurrency gate on purpose, so a node still queued for a slot reads `pending` --
which, from status alone, cannot be told apart from a node nobody has taken. The
scheduler therefore creates each round's group tasks itself and records which node ids
each one owns, and a carried task's nodes stay out of the ready set until it ends.
Without that a sibling carried before it had acquired a slot was dispatched a second
time, doing its work twice and racing its own files.

Three of the commits come from the pre-submit sweep rather than the defect: the twin
assertions the new desk event was missing, an `_await_adjudications` docstring still
saying nothing else in the graph can run during the wait, and the replan design stating
the drained-round rule as absolute when it now holds only for the signals that stop work.

Scoped deliberately to `continue`. `abandon` shares the same wait, but shortening it buys
nothing: an abandoned node's dependents cascade to `skipped` and none could have been
dispatched sooner. A test pins that as a decision rather than an omission.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Full Python suite at the branch head, rebased onto the current `main`:

    uv run --frozen --python 3.12 --all-extras pytest tests/ -q
    19961 passed, 139 skipped, 54 warnings in 386.56s

Zero failures. `--all-extras` is load-bearing: without it the deck-engine tests are not
collected, and running with only `raven` on `PYTHONPATH` resolves `raven_ppt` out of a
different checkout, which reports failures belonging to neither side.

The rebase took on twenty commits and none of them touch the five files changed here;
`git range-diff` against the pre-rebase range reports all four commits replaying
unchanged.

Two regression tests carry the behaviour, both in `tests/test_subagent_dag_runner.py`:

`test_a_continued_node_runs_again_while_a_sibling_is_still_running`
deadlocks without the fix, because only the continued node's second attempt releases the
sibling. Red before it (asyncio TimeoutError at the 15s guard), green after, in 1.97s.

`test_a_carried_group_queued_for_a_slot_is_not_dispatched_twice`
drives two independent nodes through a single slot and asserts the queued sibling runs
once. Without the ready-set guard it records the backend calls `['a', 'b', 'b', 'a']`.

Two more pin the disposal of a carried task, the property that has no owner but the two
`_reap_carried` calls:

`test_an_outer_cancellation_reaps_a_handed_on_task`
`test_a_replan_reaps_a_handed_on_task_before_it_records_them_cancelled`

Both drive one node to a continue and another to an unanswered suspension, so the loop
parks in `_await_adjudications` holding the carried task alone -- outside any round, which
is the only state in which those calls are what cancels it. Each reddens for its own call
site and neither for the other, checked by deleting them one at a time and together. They
are the siblings of `test_run_dag_cancel_skips_unfinished_and_reaps_in_flight_task`, which
proves the same property for a task inside the round.

Gates the GitLab pipeline does not run:

    ruff format --check raven/ tests/                          1487 files already formatted
    ruff check raven/ tests/                                   All checks passed
    lint-imports                                               Contracts: 9 kept, 0 broken
    python scripts/check_source_language.py origin/main...HEAD exit 0
    python scripts/check_commit_messages.py origin/main..HEAD  exit 0
    make check-large-files                                     exit 0

Six commits: the fix, three sweep follow-ups, the ready-set guard, and the reap tests.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No user-facing docs: the change is internal to the DAG scheduler and alters no tool
description, prompt asset or RPC shape.

## Risk

Behaviour change: a continued node is re-dispatched as soon as its answer lands rather
than after its round drains. Nothing else about the decision moves -- the continuation
budget, the message handed to the node, and the node's status transitions are untouched.
`dag_node_updated` for the continued node fires earlier; those events already interleave
across concurrent nodes, so no consumer gains an ordering it did not already tolerate.

Known gaps, disclosed rather than fixed:

- One bounded latency replaces the unbounded one: when a handed-on task is running and a
  second node is suspended without an answer, the loop parks in the adjudication wait and
  does not observe that task completing until an answer lands or the wait times out.
  That state was previously unreachable, and the wait it replaces had no bound at all.

Rollback: revert the four commits. No migration, no persisted state, no configuration.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A
## Summary

One livelock, three causes, all three closed here. A run read one file 127 times over an hour, exit 0 on every call, and ended its turn exactly where it started -- at iteration 343 of 600 when it was killed, with the file's md5 unchanged throughout.

**The guard did not arm.** `failure_streak` counts only hard failures and `turn_path` resets the streak on any success, so a call that keeps working and keeps answering the same thing is structurally invisible to it. `raven/agent/loop/no_progress.py` counts the other shape.

It counts **per turn rather than consecutively**, and that is the load-bearing decision: the run alternated two spellings of one read -- a plain read, and the same read behind an `md5sum` -- so no two neighbouring calls were identical and a consecutive streak would have stayed at one forever. The cost is that two spellings each reach the threshold on their own, so an alternating pair fires at about fifteen calls rather than eight. Against 127 that is the trade worth taking. A test pins the arithmetic so the design is not "simplified" back to consecutive.

Threshold 8 (higher than the failure threshold's 2, because repeating a working call is ordinary -- a poll, a re-read after an edit), capped at 2 per turn like the failure nudge, and it appends a nudge and nothing else.

**The cause was silent.** `provision` rewrites every helper beside the build script on every build. That is deliberate and its docstring says why (a quietly edited helper fails in a way that reads as a deck bug), but it said nothing -- so a run whose own `themes.json` went back to the engine's copy could not tell a reverted write from one that never happened. `ppt_build` now names what it put back, and says nothing when it put nothing back.

**The documentation contradicted the data.** The generated `ppt_theme` docstring promised a single entry and called a named theme a `KeyError`. That holds on the template route, where `with_template_helpers` narrows `themes.json` to the template's own palette. Without a template the same file carries the ten reviewed themes and naming one is how an author picks -- which `tests/test_ppt_engine_assets_script_helpers.py` itself does (`THEMES["indigo-scholar"]`). The text now follows the payload.

`ppt_charts` and `ppt_layout` opened with the same `T = THEMES[...]` line and carried the template route's claim beside it, so fixing `ppt_theme` alone left two generated modules telling a catalog-route author the opposite. They now point at the module that owns the rule, and a test pins that they do not restate it.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
python -m pytest tests/ -k "ppt_engine or agent_loop or turn_path
                            or failure_streak or no_progress"
  3956 passed, 94 skipped in 93.34s      (re-run at the rebased head)

python -m ruff check raven/agent/loop/ plugins-dist/ppt-engine/raven_ppt/backends/script/ \
                     plugins-dist/ppt-engine/raven_ppt/services/assets/script_helpers.py \
                     tests/test_agent_loop_tool_loop_break.py \
                     tests/test_ppt_engine_assets_script_helpers.py \
                     tests/test_ppt_engine_script_backend.py
  All checks passed!
python -m ruff format --check <the eight changed files>
  8 files already formatted

python scripts/check_commit_messages.py origin/main..HEAD
  exit 0, 1 commit
```

The 94 skips are the pre-existing `templates.manifest.json` payload skips; they are identical on `origin/main`.

Each of the three was watched red first, and the loop guard was mutation-tested rather than trusted:

```
# the nudge computed and then withheld (one line replaced by `pass`)
FAILED test_an_unchanging_successful_call_is_nudged_and_bounded
  AssertionError: an unchanging successful call was never nudged

# the one-home test against this repo's unmodified charts/layout
FAILED test_only_the_theme_module_states_the_theme_rule
  AssertionError: ppt_charts restates the template route's rule

# the restore note, before ppt_build carried it
FAILED test_a_restored_helper_is_named_in_the_build_note
  AssertionError: the build names the helper it put back
```

The nudge was then driven through a real `AgentLoop` to see it arrive rather than inferred from the counter:

```
THRESHOLD=8  MAX=2
[loop] `list_dir` has returned the same result for the same arguments 8 times in
this turn. Calling it again will not answer differently. ...
```

Both directions of the new guard were probed:

```
identical call and result -> one key          (fires: correct)
a poll whose answer moves -> 8 distinct keys  (never fires: correct)
```

`lint-imports` is the gate GitLab CI does not run, and it is the one that has produced a blocker on this project before:

```
lint-imports
  Contracts: 9 kept, 0 broken.
```

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

**User-visible change.** Three, all additive:

1. A turn that repeats one exact call with one exact answer eight times gets one extra line appended to that tool result, at most twice per turn. Nothing is blocked, refused or rerouted.
2. A `ppt_build` that restored a helper the author had edited says so in its `note`. A build that restored nothing is unchanged.
3. A build directory with no template now ships a `ppt_theme` docstring that says to pick a theme by name. The code in that module is byte-identical; only the text differs.

**Disclosed, and deliberately not fixed.** The no-progress key includes the result, which is what separates a poll making progress from one that is not. A result carrying a clock or a per-call nonce therefore keys differently every time and never accumulates, so the guard is too tight in that direction rather than too loose. Widening it (hashing a prefix, stripping digits) would start firing on genuine progress, which is the worse failure for a nudge that tells the model to stop looking.

- [ ] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

No security surface: no new input is read, no capability is added, and all three texts are engine-authored.

Rollback is per cause and independent -- delete the `elif` in `turn_path` for the guard, drop the `if put_back:` block in `runner` for the note, or pass no flag to `theme_module_source` for the docstring.

## Related Issues

N/A

## Review round 1

Two blocking findings from @chandler.zhang, both reproduced and fixed, one commit each.

**The restore note was dropped on a failed build** (`02ef9aa4`). `outcome.note` was read only after the tool's success return, and two post-provision exits in `run_script` dropped it as well -- the missing-script return and the timeout. The livelock this note exists for was **nine failed `ppt_build` calls**, so as first submitted it would not have fired once in the incident that motivated it. The early exits above `stage.run` are pre-provision and correctly carry nothing. Now tested through `ppt_build`, on the tool's own JSON.

**The no-progress key hashed `model_text` alone** (`5a318c2e`). An image read answers with path, dimensions and a token estimate -- stable across a repaint at the same size -- while the pixels ride in the routed blocks, so a render-edit-inspect pass read "the same result" eight times in exactly the workflow the guard was added to help. Reproduced through the loop's own path:

```
what the loop counts on   : '[image: /tmp/.../slide.png] | 400x300px | ~165 tokens'
model_text identical      : True      # two 400x300 images, different pixels
image blocks identical    : False
no_progress key identical : True
```

Of the two remedies offered I took the first, a stable identity for the routed blocks, rather than excluding multimodal results -- so the guard stays live for an image loop that genuinely is stuck. Covered at loop level with a real repainted image and vision forced on, paired against the same loop over an unchanging one.

Re-verified at the rebased head (`origin/main` moved twice during review; both replays pure, `range-diff` all `=`):

```
python -m pytest tests/ -k "ppt_engine or agent_loop or turn_path or failure_streak or no_progress"
  3958 passed, 94 skipped in 87.74s

python -m ruff check / format --check   clean
lint-imports                            9 kept, 0 broken
scripts/check_commit_messages.py        exit 0
```

Each fix was watched red first:

```
FAILED test_a_restored_helper_is_named_on_a_failed_build
  AssertionError: a failed build does not name the helper it put back
FAILED test_changing_pixels_behind_one_metadata_line_are_progress
  AssertionError: a repainted image counted as no progress
```

## Review round 2

`1a601700`. @chandler.zhang's clearing note flagged a formatter-only failure and it was real: `ruff format --check` wanted the three-line `BuildOutcome(...)` added by `02ef9aa4` on one line, and the CI lint job reported it. Now clean over every changed `.py` file, with `ruff check` and the three ppt suites (208 passed) re-run after.

Cause on my side: `ruff check` was re-run over that edit and `ruff format --check` was not, and this repo's `core.hooksPath` points at a directory that does not exist, so nothing catches it at commit time.
## Summary

One livelock, three causes, all three closed here. A run read one file 127 times over an hour, exit 0 on every call, and ended its turn exactly where it started -- at iteration 343 of 600 when it was killed, with the file's md5 unchanged throughout.

**The guard did not arm.** `failure_streak` counts only hard failures and `turn_path` resets the streak on any success, so a call that keeps working and keeps answering the same thing is structurally invisible to it. `raven/agent/loop/no_progress.py` counts the other shape.

It counts **per turn rather than consecutively**, and that is the load-bearing decision: the run alternated two spellings of one read -- a plain read, and the same read behind an `md5sum` -- so no two neighbouring calls were identical and a consecutive streak would have stayed at one forever. The cost is that two spellings each reach the threshold on their own, so an alternating pair fires at about fifteen calls rather than eight. Against 127 that is the trade worth taking. A test pins the arithmetic so the design is not "simplified" back to consecutive.

Threshold 8 (higher than the failure threshold's 2, because repeating a working call is ordinary -- a poll, a re-read after an edit), capped at 2 per turn like the failure nudge, and it appends a nudge and nothing else.

**The cause was silent.** `provision` rewrites every helper beside the build script on every build. That is deliberate and its docstring says why (a quietly edited helper fails in a way that reads as a deck bug), but it said nothing -- so a run whose own `themes.json` went back to the engine's copy could not tell a reverted write from one that never happened. `ppt_build` now names what it put back, and says nothing when it put nothing back.

**The documentation contradicted the data.** The generated `ppt_theme` docstring promised a single entry and called a named theme a `KeyError`. That holds on the template route, where `with_template_helpers` narrows `themes.json` to the template's own palette. Without a template the same file carries the ten reviewed themes and naming one is how an author picks -- which `tests/test_ppt_engine_assets_script_helpers.py` itself does (`THEMES["indigo-scholar"]`). The text now follows the payload.

`ppt_charts` and `ppt_layout` opened with the same `T = THEMES[...]` line and carried the template route's claim beside it, so fixing `ppt_theme` alone left two generated modules telling a catalog-route author the opposite. They now point at the module that owns the rule, and a test pins that they do not restate it.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
python -m pytest tests/ -k "ppt_engine or agent_loop or turn_path
                            or failure_streak or no_progress"
  3956 passed, 94 skipped in 93.34s      (re-run at the rebased head)

python -m ruff check raven/agent/loop/ plugins-dist/ppt-engine/raven_ppt/backends/script/ \
                     plugins-dist/ppt-engine/raven_ppt/services/assets/script_helpers.py \
                     tests/test_agent_loop_tool_loop_break.py \
                     tests/test_ppt_engine_assets_script_helpers.py \
                     tests/test_ppt_engine_script_backend.py
  All checks passed!
python -m ruff format --check <the eight changed files>
  8 files already formatted

python scripts/check_commit_messages.py origin/main..HEAD
  exit 0, 1 commit
```

The 94 skips are the pre-existing `templates.manifest.json` payload skips; they are identical on `origin/main`.

Each of the three was watched red first, and the loop guard was mutation-tested rather than trusted:

```
# the nudge computed and then withheld (one line replaced by `pass`)
FAILED test_an_unchanging_successful_call_is_nudged_and_bounded
  AssertionError: an unchanging successful call was never nudged

# the one-home test against this repo's unmodified charts/layout
FAILED test_only_the_theme_module_states_the_theme_rule
  AssertionError: ppt_charts restates the template route's rule

# the restore note, before ppt_build carried it
FAILED test_a_restored_helper_is_named_in_the_build_note
  AssertionError: the build names the helper it put back
```

The nudge was then driven through a real `AgentLoop` to see it arrive rather than inferred from the counter:

```
THRESHOLD=8  MAX=2
[loop] `list_dir` has returned the same result for the same arguments 8 times in
this turn. Calling it again will not answer differently. ...
```

Both directions of the new guard were probed:

```
identical call and result -> one key          (fires: correct)
a poll whose answer moves -> 8 distinct keys  (never fires: correct)
```

`lint-imports` is the gate GitLab CI does not run, and it is the one that has produced a blocker on this project before:

```
lint-imports
  Contracts: 9 kept, 0 broken.
```

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

**User-visible change.** Three, all additive:

1. A turn that repeats one exact call with one exact answer eight times gets one extra line appended to that tool result, at most twice per turn. Nothing is blocked, refused or rerouted.
2. A `ppt_build` that restored a helper the author had edited says so in its `note`. A build that restored nothing is unchanged.
3. A build directory with no template now ships a `ppt_theme` docstring that says to pick a theme by name. The code in that module is byte-identical; only the text differs.

**Disclosed, and deliberately not fixed.** The no-progress key includes the result, which is what separates a poll making progress from one that is not. A result carrying a clock or a per-call nonce therefore keys differently every time and never accumulates, so the guard is too tight in that direction rather than too loose. Widening it (hashing a prefix, stripping digits) would start firing on genuine progress, which is the worse failure for a nudge that tells the model to stop looking.

- [ ] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

No security surface: no new input is read, no capability is added, and all three texts are engine-authored.

Rollback is per cause and independent -- delete the `elif` in `turn_path` for the guard, drop the `if put_back:` block in `runner` for the note, or pass no flag to `theme_module_source` for the docstring.

## Related Issues

N/A

## Review round 1

Two blocking findings from @chandler.zhang, both reproduced and fixed, one commit each.

**The restore note was dropped on a failed build** (`02ef9aa4`). `outcome.note` was read only after the tool's success return, and two post-provision exits in `run_script` dropped it as well -- the missing-script return and the timeout. The livelock this note exists for was **nine failed `ppt_build` calls**, so as first submitted it would not have fired once in the incident that motivated it. The early exits above `stage.run` are pre-provision and correctly carry nothing. Now tested through `ppt_build`, on the tool's own JSON.

**The no-progress key hashed `model_text` alone** (`5a318c2e`). An image read answers with path, dimensions and a token estimate -- stable across a repaint at the same size -- while the pixels ride in the routed blocks, so a render-edit-inspect pass read "the same result" eight times in exactly the workflow the guard was added to help. Reproduced through the loop's own path:

```
what the loop counts on   : '[image: /tmp/.../slide.png] | 400x300px | ~165 tokens'
model_text identical      : True      # two 400x300 images, different pixels
image blocks identical    : False
no_progress key identical : True
```

Of the two remedies offered I took the first, a stable identity for the routed blocks, rather than excluding multimodal results -- so the guard stays live for an image loop that genuinely is stuck. Covered at loop level with a real repainted image and vision forced on, paired against the same loop over an unchanging one.

Re-verified at the rebased head (`origin/main` moved twice during review; both replays pure, `range-diff` all `=`):

```
python -m pytest tests/ -k "ppt_engine or agent_loop or turn_path or failure_streak or no_progress"
  3958 passed, 94 skipped in 87.74s

python -m ruff check / format --check   clean
lint-imports                            9 kept, 0 broken
scripts/check_commit_messages.py        exit 0
```

Each fix was watched red first:

```
FAILED test_a_restored_helper_is_named_on_a_failed_build
  AssertionError: a failed build does not name the helper it put back
FAILED test_changing_pixels_behind_one_metadata_line_are_progress
  AssertionError: a repainted image counted as no progress
```

## Review round 2

`1a601700`. @chandler.zhang's clearing note flagged a formatter-only failure and it was real: `ruff format --check` wanted the three-line `BuildOutcome(...)` added by `02ef9aa4` on one line, and the CI lint job reported it. Now clean over every changed `.py` file, with `ruff check` and the three ppt suites (208 passed) re-run after.

Cause on my side: `ruff check` was re-run over that edit and `ruff format --check` was not, and this repo's `core.hooksPath` points at a directory that does not exist, so nothing catches it at commit time.
## Summary

A turn ends on the first reply without a tool call, and the design engine's `completion_notice` rides that reply out. In a direct chat that is a nudge with a next turn to act on it. Dispatched as a DAG node there is no next turn, so the same text reaches the caller's `dag_verdict` judge as a verdict.

The run that prompted this wrote a 147 KB page, took ten desktop screenshots and three narrow ones, reported the absolute path, and was failed. Its ledger's last revision was written 37 minutes before the reply, with two items still `in_progress` and `pending` -- the work in them had been done and never ticked. The notice was accurate; where it landed made it a verdict on work that existed.

`DesignEngineHook.after_iteration` now sends such a turn back **once**, carrying the notice plus an instruction to settle the record, using the `rollback` + `rollback_inject` seam the hook contract documents for exactly this ("bouncing a draft back with feedback").

Two decisions worth stating:

- **Decided from the ledger, not from the prose.** Sniffing whether a reply "reads as finished" is a language judgement and would need a marker list per language. Settling the record is an action the agent can always take, and both honest outcomes -- ticking what is done, or naming in the reply what is not -- are reachable from one nudge.
- **`after_send` is untouched.** If the second answer still leaves items open, the notice is appended exactly as before and the judge fails the node. That case is a fact the judge should see, so the fallback is the unchanged path rather than a new branch.

This is the guard `plugins-dist/ppt-engine` has had at the same seat (`UNFINISHED_NUDGE`, `hook.py`). The design engine was the one of the two without it.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
python -m pytest tests/ -k "design_engine or loop_hooks or iteration_hooks
                            or hook_contract or hook_adapters"
  217 passed in 33.68s         (re-run at the rebased head)

python -m ruff check   plugins-dist/design-engine/raven_design/plugin/hook.py \
                       tests/test_design_engine_plugin_face.py
  All checks passed!
python -m ruff format --check <same two files>
  2 files already formatted

python scripts/check_commit_messages.py origin/main..HEAD
  exit 0, 1 commit
```

The new test was watched red before the fix and again after it was strengthened, both times against this repo's own unmodified copy of the engine:

```
FAILED tests/test_design_engine_plugin_face.py::test_stale_ledger_sends_the_turn_back_once
  assert False is True     (the base class returns a pass-through decision)
```

It drives the real `AgentHookContext`, the real `LLMResponse` and a real `TaskStateManager`, matching `tests/test_agents_oncall_flow_hooks.py` rather than the `SimpleNamespace` its neighbours in the same file use for `after_send`.

The instruction's means of compliance was checked rather than assumed: `make_update_task_state` returns `None` unless `shared.manager` is set, and `make_hook` is handed that same `shared.manager`, so the nudge cannot arrive on an install where the `update_task_state` tool that satisfies it is absent.

`lint-imports` is the gate GitLab CI does not run, and it is the one that has produced a blocker on this project before:

```
lint-imports
  Contracts: 9 kept, 0 broken.
```

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

**User-visible change.** A design-engine turn that would have ended on an unfinished ledger now costs one extra model call. It fires on both lanes: in a direct chat the reader waits one call longer and gets a reply whose ledger agrees with it; on a dispatch the caller's judge stops failing finished work.

Bounded three ways: once per turn (a flag in `ctx.metadata`), then the loop's own `_MAX_HOOK_ROLLBACKS = 8`, and a rollback consumes no iteration.

- [ ] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

No security surface: the change adds no capability, reads no new input, and the injected text is engine-authored.

Rollback is deleting the `after_iteration` method -- the base class's pass-through restores today's behaviour exactly, and `after_send` is unmodified.

## Related Issues

N/A

## Review round 1

One blocking finding from @chandler.zhang, reproduced and fixed.

`after_iteration` fires after `stream_llm_call` has delivered every content delta, so the rollback popped history the reader had already seen, and the ACP collector concatenated the rejected draft with its replacement. Worse than reported in one respect: the retry did not tick the ledger either, so `after_send` appended the notice again -- the caller received draft, reply and notice, strictly worse than the stale notice this change was written to prevent.

```
before: ['FIRST DRAFT', 'FINAL REPLY', '<the task-state notice>']
after:  ['FINAL REPLY', '<the task-state notice>']
```

`DraftGate` (in `recovery.py`, beside `ContinuationGate`) holds a response's deltas until something keeps it. Released as soon as a response carries tool calls, so a preamble is not parked for a whole tool run; where both gates would apply the draft gate owns the continuation head cut, because holding the whole text cuts it exactly rather than on a prefix.

**Two deviations from the remedy as asked, both deliberate.**

Asked for it buffered "on this lane". There is no lane to scope to: `raven/rpc/spine.py` runs the human TUI turn and the ACP turn through the same `run_turn(..., stream=True)`, and only a CRON turn takes `stream=False`.

Scoped by capability instead -- the gate installs only where an installed hook overrides `after_iteration`. That is narrower than a lane check, and a regression is what found it. Buffering coalesces the reply into one delta, and with the predicate on `hook_ctx is not None` two existing tests went red on the incremental sequence (`assert ['Hello'] == ['Hel', 'lo']`). `_Announce` there overrides only `after_send`, so under the narrowed predicate it installs no gate and both tests stand unchanged. They are the guard on that half; nothing was weakened to make room.

Also corrected: the comment above that hook site claimed a rollback "discards-and-re-samples it", true of history and false of the stream.

**Scope this closes beyond the finding.** `plugins-dist/ppt-engine`'s `after_iteration` rolls back a text response the same way and had the identical exposure. This closes it too, the defect being a property of the seam rather than of either hook.

**Disclosed, not fixed.** A hook's `short_circuit_result` still streams the draft it replaces. That path is untouched here and predates this branch.

Re-verified at the rebased head (`origin/main` moved twice during review; both replays pure, `range-diff` all `=`). The base's own commits touched the ACP question surface, so those suites are in the re-run rather than only mine:

```
python -m pytest tests/ -k "design_engine or loop_hooks or iteration_hooks or hook_contract
                            or hook_adapters or run_emit or agent_loop_stream or acp_questions
                            or acp_elicitation or acp_dialects or ask_user"
  665 passed in 34.09s

python -m pytest tests/            19954 passed, 138 skipped in 519.84s
python -m ruff check / format --check   clean
lint-imports                            9 kept, 0 broken
scripts/check_commit_messages.py        exit 0
```

Covered at the loop level rather than by calling the hook. Disabling the gate turns it red:

```
FAILED test_a_rolled_back_draft_never_reaches_the_stream
  AssertionError: the caller saw the rejected draft: ['FIRST DRAFT', 'FINAL REPLY']
```

## Review round 3

@chandler.zhang's round-two blocker: the draft gate was scoped by whether a hook *overrides* `after_iteration`, which only says the hook watches the phase. `eval_engine`'s `AfterIterationHook` overrides it, awaits an LLM judge inside it, and its own docstring says an evaluator should never interrupt the user's reply chain -- so enabling the Eval Engine took incremental output away from a supported configuration and held the answer until the judge returned, for a draft that was never going to be rejected. Reproduced: `['Hel', 'lo']` arrived as `['Hello']`.

`AgentHook` now declares `rolls_back_iterations` (default `False`) and the loop reads the declaration. The design engine's hook and the deck engine's hook declare it -- the latter because this description claims the deck engine's exposure is closed, and the declaration is what keeps that true.

The observer path is pinned, and the pin asserts the await completed as well as the chunk sequence: `CompositeHook` swallows a hook exception, so a probe that raised past its await would otherwise pass while proving half of what it claimed. The first draft of that test did exactly that.

**Disclosed here, closed in round four.** Six research-flow gates under `agents/raven-research` (`evidence_floor`, `finalize`, `plain_first`, `report_shape`, `spin_breaker`, `verify`) answer `rollback` while their contributed hook does not declare it, so at this revision their rejected drafts still stream as they do on `main` today.

```
pytest tests/                                     19960 passed, 139 skipped
ruff check + format --check over the job's targets clean, 1849 files
lint-imports                                       9 kept, 0 broken
```

## Review round 4

@chandler.zhang's nonblocking note from round three, closed. `ResearchFlowHook` forwards every phase to a composite it builds per session, and six of that chain's gates answer `rollback`. The loop reads `rolls_back_iterations` off the contributed hook object once per turn, before any session chain exists, so nothing could derive the flag from the members that carry the behaviour -- and nothing in `flow.py` hinted the declaration was needed. The hook now declares it, with the reason recorded at the attribute.

**Enumerated rather than taken on the note.** Every `HookDecision` built with `rollback=True` across `agents`, `plugins-dist`, `raven` and `evolver` was located by AST; the research flow was the only tree whose contributed hook did not declare. The deck and design engines declare it in the same class that rolls back. `raven-code` and `raven-oncall` contribute hooks that never roll back and correctly leave the flag `False`, so this is not a case of one lane opting out of a rule. The research manifest declares exactly one hook contribution and its other three are tools, so no gate is registered directly and the contributed hook is the only level the declaration can sit at.

**Not a regression.** Draft suppression did not exist before this branch, so the research flow never held its deltas either. It gains the guarantee rather than losing one, which is why the note was graded nonblocking and why it is closed here rather than earlier.

The new test derives that producing set instead of listing it, so the omission cannot recur silently: a tree that grows a rollback and is not mapped fails as an unmapped root, and two further cases guard the derivation itself against passing vacuously. It reads the source by AST rather than importing, because `raven_design` and `raven_ppt` are editable installs rooted at the main checkout, and importing them from a worktree reads the main checkout's code instead of the revision under test.

Watched red before the fix and green after, with the derivation guards passing both times:

```
FAILED test_a_tree_that_rolls_back_declares_it_on_its_contributed_hook[agents/raven-research/...]
  AssertionError: ResearchFlowHook can roll back an iteration but does not declare
  rolls_back_iterations = True, so the loop streams a draft it may then retract
  1 failed, 4 passed
```

Read back through the loop's own predicate rather than off the class, in both directions: a real `ResearchFlowHook` instance now makes the loop hold drafts, and `eval_engine`'s judge -- which overrides the phase and only annotates -- still does not. The declaration is also not a `dataclass` field, so the hook's constructor signature is unchanged; an annotated spelling would have added a parameter and could not have sat above the fields without a default-ordering error.

Re-verified at the rebased head. `origin/main` moved six commits during review; the replay is pure (`range-diff` reports every commit `=`) and the base's three changes touch no file this branch touches, with the repo-wide rollback-producer set identical on both sides.

```
pytest tests/ -k research                            917 passed
pytest (loop, hook contract, iteration hooks, design face)   139 passed
ruff check + format --check over the job's targets   clean, 1850 files
lint-imports                                         9 kept, 0 broken
check_commit_messages.py / check_source_language.py  exit 0
make check-large-files                               exit 0
```
## Summary

A turn ends on the first reply without a tool call, and the design engine's `completion_notice` rides that reply out. In a direct chat that is a nudge with a next turn to act on it. Dispatched as a DAG node there is no next turn, so the same text reaches the caller's `dag_verdict` judge as a verdict.

The run that prompted this wrote a 147 KB page, took ten desktop screenshots and three narrow ones, reported the absolute path, and was failed. Its ledger's last revision was written 37 minutes before the reply, with two items still `in_progress` and `pending` -- the work in them had been done and never ticked. The notice was accurate; where it landed made it a verdict on work that existed.

`DesignEngineHook.after_iteration` now sends such a turn back **once**, carrying the notice plus an instruction to settle the record, using the `rollback` + `rollback_inject` seam the hook contract documents for exactly this ("bouncing a draft back with feedback").

Two decisions worth stating:

- **Decided from the ledger, not from the prose.** Sniffing whether a reply "reads as finished" is a language judgement and would need a marker list per language. Settling the record is an action the agent can always take, and both honest outcomes -- ticking what is done, or naming in the reply what is not -- are reachable from one nudge.
- **`after_send` is untouched.** If the second answer still leaves items open, the notice is appended exactly as before and the judge fails the node. That case is a fact the judge should see, so the fallback is the unchanged path rather than a new branch.

This is the guard `plugins-dist/ppt-engine` has had at the same seat (`UNFINISHED_NUDGE`, `hook.py`). The design engine was the one of the two without it.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
python -m pytest tests/ -k "design_engine or loop_hooks or iteration_hooks
                            or hook_contract or hook_adapters"
  217 passed in 33.68s         (re-run at the rebased head)

python -m ruff check   plugins-dist/design-engine/raven_design/plugin/hook.py \
                       tests/test_design_engine_plugin_face.py
  All checks passed!
python -m ruff format --check <same two files>
  2 files already formatted

python scripts/check_commit_messages.py origin/main..HEAD
  exit 0, 1 commit
```

The new test was watched red before the fix and again after it was strengthened, both times against this repo's own unmodified copy of the engine:

```
FAILED tests/test_design_engine_plugin_face.py::test_stale_ledger_sends_the_turn_back_once
  assert False is True     (the base class returns a pass-through decision)
```

It drives the real `AgentHookContext`, the real `LLMResponse` and a real `TaskStateManager`, matching `tests/test_agents_oncall_flow_hooks.py` rather than the `SimpleNamespace` its neighbours in the same file use for `after_send`.

The instruction's means of compliance was checked rather than assumed: `make_update_task_state` returns `None` unless `shared.manager` is set, and `make_hook` is handed that same `shared.manager`, so the nudge cannot arrive on an install where the `update_task_state` tool that satisfies it is absent.

`lint-imports` is the gate GitLab CI does not run, and it is the one that has produced a blocker on this project before:

```
lint-imports
  Contracts: 9 kept, 0 broken.
```

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

**User-visible change.** A design-engine turn that would have ended on an unfinished ledger now costs one extra model call. It fires on both lanes: in a direct chat the reader waits one call longer and gets a reply whose ledger agrees with it; on a dispatch the caller's judge stops failing finished work.

Bounded three ways: once per turn (a flag in `ctx.metadata`), then the loop's own `_MAX_HOOK_ROLLBACKS = 8`, and a rollback consumes no iteration.

- [ ] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

No security surface: the change adds no capability, reads no new input, and the injected text is engine-authored.

Rollback is deleting the `after_iteration` method -- the base class's pass-through restores today's behaviour exactly, and `after_send` is unmodified.

## Related Issues

N/A

## Review round 1

One blocking finding from @chandler.zhang, reproduced and fixed.

`after_iteration` fires after `stream_llm_call` has delivered every content delta, so the rollback popped history the reader had already seen, and the ACP collector concatenated the rejected draft with its replacement. Worse than reported in one respect: the retry did not tick the ledger either, so `after_send` appended the notice again -- the caller received draft, reply and notice, strictly worse than the stale notice this change was written to prevent.

```
before: ['FIRST DRAFT', 'FINAL REPLY', '<the task-state notice>']
after:  ['FINAL REPLY', '<the task-state notice>']
```

`DraftGate` (in `recovery.py`, beside `ContinuationGate`) holds a response's deltas until something keeps it. Released as soon as a response carries tool calls, so a preamble is not parked for a whole tool run; where both gates would apply the draft gate owns the continuation head cut, because holding the whole text cuts it exactly rather than on a prefix.

**Two deviations from the remedy as asked, both deliberate.**

Asked for it buffered "on this lane". There is no lane to scope to: `raven/rpc/spine.py` runs the human TUI turn and the ACP turn through the same `run_turn(..., stream=True)`, and only a CRON turn takes `stream=False`.

Scoped by capability instead -- the gate installs only where an installed hook overrides `after_iteration`. That is narrower than a lane check, and a regression is what found it. Buffering coalesces the reply into one delta, and with the predicate on `hook_ctx is not None` two existing tests went red on the incremental sequence (`assert ['Hello'] == ['Hel', 'lo']`). `_Announce` there overrides only `after_send`, so under the narrowed predicate it installs no gate and both tests stand unchanged. They are the guard on that half; nothing was weakened to make room.

Also corrected: the comment above that hook site claimed a rollback "discards-and-re-samples it", true of history and false of the stream.

**Scope this closes beyond the finding.** `plugins-dist/ppt-engine`'s `after_iteration` rolls back a text response the same way and had the identical exposure. This closes it too, the defect being a property of the seam rather than of either hook.

**Disclosed, not fixed.** A hook's `short_circuit_result` still streams the draft it replaces. That path is untouched here and predates this branch.

Re-verified at the rebased head (`origin/main` moved twice during review; both replays pure, `range-diff` all `=`). The base's own commits touched the ACP question surface, so those suites are in the re-run rather than only mine:

```
python -m pytest tests/ -k "design_engine or loop_hooks or iteration_hooks or hook_contract
                            or hook_adapters or run_emit or agent_loop_stream or acp_questions
                            or acp_elicitation or acp_dialects or ask_user"
  665 passed in 34.09s

python -m pytest tests/            19954 passed, 138 skipped in 519.84s
python -m ruff check / format --check   clean
lint-imports                            9 kept, 0 broken
scripts/check_commit_messages.py        exit 0
```

Covered at the loop level rather than by calling the hook. Disabling the gate turns it red:

```
FAILED test_a_rolled_back_draft_never_reaches_the_stream
  AssertionError: the caller saw the rejected draft: ['FIRST DRAFT', 'FINAL REPLY']
```

## Review round 3

@chandler.zhang's round-two blocker: the draft gate was scoped by whether a hook *overrides* `after_iteration`, which only says the hook watches the phase. `eval_engine`'s `AfterIterationHook` overrides it, awaits an LLM judge inside it, and its own docstring says an evaluator should never interrupt the user's reply chain -- so enabling the Eval Engine took incremental output away from a supported configuration and held the answer until the judge returned, for a draft that was never going to be rejected. Reproduced: `['Hel', 'lo']` arrived as `['Hello']`.

`AgentHook` now declares `rolls_back_iterations` (default `False`) and the loop reads the declaration. The design engine's hook and the deck engine's hook declare it -- the latter because this description claims the deck engine's exposure is closed, and the declaration is what keeps that true.

The observer path is pinned, and the pin asserts the await completed as well as the chunk sequence: `CompositeHook` swallows a hook exception, so a probe that raised past its await would otherwise pass while proving half of what it claimed. The first draft of that test did exactly that.

**Disclosed here, closed in round four.** Six research-flow gates under `agents/raven-research` (`evidence_floor`, `finalize`, `plain_first`, `report_shape`, `spin_breaker`, `verify`) answer `rollback` while their contributed hook does not declare it, so at this revision their rejected drafts still stream as they do on `main` today.

```
pytest tests/                                     19960 passed, 139 skipped
ruff check + format --check over the job's targets clean, 1849 files
lint-imports                                       9 kept, 0 broken
```

## Review round 4

@chandler.zhang's nonblocking note from round three, closed. `ResearchFlowHook` forwards every phase to a composite it builds per session, and six of that chain's gates answer `rollback`. The loop reads `rolls_back_iterations` off the contributed hook object once per turn, before any session chain exists, so nothing could derive the flag from the members that carry the behaviour -- and nothing in `flow.py` hinted the declaration was needed. The hook now declares it, with the reason recorded at the attribute.

**Enumerated rather than taken on the note.** Every `HookDecision` built with `rollback=True` across `agents`, `plugins-dist`, `raven` and `evolver` was located by AST; the research flow was the only tree whose contributed hook did not declare. The deck and design engines declare it in the same class that rolls back. `raven-code` and `raven-oncall` contribute hooks that never roll back and correctly leave the flag `False`, so this is not a case of one lane opting out of a rule. The research manifest declares exactly one hook contribution and its other three are tools, so no gate is registered directly and the contributed hook is the only level the declaration can sit at.

**Not a regression.** Draft suppression did not exist before this branch, so the research flow never held its deltas either. It gains the guarantee rather than losing one, which is why the note was graded nonblocking and why it is closed here rather than earlier.

The new test derives that producing set instead of listing it, so the omission cannot recur silently: a tree that grows a rollback and is not mapped fails as an unmapped root, and two further cases guard the derivation itself against passing vacuously. It reads the source by AST rather than importing, because `raven_design` and `raven_ppt` are editable installs rooted at the main checkout, and importing them from a worktree reads the main checkout's code instead of the revision under test.

Watched red before the fix and green after, with the derivation guards passing both times:

```
FAILED test_a_tree_that_rolls_back_declares_it_on_its_contributed_hook[agents/raven-research/...]
  AssertionError: ResearchFlowHook can roll back an iteration but does not declare
  rolls_back_iterations = True, so the loop streams a draft it may then retract
  1 failed, 4 passed
```

Read back through the loop's own predicate rather than off the class, in both directions: a real `ResearchFlowHook` instance now makes the loop hold drafts, and `eval_engine`'s judge -- which overrides the phase and only annotates -- still does not. The declaration is also not a `dataclass` field, so the hook's constructor signature is unchanged; an annotated spelling would have added a parameter and could not have sat above the fields without a default-ordering error.

Re-verified at the rebased head. `origin/main` moved six commits during review; the replay is pure (`range-diff` reports every commit `=`) and the base's three changes touch no file this branch touches, with the repo-wide rollback-producer set identical on both sides.

```
pytest tests/ -k research                            917 passed
pytest (loop, hook contract, iteration hooks, design face)   139 passed
ruff check + format --check over the job's targets   clean, 1850 files
lint-imports                                         9 kept, 0 broken
check_commit_messages.py / check_source_language.py  exit 0
make check-large-files                               exit 0
```
…op repeating noise

## Summary

Six recorded Raven-PPT deck runs (34, 13, 8, 3, 20 and 20 pages) were read build by build for what every gate verdict and second-reader finding said and what the author did next. The useful catches (a word painted over another, copy past its box, a rule through a word, a clipped table, an orphan line) were all fixed within the first ten whole-deck builds and three readings of any run. What followed was churn: findings the author never acted on, findings that contradicted the brief, findings on a page the runner had stood in for, and readings that described a different page than the one they named. Design decision D35 in `plugins-dist/ppt-engine/docs/ppt-raven-design.md`.

### Effort tiers

`agents/raven-ppt/modes/{medium,high}.json` declare the host's ladder; the source config is `max` (no overlay). The launcher writes `acp.modes` with `defaultMode: high`.

| tier | author effort | whole-deck builds | readings |
|---|---|---|---|
| medium | low | 10 | 3 |
| high (default) | as configured (medium) | 10 | 3 |
| max | as configured | no cap | no cap |

At the build cap the stage publishes past its blocking findings and the reply names them under `released_at_cap`; at the reading cap the second reader stops and the reply says so. The plugin hook writes the session's mode overlay to `.deck-mode.json` in the deck folder (`services/tier.py`); `ppt_build` reads it per call, so a tier switched with `session/set_mode` takes effect on the next build. Evidence for the numbers: the self-evolving deck's blocking count stopped falling at build 20 and six more whole-deck builds (11.5 minutes) produced only misread claims, taste findings and a user-requested cover change; across the six runs no finding fixed after the third reading was more than taste, except one watermark catch on one run.

### Gates that stop repeating (`services/gates/quiet.py`)

- A page the runner stood in for carries only the `page_failed` finding; the two to four riders about the placeholder (its font floor, decoration share, likeness to the page before) are dropped.
- `repeated_layout` is not reported for adjacent pages the outline gives the same prototype or layout: seventeen reports on one deck, all on the fifteen paper pages the user had confirmed as one uniform format.
- `prototype_kept` ("N% of its shapes sit where the template's page puts one") is said once per page: 26 reports across 12 pages, acted on zero times, still present at delivery.
- `excessive_whitespace` and `sparse_container` are not reported when the brief asks for air (white ground, generous whitespace, minimal): 38 requests to fill on a deck whose brief said exactly that.

### Reader

- `type`, `alignment` and `listed` findings stay in the reading's own reply and are not carried in the ledger (26, 19 and 22 entries reworded after every fix and pressed every build).
- A draft shorter than the outline gets no plan and files no `claim` finding: a 12-page draft was judged against page N of a 34-page plan.
- The reader now quotes the page's `headline`; a reading whose headline is not on the page it names is dropped and counted in `last_reading.misread`. Fourteen `claim` findings on one deck described another page (page 1's entry read as a contents page, page 8's as a paper page). An offline replay of that deck through the same `_read`, the same model and the same 16-way concurrency did not reproduce the crossing, so this check stands as the guard rather than a root-cause fix.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
uv run --frozen ruff check <touched files>
uv run --frozen pytest tests/test_ppt_engine_quiet_and_tier.py tests/test_ppt_engine_build_tool.py \
  tests/test_ppt_engine_build_stage.py tests/test_ppt_engine_review_tool.py tests/test_ppt_engine_plugin.py \
  tests/test_agents_ppt_launcher.py -q
```

218 passed in the seven touched suites; the full ppt-engine set plus launcher and render suites: 3448 passed, 95 skipped (template payloads not tracked).

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- Behaviour change on the default tier: a deck run now stops reading after three readings and publishes past blocking findings after ten whole-deck builds, naming them. `max` restores the previous behaviour; the caps live in two JSON files.
- The quiet rules drop warnings only; every blocking gate still refuses a deck, except at the build cap where the release is explicit in the reply.
- A dropped reading (headline not on page) costs that page's findings for that round; the next build reads it again.
- Rollback: revert the squash commit; `.deck-mode.json`, `said-once.json` and `builds.json` are per-deck files nothing else reads.

## Related Issues

N/A
…op repeating noise

## Summary

Six recorded Raven-PPT deck runs (34, 13, 8, 3, 20 and 20 pages) were read build by build for what every gate verdict and second-reader finding said and what the author did next. The useful catches (a word painted over another, copy past its box, a rule through a word, a clipped table, an orphan line) were all fixed within the first ten whole-deck builds and three readings of any run. What followed was churn: findings the author never acted on, findings that contradicted the brief, findings on a page the runner had stood in for, and readings that described a different page than the one they named. Design decision D35 in `plugins-dist/ppt-engine/docs/ppt-raven-design.md`.

### Effort tiers

`agents/raven-ppt/modes/{medium,high}.json` declare the host's ladder; the source config is `max` (no overlay). The launcher writes `acp.modes` with `defaultMode: high`.

| tier | author effort | whole-deck builds | readings |
|---|---|---|---|
| medium | low | 10 | 3 |
| high (default) | as configured (medium) | 10 | 3 |
| max | as configured | no cap | no cap |

At the build cap the stage publishes past its blocking findings and the reply names them under `released_at_cap`; at the reading cap the second reader stops and the reply says so. The plugin hook writes the session's mode overlay to `.deck-mode.json` in the deck folder (`services/tier.py`); `ppt_build` reads it per call, so a tier switched with `session/set_mode` takes effect on the next build. Evidence for the numbers: the self-evolving deck's blocking count stopped falling at build 20 and six more whole-deck builds (11.5 minutes) produced only misread claims, taste findings and a user-requested cover change; across the six runs no finding fixed after the third reading was more than taste, except one watermark catch on one run.

### Gates that stop repeating (`services/gates/quiet.py`)

- A page the runner stood in for carries only the `page_failed` finding; the two to four riders about the placeholder (its font floor, decoration share, likeness to the page before) are dropped.
- `repeated_layout` is not reported for adjacent pages the outline gives the same prototype or layout: seventeen reports on one deck, all on the fifteen paper pages the user had confirmed as one uniform format.
- `prototype_kept` ("N% of its shapes sit where the template's page puts one") is said once per page: 26 reports across 12 pages, acted on zero times, still present at delivery.
- `excessive_whitespace` and `sparse_container` are not reported when the brief asks for air (white ground, generous whitespace, minimal): 38 requests to fill on a deck whose brief said exactly that.

### Reader

- `type`, `alignment` and `listed` findings stay in the reading's own reply and are not carried in the ledger (26, 19 and 22 entries reworded after every fix and pressed every build).
- A draft shorter than the outline gets no plan and files no `claim` finding: a 12-page draft was judged against page N of a 34-page plan.
- The reader now quotes the page's `headline`; a reading whose headline is not on the page it names is dropped and counted in `last_reading.misread`. Fourteen `claim` findings on one deck described another page (page 1's entry read as a contents page, page 8's as a paper page). An offline replay of that deck through the same `_read`, the same model and the same 16-way concurrency did not reproduce the crossing, so this check stands as the guard rather than a root-cause fix.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
uv run --frozen ruff check <touched files>
uv run --frozen pytest tests/test_ppt_engine_quiet_and_tier.py tests/test_ppt_engine_build_tool.py \
  tests/test_ppt_engine_build_stage.py tests/test_ppt_engine_review_tool.py tests/test_ppt_engine_plugin.py \
  tests/test_agents_ppt_launcher.py -q
```

218 passed in the seven touched suites; the full ppt-engine set plus launcher and render suites: 3448 passed, 95 skipped (template payloads not tracked).

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- Behaviour change on the default tier: a deck run now stops reading after three readings and publishes past blocking findings after ten whole-deck builds, naming them. `max` restores the previous behaviour; the caps live in two JSON files.
- The quiet rules drop warnings only; every blocking gate still refuses a deck, except at the build cap where the release is explicit in the reply.
- A dropped reading (headline not on page) costs that page's findings for that round; the next build reads it again.
- Rollback: revert the squash commit; `.deck-mode.json`, `said-once.json` and `builds.json` are per-deck files nothing else reads.

## Related Issues

N/A
## Summary

Overhaul the interactive `raven trajectory` browser end to end, fixing
every
issue from real-terminal testing:

- Navigation: every screen opens with a help area under its title (page
  purpose plus "[Key] function" hotkey hints, rendered in a new readable
help color). Esc walks one level up (action -> attempt -> session); on
the
top-level session screen Esc stays put and quitting is Ctrl+C only,
which
prints a single exit notice. Esc inside a running action cancels just
that
action through a dedicated exception at the action boundary, so the back
  sentinel can never reach a conversion or the data layer; a cancelled
  report keeps its pre-confirm bundle-and-pin side effects (the existing
  declined-report contract). Back/Exit menu entries are gone; answered
  prompts erase themselves and navigation is echoed as breadcrumb lines
  with markup-escaped untrusted text.
- Tables: the session list is TITLE | ATTEMPTS | LAST ACTIVITY and the
  attempt list is # | STARTED | TURNS | SPANS | VERDICT | PIN | MERGED |
PREVIEW with green check cells. Time cells keep the year. The layout is
  budgeted per screen build from the live terminal width (row indent, a
safety margin, and inter-column gaps are all part of the math); PREVIEW
  drops as a whole column when it no longer fits, and below the dynamic
  minimum width the tightest layout is kept while the renderer clips
  overlong lines at the terminal edge. Every dynamic cell passes a
  single-line sanitization gate before width math (verdict sidecars only
  guarantee a non-empty string), cells are measured and truncated by
terminal display width (CJK is 2 cells), and fixed column widths always
  fit their headers.
- Space preview: Space on an attempt row prints that attempt's per-turn
input/output previews, collected in the same snapshot scan and sorted by
a fully-stringified key so ordering never follows log source order (the
  table PREVIEW cell derives from the same sorted collection). Any key,
arrow keys included, returns with the cursor kept on the row; the waiter
  passes the terminal's CPR reply through to the renderer instead of
  treating it as a keypress, and Ctrl+C keeps the browser-wide cancel.
- Merge hotkey: with two or more attempts, m/M opens the multi-select
merge
  (advertised in the help line; the old "Merge attempts" menu entry is
gone). The checkbox screen carries its own help line for toggle,
confirm,
  and cancel. A single attempt neither binds nor advertises the key.
- Theme and feel: new success (green check) and help (readable hint
text)
colors in both palettes, all WCAG AA on their backgrounds; a lone ESC is
flushed after 50ms instead of prompt_toolkit's 0.5s default, removing
the
  laggy Esc feel.

The data layer is untouched: changes are confined to
raven/cli/trajectory_browse.py, raven/cli/_theme.py, and their tests.
The
id-taking subcommands (raven trajectory save/list/...) behave as before.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `COLORTERM=truecolor uv run pytest tests/test_cli_theme.py
tests/test_cli_trajectory_browse.py
tests/test_cli_trajectory_commands.py -q` -> 220 passed
- `COLORTERM=truecolor uv run pytest tests/ --ignore=tests/integration
-q` -> 6917 passed; the 10 failed + 20 errors are the pre-existing
environment-dependent set, identical file-by-file on a clean main
checkout (cron, everos server, config loader, tracing viewer)
- `uv run ruff check` and `uv run ruff format --check` on the four
touched files -> clean
- PTY smoke on a CPR-answering pseudo terminal at 100 and 58 columns:
full flow (preview, merge cancel, action screen, staged Esc backout,
Ctrl+C quit) and the narrow-width degradation contract
- New coverage includes real pipe-input key-binding tests (Esc on all
four prompt kinds, Space/m/M hits, waiter key paths with CPR
pass-through), table budget/clipping boundaries at the computed minimum
width, and corrupt verdict/timestamp corpora

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- The interactive browser's look and key protocol change wholesale (that
is
  the point of this PR): Esc no longer quits from the top level (Ctrl+C
  does), Back/Exit entries are gone, and lists render as tables. The
machine-facing subcommands and the trajectory data layer are untouched.
- Rollback: revert the squash commit; the browser returns to the
previous
  interaction wholesale, no data migration involved.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: 江国庆 <guoqingjiang@deepglint.com>
Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
@gloryfromca
gloryfromca force-pushed the feat/trajectory_full_preview branch from 0c4efc3 to 6604d85 Compare September 11, 2026 17:43
@gloryfromca

Copy link
Copy Markdown
Member

This PR was auto-closed by a history rewrite, not by a maintainer

On 2026-09-12 the main branch of this repository (and its GitLab mirror) was replaced with a linearized history: all merge commits were folded into a single line and author emails were unified. Every commit received a new SHA, so GitHub automatically closed every open pull request whose branch was touched. The GitHub API refuses to reopen them.

Your work is safe. The source branch of this PR was rewritten onto the new main; its file contents are byte-for-byte identical to what they were before, and it still shows the correct diff against main.

To continue: open a new PR from the same branch. Please reference this PR number so the discussion stays traceable.

The pre-rewrite history is preserved at the tag backup/pre-linearize-20260912.

Sorry for the noise.

0xKT pushed a commit that referenced this pull request Sep 19, 2026
## Summary

Space on an attempt row in the `raven trajectory` browser previously
showed two truncated per-turn preview lines. This PR replaces it with an
interactive full-conversation viewer. It re-lands the content of PR
#382,
which was auto-closed by the 2026-09-12 history linearization without
being merged; the branch is rebased onto the linearized main, with one
extra commit expressing the CJK display-width test fixtures as unicode
escapes to satisfy the source-language gate that main gained since.

- Data layer `raven/trajectory/conversation.py`: rebuilds an attempt's
  span snapshot into a causally ordered stream of labeled records (User
  input, LLM input/output, Tool input/output, skill, memory, and
  subagent events). Event-time semantics (inputs at span start, outputs
  at span end) plus nesting depth from parentSpanId keep the causal
  order. Full content comes from artifact files (paths restricted to
  the trace store, streamed 512 KiB cap per file); span previews are
  only a visible fallback. ERROR spans and expected-but-unreadable
  payloads always yield a placeholder record. LLM inputs omit only the
  item-by-item verified common message prefix against the previous call
  (omissions and rewrites are announced); tool_calls of unexecuted
  tools keep their full arguments.
- Rendering: kind-colored labels aligned to one column, hanging-indent
  bodies with word-aware CJK-safe wrapping, degraded/error/meta note
  lines, single-pass turn separators (interleaved traces are never
  regrouped), and a stacked layout on narrow terminals that never drops
  characters.
- Interactive viewer (prompt_toolkit full-screen, entered for every
  preview on a terminal): scrolling, a muted bottom bar with an
  'h for help' hint, filter/collapsed state and the scroll range (laid
  out by display width, never overflowing any terminal), a scrollable
  help page reachable in full on small screens, a global collapse (s:
  bodies over five display lines fold to five plus an ellipsis; error
  and degradation notes never fold), and a label filter (%: real input
  line, case-insensitive, empty clears; filtered-out turn groups lose
  their separators). Resizes re-wrap and re-judge folding live; the
  content caret is hidden (only the filter input shows one). q/Esc
  returns to the list; Ctrl+C cancels the whole browser from any viewer
  state. A rebuild failure falls back to the legacy per-turn preview;
  without a terminal the preview degrades to direct printing.
- Every dynamic field passes the sanitization gates (control
  characters, rich markup, and ANSI never reach the terminal). Adds the
  Conversation Record term to CONTEXT.md.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `uv run pytest tests/test_trajectory_conversation.py
  tests/test_cli_preview_viewer.py tests/test_cli_trajectory_browse.py
  tests/test_trajectory_store.py -q` -> 298 passed (viewer assertions
  run against really rendered screens)
- `uv run pytest -q` (full, on the rebased branch) -> 23463 passed,
  125 skipped; 33 failed + 20 errors reproduce with identical node ids
  on a clean origin/main worktree - environment baseline, not
  introduced here (passed-count delta of 96 equals the tests this
  branch adds)
- `make check-source-language`, `make check-large-files`,
  `make lint-types`, `make lint-python`, `make lint-imports` -> all pass
- Manual: rendered 6 real local attempts at widths 72/120/30 with zero
  over-width lines (largest attempt 7060 lines in 0.35s); drove the
  real TUI in a pty at normal and 44x12 sizes (viewer, help paging,
  collapse, label filtering, Ctrl+C cancel)

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- Read/display side only; no persisted format changes. The untrusted
  input surface (artifact content, typed filter input) is contained by
  the store-prefix path check, the 512 KiB streamed cap, and per-field
  sanitization.
- User-visible change: the Space preview is now a full-screen viewer;
  the legacy rendering remains as the automatic fallback when the
  rebuild fails, and non-terminal environments print directly.
- Rollback: revert this PR, or restore the previous `_preview_screen`
  call site to get the old preview back.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
LivXue pushed a commit that referenced this pull request Oct 12, 2026
## Summary

Space on an attempt row in the `raven trajectory` browser previously
showed two truncated per-turn preview lines. This PR replaces it with an
interactive full-conversation viewer. It re-lands the content of PR
#382,
which was auto-closed by the 2026-09-12 history linearization without
being merged; the branch is rebased onto the linearized main, with one
extra commit expressing the CJK display-width test fixtures as unicode
escapes to satisfy the source-language gate that main gained since.

- Data layer `raven/trajectory/conversation.py`: rebuilds an attempt's
  span snapshot into a causally ordered stream of labeled records (User
  input, LLM input/output, Tool input/output, skill, memory, and
  subagent events). Event-time semantics (inputs at span start, outputs
  at span end) plus nesting depth from parentSpanId keep the causal
  order. Full content comes from artifact files (paths restricted to
  the trace store, streamed 512 KiB cap per file); span previews are
  only a visible fallback. ERROR spans and expected-but-unreadable
  payloads always yield a placeholder record. LLM inputs omit only the
  item-by-item verified common message prefix against the previous call
  (omissions and rewrites are announced); tool_calls of unexecuted
  tools keep their full arguments.
- Rendering: kind-colored labels aligned to one column, hanging-indent
  bodies with word-aware CJK-safe wrapping, degraded/error/meta note
  lines, single-pass turn separators (interleaved traces are never
  regrouped), and a stacked layout on narrow terminals that never drops
  characters.
- Interactive viewer (prompt_toolkit full-screen, entered for every
  preview on a terminal): scrolling, a muted bottom bar with an
  'h for help' hint, filter/collapsed state and the scroll range (laid
  out by display width, never overflowing any terminal), a scrollable
  help page reachable in full on small screens, a global collapse (s:
  bodies over five display lines fold to five plus an ellipsis; error
  and degradation notes never fold), and a label filter (%: real input
  line, case-insensitive, empty clears; filtered-out turn groups lose
  their separators). Resizes re-wrap and re-judge folding live; the
  content caret is hidden (only the filter input shows one). q/Esc
  returns to the list; Ctrl+C cancels the whole browser from any viewer
  state. A rebuild failure falls back to the legacy per-turn preview;
  without a terminal the preview degrades to direct printing.
- Every dynamic field passes the sanitization gates (control
  characters, rich markup, and ANSI never reach the terminal). Adds the
  Conversation Record term to CONTEXT.md.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `uv run pytest tests/test_trajectory_conversation.py
  tests/test_cli_preview_viewer.py tests/test_cli_trajectory_browse.py
  tests/test_trajectory_store.py -q` -> 298 passed (viewer assertions
  run against really rendered screens)
- `uv run pytest -q` (full, on the rebased branch) -> 23463 passed,
  125 skipped; 33 failed + 20 errors reproduce with identical node ids
  on a clean origin/main worktree - environment baseline, not
  introduced here (passed-count delta of 96 equals the tests this
  branch adds)
- `make check-source-language`, `make check-large-files`,
  `make lint-types`, `make lint-python`, `make lint-imports` -> all pass
- Manual: rendered 6 real local attempts at widths 72/120/30 with zero
  over-width lines (largest attempt 7060 lines in 0.35s); drove the
  real TUI in a pty at normal and 44x12 sizes (viewer, help paging,
  collapse, label filtering, Ctrl+C cancel)

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

- Read/display side only; no persisted format changes. The untrusted
  input surface (artifact content, typed filter input) is contained by
  the store-prefix path check, the 512 KiB streamed cap, and per-field
  sanitization.
- User-visible change: the Space preview is now a full-screen viewer;
  the legacy rendering remains as the automatic fallback when the
  rebuild fails, and non-terminal environments print directly.
- Rollback: revert this PR, or restore the previous `_preview_screen`
  call site to get the old preview back.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.