by Winch Labs

Coding agents

Deltz for coding agents

Deltz is infrastructure review for teams and agents. The same deterministic gate that posts a Check Run on a pull request runs offline on the machine the agent is on — no key, no login, no network — and the agent reads one envelope in both places. This page is what an agent gets, in the order it meets it.

Every command on this page was run against the v0.2.0 binary before it was written here.

What an agent gets

garboard gate --format json prints exactly one object on stdout:

{
  "status": "blocked",
  "clean": false,
  "findings": [
    {
      "rule_id": "secops.rds_unencrypted",
      "severity": "critical",
      "enforcement": "block",
      "title": "RDS instance is not encrypted at rest",
      "file": "infra/main.tf",
      "line": 1
    }
  ],
  "coverage": {
    "completeness": "complete",
    "documents": [{ "file": "infra/main.tf", "document": 0, "state": "evaluated", "frontend": "hcl" }],
    "evaluated": ["infra/main.tf"],
    "unsupported": [],
    "failed": [],
    "missing_context": [{ "kind": "no_base_comparison", "detail": "…" }]
  },
  "meta": { "version": "v0.2.0", "rcd": null, "base": "9c82f79…", "changed": ["infra/main.tf"], "via": "[email protected]" }
}

It is the same envelope the MCP tool check_diff returns and the same one the Check Run carries, so an agent written against one surface parses the other. Three parts matter:

  • findings[] — each names its rule_id, file and line, and whether its enforcement is block or advisory. A finding without a file and line is a bug, not a feature.
  • coverage — what was evaluated, what was unsupported, what failed to read, and missing_context: what this run could not see. It is present on every run, including a clean one.
  • meta — the binary’s version; base and changed under --changed, null otherwise; via when the caller stamped itself; rcd is null here because garboard gate enforces no conventions in this build (meta.rcd_missing says so).

status takes four values, and the exit code says the same thing:

status exit meaning
clean 0 everything in scope was evaluated and nothing blocking fired
blocked 1 a blocking finding fired
incomplete 2 something in scope could not be evaluated — not a pass
not_reviewed 2 nothing in scope was evaluated — not a pass
(none) 3 the gate could not run: bad flags, a path that is not a directory, a missing artifact. Nothing was decided.

Only clean is a pass. There are three answers rather than two because a hook that treats every non-zero as “blocked” and every zero as “passed” has nowhere to put “could not tell” — and it will put it in the pass. An agent that reads 2 reports what coverage.missing_context and coverage.unsupported say was not evaluated; it does not say “checked”.

Before the first line

Before the agent writes Terraform, OpenTofu, Crossplane or CloudFormation, it reads the repository’s conventions — the ones derived from the repository itself, each carrying the file:line it was derived from. Nothing is invented.

Live, over MCP: get_conventions and get_exemplars on the garboard server init registers. The server speaks stdio only and reads .garboard/rcd.json; --transport http is refused by the binary. The MCP page lists all four tools.

{ "repo": "fixture", "conventions": [ { "id": "naming.snake_case", "pattern": "snake_case resource instance names",
  "confidence": "confirmed", "enforced": true, "evidence": [ { "file": "infra/main.tf", "line": 1 } ] } ] }

Or as a file, when there is no MCP client in the loop:

garboard export --format claude-md --stdout

Same conventions, rendered as instructions. The trade is freshness: a file is a snapshot, the server is current.

Before the push

The agent gates the change, not the tree:

garboard gate --changed --format json
garboard gate --changed --base main --format json     # when origin/HEAD is not the base you mean
garboard gate --changed --format json infra/           # narrowed to a pathspec

--changed takes the files committed on the branch since merge-base(--base, HEAD) plus staged, unstaged and untracked files, narrows them to paths an infrastructure frontend could own, and gates those. The whole tree is still read as context; findings and coverage are narrowed to the set exactly as the pull request’s are. meta.base records the sha it compared from and meta.changed the list. Outside a git work tree it exits 3.

A skill or wrapper that calls the gate can stamp itself: --via [email protected] lands in meta.via and nowhere else. It changes no rule, path or format.

The hook

garboard init --hooks installs two things: a garboard-gate hook in .pre-commit-config.yaml and, for Claude Code, a PreToolUse hook in .claude/settings.json. Claude Code calls the hook before every Bash command; for a git push, gh pr create or glab mr create it runs garboard gate --changed --format json in the command’s directory and answers in the shape Claude Code documents:

gate result the hook does
exit 0, clean nothing — no output, the push proceeds
exit 1, blocked denies the command; the reason lists every blocking finding with its rule and file:line
exit 2, incomplete or not reviewed denies the command; the reason names what was not evaluated — absent is not zero
exit 2, the change touches no infrastructure (meta.changed is [], nothing in scope) nothing — a README push has nothing in scope, and nothing in scope is nothing to say
exit 3, the gate could not run explains on stderr and does not block (a hook that blocks every push when the binary is missing is a hook that gets deleted)

This is the deny a blocked push gets, verbatim:

{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny",
 "permissionDecisionReason":"garboard gate: blocked — 2 blocking finding(s). Fix them, re-run `garboard gate --changed --format json`, then push.\n- secops.rds_public infra/main.tf:1 — RDS instance is publicly accessible\n- secops.rds_unencrypted infra/main.tf:1 — RDS instance is not encrypted at rest"}}

The agent sees the reason, fixes the findings, re-runs the gate once, and pushes.

What the hook is, and is not

A hook committed into the repository is feedback. An agent — or a person — with write access to the tree can delete .claude/settings.json, and git commit --no-verify skips pre-commit. That is fine: the hook’s job is to put the findings in front of the agent before it pushes, which is where they are cheapest to fix. Enforcement is the Check Run the hosted review posts on the pull request, which nothing inside the branch can remove.

In the pull request

The hosted review posts a Check Run on every push. Its output.text carries the same envelope as a fenced JSON block, preceded by one paragraph telling an agent what to do with it. GitHub caps that field, so when findings had to be omitted the block says how many and where the whole thing is:

curl -H "Authorization: Bearer gbp_…" https://<your-garboard>/api/reviews/<id>/verdict

GET /api/reviews/{id}/verdict serves the envelope untruncated, readable with a personal access token, and re-stated after a dismissal, an approval or a break-glass override — so what the agent reads after a human decision is the verdict that decision produced, not the one from the last push. On GitLab the merge request note carries the same JSON block. In CI, the GitHub Action exposes status, exit-code and verdict-file as step outputs.

Any hosted agent that can read a GitHub Check Run reads the verdict this way. That is the mechanism; it names no vendor.

Findings keep their identity across pushes. A finding keeps its id from one push to the next; when the gate’s own re-run no longer emits it at an evaluated head, it is marked fixed at <sha> — the gate’s statement, not an inference. A dismissal follows its resource rather than its line number. A clean re-review edits the existing comment instead of posting another.

Init per harness

One command wires a repository up:

garboard init --harness claude-code --hooks     # CLAUDE.md, .mcp.json, pre-commit + PreToolUse hooks
garboard init --harness codex                   # AGENTS.md, .mcp.json; prints the one `codex mcp add` line to run
garboard init --harness cursor                  # .cursor/rules/garboard.mdc, .cursor/mcp.json
garboard init --harness all --hooks --dry-run   # every file it would touch, with a diff, writing nothing

In order, printing each step, it:

  1. Derives the conventions into .garboard/rcd.json if that file is absent, with the same scan garboard scan runs. Commit it, or run garboard scan where the agent runs — it is what garboard mcp serves.
  2. Merges a short block into the file the harness reads first — CLAUDE.md, AGENTS.md or .cursor/rules/garboard.mdc (a Cursor rule gets the frontmatter Cursor requires; a plain .md in .cursor/rules is ignored). The block sits between <!-- garboard:agent:begin --> and <!-- garboard:agent:end -->; every byte outside it is kept, and it coexists with the conventions block garboard export writes.
  3. Registers the MCP server — {"command": "garboard", "args": ["mcp"]} — in .mcp.json, and in .cursor/mcp.json for Cursor. Other servers in the file are kept. Codex reads ~/.codex/config.toml instead, so init prints codex mcp add garboard -- garboard mcp as the next step rather than writing it.
  4. With --hooks: the garboard-gate pre-commit hook and, for Claude Code, the PreToolUse hook above.

Then it prints one next step per harness — for Claude Code, restart it so it loads .mcp.json. Running init again reports every file unchanged and writes nothing.

The block is the same for every harness, and it says: read the conventions before writing; run garboard gate --changed --format json before a push; only "status": "clean" is clean; fix every block finding, re-run once, report what was left; the local gate has no base to compare against; never hand Deltz credentials, state or plans containing secrets — it needs none of them.

The plugin

The same wiring is rendered as a Claude Code plugin and a Codex plugin, under plugins/ in the garboard repository:

garboard plugin build <dir>      # writes <dir>/claude-code and <dir>/codex from this binary

Each holds a manifest, two skills (garboard-check: the gate loop above; garboard-conventions: read before the first line), hooks/hooks.json — the same PreToolUse hook init --hooks installs, byte for byte in both plugins — and the MCP entry. The skills say, sentence for sentence, what the block init writes says, because both are rendered from one source in the binary, and a test holds the committed plugin byte-equal to what plugin build renders. It registers no login, no key, no network call and no telemetry. The garboard binary must be on PATH.

It is not published. There is no marketplace listing, no installer and no public repository. Anyone who has the garboard repository — today, design partners — loads it in place:

claude --plugin-dir /path/to/garboard/plugins/claude-code

or copies plugins/claude-code into their Claude Code plugin directory. The Codex plugin is copied next to a marketplace file (.agents/plugins/marketplace.json in a repository, or ~/.agents/plugins/marketplace.json) that lists it with a local source; Codex skips a plugin’s hooks until you review and trust them.

What was tested, exactly

Tested with Claude Code means this and nothing wider: Claude Code’s documented MCP contract — initialize, tools/list and a check_diff call over stdio — and its documented PreToolUse hook contract, each exercised with the real binary. garboard mcp answers initialize and tools/list, and check_diff on an unencrypted, public RDS instance returns a blocked envelope; the hook, given a PreToolUse event for git push, returns the documented deny on that tree and prints nothing on a clean one.

Codex and Cursor are configured by init, and the Codex plugin’s layout, hook and MCP config follow Codex’s current documentation — neither was exercised inside a Codex or Cursor session. Hosted agents get the verdict through the Check Run, as above; no vendor is named as supported.

What the local lane does not see

The pull-request review has the base ref; the local gate has your working tree. Rules that need both sides do not fire locally:

  • Destructive changes — a stateful resource about to be destroyed or replaced, a rename without moved, a count/for_each shift, a dropped prevent_destroy — fire on the pull request, or locally only from a plan you produce in your own credentialed step and hand to the gate with --plan (destructive changes).
  • Budget findings fire only in the hosted review: the threshold is organisation policy, which the offline gate does not read (cost deltas).
  • Conventions are applied by check_diff and the hosted review. garboard gate reads no RCD in this build; its meta.rcd_missing says so on every run.

The agent block says all of this, and coverage.missing_context names each gap on every run — so an agent that reports “checked” for a destructive change it could not see is contradicting the envelope it was handed.