idle-draft: per-work-type and per-provider model/effort settings

--effort/--model on child argv; provider entries accept object form
{provider, model, effort}; precedence entry > work-type > profile.
Effort levels validated against the CLI's enumerated set.

Claude-Session: https://claude.ai/code/session_01YQDoWNM7XPPii28khFWoMc
This commit is contained in:
Paul O'Reilly
2026-08-02 22:16:34 +12:00
parent ec25f02f01
commit ca8b538d34
4 changed files with 757 additions and 15 deletions

View File

@@ -239,14 +239,21 @@ For the selected `(item_or_dossier, work_type, provider)`:
invoked** (it has interactive pickers and terminal theming unsuitable for headless
cron use).
4. Build the argv: `claude -p --max-turns <N>` (`N` = 25 unless overridden), plus
`--model <id>` if `provider.env` sets `MODEL_ID`, plus `--add-dir <dir>` for each
entry in `config["evidence_dirs"]` **only** for `research` and `topic_ideas` work
types (the only ones that cite external evidence), plus `--allowedTools
`--model <id>` and `--effort <level>` resolved through the full precedence chain —
per-provider override on the selected provider's `providers`-list entry, then
work-type-level `model`/`effort`, then (for `model` only) `provider.env`'s
`MODEL_ID`; the flag is omitted at each layer where nothing resolves (see
"Per-work-type effort and model" and "Per-provider effort and model overrides"
below) — plus `--add-dir <dir>` for each entry in `config["evidence_dirs"]`
**only** for `research` and `topic_ideas`
work types (the only ones that cite external evidence), plus `--allowedTools
"<comma-joined list>"` if `config["work_types"][work_type]["allowed_tools"]` is
present and non-empty (see Config schema below). Headless `claude -p` runs cannot
answer permission prompts, so any tool beyond the CLI's defaults must be granted
this way or the child run stalls/fails; when the key is absent or an empty list,
the flag is omitted entirely (unchanged pre-existing behaviour).
this way or the child run stalls/fails; when the `allowed_tools` key is absent or an
empty list, that flag is omitted entirely (unchanged pre-existing behaviour). Same for
`effort`/`model`: absent key -> no corresponding flag, argv unchanged from before
these keys existed.
5. Run the subprocess: `cwd=<repo>`, `env=<built env>`, prompt piped via **stdin**
(not as an argv element — avoids `ARG_MAX` on large rendered prompts, same lesson
`claude-profile` already applies to its system-prompt injection), timeout = per-task
@@ -401,6 +408,87 @@ config validation failure — exit **2** at config load, same as a missing requi
before writing its Subjective stories section; the topic-ideas template requires one as
a lighter-touch check); `draft` and `review` carry no `allowed_tools` key.
### Per-work-type effort and model
Each entry in `work_types` may also carry two further optional keys, both consumed by
§11 step 4 when building the child's argv:
- **`effort`** (string): appends `--effort <value>` to the child argv. Validated at
config-load time against the fixed set of levels the `claude` CLI itself accepts:
`low`, `medium`, `high`, `xhigh`, `max`. This set was determined empirically on
2026-08-02 by running `claude --effort obviously-bogus-level -p ""` — an invalid
`--effort` value is rejected by the CLI's own argument parsing before any network
call is made, so the probe is safe to run offline — and reading the resulting error,
which enumerated the valid values verbatim (cross-checked against `claude --help`'s
`--effort <level>` description, which lists the same five). The set is hard-coded as
`CLAUDE_EFFORT_LEVELS` in `scripts/idle-draft`; if a future CLI version changes it,
re-run the same probe and update the constant (with a fresh date in the comment).
A value that is not a non-empty string, or not a member of this set, is a config
validation failure — exit **2**, same as a malformed `allowed_tools`.
- **`model`** (string): appends `--model <value>` to the child argv, **replacing** —
not adding to — the `--model <id>` that would otherwise come from the provider
profile's `provider.env` `MODEL_ID` (§11 step 4). Only type-checked at config-load
time (must be a non-empty string) — model identifiers are not enumerated, since new
ones ship independently of this tool and hard-coding a set would go stale. A
non-string or empty value is a config validation failure — exit **2**.
Both keys are optional and independent of each other and of `allowed_tools`. Absent on
a given work type -> the corresponding flag is omitted / the profile's model is used
unchanged, matching the tool's behaviour before these keys existed.
### Per-provider effort and model overrides (within a work type)
`model`/`effort` can be pinned even more narrowly than per-work-type: per
`(work_type, provider)` pair. Each entry in a `work_types[*].providers` list may be
**either**:
- a plain provider-name string (the original, unchanged form) — no per-provider
override, resolution falls through to the work-type-level `model`/`effort` (above);
or
- an object `{"provider": "<name>", "model": "<optional>", "effort": "<optional>"}` —
`model`/`effort` here apply only when *this* provider is the one actually selected
(§9) for a dispatch of this work type.
Both forms may be mixed freely within the same `providers` list; list order still
encodes provider preference exactly as before (§9) — the object form does not change
*which* provider is tried first, only what argv that provider gets if chosen.
`config.example.json` demonstrates this: `research`'s `providers` is
`["anthropic", {"provider": "minimax", "effort": "medium"}]` — `anthropic` stays a
plain string (falls through to `research`'s work-type-level `effort`), `minimax` pins
its own `effort` via the object form.
**Full precedence chain**, evaluated once a `(work_type, provider)` pair has been
selected (§9), for each of `model` and `effort` independently:
1. The per-provider override on the matching `providers`-list entry for that provider
(object form only; a plain-string entry has none).
2. The work-type-level `model`/`effort` (the keys directly under
`work_types[work_type]`, previous section).
3. Ultimate fallback: for `model`, the profile's `provider.env` `MODEL_ID` (or no
`--model` flag if unset); for `effort`, no `--effort` flag (the CLI's own default).
**Validation** (config-load time, exit **2** on any failure, same style as every other
config check):
- Every `providers`-list entry — string **or** object form — must name a provider that
exists as a key in the top-level `providers` map. An entry (of either form) naming an
unrecognised provider is rejected; this closes a gap that predates the object form —
a typo'd plain-string provider name was previously silently ineligible (never
selected, no error) rather than rejected at load time.
- An object-form entry must have a `"provider"` key whose value is a non-empty string.
- An object-form entry's keys are restricted to `provider`, `model`, `effort`; any other
key is rejected.
- An object-form entry's `model`, if present, must be a non-empty string (same rule as
the work-type-level `model`).
- An object-form entry's `effort`, if present, must be a non-empty string and a member
of `CLAUDE_EFFORT_LEVELS` (same rule as the work-type-level `effort`).
- A `providers`-list entry that is neither a string nor an object is rejected.
`--dryrun` prints the fully resolved argv (§11 step 4's `--model`/`--effort` included,
after the full precedence chain above), so a preview reveals exactly which provider,
model, and effort level a given work type would route to before anything is dispatched
for real.
## Failure classes
| Class | Examples | `attempts` effect | Item outcome |
@@ -438,6 +526,16 @@ a lighter-touch check); `draft` and `review` carry no `allowed_tools` key.
| `--once` with no eligible work | Exits 0 immediately, no task dispatched |
| Config missing a required key | Exit 2 |
| `work_types[*].allowed_tools` present but not a list of strings | Exit 2 |
| `work_types[*].effort` present but not one of `low`/`medium`/`high`/`xhigh`/`max` | Exit 2 |
| `work_types[*].effort` present but empty string / not a string | Exit 2 |
| `work_types[*].model` present but empty string / not a string | Exit 2 |
| `work_types[*].model` set alongside a profile whose `provider.env` also sets `MODEL_ID` | Work-type `model` wins (absent a per-provider override); argv carries exactly one `--model` flag |
| `work_types[*].providers[*]` (string or object form) names a provider not present in the top-level `providers` map | Exit 2 |
| `work_types[*].providers[*]` object form missing/empty `"provider"` key | Exit 2 |
| `work_types[*].providers[*]` object form has an unknown key (anything besides `provider`/`model`/`effort`) | Exit 2 |
| `work_types[*].providers[*]` object form `model`/`effort` invalid (same rules as the work-type-level keys) | Exit 2 |
| `work_types[*].providers[*]` entry is neither a string nor an object | Exit 2 |
| A provider's `providers`-list entry sets its own `model`/`effort`, and the work type also sets a work-type-level `model`/`effort` | Per-provider entry wins for that provider; a sibling provider in the same list with a plain-string entry still falls through to the work-type-level value |
| `agent-subscriptions` subprocess fails entirely | Exit 1, loud log line, no dispatch attempted |
## Examples