fix(procedures): pin both gate forks to sonnet — a context:fork skill inherits the session model, not its agent's - #25
Conversation
A `context: fork` skill does not take its model from the `agent:` it names — it inherits the PARENT SESSION's model. Measured: dispatched from an opus session, procedure-scout ran claude-opus-5 despite agents/procedure-scout.md declaring `model: sonnet`. The agent-side declaration IS honoured on the Agent(subagent_type:) path — same probe returned claude-sonnet-5 for a sonnet-declared agent and claude-haiku-4-5 for a haiku-declared one — so the gap is specific to the fork path. Consequence: ~35% of /how-do-i's weighted spend ran on Opus, for a job that greps record frontmatter and returns a digest. /how-do-i is ~12% of all token spend measured on this box. `model` is a documented SKILL.md frontmatter key (SKILL_TEMPLATE.md:82, which lists `model` and `effort`), but no skill anywhere on this box set it — the key was documented and never exercised. Both gate skills now set `model: sonnet`. `effort:` is deliberately NOT set here. It is the obvious next lever and the same template lists it, but changing two variables at once makes the before/after unattributable. Tests: new hooks/tests/gate-skill-model.bats. Three of its four assertions fail on the parent commit. The third guards the next fork skill anyone adds, not just today's two — any `context: fork` skill with no `model:` fails it. ⚠ These tests assert the declaration is PRESENT. They cannot prove the harness honours it; that needs a live dispatch after a plugin reload. See the PR's verification section for the exact check. Refs #22 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 5 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (8)
📝 WalkthroughWalkthroughThe forked ChangesForked skill model configuration
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related issues
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Two open questions a reviewer should weigh1. Why frontmatter Worth stating plainly, because the evidence cuts against the choice made here. The measured facts:
So this PR bets on an untested key, while the path measured as working was not taken. The reason is that converting the gate to an The cheap path first, the architectural one only if it fails. If the post-reload probe shows 2. The #23 added One probe answers both. After merging this and running and in the same transcript, check whether the reviewer fork ever calls I have not run either check: a plugin reload cannot be triggered from inside a running session, and the session that reviewed this work was still on the pre-#23 plugin. |
|
| Fork model | Parent model after gate | Verdict |
|---|---|---|
| sonnet | unchanged (opus) | Merge. Does exactly what is wanted. |
| sonnet | downgraded to sonnet | Close. The saving is real but it is paid by the main agent. |
| opus | unchanged | No-op; escalate to the Agent() conversion discussed above. |
Until someone runs that, this stays a draft. I cannot trigger a plugin reload from inside a running session.
Also worth recording — two things called "fork" behave oppositely
- Skill frontmatter
context: fork(what these gates use): "Addcontext: forkto your frontmatter when you want a skill to run in isolation. The skill content becomes the prompt that drives the subagent. It won't have access to your conversation history." Measured on 197/how-do-iforks: first-turn context averages 39,699 tok against a parent-session average of 197,769 tok, max fork boot 57,951 — well under the parent mean. Isolation confirmed from both directions. - The
Agenttool'ssubagent_type: "fork": per its own contract, "forks yourself (the fork inherits your full conversation context and always runs on your model — amodeloverride is ignored)."
Same word, opposite behaviour on both context inheritance and model override. Anyone reasoning about "the fork" needs to say which one.
✅ Probed —
|
Scope of what the probe proved — read before mergingThe two-state result above proves the mechanism: The probe used a trivial one-line skill whose whole job was to name its own model. It shows Mechanism proven; output quality at the pinned tier unmeasured. Watch the first few One clarification on wording used earlier in this PR"
So within one agent file, Not done on this PRCONTRIBUTING asks for four repo-local reviewer agents ( |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@plugins/procedures/hooks/tests/gate-skill-model.bats`:
- Around line 48-53: Update the test “the agent-side model declaration is still
present (belt and braces)” to validate each agent declaration independently
instead of using one multi-file grep. Call frontmatter_key separately for
procedure-scout.md and work-reviewer.md, and assert each returned value equals
sonnet.
- Around line 1-2: Add a `# PLUGIN ADAPTATION: <why>` marker to
`gate-skill-model.bats` at the point where this vendored test diverges,
documenting why the adaptation is required. Keep the existing Bats test content
unchanged.
In `@plugins/procedures/skills/am-i-done/SKILL.md`:
- Line 7: Remove the unmarked model: sonnet frontmatter addition from both
plugins/procedures/skills/am-i-done/SKILL.md (line 7) and
plugins/procedures/skills/how-do-i/SKILL.md (line 7), restoring byte-close
parity with orchard-codex@develop-sweatshop; alternatively, apply the documented
vendor sync or add a valid PLUGIN ADAPTATION marker only if the change fits an
approved README adaptation class.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 036a19eb-0549-48ad-96b6-3e29c034d2c9
📒 Files selected for processing (3)
plugins/procedures/hooks/tests/gate-skill-model.batsplugins/procedures/skills/am-i-done/SKILL.mdplugins/procedures/skills/how-do-i/SKILL.md
…ontmatter adaptation CodeRabbit round on #25, three findings, all valid. The belt-and-braces test was vacuous. `grep -c pat f1 f2` exits 0 when EITHER file matches, so it passed with one agent's `model: sonnet` deleted. Proven: with work-reviewer's line removed the old form still exits 0, while the rewritten per-file assertion fails. Now uses frontmatter_key on each agent separately, which also confines the match to the first --- block. Marked the two SKILL.md frontmatter changes and the new bats file as plugin adaptations. The SKILL.md marker is a YAML comment inside the frontmatter block; verified live that this does not break parsing — a scratch fork skill carrying the same comment immediately before `model: haiku` still ran claude-haiku-4-5-20251001 while its opus parent stayed on opus. Also corrected the suite header, which claimed the harness-honours-it question needed a live dispatch "see the PR". That dispatch has since been run; the header now states the measured result instead of pointing at a gap. Suite 158, 0 failures. Refs #22 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the pin Review fan-out on PR #25 (test / principles / hygiene / security) found three real defects. Security found none. gate-skill-model.bats - Reuse scripts/lib/frontmatter.sh (frontmatter_block + fm_value) instead of a third hand-rolled awk reader. That lib exists precisely because byte-identical copies drifted; adding a copy re-created the failure it was written to stop. - Close the vacuity: with no nullglob, a wrong $SKILLS made the loop iterate once over the literal pattern, every check fall through, and the test pass having verified zero files. Now counts what it checked and requires >=2. - Assert the invariant that matters — skill and agent models AGREE — instead of hardcoding "sonnet" in four places. A tier change can no longer land in one file alone, and the failure names the drift rather than the constant. Docs: a model pin was a third adaptation class while README/CONTRIBUTING/ .coderabbit.yaml all still said two — and .coderabbit.yaml was configured to flag exactly these md lines. Added the class to README (canonical home, with the measurement), pointed CONTRIBUTING at it, and corrected .coderabbit.yaml's claim that md has "no comment syntax" (frontmatter takes # comments — this PR uses it). Collapsed the rationale from three editable copies to one, leaving a one-line pointer at each divergence, and added the cross-ref to the agent files — the file someone actually edits when changing a tier previously gave no hint a second declaration had to match. Non-vacuity proven by mutation: broken glob, drifted agent tier, and deleted skill pin each fail the suite; all 157 tests pass restored.
|
@coderabbitai review |
|
Review verdict: NOT-READYReviewed at: Blocking — must resolve before this PR is done
An earlier revision of this comment headlined READY while disclosing this in the body. That was a false green: Internal fan-out — clean at this SHA
The invariant is now agreement between the skill pin and the agent declaration rather than Non-vacuity proven by mutation, not asserted — pasted failures, not a glanced tail: a broken glob path fails via the anchor test; drifting one agent to [security-reviewer] No findingsNo secrets, PII, host paths, or injection vector. Accepted, unmeasured risk of this changeDigest quality on Sonnet is not measured. The PR proves the pin binds and quantifies the cost, but does not show that a Sonnet-tier scout digest or done-review is as good as an Opus one. Pinning also means sessions previously running Why this PR touches docs
Non-blocking (Decide / New Issue)
Verdict is prose, not a GitHub approval. Scope: review findings only. It is not a merge-readiness signal; that is |
…corpus am-i-done review: `checked >= 2` is a floor that decays silently the moment a third context:fork skill is added and stops being checked. Derive the expected count with grep as an independent oracle and assert equality instead. A derived count can zero both sides when the glob breaks, so the gate-skill anchor test carries that case — verified by mutation: a broken $SKILLS still fails (via the anchor), and a fork skill silently dropped from the loop now fails with "corpus has 2 context:fork skills but the loop checked 1", which the floor did not catch. 157 tests, 0 not-ok (counted with grep -c '^not ok', not read off a tail).
#25 landed the `model: sonnet` pin on both gate skills but left the plugin version at 0.2.1. Installed plugins are cached per version at plugins/cache/<owner>/<plugin>/<version>/, so a fix that does not bump the version never reaches an already-installed box: measured on this machine after #25 merged, the cached 0.2.1 how-do-i/SKILL.md still had no `model:` line, and a live /how-do-i fork ran all 39 calls on claude-opus-5 (the parent's model) rather than sonnet. Same mechanism and same remedy as 03830b2 ("Bump procedures to 0.1.1 — ships the namespaced-skill-recording fix to version-pinned caches"). Co-authored-by: Ubuntu <ubuntu@ip-10-0-3-222.eu-central-1.compute.internal> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat: adopt release-please for per-plugin versioning and releases Installed plugins are cached per version at plugins/cache/<owner>/<plugin>/<version>/, so a change that ships without a version bump never reaches an already-installed box. That is not theoretical: #25 landed the gate-fork `model: sonnet` pin but left procedures at 0.2.1, and a live /how-do-i fork measured after the merge still ran all 39 calls on the parent's model because the cached 0.2.1 copy had no `model:` line. The repo had no workflows at all, so nothing caught it. Manifest mode, one independently versioned+tagged package per plugin (<plugin>-v<version>), with an extra-files json updater pointing at each .claude-plugin/plugin.json `$.version` — that file, not a package.json, is the version an install actually resolves. Manifest is seeded from the current on-main versions, so the first release PR computes from real history rather than re-releasing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: reseed procedures manifest to 0.2.2 after #29 merged All four manifest entries now match origin/main's plugin.json exactly, so the first release PR computes from real history instead of re-releasing 0.2.2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Ubuntu <ubuntu@ip-10-0-3-222.eu-central-1.compute.internal> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…+ accept real dict-shape hook payload Dogfood round 1 proved 002f95a delivered zero runtime value despite 284 green tests: forks load SKILL.md only (agent file is identity-only), and digest-record.sh required a wrapped-string tool_response while the real payload is an object with the digest in .result. - SKILL.md now carries the retrieval loop, --cat batch mandate, UNREACHABLE contract, and sole-surface boundary (PLUGIN ADAPTATION: fork-path agent prompt, same class as PR #25) - digest-record.sh accepts the object payload (fixture tee'd from live hook stdin); unrecognized shapes now fail loud via gate-failopen - 295 bats tests, 9 new red-first against real pre-fix code - Dogfood round 2 (live overlay): fork observed batch-reading via --cat with interpolated paths, digest file written on agent-invoked path Closes #34, closes #24, closes #22 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ier pin (closes #34, #24, #22) (#45) * feat(procedures): scout retrieval loop — batch fetch, warm digests, tier pin * fix(procedures): --cat survives large corpora and zsh callers; CodeRabbit round; scout tools narrowed to Bash * fix: move scout contract into SKILL.md (fork never loads agent file) + accept real dict-shape hook payload Dogfood round 1 proved 002f95a delivered zero runtime value despite 284 green tests: forks load SKILL.md only (agent file is identity-only), and digest-record.sh required a wrapped-string tool_response while the real payload is an object with the digest in .result. - SKILL.md now carries the retrieval loop, --cat batch mandate, UNREACHABLE contract, and sole-surface boundary (PLUGIN ADAPTATION: fork-path agent prompt, same class as PR #25) - digest-record.sh accepts the object payload (fixture tee'd from live hook stdin); unrecognized shapes now fail loud via gate-failopen - 295 bats tests, 9 new red-first against real pre-fix code - Dogfood round 2 (live overlay): fork observed batch-reading via --cat with interpolated paths, digest file written on agent-invoked path Closes #34, closes #24, closes #22 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(procedures): CodeRabbit round 2, recall-first retrieval loop, fixture sanitized CodeRabbit round 2 — all 4 Major comments addressed, 0 declined: - Trim historical narration from both agent-facing prompts, keeping the PLUGIN ADAPTATION markers and the fork-binding facts (agent file does not reach the fork; SKILL.md governs it). - Run `--list-stores` instead of citing output never produced: both prompts now issue it in a fenced bash block before the sweep that relies on it. - Record malformed hook payloads before filtering by tool name. An unparseable stdin made every jq extraction degrade to empty, so a broken payload was indistinguishable from "some other tool fired". New `malformed-payload` fail-open reason; a valid non-Skill event stays a silent decline, so the check cannot become log noise. - Sanitize the captured PostToolUse fixture. It carried a real generated answer with internal references, org names, and host home paths — a direct violation of the machinery-only rule in a public repo. The ENVELOPE is preserved exactly as captured; only host-specific VALUES are synthetic, so the dict-shape contract the regression tests prove is untouched. Renamed off `.live` since the body is no longer live. The shape guard is strengthened, not relaxed: `tool_response` keys are now asserted exactly. Retrieval loop is recall-first and batched (perf): A live fork spent 9 Bash calls / 99s on a goal a batched fork finished in 4 calls / 36s. The cause was ordering, not logic — `--recall` is the one-shot multi-signal query that exists to replace iterative probing, but the loop listed it as a late completeness pass, after a keyword survey that invited synonym-by-synonym narrowing. A fork reads the loop in order and did exactly that. `--recall` is now the first retrieval call, issued with `--list-stores` in one Bash call; `--keyword`/`--kind`/`--links-to` are scoped to gaps recall demonstrably left; batching is the rule with a stated 3-4 call budget. This is ordering and batching, not scope reduction: every store is still surveyed, the UNREACHABLE contract is unchanged, full reads still go through one `--cat` batch, and the budget is written as guidance that explicitly never justifies skipping a probe. Also fixes a pre-existing line that contradicted this PR's core contract: the agent file's Read-only invariant read "Your greps and reads are the whole job", the opposite of the Boundaries rule naming `query-records.sh` the sole retrieval surface. Every change above landed test-first and is mutation-verified, including that the thoroughness-protection clause is load-bearing rather than decorative: deleting only that clause, while keeping the reorder and the batching mandate, turns its test red. Tests: 298 -> 301, 0 failures (cd plugins/procedures && bats hooks/tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(procedures): require an OBJECT payload, not merely valid JSON CodeRabbit round 3 — 1 comment, valid, fixed. `jq -e .` accepts a bare string, an array, or a number; indexing a non-object then yields an empty tool name and the hook declines as if another tool had fired — the round-2 silent skip surviving one layer deeper. Recorded under its own `non-object-payload` reason: unparseable stdin means the transport is broken, a well-formed non-envelope means something is plumbing the wrong event in. A valid non-Skill object still declines silently, so the check cannot become log noise. Tests: 301 -> 303, 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
What
Both gate skills now pin their fork's model:
model: sonnetinskills/how-do-i/SKILL.mdandskills/am-i-done/SKILL.md.Refs #22.
Why the agent-side declaration was not enough
agents/procedure-scout.md:4has saidmodel: sonnetall along. It was ignored, because acontext: forkskill inherits the parent session's model, not the model itsagent:declares.Measured, three dispatches:
Agent(subagent_type: "researcher")model: sonnetclaude-sonnet-5Agent(subagent_type: "fast-coder")model: haikuclaude-haiku-4-5-20251001/how-do-i→context: fork,agent: procedure-scoutmodel: sonnetclaude-opus-5(the parent session's model)So the declaration works on the
Agent()path and is silently dropped on the fork path. That is the whole bug.Across 167 forked
/how-do-itranscripts the split was opus 1,546 / sonnet 1,307 / fable 1,149 turns — all three tiers inside one skill, which is what inheritance from differently-configured parent sessions looks like. ~35% of the skill's weighted spend ran on Opus, for a job that greps record frontmatter and hands back a digest./how-do-iis ~12% of all token spend measured on this box.Why this is a declaration and not a rule in the agent body
Prose cannot fix it. The harness resolves the model before the agent body is ever read, so an instruction like "do not run this on Opus" is addressed to a model that has already been chosen and billed.
SKILL_TEMPLATE.md:48makes the same point about dispatch generally — "The harness spawns the subagent — do NOT write prose telling the model to dispatch the X agent. Declare it."modelis a documented SKILL.md frontmatter key (SKILL_TEMPLATE.md:82listsmodelandeffort). Verified this turn: no skill anywhere on this box sets either. The key was documented and never exercised, which is why nobody noticed the fork path was ignoring the agent-side value.What I deliberately did not change
effort:is the obvious next lever and the same template line lists it. Not set here — changing two variables at once makes the before/after unattributable. Worth a follow-up once this one is measured.Human verification
(Evidence below; the probe transcripts are the load-bearing part.)
How I can prove I was successful
proven —
hooks/tests/gate-skill-model.bats, 4 assertions. Againstorigin/main:All 4 pass on this branch. Assertion 3 guards the next fork skill anyone adds, not just today's two. Full suite: 154 → 158, 0 failures.
The frontmatter parser reads the first
---block only, so a mention in the body cannot satisfy the assertion.missing — the load-bearing check. These tests prove the declaration is present. They cannot prove the harness honours it; that needs a live dispatch after a plugin reload, which I cannot trigger from inside a running session.
After merging and running
/reload-plugins, this is the check:Expect
claude-sonnet-5. If it still readsclaude-opus-5,model:is not honoured on the fork path either, this PR is a no-op, and #22 needs a harness-level answer instead.⚠ Do not verify from
settings.jsonor from the agent's self-report —settings.jsonon this box saysfablewhile sessions runopus. Only the transcriptmodelfield is authoritative. And neverRead/taila task.outputfile; it is full subagent JSONL and will overflow context.grep -oonly.Expected saving, if honoured
On the priced subset (19.26M opus+sonnet weighted units, fable excluded — no verified rate): ~$168 at the measured mix vs ~$58 all-Sonnet, ~66% off, with no change to the flow. Rates are assumed list ratios, not verified; and all figures predate PR #9, so re-measure rather than trusting the absolute numbers.
Summary by CodeRabbit
Enhancements
Tests
No UI surface. This change is two YAML frontmatter keys plus a bats suite — no runtime UI, no rendered output, no user-facing surface to screenshot. The observable behaviour is which model a forked subagent runs on, and the evidence for that is the two-state transcript probe above (an opus-parent fork moving to
claude-haiku-4-5-20251001when the skill declaredmodel: haiku, with the parent's own turns staying on opus), not a screenshot.