diff --git a/.docket.example.yml b/.docket.example.yml index c7ecd0496..474998e16 100644 --- a/.docket.example.yml +++ b/.docket.example.yml @@ -271,6 +271,11 @@ change_types: [chore, docs, feat, fix, refactor, perf] # Pins apply per agent and per field; a line you leave out keeps its built-in value. A `default:` # block applies to every harness that has no pin of its own for that agent. Model IDs and effort # tokens pass through unchecked; `effort: auto` writes no effort line, so the harness picks. +# An entry may also set `cache_ttl`, how long Claude Code keeps that agent's prompt cache +# (`1h`, `5m`). The built-in table sets `1h` on claude's implement-next only: it waits on builds +# and tests longer than the 5-minute default, and the longer TTL roughly halves its cost. +# `cache_ttl: auto` removes the pin. Cursor, Codex, and opencode have no such setting, so install +# leaves it out of their agent files and warns. # Re-run `docket install` after editing. Set pins only in the global config: an `agents:` pin in # .docket.yml or .docket.local.yml, even one that repeats the built-in value, makes docket refuse # to change the repository until you move it to the global config. @@ -286,7 +291,7 @@ change_types: [chore, docs, feat, fix, refactor, perf] # build-premium: { model: claude-opus-5, effort: medium } # build-max: { model: claude-opus-5, effort: high } # finalize-change: { model: claude-opus-5, effort: low } -# implement-next: { model: claude-opus-5, effort: medium } +# implement-next: { model: claude-opus-5, effort: medium, cache_ttl: 1h } # integration-repair: { model: claude-opus-5, effort: medium } # plan-writer: { model: claude-opus-5, effort: high } # rebase-resolver: { model: claude-opus-5, effort: medium } diff --git a/agents/harness-defaults.yml b/agents/harness-defaults.yml index a4089ad7d..33b6310df 100644 --- a/agents/harness-defaults.yml +++ b/agents/harness-defaults.yml @@ -1,4 +1,4 @@ -# agents/harness-defaults.yml — docket's shipped per-harness agent model/effort table. +# agents/harness-defaults.yml — docket's shipped per-harness agent model/effort/cache-TTL table. # # This is program data, not a user configuration file. The docket binary compiles the same table # into its built-in agent defaults (internal/config/defaults.go), and a test keeps the two equal. @@ -12,6 +12,8 @@ # because one model ID never fits every harness's roster; # - keys are wrapper short names (build-economy, not docket-build-economy); # - every listed entry supplies both model and effort; +# - an entry may add an optional `cache_ttl` (a prompt-cache TTL; only Claude Code has the +# setting). It ships on claude's implement-next alone; see the comment on that row; # - every shipped harness (claude, cursor, codex, opencode) carries a complete 17-agent block. # # Model IDs and effort tokens are opaque passthrough values (ADR-0015). Docket keeps no vendor @@ -32,7 +34,12 @@ agents: build-premium: { model: claude-opus-5, effort: medium } build-max: { model: claude-opus-5, effort: high } finalize-change: { model: claude-opus-5, effort: low } - implement-next: { model: claude-opus-5, effort: medium } + # implement-next waits on build workers and the test suite for longer than Claude Code's + # 5-minute subagent prompt cache lasts, so most of its cost went to writing its whole context + # into the cache again after each wait. A 1-hour TTL roughly halves its cost per run. The + # other agents rarely wait five minutes between turns, and a 1-hour cache write costs more + # than a 5-minute one, so they keep Claude Code's default. + implement-next: { model: claude-opus-5, effort: medium, cache_ttl: 1h } integration-repair: { model: claude-opus-5, effort: medium } plan-writer: { model: claude-opus-5, effort: high } rebase-resolver: { model: claude-opus-5, effort: medium } diff --git a/docs/install/models-and-effort.md b/docs/install/models-and-effort.md index dfc7eea48..b86e95830 100644 --- a/docs/install/models-and-effort.md +++ b/docs/install/models-and-effort.md @@ -45,6 +45,16 @@ pointing it at another harness's model, where Claude Code's effort tiers do not auto`, which drops the effort line entirely so the agent inherits the model default. Omitting the `effort:` key instead *keeps* the built-in effort, so `auto` is the explicit way to drop it. +**Prompt-cache TTL (Claude Code).** An agent entry may also set `cache_ttl`, how long Claude Code +keeps that agent's prompt cache. docket ships `cache_ttl: 1h` on Claude Code's `implement-next` +only. It waits on builds and tests longer than the 5-minute default cache lasts, so it kept paying +to write its whole context into the cache again, and the 1-hour TTL roughly halves its cost per +run. The other agents rarely wait that long, and a 1-hour cache write costs more, so they keep the +default. `cache_ttl: auto` removes a pin. Cursor, Codex, and opencode have no such setting: install +leaves the field out of their wrappers and warns. The [agent layer +reference](../../skills/docket-convention/references/agent-layer.md#prompt-cache-ttl) covers the +measurements, Claude Code's machine-wide alternatives, and which one wins. + **Finding model IDs.** A `model:` value is passed to the harness verbatim — docket never validates it — so use exactly the IDs your harness reports: diff --git a/docs/superpowers/specs/2026-10-09-per-agent-prompt-cache-ttl-pin-shipped-1h-for-implement-next-design.md b/docs/superpowers/specs/2026-10-09-per-agent-prompt-cache-ttl-pin-shipped-1h-for-implement-next-design.md new file mode 100644 index 000000000..ec300b580 --- /dev/null +++ b/docs/superpowers/specs/2026-10-09-per-agent-prompt-cache-ttl-pin-shipped-1h-for-implement-next-design.md @@ -0,0 +1,208 @@ +# Per-agent prompt-cache TTL pin — design + +Change 0546 — Per-agent prompt-cache TTL pin, shipped 1h for implement-next + +## Summary + +Add an optional `cache_ttl` field to docket's per-agent pin table, next to `model` and `effort`. +The Claude Code adapter renders it as `experimental: { cacheTtl: }` in the generated +wrapper's frontmatter. Docket ships `cache_ttl: 1h` for the claude `implement-next` entry only, +because the cost data below shows that agent losing about half its spend to cache expiry, while +every other agent would get more expensive under a 1-hour TTL. + +## Research findings (2026-10-09) + +Source: every subagent transcript under `~/.claude/projects/-Users-homer-dev-docket/*/subagents/` +(the `usage` block of each assistant message, deduplicated by message id). Costs count only +`claude-opus-5-5` turns, at Opus 5.5 prices: input $4/M, 5-minute cache write $5/M, 1-hour cache +write $8/M, cache read $0.20/M, output $20/M. + +### Where the money goes + +| Agent (pinned effort) | Runs | Turns/run | Avg peak context | Opus $/run | Total | Cache writes that are expiry rewrites | +|---|---|---|---|---|---|---| +| implement-next (low) | 95 | 65 | 173k | $5.35 | **$508** | **87%** (461 rewrites) | +| build-standard (medium) | 427 | 24 | 97k | $0.96 | $410 | 5% | +| plan-writer (high) | 67 | 56 | 245k | $3.96 | $265 | 24% | +| build-premium (high) | 103 | 43 | 155k | $2.23 | $230 | 12% | + +A "rewrite" is a turn that wrote more than 50k cache tokens while reading fewer than it wrote: +the cached prefix had expired and the whole context was written again. All recorded cache writes +used the 5-minute TTL; no 1-hour writes appear in any subagent transcript. + +### implement-next per run + +| Token type | Tokens/run | Cost/run | Share | +|---|---|---|---| +| Cache reads | 8.19M | $1.64 | ~31% | +| Cache writes (5-minute TTL) | 0.70M, of which ~0.60M are expiry rewrites | $3.48 | ~65% | +| Output (visible text and tool input, estimated) | ~11k | ~$0.22 | ~4% | + +Measurement note: transcripts record `output_tokens` while the response is still streaming, so the +logged count undercounts. The output figure above is estimated from visible text and tool-input +characters (÷4). Thinking tokens aren't recorded at all, so every output figure is a lower bound. + +implement-next is an orchestrator. It runs commands and dispatches workers, and it sits idle while +a build worker or the test suite runs. Those waits regularly outlast five minutes, so the cache +expires and the next turn writes the full ~120k context again. + +### Estimated per-run cost under each option + +| Option | implement-next | build-standard | build-premium | plan-writer | +|---|---|---|---|---| +| Opus 5.5, as today | $5.35 | $0.96 | $2.23 | $3.96 | +| Opus 5.5, 1-hour TTL | **$2.60** | $1.18 ↑ | $2.55 ↑ | $4.27 ↑ | +| Sonnet 5.5 (same effort) | $3.49 | $0.68 | $1.71 | $2.98 | +| Sonnet 5.5, 1-hour TTL | $2.11 | $0.79 | $1.87 | $3.13 | + +The 1-hour TTL only pays where expiry rewrites dominate. A 1-hour write costs 2× input; a +5-minute write costs 1.25×. The workers rarely expire, so for them the 1-hour TTL is pure surcharge. +That is why the pin is per agent and why the shipped default covers implement-next only. + +### Effort and model are weaker levers for implement-next + +- Effort mostly changes output and thinking tokens, which are ~4% of implement-next's cost. The + low → medium price gap on public benchmarks comes from output-heavy tasks and doesn't carry over + to this agent. For the workers, visible output is 11–13% of cost plus unrecorded thinking, so + effort is a modest lever there. +- Sonnet 5.5 cache reads cost the same $0.20/M as Opus 5.5, so Sonnet saves only on writes and + output: about 35% for implement-next and 24–29% for the workers. +- Haiku 5.5 would be cheapest even at its >100k-token rate ($0.50/M input; 79% of implement-next + turns are over 100k). It isn't recommended for implement-next: the agent must follow a ~110 KB + skill contract exactly over ~65 turns, and a single intelligence score doesn't measure that. + +## Claude Code's cache-TTL controls + +Source: Claude Code docs, `code.claude.com/docs/en/prompt-caching.md` ("Choose the TTL yourself") +and `code.claude.com/docs/en/sub-agents.md` (supported frontmatter fields). Requires Claude Code +v2.1.248+ for the frontmatter field and v2.1.242+ for the env var and setting. + +**Used: per-agent frontmatter.** + +```yaml +experimental: + cacheTtl: 1h +``` + +It sits under the `experimental` map, never at the top level. It's the only control scoped to one +agent, which is what the data calls for. + +**Documented, deliberately not used:** + +| Control | Scope | Why not | +|---|---|---| +| `CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=1h` (env var) | every subagent, plus workflows, teammates, forks, compaction, and session titles | Too broad: it would make the workers 10–23% more expensive. It's also process environment, which docket doesn't own. | +| `subagentPromptCacheTtl: "1h"` (settings key) | same bucket as the env var | Same breadth problem. It's a user settings file, not a generated docket surface. | +| `ENABLE_PROMPT_CACHING_1H=1` | main conversation and subagents | Broadest of all. A reported bug says it didn't reach subagents on Bedrock. | +| `FORCE_PROMPT_CACHING_5M=1` | everything | The opposite lever. It overrides all of the above, frontmatter included. | + +Precedence, highest first: `FORCE_PROMPT_CACHING_5M` > subagent env var > subagent setting > +frontmatter `cacheTtl` > `ENABLE_PROMPT_CACHING_1H` > default. A user who sets the broader +controls overrides docket's pin, which is the user's call. + +**Caveats the docs record:** + +- On a Claude subscription, frontmatter `cacheTtl: 1h` is ignored while usage is drawn from usage + credits. The env var and setting aren't ignored, but 1-hour writes cost more on credits. API-key + and cloud-provider billing default to 5 minutes for subagents. +- Claude Code documents no keep-warm, ping, or background-task way to hold a cache open. The TTL + is the only documented fix. +- An open Claude Code issue reports conflicting evidence about whether subagent writes land in the + 1-hour bucket, with a maintainer saying the effective TTL is decided downstream. Verification + (below) therefore checks the transcript, not the file. + +## Design + +### Config surface + +`AgentSetting` (`internal/config/config.go`) gains a third field next to `Model` and `Effort`: +`CacheTTL Value[string]`, JSON/YAML key `cache_ttl`. It follows the established machinery +unchanged: + +- It's honoured from the built-in table and the global config only, per field, exactly like + `model` and `effort`. A repository-layer agent pin still blocks writes, as today. +- Values are opaque passthrough (ADR-0015): docket keeps no vendor allowlist, so `1h`, `5m`, or a + future token all pass through to the harness verbatim. +- Unset means no `experimental.cacheTtl` is emitted, so wrappers without it are the same as today. + +### Shipped default + +`agents/harness-defaults.yml` (ADR-0064) gets `cache_ttl: 1h` on the claude `implement-next` +entry only. The compiled defaults in `internal/config/defaults.go` follow, and the existing test +that keeps the sidecar and the compiled table equal covers the new field. The sidecar's shape rule +"every listed entry supplies both model and effort" stays true; `cache_ttl` is an optional third +field and the header comment says so. The global-config starter that the first install scaffolds +mirrors the shipped claude block (ADR-0039; `internal/config/example_correspondence_test.go` +guards the mirror), so it shows the new field on implement-next and the otherwise-invisible +default stays visible and tunable. + +### Rendering + +The Claude adapter's `renderAgent` (`internal/harness/claude/claude.go`) writes, after +`model`/`effort`: + +```yaml +experimental: + cacheTtl: 1h +``` + +`verifyRoundTrip` extends to read `experimental.cacheTtl` back and refuses a mismatch, as it does +for model and effort. + +### Non-Claude harnesses + +Cursor, Codex, and OpenCode have no cache-TTL setting. A resolved `cache_ttl` on one of their +agents is left out of the generated files, with a warning that names the harness and agent and says +the harness has no prompt-cache TTL setting. This reuses the ADR-0077 warn-and-drop posture for an +effort docket can't attribute (trace the existing effort-drop warning in the install path and +extend it rather than adding a parallel one): the configuration never blocks, and the drop is loud rather than +silent. The shipped sidecar carries no `cache_ttl` for those harnesses, so a default install prints +no warning. + +### Documentation + +The agent-layer reference (`docket-convention/references/agent-layer.md`) and the README's agent +configuration section document: + +- the `cache_ttl` key, its layering, and the shipped implement-next default; +- why it's scoped to one agent (the per-agent cost table, condensed); +- the global Claude Code controls above as alternatives docket deliberately doesn't use, with + their precedence over the frontmatter pin; +- the usage-credits caveat. + +Docs describe current behaviour only, with no change or PR citations. + +## Testing + +- Config: `cache_ttl` resolves built-in → global per field; a repository-layer `cache_ttl` is + refused like other repository agent pins; the sidecar/compiled-table equality test covers it. +- Claude render: implement-next's generated wrapper carries `experimental.cacheTtl: 1h`; an agent + without the field renders byte-identical to today; a round-trip mismatch is refused. Mutation + check: strip the renderer's write and watch the wrapper test fail. +- Non-Claude harnesses: a global `cache_ttl` on a cursor, codex, or opencode agent leaves it out + of that harness's file and prints the warning; the shipped default prints none. + +## Verification after merge + +Rebuild and reinstall per the repo's after-merge rule, restart the Claude Code session (the agent +registry loads at process start), and run one `docket-implement-next`. In that subagent's +transcript, confirm cache writes land in `usage.cache_creation.ephemeral_1h_input_tokens`, that +large expiry rewrites (over 50k written, fewer read) mostly disappear, and that cost per run moves +toward ~$2.60. If writes still land in the 5-minute bucket, record that in the results file as a +Claude Code-side limitation (see the open-issue caveat). Leave the pin in place: it's harmless +when ignored. + +## Recommendations recorded for separate changes + +Not built here; listed for human triage. + +1. **Sonnet 5.5 for build-standard.** It has the most runs, so the model switch saves the most: + ~$0.28/run, about $120 over this history. Trial a few runs and compare build outcomes against + the Opus history before changing the shipped default. +2. **A context diet for plan-writer.** It has the largest context of any agent (245k average peak, + 80% of turns over 100k) at high effort. Trimming what it loads likely beats a model switch. +3. **Measure finalize-change** the same way before giving it the 1-hour TTL. It also waits on test + suites, but its transcripts weren't analysed. +4. **A Sonnet 5.5 medium trial for implement-next**, after this change lands and is measured. Use + medium, not low: effort barely moves this agent's cost. Judge it by `run.verdict` outcomes and + gate stops, since retries can wipe out the ~$0.50/run saving left once the TTL fix is in. diff --git a/internal/app/config.go b/internal/app/config.go index 580ad3473..4ddf7adef 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -279,8 +279,9 @@ func effectiveLines(eff *config.Effective) []effectiveLine { } // agentLines renders the harness × agent table in sorted order, pairing each -// row's model with its effort pin. The bracket names the model's winning -// layer, and adds the effort's own only when the two disagree. +// row's model with its effort pin and, where one is set, its cache TTL pin. +// The bracket names the model's winning layer, and adds the effort's or the +// cache TTL's own only when it disagrees with the model's. func agentLines(agents config.AgentsTable) []effectiveLine { harnesses := make([]string, 0, len(agents)) for harness := range agents { @@ -307,9 +308,23 @@ func agentLines(agents config.AgentsTable) []effectiveLine { if setting.Effort.Provenance != setting.Model.Provenance { layer += ", effort: " + layerLabel(setting.Effort.Provenance) } + value := fmt.Sprintf("%s / %s", setting.Model.Value, effort) + // The cache TTL segment appears only when a pin resolves or a layer + // declared one (`cache_ttl: auto` clears it): most agents carry no + // pin, and a "(no pin)" on every row would bury the ones that do. + if ttl := setting.CacheTTL; ttl.Value != "" || ttl.Explicit { + shown := ttl.Value + if shown == "" { + shown = "(no pin)" + } + value += " / ttl " + shown + if ttl.Provenance != setting.Model.Provenance { + layer += ", cache_ttl: " + layerLabel(ttl.Provenance) + } + } out = append(out, effectiveLine{ path: fmt.Sprintf("agents.%s.%s", harness, name), - value: fmt.Sprintf("%s / %s", setting.Model.Value, effort), + value: value, layer: layer, }) } diff --git a/internal/app/config_test.go b/internal/app/config_test.go index 2637558dc..48bf60018 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -857,3 +857,39 @@ func TestConfigDiagnosticsOpenArtifactsSurface(t *testing.T) { } } } + +// TestConfigDiagnosticsAgentCacheTTLSurface pins that the hand-maintained +// `effective (winning layer)` human block (agentLines) shows each agent's +// prompt-cache TTL pin beside its model and effort, and names the TTL's layer +// when it differs from the model's: the shipped `1h` on claude implement-next, +// a global override, a global `auto` that clears the pin, and no segment at +// all for an agent that carries no pin. +func TestConfigDiagnosticsAgentCacheTTLSurface(t *testing.T) { + def := DiagnosticConfig(sparseSources(), mainCtx(), false).HumanText() + for _, want := range []string{ + "agents.claude.implement-next = claude-opus-5 / medium / ttl 1h [built-in]\n", + "agents.claude.adr = claude-opus-5 / low [built-in]\n", + } { + if !strings.Contains(def, want) { + t.Errorf("default human block lacks %q:\n%s", want, def) + } + } + + global := func(body string) string { + return DiagnosticConfig([]config.Source{{ + Layer: config.LayerGlobal, Name: "/xdg/docket/config.yml", Data: []byte(body), + }}, mainCtx(), false).HumanText() + } + for _, tc := range []struct{ name, body, want string }{ + {"override", "agents:\n claude:\n implement-next:\n cache_ttl: 5m\n", + "agents.claude.implement-next = claude-opus-5 / medium / ttl 5m [built-in, cache_ttl: global /xdg/docket/config.yml:4]\n"}, + {"auto", "agents:\n claude:\n implement-next:\n cache_ttl: auto\n", + "agents.claude.implement-next = claude-opus-5 / medium / ttl (no pin) [built-in, cache_ttl: global /xdg/docket/config.yml:4]\n"}, + {"new pin", "agents:\n claude:\n adr:\n cache_ttl: 1h\n", + "agents.claude.adr = claude-opus-5 / low / ttl 1h [built-in, cache_ttl: global /xdg/docket/config.yml:4]\n"}, + } { + if h := global(tc.body); !strings.Contains(h, tc.want) { + t.Errorf("%s: human block lacks %q:\n%s", tc.name, tc.want, h) + } + } +} diff --git a/internal/app/finding_codes.go b/internal/app/finding_codes.go index 9a97ab04e..539d4a69e 100644 --- a/internal/app/finding_codes.go +++ b/internal/app/finding_codes.go @@ -162,6 +162,13 @@ const ( // is left untouched and that harness's trigger is not installed. A warning // only: the primary result is unaffected. FCHookFileNotEditable FindingCode = "hook-file-not-editable" + + // agent-cache-ttl-dropped is the warning code an install, development + // install, or install check surfaces when a resolved `cache_ttl` pin reaches + // a harness with no prompt-cache TTL setting (Cursor, Codex, opencode). The + // field is left out of that harness's agent file. A warning only: the + // configuration never blocks. + FCAgentCacheTTLDropped FindingCode = "agent-cache-ttl-dropped" ) // AllFindingCodes is the authoritative, sorted, deduplicated vocabulary of every @@ -196,6 +203,7 @@ const ( // completeness guard covers. var AllFindingCodes = []FindingCode{ FindingCode("adr-not-accepted"), + FCAgentCacheTTLDropped, FindingCode("ambiguous-adr"), FindingCode("ambiguous-change"), FCArtifactMissing, diff --git a/internal/app/install.go b/internal/app/install.go index 6408c88fb..4fb782c09 100644 --- a/internal/app/install.go +++ b/internal/app/install.go @@ -465,6 +465,49 @@ func Planners(roots install.UserRoots, agents config.AgentsTable) []install.Plan return planners } +// cacheTTLHarnesses are the harnesses whose agent definitions have a per-agent +// prompt-cache TTL setting. Claude Code reads it from `experimental.cacheTtl`; +// Cursor, Codex, and opencode have no equivalent, so their adapters leave the +// field out. +var cacheTTLHarnesses = map[string]bool{claude.Name: true} + +// CacheTTLDropWarnings reports every resolved `cache_ttl` pin a selected harness +// cannot express. The adapter drops the field; this warning keeps the drop loud +// rather than silent (ADR-0077's warn-and-drop posture). It keys on the +// RESOLVED value, not on which layer declared it, so a global `default:` pin +// that reaches a non-Claude harness warns too, while the shipped table (which +// pins a TTL on claude alone) warns nothing. Warnings are sorted by harness, +// then agent. +func CacheTTLDropWarnings(agents config.AgentsTable, harnesses []string) []config.Diagnostic { + selected := append([]string(nil), harnesses...) + sort.Strings(selected) + var out []config.Diagnostic + for _, h := range selected { + if cacheTTLHarnesses[h] { + continue + } + row := agents[h] + names := make([]string, 0, len(row)) + for name, setting := range row { + if setting.CacheTTL.Value != "" { + names = append(names, name) + } + } + sort.Strings(names) + for _, name := range names { + out = append(out, config.Diagnostic{ + Code: string(FCAgentCacheTTLDropped), + Severity: config.SeverityWarning, + Path: "agents." + h + "." + name + ".cache_ttl", + Message: fmt.Sprintf("%s has no prompt-cache TTL setting, so cache_ttl %q for %s is left out of its agent file", + h, row[name].CacheTTL.Value, name), + Remedy: "set cache_ttl under agents.claude in the global config, not under agents.default or another harness", + }) + } + } + return out +} + // planMode translates the installer's mode into the adapters'. The two // vocabularies are spelled identically today; converting through a switch keeps // a future divergence a compile-time question rather than a silent one. @@ -482,15 +525,18 @@ func planMode(m install.Mode) harness.InstallMode { // identifies what was RENDERED, and two configurations that pin the same model // from different layers render the same files. type agentDigestRow struct { - Model string `json:"model"` - Effort string `json:"effort"` + Model string `json:"model"` + Effort string `json:"effort"` + CacheTTL string `json:"cache_ttl,omitempty"` } // AgentDigest identifies the resolved agent settings a plan was rendered from: -// sha256 of the canonical JSON of {harness: {agent: {model, effort}}}, narrowed -// to the named harnesses. encoding/json sorts map keys, so the encoding is -// canonical without a second sort, and a harness the caller did not select -// cannot move the digest. +// sha256 of the canonical JSON of {harness: {agent: {model, effort[, cache_ttl]}}}, +// narrowed to the named harnesses. encoding/json sorts map keys, so the encoding +// is canonical without a second sort, and a harness the caller did not select +// cannot move the digest. cache_ttl is omitted when empty, so a table with no +// TTL digests exactly as it did before the field existed. Only a harness in +// cacheTTLHarnesses renders cache_ttl, so only its rows digest it. func AgentDigest(agents config.AgentsTable, harnesses []string) (string, error) { selected := make(map[string]bool, len(harnesses)) for _, name := range harnesses { @@ -503,10 +549,11 @@ func AgentDigest(agents config.AgentsTable, harnesses []string) (string, error) } rendered := make(map[string]agentDigestRow, len(row)) for agentName, setting := range row { - rendered[agentName] = agentDigestRow{ - Model: setting.Model.Value, - Effort: setting.Effort.Value, + digested := agentDigestRow{Model: setting.Model.Value, Effort: setting.Effort.Value} + if cacheTTLHarnesses[harnessName] { + digested.CacheTTL = setting.CacheTTL.Value } + rendered[agentName] = digested } table[harnessName] = rendered } diff --git a/internal/app/install_test.go b/internal/app/install_test.go index 687338509..b151254e6 100644 --- a/internal/app/install_test.go +++ b/internal/app/install_test.go @@ -1,11 +1,14 @@ package app import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "github.com/danielhanold/docket/internal/testsupport" "os" "path/filepath" + "reflect" "regexp" "strings" "testing" @@ -539,3 +542,92 @@ func TestInstallResultRendersAliasFindingsAsWarnings(t *testing.T) { t.Errorf("human text does not show the foreign alias warning with its remedy:\n%s", human) } } + +// A cache TTL resolved for a harness with no such setting is a warning per +// harness and agent, sorted, naming the field. Claude is never warned, nothing +// is warned for an unselected harness, and a table with no TTL warns nothing. +func TestCacheTTLDropWarnings(t *testing.T) { + ttl := func(v string) config.AgentSetting { + return config.AgentSetting{CacheTTL: config.Value[string]{Value: v}} + } + agents := config.AgentsTable{ + "claude": {"implement-next": ttl("1h")}, + "codex": {"status": ttl("1h"), "adr": ttl("5m"), "build-max": ttl("")}, + "cursor": {"status": ttl("1h")}, + "opencode": {"status": ttl("1h")}, + } + got := CacheTTLDropWarnings(agents, []string{"claude", "codex", "cursor"}) + var paths []string + for _, d := range got { + paths = append(paths, d.Path) + if d.Code != string(FCAgentCacheTTLDropped) || d.Severity != config.SeverityWarning || d.Message == "" || d.Remedy == "" { + t.Errorf("warning = %+v", d) + } + } + want := []string{"agents.codex.adr.cache_ttl", "agents.codex.status.cache_ttl", "agents.cursor.status.cache_ttl"} + if !reflect.DeepEqual(paths, want) { + t.Fatalf("warning paths = %v, want %v", paths, want) + } + if !strings.Contains(got[0].Message, "codex") || !strings.Contains(got[0].Message, "adr") || !strings.Contains(got[0].Message, "5m") { + t.Errorf("message does not name the harness, agent, and value: %q", got[0].Message) + } + if w := CacheTTLDropWarnings(config.AgentsTable{"codex": {"status": ttl("")}}, []string{"codex"}); len(w) != 0 { + t.Errorf("an empty TTL warned: %+v", w) + } +} + +// The digest moves when only a cache TTL changes, so `install check` sees the +// rendered wrapper as stale, and a table with no TTL digests exactly as the +// model/effort-only encoding did, so installs without one are not reported as +// drifted. +func TestAgentDigestCoversCacheTTL(t *testing.T) { + table := func(ttl string) config.AgentsTable { + return config.AgentsTable{"claude": {"implement-next": { + Model: config.Value[string]{Value: "claude-opus-5"}, + Effort: config.Value[string]{Value: "medium"}, + CacheTTL: config.Value[string]{Value: ttl}, + }}} + } + none, err := AgentDigest(table(""), []string{"claude"}) + if err != nil { + t.Fatal(err) + } + hour, err := AgentDigest(table("1h"), []string{"claude"}) + if err != nil || hour == none { + t.Fatalf("digest ignored a cache TTL change: %q (%v)", hour, err) + } + legacy, err := json.Marshal(map[string]map[string]struct { + Model string `json:"model"` + Effort string `json:"effort"` + }{"claude": {"implement-next": {Model: "claude-opus-5", Effort: "medium"}}}) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(legacy) + if want := "sha256:" + hex.EncodeToString(sum[:]); none != want { + t.Errorf("a TTL-less table digests %q, want the model/effort-only digest %q", none, want) + } +} + +// A harness that renders no cache TTL digests the same with or without one, +// because the digest identifies what was rendered. +func TestAgentDigestIgnoresCacheTTLForNonRenderingHarness(t *testing.T) { + table := func(ttl string) config.AgentsTable { + return config.AgentsTable{"codex": {"implement-next": { + Model: config.Value[string]{Value: "gpt-5"}, + Effort: config.Value[string]{Value: "medium"}, + CacheTTL: config.Value[string]{Value: ttl}, + }}} + } + none, err := AgentDigest(table(""), []string{"codex"}) + if err != nil { + t.Fatal(err) + } + hour, err := AgentDigest(table("1h"), []string{"codex"}) + if err != nil { + t.Fatal(err) + } + if hour != none { + t.Errorf("a cache TTL codex never renders moved its digest: %q vs %q", hour, none) + } +} diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index a5dccb772..3c149b5d3 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,14 +1,14 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:d2437c7b1af99e29a452da850d3f479ff2e6aab9fd6f27b7d1e254133410d30d", + "asset_set_id": "sha256:09542ded9e93bf9a659c43dbdf09ffd7965c29bac84d3cf5151fc784b16dd297", "entries": [ { "path": ".docket.example.yml", "role": "config-schema", "mode": 420, - "size": 21305, - "sha256": "ff6d14059cd34e7bd5a32a4189af95c40eebd31d668353c0000ec7e663426d57" + "size": 21743, + "sha256": "c6c0e9d2b2827c5263ffab51c04aaf189c41fdb8048cd7a81320930c0c2094b9" }, { "path": "agents/docket-adr.md", @@ -133,8 +133,8 @@ "path": "agents/harness-defaults.yml", "role": "harness-defaults", "mode": 420, - "size": 9775, - "sha256": "69244d331522aa4d37467d63786d76f086e7a5762125b848cb66a0241b5f7387" + "size": 10424, + "sha256": "810f3f376022dbc8043893c1268f26cc1a5f8966970e07ed1a5ba5a17b71614b" }, { "path": "cursor-rules/dispatch.head.md", @@ -329,8 +329,8 @@ "path": "skills/docket-convention/references/agent-layer.md", "role": "skill", "mode": 420, - "size": 11621, - "sha256": "ad7e44f72a6e26739caff068b61bf5600a38b3324d4f7fa44771054ca5a7addc" + "size": 14099, + "sha256": "e11cf4d54689b3dec68d1e98e41558a0d28997b5860c61eeab0f5a6272f6a7b3" }, { "path": "skills/docket-convention/references/close-out.md", diff --git a/internal/assets/embedded/tree/.docket.example.yml b/internal/assets/embedded/tree/.docket.example.yml index c7ecd0496..474998e16 100644 --- a/internal/assets/embedded/tree/.docket.example.yml +++ b/internal/assets/embedded/tree/.docket.example.yml @@ -271,6 +271,11 @@ change_types: [chore, docs, feat, fix, refactor, perf] # Pins apply per agent and per field; a line you leave out keeps its built-in value. A `default:` # block applies to every harness that has no pin of its own for that agent. Model IDs and effort # tokens pass through unchecked; `effort: auto` writes no effort line, so the harness picks. +# An entry may also set `cache_ttl`, how long Claude Code keeps that agent's prompt cache +# (`1h`, `5m`). The built-in table sets `1h` on claude's implement-next only: it waits on builds +# and tests longer than the 5-minute default, and the longer TTL roughly halves its cost. +# `cache_ttl: auto` removes the pin. Cursor, Codex, and opencode have no such setting, so install +# leaves it out of their agent files and warns. # Re-run `docket install` after editing. Set pins only in the global config: an `agents:` pin in # .docket.yml or .docket.local.yml, even one that repeats the built-in value, makes docket refuse # to change the repository until you move it to the global config. @@ -286,7 +291,7 @@ change_types: [chore, docs, feat, fix, refactor, perf] # build-premium: { model: claude-opus-5, effort: medium } # build-max: { model: claude-opus-5, effort: high } # finalize-change: { model: claude-opus-5, effort: low } -# implement-next: { model: claude-opus-5, effort: medium } +# implement-next: { model: claude-opus-5, effort: medium, cache_ttl: 1h } # integration-repair: { model: claude-opus-5, effort: medium } # plan-writer: { model: claude-opus-5, effort: high } # rebase-resolver: { model: claude-opus-5, effort: medium } diff --git a/internal/assets/embedded/tree/agents/harness-defaults.yml b/internal/assets/embedded/tree/agents/harness-defaults.yml index a4089ad7d..33b6310df 100644 --- a/internal/assets/embedded/tree/agents/harness-defaults.yml +++ b/internal/assets/embedded/tree/agents/harness-defaults.yml @@ -1,4 +1,4 @@ -# agents/harness-defaults.yml — docket's shipped per-harness agent model/effort table. +# agents/harness-defaults.yml — docket's shipped per-harness agent model/effort/cache-TTL table. # # This is program data, not a user configuration file. The docket binary compiles the same table # into its built-in agent defaults (internal/config/defaults.go), and a test keeps the two equal. @@ -12,6 +12,8 @@ # because one model ID never fits every harness's roster; # - keys are wrapper short names (build-economy, not docket-build-economy); # - every listed entry supplies both model and effort; +# - an entry may add an optional `cache_ttl` (a prompt-cache TTL; only Claude Code has the +# setting). It ships on claude's implement-next alone; see the comment on that row; # - every shipped harness (claude, cursor, codex, opencode) carries a complete 17-agent block. # # Model IDs and effort tokens are opaque passthrough values (ADR-0015). Docket keeps no vendor @@ -32,7 +34,12 @@ agents: build-premium: { model: claude-opus-5, effort: medium } build-max: { model: claude-opus-5, effort: high } finalize-change: { model: claude-opus-5, effort: low } - implement-next: { model: claude-opus-5, effort: medium } + # implement-next waits on build workers and the test suite for longer than Claude Code's + # 5-minute subagent prompt cache lasts, so most of its cost went to writing its whole context + # into the cache again after each wait. A 1-hour TTL roughly halves its cost per run. The + # other agents rarely wait five minutes between turns, and a 1-hour cache write costs more + # than a 5-minute one, so they keep Claude Code's default. + implement-next: { model: claude-opus-5, effort: medium, cache_ttl: 1h } integration-repair: { model: claude-opus-5, effort: medium } plan-writer: { model: claude-opus-5, effort: high } rebase-resolver: { model: claude-opus-5, effort: medium } diff --git a/internal/assets/embedded/tree/skills/docket-convention/references/agent-layer.md b/internal/assets/embedded/tree/skills/docket-convention/references/agent-layer.md index 76c958a70..05c8145ee 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/references/agent-layer.md +++ b/internal/assets/embedded/tree/skills/docket-convention/references/agent-layer.md @@ -54,6 +54,48 @@ and space-free: each must be a single token. alias layer and no vendor allowlist; the running harness interprets it (a Claude ID under Claude Code, a Cursor model ID under Cursor). That passthrough is what lets docket drive non-Claude harnesses. +### Prompt-cache TTL + +An agent entry may carry a third, optional field, `cache_ttl`, which sets how long Claude Code keeps +that agent's prompt cache. It layers exactly like `model` and `effort`: the built-in table, then the +global configuration, per field, with a harness entry falling back to `default:`. A `cache_ttl` in +`.docket.yml` or `.docket.local.yml` blocks writes. The value passes through verbatim (`1h`, `5m`); +`cache_ttl: auto` removes the pin and leaves Claude Code's default. + +The built-in table sets `cache_ttl: 1h` on claude's `implement-next` and nowhere else. That agent +orchestrates: it waits while a build worker or the test suite runs, and those waits regularly outlast +the 5-minute subagent cache, so its next turn writes its whole context into the cache again. The +other agents rarely wait that long between turns, and a 1-hour cache write costs twice the input price +against 1.25 times for a 5-minute one, so for them a 1-hour TTL would cost more than it saves. + +The claude wrapper carries it under the `experimental` map: + +```yaml +experimental: + cacheTtl: 1h +``` + +Cursor, Codex, and opencode have no prompt-cache TTL setting. A `cache_ttl` that resolves for one of +them, for example through a `default:` entry, is left out of that harness's wrapper, and the install +warns `agent-cache-ttl-dropped`, naming the harness and agent. + +Claude Code also has machine-wide controls. docket doesn't set them, because each one reaches every +subagent rather than one agent: + +| control | reaches | +|---|---| +| `CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=1h` (environment) | every subagent, plus workflows, teammates, forks, compaction, and session titles | +| `subagentPromptCacheTtl: "1h"` (Claude Code settings) | the same as the environment variable | +| `ENABLE_PROMPT_CACHING_1H=1` (environment) | the main conversation and every subagent | +| `FORCE_PROMPT_CACHING_5M=1` (environment) | everything, forcing the 5-minute TTL | + +Precedence, highest first: `FORCE_PROMPT_CACHING_5M`, the subagent environment variable, the +subagent setting, an agent's own `cacheTtl`, `ENABLE_PROMPT_CACHING_1H`, then the default. If you set +one of the broader controls, it overrides docket's pin. + +The agent field needs Claude Code 2.1.248 or later. On a Claude subscription, Claude Code ignores an +agent's `cacheTtl: 1h` while usage is drawn from usage credits. + ## Per-harness wrapper shapes Each harness gets its own wrapper shape: @@ -66,7 +108,9 @@ Each harness gets its own wrapper shape: | opencode | `.md` | `model:` (`openrouter//` passes through whole) | `reasoningEffort:` (a provider model option) | body preamble | Cursor's wrapper carries `name`, `description`, and `model`, leaving `readonly` and `is_background` at Cursor's -defaults. On Cursor and opencode an effort with no resolved model has nowhere to attach and is dropped. +defaults. On Cursor and opencode an effort with no resolved model has nowhere to attach and is dropped. Only the +claude wrapper carries a prompt-cache TTL (`experimental.cacheTtl`; see +[Prompt-cache TTL](#prompt-cache-ttl)). ## Repository dispatch blocks: agent_harnesses diff --git a/internal/cli/install.go b/internal/cli/install.go index 1ac455cb3..78db0424a 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -241,9 +241,14 @@ func installOptions(ctx context.Context, harnesses []string, repoDir string, res if err != nil { return install.Options{}, &InstallRefusal{Reason: app.ReasonInvalidConfig, Err: err} } - configWarnings := config.Warnings(diags) agents := snapshot.Effective.Agents - digest, err := app.AgentDigest(agents, app.HarnessNames(harnesses)) + selected := app.HarnessNames(harnesses) + // A cache_ttl resolved for a harness with no such setting is dropped from + // that harness's agent file; the warning rides the same channel as the + // config read's own warnings, so install, check, and development install + // all print it. + configWarnings := append(config.Warnings(diags), app.CacheTTLDropWarnings(agents, selected)...) + digest, err := app.AgentDigest(agents, selected) if err != nil { return install.Options{}, &InstallRefusal{Reason: install.ReasonInternal, Err: err} } diff --git a/internal/cli/install_cachettl_test.go b/internal/cli/install_cachettl_test.go new file mode 100644 index 000000000..f1c5179c8 --- /dev/null +++ b/internal/cli/install_cachettl_test.go @@ -0,0 +1,85 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/app" + "github.com/danielhanold/docket/internal/testsupport" +) + +func writeGlobalConfig(t *testing.T, home, body string) { + t.Helper() + dir := filepath.Join(home, ".config", "docket") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.yml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// A global cache_ttl that reaches a non-Claude harness surfaces as an install +// warning naming the harness and agent; the configuration never blocks. +func TestInstallOptionsWarnsOnDroppedCacheTTL(t *testing.T) { + home := pinInstallEnv(t) + t.Chdir(testsupport.TempDir(t)) + writeGlobalConfig(t, home, "agents:\n default:\n status:\n cache_ttl: 1h\n") + + opts, refusal := installOptions(context.Background(), []string{"codex", "claude"}, "", false, devInfo()) + if refusal != nil { + t.Fatalf("installOptions refused: %v", refusal) + } + var paths []string + for _, w := range opts.ConfigWarnings { + if w.Code == string(app.FCAgentCacheTTLDropped) { + paths = append(paths, w.Path) + } + } + if len(paths) != 1 || paths[0] != "agents.codex.status.cache_ttl" { + t.Fatalf("cache TTL warnings = %v, want exactly agents.codex.status.cache_ttl", paths) + } +} + +// The shipped defaults alone print no cache TTL warning on any harness. +func TestInstallOptionsShippedDefaultsWarnNoCacheTTL(t *testing.T) { + pinInstallEnv(t) + t.Chdir(testsupport.TempDir(t)) + opts, refusal := installOptions(context.Background(), nil, "", false, devInfo()) + if refusal != nil { + t.Fatalf("installOptions refused: %v", refusal) + } + for _, w := range opts.ConfigWarnings { + if w.Code == string(app.FCAgentCacheTTLDropped) { + t.Errorf("shipped defaults warned: %+v", w) + } + } +} + +// End to end: a real install writes the shipped 1h TTL into claude's +// implement-next wrapper, and only there. +func TestInstallWritesShippedCacheTTLForImplementNext(t *testing.T) { + home := pinInstallEnv(t) + t.Chdir(testsupport.TempDir(t)) + if out, stderr, code := runCLI(t, "install", "--harness", "claude", "--json"); code != 0 { + t.Fatalf("install: code=%d out=%s stderr=%s", code, out, stderr) + } + agentsDir := filepath.Join(home, ".claude", "agents") + entries, err := os.ReadDir(agentsDir) + if err != nil || len(entries) == 0 { + t.Fatalf("ReadDir %s: %v (%d entries)", agentsDir, err, len(entries)) + } + for _, e := range entries { + b, err := os.ReadFile(filepath.Join(agentsDir, e.Name())) + if err != nil { + t.Fatal(err) + } + has := strings.Contains(string(b), "\nexperimental:\n cacheTtl: 1h\n") + if want := e.Name() == "docket-implement-next.md"; has != want { + t.Errorf("%s: carries experimental.cacheTtl 1h = %v, want %v", e.Name(), has, want) + } + } +} diff --git a/internal/config/capability_test.go b/internal/config/capability_test.go index af17d9c36..2242fe8da 100644 --- a/internal/config/capability_test.go +++ b/internal/config/capability_test.go @@ -297,6 +297,16 @@ func TestClassifyMatrix(t *testing.T) { want: []capWant{{"agents.codex.status.effort", Deferred, true, true, LayerRepositoryLocal}}, blockers: []string{"agents.codex.status.effort"}, }, + { + name: "a global agent cache_ttl pin is supported", + sources: []Source{srcG("agents:\n claude:\n plan-writer:\n cache_ttl: 1h\n")}, + }, + { + name: "a repository agent cache_ttl pin blocks", + sources: []Source{srcR("agents:\n claude:\n plan-writer:\n cache_ttl: 1h\n")}, + want: []capWant{{"agents.claude.plan-writer.cache_ttl", Deferred, true, true, LayerRepository}}, + blockers: []string{"agents.claude.plan-writer.cache_ttl"}, + }, // Row 33 — a runner assignment blocks in any layer. { diff --git a/internal/config/config.go b/internal/config/config.go index a6efbefda..36fd2fcad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -217,7 +217,7 @@ type BoardSort struct { Direction Value[string] `json:"direction"` // asc | desc } -// AgentsTable: harness → agent short name → resolved model/effort. +// AgentsTable: harness → agent short name → resolved model/effort/cache TTL. // Resolved from built-in + GLOBAL layers only; repository-layer agent // declarations are deferred-capability requests and never land here. type AgentsTable map[string]map[string]AgentSetting @@ -225,6 +225,10 @@ type AgentsTable map[string]map[string]AgentSetting type AgentSetting struct { Model Value[string] `json:"model"` Effort Value[string] `json:"effort"` // "" == effort pin suppressed (`effort: auto`) + // CacheTTL is the agent's prompt-cache TTL pin. Only Claude Code has the + // setting (it renders as `experimental.cacheTtl`); "" == no pin (unset, or + // `cache_ttl: auto`). + CacheTTL Value[string] `json:"cache_ttl"` } type Snapshot struct { diff --git a/internal/config/decode_test.go b/internal/config/decode_test.go index c3b17f398..93e1c9ad4 100644 --- a/internal/config/decode_test.go +++ b/internal/config/decode_test.go @@ -179,6 +179,9 @@ func decodeAcceptanceCases() []decodeCase { {row: "agents.*.*.effort", path: "agents.codex.status.effort", block: "agents:\n codex:\n status:\n effort: xhigh\n", flow: "agents: {codex: {status: {effort: xhigh}}}\n", value: "xhigh"}, + {row: "agents.*.*.cache_ttl", path: "agents.claude.implement-next.cache_ttl", + block: "agents:\n claude:\n implement-next:\n cache_ttl: 1h\n", + flow: "agents: {claude: {implement-next: {cache_ttl: 1h}}}\n", value: "1h"}, {row: "agents.*.*.runner", path: "agents.opencode.build-max.runner", block: "agents:\n opencode:\n build-max:\n runner: opencode\n", flow: "agents: {opencode: {build-max: {runner: opencode}}}\n", value: "opencode"}, diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 6a5b2d0b3..c1fea7913 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -4,10 +4,9 @@ package config // ships with. Two halves live here — the scalar/list defaults, which mirror // the registry's `def` cells (defaults_test.go proves they cannot drift), and // the 17x4 agent table, which the registry deliberately does not carry because -// it is a table rather than a cell. The agent table is frozen against -// `agents/harness-defaults.yml` at commit a4d72613 (change 0324, which added the -// seventeenth agent docket-plan-writer), and its byte-exact copy under -// `testdata/repositories/v0.9.11/` (re-cut comment-only) is the parity oracle. +// it is a table rather than a cell. Its parity oracle is +// `testdata/repositories/v0.9.13/agents-harness-defaults.yml`, whose origin the +// `agents-harness-defaults.yml` entry in that tree's PROVENANCE.md records. // builtinProvenance is the provenance every default carries. func builtinProvenance() Provenance { @@ -171,8 +170,17 @@ var builtinAgentDefaults = map[string]map[string]agentDefault{ }, } +// builtinAgentCacheTTL is the shipped prompt-cache TTL pins, written the way +// `agents/harness-defaults.yml` writes its optional `cache_ttl` field: harness → +// agent short name → TTL token. An agent absent here carries no TTL pin. It is a +// separate table, not a third agentDefault column, because only one entry +// carries the field. +var builtinAgentCacheTTL = map[string]map[string]string{ + "claude": {"implement-next": "1h"}, +} + // builtinAgents returns the full 17x4 table with `effort: auto` suppressed to -// "". A fresh table is built per call, so a caller layering global overrides +// "" and the shipped cache TTLs applied. A fresh table is built per call, so a caller layering global overrides // on top can never write back into the shipped defaults. func builtinAgents() AgentsTable { out := make(AgentsTable, len(builtinAgentDefaults)) @@ -184,8 +192,9 @@ func builtinAgents() AgentsTable { effort = "" } row[name] = AgentSetting{ - Model: builtinValue(def.model), - Effort: builtinValue(effort), + Model: builtinValue(def.model), + Effort: builtinValue(effort), + CacheTTL: builtinValue(builtinAgentCacheTTL[harness][name]), } } out[harness] = row diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go index a9f3a6f7e..ce8630113 100644 --- a/internal/config/defaults_test.go +++ b/internal/config/defaults_test.go @@ -15,22 +15,24 @@ import ( // The frozen tree is an immutable input (testdata/README.md): these tests only // read both files. const ( - sidecarPath = "../../testdata/repositories/v0.9.11/agents-harness-defaults.yml" + sidecarPath = "../../testdata/repositories/v0.9.13/agents-harness-defaults.yml" liveSidecarPath = "../../agents/harness-defaults.yml" ) // sidecarDoc is the shipped-defaults file's shape: harness → agent short name -// → model/effort pair. +// → model/effort pair, plus the optional prompt-cache TTL. type sidecarDoc struct { Agents map[string]map[string]struct { - Model string `yaml:"model"` - Effort string `yaml:"effort"` + Model string `yaml:"model"` + Effort string `yaml:"effort"` + CacheTTL string `yaml:"cache_ttl"` } `yaml:"agents"` } -// pair is a (model, effort) tuple compared structurally, so a missing or extra -// harness/agent shows up as a difference rather than being skipped. -type pair struct{ Model, Effort string } +// pair is a (model, effort, cache TTL) tuple compared structurally, so a +// missing or extra harness/agent shows up as a difference rather than being +// skipped. +type pair struct{ Model, Effort, CacheTTL string } // TestBuiltinAgentsParityWithFrozenSidecar compares the frozen sidecar against // builtinAgents() as WHOLE structures, both directions in one DeepEqual: every @@ -66,7 +68,7 @@ func TestBuiltinAgentsParityWithFrozenSidecar(t *testing.T) { if effort == "auto" { effort = "" } - row[name] = pair{Model: entry.Model, Effort: effort} + row[name] = pair{Model: entry.Model, Effort: effort, CacheTTL: entry.CacheTTL} } want[harness] = row } @@ -75,7 +77,7 @@ func TestBuiltinAgentsParityWithFrozenSidecar(t *testing.T) { for harness, agents := range builtinAgents() { row := make(map[string]pair, len(agents)) for name, entry := range agents { - row[name] = pair{Model: entry.Model.Value, Effort: entry.Effort.Value} + row[name] = pair{Model: entry.Model.Value, Effort: entry.Effort.Value, CacheTTL: entry.CacheTTL.Value} } got[harness] = row } @@ -85,6 +87,25 @@ func TestBuiltinAgentsParityWithFrozenSidecar(t *testing.T) { } } +// TestBuiltinAgentsCacheTTLShippedOnImplementNextOnly pins the shipped +// prompt-cache TTL scope: claude's implement-next carries 1h, and no other +// harness or agent carries any TTL. The 1-hour write costs more than the +// 5-minute one, so a TTL on an agent that rarely outlasts five minutes between +// turns is a pure surcharge. +func TestBuiltinAgentsCacheTTLShippedOnImplementNextOnly(t *testing.T) { + for harness, agents := range builtinAgents() { + for name, entry := range agents { + want := "" + if harness == "claude" && name == "implement-next" { + want = "1h" + } + if entry.CacheTTL.Value != want { + t.Errorf("%s/%s: built-in cache_ttl = %q, want %q", harness, name, entry.CacheTTL.Value, want) + } + } + } +} + // TestBuiltinAgentsShape pins the table's structure independently of the // sidecar: exactly the four shipped harnesses, each carrying exactly the 17 // canonical short names, every model a non-empty single token, and every entry @@ -115,11 +136,12 @@ func TestBuiltinAgentsShape(t *testing.T) { if strings.ContainsAny(entry.Model.Value, " \t\n") { t.Errorf("%s/%s: model %q is not a single token", harness, name, entry.Model.Value) } - if entry.Model.Provenance != builtinProvenance() || entry.Effort.Provenance != builtinProvenance() { - t.Errorf("%s/%s: provenance = %+v/%+v, want %+v", - harness, name, entry.Model.Provenance, entry.Effort.Provenance, builtinProvenance()) + if entry.Model.Provenance != builtinProvenance() || entry.Effort.Provenance != builtinProvenance() || + entry.CacheTTL.Provenance != builtinProvenance() { + t.Errorf("%s/%s: provenance = %+v/%+v/%+v, want %+v", + harness, name, entry.Model.Provenance, entry.Effort.Provenance, entry.CacheTTL.Provenance, builtinProvenance()) } - if entry.Model.Explicit || entry.Effort.Explicit { + if entry.Model.Explicit || entry.Effort.Explicit || entry.CacheTTL.Explicit { t.Errorf("%s/%s: Explicit must be false on built-in defaults", harness, name) } } diff --git a/internal/config/example_correspondence_test.go b/internal/config/example_correspondence_test.go index a18789b01..ffa16edc8 100644 --- a/internal/config/example_correspondence_test.go +++ b/internal/config/example_correspondence_test.go @@ -209,11 +209,11 @@ var ( agentsOpenerRe = regexp.MustCompile(`^#[ \t]*agents:[ \t]*$`) agentsBlockLineRe = regexp.MustCompile(`^#[ \t]{2,}\S`) agentsHarnessRowRe = regexp.MustCompile(`^#[ \t]{3}([A-Za-z0-9_-]+):[ \t]*$`) - agentsAgentRowRe = regexp.MustCompile(`^#[ \t]{5}([A-Za-z0-9_-]+):[ \t]*\{[ \t]*model:[ \t]*([^,}\s]+)[ \t]*,[ \t]*effort:[ \t]*([^,}\s]+)[ \t]*\}[ \t]*$`) + agentsAgentRowRe = regexp.MustCompile(`^#[ \t]{5}([A-Za-z0-9_-]+):[ \t]*\{[ \t]*model:[ \t]*([^,}\s]+)[ \t]*,[ \t]*effort:[ \t]*([^,}\s]+)[ \t]*(?:,[ \t]*cache_ttl:[ \t]*([^,}\s]+)[ \t]*)?\}[ \t]*$`) ) // exampleAgentsTable parses the example's commented agents table into -// harness -> agent -> (model, effort), with `effort: auto` read as "" the way +// harness -> agent -> (model, effort, cache_ttl), with `effort: auto` read as "" the way // builtinAgents suppresses it. It returns the agent-row count and every line in // the block that is neither row shape, or that repeats a row, as a problem. func exampleAgentsTable(content string) (map[string]map[string]pair, int, []string) { @@ -257,7 +257,7 @@ func exampleAgentsTable(content string) (map[string]map[string]pair, int, []stri if effort == "auto" { effort = "" } - table[harness][m[1]] = pair{Model: m[2], Effort: effort} + table[harness][m[1]] = pair{Model: m[2], Effort: effort, CacheTTL: m[4]} rows++ } return table, rows, problems @@ -269,7 +269,7 @@ func builtinAgentPairs() map[string]map[string]pair { for harness, agents := range builtinAgents() { row := map[string]pair{} for name, a := range agents { - row[name] = pair{Model: a.Model.Value, Effort: a.Effort.Value} + row[name] = pair{Model: a.Model.Value, Effort: a.Effort.Value, CacheTTL: a.CacheTTL.Value} } out[harness] = row } @@ -537,6 +537,14 @@ func TestExampleSchemaCorrespondence(t *testing.T) { if len(check(mutated)) == 0 { t.Errorf("a changed model value in the commented agents table was not caught") } + // Mutation: dropping the shipped cache_ttl from the example must redden. + dropped := strings.Replace(content, ", cache_ttl: 1h }", " }", 1) + if dropped == content { + t.Fatalf("cache_ttl mutation probe did not change the example") + } + if len(check(dropped)) == 0 { + t.Errorf("a missing cache_ttl in the commented agents table was not caught") + } // An unparseable row inside the block is reported, not skipped. if _, _, p := exampleAgentsTable(strings.Replace(content, "# claude:\n", "# claude:\n# adr: model claude-opus-5\n", 1)); len(p) == 0 { t.Errorf("an unparseable row in the commented agents table was not reported") diff --git a/internal/config/fixtures_test.go b/internal/config/fixtures_test.go index 5dd893e1b..e236f907f 100644 --- a/internal/config/fixtures_test.go +++ b/internal/config/fixtures_test.go @@ -48,8 +48,10 @@ func loadFixture(t *testing.T, name string) (*Snapshot, []Diagnostic, error) { // loadFixtureFrom is loadFixture parameterized by the versioned tree root, for // the rare reader that must resolve a fixture from a tree other than the shared // fixtureRoot (TestFixtureDocketSelf, whose frozen copy was re-cut into a newer -// versioned tree when docket's own .docket.yml changed). Every other fixture -// test stays on fixtureRoot through loadFixture. +// versioned tree when docket's own .docket.yml changed, and +// TestFixtureDeferredActive, re-cut when the registry gained a repo-settable +// deferred row). Every other fixture test stays on fixtureRoot through +// loadFixture. func loadFixtureFrom(t *testing.T, root, name string) (*Snapshot, []Diagnostic, error) { t.Helper() tmp := t.TempDir() @@ -618,7 +620,12 @@ func TestFixtureDeferredPairs(t *testing.T) { // transcribed from the fixture, so adding a deferred row without extending the // fixture fails here instead of quietly shrinking the coverage. func TestFixtureDeferredActive(t *testing.T) { - snap := mustResolveFixture(t, "deferred-active") + // The fixture is re-cut into a newer versioned tree each time the registry + // gains a repo-settable deferred row, because it must request every such + // capability at once; this test reads that tree. v0.9.13 adds the + // `agents.*.*.cache_ttl` agent pin. + const deferredActiveRoot = "../../testdata/repositories/v0.9.13" + snap := mustResolveFixtureFrom(t, deferredActiveRoot, "deferred-active") decision := PreflightMutation(snap) if decision.Allowed { diff --git a/internal/config/resolve.go b/internal/config/resolve.go index d295d7d2e..c609d12c6 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -376,7 +376,7 @@ func assign[T any](dst *Value[T], declared map[string]leafDecl, path string) err } // resolveAgents lays each override layer's agent pins over the built-in 17x4 -// table. Model and effort resolve independently, and within one layer a +// table. Model, effort, and cache_ttl resolve independently, and within one layer a // harness-specific pin falls back to that layer's `agents.default` before the // next layer is consulted — so a global default pin is not silently outranked // by a built-in harness pin. @@ -403,6 +403,15 @@ func resolveAgents(byLayer map[LayerKind]map[string]leafDecl) AgentsTable { setting.Effort = Value[string]{Value: effort, Provenance: decl.prov, Explicit: true} } } + if decl, ok := agentDecl(declared, harness, name, "cache_ttl"); ok { + if ttl, ok := decl.value.(string); ok { + // `auto` removes the pin: the harness keeps its own TTL. + if ttl == autoSentinel { + ttl = "" + } + setting.CacheTTL = Value[string]{Value: ttl, Provenance: decl.prov, Explicit: true} + } + } row[name] = setting } } diff --git a/internal/config/resolve_test.go b/internal/config/resolve_test.go index 417448867..58ea89998 100644 --- a/internal/config/resolve_test.go +++ b/internal/config/resolve_test.go @@ -884,6 +884,72 @@ func TestAgentsEffortAuto(t *testing.T) { } } +// cache_ttl is a third per-field agent pin: it resolves built-in → global, a +// harness-specific pin falls back to the global `default:` block, and it never +// disturbs the model or effort beside it. +func TestAgentsCacheTTLResolution(t *testing.T) { + res := mustResolve(t, []Source{srcG( + "agents:\n" + + " default:\n" + + " build-max:\n" + + " cache_ttl: 5m\n" + + " claude:\n" + + " plan-writer:\n" + + " cache_ttl: 1h\n")}, mainCtx) + + pw := res.effective.Agents["claude"]["plan-writer"] + if pw.CacheTTL.Value != "1h" || pw.CacheTTL.Provenance.Layer != LayerGlobal || !pw.CacheTTL.Explicit { + t.Errorf("claude/plan-writer cache_ttl = %+v, want an explicit global 1h", pw.CacheTTL) + } + if pw.Model.Value != "claude-opus-5" || pw.Model.Provenance.Layer != LayerBuiltIn || pw.Model.Explicit { + t.Errorf("claude/plan-writer model = %+v, want the untouched built-in", pw.Model) + } + if pw.Effort.Value != "high" || pw.Effort.Provenance.Layer != LayerBuiltIn || pw.Effort.Explicit { + t.Errorf("claude/plan-writer effort = %+v, want the untouched built-in", pw.Effort) + } + + for _, h := range []string{"claude", "codex", "cursor", "opencode"} { + got := res.effective.Agents[h]["build-max"].CacheTTL + if got.Value != "5m" || got.Provenance.Layer != LayerGlobal || !got.Explicit { + t.Errorf("%s/build-max cache_ttl = %+v, want the global default-block 5m", h, got) + } + } + + untouched := res.effective.Agents["codex"]["adr"].CacheTTL + if untouched.Value != "" || untouched.Explicit || untouched.Provenance.Layer != LayerBuiltIn { + t.Errorf("codex/adr cache_ttl = %+v, want the empty built-in (no pin)", untouched) + } +} + +// `cache_ttl: auto` removes the pin, exactly as `effort: auto` does. +func TestAgentsCacheTTLAuto(t *testing.T) { + res := mustResolve(t, []Source{srcG("agents:\n claude:\n plan-writer:\n cache_ttl: auto\n")}, mainCtx) + got := res.effective.Agents["claude"]["plan-writer"].CacheTTL + if got.Value != "" { + t.Errorf("cache_ttl = %q, want \"\" (auto removes the pin)", got.Value) + } + if !got.Explicit || got.Provenance.Layer != LayerGlobal { + t.Errorf("cache_ttl provenance = %+v (explicit %v), want an explicit global pin", got.Provenance, got.Explicit) + } +} + +// A repository-layer cache_ttl never reaches Effective; it reaches the classifier. +func TestAgentsCacheTTLRepoLayerExcludedFromEffective(t *testing.T) { + for _, build := range []func(string) Source{srcR, srcL} { + src := build("agents:\n claude:\n plan-writer:\n cache_ttl: 1h\n") + t.Run(string(src.Layer), func(t *testing.T) { + res := mustResolve(t, []Source{src}, mainCtx) + got := res.effective.Agents["claude"]["plan-writer"].CacheTTL + if got.Value != "" || got.Explicit || got.Provenance.Layer != LayerBuiltIn { + t.Errorf("claude/plan-writer cache_ttl = %+v, want the untouched built-in", got) + } + if d, ok := res.declared["agents.claude.plan-writer.cache_ttl"]; !ok || d.prov.Layer != src.Layer { + t.Errorf("the declaration must still reach the classifier; declared = %v", res.declared) + } + }) + } +} + // The repository layers may not pin agents at all: such a declaration is a // deferred-capability request the classifier reports, never effective policy. func TestAgentsRepoLayerExcludedFromEffective(t *testing.T) { @@ -1328,3 +1394,24 @@ func TestOpenArtifactsIsAnOrdinaryLayeredKey(t *testing.T) { } } } + +// The shipped implement-next TTL obeys the same per-field layering as model and +// effort: a model-only override keeps it, and a global `auto` or `5m` replaces it. +func TestAgentsShippedCacheTTLLayering(t *testing.T) { + res := mustResolve(t, []Source{srcG("agents:\n claude:\n implement-next:\n model: claude-sonnet-5\n")}, mainCtx) + got := res.effective.Agents["claude"]["implement-next"] + if got.Model.Value != "claude-sonnet-5" { + t.Fatalf("model = %q, want the global override", got.Model.Value) + } + if got.CacheTTL.Value != "1h" || got.CacheTTL.Provenance.Layer != LayerBuiltIn || got.CacheTTL.Explicit { + t.Errorf("cache_ttl = %+v, want the built-in 1h kept by a model-only override", got.CacheTTL) + } + + for _, tc := range []struct{ in, want string }{{"auto", ""}, {"5m", "5m"}} { + res := mustResolve(t, []Source{srcG("agents:\n claude:\n implement-next:\n cache_ttl: " + tc.in + "\n")}, mainCtx) + got := res.effective.Agents["claude"]["implement-next"].CacheTTL + if got.Value != tc.want || !got.Explicit || got.Provenance.Layer != LayerGlobal { + t.Errorf("cache_ttl %s: got %+v, want explicit global %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/config/schema.go b/internal/config/schema.go index 2373de79e..e41b8d49e 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -72,7 +72,7 @@ const ( dispDeferredByValue // finalize.gate: ci/both block dispSupportedOrDropped // board_surfaces: the github token dispInertCompanion // auto_capture.types, dummy_mode.persona/surfaces, runners.* - dispAgentsLeaf // agents...model/effort: layer-dependent + dispAgentsLeaf // agents...model/effort/cache_ttl: layer-dependent dispDeferredActive // skills.*, agents...runner: any explicit value blocks ) @@ -218,7 +218,7 @@ func RepoOnlyPaths() []string { // dispositionSupported reports whether a disposition family is supported // configuration. finalize.gate (refused only for some values), board_surfaces -// (one token dropped), and the agents model/effort leaves (honoured only from +// (one token dropped), and the agents model/effort/cache_ttl leaves (honoured only from // the global layer) are supported keys whose individual values or layers may // still be refused. Obsolete, inert, inert-companion, deferred, and // deferred-active paths are not supported. A new disposition defaults to @@ -415,7 +415,7 @@ func buildRegistry() []pathSpec { {path: "skills.finish", kind: kindString, merge: mergeScalar, scope: scopeAny, disp: dispDeferredActive, validate: stringLeaf(true, false, false)}, - // 32-33: agent pins. Model IDs are opaque passthrough — docket never + // 32-34: agent pins. Model IDs are opaque passthrough — docket never // asserts a vendor ID exists — but they must be single tokens. The // built-in defaults are the 17x4 table of Reference C, which lives in // defaults.go rather than in a `def` cell here. @@ -423,10 +423,14 @@ func buildRegistry() []pathSpec { disp: dispAgentsLeaf, validate: stringLeaf(true, true, false)}, {path: "agents.*.*.effort", kind: kindString, merge: mergeScalar, scope: scopeAny, disp: dispAgentsLeaf, validate: stringLeaf(true, true, false)}, + // The prompt-cache TTL pin (`1h`, `5m`, ...) is opaque passthrough like + // the model and effort beside it; only the Claude adapter renders it. + {path: "agents.*.*.cache_ttl", kind: kindString, merge: mergeScalar, scope: scopeAny, + disp: dispAgentsLeaf, validate: stringLeaf(true, true, false)}, {path: "agents.*.*.runner", kind: kindString, merge: mergeScalar, scope: scopeAny, disp: dispDeferredActive, validate: stringLeaf(true, false, false)}, - // 34-38: runner settings — inert companions, activated only by an + // 35-39: runner settings — inert companions, activated only by an // effective agent entry naming that runner. {path: "runners.codex.sandbox", kind: kindString, enum: []string{"workspace-write", "danger-full-access"}, diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go index a67624320..39d75d3e7 100644 --- a/internal/config/schema_test.go +++ b/internal/config/schema_test.go @@ -42,7 +42,7 @@ func TestRegistryPathSetMatchesV092(t *testing.T) { "dummy_mode.enabled", "dummy_mode.persona", "dummy_mode.surfaces", "agent_harnesses", "skills.brainstorm", "skills.plan", "skills.build", "skills.review", "skills.finish", - "agents.*.*.model", "agents.*.*.effort", "agents.*.*.runner", + "agents.*.*.model", "agents.*.*.effort", "agents.*.*.cache_ttl", "agents.*.*.runner", "runners.codex.sandbox", "runners.codex.network", "runners.opencode.permissions", "runners.*.shim_model", "runners.*.shim_effort", @@ -356,6 +356,10 @@ func TestLeafValidators(t *testing.T) { {"model opaque", "agents.*.*.model", "openrouter/moonshotai/kimi-k3", "openrouter/moonshotai/kimi-k3", ""}, {"model with space", "agents.*.*.model", `"claude opus"`, nil, CodeInvalidValue}, {"effort auto is opaque", "agents.*.*.effort", "auto", "auto", ""}, + {"cache_ttl opaque", "agents.*.*.cache_ttl", "1h", "1h", ""}, + {"cache_ttl auto is opaque", "agents.*.*.cache_ttl", "auto", "auto", ""}, + {"cache_ttl with space", "agents.*.*.cache_ttl", `"1 h"`, nil, CodeInvalidValue}, + {"cache_ttl empty", "agents.*.*.cache_ttl", `""`, nil, CodeInvalidValue}, {"shim model inherit", "runners.*.shim_model", "inherit", "inherit", ""}, {"sandbox enum", "runners.codex.sandbox", "workspace-write", "workspace-write", ""}, {"sandbox bad enum", "runners.codex.sandbox", "yolo", nil, CodeInvalidValue}, diff --git a/internal/config/setting_paths_test.go b/internal/config/setting_paths_test.go index 1251a9f91..023de4d26 100644 --- a/internal/config/setting_paths_test.go +++ b/internal/config/setting_paths_test.go @@ -53,7 +53,7 @@ func TestSettingPathsSupportSplit(t *testing.T) { "gate_observation_budget", "board_surfaces", "board.section_order", "change_types", "agent_harnesses", - "agents.*.*.model", "agents.*.*.effort", + "agents.*.*.model", "agents.*.*.effort", "agents.*.*.cache_ttl", } for _, s := range BoardSectionTokens { want = append(want, "board.sorting."+s+".by", "board.sorting."+s+".direction") diff --git a/internal/harness/claude/claude.go b/internal/harness/claude/claude.go index 420e9ad51..80f252db6 100644 --- a/internal/harness/claude/claude.go +++ b/internal/harness/claude/claude.go @@ -110,7 +110,8 @@ func (adapter) Plan(in harness.PlanInput) ([]install.Target, error) { // on this harness and must survive to renderAgent. See renderAgent's // field-mapping comment. model, effort := harness.ResolvedAgentRaw(in.Agents, Name, s.ShortName) - content, err := renderAgent(s, model, effort) + cacheTTL := harness.ResolvedCacheTTL(in.Agents, Name, s.ShortName) + content, err := renderAgent(s, model, effort, cacheTTL) if err != nil { return nil, err } @@ -154,6 +155,7 @@ func GlobalDispatchTarget(r install.UserRoots) install.Target { // frontmatter skills: -> skills (preserved; omitted when empty) // resolved model -> model (omitted when unpinned) // resolved effort -> effort (omitted when unpinned) +// resolved cache TTL -> experimental.cacheTtl (omitted when unpinned) // body -> body verbatim // // One difference from emit() is deliberate: emit() is a stream transform, so it @@ -172,7 +174,7 @@ func GlobalDispatchTarget(r install.UserRoots) install.Target { // // Claude reads `skills:` natively, so unlike the codex/cursor/opencode // renderers there is no skills preamble in the body. -func renderAgent(s harness.AgentSource, model, effort string) ([]byte, error) { +func renderAgent(s harness.AgentSource, model, effort, cacheTTL string) ([]byte, error) { var b strings.Builder b.WriteString("---\n") b.WriteString("name: " + quoteYAML(s.Name) + "\n") @@ -194,6 +196,12 @@ func renderAgent(s harness.AgentSource, model, effort string) ([]byte, error) { if effort != "" { b.WriteString("effort: " + effort + "\n") } + // Claude Code reads a subagent's prompt-cache TTL from the `experimental` + // map, never from a top-level key. The value is opaque passthrough like the + // model and effort, and the round-trip check below guards it the same way. + if cacheTTL != "" { + b.WriteString("experimental:\n cacheTtl: " + cacheTTL + "\n") + } b.WriteString("---\n") // The self-recursion guard is its own paragraph, injected at one consistent @@ -210,7 +218,7 @@ func renderAgent(s harness.AgentSource, model, effort string) ([]byte, error) { b.WriteString(body) out := []byte(b.String()) - if err := verifyRoundTrip(out, s, model, effort); err != nil { + if err := verifyRoundTrip(out, s, model, effort, cacheTTL); err != nil { return nil, err } return out, nil @@ -221,17 +229,20 @@ func renderAgent(s harness.AgentSource, model, effort string) ([]byte, error) { // checked property of each write rather than a claim about the escaping helper, // which is what keeps a hostile description — or an unusual vendor model // string — from shipping as a file that parses into something else. -func verifyRoundTrip(rendered []byte, s harness.AgentSource, model, effort string) error { +func verifyRoundTrip(rendered []byte, s harness.AgentSource, model, effort, cacheTTL string) error { doc, err := document.Parse(rendered) if err != nil { return fmt.Errorf("%w: %s does not reparse: %w", ErrRender, s.Name, err) } var fm struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Skills []string `yaml:"skills"` - Model string `yaml:"model"` - Effort string `yaml:"effort"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Skills []string `yaml:"skills"` + Model string `yaml:"model"` + Effort string `yaml:"effort"` + Experimental struct { + CacheTTL string `yaml:"cacheTtl"` + } `yaml:"experimental"` } if err := doc.DecodeFrontmatter(&fm); err != nil { return fmt.Errorf("%w: %s does not decode: %w", ErrRender, s.Name, err) @@ -245,6 +256,8 @@ func verifyRoundTrip(rendered []byte, s harness.AgentSource, model, effort strin return fmt.Errorf("%w: %s round-trips model %q as %q", ErrRender, s.Name, model, fm.Model) case fm.Effort != effort: return fmt.Errorf("%w: %s round-trips effort %q as %q", ErrRender, s.Name, effort, fm.Effort) + case fm.Experimental.CacheTTL != cacheTTL: + return fmt.Errorf("%w: %s round-trips cacheTtl %q as %q", ErrRender, s.Name, cacheTTL, fm.Experimental.CacheTTL) case len(s.Skills) > 0 && !reflect.DeepEqual(fm.Skills, s.Skills): return fmt.Errorf("%w: %s round-trips skills %v as %v", ErrRender, s.Name, s.Skills, fm.Skills) } diff --git a/internal/harness/claude/claude_test.go b/internal/harness/claude/claude_test.go index 40fb1dbd0..4637cf966 100644 --- a/internal/harness/claude/claude_test.go +++ b/internal/harness/claude/claude_test.go @@ -1,6 +1,7 @@ package claude import ( + "errors" "flag" "fmt" "os" @@ -30,8 +31,9 @@ const ( ) // The fixed input table the goldens are frozen under: one agent pinned on both -// fields, one model-only, one carrying both no-pin sentinels. Every other agent -// resolves to no pin at all, which is the fourth case. +// fields, one model-only, one carrying both no-pin sentinels, and one pinned on +// model, effort, and prompt-cache TTL. Every other agent resolves to no pin at +// all, which is the fifth case. func fixtureAgents() config.AgentsTable { return config.AgentsTable{ "claude": { @@ -52,12 +54,20 @@ func fixtureAgents() config.AgentsTable { Model: config.Value[string]{Value: "inherit"}, Effort: config.Value[string]{Value: "medium"}, }, + // A prompt-cache TTL rides beside model and effort and renders under + // Claude Code's `experimental` map. + "implement-next": { + Model: config.Value[string]{Value: "claude-opus-5"}, + Effort: config.Value[string]{Value: "medium"}, + CacheTTL: config.Value[string]{Value: "1h"}, + }, }, // A sibling harness's pins must never leak into the claude rendering. "codex": { "build-standard": { - Model: config.Value[string]{Value: "gpt-5.5-codex"}, - Effort: config.Value[string]{Value: "low"}, + Model: config.Value[string]{Value: "gpt-5.5-codex"}, + Effort: config.Value[string]{Value: "low"}, + CacheTTL: config.Value[string]{Value: "9h"}, }, }, } @@ -260,7 +270,7 @@ func TestClaudeGoldenAgents(t *testing.T) { } } -// The three pin cases the fixed table exercises, asserted against the parsed +// The pin cases the fixed table exercises, asserted against the parsed // frontmatter rather than the golden bytes, so a golden regenerated by mistake // cannot quietly redefine what "pinned" means. func TestClaudeAgentPinCases(t *testing.T) { @@ -272,20 +282,22 @@ func TestClaudeAgentPinCases(t *testing.T) { } cases := []struct { - file string - model, efrt string - wantModelSet bool - wantEfrtSet bool + file string + model, efrt, ttl string + wantModelSet bool + wantEfrtSet bool + wantTTLSet bool }{ - {"docket-build-standard.md", "claude-opus-5[1m]", "high", true, true}, - {"docket-status.md", "claude-sonnet-4-6", "", true, false}, + {"docket-build-standard.md", "claude-opus-5[1m]", "high", "", true, true, false}, + {"docket-status.md", "claude-sonnet-4-6", "", "", true, false, false}, // `inherit` is a real Claude Code frontmatter value, not a docket // sentinel on this harness, so it renders verbatim; `auto` is a docket // sentinel on every harness and drops. - {"docket-adr.md", "inherit", "", true, false}, - {"docket-review-deep.md", "inherit", "medium", true, true}, - {"docket-review-lean.md", "", "", false, false}, // no table entry at all - {"docket-brainstorm-consultant.md", "", "", false, false}, // no table entry, no skills + {"docket-adr.md", "inherit", "", "", true, false, false}, + {"docket-review-deep.md", "inherit", "medium", "", true, true, false}, + {"docket-implement-next.md", "claude-opus-5", "medium", "1h", true, true, true}, + {"docket-review-lean.md", "", "", "", false, false, false}, // no table entry at all + {"docket-brainstorm-consultant.md", "", "", "", false, false, false}, // no table entry, no skills } for _, tc := range cases { t.Run(tc.file, func(t *testing.T) { @@ -298,15 +310,27 @@ func TestClaudeAgentPinCases(t *testing.T) { t.Fatalf("Parse: %v", err) } var fm struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Skills []string `yaml:"skills"` - Model string `yaml:"model"` - Effort string `yaml:"effort"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Skills []string `yaml:"skills"` + Model string `yaml:"model"` + Effort string `yaml:"effort"` + Experimental struct { + CacheTTL string `yaml:"cacheTtl"` + } `yaml:"experimental"` } if err := doc.DecodeFrontmatter(&fm); err != nil { t.Fatalf("DecodeFrontmatter: %v", err) } + if fm.Experimental.CacheTTL != tc.ttl { + t.Errorf("experimental.cacheTtl = %q, want %q", fm.Experimental.CacheTTL, tc.ttl) + } + if _, has := doc.Field("experimental"); has != tc.wantTTLSet { + t.Errorf("experimental key present = %v, want %v", has, tc.wantTTLSet) + } + if _, has := doc.Field("cacheTtl"); has { + t.Errorf("cacheTtl must sit under experimental, never at the top level") + } if fm.Model != tc.model { t.Errorf("model = %q, want %q", fm.Model, tc.model) } @@ -595,3 +619,26 @@ func TestClaudePlanTrigger(t *testing.T) { t.Errorf("trigger = %+v, want %+v", got[0], want) } } + +// A cache TTL that would not read back as itself is refused, never shipped: +// the round-trip check covers the new field exactly as it covers model and effort. +func TestClaudeCacheTTLRoundTripRefusal(t *testing.T) { + in := fixtureInput(t) + sources, err := harness.ParseInventory(in.Assets) + if err != nil || len(sources) == 0 { + t.Fatalf("ParseInventory: %v (%d sources)", err, len(sources)) + } + for _, bad := range []string{"#5m", "[1h]", "{a: b}"} { + if _, err := renderAgent(sources[0], "claude-opus-5", "low", bad); !errors.Is(err, ErrRender) { + t.Errorf("cacheTTL %q: err = %v, want ErrRender", bad, err) + } + } + // The round-trip check alone also refuses a document whose TTL disagrees. + good, err := renderAgent(sources[0], "claude-opus-5", "low", "1h") + if err != nil { + t.Fatalf("renderAgent: %v", err) + } + if err := verifyRoundTrip(good, sources[0], "claude-opus-5", "low", "5m"); !errors.Is(err, ErrRender) { + t.Errorf("verifyRoundTrip accepted a mismatched cacheTtl: %v", err) + } +} diff --git a/internal/harness/claude/testdata/golden/docket-implement-next.md b/internal/harness/claude/testdata/golden/docket-implement-next.md index 5a63c09ae..204a5acec 100644 --- a/internal/harness/claude/testdata/golden/docket-implement-next.md +++ b/internal/harness/claude/testdata/golden/docket-implement-next.md @@ -2,6 +2,10 @@ name: 'docket-implement-next' description: 'Use when you want the next build-ready change in the docket backlog implemented end-to-end to an open PR with no human interaction — picking, claiming, reconciling against current reality, planning, building with TDD, reviewing, and stopping at the PR handoff. The autonomous backlog-drainer; runs solo per change.' skills: ['docket-implement-next', 'docket-convention'] +model: claude-opus-5 +effort: medium +experimental: + cacheTtl: 1h --- You are already running as `docket-implement-next`. Carry out this wrapper's assigned charter directly. Do not dispatch another `docket-implement-next` merely to perform the current assignment. Dispatches to different agents explicitly required by the active charter remain required. diff --git a/internal/harness/cross_harness_test.go b/internal/harness/cross_harness_test.go index 774b5ac75..4028e5902 100644 --- a/internal/harness/cross_harness_test.go +++ b/internal/harness/cross_harness_test.go @@ -356,3 +356,61 @@ func TestNoCrossHarnessDelegation(t *testing.T) { } } } + +// TestCacheTTLRendersOnClaudeOnly: a prompt-cache TTL pinned on every harness +// changes exactly one file, claude's wrapper for that agent. Cursor, Codex, and +// opencode have no such setting, so their plans are byte-identical with and +// without the pin, and no payload of theirs names the field. +func TestCacheTTLRendersOnClaudeOnly(t *testing.T) { + base := crossPlanInput(t) + pinned := crossPlanInput(t) + for _, h := range harness.Order { + s := pinned.Agents[h]["build-standard"] + s.CacheTTL = config.Value[string]{Value: "1h"} + pinned.Agents[h]["build-standard"] = s + } + adapters := map[string]harness.Adapter{ + "claude": claude.New(), "codex": codex.New(), "cursor": cursor.New(), "opencode": opencode.New(), + } + for _, h := range harness.Order { + before, err := adapters[h].Plan(base) + if err != nil { + t.Fatalf("%s: Plan(base): %v", h, err) + } + after, err := adapters[h].Plan(pinned) + if err != nil { + t.Fatalf("%s: Plan(pinned): %v", h, err) + } + if len(before) != len(after) { + t.Fatalf("%s: target count moved %d -> %d", h, len(before), len(after)) + } + var changed []string + for i := range after { + if after[i].Path != before[i].Path { + t.Fatalf("%s: target order moved at %d: %s vs %s", h, i, before[i].Path, after[i].Path) + } + if string(after[i].Content) != string(before[i].Content) { + changed = append(changed, filepath.Base(after[i].Path)) + } + if h != "claude" { + c := string(after[i].Content) + if strings.Contains(c, "cacheTtl") || strings.Contains(c, "cache_ttl") { + t.Errorf("%s: %s names the cache TTL field", h, after[i].Path) + } + } + } + if h == "claude" { + if len(changed) != 1 || changed[0] != "docket-build-standard.md" { + t.Fatalf("claude: changed files = %v, want only docket-build-standard.md", changed) + } + for _, tg := range after { + if filepath.Base(tg.Path) == "docket-build-standard.md" && + !strings.Contains(string(tg.Content), "\nexperimental:\n cacheTtl: 1h\n") { + t.Errorf("claude wrapper lacks the experimental.cacheTtl block:\n%s", tg.Content) + } + } + } else if len(changed) != 0 { + t.Errorf("%s: a cache TTL pin changed %v; this harness has no such setting", h, changed) + } + } +} diff --git a/internal/harness/inventory.go b/internal/harness/inventory.go index 5a949308d..2852e60ad 100644 --- a/internal/harness/inventory.go +++ b/internal/harness/inventory.go @@ -241,3 +241,12 @@ func ResolvedAgent(t config.AgentsTable, harnessName, shortName string) (model, } return model, effort } + +// ResolvedCacheTTL returns the prompt-cache TTL pin a harness should render +// for one agent short name, or "" when the agent carries none (config already +// folds `cache_ttl: auto` to ""). Only the claude adapter reads it: Claude Code +// is the one harness with a per-agent cache TTL setting, and the other +// adapters drop the field simply by never asking for it. +func ResolvedCacheTTL(t config.AgentsTable, harnessName, shortName string) string { + return t[harnessName][shortName].CacheTTL.Value +} diff --git a/internal/repoguard/absence_test.go b/internal/repoguard/absence_test.go index 71bf3e347..7077b3050 100644 --- a/internal/repoguard/absence_test.go +++ b/internal/repoguard/absence_test.go @@ -94,7 +94,7 @@ import ( // LIVE shipped program-data file, NOT a mere frozen fixture: the built-in agent // table in internal/config/defaults.go mirrors it, and it carries no retired // reference, so it stays GREEN on its content. It is byte-pinned twice — by -// TestBuiltinAgentsParityWithFrozenSidecar's frozen sidecar (currently v0.9.11) +// TestBuiltinAgentsParityWithFrozenSidecar's frozen sidecar (currently v0.9.13) // and by the embedded-tree copy internal/assets/generate.go writes — so any edit // to it re-cuts the sidecar and regenerates the embedded copy. // diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index d4ba90a1c..fbc040862 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -199,7 +199,7 @@ var skillBudgets = []skillBudget{ {"docket-convention/SKILL.md", 361, 7276}, // 0533: editing visibility never moves a repository; repository.set-visibility does (361/7263 -> 361/7276); 0532: the private-repository writing rule and visibility hand-off (359/7160 -> 361/7263); 0531 review: skills say "the metadata branch", naming `docket` once for shared mode (359/7155 -> 359/7160); 0531: the metadata worktree and remote come from the repository.prepare context (357/7062 -> 359/7155); 0530: plan, results, and build evidence live on the docket branch — the plan-writer's one metadata operation, the attach operations as file-backlink writers, relative same-branch links, and the new ## Build evidence record section (357/7026 -> 357/7062); 0529 review: the backlink block names its PR-body writers beside artifact.backlink, one shared renderer (357/7004 -> 357/7026); 0515: the repair sign-off is retired; a green repair merges (357/7007 -> 357/7004); 0502 rebase onto 0510: kept the backlog-match follow-up clause in the results-artifact paragraph (357/7004 -> 357/7007); 0502 review: the review-tier dispatch is the controller's own (357/6995 -> 357/7004); 0502: configuration read paths corrected — operations read .docket.yml from origin's default-branch tip, the repository setup operations from the primary worktree (357/6959 -> 357/6995); 0502: "loop" means /loop only (357/7036 -> 357/7037); 0502: one metadata layout — the docket branch and the .docket/ worktree (357/7035 -> 357/7036); 0502: agent layer names user-level wrappers and global-only pins (357/7024 -> 357/7035); 0502: typed operations replace the Bash-era script names; ## Run halted facts corrected (359/7092 -> 357/7024); 0502: terminal publish, the learnings cap, and the repository auto_groom default are deleted (362/7256 -> 359/7092); 0502: the auto-capture export paragraph is deleted (370/7334 -> 362/7256); 0502: the configuration contract rewritten from the schema (371/7488 -> 370/7334); 0502: fixed role skills; halt posture (372/7660 -> 371/7488); 0502: record blocks mirror the renderers (367/7619 -> 372/7660); 0502: the dummy-mode shared definition is deleted (390/7848 -> 367/7619); 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above); 0502: drop change citations and Go v1 narration (357/7037 -> 357/6958); 0502: the harness adapter is named without a docket source path (357/6958 -> 357/6959) // 0154: docket-convention/github-board-mirror.md removed — the GitHub mirror is // retired (unsupported, mutation-blocking); its budget row is deleted with it. - {"docket-convention/references/agent-layer.md", 168, 1593}, // 0538: init and repository.configure-harnesses set agent_harnesses; repository.check reports harnesses-unset (164/1553 -> 168/1593); 0535: a private repository's instructions file and its user-level triggers (148/1419 -> 164/1553); 0502: rewritten around user-level wrappers and global-only pins (202/2346 -> 148/1419); 0502: the legacy agents-file migration clause is deleted (202/2349 -> 202/2346) + {"docket-convention/references/agent-layer.md", 212, 1951}, // 0546: the per-agent cache_ttl pin, its shipped 1h default, and the machine-wide controls docket leaves unset (168/1593 -> 212/1951); 0538: init and repository.configure-harnesses set agent_harnesses; repository.check reports harnesses-unset (164/1553 -> 168/1593); 0535: a private repository's instructions file and its user-level triggers (148/1419 -> 164/1553); 0502: rewritten around user-level wrappers and global-only pins (202/2346 -> 148/1419); 0502: the legacy agents-file migration clause is deleted (202/2349 -> 202/2346) {"docket-convention/references/close-out.md", 141, 1347}, // 0531: the metadata worktree and remote come from the repository.prepare context (141/1349 -> 141/1347); 0502: "loop" means /loop only (144/1375 -> 144/1374); 0502: one metadata layout — the docket branch and the .docket/ worktree (144/1376 -> 144/1375); 0502: the scripts/.md contract pointer becomes the schema operation (144/1374 -> 144/1376); 0502: the terminal-publication step is deleted (240/2150 -> 144/1374); 0474: reference renamed (ADR-0129 row 59a); ceilings unchanged; 0502: drop change citations and Go v1 narration (144/1374 -> 144/1356); 0502: the docket-source test citations are deleted (144/1356 -> 141/1349) {"docket-convention/references/learnings.md", 69, 487}, // 0535: promotion lands in the private instructions file in a private repository (66/457 -> 69/487); 0502: one metadata layout — the docket branch and the .docket/ worktree (66/455 -> 66/457); 0502: learning.record/learning.update are the writers; the capacity section is deleted (84/580 -> 66/455) {"docket-convention/references/stacked-changes.md", 214, 2149}, // 0502: typed operations and binary-emitted findings replace the Bash-era names (213/2125 -> 214/2149); 0502: the deferred descendant-publication sentence is deleted (215/2140 -> 213/2125); 0327: +carry-preservation contract prose (see note above) diff --git a/internal/reposetup/visibilityconfig.go b/internal/reposetup/visibilityconfig.go index a741ab59b..9e8d19746 100644 --- a/internal/reposetup/visibilityconfig.go +++ b/internal/reposetup/visibilityconfig.go @@ -20,6 +20,8 @@ import ( "strings" "go.yaml.in/yaml/v3" + + "github.com/danielhanold/docket/internal/config" ) const visibilityKey = "visibility" @@ -356,14 +358,36 @@ func configLeaves(m *yaml.Node, prefix []string) []configLeaf { return out } -// isAgentPin reports an agents...model|effort|runner leaf. -func isAgentPin(keys []string) bool { - if len(keys) != 4 || keys[0] != "agents" { - return false +// agentPinPatterns is every config registry path under agents (dynamic +// segments spelled "*"), so a new agent pin row is folded like the others +// without a second spelling list here. +var agentPinPatterns = func() [][]string { + var out [][]string + for _, sp := range config.SettingPaths() { + if keys := strings.Split(sp.Path, "."); keys[0] == "agents" { + out = append(out, keys) + } } - switch keys[3] { - case "model", "effort", "runner": - return true + return out +}() + +// isAgentPin reports a leaf matching a registry agents... +// path: model, effort, cache_ttl, runner, and any pin added there later. +func isAgentPin(keys []string) bool { + for _, pat := range agentPinPatterns { + if len(pat) != len(keys) { + continue + } + match := true + for i, seg := range pat { + if seg != "*" && seg != keys[i] { + match = false + break + } + } + if match { + return true + } } return false } diff --git a/internal/reposetup/visibilityconfig_test.go b/internal/reposetup/visibilityconfig_test.go index 88f654144..4cdd44716 100644 --- a/internal/reposetup/visibilityconfig_test.go +++ b/internal/reposetup/visibilityconfig_test.go @@ -4,6 +4,7 @@ import ( "errors" "reflect" "slices" + "strings" "testing" "github.com/danielhanold/docket/internal/config" @@ -104,6 +105,54 @@ func TestFoldPrivateConfigDropsAgentPins(t *testing.T) { } } +// TestFoldPrivateConfigDropsCacheTTLPin: a committed cache_ttl agent pin is +// refused in repository layers like model and effort, so the fold drops it and +// reports it rather than carrying it into the private config. +func TestFoldPrivateConfigDropsCacheTTLPin(t *testing.T) { + committed := []byte("agents:\n claude:\n implement-next:\n cache_ttl: 1h\nreclaim:\n auto: true\n") + folded, dropped, err := FoldPrivateConfig(committed, nil, repoOnlyForTest()) + if err != nil { + t.Fatalf("FoldPrivateConfig: %v", err) + } + if want := []string{"agents.claude.implement-next.cache_ttl"}; !slices.Equal(dropped, want) { + t.Errorf("dropped = %v, want %v", dropped, want) + } + if got := leafValues(t, folded, "agents"); len(got) != 0 { + t.Errorf("cache_ttl pin carried into the private config: %v\n%s", got, folded) + } +} + +// TestIsAgentPinCoversEveryRegistryAgentLeaf derives the agent-pin leaves from +// the config registry, so a new agents... row is a pin +// here without a second spelling list. +func TestIsAgentPinCoversEveryRegistryAgentLeaf(t *testing.T) { + n := 0 + for _, sp := range config.SettingPaths() { + keys := strings.Split(sp.Path, ".") + if keys[0] != "agents" { + continue + } + n++ + concrete := slices.Clone(keys) + for i, k := range concrete { + if k == "*" { + concrete[i] = "x" + } + } + if !isAgentPin(concrete) { + t.Errorf("isAgentPin(%v) = false for registry path %s", concrete, sp.Path) + } + } + if n == 0 { + t.Fatal("no agents.* paths in the config registry") + } + for _, keys := range [][]string{{"agents", "claude", "x", "colour"}, {"reclaim", "auto"}, {"agents", "claude", "x"}} { + if isAgentPin(keys) { + t.Errorf("isAgentPin(%v) = true, want false", keys) + } + } +} + func TestSplitPrivateConfigRoutesSavedLocalLeaves(t *testing.T) { private := []byte("visibility: private\nbuild:\n test_command: make\nreclaim:\n auto: true\nintegration_branch: main\n") localKeys := []byte("reclaim:\n auto: false\nintegration_branch: x\n") diff --git a/skills/docket-convention/references/agent-layer.md b/skills/docket-convention/references/agent-layer.md index 76c958a70..05c8145ee 100644 --- a/skills/docket-convention/references/agent-layer.md +++ b/skills/docket-convention/references/agent-layer.md @@ -54,6 +54,48 @@ and space-free: each must be a single token. alias layer and no vendor allowlist; the running harness interprets it (a Claude ID under Claude Code, a Cursor model ID under Cursor). That passthrough is what lets docket drive non-Claude harnesses. +### Prompt-cache TTL + +An agent entry may carry a third, optional field, `cache_ttl`, which sets how long Claude Code keeps +that agent's prompt cache. It layers exactly like `model` and `effort`: the built-in table, then the +global configuration, per field, with a harness entry falling back to `default:`. A `cache_ttl` in +`.docket.yml` or `.docket.local.yml` blocks writes. The value passes through verbatim (`1h`, `5m`); +`cache_ttl: auto` removes the pin and leaves Claude Code's default. + +The built-in table sets `cache_ttl: 1h` on claude's `implement-next` and nowhere else. That agent +orchestrates: it waits while a build worker or the test suite runs, and those waits regularly outlast +the 5-minute subagent cache, so its next turn writes its whole context into the cache again. The +other agents rarely wait that long between turns, and a 1-hour cache write costs twice the input price +against 1.25 times for a 5-minute one, so for them a 1-hour TTL would cost more than it saves. + +The claude wrapper carries it under the `experimental` map: + +```yaml +experimental: + cacheTtl: 1h +``` + +Cursor, Codex, and opencode have no prompt-cache TTL setting. A `cache_ttl` that resolves for one of +them, for example through a `default:` entry, is left out of that harness's wrapper, and the install +warns `agent-cache-ttl-dropped`, naming the harness and agent. + +Claude Code also has machine-wide controls. docket doesn't set them, because each one reaches every +subagent rather than one agent: + +| control | reaches | +|---|---| +| `CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=1h` (environment) | every subagent, plus workflows, teammates, forks, compaction, and session titles | +| `subagentPromptCacheTtl: "1h"` (Claude Code settings) | the same as the environment variable | +| `ENABLE_PROMPT_CACHING_1H=1` (environment) | the main conversation and every subagent | +| `FORCE_PROMPT_CACHING_5M=1` (environment) | everything, forcing the 5-minute TTL | + +Precedence, highest first: `FORCE_PROMPT_CACHING_5M`, the subagent environment variable, the +subagent setting, an agent's own `cacheTtl`, `ENABLE_PROMPT_CACHING_1H`, then the default. If you set +one of the broader controls, it overrides docket's pin. + +The agent field needs Claude Code 2.1.248 or later. On a Claude subscription, Claude Code ignores an +agent's `cacheTtl: 1h` while usage is drawn from usage credits. + ## Per-harness wrapper shapes Each harness gets its own wrapper shape: @@ -66,7 +108,9 @@ Each harness gets its own wrapper shape: | opencode | `.md` | `model:` (`openrouter//` passes through whole) | `reasoningEffort:` (a provider model option) | body preamble | Cursor's wrapper carries `name`, `description`, and `model`, leaving `readonly` and `is_background` at Cursor's -defaults. On Cursor and opencode an effort with no resolved model has nowhere to attach and is dropped. +defaults. On Cursor and opencode an effort with no resolved model has nowhere to attach and is dropped. Only the +claude wrapper carries a prompt-cache TTL (`experimental.cacheTtl`; see +[Prompt-cache TTL](#prompt-cache-ttl)). ## Repository dispatch blocks: agent_harnesses diff --git a/testdata/repositories/v0.9.13/PROVENANCE.md b/testdata/repositories/v0.9.13/PROVENANCE.md new file mode 100644 index 000000000..a1c5626a8 --- /dev/null +++ b/testdata/repositories/v0.9.13/PROVENANCE.md @@ -0,0 +1,23 @@ +# Provenance — `testdata/repositories/v0.9.13/` + +This one file covers the whole `v0.9.13/` tree, per `testdata/README.md`. Each fixture added to the +tree extends it with one entry below. + +## Contents + +- `deferred-active/` — cut from `v0.9.2/deferred-active`, 2026-10-11, by the change that adds the + per-agent `cache_ttl` pin. Hand-written configuration, no redaction. The ONLY difference from + the v0.9.2 copy is one added line, `cache_ttl: 1h`, under `agents.claude.adr` in + `repo/.docket.yml`: the new `agents.*.*.cache_ttl` registry row is a repo-settable agent pin, + so a repository layer requesting it is one more preflight blocker, and the fixture must request + every such capability at once. Everything else is carried verbatim. Older versioned fixture + trees (v0.9.2–v0.9.12) are immutable inputs and are never edited in place. +- `agents-harness-defaults.yml` — cut from `v0.9.11/agents-harness-defaults.yml`, 2026-10-11, by + the change that adds the per-agent `cache_ttl` pin. Source repo `danielhanold/docket`; commit: + the commit that adds this file (it equals `agents/harness-defaults.yml` there). No redaction. A + byte-exact copy of docket's own `agents/harness-defaults.yml` at that change. It differs from + the v0.9.11 sidecar in one value and its comments: the claude `implement-next` row gains + `cache_ttl: 1h` with a comment explaining why, and the header documents the optional + `cache_ttl` field. Every model and effort is unchanged. The Go built-in table in + `internal/config/defaults.go` gains the matching `builtinAgentCacheTTL` entry, and only the + agent-defaults parity oracle's `sidecarPath` advances to this tree. diff --git a/testdata/repositories/v0.9.13/agents-harness-defaults.yml b/testdata/repositories/v0.9.13/agents-harness-defaults.yml new file mode 100644 index 000000000..33b6310df --- /dev/null +++ b/testdata/repositories/v0.9.13/agents-harness-defaults.yml @@ -0,0 +1,144 @@ +# agents/harness-defaults.yml — docket's shipped per-harness agent model/effort/cache-TTL table. +# +# This is program data, not a user configuration file. The docket binary compiles the same table +# into its built-in agent defaults (internal/config/defaults.go), and a test keeps the two equal. +# +# Only the global configuration (${XDG_CONFIG_HOME:-~/.config}/docket/config.yml) overrides it. +# Overrides apply per agent and per field; the repository layers (.docket.yml and +# .docket.local.yml) never override agent pins. +# +# Shape rules: +# - every entry nests under a concrete harness; there is no harness-neutral `default:` block, +# because one model ID never fits every harness's roster; +# - keys are wrapper short names (build-economy, not docket-build-economy); +# - every listed entry supplies both model and effort; +# - an entry may add an optional `cache_ttl` (a prompt-cache TTL; only Claude Code has the +# setting). It ships on claude's implement-next alone; see the comment on that row; +# - every shipped harness (claude, cursor, codex, opencode) carries a complete 17-agent block. +# +# Model IDs and effort tokens are opaque passthrough values (ADR-0015). Docket keeps no vendor +# allowlist; an unrecognized string is never rejected here. +agents: + claude: + adr: { model: claude-opus-5, effort: low } + auto-groom: { model: claude-opus-5, effort: low } + auto-groom-critic: { model: claude-opus-5, effort: medium } + brainstorm-consultant: { model: claude-opus-5, effort: medium } + # build-economy ships Sonnet rather than Haiku deliberately: the worker contract is long and strict, + # and its failure modes (malformed return, stray commit, unverifiable COMPLETE) HALT the build + # instead of escalating, so contract-fumble risk lands on the human. Haiku + # (claude-haiku-4-5-20251001) is the documented cost-aggressive user-layer override, not a + # shipped default. + build-economy: { model: claude-sonnet-5, effort: low } + build-standard: { model: claude-opus-5, effort: low } + build-premium: { model: claude-opus-5, effort: medium } + build-max: { model: claude-opus-5, effort: high } + finalize-change: { model: claude-opus-5, effort: low } + # implement-next waits on build workers and the test suite for longer than Claude Code's + # 5-minute subagent prompt cache lasts, so most of its cost went to writing its whole context + # into the cache again after each wait. A 1-hour TTL roughly halves its cost per run. The + # other agents rarely wait five minutes between turns, and a 1-hour cache write costs more + # than a 5-minute one, so they keep Claude Code's default. + implement-next: { model: claude-opus-5, effort: medium, cache_ttl: 1h } + integration-repair: { model: claude-opus-5, effort: medium } + plan-writer: { model: claude-opus-5, effort: high } + rebase-resolver: { model: claude-opus-5, effort: medium } + # The review tiers price REVIEW work directly rather than shifting the build ladder by one: a + # read-and-return reviewer spends its budget reading a whole branch, not assembling one. On + # every harness review-deep equals the build-max pin, so the cap tier never reviews below the + # strength the riskiest build work was built with. The lean tier deliberately departs from a + # literal one-above-the-build on claude: the build-economy comment's reason for avoiding a + # smaller model — a contract fumble HALTS the build — does not apply here, because a reviewer + # that fumbles returns findings the controller can simply discount. + review-lean: { model: claude-sonnet-5, effort: high } + review-standard: { model: claude-opus-5, effort: medium } + review-deep: { model: claude-opus-5, effort: high } + status: { model: claude-haiku-4-5-20251001, effort: medium } + + # Cursor's full wrapper set. Each ID is a COMPLETE Cursor built-in whose variant is already + # encoded, so effort is `auto`: the Cursor emitter writes the ID verbatim rather than appending + # a second, conflicting `[effort=…]` suffix. The tiering mirrors the claude block's intent — + # cheapest for the mechanical status sweep, mid for design and repair work, highest for the + # max build tier — expressed in Cursor's variant suffixes instead of an effort token. + cursor: + adr: { model: cursor-grok-4.5-high, effort: auto } + auto-groom: { model: cursor-grok-4.5-medium, effort: auto } + auto-groom-critic: { model: cursor-grok-4.5-high, effort: auto } + brainstorm-consultant: { model: cursor-grok-4.5-high, effort: auto } + build-economy: { model: cursor-grok-4.5-low, effort: auto } + build-standard: { model: cursor-grok-4.5-medium, effort: auto } + build-premium: { model: cursor-grok-4.5-high, effort: auto } + build-max: { model: claude-opus-5-high, effort: auto } + finalize-change: { model: cursor-grok-4.5-high-fast, effort: auto } + implement-next: { model: cursor-grok-4.5-high, effort: auto } + integration-repair: { model: cursor-grok-4.5-high, effort: auto } + plan-writer: { model: cursor-grok-4.5-xhigh, effort: auto } + rebase-resolver: { model: cursor-grok-4.5-high, effort: auto } + review-lean: { model: cursor-grok-4.5-medium, effort: auto } + review-standard: { model: cursor-grok-4.5-high, effort: auto } + review-deep: { model: claude-opus-5-high, effort: auto } + status: { model: cursor-grok-4.5-low-fast, effort: auto } + + # Codex's full wrapper set. Codex takes a real reasoning-effort token per agent, + # so unlike the cursor block every row carries a distinct model/effort PAIR rather than `auto`. + # + # The tier names describe the capability/cost role of a complete pair, NOT a cross-model + # ordinal: reasoning tokens are model-specific settings, so `xhigh` on Luna is not "stronger + # than" `medium` on Sol. + # The build ladder is therefore Luna/xhigh for the economy tier, Terra/medium for the standard + # tier, Sol/low for the premium tier, and Sol/medium for the rare max tier — note Sol + # appears twice at different efforts, which is the point: the pair is the role, not the model. + codex: + adr: { model: gpt-5.6-terra, effort: xhigh } + auto-groom: { model: gpt-5.6-sol, effort: low } + auto-groom-critic: { model: gpt-5.6-sol, effort: medium } + brainstorm-consultant: { model: gpt-5.6-sol, effort: medium } + build-economy: { model: gpt-5.6-luna, effort: xhigh } + build-standard: { model: gpt-5.6-terra, effort: medium } + build-premium: { model: gpt-5.6-sol, effort: low } + build-max: { model: gpt-5.6-sol, effort: medium } + finalize-change: { model: gpt-5.6-terra, effort: high } + implement-next: { model: gpt-5.6-sol, effort: medium } + integration-repair: { model: gpt-5.6-sol, effort: high } + plan-writer: { model: gpt-5.6-terra, effort: high } + rebase-resolver: { model: gpt-5.6-sol, effort: high } + review-lean: { model: gpt-5.6-terra, effort: medium } + review-standard: { model: gpt-5.6-terra, effort: high } + review-deep: { model: gpt-5.6-sol, effort: medium } + status: { model: gpt-5.6-luna, effort: xhigh } + + # opencode's full wrapper set. opencode has no first-class effort token; it passes + # unrecognized agent-frontmatter keys through to the provider as model options, so docket emits + # the effort as `reasoningEffort:` and it arrives as a real per-agent reasoning effort. Verified + # against opencode 1.18.11 via `opencode debug agent`, which reports it under `options`. + # + # Models are reached through OpenRouter, so IDs are double-prefixed (`openrouter//`). + # The table is three models, chosen for very cheap + # high intelligence: DeepSeek V4 Flash carries every volume row (sweep, grooming, orchestration, + # the economy and standard build tiers, the lean review tier), Kimi K3 carries the judgment rows + # — design prose, ADRs, merge-intent reconstruction, red-suite repair — and the ladder top, + # appearing at two efforts exactly as Sol does in the codex block, and GPT-5.6 Luna is one + # deliberate diversity row so the adversarial critic does not share the drafter's blind spots. + # + # As in every other block the PAIR is the role, not the model: `high` on Flash is not "stronger + # than" `medium` on Kimi. review-deep equals build-max so the cap tier never reviews below the + # strength the riskiest build work was built with. Efforts cap at `high` — reasoning-effort + # vocabularies are model-specific and xhigh-class tokens are not assumed portable here. + opencode: + adr: { model: openrouter/moonshotai/kimi-k3, effort: medium } + auto-groom: { model: openrouter/deepseek/deepseek-v4-flash-0731, effort: medium } + auto-groom-critic: { model: openrouter/openai/gpt-5.6-luna, effort: high } + brainstorm-consultant: { model: openrouter/moonshotai/kimi-k3, effort: medium } + build-economy: { model: openrouter/deepseek/deepseek-v4-flash-0731, effort: medium } + build-standard: { model: openrouter/deepseek/deepseek-v4-flash-0731, effort: high } + build-premium: { model: openrouter/moonshotai/kimi-k3, effort: medium } + build-max: { model: openrouter/moonshotai/kimi-k3, effort: high } + finalize-change: { model: openrouter/deepseek/deepseek-v4-flash-0731, effort: high } + implement-next: { model: openrouter/deepseek/deepseek-v4-flash-0731, effort: high } + integration-repair: { model: openrouter/moonshotai/kimi-k3, effort: high } + plan-writer: { model: openrouter/deepseek/deepseek-v4-pro-0813, effort: medium } + rebase-resolver: { model: openrouter/moonshotai/kimi-k3, effort: high } + review-lean: { model: openrouter/deepseek/deepseek-v4-flash-0731, effort: high } + review-standard: { model: openrouter/moonshotai/kimi-k3, effort: medium } + review-deep: { model: openrouter/moonshotai/kimi-k3, effort: high } + status: { model: openrouter/deepseek/deepseek-v4-flash-0731, effort: low } diff --git a/testdata/repositories/v0.9.13/deferred-active/repo/.docket.yml b/testdata/repositories/v0.9.13/deferred-active/repo/.docket.yml new file mode 100644 index 000000000..92d0fc8c3 --- /dev/null +++ b/testdata/repositories/v0.9.13/deferred-active/repo/.docket.yml @@ -0,0 +1,37 @@ +# Every repo-settable deferred capability requested at once: the complete +# blocker set in one document. The test derives the expected set from the +# schema registry rather than from this list, so a deferred row added to the +# registry without a line here fails loudly instead of going unasserted. + +finalize: + gate: ci + skip_results_only_delta: true + +build: + checkpoint: true + +terminal_publish: true +auto_groom: true + +auto_capture: + enabled: true + +dummy_mode: + enabled: true + +board_surfaces: [inline, github] + +skills: + brainstorm: docket-brainstorm + plan: docket-plan + build: docket-build + review: docket-review + finish: docket-finish + +agents: + claude: + adr: + model: repo-pinned-model + effort: high + cache_ttl: 1h + runner: codex