Files
open-design-crew[bot] bb9682a5f9 feat(daemon): make OD Next the default route, and keep the switch usable (#7876)
* feat(daemon): make OD Next the default route, and keep an opt-out opted out

An installation that configured nothing ran the ordinary strategy route, so
shipping OD Next in a build never changed how a run behaved until someone
opened Settings and asked for it. That made the Labs switch the whole rollout:
the strategy could only reach the users who already knew to look for it.

`resolveRequestedMode` now resolves an unconfigured installation to `active`.
Every other admission condition already defaulted open — assignment bucket,
both content flags, the production approval flag — so this last default was the
only thing holding the route closed.

The one-line change is the small part. Flipping the default inverts which case
is load-bearing. While the strategy was opt-in, an installation that lost its
saved mode kept the behaviour it already had; now an installation that loses
its saved mode is switched back on. So the invariant this has to keep is that
an installation which opted out reads `off` through every later release, and
that rests on opting out being stored rather than erased: `off` is a value the
config carries, `applyConfigValue` drops the key only for a value it cannot
store, and `assertWritableControlValues` refuses those on the way in.

That last guard is the same code as before but now protects the opposite
direction. It used to stop a typo from masquerading as an opt-out; it now stops
a typo from revoking one. Its docblock, the field comments on both sides of the
contract, the `od strategy` help text, and the prompt-composition note all
described the opt-in world and would have read as the reverse of the truth.

Tests carry the semantics:

- a new assertion pins the opt-out invariant, and checks not just the value
  read back but that the decision built from it actually evaluates to `off`;
- the route test now separates `default` from a saved `off`, which resolve to
  opposite routes and no longer to the same one;
- the app-config acceptance case against a real server is rewritten end to end.
  It was "nothing configured takes the ordinary route, and the same running
  daemon takes OD Next once you opt in". It is now the opt-out direction, and
  proves it from the agent invocation log rather than from the run record: the
  first run's prompt is the strategy's, the run after `off` is saved is not.

Packaged installs have no second control — the packaged child environment
allowlist carries no `OD_NEXT_*` key — so the saved mode is the only thing
standing between an opted-out user and this default.

e2e is deliberately untouched here and follows separately. Its specs mostly
never set this mode, so prototype and deck cases now take the OD Next route.
Pinning the env var in the harness is not the fix: the env var outranks the
saved config by design, which would break the spec asserting that an
app-config write takes effect with `app_config` as its source.

* test(daemon): stop the panel-grammar spec failing on the clock

`thinking-stream-panel-grammar` asserted that the whole SSE body contains
neither `Critic` nor `8.1`, on the stated premise that both "只可能来自标记内部".
That premise does not hold for the second one. The body also carries the
daemon's own `start` envelope, and `toolTokenExpiresAt` is an ISO timestamp: a
run whose token expiry lands on a second ending in `8` with milliseconds
starting with `1` produces `…:48.161Z`, which contains `8.1`. Roughly one run in
a hundred, and it hit on CI — with a thinking stream the log shows was clean,
so the spec failed on the clock rather than on a leak.

The two attribute-fragment assertions now run against the concatenated
`thinking_delta` / `text_delta` text instead of the raw body. That is not a
weaker check: persistence consumes exactly those deltas, so it is what the
spec's own "SSE clean ≡ database clean" argument was always about, and the
envelope fields it drops were never a place a leak could surface. The tag regex
stays on the whole body, where an opening `<PANELIST` cannot appear by accident.

The two prose assertions move to the same extracted text, which is what keeps
the negative assertions honest: an extractor that selected the wrong event type
would return an empty string, and every `not.toContain` would pass against it.

* feat(daemon): remove the OD Next stop latch

A run that hit certain failures wrote a row to `strategy_rollout_controls`.
From the next request on, every task on that installation took the legacy
route. The row outranked the saved mode, survived restart, and the Labs switch
kept reading "on" while the notice underneath said the harness had been paused —
with no control to unpause it. The only way back was an operator running
`od strategy rollout reset`.

The code already argued against this. The docblock over
`rolloutStopSignalForBlockedContinuation` says only a failure of the machine
contract itself may raise a latch, because an agent turn that mis-populates a
machine block "is already fail-closed for that task ... and it must not silently
return every later request in the daemon to the legacy path." One signal had
already been retired for doing exactly that: `complex_child_unverified` took OD
Next away from Codex, Claude and OpenCode because a fourth runtime lacks a
child-lifecycle producer. This generalises those narrowings instead of waiting
for the next signal to earn its own.

It also stopped being survivable. While OD Next was opt-in, the latch could only
reach the few installations that had asked for it; with the default flipped it
reaches everyone, and the population exposed to an automatic, restart-surviving,
UI-unrecoverable disable is now the whole install base.

Deleting it changes nothing about how any single failure is handled. All three
live trigger points were additive actions taken *after* the existing handling:
`native_resume_failed` already blocked the continuation, sent
`AGENT_SESSION_RESUME_FAILED` and finished the run failed; the blocked-
continuation cases were already set to `outcome: 'blocked'` with their reason
codes persisted for attribution. What goes away is only the side effect on every
later request. A fourth trigger — the run-duration threshold — never fired at
all: `OD_NEXT_STRATEGY_MAX_RUN_DURATION_MS` has no default anywhere, so the
comparison ran against NaN.

What is removed: the table and its migration, the write path and its telemetry
module, the single read in the run route, the reset endpoint, the
`od strategy rollout reset` subcommand, the latched lock in the Labs switch and
its 19 locale strings, and five contract types. `readOdNextRolloutControlStatus`
stays but no longer takes a database — the effective mode is now a pure function
of the requested mode and the content flags, so two callers reading it at the
same moment cannot disagree. Status is still worth an endpoint because
`requestedModeSource` names the authority, and `default` and a saved `off`
resolve to opposite routes.

Installations currently holding a latch recover on their own: with nothing left
to read the row, their next run is admitted normally. No migration needed, and a
downgrade is safe — older code recreates the table, reads it empty, and treats
the installation as unlatched.

Tests carry the invariant rather than a list. The cases that pinned which
signals were allowed to latch are replaced by one that pins the shape: the
decision is a function of the policy and this run's facts and takes no argument
through which an earlier run could reach it, so there is no signal left to
enumerate and none can be added by accident. The real-server case now asserts
that status still reports the deciding authority while both the reset endpoint
and its CLI subcommand are gone.

* fix(daemon): never leave the harness switch stuck, whatever the config says

Field report from a packaged build of this branch: the Design Harness switch
read on, greyed out, with "detected a problem, generation is using the original
approach" underneath, and every task on the machine taking the legacy route.
Nothing in the UI could clear it.

The diagnostics name the cause exactly. One agent turn ended mid-block —
`Unclosed <open-design-plan-contract> block`, 2 characters of visible text — and
that raised `machine_contract_leak`, whose stop mode is `off`, not `observe`. A
truncated response took the whole install off OD Next; the next daemon start
logged `requestedMode: 'active'`, `effectiveMode: 'off'`,
`od_next_rollout_stop_latched`, which is the screenshot.

That is not a rare condition. A stream ending early is the most ordinary failure
an agent has, and flipping the default hands it the entire install base.

Three changes, one property: the switch a user can see is a switch a user can
use.

1. The stop latch is removed (previously PR #7879, folded in here). A build that
   defaults on and still ships an automatic, restart-surviving, UI-unrecoverable
   disable is not shippable as two PRs — the window between them is exactly the
   build that was reported. Everything the latch did on top of the existing
   per-task handling is gone; the per-task handling is untouched.

2. An unreadable saved config no longer reads as an unconfigured one. The read
   path kept two states apart where it needed three: no file at all, a readable
   choice, and a config that exists but cannot be believed — malformed JSON, a
   non-object body, a mode this build does not recognise. The third used to
   collapse into the first harmlessly, because unconfigured meant `off` and so
   did an opt-out. Now unconfigured means `active`, so the same collapse turns
   "we cannot read your choice" into "you chose OD Next", against the
   installations most likely to have chosen the opposite. It resolves to `off`
   instead — the only answer that cannot override a real decision, and the
   recoverable one, since a user who never opted out sees the switch off and can
   turn it back on. `assertWritableControlValues` was never enough here: it
   guards writes through the API and can say nothing about a file that arrived
   bad, was hand-edited, or was written by another version.

3. A failed status read is retried from the switch instead of being permanent.
   It rendered the row locked and lifted only on remount, so a transient failure
   left a control that does nothing. A click on that lock now re-reads. The
   environment lock stays inert deliberately: the daemon would ignore the write,
   and pretending otherwise is worse than doing nothing — and no packaged
   install can reach it, since the packaged child env allowlist carries no
   `OD_NEXT_*`.

After this, the only greyed state a packaged user can reach is the moment before
the first read returns.

Installations already holding a latch recover on their own: with nothing left to
read the row, their next run is admitted normally. No migration, and a downgrade
is safe — older code recreates the table, reads it empty, and treats the
installation as unlatched.

Coverage follows the same split. The config cases run against real files through
`readAppConfig` rather than against the policy reader in isolation, because the
hole was in the join: truncated JSON, a non-object body, an unrecognised mode, a
typo, a non-string — each reads `off`, while a missing file and an explicit
`null` still reach the default. One case asserts the whole chain, saving `off`
then truncating the file and checking the mode survives, with a fresh
installation as the negative control so the guard cannot swallow the rollout it
protects. The UI case fails the first status read, clicks, and asserts the row
becomes operable.

* refactor(daemon): drop the JSDoc left above the removed latch classifier

The block described `MACHINE_CONTRACT_BOUNDARY_CODES` and
`rolloutStopSignalForBlockedContinuation`, both deleted with the stop latch.
Left in place it reads as documentation for the export below it, implying this
service still classifies machine-contract failures when it no longer does.

* fix(daemon): tell operators why `od strategy rollout reset` is gone

The command shipped in 0.21.0 and 0.21.1. Removing it left the subcommand
falling through to the generic usage dump, so an operator who scripted it saw a
list it was simply missing from and had to guess whether the command was
renamed, moved, or broken.

It now names itself: the daemon-instance stop latch it cleared no longer exists,
so there is nothing to reset, and the message points at the two commands that
replaced its use — `od config set odNextStrategyMode off` to opt out, and
`od strategy rollout status` to see which authority decides the mode.

It still exits non-zero rather than succeeding as a deprecated no-op. The
command meant "recover this machine's rollout". Reporting success for a recovery
that neither happened nor can happen is the same lie the latch itself told —
a control that says it acted while nothing changed — and this release is
removing that lie rather than relocating it into the CLI.

* fix(daemon): stop a broken config file from opting an installation out

The previous commit fixed a real hole — a saved `off` could be silently revoked
— but fixed it too widely and in the wrong direction for this rollout. It
treated a config file that failed to parse at all as evidence of an opt-out,
which it is not.

A file that will not parse resets every preference to its default: agent,
telemetry, all of it. Singling this one key out to resolve against its default
was inconsistent with the rest of the file, and the population it hits is the
wrong one — everyone whose config breaks, not everyone who opted out. Since the
second group is a subset of the first, the guard opted more installations out of
the rollout than it protected, in a change whose entire purpose is opting them
in.

The guard now covers only what it can actually see: `odNextStrategyMode` present
with a value that is not one of the modes — a hand edit, a typo, a mode written
by some other version. Something was configured and cannot be read, so it reads
`off` rather than becoming "you chose OD Next". A fresh install, a missing file,
an unparseable file and a config without the key all reach the default, which is
`active`.

That keeps both invariants this step owes: the default is on for anyone who has
not said otherwise, and a user who did say otherwise keeps their answer. The
switch stays operable in every one of these states, so nothing here can leave a
user looking at a control that does nothing.

* style(daemon): drop the blank line left at EOF by the classifier removal

The line separated `resolveAutomaticContinuationEvidence` from the JSDoc and
classifier below it. Removing those left the separator as a trailing blank line,
which `git diff --check` reports. No logic change.

* docs(daemon): stop enumerating which releases shipped `rollout reset`

The comment named 0.21.0 and 0.21.1. 0.22.0 shipped today and carries the
command too, so the enumeration was already wrong the day it was written --
any list of shipped versions goes stale by construction. Say "every release
since 0.21.0" instead, which stays true without maintenance.

The point the sentence is making is unchanged, and slightly stronger: the
surface has been out for three releases, so an operator who scripted it gets
told what happened rather than shown a help list it is missing from.

---------

Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com>
2026-09-09 01:37:25 +00:00

13 KiB

Prompt composition — the variant axes

Read this before changing any prompt text, wherever it lives.

The one thing to know first

A generation run is composed by ONE of two independent implementations, and apps/daemon/src/prompts/ is only one of them. The fork is an early return at the top of composeSystemPrompt:

// apps/daemon/src/prompts/system.ts:905
}: ComposeInput): string {
  if (odNextStrategyRecipe) {
    return composeOdNextStrategyRequestPromptV2(odNextStrategyRecipe, {...});
  }
  // ↓ everything below — the entire legacy stack — is skipped on the OD Next path

The API/BYOK mirror at packages/contracts/src/prompts/system.ts:318 forks the same way. The two sides share no composition floor: a rule added to one holds only for the runs that take that side.

Which runs take which path

OD Next is on by default and gated. evaluateOdNextRollout (apps/daemon/src/strategies/od-next/rollout.ts:151) is an AND over the policy and one run's facts, and some of its inputs vary run to run on one machine — which is why a divergence between the two sides surfaces as an intermittent bug rather than a reproducible one:

  • Scenario provenance (rollout.ts:134, odNextTaskTypeForProjectScenarioBinding) requires provenance === 'automatic_default'. A project where the user explicitly picked a scenario resolves taskType to null and takes the legacy path.
  • agentId must be one of codex / claude / opencode / amr, sourceKind must be bundled, and the runtime capability preflight must have passed.

Nothing outside one run's own inputs takes part. There used to be a stop latch — a row a failing run wrote that disabled OD Next for the whole daemon instance, outranked the saved mode, and survived restart while the Labs switch still read "on". It is gone, so a divergence you are chasing cannot be a leftover from an earlier run on the same machine.

User-facing switch: Settings → Labs → Design Harness (apps/web/src/components/LabsSection.tsx, reading GET /api/strategies/od-next/rollout). Process override: OD_NEXT_STRATEGY_ROLLOUT. Saved preference: app-config odNextStrategyMode.

The variant axes

Changing "the prompt" can mean up to four edits. Check each one:

Axis Switch A side B side
Strategy odNextStrategyRecipe (apps/daemon/src/prompts/system.ts:905) apps/daemon/src/prompts/ plugins/_official/scenarios/od-next-strategy/assets/** plus TypeScript in packages/contracts/src/prompts/od-next-strategy.ts
Legacy core OD_PROMPT_CORE (apps/daemon/src/server.ts:10049) — default is slim, classic is the opt-out apps/daemon/src/prompts/core-slim.ts official-system.ts + discovery.ts
Execution mode none; hand-maintained mirror apps/daemon/src/prompts/*.ts packages/contracts/src/prompts/*.ts (API/BYOK)
OD Next internals none; the same list is declared twice OD_NEXT_PROMPT_STAGE_CONTRACT_V2 (packages/contracts/src/prompts/od-next-strategy.ts:214) od.pipeline.stages in the plugin's open-design.json

On the third axis, five pairs are still hand-maintained and have already drifted, so diffing a pair is not a reliable way to find what a change is missing:

File daemon contracts
system.ts 2048 1141
discovery.ts 317 286
directions.ts 321 284
media-contract.ts 594 154
official-system.ts 202 163
core-slim.ts 429 absent

core-slim.ts having no contracts counterpart means the API/BYOK path cannot receive the slim charter at all — packages/contracts/src/prompts/system.ts has no promptCoreVariant input. Whether that is intended has not been established here; treat it as an observed asymmetry, not a known bug.

deck-framework.ts is the exception and the model: see below.

Host runtime contracts

These are not style preferences. Each is consumed by product code, and a generated artifact that omits one is not controllable by the host. They belong to the host, not to either strategy — if a strategy is retired, these stay.

Contract Host consumer Legacy OD Next
data-od-deck-protocol="1" on <html> apps/web/src/runtime/srcdoc.ts:3136 — how the host recognizes a v1-native deck ✅ ✅
od:deck-ready announce (protocolVersion, capabilities) srcdoc.ts ready listener — a message without protocolVersion === 1 is ignored ✅ ✅
od:slide-state {active, count} posts unified slide counter and toolbar state ✅ ✅
od:slide navigate listener host-driven paging (next/prev/first/last/go) ✅ ✅
id="deck-stage" srcdoc.ts:3145 isFrameworkDeck → stage style fix, disables click-nav ✅ ✅
@media print block Share → PDF multi-page stitching ✅ ✅
<question-form> AssistantMessage.tsx → QuestionFormView; runAskedUserQuestion analytics ✅ discovery.ts ✅ od-next-strategy.ts:434
.od-frames/ device shells prototype device frames ❌ ✅ OD Next only

Two things to read off this table.

The deck rows are the model for how a host contract should be carried. One source — renderDeckFrameworkDirective / renderLegacyDeckCompatibilityDirective in packages/contracts/src/prompts/deck-framework.ts:573, whose skeleton pins data-od-deck-protocol="1" at line 48 — feeds all three prompt paths. The daemon file is now a ten-line re-export whose own docblock states the reason: "the canonical deck scaffold lives in contracts so classic, BYOK, and OD Next prompt paths cannot drift." OD Next reaches it through resolveOdNextDeckFrameworkMode (od-next-strategy.ts:187), which picks canonical for a blank deck and legacy_compatible when a seed or existing deck HTML is present, and the directive is emitted as an <od-next-context kind="instruction" name="deck-framework"> block (od-next-strategy.ts:548). The wiring that supplies those inputs lives at apps/daemon/src/server.ts:10125. Copy this shape for the next contract.

The divergence still runs both ways. OD Next is not a superset of legacy; it owns .od-frames/ device shells that legacy has no equivalent for. And OD Next content has two possible homes — the plugin's markdown assets and the TypeScript in od-next-strategy.ts. The deck contract lives in the TypeScript, not in ppt.md. Check both before concluding a contract is absent on that side.

Worked example: #7568, then #7651

These two PRs are the same author, three days apart, and together they are the clearest statement of what this document is for.

#7568 (fix(deck): make legacy thumbnail navigation instant, merged 2026-08-29, 973e868ce) introduced deck protocol v1 across 18 files, +510/-44. Its stated purpose, in its own PR body, was to give newly generated decks "one canonical, versioned navigation protocol so future agents do not create more navigation dialects."

It handled the daemon ↔ contracts axis completely: a shared constant so the two prompt copies could not drift on the protocol, both copies changed, a guard added on each side, and validation that ran the full web (7,139 tests) and contracts (500) suites plus two packaged DMG end-to-end runs. By every convention in this repository it was a thorough PR.

It touched nothing under plugins/_official/scenarios/ and nothing in od-next-strategy.ts. Every deck generated on the OD Next path still shipped no protocol: the PR whose goal was to stop new navigation dialects left one live.

Three things made the miss invisible. All three are worth checking against your own change:

  • The guards it added could not reach the other side. Both new tests called composeSystemPrompt({ skillMode: 'deck' }) with no odNextStrategyRecipe, so they never passed the fork. The daemon one was named 'ships new Agent decks with OD Deck Protocol v1' — a claim about Agent decks that was false for every OD Next run, asserted by a test that was green. A test name is not coverage; check what its inputs can actually reach.
  • The snapshot moved and stayed green. apps/daemon/tests/prompts/__snapshots__/system-prompt-matrix.test.ts.snap updated cleanly, because every scenario in that matrix takes the legacy branch. That is still true today.
  • Sharing a constant fixed the machine-readable half only. The directive prose around the shared protocol constant had already drifted between the two copies before #7568, and #7568 inserted the same new clause into both — into two sentences that already disagreed.

#7651 (fix(deck): pin deck protocol in OD Next prompts, merged 2026-08-31, f65d245de) repaid it: 16 files, +855/-622, including a new od-next-prompt-recipe.test.ts, a new plugins-strategy-recipe.test.ts, and new e2e/ui/real-daemon-run.test.ts coverage. The fix is the right shape — it collapsed the deck scaffold to a single source in contracts and reached OD Next through the recipe rather than copying the skeleton into markdown — and it is the reason the deck rows above now read ✅ on both sides.

The transferable rule is the cost, not the outcome: closing a one-sided change took a second PR nearly twice the size of the first. Before landing a change to generated-artifact behavior, name every path that composes a prompt, then say for each one whether your change reaches it. "The tests are green" answers a narrower question than that.

Editing rules

  • Changing a rule that affects generated artifacts? Apply it to every affected path, or state in the PR body why one is genuinely out of scope. "I'll do OD Next later" leaves the bug live for whichever runs take that path.
  • Adding a host contract? Give it one source that all paths consume, the way deck-framework.ts now does, and add its row to the table above in the same PR.
  • Prompt-facing vs. maintenance text. Everything under plugins/_official/scenarios/od-next-strategy/assets/ is sent to the model verbatim. Never put repository-maintenance notes there — put them in that folder's AGENTS.md, which is not part of the asset roster. In apps/daemon/src/prompts/ and packages/contracts/src/prompts/ the exported string is the prompt, so /** */ docblocks above an export are safe.
  • A green suite is not evidence that both sides carry your change. apps/daemon/tests/prompts/system-prompt-matrix.test.ts freezes which sections each scenario receives, but every one of its scenarios takes the legacy branch, so it cannot see an OD Next regression.

Editing the OD Next plugin assets

assets/core-system-prompt.md, assets/general-orchestration.md, and exactly one assets/task-profiles/<taskType>.md are decoded and concatenated verbatim into the prompt bundle (apps/daemon/src/strategies/od-next/initial-prompt-bundle-service.ts).

apps/daemon/src/plugins/strategy-package.ts:158 builds an explicit roster:

./open-design.json, ./SKILL.md,
assets.core.path, assets.orchestration.path,
<selected task profile>.path, assets.taskProfileMapping.path,
<selected profile>.resources[*].path

Only those files are read, and only those are hashed into the package identity.

  • A file the manifest does not declare (that folder's AGENTS.md, for instance) has zero runtime effect — not read, not hashed.
  • open-design.json and SKILL.md are in the roster. Editing either moves the package identity.
  • Only the selected profile's resources join the roster, so one task type's resources changing does not move another task type's hash.
  • Changing a task profile's body should come with a version bump on its entry in open-design.json.

Two shapes for adding content a profile needs. Prompt text goes in the profile's own .md. Non-prompt files (skeletons, shells, stylesheets) get a resources entry on that profile in open-design.json; prototype does this with assets/task-profiles/prototype/device-frames/*.html and layout.css, staged into the project as .od-frames/ and referenced by the profile, never concatenated into the prompt head. Prefer neither when the content is a host contract — give it one source in packages/contracts and have every path consume it, as the deck framework now does.

Known gaps

Verified on 6fb440a86 (2026-08-31).

  • Five daemon/contracts prompt pairs remain hand-maintained and already divergent (table above). deck-framework.ts shows what unifying one looks like; none of the others has been done.
  • core-slim.ts exists only on the daemon side, so the API/BYOK path cannot select the slim charter.
  • No machine check covers the OD Next column of the contract table. packages/contracts/tests/system-prompt.test.ts and apps/daemon/tests/prompts/system.test.ts keep the legacy copies honest, but neither passes an OD Next recipe. #7651 added packages/contracts/tests/od-next-prompt-recipe.test.ts, which does exercise the OD Next recipe for the deck contract specifically; nothing generalizes that to the other rows.