DEV Community

Quinn Sun
Quinn Sun

Posted on

Write the Change Surface First: A Pairing Session on Cheap Agent Patches

The pairing started with a green test suite and a messy prompt. A mid-level engineer opened a coding agent, dumped three packages from a brownfield Node service, and asked it to thread a request id through HTTP logs. The senior did not look at the generated files. They looked at the prompt.

The session below is a reconstructed pairing example for a small backend change. It is not a customer case study, and it does not claim production metrics.

The scene

The bug was real. Access logs showed timeouts, but the request id lived only in the HTTP layer. Downstream jobs printed timestamps and nothing else. The pair had forty-five minutes and a free-tier agent on a shared machine.

The junior's first prompt named the symptom and attached too much code. The senior stopped the send. Cheap generation makes a wide patch feel free. Pairing time does not.

Questions the senior asked out loud

The senior treated the agent like a contractor who had never seen the repo. The questions went on a notepad before any model call.

  1. Which files may change in this session?
  2. Which test already fails, or which test will be added first?
  3. What existing helper must be reused instead of rewritten?
  4. What is the maximum diff the pair is willing to review before lunch?
  5. Who writes the commit message, and who is not allowed to run git add?

None of those items were about model brands. They were about blast radius. Review debt grows when AI-generated code is cheap and file boundaries are implied in prose.

Dead end 1: let it try

The junior argued that a free model is inexpensive enough to spray attempts. They sent the original prompt.

The agent produced a new logger.js, a wrapper around console, and a drive-by rename in a config loader. Tests still passed because the loader's tests stubbed I/O. The pair spent twelve minutes reverting files the task never needed.

The failure was not "the model is bad." The failure was an unbounded change surface. The agent optimized for a complete-looking logging story. The pair needed a request id in two call sites.

Dead end 2: add caution to the prompt

The second attempt kept the same attachment and added a paragraph of manners. Do not invent files. Do not refactor neighbors. Prefer the smallest patch.

The reply was politer. The diff was still wide. A shared context object gained fields the HTTP handler never read. The senior called it prompt theater: extra words without a mechanical check.

Caution in natural language is not a gate. The pair needed something that fails closed.

Dead end 3: generate first, review later

The third idea was to let the agent finish, then review the result as if it were a human pull request. That is how the pair already used async review.

It failed for a different reason. The dump was hundreds of lines across nine files. Two people staring at an unbounded diff is how a pairing session turns into silence. Async review can absorb a large patch over a day. A pairing block cannot.

The senior closed that path with a rule. If the pair cannot name the files before the model runs, the model does not run.

The decision that stayed

After the three dead ends, the pair kept one protocol.

  • Write a change-surface card before the first agent call.
  • Add or adjust a failing test that mentions the request id.
  • Run a local gate that rejects any patch outside the card.
  • Let the agent propose a patch. Humans own git add and the commit message.

The agent stayed in the loop. It lost the right to choose the loop. That decision is boring on purpose. It survives a free model, a paid model, and a model swap next month. The card is the pairing artifact. The model is a patch source.

Artifact: the change-surface card

The card is a small file in the repo. The example below is a proposal for the logging-context task. It is not executed against a public service in this article.

# pairing/change-surface.yaml
task: thread request-id into job logs
owner: pairing-session
allow_paths:
  - src/http/request-context.js
  - src/jobs/worker.js
  - test/jobs/worker.test.js
forbid_paths:
  - src/config/**
  - src/logger.js
must_reuse:
  - src/http/request-context.js
max_files: 3
max_changed_lines: 80
failing_test: test/jobs/worker.test.js
commit_owner: human
agent_may_git_add: false
notes: >
  Do not create a new logger. Read request id from
  request-context.getId(). Worker tests must assert the id
  appears on the error path.
Enter fullscreen mode Exit fullscreen mode

The senior filled allow_paths while the junior wrote the failing test. That order mattered. Files first. Prompt second.

A matching prompt stub stayed short because the card already held the constraints:

Task: make job error logs include the existing request id.
Read pairing/change-surface.yaml and obey it.
Do not add files. Do not edit forbid_paths.
Reuse request-context.getId(). Return a unified diff only.
Enter fullscreen mode Exit fullscreen mode

Map questions to fields

The notepad questions were not ceremony. Each one became a field the gate can see.

  • Allowed files became allow_paths and max_files.
  • The test question became failing_test.
  • The reuse question became must_reuse and forbid_paths.
  • The review budget became max_changed_lines.
  • Commit ownership became commit_owner and agent_may_git_add.

If a field cannot be filled, the pair does not have a task yet. They have a fishing expedition. Fishing expeditions are valid. They are not pairing-with-an-agent tasks.

A gate the pair can run before git add

Prompts do not enforce cards. A script does. The Node helper below reads JSON to avoid extra parsers, then inspects git diff against HEAD. Treat it as a local proposal, not a benchmark.

{
  "allow_paths": [
    "src/http/request-context.js",
    "src/jobs/worker.js",
    "test/jobs/worker.test.js"
  ],
  "forbid_paths": ["src/config", "src/logger.js"],
  "max_files": 3,
  "max_changed_lines": 80
}
Enter fullscreen mode Exit fullscreen mode
#!/usr/bin/env node
// scripts/check-change-surface.mjs
// Proposal: fail closed on diffs that escape the pairing card.
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";

const card = JSON.parse(readFileSync("pairing/change-surface.json", "utf8"));
const diff = execSync("git diff --name-only HEAD", { encoding: "utf8" })
  .trim()
  .split("\n")
  .filter(Boolean);

if (diff.length === 0) {
  console.error("change-surface: no edits versus HEAD");
  process.exit(1);
}

const escaped = diff.filter((f) => !card.allow_paths.includes(f));
const forbidden = diff.filter((f) =>
  card.forbid_paths.some((p) => f === p || f.startsWith(p + "/"))
);

const stat = execSync("git diff --numstat HEAD", { encoding: "utf8" }).trim();
let changed = 0;
for (const line of stat.split("\n").filter(Boolean)) {
  const [add, del] = line.split("\t");
  if (add === "-" || del === "-") continue;
  changed += Number(add) + Number(del);
}

const errors = [];
if (diff.length > card.max_files) {
  errors.push(`too many files: ${diff.length} > ${card.max_files}`);
}
if (changed > card.max_changed_lines) {
  errors.push(`too many lines: ${changed} > ${card.max_changed_lines}`);
}
if (escaped.length) errors.push(`outside allow list: ${escaped.join(", ")}`);
if (forbidden.length) errors.push(`forbidden: ${forbidden.join(", ")}`);

if (errors.length) {
  console.error("change-surface gate failed:");
  for (const e of errors) console.error(`- ${e}`);
  process.exit(1);
}

console.log(`change-surface ok: ${diff.length} files, ${changed} lines`);
Enter fullscreen mode Exit fullscreen mode

Commands the pair ran in the example:

git status --short
git diff --stat HEAD
node scripts/check-change-surface.mjs
npm test -- test/jobs/worker.test.js
Enter fullscreen mode Exit fullscreen mode

If the gate fails, the pairing conversation returns to the card, not to a longer prompt. That is the whole point. A dirty working tree also fails, which is useful. Unrelated dirty files are how extra paths sneak into agent context.

A tiny failing test keeps the agent honest:

// test/jobs/worker.test.js  (sketch, unexecuted in this article)
import test from "node:test";
import assert from "node:assert/strict";
import { runJob } from "../../src/jobs/worker.js";
import { setId } from "../../src/http/request-context.js";

test("error logs include request id", async () => {
  setId("req-test-1");
  const lines = [];
  const restore = hookStderr(lines);
  await assert.rejects(() => runJob({ boom: true }));
  restore();
  assert.match(lines.join("\n"), /req-test-1/);
});
Enter fullscreen mode Exit fullscreen mode

hookStderr can be a few lines that monkey-patch process.stderr.write. The pairing rule is the assertion, not the harness.

Decision table the pair kept on the pad

Symptom Temptation Decision that stayed
Unbounded first prompt Paste more packages Fill allow_paths first
Polite but wide diff Stronger wording Run the gate
Dirty tree Ignore extra files Fail the gate
Rename exceeds the line budget Quietly raise the number later Edit the card in the pairing
Agent offers to commit Save a few keystrokes Humans own git add

The table is the pairing memory. The next session does not need to rediscover the dead ends.

Where a free model and a free server participate

The card and gate do not require a vendor. They require a shared shell and a patch generator that will obey a short prompt often enough to be worth gating.

MonkeyCode is relevant here only as one place to run that shell. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The project is open source. Operator-supplied capabilities used in this workflow are free model access and a free server option. This article does not claim model names, token quotas, hardware sizes, uptime, or benchmark wins.

On a shared free server, the senior and the junior see the same git diff and the same gate exit code. That removes a class of pairing noise: a patch that passed on one laptop and drifted on another. The free model is the patch generator behind the card. The server is where the gate and the targeted tests run before anyone copies a diff onto a machine they forgot to pull.

If a shared free server helps this loop, MonkeyCode is one option to try with the same card and script.

What this protocol does not do

It does not make a weak model produce a correct patch. It does not replace review for auth, crypto, or schema migrations. It does not stop an agent that is given git add and production credentials.

Line budgets can reject a legitimate rename. When that happens, edit the card in the pairing. Do not silently raise the number in a later commit. Path allowlists also miss generated files that should never be hand-edited; add those paths to forbid_paths when the repo has them.

The protocol assumes the pair can write a failing test. Without that, the gate only protects file paths, not behavior.

Who should skip this

Skip the card if the work is exploratory and the files are throwaway. Skip it if the repository has no tests at all and nobody will add one in the session. Skip it if the change is a mechanical rename that must touch dozens of files; the budget will lie, and a compiler or a codemod is the better pair.

Teams that already use stacked pull requests and tight CODEOWNERS may already have a stronger version of this. The pairing value is for groups that started using cheap coding agents and noticed review queues filling with polite, wide diffs.

Closing the session

The request id landed in worker.js and the test file. request-context.js gained no new API. The config loader was never opened. The commit message was written by the human who ran the gate.

The senior's last note on the pad was not about agents. It was about pairing: decide the surface, then generate. Cheap models make the second step easy. They do not make the first step optional.

Top comments (0)