anyplot uses GitHub Actions to automate the entire plot lifecycle: from specification creation to implementation generation, quality review, and deployment.
Issue + [spec-request] label
|
v
spec-create.yml
|-- Creates branch: specification/{spec-id}
|-- Generates: specification.md + specification.yaml
|-- Claude opens the PR --> main as claude[bot] (CI runs without a manual approval)
|-- Posts analysis comment
|
v (repository owner adds [approved] label to Issue)
|
spec-create.yml (merge job)
|-- Verifies the PR: opened by the Claude app, from specification/{spec-id}
| into main, references the issue, changes only the spec files
|-- Enables auto-merge: GitHub squash-merges once the required checks pass
|-- Waits up to 15 minutes for the merge to land
|-- Dispatches sync-postgres.yml
|-- Adds [spec-ready] label
The spec PR is opened by the Claude GitHub App rather than by the workflow's
GITHUB_TOKEN. Since GitHub's
2026-06-11 change,
workflow runs on a pull request that GITHUB_TOKEN opens or updates wait at
action_required until someone approves them, so a GITHUB_TOKEN spec PR never
got the required checks and could not merge on its own.
The merge job acts only on an approved label that the repository owner adds.
It refuses, with a comment on the issue, any PR that fails a check — for
example, one that changes a file outside plots/{spec-id}/specification.md,
specification.yaml, and the .gitkeep placeholders under implementations/
and metadata/. Don't approve pending workflow runs for such a PR, and don't
merge it by hand. If the merge doesn't land within 15 minutes, the job turns
auto-merge off again and says so on the issue; once the checks are green,
re-run the job or remove and re-add approved.
Every specification.md that spec-create writes ends with a
## What a good version looks like section: 3-6 one-line bullets that name what
a viewer sees in a good render of that plot type. Each bullet starts with its
kind, and each bullet has one kind:
A good version shows:is an affirmative property. The review deducts points when a render misses or violates it, and it's the only kind a regeneration can cite as an improvement.Expected, not a defect:is a permission: something that can look like a flaw but is inherent to the type, such as overlapping bubbles in a dense bubble chart. The review never penalizes it, and generation never targets it. How a good version handles it goes in its ownA good version shows:bullet.
Every section has at least one bullet of each kind. How the pipeline uses it:
- The AI review (
impl-review.yml) scores every implementation against this section: it never lists something anExpected, not a defect:bullet names as a weakness, and it deducts when anA good version shows:property is missing. When a spec has no section, the review infers the same from Description, Data, and Notes. - Generation and repair build toward the
A good version shows:bullets and decline review weaknesses that contradict the section. - The daily spec polish (
daily-regen.yml) never adds, edits, or removes the section. - The Postgres sync doesn't store the section, so the website doesn't show it.
- CI checks the section on every PR and every push to
main. The "Run Tests" job runsautomation/scripts/spec_characteristics_lint.py:contractover every spec, which blocks, andstyleover the changed specs, which only warns. A spec PR whose section fails the contract can't merge: fix the section on the branch, then remove and re-addapproved.
The regen gate numbers the bullets C1..Cn in document order and never
compares these ids across PRs. Append new bullets at the end, and edit a
section only when no regeneration PR for that spec is open.
On a -basic spec, the A good version shows: bullet that sets the basic
variant's scope names reference lines, highlights, and callouts, as excluded
or, where the Notes allow them, as allowed. The review and regeneration read
that list as complete, so a layer the bullet leaves out reads as permitted. The
style lint warns (W7) when one of the three is missing.
When you review a spec PR before adding approved, check this section too: it
must describe the plot type rather than generic ideals such as "no overlap",
contain no numeric thresholds, and not contradict Description, Data, or Notes.
See Report Issues for details.
One unified template: report-plot-issue.yml - Report any plot problem (spec or implementation)
User reports issue (from anyplot.ai or GitHub)
|
v (report-pending label auto-added)
|
report-validate.yml
|-- Validates spec/impl exists
|-- AI analyzes the issue
|-- Posts structured analysis comment
|-- Updates labels: report-validated + category:*
|
v
Ready for maintainer review (fix manually)
Issue + [generate:{library}] label OR workflow_dispatch
|
v
impl-generate.yml
|-- Creates branch: implementation/{spec-id}/{library}
|-- AI generates code (Python: adaptability self-check, Step 3c)
| or, with source_ref, takes the file from that ref and renders it (adopt mode)
|-- Fails if the commit changes a dependency file
|-- Creates metadata/{language}/{library}.yaml (initial)
|-- Python: adaptability smoke run, plus the predecessor's render on a regeneration (record-only)
|-- Tests execution
|-- Uploads preview to GCS staging (adaptability records under a private adapt/ prefix)
|-- Creates PR --> main
|
v
impl-review.yml
|-- Python: adaptability check of the PR's file and its predecessor (record-only)
|-- AI evaluates code + image
|-- Posts review comment with score
|-- Updates metadata/{language}/{library}.yaml (quality_score, review feedback)
|-- Adds [quality:XX] label
|
|-- Meet Threshold --> [ai-approved] --> impl-merge.yml
| |-- Refuses a PR that changes a dependency file
| |-- Squash merge
| |-- Promotes GCS: staging --> production
| |-- Triggers sync-postgres.yml
|
|-- Below Threshold --> [ai-rejected] --> impl-repair.yml (max 4 attempts)
| |-- Reads AI feedback
| |-- Fixes implementation
| |-- Python: reruns the adaptability smoke run (record-only)
| |-- Re-triggers impl-review.yml
|
|-- Regeneration (implementation already on main) --> regen gate, one review, no repair
|-- Replace --> [regen:improved] (+ [regen:adapt] or [regen:adapt-overruled]) + [ai-approved] --> impl-merge.yml
|-- Keep --> (+ [regen:adapt-overruled]) [regen:kept] --> PR closed, live code and GCS unchanged
|-- Stored review <-- re-score: [review-writeback] PR --> impl-merge.yml (writeback job)
Python implementations follow the Data block convention of Adaptable catalogue code. These steps measure it. None of them fails a run, and the reviewer never sees their output:
| Workflow | Step | What it does |
|---|---|---|
impl-generate.yml |
Adaptability smoke run (record-only) | Runs the new source on perturbed tables (core/smoke_run.py run). On a regeneration it also renders the predecessor next to the new source in the same runner (pair) and runs the smoke run on it. It records the results under $RUNNER_TEMP/adapt without gating. |
impl-generate.yml |
Upload to GCS Staging | Clears staging/{spec}/python/{library}/adapt/ and stages this run's smoke results, pair record, raw pair renders, and the generator's self-check log there, privately and never promoted. |
impl-repair.yml |
Adaptability smoke run (record-only) | Reruns the smoke run on the repaired file. |
impl-repair.yml |
Upload repaired plot to GCS staging | Replaces adapt/new-smoke.json, or removes the stale one when the repair run wrote none. |
impl-review.yml |
Set up Python | Pins the job's python3 to .python-version, which the regen tools and the checker mirror need to parse PEP 758 except A, B:. |
impl-review.yml |
Adaptability check (record-only) | Runs the workflow-ref checker mirror on the PR's Python file and, on a regeneration, on its predecessor. It combines each with the staged smoke run into /tmp/anyplot-adapt-new.json and /tmp/anyplot-adapt-prev.json, asks the gate whether the adapt route holds (regen_gate.py adapt-route), and skips the review only when the route holds and the owner's ADAPT_REVIEW_FREE switch is on. |
impl-review.yml |
Add carried quality score label | On a review-free adapt merge, labels the PR with the stored score that carries over. |
impl-merge.yml promotes only the plot-* files from staging, so adapt/ never reaches production; its cleanup removes adapt/ with the rest of staging.
Each language's plotting stack is pinned in the repository:
| Language | Pin | Installed in the pipeline by | Kept current by |
|---|---|---|---|
| Python | uv.lock |
uv sync --frozen --extra lib-{library} in impl-generate.yml and impl-repair.yml, as agents/renderer/Dockerfile and CI install |
Dependabot (uv, /) |
| JavaScript | package.json + package-lock.json at the root |
npm ci in .github/actions/setup-node |
Dependabot (npm, /) |
| Julia | Project.toml + Manifest.toml |
Pkg.instantiate() in .github/actions/setup-julia |
Hand-made PRs |
| R | Dated Posit snapshot in .github/actions/setup-r |
install.packages() from the snapshot |
Hand-made PRs |
Implementation PRs never change these files. A generation whose commit changes
pyproject.toml, uv.lock, package.json, package-lock.json,
Project.toml, Manifest.toml or .python-version fails in
impl-generate.yml, and impl-merge.yml refuses to merge an implementation PR
that changes one, which also covers repair commits. A re-resolved lock file
moves the library version for every implementation of the language: #9398
committed a Manifest.toml that a Pkg.add had re-resolved and moved Makie
from 0.22.10 to 0.21.9. A spec that needs a new package needs a dependency PR
first.
No version is written down by hand. The version an implementation was made
with is measured at generation time and stored in its metadata as
library_version, and impl-review.yml writes it into the file header. The
version on the Libraries page and each language's runtime version are read
from the pins by core/library_versions.py when sync-postgres.yml runs; a pin
that does not resolve keeps the stored value and logs a warning. So a minor or
patch bump, Dependabot's included, needs no other edit.
The example headers in prompts/library/*.md carry no version. Every prompt of
a locked library (Python, JavaScript, Makie) states the API level the generator
writes against
(**API level:** ECharts 6.), and tests/unit/core/test_version_sources.py
holds it to the lock's major, so a major bump updates the prompt in the same
PR. The same test checks that every pin resolves.
| Label | Meaning | Set By |
|---|---|---|
spec-request |
New specification request | User |
spec-ready |
Specification merged, ready for implementations | Workflow |
| Label | Meaning | Set By |
|---|---|---|
generate:{library} |
Trigger generation for library | User |
impl:{library}:pending |
Generation in progress | Workflow |
impl:{library}:done |
Implementation merged to main | Workflow |
impl:{library}:failed |
Set by impl-generate.yml after three failed generation attempts for the pair within a 12-hour window, or by impl-review.yml when the PR still scores below 50 after four repair attempts. Infrastructure failures (provider incident, cloud auth, GitHub API) are retried on a separate cap of 5 and never set this label; a pair paused that way carries no label. A generation Claude stopped never sets it (see When Claude stops a session), and an adopt-mode source failure doesn't either (see Adopt implementations from a branch). |
Workflow |
| Label | Meaning | Set By |
|---|---|---|
ai-approved |
Quality check passed (based on cascading thresholds) | Workflow |
ai-rejected |
Quality check failed, triggers repair | Workflow |
ai-attempt-1/2/3/4 |
Retry counter | Workflow |
ai-review-paused |
Claude stopped the PR's review (a spend or usage limit, or refused credentials): no score, no verdict. The watchdog skips the PR; pair mode or a manual rerun resumes it, and the next review removes the label (see When Claude stops a session) | Workflow |
ai-repair-paused |
Claude stopped the PR's repair, next to ai-rejected; the attempt isn't used up. The watchdog skips the PR; pair mode or a manual restart resumes it, and the next repair removes the label |
Workflow |
quality:XX |
Quality score (e.g., quality:92) | Workflow |
quality-poor |
Score < 50, needs fundamental fixes | Workflow |
regen |
Regeneration of an implementation that is live on main (marker for humans and the watchdog; impl-review.yml decides "regeneration" from origin/main) |
Workflow |
regen:forced |
Regeneration dispatched with regen_gate=false or in adopt mode (source_ref): bypasses the regen gate and takes the fresh-generation path |
Workflow |
regen:improved |
The regen gate replaced the live implementation (added before ai-approved) |
Workflow |
regen:kept |
The regen gate kept the live implementation; the PR is closed unmerged | Workflow |
regen:adapt |
The regen gate replaced the live implementation on the adapt route (added after regen:improved); one of the two counts the owner reads before turning on ADAPT_REVIEW_FREE |
Workflow |
regen:adapt-overruled |
The adapt route held before the review, and the review's floor turned it off (added before the verdict label, on a merge or a keep); the other count the owner reads before turning on ADAPT_REVIEW_FREE |
Workflow |
review-writeback |
A metadata PR that stores a kept regeneration's re-score as the live implementation's review (branch review-writeback/{spec}/{library}/{kept PR}); impl-merge.yml merges it |
Workflow |
| Label | Meaning | Set By |
|---|---|---|
approved |
Human approved specification | Maintainer |
rejected |
Human rejected | Maintainer |
| Label | Meaning | Set By |
|---|---|---|
report-pending |
Report submitted, awaiting validation | Auto (template) |
report-validated |
AI validated, ready for review | Workflow |
report:spec |
Issue with specification | Workflow |
report:impl |
Issue with implementation | Workflow |
report:impl:{library} |
Specific library affected | Workflow |
category:visual |
Design/visual issues | Workflow |
category:data |
Data quality issues | Workflow |
category:functional |
Non-functional elements | Workflow |
category:other |
Other issues | Workflow |
- Review 1 (Initial): Score >= 90
- Review 2 (Repair 1): Score >= 80
- Review 3 (Repair 2): Score >= 70
- Review 4 (Repair 3): Score >= 60
- Review 5 (Repair 4): Score >= 50
- Failure: < 50 after 4 repairs -> close PR, mark as failed (a forced regeneration keeps its live implementation and stays
impl:{library}:done)
The cascade applies to fresh generations only. A regeneration takes the regen gate below.
Nothing to repair. A review below its threshold is still approved when it scores at least 80, names no defect, has every technical criterion (the 19 VQ, SC, DQ, and CQ items) at its maximum, and its checklist adds up to its score. A repair fixes defect lines, so such a review leaves a repair nothing to do: the points it withheld are design and library levels (DE, LM) that no line asks a repair to change. impl-review.yml reads both conditions (regen_gate.py nothing-to-repair, from review_checklist.json and review_weaknesses.json): a defect line that names a DE or LM criterion is repairable, and a technical point deducted without a defect line still gets its repair. The pull request gets a comment that says why it merged below the threshold.
The workflow owns the verdict. The stored review.verdict is what the workflow decided: APPROVED when the score met the threshold, when the nothing-to-repair rule applied, or when the regen gate merged, and REJECTED when a first generation goes to repair. A regeneration the gate keeps stores nothing on its own branch. It isn't the reviewer's opinion; a kept regeneration's write-back stores APPROVED, because the kept implementation stays live.
The stored review is out of the working tree during a review. Once a review has run, the pull request branch carries it: the Quality: N/100 header and the metadata file with the score and the whole review. While a reviewer runs, the header reads Quality: pending and the metadata file is out of the workspace; both come back before the workflow stores the new review. This holds for every review, not only the ones after a repair, because a manual re-review or a watchdog retry of a scored pull request runs without an attempt label. The run log's blind review: notice shows the state the reviewer saw, and the commit that stores a review names no score. This hides the working-tree copies only. The branch history and the pull request stay reachable for a reviewer that goes looking: git diff shows the moved file, git log -p the earlier commit, and gh the earlier review comments and the quality:N label. Nothing mechanical closes those; the review prompt tells the reviewer not to open earlier reviews, the stored score, or another library's implementation of the spec.
What the reviewer doesn't see. The thresholds above and what follows from a score are in this file and in the workflow, not in the reviewer's prompts (prompts/quality-criteria.md, prompts/workflow-prompts/ai-quality-review.md). The reviewer scores 24 criteria and adds them up; it writes no verdict and doesn't know which score approves. Two rules shape that score:
- The 19 technical criteria (VQ, SC, DQ, CQ) start at their maximum, and every point below it is carried by a defect line that names the criterion. The workflow's format check counts the deductions without one (
weakness_format … silent=Nin the run log). - The five judgment criteria (DE, LM) start at their default, and only evidence beyond what the spec and the style guide require raises them. An implementation that meets every requirement and adds nothing lands near 82.
A unit test (tests/unit/prompts/test_prompts.py) keeps the thresholds out of the three reviewer prompts and in this file.
impl-review.yml re-runs a review at most once by itself, whatever the cause. A comment carrying the <!-- review-retry:{spec}:{library} --> marker records that retry:
- No score. The review wrote no
quality_score.txtand no review comment ("Validate review output"): one retry throughrepository_dispatch. - Scored, then a later step failed before any verdict label landed. For example, a GitHub 5xx on the
quality:Nlabel ("Re-dispatch review after a post-score failure"): one retry throughgh workflow run --refon the run's own ref. If the PR already has a verdict label (ai-approved,ai-rejected,regen:improved, orregen:kept), the step does nothing, and the watchdog's merge or repair cases take over. A verdict label that can't be added after three tries fails "Add preliminary verdict label (early)" on every path, includingregen:kept, so this retry also covers a lost verdict label. A review-free adapt run (the review skipped onADAPT_REVIEW_FREE) has the same retry when a step fails before its verdict label, such as the carriedquality:Nlabel; its comment names the stored score, because no review scored.
Both paths write the marker comment before they dispatch the retry, and they retry the comment and the dispatch three times each. A marker that can't be read counts as a spent retry, so a GitHub outage can't buy a second one. When the retry is spent, or the marker comment or the dispatch itself fails, the PR gets ai-review-failed. The watchdog re-dispatches the review once for a fresh generation and only flags a regeneration. A PR left with only quality:N and no verdict is picked up by the watchdog's never-reviewed case. impl-review.yml adds these labels with GITHUB_TOKEN, which doesn't start workflow runs, so impl-review-retry.yml reacts only to a label that a person adds.
ai-review-failed is removed by the review it asks for. Every review run removes the label when it starts ("Clear a stale ai-review-failed label", best effort). The watchdog leaves a PR with that label alone while a review of it is queued or running, and drops the label without a re-review when the PR already has a verdict label. This matters when GitHub accepts a dispatch but reports an error: the step then adds ai-review-failed although the re-review is already queued, and without these two guards the watchdog would start a second review of the same PR.
A review that Claude stopped, at a spend or usage limit or on refused credentials, takes neither path: it gets ai-review-paused, spends no retry, and the watchdog skips it (see When Claude stops a session).
impl-repair.yml has the same one-retry rule for a repair run that crashes, recorded by a <!-- repair-retry:{spec}:{library}:attempt-{n} --> marker. It sums the marker count over all comment pages and treats a count it can't read as a spent retry. The watchdog's crashed-repair case takes over from there.
The generation retry of impl-generate.yml and the post-score review retry run on the ref of the run that failed: a run dispatched from a feature branch retries on that branch, and a production run retries on main. The no-score review retry still goes through repository_dispatch, which always runs on main.
A regeneration is an implementation PR for a (spec, library) pair that already has an implementation on main — for example every PR that daily-regen.yml produces. It gets one review and no repair loop, and the live implementation is replaced only when the new one is visibly better:
-
impl-review.ymldetects the regeneration fromorigin/main(the implementation file exists there), downloads the predecessor's production renders to/tmp/anyplot-prev-plot-{light,dark}.png(outside the working directory), and writes the previous review with stable weakness idsW1..Wn— without its stored scores — to/tmp/anyplot-prev-review.md. EachWis tagged by its format:(defect)for a line that names the criterion it violates (VQ-02 (light): … → …. Likely cause: ….),(suggestion)for aSuggestion: …line, and(older review)for a line from before the two formats. -
The review scores the new render blind (the prompt never shows it the stored score, and
impl-generate.ymlresets the new file'sQuality: N/100header, which a regeneration inherits from its predecessor, toQuality: pendingbefore the PR opens), then re-scores the predecessor's renders against the same criteria and writes its before/after judgement toreview_regen.json(step 8b ofprompts/workflow-prompts/ai-quality-review.md). The re-score records its 24 item scores (prev_checklist), classifies every previous weakness as a defect, a suggestion, or obsolete (anExpected, not a defect:bullet covers it), and gives every predecessor defect it found (P1,P2, …) the criterion it violates. -
automation/scripts/regen_gate.pydecides. Replace requires all of:- new score >= re-scored predecessor - 1 (the stored score is display-only);
- at least one improvement with a named, visible location that doesn't cite an
Expected, not a defect:bullet of the spec or a weakness the re-score classed obsolete (neither is ever an improvement; the gate lists such an item as not counted); - at least one visible improvement fixes a defect: a visual, spec, data (not feature coverage), or code defect (a defect-class weakness or a predecessor defect) that scores higher in the new review than in the re-score, or an
A good version shows:property, and the review names its kindfixorremoval. Suggestions, design, library-mastery, and feature-coverage points, additions of optional features, polish of an element the spec's scope excludes, improvements without a kind, and obsolete weaknesses never carry a replacement; - no regressions. On a
*-basicspec, a replaced data scenario or added encodings count as regressions unless a change request asked for them.
Code path. When no improvement fixes a defect, a regeneration still replaces the live implementation when it fixes a code defect the previous review named. The review lists it under
code_improvements, and the gate counts it when all of these hold: the previous weakness is aCQ-04 (code): … → \call`(or→ remove) line that the re-score classed a CQ-04 defect; CQ-04 scores higher in the new review than in the re-score; the new source calls the named call more often than the predecessor (or the target was a removal), where a comment, a string literal, or a definition of the same name doesn't count as a call; the new source is shorter; and the data scenario and encodings are unchanged, on any spec. The score tolerance and the regression rule apply as above, and visible suggestions ride along. The gate reads both sources (--prev-impl,--new-impl`); without them the code path is off. A code-path replacement is a merge, so like any merge it writes no re-score back.Adapt route. A Python regeneration that converts the implementation to the adaptable-code convention (Adaptable catalogue code) changes the code but not the picture, so it has no visible improvement to carry it. It replaces the live implementation on render identity instead, when all of these hold:
- the predecessor fails the adaptability check (the static check or the smoke run) and the new source passes both;
- the two sources' renders, both made in the same
impl-generate.ymlrunner ("Adaptability smoke run (record-only)") from the very sources the records checked, differ in at most 0.5 % of their pixels in each theme, at equal size; a size change fails, and so does a pair rendered from other source versions (pair_stale); - the new source has at most max(1.25 × prev, prev + 15) lines, and the canvas gate passed;
- when the review ran: a score above 0, no regression, an unchanged data scenario and no added encoding (on any spec), and no VQ, SC, DQ, or CQ item below its value in the re-score.
The route takes the place of three keeps:
no_visible_improvement,no_defect_improvement, andbelow_tolerance. The score tolerance never applies to it: the reviewer's run-to-run spread and the re-score's offset from the stored score make any tolerance a coin flip, so the technical items are the floor instead. The CQ-04 code path keeps its tolerance. A replacement on the route carries the reason codeadapt_identity. The gate reads three files: the two records that "Adaptability check (record-only)" writes, one per source, and the render pair that impl-generate staged. A missing or malformed record, a missing render pair, or an implementation in another language turns the route off, and the record says why. It never counts as a pass. A record whose status does not follow from its static and smoke results counts as malformed (record_invalid), and so does a render pair that does not name the PR's library.impl-review.ymlevaluates the conditions that need no review before the review runs (regen_gate.py adapt-route, theadapt_routenotice). When the repository variableADAPT_REVIEW_FREEistrueand those conditions hold, the review is skipped and the gate merges on them alone (decide --review-skipped), carrying the stored score and review forward. A skipped review whose route does not hold is kept asno_score. The variable is off by default, and only the owner turns it on. The evidence is the route-held PRs:regen:adaptmarks a merge on which the route held before and after the review, andregen:adapt-overruledmarks a PR whose route held before the review and whose review then turned it off (decide --adapt-route-held, the gate outputadapt_overruled). Route-held PRs are theregen:adaptPRs plus theregen:adapt-overruledPRs. The owner turns the switch on only when the overruled share stays near zero over at least 50 route-held PRs:gh pr list --state merged --label regen:adapt --limit 1000 --json number --jq length gh pr list --state all --label regen:adapt-overruled --limit 1000 --json number --jq length
Adapt regression. On every spec, when the predecessor passes the adaptability check and the new source fails it, the live implementation stays (
adapt_regression), whatever else would replace it. The same holds without a smoke run: when the predecessor passes the static check, its smoke run is missing, stale, or skipped, and the new source fails the static check. Converted code stays converted. -
Replace:
regen:improved, thenregen:adaptwhen the adapt route counted orregen:adapt-overruledwhen the review overruled it, thenai-approved, a PR comment with the same summary (stored, re-scored, and new score, improvements, regressions, reason), then the normal merge. Keep:regen:adapt-overruledwhen the review overruled the adapt route, thenregen:kept, the PR is closed with that comment, the issue getsimpl:{library}:doneback, and the live code and GCS production stay as they are. The re-score of the live implementation becomes its stored review through a separate metadata PR (see Stored review on a keep).
A crashed regen review is auto-retried once by impl-review.yml; after that the PR carries ai-review-failed and the watchdog only flags it — it never dispatches a further review for a regeneration. The same one-retry budget covers a review of any PR that scored but failed before a verdict label landed, for example on a GitHub 5xx (see Review recovery).
Anything missing or malformed — no review_regen.json, an unknown weakness id, missing previous renders, a failed canvas gate, a score of 0 — keeps the live implementation. The classification never invalidates the file, but every gap points toward keep: an unclassified weakness counts as a suggestion, and without prev_checklist or the new review's review_checklist.json no criterion claim verifies, so only an A good version shows: property can carry the replacement.
Every review writes its weaknesses in the two formats: defect lines first, then at most three Suggestion: lines. Generation and repair fix the defects and decline every suggestion. A non-gating step in impl-review.yml ("Check review feedback format") runs regen_gate.py check-feedback --warn-only and reports lines in neither format, and claims the scores don't confirm, as warnings; the review runs the same check on itself before it finishes.
Every decision leaves three traces:
- The gate record in the PR comment. Both the keep and the replace comment end with an invisible
<!-- regen-gate-record:v1 {...} -->marker: one line of JSON with the scores, improvement and regression counts (the improvements split into carriers, suggestions, and obsolete citations; the suggestions name additions, polish, and improvements without a kind apart, and a histogram counts the kind of every listed improvement, andcodecounts the code improvements the gate counted), the reason code (merge,below_tolerance,no_visible_improvement,no_defect_improvement,regression,adapt_identity,adapt_regression,regen_json_invalid,canvas_failed, and so on), and the provenance of both reviews (resolved model and rules version of this review and of the stored one). Theadaptblock holds the adapt route:version(the checker version),prevandnew(each source's adaptabilitystatus, with itsstaticandsmokestatuses),identity(the share of differing pixels per theme),counted,why(routewhen it counted, otherwise the first condition that failed, such asnot_python,prev_passes,identity_dark, ortechnical_below),review(ranorskipped), androute_pre_review(whetheradapt-routeheld before the review:true,false, ornullwithout adapt input). A keep record also says what happened to the re-score (writeback, see the next section). It holds no model-written text. PR comments are permanent, so read this first. - The notice line in the run log.
::notice::regen_gate spec=… lib=… prev_stored=… prev_rescored=… new=… verdict=… code=… model=… criteria=… prev_lines=… new_lines=… adapt_counted=… adapt_why=… adapt_overruled=… reason=…, whereprev_linesandnew_linesare the two sources' line counts, andadapt_counted,adapt_why, andadapt_overruledappear when the step passes the adapt files. Run logs expire. - The pair artifact.
regen-pair-<pr>-<attempt>on theimpl-review.ymlrun, kept 60 days: both renders, both sources (the predecessor's with its score hidden), the previous review, this review's files, and the gate record. For a Python pair,adapt/adds both adapt records and what impl-generate staged for them: the smoke runs,pair.json, the raw renders of both sources, and the generator's self-check log. The predecessor's production renders are overwritten on the next merge, so this is the only copy of what the gate compared.
uv run python -m automation.scripts.review_retest gate-report aggregates the records across pull requests: merge rate, reason codes, how far re-scored predecessors land from their stored scores, the counted visible improvements and carriers, cited permissions and obsolete weaknesses, what happened to the re-scores of kept implementations, the merges the code path carried, and soft alarms. See Review retest.
A kept regeneration still reviewed the live implementation: the gate's re-score. That re-score becomes the implementation's stored review, so the next regeneration starts from current problems under the current model and rules. This happens on every keep, whether or not the model or the rules changed since the stored review.
- The review writes the re-score as a full review,
review_prev.json(step 8b ofai-quality-review.md, step 5): the image description of the production renders, the criteria checklist (item for item equal toprev_checklist, each category score the sum of its items), strengths, weaknesses (the predecessor defectsP1,P2, … as defect lines, except one whose improvement the review names anadditionorpolish, which is written as a suggestion, then at most three suggestions), and a verdict. It has no score: the stored score is the gate'sprev_rescored. impl-review.yml("Write back the re-score (regen keep)") checks the file withautomation/scripts/regen_writeback.py check, confirms thatmainstill holds the two files the review read, and commits the newquality_score, the five review fields,review.model, andreview.criteria_versionin the metadata, plus the number in the implementation'sQuality: N/100header, on the branchreview-writeback/{spec}/{library}/{kept PR}. It opens a PR labelledreview-writebackwithGITHUB_TOKENand dispatchesimpl-merge.yml.updated,review.rendered_at,impl_tags, and the previews stay as they are: the code didn't change.- The
writebackjob ofimpl-merge.ymlmerges the PR with the admin token after three checks: the PR is the bot's, and its score, review model, and rules version equal theprev_rescored,model, andcriteria_versionof the kept PR's gate record (check-pr), the diff changes only those keys and the header number (verify-diff), andmainhasn't changed the pair's files since the branch was cut (check-fresh, before every merge attempt). Then it triggers the database sync.
Any failure leaves the stored review as it was and closes the write-back PR; the keep itself is never held up. The keep record's writeback says what happened: opened (the PR exists and its merge was dispatched, not yet that it merged), unchanged, no_rescore (the gate had no valid review_regen.json), invalid (review_prev.json failed its check), stale (main changed the pair's files during the review), or failed. The kept comment has a matching Stored review line. A lost write-back heals at the pair's next gated regeneration, which re-scores the implementation again.
So a stored weakness list in the older format lasts only until the pair's first gated regeneration: a merge stores the new render's review, and a keep stores the re-score, both in the defect and suggestion format.
To replace an implementation without the gate, dispatch with regen_gate=false (impl-generate.yml or bulk-generate.yml): the PR is labelled regen:forced and takes the fresh-generation path, including the repair loop. If the repairs are exhausted, the PR is closed and the live implementation stays on main; nothing is deleted. A run in adopt mode (source_ref) is always forced this way; see Adopt implementations from a branch.
The local /regen command (agentic/commands/regen.md) is an owner override without the review gate; it only withholds ai-approved when the new score is more than one point below the stored score.
- Decoupled: Each library runs independently (no single point of failure)
- Partial OK: 6/9 implementations done = fine
- No merge conflicts: Per-library metadata files
- Auto-sync: Database updated on every merge to main
- GCS flow: staging --> production only after merge
Located in .github/workflows/:
| Workflow | Purpose |
|---|---|
spec-create.yml |
Creates new specifications |
impl-generate.yml |
Generates single implementation, or adopts one from a ref with source_ref |
impl-review.yml |
AI quality review |
impl-repair.yml |
Fixes rejected implementations |
impl-merge.yml |
Merges approved PRs, and the review write-back PRs a kept regeneration opens (job writeback) |
bulk-generate.yml |
Batch implementation generation |
review-retest.yml |
Dispatch-only measurement: re-runs the AI quality review on the frozen retest set (several fresh sessions per item, regen pairs in both orders) for one rules version, and reports score spread, verdict flips, weakness agreement, and gate order bias against a baseline arm. Read-only tokens; writes nothing outside its own artifacts. See Review retest |
daily-regen.yml |
Cron-driven regeneration of the oldest implementations (once a day at 02:17 UTC, off the top of the hour to dodge GitHub's scheduler overload). A spec's age counts from the newer of its last merged update and the last activity on its spec issue (every regen touches the issue), so a kept regeneration is not re-picked the next night. Any activity on the issue — a comment, a label, a report — postpones that spec's regen the same way. With the repository variable REGEN_PAIRS_PER_DAY, the scheduled run regenerates single (spec, library) pairs instead of whole specs; see Daily regeneration modes |
watchdog-stuck-jobs.yml |
6-hourly safety net: re-dispatches stuck reviews, repairs (including a repair that crashed after a rejection), merges and generations (straight to impl-generate.yml, marked only once the run exists), re-closes open regen:kept PRs, never rescues a regeneration into repair or re-dispatches its failed review (it flags it for manual attention instead; "regeneration" = the regen label or the implementation file on main, unless regen:forced), skips a PR whose review or repair Claude stopped (ai-review-paused, ai-repair-paused), and rescues daily-regen when its cron is silently starved by GitHub (>26 h without a run) |
report-validate.yml |
Validates user-submitted issue reports |
sync-postgres.yml |
Syncs plots/ filesystem state to PostgreSQL on push to main, and runs alembic upgrade head against Cloud SQL first; a push that only adds a revision under alembic/versions/ triggers it too, and so does one that changes a pin the library versions are read from (uv.lock, package-lock.json, Manifest.toml, .python-version, a setup action) |
sync-labels.yml |
Auto-syncs spec/impl labels after manual PR merges |
indexnow-submit.yml |
Pushes changed page URLs to IndexNow (Bing, Yandex, Seznam, Naver, Yep) on every push to main that touches plots/; a push that changes the workflow file itself or bumps TEMPLATE_LAST_CHANGED in api/routers/seo.py submits the full list, as does workflow_dispatch with scope=sitemap |
codeql.yml |
CodeQL scanning (actions, JavaScript/TypeScript, Python) on pushes to main, PRs and a weekly cron; plots/** is excluded from triggers and analysis, so pipeline PRs never start a scan |
ci-lint.yml |
Ruff lint check on PRs. ci-lint.yml, ci-tests.yml, ci-image.yml, ci-migrations.yml and ci-changelog.yml start no run on a PR that changes only plots/*/implementations/** and plots/*/metadata/**, which is what the plot pipeline's PRs touch: GitHub holds the runs of a PR that GITHUB_TOKEN opened for an approval that never comes, and they end as failure at the merge. A PR that also changes any other file, such as a spec PR, keeps every run. |
ci-tests.yml |
Unit and integration tests on PRs, including PRs into feature/**, on four pytest-xdist workers (-n 4 --dist worksteal) with coverage, in "Run Tests", which has no GCP access. "Run Tests" installs the renderer image's eight lib-* extras and sets ANYPLOT_REQUIRE_LIBRARIES=1, so a plotting library that does not import fails its tests instead of skipping them (tests/libraries.py). "Run E2E Tests" runs after it, the only job that logs in to GCP: E2E against the Cloud SQL test database through the proxy, skipped for PRs by Dependabot and Google Jules. "Run Tests" also lints the "What a good version looks like" section on every run, even when no Python changed: the contract over every spec (blocking) and the style of the changed specs (warnings) |
ci-image.yml |
Builds api/Dockerfile and smoke-tests the container before merge — /health, the reported version against pyproject.toml, the runtime stage's COPY payload, non-root uid — plus hadolint on every Dockerfile it builds. Skips when only plots/** or frontend sources changed. A third job builds agents/renderer/Dockerfile when the renderer, the probe harness or the dependency lock changed: it renders a seaborn plot through the harness under the sandbox's rlimits with no network, checks that the image carries no ADK and reports the pyproject.toml version, and runs hadolint. A fourth job builds agents/Dockerfile when agents/ (outside agents/evals/), core/, the catalogue prompt files the agents read or the dependency lock changed: it checks that GET /v1/status reports the model, provider and libraries its environment sets and the pyproject.toml version, that the production default refuses an anonymous caller with 401, that the prompts and fonts are in the image and the eval cases are not, and that the service runs as uid 1000, and it runs hadolint |
ci-migrations.yml |
Job Verify Migrations: checks every Alembic revision on a throwaway PostgreSQL 18 service container before merge — exactly one head, alembic upgrade head, alembic check, a CHECK-constraint parity query (which alembic check cannot do), and a downgrade to the base commit's head and back. Runs the checks only when alembic/, alembic.ini, core/database/, pyproject.toml or uv.lock changed, and reports a skip otherwise. It catches two revisions forked from the same head only when the PR is pushed after the other revision merged, because ruleset main does not require up-to-date branches; otherwise the push run on main reports the second head after the merge. See Database migrations |
notify-deployment.yml |
Records GitHub deployment events for app / api |
bot-serving-check.yml |
Daily synthetic monitor: curls the Cloud Run origin with crawler UAs and fails on non-200 or a page that is not the prerendered one. Routes are derived from the @router.get("/seo-proxy/…") decorators in api/routers/seo.py and the expected spec title from plots/<spec>/specification.yaml, so no literal here can go stale. A failure opens (or comments on) the fixed-title issue Bot serving check is red; the next green run closes it — the bot→seo-proxy path is invisible to human traffic and needs its own alarm |
util-claude.yml |
On-demand @claude utility (issue/PR comments) |
# All libraries for one spec:
gh workflow run bulk-generate.yml -f specification_id=scatter-basic -f library=all
# One library across all specs:
gh workflow run bulk-generate.yml -f specification_id=all -f library=matplotlibConcurrency limit: Max 3 parallel implementations globally.
To generate or regenerate a single (spec, library) pair, dispatch
impl-generate.yml directly. It's the workflow bulk-generate.yml dispatches
once per library:
gh workflow run impl-generate.yml -f specification_id=scatter-basic -f library=bokehspecification_id and library are required. The optional inputs are the
following:
| Input | Default | Meaning |
|---|---|---|
issue_number |
empty | The spec issue to report on; empty reads it from plots/{spec-id}/specification.yaml |
model |
auto |
The generation model, also used for repair: auto routes as in Generation model; haiku, sonnet, or opus pins one. Reviews always run on Opus |
change_request |
empty | A one-sentence hint the generator follows, such as the cross-library divergence note of the daily-regen similarity audit |
regen_gate |
true |
For a pair that already has an implementation on main: one review decides keep or replace, with no repair. false forces the fresh-generation path with the repair loop and the regen:forced label (see Regen gate) |
source_ref |
empty | Adopt mode: take the file from this branch, tag, or commit SHA (see Adopt implementations from a branch) |
source_model |
empty | Adopt mode only: the model that wrote the adopted file, recorded as generated_by |
For example, to regenerate one pair on Opus with a hint:
gh workflow run impl-generate.yml -f specification_id=scatter-basic -f library=bokeh \
-f model=opus -f change_request="Use a different data scenario from the matplotlib version."Adopt mode takes each implementation file from a branch, tag, or commit SHA of
the repository instead of generating it. Use it for code written outside the
pipeline, for example a gemini/* branch that migrated every MUI X
implementation to a new major version. The pipeline still owns everything
else: render, metadata, review, repair, merge, GCS promotion, and labels.
Nothing from the source branch is merged directly.
To adopt, pass source_ref to bulk-generate.yml or impl-generate.yml, and
name the model that wrote the code in source_model:
gh workflow run bulk-generate.yml -f specification_id=all -f library=muix \
-f source_ref=gemini/muix-9 -f source_model=gemini-4-argonName the ref as the repository knows it, such as main or gemini/muix-9. A
leading origin/ is dropped. source_model is optional and takes a model
name of letters, digits, ., _, and -, at most 40 characters. Both
workflows check both inputs before they print them, and any other value stops
the run, so a bulk-generate.yml run with a malformed value stops before its
first dispatch. Without source_ref, the run ignores source_model with a
warning. An adopted run differs from a generation in these ways:
- The file comes from the ref. The step "Adopt implementation from source
ref" fetches the ref and takes exactly
plots/{spec-id}/implementations/{language}/{library}{ext}, the file the generator would write. The Claude steps and the prompts overlay are skipped, and every later step runs unchanged. - It renders the file the way the generator does. Both themes run with the
generator's commands:
python -PwithMPLBACKEND=Aggfor Python, and the browser render harness for JavaScript.core.canvasthen checks both PNGs. A drift from the canvas only warns, because the canvas gate inimpl-review.ymlsends it to repair. Python and JavaScript only: an R or Julia run fails with an error. - It's always a forced regeneration. The PR is labelled
regen:forcedwhateverregen_gatesays, so it takes the fresh-generation path with the repair loop and never the regen gate. - No pipeline model writes the code. The routed model, Opus under
model=autoas for any forced regeneration, still runs repair. The PR body names the source, its commit, and thesource_modelon a**Generation:**line, and keeps the repair model on its**Model:**line, which review and repair rescues read. - The metadata names the author and the source.
generated_byrecords thesource_model, such asgemini-4-argon, oradopted:{source_ref}when the run has none. A new key,adopted_from, follows it with the ref and the full commit SHA the file came from, in the formadopted_from: {source_ref} ({sha}). A later regeneration by Claude rewrites the metadata withoutadopted_from. - A source failure is not retried. When the file or the ref is missing, or
the file doesn't render, the run comments on the spec issue and stops. It
isn't retried, it doesn't count toward the 3-attempt cap, and it never sets
impl:{library}:failed, because every retry would read the same file. Fix the file on the source branch, then dispatch again. A failure of the runner, such as an unreachable origin or a canvas check that can't run, and a failure in a later step, such as the push, the metadata, the upload, or the PR, are retried as usual, and the retry keepssource_refandsource_model.
Each adopted PR still gets an Opus review. At the default pace of 180 seconds,
one bulk-generate.yml run dispatches about 115 pairs before its 6-hour job
timeout, so a library across the whole catalogue needs a lower pace_seconds
or several runs. With specification_id=all, every spec whose file is missing
on the source ref gets an "Adoption Failed" comment. The watchdog's generation
rescues don't carry source_ref or source_model: a pair the watchdog
re-dispatches is generated by Claude.
daily-regen.yml has two modes. The repository variable
REGEN_PAIRS_PER_DAY chooses the mode of the scheduled run:
REGEN_PAIRS_PER_DAY |
Scheduled run |
|---|---|
| Unset or empty | Spec mode, as before: the oldest spec (count, default 1), spec polish, the cross-library similarity audit, then bulk-generate.yml for all 15 libraries |
| A positive integer N | Pair mode: the N oldest implementations across the catalogue, the similarity audit for each spec among them, then one impl-generate.yml dispatch per pair |
| Anything else | The run stops with an error before it picks anything |
The variable changes only the scheduled run. A manual run keeps its inputs and
their meaning, and switches to pair mode only with the pairs input. The
watchdog's cron rescue replaces a missed scheduled run, so it forwards the
variable as pairs. A specification_id always wins over a pair count.
Pair mode picks the oldest pairs and never picks the same pair night after night:
- A pair's age is its newest touch: its metadata
updatedtimestamp (createdwhen there's none), or its last real attempt, whichever is newer. A real attempt is animplementation/{spec-id}/{library}PR that was merged or closed, aregen:keptPR included, or a failed generation, which leaves an<!-- impl-fail:{spec-id}:{library} -->comment on the spec issue. A kept regeneration and a failed generation leaveupdatedalone, so without the attempt record the same pair would come back the next night. - A pair touched within the attempt window is skipped. The window is the
attempt_window_daysinput, else the repository variableREGEN_ATTEMPT_WINDOW_DAYS, else 30 days. After the window, the pair queues behind every pair touched before it. - An infrastructure failure, whose comment carries
<!-- impl-fail-cause:infra -->, and a generation Claude stopped, whose comment carries<!-- impl-paused-claude:… -->, are not attempts: the pair stays at its place in the queue and comes back on a later night. - A pair with an open implementation PR is skipped: its attempt is still running, stuck, or paused.
- Every pair of a spec whose issue saw activity within
min_age_hours(default 20) is skipped for that night, as in spec mode. - Only pairs with a metadata file are candidates, so pair mode never writes a
first implementation. Gap backfill stays with
bulk-generate.yml.
Before the new pairs, pair mode resumes the reviews and repairs that Claude
stopped: the open implementation PRs with ai-review-paused or
ai-repair-paused, oldest first. Each resume counts against N, so a night with
three paused PRs and REGEN_PAIRS_PER_DAY=5 dispatches three resumes and two
new pairs. The job "Re-dispatch paused reviews and repairs" starts
impl-review.yml or impl-repair.yml with the arguments the watchdog's
rescues use, at the pace of the pair dispatches. While the stop lasts, a resume
is refused at its first request, which costs nothing, and pauses again.
The picker fails closed. When the implementation PR listing fails, the run stops with an error, because that listing records the kept, closed, open, and paused PRs. The kept-regeneration and issue listings degrade with a warning. The picker reads the failed-generation comments only for the specs at the front of the queue, at most 100 issues per run, and a spec whose comments can't be read sits out the night. Spec mode keeps its own rule: a spec's age is the newer of its metadata and any activity on its issue, so a kept or failed regeneration moves the spec to the back of the queue, and so does an infrastructure failure or any comment.
Pair mode skips the spec polish, because a spec rewritten for one library
would leave its other implementations written against the old text. It
dispatches each pair straight to impl-generate.yml with the arguments and
pace of bulk-generate.yml (180 seconds between dispatches, 120 seconds for a
pinned sonnet or haiku), and never through bulk-generate.yml: that
workflow's single concurrency group keeps one pending run and cancels the
older one. The regen gate, the review, and the merge are unchanged.
The picks go to the log and the run summary, so a dry run shows them:
gh workflow run daily-regen.yml -f pairs=5 -f dry_run=trueThe dry run still runs the similarity audit, as in spec mode. With
CLAUDE_AUTH=oidc, run Test the spend limit first,
before you set the variable. To turn pair mode on for the schedule, run
gh variable set REGEN_PAIRS_PER_DAY --body 5; to turn it off, run
gh variable delete REGEN_PAIRS_PER_DAY.
When Claude stops a pre-flight session (the spec polish or the similarity audit), the night's dispatch for that spec is skipped and the run fails (see When Claude stops a session). In pair mode, the similarity audit of each spec runs before that spec's pairs are dispatched, one spec after another, so it works as the night's probe: once a pair's generation, review, or repair has reached the limit, every later spec of the night stops at its audit and dispatches nothing. Pairs that were already dispatched fail at their first refused request, which costs nothing, and are parked without a retry or a counted attempt. Their spec keeps its pairs in the queue for a later night. The resumes don't wait for the audit: they start in a job of their own, and while the stop lasts each one pauses again at no cost.
The pipeline chooses two models separately: the generation model, which writes
and repairs an implementation, and the review model, which scores it. The other
pipeline LLM steps use fixed models: spec-create.yml runs on Opus, and the
spec polish and cross-library similarity audit in daily-regen.yml run on
Sonnet.
impl-generate.yml picks the generation model for each (spec, library) pair
and threads it into every repair of that pair's PR:
| Situation | Generation model |
|---|---|
First implementation: the pair has no implementation file on origin/main yet |
Opus |
Regeneration: the pair already has an implementation on origin/main |
Sonnet |
Forced regeneration: a regeneration dispatched with regen_gate=false (see Regen gate) |
Opus |
Adopt mode (source_ref): the file comes from that ref (see Adopt implementations from a branch) |
None generates; the routed model runs repair (Opus, as for a forced regeneration) |
Explicit model input (haiku, sonnet, or opus) |
That model, for every pair |
The routing applies whenever nobody chooses a model: a generate:{library}
label, a bulk-generate.yml or daily-regen.yml run with the default
model=auto, and the watchdog's generation retries. A failed generation
forwards its resolved model to its own retry, so a first run stays on Opus.
The run summary, the issue preview comment, and the PR body show the resolved
model; the preview comment and the summary also show why, for example
opus (first implementation).
Review and repair runs that arrive without a model, such as
impl-review-retry.yml, the watchdog's review and repair rescues, or a manual
gh workflow run impl-review.yml -f pr_number=N, read the **Model:** line
from the PR body, so an explicit pin survives a rescue and still reaches every
repair. impl-review.yml resolves this value only to forward it to repair; it
never chooses the review model. Only a PR without that line is routed again.
If origin/main can't be read, impl-generate.yml fails instead of guessing.
Review and repair log a warning and assume a first run (Opus).
To pin one generation model for a whole run, pass it explicitly. The pin covers generation and repair; the reviews still run on Opus.
gh workflow run bulk-generate.yml -f specification_id=scatter-basic -f library=all -f model=sonnetbulk-generate.yml waits 180 seconds between dispatches for model=auto or
opus, and 120 seconds for sonnet or haiku; pace_seconds overrides
either default. The 120-second default dates from when a pinned run also
reviewed on its own model. Its reviews now run on Opus, so pass
pace_seconds=180 for a large pinned run.
Every quality review runs on Opus, whatever model generated the
implementation. That covers the review of a first implementation, the review
after each repair, and the regen gate's session, which re-scores the live
implementation and reviews the new render in one go. On a keep, that re-score
becomes the live implementation's stored review
(Stored review on a keep). impl-review.yml is the
only pipeline workflow that runs a quality review, and it chooses the review
model itself: no other workflow forwards one. The
retest harness (review-retest.yml) runs the same review
on a frozen set, for measurement only.
To review one PR on another model, for example for an experiment, dispatch
impl-review.yml with review_model:
gh workflow run impl-review.yml -f pr_number=123 -f review_model=sonnetThe pin covers that run and its own auto-retry. A later review of the same PR,
after a repair or a rescue, runs on Opus again. The run summary and the notice
line name both models, for example
review model: opus, generation model: sonnet from PR body.
Opus scores lower than Sonnet: on the 14 fresh core items both scored under
the same rules, it averaged 77.5 against Sonnet's 85.7 (retest runs
36354452853 and 36359464410; a 15th item auto-rejected on Opus).
Most stored scores come from Sonnet or Haiku reviews, so a pair reviewed on
Opus usually stores a lower score than the one it replaces, on a merge and,
through the write-back, on a keep. review.model names the model behind a
stored score.
An alias such as opus points at a newer model after each release, so
impl-review.yml stores what actually ran. The metadata's review block
records the resolved model ID (review.model, for example claude-opus-5),
the rules version the reviewer read (review.criteria_version, the git blob
IDs of prompts/quality-criteria.md, ai-quality-review.md,
default-style-guide.md, and the library prompt), and when the reviewed render
was made (review.rendered_at). These keys aren't synced to the database.
A value the run can't determine is left out, and any value an earlier review
wrote for that key is removed with it. For example, an execution file that
names no model leaves review.model out, never filled with the alias, and the
gate record and notice line show model=n/a.
Every Claude step of the pipeline runs on one of three credentials. The
repository variable CLAUDE_AUTH chooses it for every run:
CLAUDE_AUTH |
Credential | Billed to |
|---|---|---|
Unset, empty, or subscription |
The CLAUDE_CODE_OAUTH_TOKEN secret |
The owner's Claude subscription, as before the switch |
oidc |
Workload identity federation: the job's GitHub OIDC token, exchanged for a short-lived Claude API token. The repository variables CLAUDE_WIF_RULE_ID, CLAUDE_WIF_ORG_ID, CLAUDE_WIF_SERVICE_ACCOUNT_ID, and CLAUDE_WIF_WORKSPACE_ID name the federation. |
The API credits of the federation's workspace |
api |
The ANTHROPIC_API_KEY secret, the fallback when federation isn't available |
The API credits of the key's workspace |
| Anything else | None: the job stops | Nothing |
The value ignores case. The switch covers all 13 Claude steps in the 8
workflows that run Claude: daily-regen.yml, impl-generate.yml,
impl-repair.yml, impl-review.yml, report-validate.yml,
review-retest.yml, spec-create.yml, and util-claude.yml. The models don't
change: the aliases sonnet and opus resolve to the same models on the API.
Every job that runs Claude starts with the step "Resolve Claude credentials".
It turns CLAUDE_AUTH into a mode, and every Claude step reads the step's
outputs, so a new mode changes only that step. Each Claude step gets exactly
one credential and leaves the others empty, because an API key or an OAuth
token silently shadows federation, and the action doesn't document which of
the two wins. The step stops the job with an error in these cases, and never
falls back to the subscription, so a broken switch can't spend the owner's
interactive quota:
CLAUDE_AUTHhas an unknown value.CLAUDE_AUTHisapiand theANTHROPIC_API_KEYsecret is missing.CLAUDE_AUTHisoidcand one of the fourCLAUDE_WIF_*variables is missing or isn't an ID.CLAUDE_AUTHisoidc, the run is one that federation refuses, and theCLAUDE_CODE_OAUTH_TOKENsecret is missing (see Which runs federation accepts).
In spec-create.yml and report-validate.yml, the step runs only for an
issue that reaches Claude. In impl-generate.yml, it runs before the issue
number is known, so a stop never counts against the pair's three attempts.
With an API key, the action also classifies Claude's unconfirmed inline PR
comments with Haiku on that key and drops the ones it reads as test probes.
With the subscription or federation, it posts all of them. Only
util-claude.yml on a pull request writes inline comments.
GitHub mints the OIDC token per job, and its sub claim names the trigger.
The federation rule accepts only the subject
repo:MarkusNeusinger/anyplot:ref:refs/heads/main, so with CLAUDE_AUTH=oidc
the Claude steps of these runs work:
| Workflow | Triggers | Subject |
|---|---|---|
daily-regen.yml |
schedule, workflow_dispatch on main |
…:ref:refs/heads/main |
impl-generate.yml |
issues (a generate: label), workflow_dispatch on main from bulk-generate, daily-regen, the watchdog, and its own retries |
…:ref:refs/heads/main |
impl-review.yml |
repository_dispatch, workflow_dispatch on main |
…:ref:refs/heads/main |
impl-repair.yml |
workflow_dispatch on main |
…:ref:refs/heads/main |
spec-create.yml, report-validate.yml |
issues (a label) |
…:ref:refs/heads/main |
util-claude.yml |
issue_comment, also on a pull request |
…:ref:refs/heads/main |
Federation would refuse the other runs with 401 Authentication failed, so
under oidc they use the subscription on purpose:
util-claude.ymlstarted by apull_request_review_commentor apull_request_review: the subject isrepo:MarkusNeusinger/anyplot:pull_request.- Any workflow dispatched on another branch, such as a branch smoke with
--ref feature/xand the retries it dispatches on that branch: the subject is…:ref:refs/heads/feature/x. review-retest.yml: its review job has noid-tokenpermission on purpose, because without it the action can't mint the Claude GitHub App token, so no session can write to the repository. A retest arm onoidcruns on the subscription.
"Resolve Claude credentials" recognizes these runs by the event name, the
ref, and the missing token request URL. It logs
oidc not available for <event>/<ref>; using subscription as a notice and in
the job summary, then runs the job on the CLAUDE_CODE_OAUTH_TOKEN secret. If
the secret is missing, it stops the job instead.
Every other job that runs Claude already has id-token: write, for the
Claude GitHub App token and, in some of them, Google Cloud. The federation
didn't add the permission to any job.
-
In the Claude Console, open Workload identity, click Connect workload, and select GitHub Actions. The wizard registers GitHub's issuer, creates a service account, and creates the federation rule.
-
Give the rule these values:
- Subject prefix:
repo:MarkusNeusinger/anyplot:ref:refs/heads/main. - Audience:
https://api.anthropic.com, the action's default. - Claims:
repository_owner=MarkusNeusinger,repository=MarkusNeusinger/anyplot, andref=refs/heads/main. - Workspace:
ci-regenonly, with the scopeworkspace:developerand a token lifetime of 600 seconds. The action fetches a fresh GitHub token every 4 minutes and Claude Code exchanges it again, so a session longer than the lifetime keeps running.
- Subject prefix:
-
Remove the
event_name=pushclaim that the wizard fills in. With it, the rule refuses every scheduled, dispatched, and issue-triggered run, which is every run of the pipeline. -
Set a spend limit on the
ci-regenworkspace. -
Store the four IDs as repository variables. They're identifiers, not secrets. The organization ID is on the Organization page; the rule ID (
fdrl_…), the service account ID (svac_…), and the workspace ID (wrkspc_…) belong to the resources the wizard created:gh variable set CLAUDE_WIF_RULE_ID --body fdrl_... gh variable set CLAUDE_WIF_ORG_ID --body 00000000-0000-0000-0000-000000000000 gh variable set CLAUDE_WIF_SERVICE_ACCOUNT_ID --body svac_... gh variable set CLAUDE_WIF_WORKSPACE_ID --body wrkspc_...
-
Switch the pipeline to federation, as described in Switch modes.
A refused exchange always answers 401 Authentication failed. The reason, such
as match_subject_prefix, is in the authentication history on the Workload
identity page.
Switch with one command. Each takes effect with the next job that starts.
- Federation:
gh variable set CLAUDE_AUTH --body oidc - API key: run
gh secret set ANTHROPIC_API_KEYonce, which prompts for the value so the key stays out of your shell history, thengh variable set CLAUDE_AUTH --body api - Subscription:
gh variable set CLAUDE_AUTH --body subscription, orgh variable delete CLAUDE_AUTH
To check a switch, open the next Claude run: the step "Resolve Claude
credentials" logs Claude credentials: <mode>, and the cost line in the job
summary reads auth=<mode>. For an API key, create it in a workspace of its
own with a spend limit.
After every Claude step, the step "Claude session summary" writes one line to the job summary and the log:
claude-cost step=impl-generate auth=api model=claude-sonnet-5-5 total_cost_usd=0.2931
total_cost_usd is Claude Code's own estimate at API list price. On the
subscription it's what the session would have cost on the API. It reads
n/a when the session wrote no result, and the step never fails the job. The
weekly regen review sums these lines (collect_week.py --costs) and shows the
part that ran on API credits. Job logs measured from 2026-10-28 to 2026-10-31
put a generation on Sonnet at about $0.29 per implementation, a review on
Opus at about $1.10, and a repair with its re-review at about $2.70 per
implementation in all.
Two stops end the pipeline's work cleanly instead of counting as a failure of the implementation:
- A Claude spend or usage limit, in every mode. Once the limit is reached, every request is refused until it resets or you raise it, and Claude Code's own retries can't get past it.
- Refused credentials, with
apiandoidconly: an invalid or revoked API key, or a federation exchange that the rule refuses. With the subscription, an authentication error keeps the handling it had before the switch.
For a limit, the pipeline recognizes these texts:
- A spend limit you set on the workspace or the organization (
oidc,api): HTTP 400invalid_request_error, with a message that starts withYou have reached your specified workspace API usage limitsorYou have reached your specified API usage limits. - The usage tier's monthly spend cap: HTTP 429
rate_limit_errorwith theerror_codeenforced_spend_limit_reachedand noretry-afterheader. - An empty credit balance: the API's
Your credit balance is too low, Claude Code'sCredit balance is too loworCredit balance too low · Add funds, and its error typebilling_error. - The subscription's usage limit (
subscription): Claude Code's messages that start with one of the prefixes its SDK exports asUSAGE_LIMIT_ERROR_PREFIXES, such asYou've hit your session limit · resets 12:30pm, and the olderClaude AI usage limit reached.
A transient rate limit, such as a per-minute token limit, is none of these: it keeps its retry and the infrastructure path it took before.
For refused credentials, it recognizes the API's error types
authentication_error and permission_error, HTTP status 401 or 403 on the
failed request, Claude Code's error type authentication_failed, and the
texts Authentication failed and invalid x-api-key. A limit wins over its
status code: a 403 with a usage-limit message counts as a limit.
"Claude session summary" reads these texts only from the error fields of a
session that failed: the error result, Claude Code's error messages, and its
API retries. It never reads the conversation, which may quote the same text,
and never flags a session that ended well. When it finds one, it writes an
::error:: and the line Claude spend/usage limit reached (auth=oidc) or
Claude credentials refused (auth=oidc), with the mode, to the job summary,
and every path treats the stop as an infrastructure failure:
| Workflow | What happens |
|---|---|
impl-generate.yml |
The retry is skipped, and "Stop when Claude stopped the session" fails the job. "Handle generation failure" parks the pair before it counts anything: it removes generate:{library} and impl:{library}:pending, puts impl:{library}:done back for a live implementation, and comments with the marker <!-- impl-paused-claude:{spec-id}:{library} -->. Nothing is retried, no attempt counts toward the cap of three, and the pair is never marked impl:{library}:failed, a first implementation included. No PR exists; the next generation recreates the branch. |
impl-review.yml |
No score, no verdict, and no regen gate decision, even when the session wrote quality_score.txt before it stopped: "Extract quality score" is skipped. "Validate review output" adds ai-review-paused and the "AI Review Paused" comment, without the auto re-review and without spending it. The PR stays open, and nothing is merged or closed. |
impl-repair.yml |
The retry is skipped. "Handle repair failure" restores ai-rejected, adds ai-repair-paused, and comments, without the auto-retry and without spending it. The attempt isn't used up. |
daily-regen.yml |
A spec polish or similarity audit that Claude stopped skips that spec's dispatch, and the entry fails. |
spec-create.yml, util-claude.yml |
The retry is skipped, and the job fails. |
report-validate.yml, review-retest.yml |
The step fails as before: the issue gets the "Validation Failed" comment, and the cell is collected as failed. |
The watchdog skips every PR with ai-review-paused or ai-repair-paused, so
it never spends its one-shot rescue on a stop. In pair mode, daily-regen
resumes the paused PRs first, oldest first (see
Daily regeneration modes), and the resumed review
or repair removes its label when it starts. A resume that pauses again while
the stop lasts puts the label back but posts no second comment when the PR's
latest bot comment is already the same pause. A parked pair keeps its place in
the queue: neither a parked generation nor a paused review or repair is a real
attempt.
In spec mode, or to resume sooner, resume the paused PRs yourself once the limit resets or the credentials are fixed:
-
Rerun every paused review:
gh pr list --label ai-review-paused --json number --jq '.[].number' \ | xargs -I{} gh workflow run impl-review.yml -f pr_number={}
-
Restart every paused repair with the command in its "Repair Paused" comment, which names the PR, the pair, and the attempt.
Before this change, a subscription usage limit counted as the pair's own
failure: it used up the three attempts and marked a first implementation
impl:{library}:failed. A bulk run that reaches the subscription limit now
parks the rest of its pairs instead, and you re-dispatch them once the limit
resets.
Test the stop on the first day of CLAUDE_AUTH=oidc, before you set
REGEN_PAIRS_PER_DAY, so that no nightly run spends the workspace while you
test:
-
In the Claude Console, set the
ci-regenworkspace's spend limit to the month's spend so far plus about 0.50 USD. -
Dispatch one regeneration of a pair whose implementation is live on main:
gh workflow run impl-generate.yml -f specification_id=SPEC_ID -f library=LIBRARY
The generation on Sonnet costs about 0.29 USD and passes; the review on Opus costs about 1.10 USD and reaches the limit while it runs.
-
Check the
impl-review.ymlrun: its summary shows Claude spend/usage limit reached (auth=oidc), the PR hasai-review-pausedand the "AI Review Paused (Claude spend/usage limit reached)" comment, no second review run started, and no verdict orquality:label was added. If the summary line is missing, the run's error text didn't match: read it in the log and extendLIMIT_PATTERN. -
Check the spec issue: no
impl:{library}:failedlabel, and noimpl-failcomment for the pair. -
Check the next watchdog scan: its log reads
PR #PR_NUMBER: paused (Claude stopped its review or repair) — skipped, and it dispatched nothing for the PR. -
Restore the spend limit, then rerun the review:
gh workflow run impl-review.yml -f pr_number=PR_NUMBER
-
Check that the review removes
ai-review-paused, finishes, and the regen gate decides as usual.
To test the generation path as well, set the limit to the spend so far plus about 0.05 USD: the generation stops, the pair is parked as described above, and its retry is skipped.
Each credential reaches only the Claude steps, and nothing else:
tests/unit/workflows/test_claude_auth.py checks that the API key appears only
in the action input and in the resolve step's != '' test, and that no job
gained id-token: write. None of the eight workflows runs on pull_request
or pull_request_target, so a pull request from a fork never gets a
credential. util-claude.yml runs only for comments by owners, members, and
collaborators. spec-create.yml and report-validate.yml run on a label,
which only maintainers can add, but they read issue text that anyone can
write.
Federation adds no stored secret, but the trust lives in the Claude Console, not in the repository:
- Any step of a job with
id-token: writecan mint an OIDC token. The federation rule accepts onlymain, so a token from a pull-request event or another branch is refused, but a step onmaincan exchange its token for a Claude API token whateverCLAUDE_AUTHsays. - Switching
CLAUDE_AUTHback tosubscriptionorapidoesn't close that path. While you don't useoidc, disable the federation rule on the Workload identity page. - The rule's claims name the repository and the ref, not the workflow. GitHub
also signs the
job_workflow_refclaim, the workflow file the job runs from, so adding it to the rule could narrow federation to the workflows that run Claude. This is unverified: check that the Console accepts the claim before you rely on it. - The steps that run model-written code in a job with
id-token: writeunsetACTIONS_ID_TOKEN_REQUEST_URLandACTIONS_ID_TOKEN_REQUEST_TOKENfirst, so that code can't mint a token: "Adopt implementation from source ref" and "Adaptability smoke run (record-only)" inimpl-generate.yml, and "Adaptability smoke run (record-only)" inimpl-repair.yml. The adaptability check inimpl-review.ymlreads the code without running it. The switch doesn't change what a Claude session's own tools can reach.
A leaked federated token expires after 600 seconds, and an API key is billed until you revoke it; the workspace's spend limit bounds either.
The agentic workflows use abstract model tiers (small, medium, large) instead of CLI-specific model names. This allows the same command to work across different AI tools.
| Tier | Purpose | Claude | Copilot | Gemini |
|---|---|---|---|---|
| small | Fast/cheap tasks | haiku | gpt-4o-mini | gemini-2.0-flash |
| medium | Balanced tasks | sonnet | gpt-4o | gemini-2.0-flash-thinking |
| large | Complex tasks | opus | o1 | gemini-2.5-pro |
# Use large tier (default for plan_build)
uv run agentic/workflows/plan_build.py "Add feature X" --model large
# Use medium tier with Copilot
uv run agentic/workflows/prompt.py "Quick fix" --model medium --cli copilotOverride the default model for any tier via environment variables:
# Linux/WSL
export CLI_MODEL_CLAUDE_LARGE=claude-opus-4-7
export CLI_MODEL_COPILOT_MEDIUM=gpt-4-turbo
# Windows PowerShell
$env:CLI_MODEL_CLAUDE_LARGE = "claude-opus-4-7"
$env:CLI_MODEL_COPILOT_MEDIUM = "gpt-4-turbo"All environment variable names follow the pattern: CLI_MODEL_{CLI}_{TIER}