Skip to content

docs(fix-issue): tier the workers, fit the skill in context, and enforce both in CI - #1776

Merged
pinin4fjords merged 30 commits into
mainfrom
docs/fix-issue-skill-economy
Aug 21, 2026
Merged

docs(fix-issue): tier the workers, fit the skill in context, and enforce both in CI#1776
pinin4fjords merged 30 commits into
mainfrom
docs/fix-issue-skill-economy

Conversation

@pinin4fjords

@pinin4fjords pinin4fjords commented Aug 21, 2026

Copy link
Copy Markdown
Member

What this is for

The fix-issue skill runs an nf-metro issue end to end by delegating to 8-15 subagents. Four things were wrong with how it did that, and this PR fixes them.

It spent more than it needed to. Nothing said which model each delegated worker should use, so every one of them ran on the session's model. A worker whose whole job is running ruff check and reporting the exit code was costing the same as one doing layout diagnosis.

It was too big to survive a long session. After an auto-compaction, Claude Code re-attaches only the first 5,000 tokens of a skill. SKILL.md was ~11,300, so well over half of it was silently dropped mid-run, taking the later steps and the cost rules with it.

Some of its instructions did not work when run. The verifier's "the tree must be clean" check passed on a dirty tree. The visual-review step could review renders from the wrong commit, or report a clean run as a failure. These were readable and plausible and simply did not do what they said.

Nothing checked any of it. A future edit could break the tier table, a reference link or a shell command with all of CI staying green.

What changed

  • Worker tiers are explicit. SKILL.md gains a role-to-tier table: LIGHT for mechanical work, MID for bounded reasoning, HIGH for judgment where being wrong wastes the run. Forks are forbidden, because a fork ignores the model parameter and inherits the parent's whole context.

  • Ten agent definitions in .claude/agents/ (a third of this diff). Each is a small file declaring one role's model, tool allowlist and effort, so Claude Code resolves them instead of the skill asking a coordinator to remember. For example, the verifier:

    name: fix-issue-verifier
    model: haiku
    tools: Bash, Read
    effort: low

    Two consequences worth reviewing. The tier stops being advice: it is a property of the role, and check_skill.py fails if a definition and the table disagree. And read-only becomes partly structural, since those roles hold no Edit or Write at all. They do hold Bash, so it is a backstop rather than a boundary, and the skill says so.

  • SKILL.md is a spine, not a manual. 4,470 tokens with the procedure and tier contract first, so a truncation loses rationale rather than instructions. Detail moved to references/, split by owner: the coordinator reads four files, everything else is named in a worker's brief and read in that worker's context, which is discarded afterwards.

  • The fragile shell chains are real scripts. visual_preview.sh, render_pairs.sh and verify_candidate.sh replace multi-block command sequences that could not be verified as prose. Each guards its own failures and carries --self-test. corpus_map.py resolves gallery output names from gallery.yaml instead of guessing at them.

  • Diagnosis covers more than geometry. Structural defects with no rendered symptom get their own classification and evidence form, and "not a bug" is a first-class outcome held to the same evidential standard.

  • Cost rules point at what measurement says is expensive: keeping bulk command output out of every context, handing the writer off before its context makes each turn expensive, and not parking a large context across a CI wait.

  • nf-metro-layout-fix gets a boundary. It advertised the same invariant-test-first and gallery-vetting workflow, neither skill mentioned the other, and roughly half of "fix the kink in feat(gallery): list seqinspector showcase render on the nf-core pipelines page #1234" would have landed on it instead. Both descriptions now state the split: issue-driven work goes to fix-issue, a bad render with no issue behind it goes to layout-fix. Its stale src/nf_metro/routing/ path is corrected while there.

  • CLAUDE.md sheds the Astro build detail that every subagent was loading and that lives in the docs and the serve-docs skill.

What now enforces it

A skill-selfcheck CI job runs check_skill.py, which validates link resolution, agent-definition and tier-table agreement, reference ownership, shell parsing in both bash and zsh, shellcheck, every script's self-test, repo paths named in commands and prose, and the token budget. The budget is measured with Anthropic's count_tokens when a credential is available, since only that knows Claude's tokenizer; otherwise it falls back to tiktoken scaled by the documented 15-20% undercount and gates on a 4,750 working ceiling. The skill-checks extra declares both, so the check cannot silently skip.

What savings to expect

All figures are Claude tokens, and all dollar figures are list prices projected from measuring 45-90 historical runs of this workflow on one machine. Nothing below is observed on the revised skill, because it has not been run end to end yet. On a subscription none of these dollars are billed as cash; treat them as relative weight, not an invoice.

Static, per spawn or per session:

before after
SKILL.md 11,276 4,286
coordinator-resident (skill + its 4 files) 11,276 11,071
project CLAUDE.md, loaded by every subagent 3,241 2,695
visual-review.md, read per run with deltas 4,959 2,664
environment.md, read by any command-running worker 2,308 1,638
ten agent definitions, always-on in this repo 0 +2,484

The SKILL.md figure is a correctness fix, not a saving: at 11,276 tokens against a 5,000-token post-compaction re-attachment cap, more than half the file was being silently dropped mid-run. Coordinator-resident is flat. The always-on footprint is close to a wash once the agent definitions are counted. The genuine static win is about 3,000 tokens off each worker spawn that reads those two references, plus 546 off every spawn from the CLAUDE.md trim: call it 8,000-15,000 tokens per session depending on spawn mix, worth $1-3 once re-reads are counted.

Behavioural, which is where the money is:

lever per session
naming a tier instead of inheriting the session model ~$18 (3-6% of spend)
handing the writer off near 200 turns ~$28 on an affected run, and 1 run in 3 is affected
not parking a large context across a CI wait ~$10 of the ~$28 those re-encodes cost
never fork lumpy: $1,322 across the corpus, concentrated in 2 sessions
keeping bulk command output out of context unquantified, and the largest: tool output is 74.5% of all resident-context cost

Best guess for a typical session: roughly $40-60 saved on a mean run of about $300, so 15-20%, with the median run being cheaper and saving proportionally less. In tokens that is on the order of 80-120M cache-read tokens against a measured median of ~230M per session, since cache reads are 68% of spend.

Two honest caveats. The bulk-output rule is excluded from that range because its delta is unmeasured, and it plausibly exceeds all the others combined. And run cost varies enormously: median $123, mean $300, p90 $732, so a percentage is more meaningful than a dollar figure.

What has actually been exercised

Executed, not read:

Not exercised, and worth knowing before merge:

  • No end-to-end run. Nothing has driven a real issue from gh issue view to a ready PR through this skill. The maintainer intends to exercise this in real usage rather than as a pre-merge test.
  • The LIGHT tier is unvalidated. No spawn in this repo's history ran on that model, so nothing shows those four roles can do their jobs there. The skill says this and asks that early LIGHT spawns be treated as an experiment.
  • The model fallback when model is omitted rests on documentation. Tested and confirmed in a fresh session on this branch: the ten types resolve, and spawning one with no model parameter runs the definition's model rather than the session's. The skill no longer asks for both, only for the role name, with the model still required when spawning a generic type.
  • The delta path of the visual gate. No PR with visual changes was available; that branch is verified against the workflow source only.
  • Steps 10 to 12 against a live PR, and per-subagent hooks as a structural read-only guard, which is documented as a known gap rather than implemented.

Reviewing this

CI covers it: skill-selfcheck runs the validator, and lint, format and
render-diff are green. No application code is touched, so there is no render or
layout impact and the render-diff reports no visual changes.

The two things most worth a human eye are the ten agent definitions in
.claude/agents/ (are those the right tiers and tool sets for those jobs?) and
the honesty of the unexercised list above.

🤖 Generated with Claude Code

Restore explicit per-worker capability selection, lost when the skill was
rewritten provider-agnostically. Express it as a LIGHT/MID/HIGH tier contract
with a harness mapping table (Claude Code haiku/sonnet/opus, Codex
luna/terra/sol) so the rule binds regardless of which harness runs the skill,
and pin each worker role to a fixed tier.

Separate the two economy levers the skill previously conflated: delegate to
keep bulk output out of the coordinator, choose the tier to control cost.
The coordinator may now run trivial deterministic assertions itself instead
of spawning for a hash comparison.

Consolidate the review gates from six mandatory reviewer spawns to two: a
post-diagnosis gate that also challenges the domain classification, and a
pre-ready gate that combines the code review with the final aggregate review.
Extra aggregate reviews are trigger-only and receive evidence links, not
inlined evidence.

Make CI the default owner of the full test suite, with local runs targeted.

Move conditionally-relevant procedure out of the always-loaded SKILL.md into
references/: autonomous mode, environment and hooks, generated-artifact
gates, regression locks and the xfail rule, and push/merge/cleanup.
The autonomous-mode reference warned that a long unattended run is where an
unset tier silently bills the top model, then left the two assignments below
that warning untiered. Name them: MID for the verifier, HIGH for the visual
reviewer.
The PR API lags a push by seconds and reports the previous SHA, so the origin
check fired a false "lost commit" alarm during this branch's own second push.
Query the ref directly and re-query once before treating a mismatch as real.
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Render preview: no visual changes detected. All renders match main.

A dry-run plan eval against issue #1765 (a theme-threading defect with no
rendered symptom) surfaced that the diagnostic protocol assumed every issue is
a geometry bug. Step 3 demanded "render the example, inspect coordinates,
restate as a numeric claim", the classification offered only "authoring
mistake" or "engine bug", and the post-diagnosis gate was scripted to challenge
a numeric claim that cannot exist for such an issue.

- Step 3 now carries two claim forms, geometry and non-geometry, and adds a
  third classification bucket for input-independent structural defects.
- An issue that already names its own root cause gets a MID confirm-or-refute
  brief instead of a HIGH re-derivation. Independent confirmation stays
  mandatory.
- Step 5 says explicitly when to skip: no layout property to guard, no
  validator.
- The gate-coverage ratchet trigger is now literal. It scans
  src/nf_metro/layout/routing/ only, so a change to engine.py cannot trip it
  and needs no defensive check. The guard-trace golden is a separate trigger.
- The writer tier rule resolves a diff spanning both HIGH and MID directories.
- Visual review is sized to the verdict: a LIGHT worker may report a literal
  "no visual changes", but any delta at all gets HIGH eyes on every changed
  render.
An audit for duplicated expensive work found the draft-PR push specified in
three separate steps. A push runs the full test matrix and renders the whole
gallery on both the PR branch and the base, so it is the costliest action in
the workflow, and a coordinator reading linearly could pay for it three times.

- Step 8 now owns the single push and draft-PR creation. Step 10 verifies it
  landed and edits the body instead of re-creating. Step 11 only flips the
  draft to ready.
- Accepted fixes batch into one push per round rather than one push per fix.
- The local before/after sweep and the CI render-diff compute the same corpus
  diff; pick one, never both for the same SHA.
- Hooks run on the changed files, not `--all-files`, which sweeps the repo with
  a cold mypy over all of src/.
- The verifier's mypy cache moves outside the per-SHA artifact directory, since
  a fresh cache per candidate made every verification run cold.
- Step 8's probe_layout/inspect_layout reading is labelled as the candidate-SHA
  after-state, so it does not read as a repeat of Step 3's before-state.
Cut the per-spawn coordinator output that persists in the transcript and is
re-read on every later turn.

- Add references/worker-contract.md holding everything invariant across
  spawns: the one-writer rule, read-only discipline, the six-item return
  schema, evidence-by-path, and what a worker does when the work exceeds its
  briefed tier. Briefs now carry only the task-specific fields plus a pointer,
  instead of restating the contract each time.
- Evidence arrives as a path plus the one figure carrying the verdict. Pasted
  coordinate dumps, render analysis, and test logs were landing in coordinator
  context permanently, which defeats the delegation the skill is built on.
- The ledger keeps only its live slice in context (current gate, open blockers,
  accepted SHA, active assignments) and appends settled rows to a file. It was
  the one item growing every turn.
- State that the coordinator does not read src/ at all. It does no substantive
  diagnosis or implementation, so the large layout modules belong only in
  worker contexts.
- Halve the frontmatter description, which loads every session for every skill
  whether or not this one is used. All trigger phrases retained.
CLAUDE.md loads into every subagent, so a worker running a lint check received
the full Astro/Starlight build description: the Vite plugin, the cache
directory, the gallery generator's outputs, the gh-pages deployment. The
section was 3.1KB of a 12.9KB file.

Reduce it to the structural facts plus pointers. Nothing is lost: the flag list
is in `scripts/serve_docs.sh --help`, the `<Metro>` component options are
documented more fully in docs/contributing.mdx, the branch-preview workflow is
in the serve-docs skill, and the render pipeline is in
website/src/lib/render-metro.mjs.
…test

Running it by default stays the rule. Skipping requires all four conditions to
hold and must be declared: no new function, class, branch, or code path; no
shared helper, dispatch table, or multi-caller site touched; under roughly 20
lines of non-test production code; and no production code added by a later
step. A small diff that introduces a branch or threads an argument through
three call sites does not qualify, and weighing whether it qualifies means it
does not.
…e compaction cap

Two dry-plan evals (issues #1765 and #1563) and a review of Claude Code's skill
mechanics drove this.

Per-role agent types. `.claude/agents/` now defines one type per role with its
tier and tool set in frontmatter. Spawn by role name and pass the model
explicitly: the spawn-time model is verified to take precedence, and the
definition's model is a safety net only, since the fallback direction is not
verified here. Read-only roles carry no Edit/Write tool, making read-only
structural rather than advisory. The investigator and verifier may instead use
the built-in Explore type, the only one that loads neither CLAUDE.md, verified
by self-report as saving roughly 5k tokens per spawn.

Diagnosis can now conclude there is no defect. The classification offered only
authoring mistake, engine bug, or structural defect, all of which presuppose a
bug; issue #1563 exists specifically to be closed if the mirrored shape routes
acceptably. "Not a bug" is now a first-class outcome held to the same evidential
standard, and the post-diagnosis gate certifies whether a defect exists, not
only which kind it is.

Also: the single-push rule states its own exception, since the routing ratchets
skip off Python 3.11 and surface only in CI; the writer is one continuing worker
rather than a fresh spawn per step, so it keeps the large layout modules it
already read; the writer tier distinguishes geometry-affecting changes from
structural ones in the same directories; the stated-cause carve-out is bounded
to single-site claims; the brief template gains a DECIDE field for options a
worker must surface rather than pick; fixture parametrisation carves out
structural defects; a writer's own draft renders are not a spawn; and the
regression-lock grep warns about parent issue numbers.

Split SKILL.md from 42KB to 18KB (311 lines). After auto-compaction Claude Code
re-attaches at most 5,000 tokens per skill, so the previous body would have been
silently truncated mid-run. Procedure detail moves to references/; SKILL.md
keeps the worker contract, the tier tables, the gates, cost discipline, and a
twelve-step spine. Verified by sentence-level diff that no rule was dropped.
An independent review against the Claude Code docs found the prose had been
restructured carefully and the executable content had not been re-tested.

Cost and blast radius:
- fix-issue-writer had no tools allowlist, so the most expensive worker in the
  run inherited every MCP tool schema, plus Agent, letting it spawn untiered
  children and break the one-writer rule. Every definition now carries an
  explicit allowlist in the documented comma-separated form.
- Definitions gain `effort`. The writer persists across steps by design, so a
  top-tier model was regenerating goldens and typing commit messages; mechanical
  re-briefs now drop to `effort: low` without losing its context.
- The eco-merge assessor moves from MID to HIGH: it gates shipping code CI has
  not verified.

Gates that could not be executed as briefed:
- The visual reviewer had no way to get renders into context. Read takes
  filesystem paths, the preview embeds SVG, and WebFetch returns markdown.
  Added a curl-and-rasterise block, since Bash already covers it.
- The verifier's command block asserted against a frozen checkout that nothing
  created, and never cd'd anywhere, while the worker least licensed to improvise
  was the one told not to choose different commands.
- Local renders omitted --no-chrome-css, which CLAUDE.md already warns aborts
  cairosvg.
- The simplify worker was pointed at the unqualified skill, and both candidates
  apply fixes and need Agent.

Corrected mechanics claims:
- Plan skips CLAUDE.md too, Explore is one-shot and cannot be re-briefed, it
  runs its own system prompt rather than the role definition, and it inherits
  the session model. The invented tool list is gone.
- CLAUDE_CODE_SUBAGENT_MODEL outranks the per-invocation model, so the full
  four-step resolution order is now stated.

Truncation keeps the FIRST 5,000 tokens of a skill, and the twelve-step spine
was last. Reordered so procedure and the tier contract come first and only the
rationale is at risk, and added the instruction to re-invoke after compaction.
Two roles that had no agent type, both needing the Skill tool, now have one.
…budget

A second independent review checked the first round's fixes against the live
preview and the repo, and found three that were present but non-functional.

The visual gate could not see anything. The render-fetch block globbed for
`.svg` files on the preview site, but `scripts/build_render_diff.py` inlines
every render through `_inline_svg` and writes a single multi-megabyte
`index.html`; there are no fetchable SVGs, and the inlined markup carries
`var()` and `light-dark()` that cairosvg cannot parse. The block would have
produced an empty file list, a no-op loop, and a reviewer giving a confident
verdict having seen nothing. It now reads the changed stems from the page's
diff-entry anchors and re-renders those locally at both SHAs with
--no-chrome-css, and explicitly forbids reading the preview page itself.

The environment recipe built an env that could not import nf_metro: the
hand-written package list omitted `lark`, a hard runtime dependency, and
`coverage`, which the gate ratchet needs. It now installs from pyproject.

The verifier could not run its own block: no env activation, so ruff, mypy and
pytest were off PATH, and without `set -e` the frozen-SHA and clean-tree
assertions returned non-zero and execution continued, so the guarantees the
verifier exists to provide were not enforced. The SHA comparison also required
a full 40-character SHA and now compares rev-parse output on both sides.

Corrected claims: `effort` is fixed for the life of a definition, there is no
per-invocation effort, so the persistent writer cannot be dialled down for
mechanical re-briefs; the retained context is worth more than the thinking
tokens that effort moves. `permissionMode` is ignored when the parent runs in
auto mode, so the structural lever for read-only is the per-subagent `hooks`
field. Making the writer persistent requires `SendMessage` to its agent ID,
which was never stated.

Also: the eco-merge tier contradiction survived in merge-and-cleanup.md; the
simplifier held unrestricted Agent while being told not to write, pointed at a
skill that applies fixes, and never named the qualified `pinin4fjords:simplify`;
the post-diagnosis gate claimed to review aggregate progress at Step 3, before
anything is aggregated; the hook command activated the wrong env, never cd'd,
and word-split its file list.

CLAUDE.md's editable-install claim is corrected rather than left contradicting
the skill, and environment.md notes that render-topologies brings its own env.

Measured with tiktoken rather than estimated: SKILL.md is 4,882 tokens, with the
twelve-step spine at 1,217 and the tier contract at 3,373, so the procedure sits
well inside the 5,000-token re-attachment cap and only a pointer paragraph is
near it. All 17 shell blocks pass `bash -n`.
…en read

A third review executed the pipelines instead of reading them. Three defects
failed silently, which is worse than failing loudly.

The render loop consumed $ART from a previous Bash call. Shell state does not
persist between calls, a rule this skill states itself, so the worktree path
became /base, git worktree add failed, every stem fell through the resolution
guard, and the mandatory visual gate produced zero images while reporting
nothing. The block is now self-contained and aborts on an unset ART.

Stem resolution missed 7 of 38 real stems. The corpus spans examples/,
tests/fixtures/ and tests/fixtures/hash_seed_determinism/, and gallery ids can
carry a pipeline_ prefix the source file does not, so a glob rooted at examples/
skipped them under `|| continue` while the prose demanded no silent skips.
Resolution now runs against git ls-files across the whole tree with the prefix
stripped as a fallback, and names every unresolved stem. Executed against the
live preview for PR 1743: 38 stems extracted, 38 resolved, against 31 before.

Executing it also surfaced a defect no review found: some corpus fixtures abort
by design at head, and under `set -euo pipefail` one aborting fixture killed the
whole sweep. Verified with seed_41, which aborts on bundle curves at this SHA.
Renders are now guarded per stem, stderr is kept per stem, and a stem that
renders on base but fails on the candidate is called out as the regression it is
rather than skipped.

The Step 3 tooling could not import nf_metro at all: no PYTHONPATH on
probe_layout, inspect_layout, explain or info. Worse, with a non-editable
install they would have silently diagnosed the installed snapshot instead of the
worktree under test, so the numeric claim the whole run rests on would have been
about the wrong code.

Step 5 named only _guard_*; routing invariants are check_* registered in
CHECK_REGISTRY via a GuardSpec whose tier decides how much golden churn a change
causes across hundreds of committed traces. Both are now documented.

Measured, and correcting my own earlier figure: SKILL.md is 4,619 tokens with
381 headroom, and the twelve-step spine ends at 1,565 cumulative, not the 1,217
I reported. That number excluded the file head. Moved the model-resolution order
and the effort/read-only mechanics into agent-types.md to buy the margin.
All 17 shell blocks pass both bash -n and zsh -n.
…it the checks

An independent review of the validation methodology measured what I had not:
the restructure read 14,817 tokens on a normal run against the baseline's 9,397,
a 58% increase. I had shrunk the always-loaded file, reported that as the win,
and grown the total. The reference table even said seven of eleven references
load on a normal run; I never did the arithmetic on my own sentence.

The axis was wrong. References are now split by owner, not by step number. The
coordinator reads four files and is told explicitly not to read the others,
because it is forbidden from acting on them and reading them puts worker-facing
bytes in the context that is re-read every turn. procedure.md, which mixed both,
becomes coordinator.md and writer-steps.md. Coordinator-resident text is now
7,903 tokens, 16% below the baseline, with 10,091 tokens of worker-facing
material read in contexts that are discarded at handoff.

Closed the merge-assessor gap rather than documenting it again: the role no
longer carries `Skill`, so it cannot reach a procedure ending in
`gh pr merge --admin`. The eco-merge safelist is inlined, extended with the
repo-specific paths a ratchet or the render-diff reads. Capability removal
rather than a `permissionMode` that auto mode ignores, or a hooks schema I have
not verified.

Fixed the trigger collision. nf-metro-layout-fix advertises the same
invariant-test-first, runtime-validator and gallery-vetting loop, neither skill
mentioned the other, and roughly half of "fix the kink in #1234" would have
landed on it. Both descriptions now state the boundary: issue-driven work goes
to fix-issue, a bad render with no issue goes to layout-fix. Its stale
`src/nf_metro/routing/` path is corrected, and the tier table's path strings now
match the real nesting.

Removed the self-contradiction on tier resolution. SKILL.md claimed an unset
parameter lands on the session's top tier while agent-types.md gave the
documented order in which it lands on the definition. Both could not hold, and
which one does is the thing never tested; the rule now points at the order and
names the environment variable that overrides every tier here.

scripts/check_skill.py commits the verification instead of leaving it in a
transcript: link resolution, spine cross-references, tier naming, owner-split
accounting, agent-definition agreement with the tier table, bash and zsh syntax,
and the token budget. It found three defects on first run, including two
untiered assignments and an owner mismatch, all fixed here.
…s the SHA

A review that executed every block found that `set -euo pipefail` is inert in
this harness: the Bash tool runs zsh and evals the block, so ERREXIT never fires.
Verified directly - `set -euo pipefail; false; echo SURVIVED` prints SURVIVED,
exit 0. Both of the verifier's mandatory guards were therefore decorative, and
running the block against a wrong SHA on a dirty tree reached the end reporting
success. Every guard now fails itself through a `die` helper, the block ends with
an explicit `VERIFY OK` line, and an absent line is a failure whatever the exit
status looked like. Proven by execution: the SHA guard and the dirty-tree guard
both now abort.

The visual gate could review the wrong tree. Nothing checked that the preview was
built from the candidate SHA, and this skill's own `[skip ci]` default guarantees
staleness after any post-Step-8 push. On the branch used for testing the preview
was eight days older than the head, and in a quarter-sample of the ids missing
from its stem list, four rendered differently and five aborted on the candidate
but not the base - all invisible to the procedure. The sweep now proves
provenance from the workflow run's headSha before trusting the stem list, uses
`curl -fsS` so a 404 fails instead of silently saving an error page into an empty
stem list, and says to enumerate the corpus rather than fall back to a stale one.

All three gate-ratchet commands failed as written for want of PYTHONPATH in the
prescribed env, and the third emitted 27 subprocess errors that look exactly like
the gate failures the file forbids hand-editing away.

Also: `nf_metro info --json` was missing both PYTHONPATH and its required
argument; the gallery row lives in scripts/gallery.yaml, not GALLERY_ENTRIES,
which is derived from it, and the lock grep now looks there; the force-push guard
is a Claude Code PreToolUse matcher, not the git pre-push hook this file claimed;
the worktree needed --no-track, since otherwise the branch takes main as upstream
and a bare push suggests `git push origin HEAD:main`; and the LIGHT verifier is no
longer asked to assess a full render.

check_skill.py gains a check that no shell guard depends on `set -e`, which is
what let this class of defect through. It caught two more on first run.

One reviewer claim was fabricated and is not encoded: gallery.yaml has no
render_only.expected_aborts key, and no allow-list of aborting fixtures exists
anywhere in the repo. Aborts are judged by comparing both sides instead.
… money

A cost audit over 45 real runs of this workflow (25,942 main-thread turns,
62,715 subagent turns, 466 spawns, from the per-request usage blocks) found the
skill was optimising terms worth under 1% while ignoring the two that dominate.
Measured structure: cache reads are 68% of spend, the HIGH writer's turns are
45%, and the coordinator re-reading its accumulated context is 26%.

Two levers added, neither previously present:

- The writer hands off at roughly 150 turns. Cost grows as turns^1.28; spawns in
  the 150-300 band averaged $25 while the 46 that ran past 300 averaged $62.
  This directly qualifies the previous unconditional "one continuing worker"
  rule, which is right for a 100-turn writer and expensive for a 500-turn one.
  Worth about $35 a run, roughly 8x the tiering saving.
- Do not hold a large context idle across a CI run. 468 turns, 1.8% of all
  turns, were 9.8% of total spend: full re-encodes averaging 358k tokens at the
  1-hour cache premium, caused by idle gaps busting the cache. Worth about $15 a
  run.

Three of my own claims were contradicted by measurement and are corrected rather
than quietly dropped: coordinator `src/` reads are under 1% of a run, not "the
largest avoidable cost"; naming a tier is 5.7%, not "the single largest lever",
and much of the per-spawn gap between tiers is turns rather than price per token;
and the Explore substitution is worth about $0.24 a run, so it is a curiosity
that no longer spends resident tokens arguing for itself.

Push and `[skip ci]` policy moves to merge-and-cleanup.md, where the push lives:
it is governance, not economy, and it was filed under cost discipline. It also
now states its own cost, since a `[skip ci]` push invalidates the render preview
and forces a local corpus sweep.

Separately, ~/.claude/skills/session-economy/analyze.py priced Opus at $15/$75.
Opus 5 is $5/$25, verified against the claude-api skill's pricing table, so every
dollar figure that tool has produced for this project was inflated threefold.
Corrected, with Fable added and the Sonnet 5 introductory rate noted.
A mechanics review executed every block and found the Step 8 provenance check,
added two commits ago, wrong in the commonest case and in the dangerous one.

Verified against the workflows: when a PR has no visual changes,
build_render_diff.py returns early, pr-renders.yml sets has_changes=false, and
every deploy step in pr-render-publish.yml is gated on it being true. So the page
is never published and my curl died telling the reviewer to wait for something
that would never arrive - on a clean run, which is the common one. In the other
direction, pr-render-publish.yml deploys with keep_files: true and only cleans up
on PR close, so comparing the workflow's headSha passes while the page fetched
belongs to an earlier push. The gate now reads the sticky comment first and
treats "no visual changes detected" as the verdict it is, then proves the page is
this run's by matching its own nf-metro-render-run marker against the run id,
with both SHAs normalised through git rev-parse.

Two guards were still decorative. `test -z "$(git status --porcelain)"` passes
when git itself fails, because it inspects stdout only - verified in a non-repo
directory - so both clean-tree assertions now capture and test separately. And
two verifiers on one candidate SHA derived the same worktree path and collided,
which also blocked any re-run after a mid-block failure; the path is now
process-unique and the block prunes and checks before adding.

The origin check was prose telling the reader to compare two printed SHAs. It is
now a self-failing test, which is what the rest of the skill requires.

Fixed from the same review: agent-types.md, a coord-owned file, still claimed the
merge-assessor holds Skill after that grant was removed, and pointed hardening at
the one role already safe; the Step 4 lock grep still named build_gallery.py,
which contains zero issue references while gallery.yaml holds 110; the golden
gate covers every root _discover_fixtures walks, not just examples/topologies;
the largest files are the routing handlers and invariants, not the trio named;
and nobody owned materialising a reproducer that exists only inside a details
fold, which is where a real trace got stuck - the diagnostician now does it.

check_skill.py was demonstrated to pass eight distinct mutations. It now joins
shell continuations, matches bracket guards and undefined `die`, validates repo
paths in commands, checks agent definitions against the role table both ways,
catches orphaned references, and fails rather than skips when tiktoken is absent.
The role table names the agent type per role so that check is exact rather than
heuristic. Mutation-tested: 9 of 9 now caught, from 1 of 9.

SKILL.md holds a declared 10% margin under the re-attachment cap, enforced by the
checker, since o200k is only a proxy for Claude's tokenizer. Gate and
writer-discipline prose moved to coordinator.md: still resident for the
coordinator, but out of the capped file. 4,274 tokens, 726 headroom.

The measured cost figures are now attributed as a one-off measurement on one
machine rather than presented as reproducible, since they do not meet the
evidence bar this skill imposes on its own workers.
…8% term

Two round-two reviews, both executing rather than reading.

The Step 8 chain was broken by `| tail -1`. The sticky comment body is
multi-line and its last LINE is the sticky HTML marker, so both greps missed and
the block fell through as if deltas existed, then 404'd on a page that is never
published for a clean run. Reproduced on live PR 1776: the body says "no visual
changes detected", `tail -1` yields the marker, grep misses. Now selects the last
matching comment whole (`last | .body`), and the repaired chain returns the right
verdict when executed against that PR. Three companion defects in the same chain:
`$$` in the artifact path made it unreachable between Bash calls, two blocks
never created the directory they wrote into, and the fallback enumeration raised
TypeError because `render_only` mixes lists of strings with lists of dicts.

Cost, from a second audit over 115,383 usage blocks: my own advice was the
largest remaining leak. "Read the big layout files in wide slices, generously"
holds 205k tokens resident, which is about $30 in cache reads over a 300-turn
top-tier spawn, and 68% of spend is re-reading accumulated context. It now says
to grep to the function and read 10-25k around it. Second: forks. A fork
inherits the parent's whole context and ignores the `model` parameter, so every
tier rule here is void inside one; six fork spawns averaged $220 each. One line
forbids them.

Corrected my own numbers rather than leaving them flattering: "8x the tiering
saving" was a category error and is ~1.1x; "one extra preamble costs cents" is
$1.50-3.00; "1.8% of all turns" divided a both-thread numerator by a main-thread
denominator and is under 1%; most of those re-encodes were at the 5-minute
premium, not the 1-hour one; and the Explore substitution costs a few dollars a
run once resident re-reads are counted, not $0.24. The 150-turn handoff rule was
also unimplementable and had a losing branch below 178 turns: the trigger moves
to 200 and the writer now reports turns used as item 7 of the handoff schema,
since it is the only party that can see them.

Also: the golden gate covers every root `_discover_fixtures` walks, and a fixture
under `tests/` reds it, which matters because Step 4 puts tests there; the red
message quoted was not the one the test emits; `_VALIDATE_DEFAULT = False`, so a
runtime validator does not fire in an ordinary render and the skill said
otherwise; D-delta narrowing was the only uncapped loop and now stops at two
rounds; and a truncated sentence had lost the pointer to `pr-chain-vet`.

check_skill.py was shown to miss 9 of 13 mutations outside my own set. It now
checks prose tool claims against the definitions, prose repo paths, effort
values, per-role required tools, non-failing `if` guards, `$ART` writes without
mkdir, owner-table membership, and broader spawn phrasings, while skipping
re-briefs so it stops flagging them. Re-tested: 12 of 12 caught, including the
three that survived the first hardening pass.

One reviewer claim was wrong and is not acted on: `run-tutorial` does exist, as a
project-scoped skill in training-studios-config, so the CLAUDE.md pointer is live.
A third cost audit computed the resident-context integral across 586 subagent
spawns and the lever I promoted two commits ago is wrong by two orders of
magnitude. Bash output is 74.5% of resident cost ($1,062 across the corpus, 36k
calls); reading the three big layout modules is 2.0% ($29 in a month, $0.05 a
spawn). Worse, 571 of 573 reads of those files already passed an explicit limit,
so "read the region" described behaviour that was already universal - and my
"10-25k around it" was 14-36x the median window actually used, against a median
function of 28 lines. Following it literally would have read more, not less.

So: a general rule about keeping bulk command output out of every context, which
is the measured lever and 37x the one it replaces; the region-reading advice
demoted to a clause and moved to worker-contract.md where the audience actually
is, calibrated to 1-3k tokens; and an explicit warning against under-reading,
since reading costs $0.05 a spawn while a wrong fix costs a narrowing round plus
a CI cycle, roughly $50. That warning names docs/dev/inter_section_dispatch.mdx,
because routing/core.py is a first-match dispatcher and a handler read in
isolation cannot tell you whether it fires.

Other honest corrections from the same audit:

- The idle-context rule reaches about a third of the cost it sits beside. Only
  35% of those re-encodes follow an idle gap; the rest happen within a minute of
  the previous turn for reasons not visible in the transcripts. It now says so.
- The LIGHT tier is unvalidated: no historical spawn in this repo ran on that
  model. Four roles are assigned it on no evidence, so early LIGHT spawns are
  labelled an experiment to re-route rather than a settled default.
- The turn trigger is unobservable by either party - no tool reports a turn
  count - so the schema now says the writer's figure is an estimate.
- The fork multiple is ~4x a comparable named worker; the $220 mean was a
  property of the parent model those forks inherited.
- The owner split is stated as organisational rather than a saving: the resident
  difference is pennies a session while the corpus more than doubled.

The Explore figure disagreed between two files ($0.24 versus "a few dollars") and
nothing caught it. Both are now the measured $1.65, and check_skill.py gained a
numeric-consistency check so the same quantity cannot carry two figures again.

SKILL.md 4,470 tokens, 530 headroom, real margin restored.
… merge ruling

A third mechanics review executed every block and found the visual gate still
returning a wrong answer on the path that matters. `runid` was assigned in the
provenance block and compared in the next one, which is a separate Bash call, so
it was always empty and every delta-carrying PR got a false "stale preview" -
routing it into the corpus-wide local sweep the same file forbids. It is now
derived where it is used, with an empty-result guard, because `gh run list` exits
0 when a branch has no runs and an empty value otherwise reaches the comparison
and dies misleadingly.

Three more faults in the same chain, all the same underlying mistake of trusting
state or prose instead of a discriminator:

- The sticky selector matched on the words "Render preview", so a human comment
  saying "the Render preview looks fine" would be chosen as the latest match. It
  now anchors on the sticky marker, and guards the case where jq prints a literal
  "null" and exits 0.
- There are five sticky wordings, not four. `pr-render-publish.yml:173` emits
  "was not generated because a prerequisite check or the render job failed",
  which both greps missed; the chain then waited for a page that CI had already
  failed to build. That is now a distinct failure, not a wait.
- Two blocks called `python`, which is not on PATH unactivated, and block 1 then
  reported the resulting empty SHA as a candidate mismatch. Both now activate.

Also: my guard used `a && ! b`, which zsh rejects outright - and this harness is
zsh, so it was a parse error rather than a weak check. Verified directly and
split into two guards.

The merge rule contradicted a standing ruling. It said never escalate to
`--admin`; the recorded feedback says that when checks are green and the only
blocker is an out-of-date branch, "merge" is standing authority to admin-merge
that PR, and that merging the base in to satisfy the policy is the CI waste the
ruling exists to prevent. The exception is now stated, with never-squash intact.

check_skill.py gained guards for the classes this round exposed: `$$` in a shared
artifact path (the exact regression fixed last round, previously unguarded), a
read-only role holding Edit or Write, one-line `if` guards that never exit, and
paths under docs, .github and .claude which were outside its scope. Its numeric
check was firing false conflicts across paragraphs and is now sentence-scoped.
Guard detection accepts `continue` and `break`, since a loop guard need not exit.
Eight review rounds produced the same class of defect over and over, always in
the same file, always found by someone executing rather than reading: tail -1 on
a multi-line body, $$ in a path shared across Bash calls, runid scoped to the
wrong block, python not on PATH, and `a && ! b` which zsh rejects outright. Each
was declared fixed and the next round found another.

The root cause was structural, not carelessness. A twenty-block bash chain
embedded in markdown is unverifiable: there is no program, so nothing can catch a
variable scoped to the wrong block, `set -e` does not fire under the harness's
eval, and `bash -n` sees only fragments. The whole class becomes impossible once
the chain is a script, because a script is one process.

scripts/visual_preview.sh, scripts/render_pairs.sh and scripts/verify_candidate.sh
replace those chains. Each takes named arguments, each guards its own failures,
and each carries --self-test that exercises its parsing against fixtures.
check_skill.py now parses every script in bash and zsh, runs shellcheck over it,
runs its self-test, asserts every script is referenced, and cross-checks the
sticky-comment wordings the preview script greps for against what
pr-render-publish.yml actually emits - so a rename of both the grep and its own
fixture cannot pass.

Mutation-tested against the three classes that blocked the last three rounds:
a variable referenced but never assigned is caught by shellcheck, a grep string
the workflow never emits is caught by the workflow cross-check, and `a && ! b` is
caught by zsh -n. Six of six mutation classes caught, where prose caught none of
these three.

Token effect, which is the other half of the point: visual-review.md falls from
4,133 to 2,056 tokens and environment.md from 1,924 to 1,333. Those are
worker-facing files read fresh on every run, so that is 2,668 tokens off each
affected spawn, and the logic now lives in scripts that are executed rather than
loaded into any context at all.
The scripts are committed executable (mode 100755) and run from a fresh
worktree, verified. But the documented invocation is a relative path, so a
worker whose cwd is elsewhere would fail; both call sites now cd first.
…cy contract

An external review found the two material blockers, both confirmed: nothing
invoked check_skill.py, and tiktoken was not a declared dependency. The PR's
whole thesis is that this workflow should be mechanically protected against
drift, while its guarantees were unenforced and its token gate failed before it
could measure anything.

- New `skill-selfcheck` CI job runs the checker from the repo root, installing
  shellcheck and zsh so the shell and script checks are real rather than skipped.
- New `skill-checks` extra declares pyyaml and tiktoken, so the token budget is a
  checkable invariant rather than a dependency the script hopes is present.
- Without the extra the checker now warns on a byte estimate calibrated to the
  measured 4.13 bytes/token instead of hard-failing, since that figure is too
  imprecise to gate on. CI is the enforcing path; both branches are tested.

The review also caught that render_pairs.sh reimplemented gallery interpretation
in shell rather than reusing what the repo already owns. It resolved output names
by searching for a matching basename, which guesses at data gallery.yaml states
outright and gets duplicate basenames, ids containing regex metacharacters, and
every entry whose output differs from its id wrong. New corpus_map.py emits
`output_name<TAB>source_path` using the same group semantics as
build_gallery.py, and the shell now does an exact awk lookup with no pattern
matching. Writing it as a real resolver with a self-test immediately caught two
wrong assumptions the grep version could never have surfaced: pipelines follow
their gallery entry's source_dir rather than always examples/, and nextflow
conversions source from examples/ rather than tests/fixtures/nextflow/. All 291
mapped sources now verify to exist.

Push policy reworded, since "the one and only push in the run" followed by a
stated exception for CI-only failures was a contradiction a reader could take
either way. The invariant is one CI-triggering push per candidate round, with
further pushes only for findings CI surfaced that could not be reproduced
locally - in practice the routing ratchets off Python 3.11.
…face

Both follow-up findings confirmed by execution.

The positional repository argument was read from argv[1], so `corpus_map.py REPO`
silently used the cwd and `render_pairs.sh --repo DIR` did not honour DIR unless
the caller already happened to be inside it. Reproduced from /tmp, where it
raised rather than resolving. Argument parsing is now a named function with its
own self-test cases, so the shape is asserted rather than assumed.

The docstring claimed missing sources are skipped as build_gallery.py skips them.
They are not: the map emits them and render_pairs.sh reports them as UNRESOLVED.
That is the better behaviour for a review gate - a missing source should be loud,
not invisible - so the docstring now says what the code does, and the self-test's
existence assertion means the two only diverge when something is genuinely wrong.

The deeper point in the finding was that this is the class the self-check
machinery exists to catch, and it did not. check_skill.py only globbed *.sh, so
the Python helper's self-test never ran. It now runs every scripts/*.py
self-test, from a foreign cwd with an explicit repo argument, which is precisely
the invocation that exposes a positional-argument bug. Mutation-tested: reading
the argument from the wrong index, removing the self-test, and pointing a group
at the wrong source directory are all caught now.
@pinin4fjords pinin4fjords changed the title docs(fix-issue): enforce worker tiers and split conditional procedure docs(fix-issue): tier the workers, fit the skill in context, and enforce both in CI Aug 21, 2026
…itions

Adding ten `.claude/agents/` definitions quietly made the primary spawn
instruction Claude Code specific. The LIGHT/MID/HIGH mapping to Codex's tiers
survived, but "spawn by role name and pass the model" assumes definitions exist,
and on a harness without them the first half simply fails with no stated
alternative.

The contract is the tier table and always was: name the role from it and pass
that tier's model by whatever means the harness offers. The definitions are a
Claude Code optimisation that makes the tier structural rather than remembered,
not the thing the rule depends on. agent-types.md is now labelled as
harness-specific at the top, since agent definitions, Explore/Plan, effort,
permissionMode, hooks, SendMessage resumption and the CLAUDE_CODE_SUBAGENT_MODEL
precedence are all this harness's mechanics and none of them port.

Trimmed three restatements to hold the token margin: the Explore trade-off, the
tools rationale and the read-only caveat each say once what they said twice.
Adding ten agent definitions made the skill substantially harness-specific, and
a one-line Codex tier mapping was left implying otherwise. Measured: about 40
references to Claude Code mechanics across 9 of the 14 skill files - agent
definitions, Explore/Plan, effort, SendMessage resumption, the
CLAUDE_CODE_SUBAGENT_MODEL precedence, the post-compaction re-attachment cap and
the .claude layout itself. A Codex run would not get through the first spawn.

So the scope is now stated where the tier contract is: this skill does not run
elsewhere as written. The doctrine does port - the two levers, the tiers, one
writer with independent readers, the gates, diagnose-before-fix - but porting it
means re-implementing the enforcement for that harness, not reading these files
as-is. The tier mapping stays as a note for whoever does that, rather than as a
claim that it already works.

Dropped the "read the region" bullet from SKILL.md to hold the token margin. Two
reviews pointed out it was worker-facing advice, measured at $0.05 a spawn, sat
resident in the coordinator's file, and described what workers already do;
worker-contract.md carries the version its audience actually reads.
I proposed replacing tiktoken with a bytes-per-token constant, on the grounds
that a foreign tokenizer cannot give precision against a Claude-token cap.
Measuring first showed that wrong: bytes/token is 3.37 on the shell scripts and
4.31 on prose, a 28% spread, so a constant calibrated on prose would under-count
a code-heavier file by around 20% and silently consume the entire margin.
tiktoken tracks content shape, which is the property that matters here, so it
stays.

What was actually wrong was the description, not the choice. The checker now says
o200k is OpenAI's encoding rather than Claude's, that the correct instrument is
the count_tokens endpoint but it needs network and credentials a hermetic CI gate
should not require, and that the 10% margin exists to absorb the encoding bias
rather than as arbitrary padding. Its output reports "~4490 tokens by o200k
proxy" against "a 5000 Claude-token cap" so nobody reads it as measured.

The local fallback stays a warning and not a gate, with the reason stated: the
same constant that makes it usable for this file makes it unsafe as a threshold.
Reading the authoritative guidance changed the answer materially: tiktoken
undercounts Claude tokens by 15-20%, and more on code. Every figure I have quoted
this session was that much low, and the 10% margin I was defending was smaller
than the bias it was supposed to absorb. SKILL.md at 4,490 proxy tokens projected
to about 5,388 Claude tokens, so it was already over the 5,000 re-attachment cap
and the compaction-survival claim was false.

The checker now prefers the correct instrument, count_tokens, which is the only
thing that knows Claude's tokenizer, pinned to the model in use. Where no SDK or
credential is available it falls back to tiktoken but scales the reading by the
documented 20% and gates on a working ceiling of 4,750, keeping 5% slack because
the 15-20% band is itself a range. It no longer reports a proxy reading as if it
were a measurement. Both paths are exercised, and the gate was confirmed to bite
by pushing the file over the ceiling.

Getting under the real cap needed real cuts, not compression alone: the brief
template and the cost-discipline rules move to coordinator.md, which the
coordinator always reads anyway, so they stay resident while leaving the file the
cap applies to. SKILL.md is 3,572 proxy tokens, projecting to ~4,286 Claude
tokens against a 4,750 ceiling.

The skill-checks extra now declares anthropic as well, and the CI job passes
ANTHROPIC_API_KEY through if the repo has one. It is optional: without it the
fallback is conservative rather than wrong.
Tested in a fresh session on this branch: the ten agent types resolve, and
spawning one with no `model` parameter runs the definition's model rather than
the session's. That was the one claim resting on documentation alone, and it
holds.

So the double declaration goes. The skill asked for the role name *and* the
model, belt and braces, because the fallback direction was unverified. An
external review pointed out that this leaves two things to keep in sync and
nothing able to check the spawn site; with the fallback confirmed, the definition
is the single source and naming the role is sufficient.

Two cases still need the model explicitly, and the skill says which: a generic
type has no definition to fall back on, and `CLAUDE_CODE_SUBAGENT_MODEL`
overrides every level, so it is worth checking once at session start.
@pinin4fjords
pinin4fjords merged commit 2f8e4a4 into main Aug 21, 2026
14 checks passed
@pinin4fjords
pinin4fjords deleted the docs/fix-issue-skill-economy branch August 21, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant