openai client. The quality gates — ruff, bandit, ast-grep, pre-commit — are all free, open-source, and run entirely offline. Nothing in this module needs a paid API or a cloud account.
M23: Agentic Coding Workflows & Quality Gates
Once an agent can write code — and commit it — the bottleneck stops being "can it produce output?" and becomes "can I trust the output enough to let it merge?" This module is about the engineering scaffolding that makes an agent a safe contributor: reusable workflow files so you stop re-typing prompts, workflow frameworks to orchestrate multi-step coding tasks, structural linting that catches what regex cannot, and pre-commit gates that turn a failing check into a lesson the agent fixes itself.
Learning Objectives
- Explain why agent-written code needs stricter automated gates than human-written code, not looser ones
- Turn an ad-hoc prompt into a versioned, parameterized reusable workflow file and run it from code
- Build a minimal workflow framework: a spec → generate → verify → iterate loop driven by local Mistral
- Use structural linting (
ruff,bandit,ast-grep) to catch semantic problems a text search would miss - Wire a pre-commit gate that blocks bad commits and feeds the failure back to the agent as a correction loop
- Layer local pre-commit + server-side CI so a skipped local hook still cannot reach
main - Build and run a self-correcting commit gate end to end against Ollama
Why Agent-Written Code Is Different
BEFORE: Imagine you hire a tireless junior developer who types 200 words per minute, never gets bored, and is happy to attempt any task at 3 a.m. You ask for a function; thirty seconds later it exists, with tests, looking completely plausible. For simple tasks this feels like magic — you got a week of grunt work done before lunch.
PAIN: The pain is that "looks completely plausible" is exactly the failure mode. A human junior who is unsure pauses, asks a question, or leaves a TODO. The agent never pauses. It confidently invents an API that does not exist, hardcodes a password to make a test pass, swallows an exception to make an error "go away," or silently deletes a check it did not understand. And it does this at machine speed, across dozens of files, with a commit message that says "done." Your code review — the thing that catches a careless human — cannot keep up with an agent that produces plausible-looking diffs faster than you can read them.
MAPPING: The fix is to stop relying on a human reading every line and instead put machines in the path: deterministic checks the agent must satisfy before its code is allowed to exist in your history. A linterA program that analyzes source code for problems without running it: style violations, likely bugs, unsafe patterns. "Lint" is the fuzz it picks off your code. Linters are deterministic — same input, same verdict every time. that rejects unsafe patterns. A security scanner that finds the hardcoded password. A pre-commit gateA set of checks that run automatically just before a commit is recorded. If any check fails, the commit is blocked and nothing enters Git history. Configured here with the open-source `pre-commit` framework. that blocks the commit and hands the agent the error to fix. The agent does not get annoyed by strict rules — it reads the error, fixes the code, and tries again. That is the whole game: humans review intent; machines enforce the floor.
A human developer might open 3–5 pull requests a day, each small enough to review by eye. A coding agent on a loop can open 40+ commits an hour. If your only safety net is "someone reads the diff," you have just moved the bottleneck from writing code to reviewing it — and a tired reviewer rubber-stamps. One hardcoded secret, one swallowed exception, one eval() on user input that slips through is a production incident. The gates in this module run in seconds, identically, on every single commit, so the human review can focus on "is this the right change?" instead of "did it do something dangerous?"
Reusable Workflow Files
A reusable workflow file is a prompt-plus-procedure saved to disk as a versioned, parameterized file instead of being re-typed into a chat box each time. It captures a task you do repeatedly — "review this diff for security issues," "write a docstring for this function," "generate a migration for this schema change" — with named slots ({{diff}}, {{function}}) you fill in at run time. Because it is a file, it lives in your repo, gets code-reviewed, and improves over time. Think of it as the difference between explaining a recipe out loud every night and writing it on a card.
You have been writing prompts since M03. The shift here is treating a good prompt as source code: something you name, store, version, and reuse, rather than something you improvise. The animation shows why that matters. On the left, the ad-hoc path: a human re-types a slightly different prompt every time, so every run is a fresh, unrepeatable experiment. On the right, the workflow-file path: one reviewed file is filled with parameters and run identically a hundred times.
review.md from repo{{diff}}Anatomy of a Workflow File
A workflow file is just text with structure. A useful convention: frontmatter (metadata: name, model, allowed tools) on top, then the procedure as a templated prompt with named slots. Here is one for a security-focused diff review, plus a small runner that loads it, fills the slots, and runs it against local Mistral.
WHY: Stored in the repo, it is reviewed and reused instead of improvised per run
GOTCHA: Keep the output contract explicit (here: JSON only) so the runner can parse it deterministically
---
name: security-review
description: Review a code diff for security and safety problems
model: mistral
output: json
---
You are a careful security reviewer. Review the DIFF below and report problems.
Look specifically for:
- Hardcoded secrets (passwords, API keys, tokens)
- Use of eval/exec or shelling out with untrusted input
- Swallowed exceptions (except: pass) that hide failures
- SQL or command strings built by concatenation
Respond with ONLY a JSON object, no prose:
{"issues": [{"severity": "low|medium|high", "line": int, "problem": str, "fix": str}],
"approved": bool}
DIFF:
{{diff}}
WHY: One generic runner executes any workflow file — the procedure lives in data, not code
GOTCHA: Validate the model's JSON against the file's declared
output contract; small models drift# run_workflow.py — load a workflow file, fill params, run it on local Mistral
# WHAT: A generic executor for reusable workflow files
# WHY: The procedure is data (a .md file); this code just drives it
# GOTCHA: Frontmatter is between the first two '---' lines; parse defensively
import sys, json, re
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
def load_workflow(path: str) -> tuple[dict, str]:
text = open(path, encoding="utf-8").read()
m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.DOTALL)
if not m:
raise ValueError(f"{path}: missing --- frontmatter --- block")
meta_block, body = m.group(1), m.group(2)
# Tiny YAML-ish parser: "key: value" per line (good enough for flat metadata)
meta = {}
for line in meta_block.splitlines():
if ":" in line:
k, v = line.split(":", 1)
meta[k.strip()] = v.strip()
return meta, body
def run_workflow(path: str, params: dict) -> dict:
meta, body = load_workflow(path)
# Fill {{name}} slots; fail loudly if a required slot was left unfilled
prompt = re.sub(r"\{\{(\w+)\}\}", lambda mm: str(params[mm.group(1)]), body)
resp = client.chat.completions.create(
model=meta.get("model", "mistral"),
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
raw = resp.choices[0].message.content.strip()
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
if meta.get("output") == "json":
return json.loads(raw) # raises if the model didn't honor the contract
return {"text": raw}
if __name__ == "__main__":
diff = sys.stdin.read() # pipe a diff in: git diff | python run_workflow.py
try:
result = run_workflow("workflows/review.md", {"diff": diff})
print(json.dumps(result, indent=2))
sys.exit(0 if result.get("approved") else 1) # exit code = the verdict
except json.JSONDecodeError as e:
print(f"workflow output was not valid JSON: {e}", file=sys.stderr)
sys.exit(2)
except KeyError as e:
print(f"workflow needs a parameter that wasn't supplied: {e}", file=sys.stderr)
sys.exit(2)
// run_workflow.js — load a workflow file, fill params, run it on local Mistral
// WHAT: A generic executor for reusable workflow files
// WHY: The procedure is data (a .md file); this code just drives it
// GOTCHA: Frontmatter is between the first two '---' lines; parse defensively
import fs from "node:fs";
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "http://localhost:11434/v1", apiKey: "ollama" });
function loadWorkflow(path) {
const text = fs.readFileSync(path, "utf8");
const m = text.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!m) throw new Error(`${path}: missing --- frontmatter --- block`);
const meta = {};
for (const line of m[1].split("\n")) {
const i = line.indexOf(":");
if (i !== -1) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
return { meta, body: m[2] };
}
async function runWorkflow(path, params) {
const { meta, body } = loadWorkflow(path);
const prompt = body.replace(/\{\{(\w+)\}\}/g, (_, name) => {
if (!(name in params)) throw new Error(`missing parameter: ${name}`);
return String(params[name]);
});
const resp = await client.chat.completions.create({
model: meta.model || "mistral",
temperature: 0,
messages: [{ role: "user", content: prompt }],
});
let raw = resp.choices[0].message.content.trim()
.replace(/^```json/, "").replace(/^```/, "").replace(/```$/, "").trim();
return meta.output === "json" ? JSON.parse(raw) : { text: raw };
}
const chunks = [];
for await (const c of process.stdin) chunks.push(c); // git diff | node run_workflow.js
try {
const result = await runWorkflow("workflows/review.md", { diff: Buffer.concat(chunks).toString() });
console.log(JSON.stringify(result, null, 2));
process.exit(result.approved ? 0 : 1); // exit code = the verdict
} catch (e) {
const code = e instanceof SyntaxError ? 2 : 2; // bad output / bad params
console.error(e.message);
process.exit(code);
}
You separated the procedure (a reviewed, versioned review.md) from the executor (one generic runner). Now git diff | python run_workflow.py runs the exact same security review every time, and the exit code reflects the verdict so a script can act on it. Add a workflows/docstring.md or workflows/migration.md and the same runner executes them with zero new code. That is the payoff of treating prompts as files: they compose, they version, and they become the unit a framework orchestrates — which is exactly the next step.
Workflow Frameworks
A workflow framework is the harness that orchestrates multi-step agent work: it sequences steps, carries state between them, retries on failure, and decides when to stop. You met the bones of this in the ReAct loop (M12) and planning (M13). For coding specifically, the canonical shape is spec-drivenAn approach where a written specification — the desired behavior and acceptance tests — is the source of truth. The agent generates code to satisfy the spec, and the spec's tests are the objective check on whether it succeeded.: a written spec defines success, the agent generates code, an objective check (the tests) grades it, and the loop iterates until the check passes or a budget runs out. Open-source coding harnesses like Aider and OpenHands are full frameworks of this kind; here you will build the minimal version so you understand the moving parts.
The defining feature of a coding framework — the thing that separates it from "ask the model and hope" — is that the grader is objective and external to the model. The tests either pass or they do not. The model's opinion of its own work does not count. That single design choice is what lets the loop run unattended without drifting into confident nonsense.
WHY: The external test result — not the model's self-assessment — decides success and drives the next step
GOTCHA: Always cap the iterations; a model that can't satisfy the spec will otherwise loop forever
# build_from_spec.py — a tiny spec-driven coding framework on local Mistral
# WHAT: Generate code to satisfy a spec, run its tests, feed failures back, repeat
# WHY: The test suite is the objective grader; the loop iterates until it's green
# GOTCHA: A hard max_iters cap is mandatory — the model may never satisfy the spec
import subprocess, sys, re
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
SPEC = """Write a Python function `slugify(text: str) -> str` that lowercases text,
replaces any run of non-alphanumeric characters with a single hyphen, and strips
leading/trailing hyphens. slugify('Hello, World!') must return 'hello-world'."""
TESTS = '''import pytest
from solution import slugify
def test_basic(): assert slugify("Hello, World!") == "hello-world"
def test_trim(): assert slugify("--Already--Slugged--") == "already-slugged"
def test_empty(): assert slugify("") == ""
'''
def extract_code(reply: str) -> str:
# Pull the first fenced code block; fall back to the whole reply
m = re.search(r"```(?:python)?\n(.*?)```", reply, re.DOTALL)
return (m.group(1) if m else reply).strip()
def run_tests() -> tuple[bool, str]:
proc = subprocess.run([sys.executable, "-m", "pytest", "-q", "test_solution.py"],
capture_output=True, text=True, timeout=60)
return proc.returncode == 0, proc.stdout + proc.stderr
def build(max_iters: int = 4) -> bool:
open("test_solution.py", "w").write(TESTS)
messages = [{"role": "system", "content": "You are a precise Python engineer. Return only code."},
{"role": "user", "content": SPEC}]
for i in range(max_iters): # the mandatory iteration cap
reply = client.chat.completions.create(model="mistral", messages=messages,
temperature=0).choices[0].message.content
open("solution.py", "w").write(extract_code(reply))
passed, output = run_tests() # the OBJECTIVE grader
print(f"[iter {i}] tests {'PASSED' if passed else 'FAILED'}", file=sys.stderr)
if passed:
return True
# Feed the real failure back so the next attempt is informed, not random
messages += [{"role": "assistant", "content": reply},
{"role": "user", "content": f"The tests failed:\n{output}\nFix the code."}]
return False
if __name__ == "__main__":
sys.exit(0 if build() else 1)
// build_from_spec.js — a tiny spec-driven coding framework on local Mistral
// WHAT: Generate code to satisfy a spec, run its tests, feed failures back, repeat
// WHY: The test run is the objective grader; the loop iterates until it's green
// GOTCHA: A hard maxIters cap is mandatory — the model may never satisfy the spec
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "http://localhost:11434/v1", apiKey: "ollama" });
const SPEC = `Write a JS function slugify(text) that lowercases text, replaces any run
of non-alphanumeric characters with a single hyphen, and strips leading/trailing
hyphens. slugify('Hello, World!') must return 'hello-world'. Export it with
module.exports = { slugify }.`;
const TESTS = `const { slugify } = require("./solution");
const assert = require("node:assert");
assert.strictEqual(slugify("Hello, World!"), "hello-world");
assert.strictEqual(slugify("--Already--Slugged--"), "already-slugged");
assert.strictEqual(slugify(""), "");
console.log("ok");`;
const extractCode = (reply) => {
const m = reply.match(/```(?:javascript|js)?\n([\s\S]*?)```/);
return (m ? m[1] : reply).trim();
};
function runTests() {
try {
execFileSync("node", ["test_solution.js"], { stdio: "pipe", timeout: 60000 });
return { passed: true, output: "ok" };
} catch (e) {
return { passed: false, output: (e.stdout || "") + (e.stderr || "") };
}
}
async function build(maxIters = 4) {
fs.writeFileSync("test_solution.js", TESTS);
const messages = [
{ role: "system", content: "You are a precise JS engineer. Return only code." },
{ role: "user", content: SPEC },
];
for (let i = 0; i < maxIters; i++) { // the mandatory iteration cap
const reply = (await client.chat.completions.create(
{ model: "mistral", messages, temperature: 0 })).choices[0].message.content;
fs.writeFileSync("solution.js", extractCode(reply));
const { passed, output } = runTests(); // the OBJECTIVE grader
console.error(`[iter ${i}] tests ${passed ? "PASSED" : "FAILED"}`);
if (passed) return true;
messages.push({ role: "assistant", content: reply },
{ role: "user", content: `The tests failed:\n${output}\nFix the code.` });
}
return false;
}
process.exit((await build()) ? 0 : 1);
Read the loop as four moving parts — this is the skeleton every coding framework shares:
- The spec and the tests are fixed up front.
SPECis what the model reads;TESTSis how we grade it. Crucially the model never sees a way to "pass" except by making real code that satisfies real assertions — there is no self-grading to game. - Generate, then write to disk. The model's reply is run through
extract_code(models love to wrap code in prose and fences) and written assolution.py. Nothing is trusted yet. - Run the external grader.
run_testsshells out topytestin a subprocess with a timeout. The return code — not the model — is the verdict. This is the line that makes the framework trustworthy. - Feed the real failure back. On failure, the actual test output is appended to the conversation and the loop tries again, now informed by what broke. Bounded by
max_iters, the worst case is a clean "couldn't do it" (exit 1), never an infinite spin.
A 7B model like Mistral often needs 2–3 iterations where a frontier model nails slugify in one. That is not a bug in your loop — it is exactly why the loop exists. The iterate-on-real-test-output pattern turns a weaker model into a reliable one: it does not have to be right the first time, only right eventually, with the grader catching every wrong attempt. Budget a slightly higher max_iters (4–6) for small models, and keep the spec and tests tight so each retry has clear signal.
eval() on user input. Catching that class of problem is the job of structural linting.Structural Code Linting
BEFORE: Your first instinct for "ban eval() in agent output" is a text search: grep -r "eval(" .. It is fast, it is simple, and for about a day it works.
PAIN: Then the false positives and false negatives roll in. grep flags self.evaluate() and a comment that says "don't eval this" — noise. Worse, it misses the real thing: the agent wrote e = eval; e(user_input), or getattr(builtins, "ev"+"al"), or put the dangerous call on a line your regex did not anticipate. Text search sees characters, not meaning. It cannot tell a function call from the same letters in a string, so it is simultaneously too noisy and too leaky to be a security gate.
MAPPING: A structural linterA linter that analyzes the code's Abstract Syntax Tree (its parsed grammatical structure) rather than its raw text. It can match "a call to eval with any argument" regardless of spacing, aliasing, or surrounding text — because it sees the actual call node, not the characters. reads the code the way the interpreter does: it parses the source into an ASTAbstract Syntax Tree: a tree representation of source code's structure. `eval(x)` becomes a Call node whose function is the name `eval`. Matching on the tree is immune to whitespace, comments, and string look-alikes. and matches on the shape of the code — "a call to eval with any argument" — immune to spacing, comments, and string look-alikes. Tools like ruff (style + bugs), bandit (security), and ast-grep (your own structural rules) do exactly this, deterministically, in milliseconds.
Three Tools, Three Jobs
| Tool | Catches | Example it flags in agent code |
|---|---|---|
ruff | Style violations & likely bugs (a fast linter + formatter) | Unused import, undefined name, mutable default arg |
bandit | Known security anti-patterns in Python | Hardcoded password, subprocess with shell=True, eval() |
ast-grep | Your own structural rules, any language | "No except: pass", "no print in library code" |
The first two are batteries-included: point them at a file and they apply a curated rule set. ast-grep is where you encode your policy. Here is a rule that bans swallowed exceptions — the except: pass pattern an agent loves to use to make an error "go away."
WHY: It catches
except Exception: pass however it is spaced or named, and never false-matches a stringGOTCHA: Structural rules match the parse tree — test them on real code, since an over-broad pattern can flag legitimate uses
# rules/no-swallowed-except.yml — run: ast-grep scan -r rules/no-swallowed-except.yml .
id: no-swallowed-except
language: python
severity: error
message: "Swallowed exception: handle the error or re-raise, never 'except: pass'."
rule:
kind: try_statement
has:
kind: except_clause
has:
# the body of the except is a single 'pass' statement
kind: block
has:
kind: pass_statement
not:
has:
kind: expression_statement # ...and nothing else in the block
# Install once (all free, all offline after download):
pip install ruff bandit
# ast-grep: brew install ast-grep | cargo install ast-grep | npm i -g @ast-grep/cli
# 1) ruff — style + likely bugs, auto-fixable
ruff check solution.py
# solution.py:3:1: F401 [*] `os` imported but unused
# 2) bandit — security anti-patterns
bandit -q solution.py
# >> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'hunter2'
# Severity: Low Confidence: Medium Location: solution.py:7
# 3) ast-grep — your own structural policy
ast-grep scan -r rules/no-swallowed-except.yml solution.py
# error[no-swallowed-except]: Swallowed exception: handle the error or re-raise.
# --> solution.py:12:5
# Any non-zero exit from these = the gate fails. Chain them:
ruff check solution.py && bandit -q solution.py && ast-grep scan -r rules/ solution.py
A regex gate is a security theater you will eventually regret. Consider an agent that needs to bypass your grep "eval" filter — or simply writes idiomatic code that happens to dodge it. eval (user_input) with a space, builtins.eval(x), or aliasing ev = eval all sail past text search but are identical to the AST: a call whose target resolves to eval. A structural rule matches the call node itself, so spacing, aliasing, and comments cannot evade it, and a string literal "see the eval docs" cannot trigger a false alarm. For a gate that decides whether agent code reaches production, that determinism is the whole point.
You now have three deterministic judges of agent code: ruff for correctness-adjacent style, bandit for known Python security holes, and ast-grep for the policies that are specific to your codebase. Each exits non-zero on a violation, which means each is a building block a gate can branch on — the same exit-code contract you used for headless agents in M21C. The final step is to put these judges in front of the commit, so failing code physically cannot enter your history.
Pre-Commit Gates
A pre-commit gate is a set of checks Git runs automatically just before a commit is recorded. If any check fails, the commit is blocked — nothing enters history. The open-source pre-commit framework manages these as a list of hooks in a .pre-commit-config.yaml file: whitespace fixers, ruff, bandit, ast-grep, secret scanners. For a human it is a safety net; for an agent it is something stronger — a teacher. The agent hits the failing gate, reads the error, fixes the code, and re-commits. The gate turns a vague "write good code" into a concrete, machine-checkable loop.
The animation traces that loop — the single most important idea in this module. The agent writes code and tries to commit. The gate runs and rejects it. The agent reads the specific error, fixes exactly that, and commits again. This time the gate passes and the commit lands. The rejection is not a dead end; it is feedback the agent acts on.
Configuring the Gate
The pre-commit framework reads one file and installs Git hooks for you. Each entry is a hook that runs on the files about to be committed; a non-zero exit blocks the commit.
WHY: One file makes every commit — human or agent — pass the same floor before it can be recorded
GOTCHA: Order matters; put the cheap auto-fixers first so the expensive checks run on already-tidied files
# .pre-commit-config.yaml — the gate every commit must pass
repos:
# Layer 1: cheap hygiene — whitespace, file size, valid YAML/JSON
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-added-large-files # block a 200MB blob from an agent gone wrong
- id: check-yaml
# Layer 2: ruff — lint + format, auto-fixing what it safely can
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
# Layer 3: bandit — security scan, fails the commit on a finding
- repo: https://github.com/PyCQA/bandit
rev: 1.7.10
hooks:
- id: bandit
args: ["-q"]
# Layer 4: your structural policy via ast-grep (local hook)
- repo: local
hooks:
- id: ast-grep-policy
name: ast-grep structural rules
entry: ast-grep scan -r rules/
language: system
types: [python]
# Install the framework and wire it into this repo's .git/hooks
pip install pre-commit
pre-commit install # now the hooks fire on every `git commit`
# What a blocked commit looks like (agent committed a hardcoded password):
$ git commit -m "add db client"
trailing-whitespace.................................................Passed
ruff................................................................Passed
ruff-format.........................................................Passed
bandit..............................................................Failed
- hook id: bandit
- exit code: 1
>> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'hunter2'
Location: db.py:7
# --> the commit did NOT happen. Nothing entered history.
# Run the whole gate by hand any time (e.g. inside an agent loop):
pre-commit run --all-files ; echo "gate exit=$?"
Closing the Loop: the Agent Fixes Its Own Rejection
Here is where it all comes together. A small driver lets the agent commit, and when the gate rejects the commit, it feeds the gate's exact output back to Mistral and asks for a fix — the correction loop, in code.
WHY: This is the "gate as teacher" pattern — the agent improves from the real error, not a guess
GOTCHA: Cap retries and stop on a clean pass or exhaustion; never loop on the gate forever
# commit_with_fixups.py — let the gate teach the agent until the commit is clean
# WHAT: Try to commit; if the gate blocks it, send the error back to Mistral, fix, retry
# WHY: Turns "write good code" into a concrete loop graded by deterministic hooks
# GOTCHA: Bound the retries; a gate the model can't satisfy must fail loudly, not spin
import subprocess, sys
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
TARGET = "db.py"
def run_gate() -> tuple[bool, str]:
# Run the full pre-commit gate on staged files; non-zero = blocked
proc = subprocess.run(["pre-commit", "run", "--files", TARGET],
capture_output=True, text=True, timeout=120)
return proc.returncode == 0, proc.stdout + proc.stderr
def ask_fix(code: str, gate_output: str) -> str:
resp = client.chat.completions.create(
model="mistral", temperature=0,
messages=[
{"role": "system", "content": "You are a Python engineer. Return only the corrected file."},
{"role": "user", "content":
f"This file was rejected by our commit gate.\n\nFILE ({TARGET}):\n{code}\n\n"
f"GATE OUTPUT:\n{gate_output}\n\nReturn the full corrected file."},
])
reply = resp.choices[0].message.content.strip()
return reply.removeprefix("```python").removeprefix("```").removesuffix("```").strip()
def commit_clean(max_fixups: int = 3) -> int:
for attempt in range(max_fixups + 1):
subprocess.run(["git", "add", TARGET], check=True)
passed, output = run_gate()
if passed:
subprocess.run(["git", "commit", "-m", "agent change (gate-clean)"], check=True)
print(f"[gate] passed on attempt {attempt}; commit landed", file=sys.stderr)
return 0
print(f"[gate] blocked on attempt {attempt}:\n{output}", file=sys.stderr)
if attempt == max_fixups:
break
fixed = ask_fix(open(TARGET, encoding="utf-8").read(), output)
open(TARGET, "w", encoding="utf-8").write(fixed) # the agent's correction
print("[gate] still failing after retries — escalate to a human", file=sys.stderr)
return 2 # bad output: do not auto-merge
if __name__ == "__main__":
sys.exit(commit_clean())
// commit_with_fixups.js — let the gate teach the agent until the commit is clean
// WHAT: Try to commit; if the gate blocks it, send the error back to Mistral, fix, retry
// WHY: Turns "write good code" into a concrete loop graded by deterministic hooks
// GOTCHA: Bound the retries; a gate the model can't satisfy must fail loudly, not spin
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "http://localhost:11434/v1", apiKey: "ollama" });
const TARGET = "db.py";
function runGate() {
try {
execFileSync("pre-commit", ["run", "--files", TARGET], { stdio: "pipe", timeout: 120000 });
return { passed: true, output: "" };
} catch (e) {
return { passed: false, output: (e.stdout || "") + (e.stderr || "") };
}
}
async function askFix(code, gateOutput) {
const resp = await client.chat.completions.create({
model: "mistral", temperature: 0,
messages: [
{ role: "system", content: "You are a Python engineer. Return only the corrected file." },
{ role: "user", content:
`This file was rejected by our commit gate.\n\nFILE (${TARGET}):\n${code}\n\n` +
`GATE OUTPUT:\n${gateOutput}\n\nReturn the full corrected file.` },
],
});
return resp.choices[0].message.content.trim()
.replace(/^```python/, "").replace(/^```/, "").replace(/```$/, "").trim();
}
async function commitClean(maxFixups = 3) {
for (let attempt = 0; attempt <= maxFixups; attempt++) {
execFileSync("git", ["add", TARGET]);
const { passed, output } = runGate();
if (passed) {
execFileSync("git", ["commit", "-m", "agent change (gate-clean)"]);
console.error(`[gate] passed on attempt ${attempt}; commit landed`);
return 0;
}
console.error(`[gate] blocked on attempt ${attempt}:\n${output}`);
if (attempt === maxFixups) break;
const fixed = await askFix(fs.readFileSync(TARGET, "utf8"), output);
fs.writeFileSync(TARGET, fixed); // the agent's correction
}
console.error("[gate] still failing after retries — escalate to a human");
return 2; // bad output: do not auto-merge
}
process.exit(await commitClean());
The gate and the agent are now in a closed loop. The agent commits; the deterministic hooks judge; on rejection the exact gate output (e.g. "B105 hardcoded password, line 7") goes back to the model, which fixes that specific thing and re-commits. Success lands the commit and returns 0; exhausting the retries returns 2 so an outer system escalates instead of merging junk. Notice no human read a line of code — yet nothing unsafe could enter history, because the floor is enforced by machines, exactly as the opening analogy promised.
One Gate Is Not Enough: Pre-Commit + CI
A local pre-commit hook protects your machine, but it can be skipped (git commit --no-verify), misconfigured, or simply absent on a teammate's or an agent's clone. So the same checks must run again on a clean server after the code is pushed — in CIContinuous Integration: a server that automatically runs your checks and tests on every push, on a fresh clean machine. Because it cannot be skipped with --no-verify, it is the authoritative gate before code merges.. Two layers: pre-commit catches mistakes before the commit; CI catches them before the merge.
WHY: CI cannot be skipped with
--no-verify, so it is the authoritative gate before mergeGOTCHA: Agents push many small updates fast — the concurrency block cancels stale runs so you don't waste CI on outdated code
# .github/workflows/gate.yml — the same checks, re-run where they can't be skipped
name: Quality gate
on: [push, pull_request]
# Agents push many small commits; cancel superseded runs to save CI minutes.
concurrency:
group: gate-${{ github.ref }}
cancel-in-progress: true
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- name: Install the gate
run: pip install pre-commit
# Re-run the IDENTICAL hooks from .pre-commit-config.yaml on a clean machine.
# A non-zero exit fails the job and blocks the merge on a protected branch.
- name: Run pre-commit on all files
run: pre-commit run --all-files --show-diff-on-failure
--no-verify Exists
Local hooks are advisory: anyone — or any agent — can bypass them with git commit --no-verify, and a fresh clone that never ran pre-commit install has no hooks at all. If local were your only gate, the one commit that skips it is exactly the one that breaks production. CI re-running the same config on a clean server, gating a protected branch that refuses merges on a red check, is what makes the floor non-optional. Pre-commit is the fast feedback you want; CI is the guarantee you need.
Lab: A Self-Correcting Commit Gate
What you'll build: a coding agent that writes a small module, tries to commit it, and — when the pre-commit gate rejects unsafe code — reads the error and fixes itself until the commit is clean or it escalates. Time: ~40 min. Prereqs: Ollama running with mistral pulled; git; Python 3.12.
mkdir gate-lab && cd gate-lab && git init
pip install openai ruff bandit pre-commit
# ast-grep: brew install ast-grep (or) cargo install ast-grep (or) npm i -g @ast-grep/cli
ollama pull mistral # the local model the agent uses
mkdir -p rules workflows
Create .pre-commit-config.yaml from the "Configuring the Gate" section (all four layers) and rules/no-swallowed-except.yml from the linting section. Run pre-commit install. Verify the gate is live: pre-commit run --all-files should execute and pass on an empty repo.
Prompt Mistral to "write db.py: a function that connects to Postgres." Small models frequently hardcode a password and wrap the connect in try/except: pass — exactly what your gate catches. Write the reply to db.py. (If it happens to write safe code, inject a password = "hunter2" line so you can see the gate fire.)
Run git add db.py && git commit -m "add db client". Confirm the commit is blocked with a bandit B105 (hardcoded password) and/or your ast-grep swallowed-exception error, and that git log shows nothing landed.
Use commit_with_fixups.py from the gates section. It runs the gate, feeds the exact failure back to Mistral, writes the corrected file, and retries up to 3 times. Watch stderr: you should see "blocked on attempt 0" then "passed on attempt 1" (or 2) as the model removes the hardcoded secret and fixes the exception handling.
Add .github/workflows/gate.yml from the two-layer section. Even without pushing to GitHub, confirm you understand the contract: it re-runs pre-commit run --all-files on a clean machine, so a teammate who ran --no-verify still gets caught before merge.
A flawed first attempt is blocked by the gate (nothing in git log); the correction loop drives the agent to a clean version and lands exactly one gate-clean commit; exhausting retries returns exit code 2 and escalates instead of merging; and you can articulate why the CI job is required even though the local hook already passed. That is a production-shaped agentic coding workflow: humans review intent, machines enforce the floor.
Knowledge Check
Test your understanding of reusable workflow files, workflow frameworks, structural linting, and pre-commit gates.
1. Why does agent-written code need stricter automated gates than human-written code, rather than looser ones?
2. What is the main advantage of a reusable workflow file over re-typing a prompt each time?
3. In the spec-driven framework, what makes the loop trustworthy enough to run unattended?
4. Why is a structural linter (AST-based) better than grep "eval(" as a security gate?
5. The module calls a failing pre-commit gate a "teacher" for an agent. Why?
6. You have a local pre-commit gate. Why is a CI job re-running the same checks still necessary?
--no-verify), misconfigured, or absent on a fresh clone; CI re-runs on a clean server and gates a protected branch, so the floor is non-optional