From 8fde26137f5faf3b56caee219148a3ed80ab9885 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sun, 11 Oct 2026 09:41:29 -0400 Subject: [PATCH 1/9] Add design spec: Per-agent prompt-cache TTL pin, shipped 1h for implement-next --- ...in-shipped-1h-for-implement-next-design.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-09-per-agent-prompt-cache-ttl-pin-shipped-1h-for-implement-next-design.md 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. From 07e37895650f88776339ea7b0a3479ac89ecdb4a Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sun, 11 Oct 2026 09:56:44 -0400 Subject: [PATCH 2/9] feat(config): add the per-agent cache_ttl pin --- internal/config/capability_test.go | 10 +++ internal/config/config.go | 6 +- internal/config/decode_test.go | 3 + internal/config/defaults.go | 5 +- internal/config/defaults_test.go | 9 +-- internal/config/fixtures_test.go | 13 +++- internal/config/resolve.go | 11 +++- internal/config/resolve_test.go | 66 +++++++++++++++++++ internal/config/schema.go | 12 ++-- internal/config/schema_test.go | 6 +- internal/config/setting_paths_test.go | 2 +- testdata/repositories/v0.9.13/PROVENANCE.md | 14 ++++ .../v0.9.13/deferred-active/repo/.docket.yml | 37 +++++++++++ 13 files changed, 177 insertions(+), 17 deletions(-) create mode 100644 testdata/repositories/v0.9.13/PROVENANCE.md create mode 100644 testdata/repositories/v0.9.13/deferred-active/repo/.docket.yml 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..cace28ae7 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -184,8 +184,9 @@ func builtinAgents() AgentsTable { effort = "" } row[name] = AgentSetting{ - Model: builtinValue(def.model), - Effort: builtinValue(effort), + Model: builtinValue(def.model), + Effort: builtinValue(effort), + CacheTTL: builtinValue(""), } } out[harness] = row diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go index a9f3a6f7e..f4447f1d4 100644 --- a/internal/config/defaults_test.go +++ b/internal/config/defaults_test.go @@ -115,11 +115,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/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..1c5c6b701 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) { 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/testdata/repositories/v0.9.13/PROVENANCE.md b/testdata/repositories/v0.9.13/PROVENANCE.md new file mode 100644 index 000000000..521d9da6c --- /dev/null +++ b/testdata/repositories/v0.9.13/PROVENANCE.md @@ -0,0 +1,14 @@ +# 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. 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 From 0affb367e61cbbdc8308452a0b1cd6e9eb50fbc2 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sun, 11 Oct 2026 09:58:25 -0400 Subject: [PATCH 3/9] feat(agents): ship a 1h prompt-cache TTL for claude implement-next --- .docket.example.yml | 7 +- agents/harness-defaults.yml | 11 +- internal/assets/embedded/manifest.json | 10 +- .../assets/embedded/tree/.docket.example.yml | 7 +- .../embedded/tree/agents/harness-defaults.yml | 11 +- internal/config/defaults.go | 15 +- internal/config/defaults_test.go | 39 +++-- .../config/example_correspondence_test.go | 16 +- internal/config/resolve_test.go | 21 +++ internal/repoguard/absence_test.go | 2 +- testdata/repositories/v0.9.13/PROVENANCE.md | 9 ++ .../v0.9.13/agents-harness-defaults.yml | 144 ++++++++++++++++++ 12 files changed, 264 insertions(+), 28 deletions(-) create mode 100644 testdata/repositories/v0.9.13/agents-harness-defaults.yml 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/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index a5dccb772..87e19afc8 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:991b1e10892b82cb6223a456b084ffdafd81b44de4d25fdc5760261e720cc426", "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", 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/config/defaults.go b/internal/config/defaults.go index cace28ae7..4bc4d802e 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -7,7 +7,7 @@ package config // 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. +// `testdata/repositories/v0.9.13/` is the parity oracle. // builtinProvenance is the provenance every default carries. func builtinProvenance() Provenance { @@ -171,8 +171,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)) @@ -186,7 +195,7 @@ func builtinAgents() AgentsTable { row[name] = AgentSetting{ Model: builtinValue(def.model), Effort: builtinValue(effort), - CacheTTL: builtinValue(""), + CacheTTL: builtinValue(builtinAgentCacheTTL[harness][name]), } } out[harness] = row diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go index f4447f1d4..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 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/resolve_test.go b/internal/config/resolve_test.go index 1c5c6b701..58ea89998 100644 --- a/internal/config/resolve_test.go +++ b/internal/config/resolve_test.go @@ -1394,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/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/testdata/repositories/v0.9.13/PROVENANCE.md b/testdata/repositories/v0.9.13/PROVENANCE.md index 521d9da6c..a1c5626a8 100644 --- a/testdata/repositories/v0.9.13/PROVENANCE.md +++ b/testdata/repositories/v0.9.13/PROVENANCE.md @@ -12,3 +12,12 @@ tree extends it with one entry below. 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 } From 1e82287699e7d3d99fb08c3dd1847876a37860ed Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sun, 11 Oct 2026 09:59:49 -0400 Subject: [PATCH 4/9] feat(harness): render the claude cache TTL as experimental.cacheTtl --- internal/harness/claude/claude.go | 31 +++++-- internal/harness/claude/claude_test.go | 87 ++++++++++++++----- .../testdata/golden/docket-implement-next.md | 4 + internal/harness/cross_harness_test.go | 58 +++++++++++++ internal/harness/inventory.go | 9 ++ 5 files changed, 160 insertions(+), 29 deletions(-) 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 +} From c4b0a228061c711e9325e1adceb2a5fb1cd88c58 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sun, 11 Oct 2026 10:01:46 -0400 Subject: [PATCH 5/9] feat(install): warn when a cache_ttl pin is dropped; digest it --- internal/app/finding_codes.go | 8 +++ internal/app/install.go | 62 ++++++++++++++++--- internal/app/install_test.go | 69 ++++++++++++++++++++++ internal/cli/install.go | 9 ++- internal/cli/install_cachettl_test.go | 85 +++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 internal/cli/install_cachettl_test.go 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..bd66b281b 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,17 @@ 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. func AgentDigest(agents config.AgentsTable, harnesses []string) (string, error) { selected := make(map[string]bool, len(harnesses)) for _, name := range harnesses { @@ -504,8 +549,9 @@ 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, + Model: setting.Model.Value, + Effort: setting.Effort.Value, + CacheTTL: setting.CacheTTL.Value, } } table[harnessName] = rendered diff --git a/internal/app/install_test.go b/internal/app/install_test.go index 687338509..f4e3520a9 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,69 @@ 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) + } +} 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) + } + } +} From 659589751514d30cf0386361bc087886b5a54f2e Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sun, 11 Oct 2026 10:03:05 -0400 Subject: [PATCH 6/9] docs(agents): document the per-agent cache_ttl pin --- docs/install/models-and-effort.md | 10 ++++ internal/assets/embedded/manifest.json | 6 +- .../references/agent-layer.md | 55 ++++++++++++++++++- internal/repoguard/budgets_test.go | 2 +- .../references/agent-layer.md | 55 ++++++++++++++++++- 5 files changed, 122 insertions(+), 6 deletions(-) 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/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 87e19afc8..1855fad87 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,7 +1,7 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:991b1e10892b82cb6223a456b084ffdafd81b44de4d25fdc5760261e720cc426", + "asset_set_id": "sha256:61e4db8e9208c263c3bb32f1fa5770b702be31dc0084568e38868d6340468b69", "entries": [ { "path": ".docket.example.yml", @@ -329,8 +329,8 @@ "path": "skills/docket-convention/references/agent-layer.md", "role": "skill", "mode": 420, - "size": 11621, - "sha256": "ad7e44f72a6e26739caff068b61bf5600a38b3324d4f7fa44771054ca5a7addc" + "size": 14448, + "sha256": "239bca40e96c9c3f4559226f6bb614cd5b0c894bf35d7b476627b2793dcee961" }, { "path": "skills/docket-convention/references/close-out.md", 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..efad78eb3 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,57 @@ 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. A cost +audit of its transcripts found most of its spend going to those rewrites, and a 1-hour TTL is +estimated to halve its cost per run. 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 is a surcharge of about 10–23%. + +| agent | cost per run | cache writes that were expiry rewrites | +|---|---|---| +| implement-next | $5.35 | 87% | +| plan-writer | $3.96 | 24% | +| build-premium | $2.23 | 12% | +| build-standard | $0.96 | 5% | + +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 +117,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/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index d4ba90a1c..ce346d1c2 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", 221, 2021}, // 0546: the per-agent cache_ttl pin, its shipped 1h default, and the machine-wide controls docket leaves unset (168/1593 -> 221/2021); 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/skills/docket-convention/references/agent-layer.md b/skills/docket-convention/references/agent-layer.md index 76c958a70..efad78eb3 100644 --- a/skills/docket-convention/references/agent-layer.md +++ b/skills/docket-convention/references/agent-layer.md @@ -54,6 +54,57 @@ 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. A cost +audit of its transcripts found most of its spend going to those rewrites, and a 1-hour TTL is +estimated to halve its cost per run. 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 is a surcharge of about 10–23%. + +| agent | cost per run | cache writes that were expiry rewrites | +|---|---|---| +| implement-next | $5.35 | 87% | +| plan-writer | $3.96 | 24% | +| build-premium | $2.23 | 12% | +| build-standard | $0.96 | 5% | + +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 +117,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 From 2870236422795ed1695a7ce09e653848db2baba5 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sun, 11 Oct 2026 10:16:23 -0400 Subject: [PATCH 7/9] fix(reposetup): treat the cache_ttl agent pin as an agent pin in the visibility fold Review finding: isAgentPin hand-listed model/effort/runner, so a repository-layer agents...cache_ttl was carried into the private config instead of being dropped and reported. Derive the agent-pin leaves from the config registry's agents.* rows instead of a spelling list. --- internal/reposetup/visibilityconfig.go | 38 +++++++++++++--- internal/reposetup/visibilityconfig_test.go | 49 +++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) 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") From 49c844307ce885513902260eab340db5158963f5 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sun, 11 Oct 2026 10:18:12 -0400 Subject: [PATCH 8/9] fix(app): show the per-agent cache_ttl pin in the effective-config listing Review finding: agentLines rendered only model / effort, so the shipped 1h cache TTL on claude implement-next, a global override, and a global auto that clears it were all invisible in the human effective block. Append a ttl segment when a pin resolves or a layer declared one, and name the cache_ttl layer in the bracket when it differs from the model's. --- internal/app/config.go | 21 ++++++++++++++++++--- internal/app/config_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) 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) + } + } +} From c19ca264f1c5289520d3d845716a4a8dcc261c57 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sun, 11 Oct 2026 10:19:14 -0400 Subject: [PATCH 9/9] fix: address three review findings on the cache_ttl pin - AgentDigest digests cache_ttl only for harnesses in cacheTTLHarnesses, since the digest identifies what was rendered; add a test that a codex digest is unchanged by a cache_ttl. - defaults.go package comment anchors the agent table's parity oracle on testdata/repositories/v0.9.13 and its PROVENANCE.md entry. - agent-layer.md drops the dated per-agent cost table and figures, keeping the qualitative reason; regenerate embedded assets and lower the budget row to 212/1951. --- internal/app/install.go | 11 +++++---- internal/app/install_test.go | 23 +++++++++++++++++++ internal/assets/embedded/manifest.json | 6 ++--- .../references/agent-layer.md | 15 +++--------- internal/config/defaults.go | 7 +++--- internal/repoguard/budgets_test.go | 2 +- .../references/agent-layer.md | 15 +++--------- 7 files changed, 42 insertions(+), 37 deletions(-) diff --git a/internal/app/install.go b/internal/app/install.go index bd66b281b..4fb782c09 100644 --- a/internal/app/install.go +++ b/internal/app/install.go @@ -535,7 +535,8 @@ type agentDigestRow struct { // 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. +// 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 { @@ -548,11 +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, - CacheTTL: setting.CacheTTL.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 f4e3520a9..b151254e6 100644 --- a/internal/app/install_test.go +++ b/internal/app/install_test.go @@ -608,3 +608,26 @@ func TestAgentDigestCoversCacheTTL(t *testing.T) { 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 1855fad87..3c149b5d3 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,7 +1,7 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:61e4db8e9208c263c3bb32f1fa5770b702be31dc0084568e38868d6340468b69", + "asset_set_id": "sha256:09542ded9e93bf9a659c43dbdf09ffd7965c29bac84d3cf5151fc784b16dd297", "entries": [ { "path": ".docket.example.yml", @@ -329,8 +329,8 @@ "path": "skills/docket-convention/references/agent-layer.md", "role": "skill", "mode": 420, - "size": 14448, - "sha256": "239bca40e96c9c3f4559226f6bb614cd5b0c894bf35d7b476627b2793dcee961" + "size": 14099, + "sha256": "e11cf4d54689b3dec68d1e98e41558a0d28997b5860c61eeab0f5a6272f6a7b3" }, { "path": "skills/docket-convention/references/close-out.md", 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 efad78eb3..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 @@ -64,18 +64,9 @@ global configuration, per field, with a harness entry falling back to `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. A cost -audit of its transcripts found most of its spend going to those rewrites, and a 1-hour TTL is -estimated to halve its cost per run. 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 is a surcharge of about 10–23%. - -| agent | cost per run | cache writes that were expiry rewrites | -|---|---|---| -| implement-next | $5.35 | 87% | -| plan-writer | $3.96 | 24% | -| build-premium | $2.23 | 12% | -| build-standard | $0.96 | 5% | +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: diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 4bc4d802e..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.13/` 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 { diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index ce346d1c2..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", 221, 2021}, // 0546: the per-agent cache_ttl pin, its shipped 1h default, and the machine-wide controls docket leaves unset (168/1593 -> 221/2021); 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/skills/docket-convention/references/agent-layer.md b/skills/docket-convention/references/agent-layer.md index efad78eb3..05c8145ee 100644 --- a/skills/docket-convention/references/agent-layer.md +++ b/skills/docket-convention/references/agent-layer.md @@ -64,18 +64,9 @@ global configuration, per field, with a harness entry falling back to `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. A cost -audit of its transcripts found most of its spend going to those rewrites, and a 1-hour TTL is -estimated to halve its cost per run. 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 is a surcharge of about 10–23%. - -| agent | cost per run | cache writes that were expiry rewrites | -|---|---|---| -| implement-next | $5.35 | 87% | -| plan-writer | $3.96 | 24% | -| build-premium | $2.23 | 12% | -| build-standard | $0.96 | 5% | +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: