feat(#2481): add a negotiated effortSurface axis and wire invocation-time effort

ADR-1239 gains a ninth negotiated axis, effortSurface (argv | none), declaring how
a host accepts reasoning effort. ADR-443 is amended in the same change because its
recorded deferral is what the axis resolves: its Unblock condition offered paths
(a) and (b) and stated the choice was 'a maintainer call this file records but does
not make'. Path (a) is selected and satisfied here.

Before this, effort reached a runtime only through install-time channels
(EFFORT_RENDERING's frontmatter/api), so reviewer CLIs spawned as subprocesses
silently inherited whatever effort sat in the user's own global CLI config. The
review lane now resolves one universal effort through the ADR-443 cascade and
renders it per host through the negotiated descriptor.

Every per-host value is documentation-sourced, never inferred:
- claude   argv  -- verified via 'claude --help' (--effort <level>)
- opencode argv  -- verified via 'opencode run --help' (--variant)
- codex    argv  -- codex-rs/exec/src/cli.rs: model_reasoning_effort is NOT a CLI
                    flag (config.toml key only), so the global -c override is the
                    only argv route
- 15 hosts undocumented -- their docs state no reasoning setting; the sentinel
                    fails closed rather than inheriting a profile baseline

No config-file vocabulary member: the only host that ever had one (Gemini CLI's
thinkingConfig) was removed as a sunset runtime by 8f2ebbe9b (#1928, PR #1996),
and neither Antigravity CLI nor ZCode documents a reasoning setting.

Closes #2481

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-21 09:12:32 -04:00
parent c19d3d7bda
commit 09b535ac00
51 changed files with 863 additions and 100 deletions

View File

@@ -1342,6 +1342,36 @@ The model-catalog's `reasoning_effort` per-tier hint is a legacy field kept for
Valid effort values: `minimal`, `low`, `medium`, `high`, `xhigh`, `max`.
#### Where effort actually reaches — added in v1.8.0
Effort resolved from the cascade above reaches a runtime through one of two channels.
**Install-time.** The value is baked into the artifacts the installer generates — the
`effort:` frontmatter key on a Claude subagent, `model_reasoning_effort` in a generated
Codex `.toml`. This is fixed at install and changes only on reinstall or sync.
**Invocation-time.** When GSD spawns another CLI as a subprocess — the cross-AI reviewers
in `/gsd:review` — the effort is appended to that CLI's own command line. Whether a host
can receive effort this way is a declared capability (`effortSurface`, ADR-1239), not an
assumption:
| Reviewer CLI | Receives effort as |
|---|---|
| `claude` | `--effort <level>` |
| `opencode` | `--variant <level>` |
| `codex` | `-c model_reasoning_effort=<level>` |
A host whose documentation states no reasoning setting is left **untouched** — no flag is
guessed, and GSD never writes into your own CLI's config file to set one. Levels a given
CLI does not accept are clamped to its nearest supported value (`minimal` → `low` for
Claude, `max` → `xhigh` for Codex), so a cross-provider value never produces an invalid
argument.
Before this, a review run inherited whatever effort happened to be configured in your
personal CLI config, which is why the same project could produce very different review
times on two machines. Setting `effort.default` (or an agent/tier override) now controls
review runs too.
---
### Fast Mode (`fast_mode`) — added in v1.42

View File

@@ -100,6 +100,50 @@ Phase A is **implemented** and Phases B–E have landed, so this ADR is **Accept
No consumer wires the negotiated result yet — Phase A is interface-definition only; the engine↔host boundary (Phase B) and the adapters (Phase C) are where it is consumed.
## Amendment (2026-07-21): `effortSurface` axis (#2481)
> Adds a **ninth negotiated axis** covering reasoning effort. Raised by #2475: reviewer CLIs invoked as subprocesses by the review workflow silently inherit whatever reasoning effort sits in the user's own global CLI config, producing 1–3 minute review cycles on one machine and 12–15+ minute cycles on another with no in-project way to influence it.
**The gap.** Reasoning effort is a first-class, config-driven GSD concept that the negotiated schema does not describe. Three mechanisms exist and none of them meet:
1. **A core effort vocabulary and cascade** ([ADR-443](443-opus48-unified-effort-and-fast-mode-routing.md)) — `resolveEffortInternal` (`src/model-resolver.cts`) resolves a *universal* effort string through invocation override → `effort.agent_overrides.<agent-id>` → `effort.routing_tier_defaults.<tier>` → `effort.default` → canonical defaults.
2. **A core-owned per-runtime rendering table** — `EFFORT_RENDERING` / `renderEffortForRuntime` (`src/model-catalog.cts`), where each runtime declares `param`, `channel`, `supported` levels and a `clamp()` rule. It holds two entries (`claude`, `codex`) and its `channel` vocabulary is `frontmatter` | `api` — **both install-time artifact channels**. Its production callers are exactly those two channels: the static install-time renderer (`bin/install.js`, via `src/install-effort-resolver.cts`) and the manual `query resolve-execution` / effort-sync CLI surface (`src/commands.cts`). **No workflow or agent dispatch calls it**, so there is no invocation-time channel and no per-host declaration of one.
3. **This negotiated schema** — eight axes under `capability.json` `runtime.hostIntegration`. Effort is not among them; `modelMode` covers only whether the host lets GSD drive model *selection*.
`EFFORT_RENDERING` is performing this schema's job — declaring per-host support and degrading gracefully — but as a core table keyed by runtime name, at the wrong layer, predating this ADR. [ADR-443](443-opus48-unified-effort-and-fast-mode-routing.md) names the same gap in its own text: its dynamic paths *"exist only as CLI-callable resolver code… Nothing in the shipped orchestration actually calls them,"* and *"the only propagation channel actually wired into a real GSD flow is the static one (config → `install()` → frontmatter, baked once at install time)."*
**The axis.** `effortSurface` declares **how reasoning effort reaches a host** — a two-value closed vocabulary consistent with the existing axes:
- **`argv`** — effort is deliverable as an argument on the host's own invocation (claude `--effort`; opencode `--variant`; codex via the generic `-c model_reasoning_effort=` config override). This is the channel the schema was missing.
- **`none`** — the host exposes no reasoning-effort mechanism.
`undocumented` is **not** a value of this vocabulary. It is the corpus-wide sentinel (`UNDOCUMENTED` in `src/host-integration.cts`) that any axis may carry when a host's documentation does not state a value: it validates, but never propagates into effective axes — failing closed exactly like an unknown or missing value. A host whose effort mechanism is undocumented carries the sentinel and negotiates to the safe floor.
Per-host `param` name, accepted level set, and clamp rule are carried **in the descriptor** rather than a core table, so `EFFORT_RENDERING` collapses into descriptor data rather than growing a parallel vocabulary.
**Degradation ladder — interface point 3 (Model) gains an effort row:**
| Interface point | Full | Degraded | Absent → fallback |
|---|---|---|---|
| 3 Model — effort | `argv`: universal effort rendered onto the invocation, clamped to the host's level set | *(no rung — see below)* | `none` / `undocumented`: effort is not propagated; host default stands |
**Why there is no `config-file` member.** A config-file-only effort surface is a real shape — Gemini CLI exposed exactly that (`thinkingConfig.thinkingLevel` / `.thinkingBudget` under `modelConfig.generateContentConfig`, settable only in settings.json model presets). It is nonetheless **not** a vocabulary member, because no supported runtime has one:
- **Gemini CLI was removed from this repo as a sunset runtime** — `capabilities/gemini/capability.json` was deleted by commit `8f2ebbe9b` (#1928, PR #1996), following Google's own transition notice (`developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/`).
- **Antigravity CLI**, its documented successor, states no reasoning or thinking setting on its features/settings page (`antigravity.google/docs/cli/features`).
- **ZCode**, the other declarative host with a rich config surface, states none either (`zcode.z.ai/en/docs/configuration`).
A closed, first-party vocabulary with a member no host can claim is an invitation to guess. If a supported host later documents a config-file effort surface, adding the member is a small, evidence-backed change — and the degradation ladder already has the shape for it.
**Boundary against #2313.** That epic (whose own Phase 0 records the Codex passive-model posture as a separate ADR, not yet written) owns the **static / install-time** effort channel for Codex — `model_reasoning_effort` in generated `~/.codex/agents/<agent>.toml` under the passive posture, plus a sync path — and explicitly places *"orchestrator effort-override drift"* outside its scope. This amendment covers the **invocation-time** channel only and does not change install-time emission. The two channels may share a descriptor once `EFFORT_RENDERING` folds in.
**Evidence.** Per this ADR's own rule that axis values are documentation-sourced and never inferred: `claude --effort` and `opencode --variant` were verified against each CLI's `--help`; codex's `-c model_reasoning_effort=` and gemini's `thinkingConfig` were verified against first-party documentation. **The remaining installed hosts were not researched for this axis and must carry the explicit `undocumented` sentinel**, joining the 28 sentinels already carried across the 18 descriptors that declare `hostIntegration`, rather than inheriting a guessed value from a profile baseline.
**Corrections to the Phase A amendment's counts** (recorded here rather than by editing that section, since ADRs are append-only). The Phase A text states "all 16 installed CLIs" and "22 such markers exist today". Both have drifted: **18** descriptors now declare `runtime.hostIntegration`, carrying **28** `undocumented` sentinels. The lists have since drifted in both directions, for different reasons: **`zcode`** carries a descriptor but is named in neither list, and **`gemini`** is named in the declarative list but its descriptor was **deliberately deleted** by `8f2ebbe9b` (#1928, PR #1996) when Google sunset Gemini CLI in favor of Antigravity CLI. Any per-host work on this axis must enumerate the descriptors from the tree, not from those lists.
**Relationship to [ADR-443](443-opus48-unified-effort-and-fast-mode-routing.md).** That ADR is `Proposed`, blocked on its decided invocation-override and escalation paths having no live caller, and its own text recorded the choice of unblock path as a maintainer call it did not make. It is amended in the same change (#2481) to select path (a), to record that audit issue #1192's action-plan item 18 was never converted into a tracked follow-up, and to correct its own stale audit: the blocker's grep excluded `references/*.md`, which is `@`-included into workflows, and predates #2296's escalation caller. This axis does not by itself satisfy either of path (a)'s two mechanisms — see that ADR's amendment for the precise status. `effortSurface` is therefore the declaration layer for a decision ADR-443 already made; it does not restate ADR-443's cascade or enum.
**Status:** delivered in #2481 — axis vocabulary, descriptor values, validator parity, fail-closed negotiation, the degradation row, and the consuming review-lane wiring all land together. `effortSurface` is a wired axis, not a declared-but-unconsumed one; it is the first negotiated axis whose consumer is an invocation-time argument rather than an install-time artifact.
## Host-capability profiles (negotiation baselines)
- **Programmatic-CLI** (Claude Code, pi, OpenCode): imperative; full dispatch; host hook bus; MCP; `slash` surface. The richest target — minimal degradation.

View File

@@ -12,6 +12,31 @@ The audit confirmed the cross-provider resolver/renderer/CLI machinery genuinely
**Unblock condition.** Either (a) wire the orchestrator-invocation-override and attempt-based-escalation paths into an actual GSD workflow or agent dispatch (so `resolveEffortForTier`'s escalation and `resolveEffortInternal`'s invocation-override step have a real caller outside `src/commands.cts`'s CLI surface and tests), and add a test exercising that live path the way `tests/install-runtime-artifacts.test.cjs` exercises the static one; or (b) if the ADR's intended scope is in fact limited to static install-time propagation, amend Decision items 1 and 6 to say so explicitly and close out audit issue #1192's action-plan item 18 with a note pointing at the shipped install-wiring tests. Either is a maintainer call this file records but does not make.
## Amendment (2026-07-21): path (a) chosen; audit corrected (#2481)
**The maintainer call above has been made: path (a).** Raised by #2475 — reviewer CLIs invoked as subprocesses by the review workflow silently inherit whatever reasoning effort sits in the user's own global CLI config, because no shipped orchestration resolves effort at invocation time. That is this ADR's blocker surfacing as a user-visible defect, not a new problem.
**Two deferrals recorded above are closed by this change — resolved, not re-tracked:**
1. **Audit issue #1192's action-plan item 18** ("Strengthen … ADR-443 end-to-end effort propagation") — which the blocker text notes *"was never converted into a tracked follow-up issue"* — is **satisfied by this change**, which supplies the end-to-end propagation and the live-path tests it asked for. It is closed out, not converted into another follow-up.
2. **The choice between (a) and (b)** — which this file previously recorded without making — is resolved as **(a)**. Scope is *not* limited to static install-time propagation. Choosing the path is not the same as completing it; see the status table below for what remains.
**How path (a) is being satisfied.** The consumer is defined through the Host-Integration Interface rather than by hard-coding per-CLI effort syntax into a workflow: [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) gains an `effortSurface` axis declaring how each host accepts reasoning effort (`argv` | `none`), so a universal effort value resolved by this ADR's cascade is rendered per host through the negotiated descriptor. `EFFORT_RENDERING` (`src/model-catalog.cts`) — whose `channel` vocabulary is `frontmatter` | `api`, both install-time — collapses into that descriptor data rather than growing a parallel per-runtime table. Its callers today are exactly the two channels this ADR already ships: the static install-time renderer (`bin/install.js`, via `src/install-effort-resolver.cts`) and the manual `query resolve-execution` / effort-sync CLI surface (`src/commands.cts`). No workflow or agent dispatch calls it — which is the blocker restated in terms of the renderer rather than the resolver.
**What this change actually delivers — and what it does not.** Path (a) names *two* mechanisms needing a live caller. This change delivers neither of them; it delivers a third thing the blocker did not anticipate, and the audit of the other two turns out to have been stale.
| Path (a) mechanism | Status |
|---|---|
| Decision item 1 — `resolveEffortInternal`'s **invocation-override** step (`--effort`) | **Still no live caller.** No workflow, reference, or agent passes `--effort` to `resolve-execution`. This change does not add one. |
| Decision item 6 — `resolveEffortForTier`'s **attempt-based escalation** | **Already satisfied — by #2296, not by this change.** `gsd-core/references/execute-phase-quota-recovery.md` calls `resolve-execution gsd-executor --attempt "${QUOTA_ATTEMPT:-1}" --failure-class quota-exceeded`, and that reference is `@`-included into `gsd-core/workflows/execute-phase.md`, so it executes as part of the live workflow. |
| **New here:** the resolved effort **cascade** reaches a spawned host as an invocation argument | Delivered. `gsd-core/workflows/review.md` calls `resolve-execution … --host <id>` per reviewer and appends the rendered argument, gated by the host's negotiated `effortSurface`. |
**The blocker's grep was stale in two ways.** It searched only `gsd-core/workflows/*.md` and `agents/*.md`; `references/*.md` is `@`-included into workflows and is therefore just as live — that is where #2296's escalation caller sits. And the blocker text was written 2026-07-17, three days before #2296 landed (`455ad49ae`, 2026-07-20), so its "zero hits" finding was correct on the day and has since been overtaken.
**This ADR therefore remains `Proposed`.** Decision item 6's condition is met (by #2296); Decision item 1's is not. The corpus rule for ratifying a stale `Proposed` requires the decided mechanism to demonstrably exist in the tree, and the invocation-override step still has no caller outside the CLI surface and tests. Status flips when item 1 gains a live caller and a test exercises that path through a workflow rather than through `gsd-tools` directly.
**Boundary.** #2313 owns the static/install-time effort channel for Codex (`model_reasoning_effort` in generated `~/.codex/agents/<agent>.toml`, plus a sync path) and explicitly places orchestrator effort-override drift outside its scope. That is the static channel this ADR already ships; the work above is the invocation-time channel it does not.
## Context
### Effort control and fast mode in Claude Opus 4.8