Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Quickstart

ago is a semantic edit protocol for Go: agents query the typechecked workspace and submit compiler-checked mutations instead of editing text.

Go as a language for agents. The protocol puts the Go toolchain between the model and the files: an edit that does not typecheck cannot reach disk. LSP-backed tools have the same compiler diagnostics available and can still write broken code, because checking is advisory there. Here invalid edits are unrepresentable, not merely checked.

The thesis under test: weak local models become effective repo-scale Go editors when raw file editing is replaced by semantic queries and validated mutations with typed rejections. The bench in this repo measures exactly that, on tasks mined from real commits in traefik, vault, and boundary.

Install

brew install guygrigsby/tap/ago
curl -fsSL https://ago.aeryx.ai/install.sh | sh
go install github.com/guygrigsby/agent-go/cmd/ago@latest

Windows: grab the zip from the releases page. Every channel ships the agent skill in the binary; ago skill install drops it into ~/.claude/skills for shell-capable coding agents. In an existing repo, ago init wires the agent files without touching the module: AGENTS.md (read natively by Codex, Copilot, Cursor, Windsurf, Zed, Claude Code, and the rest of the AGENTS.md ecosystem), MCP wiring, and a GEMINI.md pointer for the one holdout.

Or from a clone:

go build -o ago ./cmd/ago

Requires Go 1.26+. The daemon auto-spawns on first use, one per workspace, and exits after five minutes idle; there is nothing to start or configure.

How it works

One binary, three fronts over a per-workspace daemon (auto-spawned on first use, unix socket, idle exit):

$ ago --help
semantic edit protocol for Go: query the typechecked workspace, submit compiler-checked mutations

Usage:
  ago [command]

reads and setup:
  help        the versioned op catalog: args, examples, ceilings
  init        scaffold an agent-first module: MCP wiring, AGENTS.md
  inspect     kind, signature, position, doc for one symbol
  query       semantic questions by --kind (callers, implementations, ...)
  refs        every reference, tests included
  search      name fragment to exact symbol addresses
  status      load or refresh the snapshot: packages, files, errors
  view        declaration as annotated text with node handles

mutations (validated before anything touches disk):
  add-param   add a parameter, call sites updated with --default
  patch       ordered multi-op edit, atomic and generation-checked
  rename      rename a symbol, every reference proven to resolve
  set-body    replace a function body, typechecked first
  test        go test, scoped, structured pass/fail
  upsert      add or replace a whole declaration

lifecycle:
  daemon      run the workspace daemon in the foreground
  mcp         serve the MCP tools over stdio
  skill       install or print the embedded agent skill

Additional Commands:
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command

Flags:
  -C, --dir string   workspace directory (default ".")
  -h, --help         help for ago

Use "ago [command] --help" for more information about a command.

A few invocations:

ago search -s MaxEntries
ago refs -p <pkg> -s <sym>
ago query -k implementations -p <pkg> -s <iface>
ago rename -p <pkg> -s DefaultLimiterMaxEntries --to DefaultLimiterMaxQuotas
echo 'return v << 1' | ago set-body -p <pkg> -s Double --body-file -
ago patch --body-file patch.json

Agents connect over MCP (ago mcp); ago init writes the wiring. For agents with a shell (Claude Code and kin), ago skill install teaches the CLI workflow in any Go repo with no per-repo setup; the skill ships embedded in the binary. patch is the full language: 14 statement ops, composable decl ops, table-driven test ops; rename/set-body/add-param/upsert are one-op sugar over it. Full catalog via ago help.

Every mutation validates before anything touches disk: the daemon re-typechecks only the affected packages against its in-memory graph (~200ms for a 29-reference rename on a 533-package repo) and rejects with the compiler’s own diagnostics:

{
  "status": "rejected",
  "reason": "edit does not typecheck",
  "diagnostics": [
    {"pos": "config.go:114:29", "msg": "undefined: slices"},
    {"pos": "config.go:9:2", "msg": "\"reflect\" imported and not used"}
  ]
}

Rejections are data, not errors: the agent loop is query, mutate, and on rejection adjust and retry. Most rejections carry possible_repairs, complete paste-ready calls: the corrected mutation, the re-view that refreshes handles, the search that locates an undefined identifier. Resending a rejected call unchanged gets an escalated rejection instead of the same one forever. Rename also proves every rewritten reference still resolves to the renamed symbol, so shadowing capture is rejected even when the compiler is satisfied.

Bench

bench/ holds the raw-vs-semantic comparison: same local model, same harness (opencode), same mined tasks (rename and add-param kinds, from traefik, vault, boundary, and cobra); one mode gets shell and file editing, the other gets only the protocol.

The oracle is the bench’s third arm: a scripted replay of each task’s ground-truth commit through the protocol itself, no model in the loop. Every mined task carries the real change its source commit made; the oracle submits that change as protocol calls and must reach green. A task enters the roster only after the oracle certifies it, so “the model failed” is never confused with “the task was impossible”. Oracle rejections are findings (an engine bug, a bad extraction, or a named protocol ceiling), its wall time is the task’s time-to-green floor, and its accepted transcripts seed the fine-tuning corpus.

Scoring is goal predicate + typecheck + scoped tests under a time cap; the tests gate counts only where a pristine worktree passes the same tests. Every episode records its transcript, config, diff, score, token usage, failure kind, and the daemon’s per-request log under bench/results/. Serving setups are pinned as named profiles in bench/profiles.json and embedded in each run’s run.json.

# smoke: one certified task per kind, smallest repo, for first contact
AGO_BENCH_SUITE=smoke AGO_BENCH_PROFILE=<name> AGO_BENCH_SCRATCH=<clones dir> \
go test ./bench -bench Rename -benchtime 1x -timeout 0

# model round, k=3
AGO_BENCH_PROFILE=glm-flash AGO_BENCH_SCRATCH=<clones dir> \
go test ./bench -bench Rename -benchtime 3x -timeout 0

# oracle sweep: no model, episodes run in parallel
AGO_BENCH_MODES=oracle AGO_BENCH_SCRATCH=<clones dir> \
go test ./bench -run OracleSweep -parallel 20 -timeout 0

# mine candidate tasks from any clone; report across runs
go run ./cmd/bench mine -scratch <clones dir> <repo>
go run ./cmd/bench report bench/results/<run> ...

Development

go test ./...        # full suite; snapshot tests take ~40s
gofmt -l .           # must be empty

Rules of the road:

  • TDD: write the failing test first, watch it fail, then implement. Every op and repair in the tree landed that way.
  • Docs are drift-guarded by tests: the op catalog must match docs/specs/surface.md, help examples must be accepted against the test fixture, and README invocations must match the real CLI dispatch. Change behavior and the guard tells you which doc to touch.
  • The demo fixture (internal/snapshot/testdata/demo) is copied per test; never mutate it in place. demo/lib is frozen behind a recorded hash (TestDemoLibFixtureFrozen) because the view-handle tests depend on its exact layout. demo/sig is the place for new fixture shapes.
  • Race-sensitive changes (the parallel retypecheck) should run under go test -race ./internal/snapshot.

Docs

  • docs/specs/language.md: the full op catalog, decl through test ops
  • docs/specs/surface.md: shipped and foreseen calls, drift-guarded by test
  • docs/specs/protocol.md: protocol semantics and guarantees
  • docs/specs/bench.md: bench design
  • docs/specs/plan.md: status and build order
  • docs/optimizations/: per-model serving research and the cross-model build list
  • docs/tenets.md: the engineering principles this repo lives by
  • docs/model-strategies.md: brainstorm on coding with local models
  • docs/adr/: architecture decisions
  • idea.md: the original thesis

Work is tracked in beads: bd ready for the queue, bd list for everything; issues sync through .beads/issues.jsonl.

Experimental. Interfaces change without notice.

Inspired by zero, a programming language for agents.

Why we chose Go

The thesis: weak local models become effective repo-scale editors when raw file editing is replaced by semantic queries and validated mutations with typed rejections. Picking the target language came down to two questions. Which language is easiest for a small model to reason about, and which toolchain exposes enough semantic machinery to build the protocol without writing a compiler?

Go won the first question. C# won the second. Go got the nod because the agent’s job has to be easy; ours only has to be possible.

Why Go

Go removes most of the ambiguity that trips up local models:

  • small grammar
  • very little syntactic variation
  • explicit imports
  • explicit error flow
  • no inheritance hierarchy
  • limited metaprogramming
  • structural interfaces
  • standardized formatting
  • fast compilation and tests
  • predictable repository layout

That matters more than people give it credit for. A 7B or 14B model has less language surface to understand and fewer plausible-but-wrong ways to express a change.

More importantly, Go exposes most of the analysis stack as ordinary libraries:

go/parser       syntax
go/ast          AST
go/types        type checking and symbol identity
go/packages     whole-program/package loading
go/ssa          SSA and call-oriented analysis
go/analysis     modular analyzers
gopls           references, rename, fixes and refactorings

go/packages loads and type-checks complete programs. go/ssa provides an analysis-oriented intermediate representation. gopls already performs structured transformations: rename, extraction, inlining, code repair, formatting. Nobody had to invent the semantic substrate; the work was putting a better agent protocol over it.

The shape we wanted

Not an MCP server with twenty vague tools. More like a small database language:

inspect symbol "github.com/acme/store.(*Store).Put"
find callers of symbol_id="..."
find implementations of interface_id="..."
find writes to field_id="..."
find paths from handler_id="..." to effect="network"

And tightly scoped mutations:

rename_symbol
add_parameter
replace_call
implement_interface
extract_function
move_declaration
add_struct_field
wrap_error
add_test_case

Every mutation returns structured data:

{
  "status": "rejected",
  "reason": "interface implementation would become incomplete",
  "affected_symbols": ["..."],
  "diagnostics": ["..."],
  "possible_repairs": ["add_method", "change_interface"]
}

That is where a smaller model wins. It chooses from bounded operations instead of synthesizing arbitrary patches.

What we passed on

Rust

rust-analyzer is an excellent semantic engine: query-driven incremental analysis, separate syntax and semantic representations. Philosophically it is close to what we wanted.

The language itself asks a lot of a weak model:

  • traits and associated types
  • lifetime relationships
  • coercions and autoderef
  • macros
  • feature-gated compilation
  • conditional compilation
  • complex generics
  • borrow-checker-driven repairs

The compiler can tell an agent it is wrong, but choosing the right repair often takes more reasoning than the equivalent Go. Rust could be made very agent-friendly, and rust-analyzer is probably the right foundation for whoever takes that on. For a proof of concept, too much of our time would have gone to interfacing with compiler internals that were never designed as a public surface, and to interpreting diagnostics written for a human reader.

C#

Roslyn is almost comically well suited to this kind of project. Its workspace model already exposes:

  • complete solutions and projects
  • source text
  • immutable syntax trees
  • semantic models
  • compilations
  • symbol identities
  • analyzers
  • code fixes
  • refactorings

The API is designed for programmatic inspection and transformation across a whole solution, and a convincing demo would probably have landed here fastest:

var symbol = semanticModel.GetDeclaredSymbol(node);
var references = await SymbolFinder.FindReferencesAsync(symbol, solution);

The language is bigger though, with more history for a model to carry:

  • overload resolution
  • inheritance
  • attributes
  • LINQ
  • delegates and events
  • nullable-state analysis
  • reflection
  • source generators
  • multiple equivalent syntactic styles

Roslyn optimizes for the builder. Go optimizes for the agent. We built for the agent.

C++

Clang may have the richest low-level code-query and transformation tooling anywhere: LibTooling, AST matchers, refactoring APIs, compiler-grade semantic information.

The language asks more of a small model than anything else we considered:

  • macros obscure source identity
  • templates carry enormous semantic complexity
  • overload resolution is intricate
  • undefined behavior is invisible to structural checks
  • ownership is largely conventional
  • build configurations alter the visible program
  • small changes can surface distant failures

Building here would have proven the infrastructure impressive without showing that the approach makes local agents useful.

Zig

Appealing because the language is explicit, but the compiler APIs are not ready to carry a project like this. Choosing it would mean living the same lesson zero is working through: pick an interesting language, then discover the semantic tooling is itself a research project.

What we built

Ultimately, we chose Go for the target language and Go for the control plane, sitting directly on go/packages, go/types, go/ssa, and selected gopls machinery.

agent → semantic protocol → Go workspace model
                              ↓
                    transaction/refactoring engine
                              ↓
                 gofmt → go vet → go test → commit

The first operations:

  • inspect_symbol
  • find_references
  • rename_symbol
  • change_signature
  • implement_interface

Then the same local model runs in two modes, raw shell and source editing against semantic operations only, on deliberately repository-wide tasks. The bet: semantic mode shows a large improvement in completion rate, particularly around missed callers, wrong symbols, imports, and compile-repair loops. The bench in this repo measures exactly that.

How it works

Thesis (from idea.md): weak local models become effective repo-scale Go editors when restricted to semantic queries and validated mutations instead of raw text editing. The protocol is the product; the bench proves or kills the thesis.

Language

  • Workspace: one Go module tree (go.work planned). Unit of daemon ownership and snapshot state.
  • Snapshot: the typechecked in-memory view of a workspace: packages, syntax, types, reference info. Owned by the daemon, never by clients.
  • Symbol address: pkg (import path) + sym (Name, or Type.Member for methods and fields). Locals and import aliases are not addressable yet; that needs scope- or position-qualified addressing.
  • Query: read-only question answered from the snapshot: status, search, inspect, refs, callers, callees, implementations, doc. Milliseconds; never touches disk. List results page at 50 with a total count, truncated, and next_offset/offset.
  • Mutation: a checked edit: the full op catalog in language.md (decl, statement, and test ops, incl. set_signature and move_decl), plus the four sugar tools. Validated against the snapshot before any file is written. All-or-nothing: failure writes nothing and rolls back everything. An accepted mutation that reshaped exactly one declaration embeds that declaration’s fresh view in its response.
  • Rejection: the structured refusal of a mutation. Reason, detail, compiler diagnostics, did_you_mean candidates, and possible_repairs, complete paste-ready next calls. A rejection is data for the agent, not an error; the agent loop is query → mutate → (rejection → repair → retry). Exact resends of a rejected call escalate instead of repeating.
  • Splice: package-level revalidation: re-typecheck only the dirty set (edited-file packages, plus transitive reverse importers when the edit can change API or method sets) and swap results into the live snapshot. Packages check in parallel, one goroutine each, scheduled over the post-edit import graph (~56ms for a 117-package dirty set). Identity across splices is objectpath-based (ADR 0002).

Guarantees

The canonical guarantees list lives in language.md (its list is the superset: atomicity, generations, tolerance for pre-existing issues, embedded views). In one line: a mutation introduces no new diagnostic or it changes nothing.

Surface

One binary, three fronts over one daemon (unix socket per workspace, auto-spawned, idle-exit; gopls -remote=auto precedent, ADR 0001):

  • CLI: ago init|status|inspect|refs|rename|set-body|stop, JSON out, exit 2 on rejection.
  • MCP: ago mcp over stdio for agent harnesses; tools ago_* mirror the CLI ops; rejections returned as payloads, not tool errors.
  • ago init scaffolds an agent-first project: compilable module, MCP wiring, AGENTS.md protocol instructions.

Op catalog

Implemented surface: the ten-tool MCP/CLI surface (status, help, query, view, patch, test, rename, set_body, add_param, upsert_decl) plus the full patch op catalog underneath patch. See docs/specs/language.md for the language spec and ago help for the versioned, per-op catalog (args, one example, notes) that always matches what’s built.

Non-goals for now: multi-module go.work, non-Go files, formatting choices (gofmt is the only style), IDE features (hover docs, completion).

Engineering tenets

The principles this repo lives by. Each names its enforcement; a tenet without a mechanism is a wish. The rules are strict on purpose: this work is meant to be checked, used, and built on by other people, and the rigor is for them. We care about the work and the humans it lands on.

1. Automate everything so drift is impossible

Every fact that lives in two places gets a test comparing them, code side as the source of truth. Review only has to catch prose semantics; existence, status, counts, shapes, and examples are machine-checked. Enforced by the guard lattice (op registry ↔ help catalog ↔ surface.md ↔ language.md ↔ issue status ↔ README ↔ MCP wire ↔ init scaffold ↔ bench counters), catalog-version hashing, fixture-executed examples, and CI running the whole lattice on every push. Adding a fact without a guard is the bug.

2. Check the answer key first

Every benchmark task comes from a real commit, so the correct answer is already known. Before any model is asked to attempt a task, we submit that known answer through the protocol ourselves. If the right answer can’t get through, the model never had a chance: the problem is ours (a spec bug, an engine bug, or a protocol limit), and the task doesn’t count until it’s fixed. Enforced by TestOracleSweep and the certified flag: bench certify flips it from committed oracle evidence only, and model rounds refuse uncertified tasks.

3. Rejections are data

Every rejection answers “what is the correct next call”: diagnostics say what broke and possible_repairs carries the corrected call whole. Sending the same rejected call again escalates instead of looping. A repair that would itself reject is worse than none, so every repair runs verbatim in tests before it is ever offered. Enforced by the repairs suite; each new repair kind lands with its paste-back execution test.

4. Parallel by default, deterministic by test

Concurrency is the default for independent work (bench episodes, packages, subagents); serial needs a named reason. Identical query, identical bytes: caches, benchmarks, and result comparability all hang on it, and map iteration order is not an ordering. Enforced by the race detector in CI, TestParallelRetypecheckMatchesSerial, and TestQueryDeterministicBytes.

5. Name the ceiling, ship the floor

Deliberate simplifications are documented where they bind, with the trigger for lifting them. “v1 ceiling: X, rejected with the blocker named” is a feature; a silent wrong answer is the bug. Enforced by ceiling notes in the catalog and specs, rejects that name what blocked them, and TestLanguageSpecOpRowsMatchRegistry: an UNSHIPPED row that ships, or a shipped row that does not exist, fails the build.

6. Tolerate the world, reject the change

Real codebases carry history, and the people working in them shouldn’t be blocked by problems they inherited. A mutation is judged only on the diagnostics it introduces; what was already failing never stops unrelated work. Same rule shapes scoring: a test gate no ground truth could pass is vacuous, not failed. Enforced by baseline capture and filter in the engine and the pristine-worktree baseline in bench scoring.

7. Meet the codebase where it is

Production code bends to deadlines, migrations, and business reality. That is not a defect to punish; it is the medium. The protocol serves the engineer shipping under those constraints, not an imagined ideal repo, and nothing here demands a clean world before it helps. Enforced by the bench itself: every task is mined from a real commit in a production repo (traefik, vault, boundary), never hand-written, so the tool is measured on the code people actually live in. TestTaskManifestsCiteRealCommits fails any manifest entry that does not cite a full commit from a known repo.

8. The data is the contribution

Every run is captured as if a reader will check the work: transcripts, configs, diffs, scores, tokens, failure kinds, and request logs in git, serving setups pinned per run, prompts token-counted. A number without its evidence trail is an anecdote, and nobody has measured Go-with-local-models in this flavor; the data matters as much as the code. Enforced by the bench record path, the results-in-git convention, the MLflow export, and TestCommittedEpisodesCarryIdentity: committed evidence missing its task, mode, or profile fails the build.

9. Tooling speaks the project’s language

No sidecar scripts. A committed script is a missing subcommand; mining, reporting, extraction, and validation are Go code with tests, living next to what they serve. Enforced by TestNoSidecarScripts, which fails on any committed script file.

The ago language

The complete semantic edit language for Go agents. Supersedes the op catalog in protocol.md (which documents the currently implemented surface); engine semantics (daemon, snapshot, splice) are unchanged and specified there and in the ADRs.

Design stance, fixed by the bench evidence and Guy’s calls (2026-07-15): statement-granular ops so the model composes operations, not syntax; expression arguments are text atoms now with a structured form designed in; node handles from views address everything inside a declaration; a patch is an ordered, atomic, generation-checked transaction. Be as structured as possible; start looser to get going.

Surface

Six tools, four sugar ops. Everything else is patch payload.

toolpurpose
statusload/refresh; packages, files, errors
helpversioned op catalog with per-op schemas and examples
querysemantic questions: search, inspect, refs, callers, callees, implementations, doc
viewrender a declaration with node handles and its generation
patchordered op list, validated and applied atomically; dry_run for preview
rename, set_body, add_param, upsert_declstandalone sugar, each exactly a one-op patch

Addressing

  • Packages: import path. Symbols: Name or Type.Member (methods, fields). Test-file symbols resolve through test variants.
  • Inside a declaration: node handles (n1, n7, …) issued by view. A handle is meaningful only against the generation the view reported.
  • Every declaration has a generation, bumped by any accepted mutation that touches it. A patch names the generation it was built against; a mismatch rejects with stale generation: re-view, never a guess.

Locals and import aliases are addressable only through handles (their declaring node), never by name; that closes the addressing gap without a second naming scheme.

Queries

All from the typechecked snapshot (go/types tier); milliseconds.

  • search {q}: case-insensitive name fragment to exact addresses.
  • inspect {pkg, sym}: kind, signature, decl position, doc.
  • refs {pkg, sym}: every reference, tests included, defs marked.
  • callers {pkg, sym} / callees {pkg, sym}: static call-graph edges from types info. A call through an interface reports the interface method (query the interface method for its callers; implementations bridges to concrete types). v1 ceiling: no method-set candidate expansion for dynamic dispatch.
  • implementations {pkg, sym}: interface -> implementing types, or type -> interfaces satisfied.
  • doc {pkg, sym}: doc comment.

View

view {pkg, sym} returns the declaration as annotated text, one handle per statement and per addressable expression slot, plus generation:

gen 14
func (s *Store) Put(v int) error {
  n1: if s.frozen {
  n2:   return ErrFrozen
      }
  n3: s.n = v
  n4: return nil
}

Views are projections; agents read them, never edit them.

Patch

{"pkg": "demo/lib", "sym": "UseHelper", "generation": 14, "dry_run": false,
 "ops": [{"op": "add_if", "at": "n1", "where": "after", "cond": "v > 0"},
         {"op": "add_return", "at": "$1", "where": "first", "exprs": ["v"]}]}

Ordered. Validated as one unit: all ops apply to an in-memory copy, the dirty set re-typechecks once, resolution proofs run once, then everything writes and splices, or nothing does. Ops later in the list may address handles created by earlier ops (constructors return handles in the response; in one patch they are referenced as $1, $2, … by op index). dry_run runs the whole pipeline and reports the outcome without writing.

Cross-declaration patches (e.g. interface + impls rename) name pkg/sym per op instead of at the top level.

Decl ops

opargsnotes
upsert_declpkg, text, imports?add or replace a whole declaration; goimports in the loop, imports names what it cannot infer (aliases, ambiguous names); replaces into _test.go files and single members of grouped blocks in place (iota and inherited-value members reject, named), new decls append to an existing package file (test funcs to a _test.go) and may reference other in-flight ops; creates packages under the module on demand
delete_declpkg, sym or symsrejected while references remain (listed); the syms batch tolerates intra-set references, spans earlier ops rewrote do not count, grouped members excise in place (iota and inherited values reject), and methods deleting with their receiver type defer to the end-of-list typecheck
move_declpkg, sym, to_pkg, create_pkg?rewrites references and imports, requalifying call sites; the declaration’s own imports travel with it, aliases included; test decls land in a _test.go, created on demand. create_pkg creates a missing module-local target (opt in; a bare miss rejects and offers the flag as a repair). a type moves with its whole method set; one spec of a grouped block extracts standalone. v1 ceilings: the declaration must be self-contained (no uses of its old package’s other top-level symbols); grouped specs may not lean on iota or inherited values; each rejects naming the blocker
renamepkg, sym, toproves post-splice resolution; rejects capture and collision
set_bodypkg, sym, bodybody as checked text; the coarse escape hatch
set_signaturepkg, sym, signature, defaults?full param/result rewrite as Go text. Parameters match the old signature by name: carried ones keep each call site’s argument (reordering reorders them), dropped ones drop it, new ones splice their defaults entry positionally, so spread sites f(args...) survive insertions before the variadic. Underscore params pair positionally when their type matches, so widening func(ctx context.Context, _ DecryptFn) carries the _ argument. Interface methods work; changing an interface and its implementors is one atomic multi-op patch. Value uses and the body are NOT rewritten: repair them with sibling ops in the same patch or the end-of-list typecheck rejects with the positions
add_parampkg, sym, name, type, defaultcallers updated with default, inserted before a variadic tail (spread sites included); a top-level body local name := <default> is promoted into the parameter, any other same-named local rejects with its position; value uses rejected with theirs
remove_parampkg, sym, nameUNSHIPPED (use set_signature, which drops params today); planned as sugar over it
add_fieldpkg, sym (Type), name, type, tag?
remove_fieldpkg, sym (Type.Field)rejected while references remain
set_docpkg, sym, textdoc comment only
implement_interfacepkg, type, ifaceUNSHIPPED; generates missing method stubs

Statement ops

All take {at: handle, where: before|after|first|last} for placement (first/last against a block handle). Expression-valued arguments are text atoms, parsed and typechecked in scope at the target position.

opargsnotes
add_assignlhs, rhs, define?:= when define
add_returnexprs[]arity/type checked against signature
add_callexprexpression statement
add_ifcond, else?creates empty block(s); returns the then-block handle (else block addressed via a fresh view)
add_forcond? or range?empty body; returns handle. v1 ceiling: no init/post clauses; use upsert_decl/set_body for classic three-clause loops
add_switchtag?empty; extend with add_case
add_caseat (switch handle), exprs[] or defaultreturns body handle
add_deferexpr
add_goexpr
set_condat, exprif/for/case condition replacement
replace_exprat, exprv1 ceiling: the node’s condition or a whole expression statement only; per-slot sub-expression handles are future
delete_nodeatstatement or case; deleting a block requires it be empty
wrap_stmtsfrom, to, with (if/for/block), cond?from/to must be siblings in order in the same block; returns the new node’s handle
wrap_errorat (assign or call handle), messagethe Go idiom: assign err, add if err != nil return with fmt.Errorf("...: %w", err). v1 ceiling: a bare expression-statement call resolves its return arity only for a same-package function identifier

The statement vocabulary deliberately omits constructs an agent should express with upsert_decl/set_body wholesale (select, labeled statements, complex composite literals); help says so per gap. If bench evidence shows a missing op mattering, it gets added; the catalog is versioned.

Test ops

Tests are declarations underneath, but they get dedicated ops for three reasons: placement is constrained (a _test.go file, correct test package), the naming is constrained (TestXxx(t *testing.T)), and the idiomatic form humans expect, table-driven, is structured enough that an agent should compose it from data, not synthesize its shape.

opargsnotes
add_testpkg, target (sym under test), name?scaffolds a table-driven test: case struct derived from the target’s signature (inputs from params, want from results), rows slice, range + t.Run loop, one starter failure message. v1: address the test by name in follow-up ops. Name defaults to Test<Target>
add_test_casetest (name), name, args[], want[]appends one row; values are expression atoms typechecked against the case struct. v1 addresses tests by name; table handles are future
set_test_case / remove_test_casecase addressed by test + row name
add_benchpkg, target, name?UNSHIPPED; BenchmarkXxx(b *testing.B) skeleton

Placement and form rules, enforced at validation:

  • New tests land in <declfile>_test.go next to the target, created on demand. Internal vs external test package follows the package’s existing tests; a package with no tests gets internal.
  • Assertion style follows the package’s dominant existing convention (stdlib t.Errorf vs testify require/assert), detected from the test files already present; stdlib when there is no precedent (v1 detection: any existing _test.go importing testify/require flips to require.Equal; assert unsupported).
  • The canonical skeleton is fixed by this spec (name/args/want struct, got := call, comparison, t.Errorf("Target(%v) = %v, want %v", ...)) so generated tests read the same everywhere; gofmt applies as always.
  • Generated helpers call t.Helper().

Arbitrary non-table tests remain expressible with upsert_decl into a _test.go path; the ops cover the idiomatic 90%.

The test tool

Semantic mode has no shell, so running tests is part of the language: a test {pkg?, run?} tool executes go test scoped to a package (and optionally -run filter) and returns structured results: pass/fail per test, failure messages with positions, elapsed time. Per Guy’s workflow rule, validation of mutations stays compiler-only; test is how the agent closes the behavior loop per set of changes, at its own judgment. The bench’s scoring runs tests independently either way.

Project ops

opargsnotes
add_dependencymodule, version?go get + tidy, validated build
remove_dependencymodulerejected while imported
move_filefrom, topackage clauses and imports updated
delete_filepathrejected while it declares referenced symbols
mod_tidy

Expressions: atoms now, structure designed in

Every expression-typed argument accepts either a string (text atom) or a structured node. The structured grammar is fixed now so it can arrive per-argument without a protocol break:

{"kind": "binary", "op": "!=", "left": {"kind": "ident", "name": "err"},
 "right": {"kind": "ident", "name": "nil"}}

Kinds: ident, select, index, call, lit, unary, binary, paren, func (closure bodies are op lists). v1 implements text atoms; the structured form is the target for constrained decoding (one JSON grammar covers a whole patch, so a llama.cpp GBNF grammar can force validity at the decoder).

Rejections

{"status": "rejected", "reason": "...", "detail": "...",
 "diagnostics": [{"pos": "...", "msg": "..."}],
 "did_you_mean": ["..."],
 "possible_repairs": [{"why": "demo/lib.Double resolves",
   "call": {"tool": "view", "args": {"pkg": "demo/lib", "sym": "Double"}}}]}

A rejection is the agent’s error channel and must always answer “what is the correct next call”: diagnostics say what broke, did_you_mean lists bare candidates, possible_repairs carries the corrected call whole, complete and paste-ready. Addressing misses resend the corrected call (view, query, patch ops, and the sugar mutations), filtered so a repair never repeats the rejection that produced it; stale generations and unknown handles repair with the re-view call; a missing required op argument falls back to the help call; an undefined identifier in a typecheck reject gets the search call that locates it. Nothing is guessed: where no mechanical repair exists, diagnostics stand alone. Patch rejections say which op index failed; earlier ops in the patch have no effect. Op arguments decode strictly: a field from another op’s vocabulary rejects at the shape layer naming the field, with the help call as its repair. An exact resend of a just-rejected call gets an escalated rejection (resent, escalation) instead of the same answer forever. List-returning queries page at 50 entries: count is always the total, truncated responses carry truncated and next_offset, and offset requests the next page.

Guarantees

  1. An accepted edit introduces no new compiler diagnostic. Problems the code already had never block it; they are measured up front and reported separately as pre_existing.
  2. A rejected patch changes nothing: disk, snapshot, or generation.
  3. Rename and move prove every rewritten reference still points at the intended object. A reference captured by shadowing is rejected even when the compiler would accept it.
  4. Patches apply whole or not at all; a patch built on a stale generation is rejected.
  5. Queries see accepted mutations immediately. A patch that reshapes one declaration gets that declaration’s fresh view back in the response, so the next edit needs no extra call; multi-declaration patches say why the view was omitted (views_omitted).
  6. Edits made outside the protocol are detected on the next request and trigger a full reload.

Future, named

  • SSA query tier: writes_to, paths_to_effect, purity/escape facts.
  • Multi-module / go.work workspaces (vault sdk/ class of repo).
  • Structured-expressions-only mode + constrained decoding integration.
  • Batch cross-repo transactions.
  • Statement-op coverage growth driven by bench evidence, via the versioned catalog.

Testing

Per-op unit tests against the fixture module; the oracle harness executes ground-truth-derived patches for every bench task (a task enters the bench only if the protocol can express it); the raw-vs-semantic bench is the integration gate and the measure of whether added structure pays.

Op catalog

Generated from ago help (catalog version v9) at build time. 33 patch ops.

Tools

status

Load or refresh the workspace snapshot. Returns package and file counts and any type errors. No arguments.

help

Return this versioned op catalog: every patch op’s argument schema, one worked example, and its v1 ceilings, plus short descriptions of the six tools. No arguments.

query

Semantic questions against the typechecked snapshot, dispatched by kind: search (case-insensitive name fragment -> exact addresses), inspect (kind, signature, decl position, doc), refs (every reference, tests included, defs marked), callers/callees (static call-graph edges; a call through an interface reports the interface method), implementations (interface -> implementing types, or type -> satisfied interfaces), doc (doc comment text). Args: kind (required), pkg, sym, q (name fragment, for kind=search, falls back to sym), offset (page offset for list results). Lists are position-sorted and paged 50 at a time: count is the total found; a truncated response carries truncated=true and next_offset to pass back as offset.

view

Render a declaration as annotated text. Functions and methods get a per-statement nK: handle prefix plus a generation counter for staleness checks; other declarations (const, var, type) render as plain source. Handles are meaningful only against the generation the same response reports. Args: pkg, sym.

patch

Apply an ordered list of ops as one atomic, generation-checked transaction: every op applies to an in-memory copy, the dirty set re-typechecks once, then everything writes and splices together — or nothing does. Ops compose: an op later in the list can address a handle an earlier op returned, referenced as $1, $2, … by 1-based op index. dry_run runs the identical pipeline and reports accept/reject without writing. Op families (full schemas and examples via help): decl ops (rename, set_body, add_param, upsert_decl, delete_decl, set_doc, add_field, remove_field), statement ops (add_assign, add_call, add_return, add_if, add_for, add_switch, add_case, add_defer, add_go, set_cond, replace_expr, delete_node, wrap_stmts, wrap_error), test ops (add_test, add_test_case, set_test_case, remove_test_case), project ops (delete_file, move_file, add_dependency, remove_dependency, mod_tidy). A decl, test, or project op (rename, set_body, add_param, upsert_decl, delete_decl, set_doc, add_field, remove_field, add_test, add_test_case, set_test_case, remove_test_case) and a statement op cannot edit the same file in one patch; run them as separate patches. An accepted patch that touched exactly one declaration embeds that declaration’s fresh view (same {text, nodes, generation} payload the view tool returns) under “view”, so back-to-back edits need no view call in between; when several declarations were touched the response carries views_omitted instead. Args: pkg/sym (defaults for ops that omit them), generation, dry_run, ops (required, the array of op objects).

test

Run go test, scoped to a package (default the whole workspace) and optionally filtered by name, and return structured per-test results: pass/fail, elapsed time, and captured output for failures. Validation of mutations stays compiler-only; this is how you close the behavior loop after a set of changes. Args: pkg, run (a -run filter).

Patch ops

rename

proves post-splice resolution: every rewritten reference must resolve to the renamed object; reference capture rejects even when the compiler is satisfied

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
symstringsymbol: Name or Type.Member; defaults to the envelope’s sym
tostringyesnew name

Example:

[
  {
    "op": "rename",
    "sym": "Double",
    "to": "Twice"
  }
]

set_body

the coarse escape hatch: replaces the whole block between braces, validated by typecheck like every other op

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
symstringsymbol: Name or Type.Member; defaults to the envelope’s sym
bodystringyesnew body as statements, no surrounding braces

Example:

[
  {
    "op": "set_body",
    "sym": "Double",
    "body": "return v + v"
  }
]

add_param

callers updated with default; a top-level local name := <default> in the body is superseded and deleted (parameters share the body scope), any other same-named body declaration is rejected with its position; references to the function as a value (assigned, passed, satisfying an interface) cannot be repaired and are rejected with their positions

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
symstringsymbol: Name or Type.Member; defaults to the envelope’s sym
namestringyesnew parameter name
typestringyesnew parameter type, e.g. context.Context
defaultstringargument expression for existing call sites; required whenever the function already has callers

Example:

[
  {
    "op": "add_param",
    "pkg": "demo/sig",
    "sym": "Scale",
    "name": "offset",
    "type": "int",
    "default": "0"
  }
]

upsert_decl

add or replace a whole top-level declaration; goimports runs in the loop. Replacement finds declarations in _test.go files too, and replaces single members inside grouped const/var/type blocks in place (send the standalone form; iota groups and members a following bare spec inherits from reject with the blocker named). New declarations append to an existing package file (test funcs to a _test.go file, so they actually run) and may reference symbols other ops in the same patch introduce; a package with no such file gets agent.go / agent_test.go created on demand — including a brand-new package mid-patch, so one atomic patch can create a package and move declarations into it

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
textstringyescomplete declaration source, including doc comment if any; the symbol name is parsed from it
imports[]{path,name}imports the declaration needs that goimports cannot infer: an aliased import or an ambiguous package name; name is the alias, empty for the default

Example:

[
  {
    "op": "upsert_decl",
    "pkg": "demo/lib",
    "text": "// Double doubles v.\nfunc Double(v int) int {\n\treturn v + v\n}"
  }
]

delete_decl

rejected while any non-declaring reference remains outside the declaration itself (a recursive self-call does not count), outside the batch, and outside spans earlier ops in the same patch rewrote; the diagnostics list where. A member of a grouped const/var/type block excises in place (iota and inherited-value members reject, position defines them); a method whose receiver type deletes in the same batch skips the reference guard, the end-of-list typecheck arbitrates

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
symstringsymbol: Name or Type.Member; defaults to the envelope’s sym
syms[]stringbatch form: several symbols delete together, so intra-set references (a helper and the test that used it) do not block

Example:

[
  {
    "op": "delete_decl",
    "sym": "Unused"
  }
]

set_doc

doc comment only; replaces an existing one rather than appending, and does not affect the typecheck surface

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
symstringsymbol: Name or Type.Member; defaults to the envelope’s sym
textstringyesdoc comment body; each line is rendered with a “// “ prefix

Example:

[
  {
    "op": "set_doc",
    "sym": "Double",
    "text": "Double doubles v."
  }
]

add_field

appended to the struct’s field list; rejected if the name already exists

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
symstringthe struct type’s name; defaults to the envelope’s sym
namestringyesnew field name
typestringyesnew field type
tagstringstruct tag, without backticks

Example:

[
  {
    "op": "add_field",
    "sym": "Store",
    "name": "Tag",
    "type": "string"
  }
]

remove_field

rejected while referenced. v1 ceiling: a field sharing a multi-name declaration (“a, b int”) or an embedded field is not supported

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
symstringfield: Type.Field; defaults to the envelope’s sym

Example:

[
  {
    "op": "remove_field",
    "sym": "Config.Legacy"
  }
]

move_decl

relocates the whole declaration (doc comment included) and requalifies every reference, adding imports where needed; a type moves together with its whole method set, and one spec of a grouped const/var/type block extracts standalone. v1 ceilings: the declaration must be self-contained (no uses of its old package’s other top-level symbols) and grouped specs may not lean on iota or an inherited value; each rejects with the blocking names

argtyperequireddescription
pkgstringsource package import path; defaults to the envelope’s pkg
symstringtop-level declaration name; defaults to the envelope’s sym
symsarrayseveral declarations moving as one set (type + constructor + tests); intra-set references are legal; mutually exclusive with sym
to_pkgstringyestarget package import path (must exist by the time this op runs; an earlier upsert_decl in the same patch can create it)
create_pkgbooleancreate a missing module-local target package as part of this patch; without it a missing target rejects (typo safety) and offers this flag as a repair

Example:

[
  {
    "op": "move_decl",
    "pkg": "demo/sig",
    "sym": "Fetch",
    "to_pkg": "demo/lib"
  }
]

set_signature

full parameter/result rewrite: parameters are matched to the old signature by name — carried ones keep each call site’s argument (reordering reorders them), dropped ones drop it, new ones take their default; underscore params pair positionally when their type matches, so widening func(ctx context.Context, _ DecryptFn) keeps the _ argument; a spread call site f(args…) survives insertions before the variadic. Value uses of the function and the body itself are not rewritten: repair them with sibling ops in the same patch or the end-of-list typecheck rejects with the site positions

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
symstringsymbol: Name or Type.Member; defaults to the envelope’s sym
signaturestringyesthe complete new signature as Go text: “(params) results”
defaultsobjectargument expression per NEW parameter name, spliced into every existing call site; required for any new parameter when call sites exist

Example:

[
  {
    "op": "set_signature",
    "pkg": "demo/sig",
    "sym": "Fetch",
    "signature": "(ctx context.Context, a int, b string, rest ...int) int",
    "defaults": {
      "ctx": "context.Background()"
    }
  }
]

delete_file

removes one file; rejected while any package-level symbol it declares is referenced from outside it (the rejection lists the reference positions). Deleting a package’s last file removes the package

argtyperequireddescription
pathstringyesfile path relative to the module root (or absolute)

Example:

[
  {
    "op": "upsert_decl",
    "pkg": "demo/scratch",
    "text": "func Tmp() int {\n\treturn 0\n}"
  },
  {
    "op": "delete_file",
    "path": "scratch/agent.go"
  }
]

move_file

same-directory moves are pure renames; a cross-package move rewrites the package clause to the target package and drops a now-self import, and is rejected while the file declares symbols referenced from outside it (their qualifiers would all be wrong — use move_decl per declaration instead)

argtyperequireddescription
fromstringyescurrent file path relative to the module root (or absolute)
tostringyesnew file path; a different directory must hold an already-loaded package

Example:

[
  {
    "op": "move_file",
    "from": "lib/lib.go",
    "to": "lib/core.go"
  }
]

add_dependency

runs go get module@version against the workspace module; go.mod and go.sum restore byte-for-byte on any later rejection in the same patch. Needs the module in the local cache or network access

argtyperequireddescription
modulestringyesmodule path, e.g. golang.org/x/sync
versionstringmodule version; defaults to latest

Example:

[
  {
    "op": "add_dependency",
    "module": "golang.org/x/sync",
    "version": "v0.10.0"
  }
]

remove_dependency

runs go get module@none; rejected while any workspace file still imports the module (the rejection lists the import positions)

argtyperequireddescription
modulestringyesmodule path to drop

Example:

[
  {
    "op": "remove_dependency",
    "module": "golang.org/x/sync"
  }
]

mod_tidy

runs go mod tidy with the same go.mod/go.sum restore-and-validate wrapper as the other module ops

argtyperequireddescription

Example:

[
  {
    "op": "mod_tidy"
  }
]

add_assign

argtyperequireddescription
atstringyeshandle (or $N) to place the statement relative to
wherestringyesbefore | after | first | last (first/last need a block-owning handle)
lhsstringyesassignment target identifier
rhsstringyesright-hand-side expression, parsed and typechecked in scope
definebooluse := instead of =

Example:

[
  {
    "op": "add_assign",
    "at": "n2",
    "where": "after",
    "lhs": "_",
    "rhs": "h(3)",
    "define": false
  }
]

add_call

argtyperequireddescription
atstringyeshandle (or $N) to place the statement relative to
wherestringyesbefore | after | first | last
exprstringyesa call expression or channel receive (<-ch); assignments belong to add_assign

Example:

[
  {
    "op": "add_call",
    "at": "n2",
    "where": "after",
    "expr": "fmt.Println(h(1))"
  }
]

add_return

arity and result types are checked against the enclosing signature at end-of-list typecheck, not here

argtyperequireddescription
atstringyeshandle (or $N) to place the statement relative to
wherestringyesbefore | after | first | last
exprs[]stringresult expressions, in order; omit for a bare “return”

Example:

[
  {
    "op": "add_return",
    "at": "n2",
    "where": "after",
    "exprs": [
      "h(2)"
    ]
  }
]

add_defer

argtyperequireddescription
atstringyeshandle (or $N) to place the statement relative to
wherestringyesbefore | after | first | last
exprstringyesa call expression

Example:

[
  {
    "op": "add_defer",
    "at": "n1",
    "where": "after",
    "expr": "fmt.Println(\"done\")"
  }
]

add_go

argtyperequireddescription
atstringyeshandle (or $N) to place the statement relative to
wherestringyesbefore | after | first | last
exprstringyesa call expression

Example:

[
  {
    "op": "add_go",
    "at": "n2",
    "where": "after",
    "expr": "h(1)"
  }
]

delete_node

a block-owning statement with children, or an if with an else, is rejected rather than silently discarding content — delete children first

argtyperequireddescription
atstringyeshandle of the statement or case clause to remove

Example:

[
  {
    "op": "add_return",
    "at": "n3",
    "where": "after",
    "exprs": [
      "h(9)"
    ]
  },
  {
    "op": "delete_node",
    "at": "n3"
  }
]

add_if

returns the new then-block’s own handle via $N; there is no v1 handle for a requested else block (view again to reach it)

argtyperequireddescription
atstringyeshandle (or $N) to place the statement relative to
wherestringyesbefore | after | first | last
condstringyescondition expression
elseboolalso create an empty else block

Example:

[
  {
    "op": "add_if",
    "at": "n2",
    "where": "after",
    "cond": "h != nil",
    "else": false
  }
]

add_for

empty body, returns its handle via $N. v1 ceiling: no init/post clauses — use upsert_decl/set_body for a classic three-clause loop

argtyperequireddescription
atstringyeshandle (or $N) to place the statement relative to
wherestringyesbefore | after | first | last
condstringcondition expression; mutually exclusive with range
rangestringa full range clause, e.g. “k, v := range coll”; mutually exclusive with cond

Example:

[
  {
    "op": "add_for",
    "at": "n2",
    "where": "after",
    "cond": "h(0) \u003e 0"
  }
]

add_switch

empty body; extend with add_case

argtyperequireddescription
atstringyeshandle (or $N) to place the statement relative to
wherestringyesbefore | after | first | last
tagstringswitch tag expression; omit for a tagless switch

Example:

[
  {
    "op": "add_switch",
    "at": "n2",
    "where": "after",
    "tag": "h(1)"
  }
]

add_case

always appends as the last clause (v1 has no argument for placing a case among existing ones); returns the new case’s body handle via $N

argtyperequireddescription
atstringyeshandle (or $N) of the switch statement to extend
exprs[]stringcase expressions; mutually exclusive with default
defaultboolmake this the default clause; mutually exclusive with exprs

Example:

[
  {
    "op": "add_switch",
    "at": "n2",
    "where": "after",
    "tag": "h(1)"
  },
  {
    "op": "add_case",
    "at": "$1",
    "exprs": [
      "1",
      "2"
    ]
  }
]

set_cond

a case clause’s whole expression list is replaced as one; v1 has no per-element case-expr addressing

argtyperequireddescription
atstringyeshandle (or $N) of the if/for/case to retarget
exprstringyesreplacement condition

Example:

[
  {
    "op": "add_if",
    "at": "n2",
    "where": "after",
    "cond": "h == nil"
  },
  {
    "op": "set_cond",
    "at": "$1",
    "expr": "h != nil"
  }
]

replace_expr

v1 ceiling: an if/for/case condition or a whole expression statement only; per-argument sub-expression handles are future work

argtyperequireddescription
atstringyeshandle (or $N) of the target node
exprstringyesreplacement expression

Example:

[
  {
    "op": "add_call",
    "at": "n2",
    "where": "after",
    "expr": "h(1)"
  },
  {
    "op": "replace_expr",
    "at": "$1",
    "expr": "h(2)"
  }
]

wrap_stmts

from/to must be direct siblings, in order, of the same statement list; returns the new node’s handle via $N

argtyperequireddescription
fromstringyeshandle (or $N) of the first statement to enclose
tostringyeshandle (or $N) of the last statement to enclose
withstringyesif | for | block
condstringcondition; required for with=if/for, forbidden for with=block

Example:

[
  {
    "op": "wrap_stmts",
    "from": "n1",
    "to": "n2",
    "with": "if",
    "cond": "helper(1) \u003e 0"
  }
]

wrap_error

the Go idiom automated end to end: binds err, inserts “if err != nil { return …, fmt.Errorf(…) }”. v1 ceiling: a bare expression-statement call resolves its return arity only for a same-package function identifier

argtyperequireddescription
atstringyeshandle (or $N) of the assignment or expression-statement call to wrap
messagestringyescontext prefix for fmt.Errorf(“…: %w”, err)

Example:

[
  {
    "op": "wrap_error",
    "at": "n1",
    "message": "fetch"
  }
]

add_test

scaffolds a table-driven test: case struct derived from the target’s signature, rows slice, range+t.Run loop. v1 targets a plain function, not a method; address the generated test by name in follow-up ops

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
targetstringyesthe function under test; defaults to the envelope’s sym when omitted
namestringtest function name; defaults to Test<Target>

Example:

[
  {
    "op": "add_test",
    "target": "Double"
  }
]

add_test_case

values are expression atoms, typechecked against the case struct at end-of-list typecheck

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
teststringtest function name (from add_test); defaults to the envelope’s sym
namestringyescase row name
args[]stringargument expressions, in target parameter order
want[]stringexpected-result expressions, in result order (wantErr last, if the target returns error)

Example:

[
  {
    "op": "add_test",
    "target": "Double"
  },
  {
    "op": "add_test_case",
    "test": "TestDouble",
    "name": "positive",
    "args": [
      "2"
    ],
    "want": [
      "4"
    ]
  }
]

set_test_case

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
teststringtest function name; defaults to the envelope’s sym
casestringyesexisting row’s current name
namestringnew row name; defaults to case (no rename)
args[]stringreplacement argument expressions
want[]stringreplacement expected-result expressions

Example:

[
  {
    "op": "set_test_case",
    "test": "TestScale",
    "case": "one",
    "args": [
      "3",
      "3"
    ],
    "want": [
      "9"
    ]
  }
]

remove_test_case

argtyperequireddescription
pkgstringpackage import path; defaults to the envelope’s pkg
teststringtest function name; defaults to the envelope’s sym
casestringyesrow name to remove

Example:

[
  {
    "op": "remove_test_case",
    "test": "TestScale",
    "case": "one"
  }
]