Skip to content

feat(agents): R (ggplot2) code profile for the agent chat - #12443

Merged
MarkusNeusinger merged 13 commits into
mainfrom
claude/r-agent-profile
Oct 29, 2026
Merged

MarkusNeusinger merged 13 commits into
mainfrom
claude/r-agent-profile

Conversation

@MarkusNeusinger

Copy link
Copy Markdown
Owner

Summary

  • Adds the R (ggplot2) code profile in agents/anyplot/code/profiles/r/: tree-sitter parsing (tree-sitter 0.26.0, tree-sitter-language-pack 0.13.0, pinned in the agents extra), the protected regions, the normaliser, the SECURITY and ADAPTATION validator, the readiness scan, the readr loader with column kinds (data/kinds.py) and R's error grammar. Step 2c of the all-libraries plan, PR 3a. It is not served yet: RRuntime, the registry entry, the prompts and the eval fixtures come in PR 3b.
  • Follows the owner decision of 2026-10-29 for every language: a missing # Data marker makes a file coupled, never blocked. On the 325 catalogue files the split is 4 clean, 306 coupled and 15 blocked (the 13 map specs, qrcode-basic, and maze-circular for its <<-). Other owner decisions: the catalogue title is blanked to "" as in Python, the canvas fix rewrites only the save's dpi (ragg truncates, which was measured and is modelled), and only gridExtra::arrangeGrob is allowed.
  • code/edits.py gets a language-neutral core with a Dialect per language, and code/export.py takes the run line as a parameter. Python's behaviour is unchanged and its tests are untouched. agents/Dockerfile keeps only the R grammar of the language pack (0.6 MB of 184 MB).

Plan

agentic/specs/261024-agent-all-libraries.md, step 2c ("What PR 3a built"). The "readiness blocks a file without the marker" text is marked superseded.

Security

The sandbox (gVisor) and the exec filter (execdeny, proven by a preflight before user code) are the boundary. The static validator is hygiene for the exported plot.R.

Before this PR opened, Fable reviewed the validator (verdict: sound, no high findings) and Gemini 3.8 Flash checked the diff. Every confirmed finding is fixed in this branch:

  • A rebind on the same line. THEME <- ...; THEME <- "dark" passed. Bindings are now counted by node, and every protected statement owns its lines.
  • A wider data-mask residual. Fable's two routes are closed: with/within are no longer masks, and immediately invoked function literals, subset and $ callees, calls of a function-literal parameter, unknown call-result callees, and do.call as a value are refused. None of these refusals hits a catalogue file.
  • Two false positives. transform = scales::transform_log() and do.call("rbind", ...) were refused and are now accepted.
  • Tidiness. textConnection and rawConnection are denied names.

A denied name is refused where it can run: as a callee, as a function-valued argument, or as a value outside a data mask. A user column called source, file or load therefore passes. The accepted residual is documented in the validator's module docstring.

Notes for PR 3b

  • Dataset packages. gapminder and palmerpenguins are an ADAPTATION finding (dataset-package), but pipeline.py's BLOCKING_RULES does not refuse them yet.
  • Large integers. read_kinds() counts integer cells beyond ±2^53, which R reads inexactly, so the session can announce them.
  • Live render. The first live render should confirm the readr types end to end. They were already checked in the toolchain image: True/False, microseconds, offsets to UTC, and text NA/001.
  • Known issue. 67 files size the title font with nchar(title). With the blank "" title they size it for an empty string until the adapter writes a title. No workaround was added.
  • Upstream bug. tree-sitter 0.26.0 on Python 3.14 segfaults when Point.row/.column is read above 256. The code uses tuple indexing, and a test guards it.

Test plan

  • uv run ruff check . and uv run ruff format --check .
  • uv run --extra typecheck --extra agents mypy api core agents: no issues in 166 files.
  • uv run pytest tests/unit tests/integration -n 6: 19,223 passed, 27 skipped.
  • Corpus tests over all 325 plots/*/implementations/r/ggplot2.R: every file parses with no ERROR node, the normaliser is idempotent, floor(inches * dpi) hits the target in both dimensions, and the readiness split asserts the exact counts.
  • The named bypasses from the brief, Fable and Gemini are validator tests.
  • uv run python -m tools.changelog check --base origin/main and uv lock --check.
  • The agents/Dockerfile builder stage was built locally: only r.abi3.so is left and the grammar import check passes.
  • CI green, then the Copilot review.

MarkusNeusinger and others added 12 commits October 29, 2026 09:51
Pin tree-sitter 0.26.0 and tree-sitter-language-pack 0.13.0 in the agents
extra (owner decision of 2026-10-29): 0.13.0 bundles its grammars, the R
one is r-lib's (MIT), while 1.x downloads them at run time.

agents/anyplot/code/treesitter.py parses once and maps tree-sitter's byte
positions to the lines and character offsets the edit applier uses, lists
ERROR and MISSING nodes as syntax problems, walks without recursion, and
holds the R helpers: string decoding with escapes and raw strings, backtick
names, callees, arguments with positions, assignments with the name they
bind. It imports only the standard library and tree-sitter, so the R
detection built on it can move to core/ later.

The corpus test parses all 325 ggplot2 catalogue files with no ERROR or
MISSING node. Nesting past tree-sitter's depth of about 1,024 levels is a
syntax problem, so such a file fails closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
agents/anyplot/code/profiles/r/regions.py finds the ggplot2 conventions:
the exact theme statement (its default pinned to "light"), theme tokens
built from literals and earlier tokens (1,322 in the catalogue), the
Imprint palette, the protected save (the last top-level ggsave writing
sprintf("plot-%s.png", THEME) through ragg::agg_png), the Data and Plot
markers matched loosely (247 files carry both), column constants with a
role comment (4 files) and the placeholder df <- load_user_data(). It
imports only stdlib, tree-sitter, core.* and treesitter.py, so it can move
to core/ later; a test pins that boundary.

agents/anyplot/code/profiles/r/normalise.py strips the roxygen header,
inlines logistic-regression's bound-once output_file, blanks the catalogue
title with the Python profile's rule (now public as
normalise.is_catalogue_title; Python's output is unchanged) and notes the
five paste0 splits and the sprintf format, and rewrites only the protected
save's dpi so 34 saves (4800x2700, 2700x2700, 3600x3600) land on 3200x1800
or 2400x2400 with the layout in inches unchanged. The dpi is the shortest
decimal for which inches * dpi lies in [t, t + 0.5), checked in R's
floating point, so the canvas is exact whether ragg truncates or rounds.
All 325 catalogue files normalise idempotently onto a target canvas.

tree-sitter 0.26.0's Point.row and Point.column free an int past 256 under
Python 3.14 (a segfault at the next garbage collection, found on the
catalogue's line 271), so the modules index the point instead and a test
refuses the attribute form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
agents/anyplot/code/profiles/r/validate.py walks the tree-sitter tree with
the rules of the design pass of 2026-10-29, reusing the Python profile's
Finding and rule ids. SECURITY: denied names as identifiers anywhere and
as strings equal to them (the design's list plus file, editor and
environment routes, without words common as column names such as dir or
browser); library() and pkg:: only on the owner's allowlist with
per-package rules (utils::head, tools::toTitleCase,
gridExtra::arrangeGrob, ragg::agg_png only as the save's device); ':::'
refused; string and parenthesised callees refused; do.call only with a
bare rbind, cbind or c; function-valued arguments only literal functions,
lambdas, literals, names bound only to functions or factories over them;
!! and !!! only on column constants, := and glue names checked; facet
and formula strings parsed and walked as code; file = refused except
pdf(NULL); <<- and ->> refused; the protected save pinned and THEME,
ggsave, sprintf, Sys.getenv and the tokens bound only where they belong.
ADAPTATION: placeholder count and use, random draws (runif, sample,
set.seed and jitter stay), literal data (c() over 20 numbers, data(),
built-in datasets) and the palette prefix.

The named bypasses of the design pass are tests. Over the 325 normalised
catalogue files SECURITY flags 15: 4 map specs, qrcode-basic, the dataset
packages of dendrogram-basic and scatter-categorical, maze-circular's <<-
and 7 files whose flow data use a column or variable called source. The
Python validator is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
agents/anyplot/code/profiles/r/coupling.py holds the R detection that is
meant to move to core/ (stdlib, tree-sitter, core.*, treesitter and the R
regions only, pinned by a test): the structural reasons (syntax, no-theme,
savefig-target) and the coupling categories data-block, synthetic-data,
limits, color-scale, annotations, statistics and palette. A file is clean
with both markers and role-commented column constants and nothing outside
its Data block; synthetic data inside a complete block is the block the
adapter swaps, and a bare set.seed beside it couples nothing.

profiles/r/readiness.py adds the SECURITY validator and the wording, with
the Python scan's Readiness result and reason format: blocked only for real
reasons (map spec, library disabled, syntax, security, no theme, save), a
missing marker never blocks (owner decision of 2026-10-29). The Python
scan's formatting helpers became public (snippet_of, clip_reason,
format_hint) so both share them; its output is unchanged.

Over the 325 normalised catalogue files: 4 clean (area-basic, box-basic,
line-basic, line-timeseries), 297 coupled, 24 blocked (13 map specs,
qrcode-basic, 7 files with a 'source' column, the gapminder and
palmerpenguins dataset packages, maze-circular's <<-).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
… line and R error grammar

code/edits.py becomes one core for matching, overlap, drift and restore with
a Dialect per language (dialect_for): Python reads with ast and compares
ast.dump as before; R reads with tree-sitter, protects THEME, the tokens,
df <- load_user_data() and the final ggsave, compares the token stream with
decoded strings, counts R bindings (any arrow, replacement forms,
parameters, for variables) and lets the whitespace tier read backticks,
strings that span lines and raw strings. The Python messages and results are
byte-identical (test_edits.py unchanged).

data/kinds.py derives the value kinds a typed reader needs from the
canonical data.csv: integer or big-integer (past 32 bits), number, boolean,
text, date (every cell a plain ISO date) or datetime. profiles/r/loader.py
replaces the placeholder with readr::read_csv typed from them (col_integer,
col_double, col_logical, col_character, col_date, col_datetime in UTC),
na = "", one thread, no progress, eager. The Python loader is untouched.

code/export.py takes the run command (python plot.py or Rscript plot.R).
profiles/r/errors.py reads R's stderr: the erroring function of base R's
and rlang's error lines, the chained cause and ggplot2's layer ordinal, the
last three Calls: names, and fixed texts for memory, recursion and a
missing package; never the message.

Plus the changelog fragment for the R profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
ancestors() and field_of() walked Node.parent, which visits() replaced
with one pass that carries the parent and field along; nothing calls them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
…ose the formula gap

Owner decisions of 2026-10-29 on the first pass's findings:

- A string equal to a denied name is refused only where a string becomes a
  function: as a callee, as a function-valued argument (named, or the
  positional slot of the apply family, Map/Reduce/Filter, outer, the purrr
  map family, across) and inside a parsed code string. Everywhere else it
  is data, so a user's column called source, file, load, options or body
  passes.
- A bare denied name is refused as a callee and as a function-valued
  argument everywhere, and as any other value outside a data mask. Inside
  the arguments of the data-masking and tidy-select calls (DATA_MASK_CALLS:
  aes, vars, the facets, the dplyr and tidyr verbs, with/subset) and in the
  operands of ~ it is a column. The accepted residual is written down: a
  function object can be stored in a column inside a mask and called
  through a lambda's parameter; the sandbox and the exec filter are the
  boundary.
- gapminder and palmerpenguins pass SECURITY and become the ADAPTATION
  finding dataset-package, a synthetic-data hint in the readiness scan.
- A formula position (formula = and the first positional of lm, glm, nls,
  loess and the other FORMULA_FIRST models) takes only a ~ literal, a
  string literal or a name bound once to one; the formula-or-data
  functions (aggregate, the *.test functions, prcomp) refuse a string built
  at run time (paste0, sprintf, format, glue, ...).

The 7 flow files and the 2 dataset-package files are coupled now: 4 clean,
306 coupled, 15 blocked (13 map specs, qrcode-basic, maze-circular).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Measured in the toolchain image (R 4.6.1, readr, ragg) on 2026-10-29: ragg
truncates (13.333 in at 240 dpi gives 3199 px), so the normaliser plans
the dpi for t <= inches * dpi < t + 1 and measures floor(inches * dpi). The
corpus test computes floor(inches * dpi) from every rewritten save's own
literals and asserts the target in both dimensions.

col_double() reads 9007199254740993 as ...992, so data/kinds.py gains
read_kinds() with ColumnKinds.inexact: per column, the integer cells
beyond +-2^53, for PR 3b to announce under the owner's data-fidelity
rule. column_kinds() stays as before. The loader's docstring records the
other measurements (True/False, microseconds, offsets to UTC, text NA and
001, empty cells).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
…ents image

tree-sitter-language-pack 0.13.0 bundles about 170 compiled grammars (184
MB installed). The agents image, the only one that installs the agents
extra (the renderer installs the lib-* extras), now deletes every grammar
but TREE_SITTER_GRAMMARS (R) after uv sync and fails the build when a kept
grammar no longer loads: 0.6 MB left. A test ties the list to GRAMMARS in
treesitter.py, for every Dockerfile that installs the extra.

Verified by building the builder stage locally (r.abi3.so alone, the
import check passes) and with hadolint 2.15.1 at the warning threshold
(no new finding).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
…changelog

The spec's step 2c records what PR 3a built and the owner decisions of
2026-10-29, and marks the plan's "the readiness scan blocks a file without
the marker" superseded: in every language a missing marker means coupled,
never blocked, so no language step waits for the Data-block regenerations.
It also names what 3b must wire: dataset-package is an ADAPTATION finding,
which the pipeline treats as a defect line, so the R runtime's blocking set
needs it for the working form to be refused.

docs/concepts/agent-network.md gains a short R-profile paragraph under
"Adapt and validate", and the changelog fragment carries the narrowed
denied-name rule, ragg's truncation, the 2^53 count and the pruned image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
…-function calls

Review of a12dd3e (Fable on security, Gemini 3.8 Flash on the diff):

- THEME, the tokens and the save's names were checked by line, so
  'THEME <- Sys.getenv("ANYPLOT_THEME", "light"); THEME <- "dark"' passed.
  The R regions now carry each statement's exact span, the validator
  counts a binding only when it is that statement, and every protected
  statement (theme, tokens, palette, placeholder, save) must have its lines
  to itself, which also closes 'df <- load_user_data(); df <- iris'.
- The data-mask residual was wider than written. with and within are no
  mask any more, and the calls that would run a function held as a value
  are refused, after measuring the catalogue: a function literal called at
  once (0 uses), an index or field as callee (1, plotmath in expression(),
  exempt), a call to a parameter of an enclosing function literal (0),
  do.call as a value, and a call's result unless it is a builder
  (CALLABLE_FACTORIES; the 12 catalogue uses are colorRampPalette and
  scales labels). .mapply, forceAndCall, nlm and nlminb gained function
  slots. The residual paragraph now says what still passes.
- transform = and inverse = accept scales' transformations
  (transform_*(), *_trans(), trans_new()), as labeller = its builders.
- do.call also takes "rbind", "cbind", "c" as strings and paste, paste0.
- textConnection and rawConnection are denied names.

The split is unchanged: 4 clean, 306 coupled, 15 blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
… coupling scan

coupling.py flagged 'iris <- df[...]' and later uses of iris as synthetic
data, while the validator skips names the file binds; both read it as the
file's own variable now (Gemini review of 2026-10-29). The spec's step 2c
records the review's fixes and the remaining residual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Copilot AI lite review requested due to automatic review settings October 29, 2026 10:56
@codecov

codecov Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human review is warranted because the change combines a security-sensitive R validator and grammar-pruned image path with deferred runtime and sandbox integration that cannot be verified end to end here.

No open issues 🎉

What changed in this PR

This pull request adds the deterministic R/ggplot2 code-profile foundation for the agent chat’s future “use with my data” flow. Runtime and registry integration remain deferred to a follow-up PR.

Changes:

  • Adds R-specific parsing, protected regions, normalization, validation support, readiness scanning, data loading, error handling, editing, and export infrastructure.
  • Generalizes shared editing and export helpers while preserving Python behavior.
  • Adds pinned tree-sitter dependencies, Docker grammar pruning, corpus tests, documentation, and changelog updates.
File Description
uv.lock Locks tree-sitter and language-pack dependencies.
tests/​unit/​agents/​code/​test_treesitter.py Tests parsing, positions, R literals, assignments, grammar constraints, and catalogue parsing.
tests/​unit/​agents/​code/​test_profile_r_regions.py Tests R protected regions, markers, theme tokens, saves, and columns.
tests/​unit/​agents/​code/​test_profile_r_readiness.py Tests readiness classification, coupling hints, and corpus counts.
tests/​unit/​agents/​code/​test_profile_r_normalise.py Tests R header, title, save-target, and canvas normalization.
tests/​unit/​agents/​code/​test_profile_r_loader.py Tests typed readr loader generation and column-kind detection.
tests/​unit/​agents/​code/​test_profile_r_errors.py Tests safe R error summaries and resource-error classification.
tests/​unit/​agents/​code/​test_profile_r_edits.py Tests R dialect editing, protected drift, restoration, and export behavior.
tests/​unit/​agents/​code/​conftest.py Adds R catalogue fixtures and corpus inputs.
pyproject.toml Adds pinned tree-sitter dependencies to the agents extra.
docs/​concepts/​agent-network.md Documents the staged R profile and its security and readiness behavior.
changelog.d/​r-agent-profile.md Adds the changelog fragment for the R profile foundation.
agents/​Dockerfile Prunes the language-pack image to the grammars used by active profiles.
agents/​anyplot/​data/​kinds.py Derives R-specific column kinds and large-integer precision warnings.
agents/​anyplot/​code/​readiness.py Shares readiness formatting helpers with language profiles.
agents/​anyplot/​code/​profiles/​r/​regions.py Detects R theme, palette, marker, placeholder, column, and save regions.
agents/​anyplot/​code/​profiles/​r/​readiness.py Classifies R files as blocked, coupled, or clean.
agents/​anyplot/​code/​profiles/​r/​normalise.py Normalizes R catalogue headers, titles, save targets, and canvas DPI.
agents/​anyplot/​code/​profiles/​r/​loader.py Generates typed readr::read_csv loader code.
agents/​anyplot/​code/​profiles/​r/​errors.py Summarizes R stderr without echoing user data.
agents/​anyplot/​code/​profiles/​r/​coupling.py Detects data, synthetic-data, scale, annotation, statistic, and palette coupling.
agents/​anyplot/​code/​profiles/​r/​__init__.py Defines the staged R profile package.
agents/​anyplot/​code/​profiles/​__init__.py Documents the non-Python code-profile package.
agents/​anyplot/​code/​normalise.py Exposes the shared catalogue-title predicate for R normalization.
agents/​anyplot/​code/​export.py Adds language-specific export run commands, including Rscript plot.R.
agentic/​specs/​261024-agent-all-libraries.md Records the R profile scope, implementation plan, and owner decisions.

Review effort: Lite

@MarkusNeusinger
MarkusNeusinger merged commit 51ceaac into main Oct 29, 2026
16 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the claude/r-agent-profile branch October 29, 2026 11:23
MarkusNeusinger added a commit that referenced this pull request Oct 29, 2026
## Summary
- `RRuntime` (`agents/anyplot/render/runtimes/r.py`) serves ggplot2
through the runtime registry on top of PR 3a's R profile (#12443).
  - The registry dispatches on the library's language.
- The contract gains the column kinds for the loader, per-runtime
blocking rules (R adds `dataset-package`, so a working form that keeps
gapminder or palmerpenguins is refused) and adaptation targets.
- The R row runs in exec mode under the exec filter, with a repair
reserve of 15 s.
- `agents/cloudbuild.yaml` adds ggplot2 to the admin deploy's
`_LIBRARIES`; the 80 % eval matrix still gates the public release.
- **Prompts.**
- `adapter.md` is split into shared rules with slots, filled by
`adapter_python.md` and `adapter_r.md` (and likewise
`adapter_highlights.md`). Python's assembled prompts are pinned
byte-identical by SHA-256.
- Matplotlib/probe-only lines moved into Python's slots; R's say an R
render has no probe.
- The scope judge and the fixed `other_library` reply (11 languages)
were stale since 2026-10-25: they still said only matplotlib and
seaborn. They now name the served libraries from `AGENT_LIBRARIES`
through a `{{libraries}}` slot, so a request for this plot in a served
library is in scope.
- **Data fidelity** (owner decision of 2026-10-29): when R reads integer
cells beyond ±2^53 as the nearest double, the version's "what changed"
list names each column with its count, and the plot stays `ok`.
- **Evals:** 60 ggplot2 fixtures for the 10 matrix specs × 6
perturbations, plus the smoke case `scatter-basic-ggplot2-renamed`.
Eligibility and the dev fixture find files by the library's language.
ggplot2: 310 of 325 eligible (4 clean, 306 coupled, 15 blocked).
- **Frontend fix:** a result card highlighted every version in the
page's language, so after a library switch an older matplotlib version
showed as R or the other way round. It now uses the version's own
library.

## Plan
`agentic/specs/261024-agent-all-libraries.md`, step 2c ("What PR 3b
built").

## Test plan
- [x] `uv run ruff check .` and `uv run ruff format --check .`
- [x] `uv run --extra typecheck --extra agents mypy api core agents`: no
issues in 167 files.
- [x] `uv run pytest tests/unit tests/integration -n 6`: 19,273 passed,
27 skipped.
- [x] App: `yarn lint`, `fm:check`, `type-check`, `test` and `yarn
build` on Node 24.
- [x] Browser check with the BFF mock
(`AGENT_LIBRARIES=matplotlib,ggplot2`): after a plot in matplotlib, a
switch to ggplot2 and a second plot, version 1 shows `language-python`
and version 2 `language-r`. The only console errors are the known font
CORS errors on port 3002.
- [x] Local render without a model: an identity adaptation of
`line-basic-ggplot2-renamed` through `LocalDockerBackend` on the
renderer image gives 3200x1800 in both themes, R1–R3 pass, and
`exec_deny: on`.
- [x] Gemini 3.8 Flash checked the diff before the PR. All three
findings were checked and none needed a change:
- the heredoc in `ci-image.yml`: the YAML block strips the indentation,
and `bash -n` passes on the extracted step;
  - the `refusals()` cache: process-wide, like `get_settings`;
  - the note length: bounded by the 64-character column cap.
- [ ] `.github/workflows/ci-image.yml`: the new agents-image step (the R
grammar loads, an R readiness scan runs in the built image) has no local
loop; its first real run is this PR's CI.
- [ ] After merge and deploy, two owner steps: the ggplot2 eval matrix
on Vertex, then the first live admin test, which confirms the readr
types end to end.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants