DEV Community

Quinn Li
Quinn Li

Posted on

Letter to Wednesday-Me: One Contract Per Ticket, Then Prompt

The remote shell was still warm at noon. Two open tickets had shared one model session. The helper compiled after two mixed prompts ran.

Letter to Wednesday-Me

Dear Wednesday-Me,

You will open a remote box today. You will paste a vague prompt into it. You will lose a day to silent drift.

Read this before that first prompt. This note reconstructs a failed lab day. It is a procedure, not a scoreboard.

The scene you will repeat

A query parser needed two small changes. Ticket A required a strict key allowlist. Ticket B required CSV values for tags.

You used one chat session for both. The model mixed the two rule sets. Ticket A tests still passed on green.

Ticket B wrote into the same module. Review saw a compile and a smile. Production later saw merged keys as arrays.

The loss was not model spend. The loss was a dirty worktree. The loss was a blurry replay log.

What this letter will give you

You get a contract-first remote loop. You get logs outside the git tree. You get a one-ticket session rule.

The artifact is a JSON contract. The artifact is a tiny gate script. The artifact is a Node acceptance test.

A remote lab can host that loop. MonkeyCode provides free model access. It also provides a free server option.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Drop those product lines if you want. The files below still stand alone.

Mistake 1: You prompted without a frozen contract

You described the parser inside chat. Chat is not a stable interface. The model invented new export names.

You accepted them after a clean compile. Compile is a weak judge here. Names drifted away from the ticket.

Step 1: Write the contract as a file

Create contracts/parse-query.v1.json first. The agent may read that file. The agent must not edit that file.

{
  "id": "parse-query.v1",
  "module": "src/parseQuery.js",
  "exported": ["parseQuery"],
  "input": {
    "type": "string",
    "name": "search"
  },
  "output": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "q": { "type": "string" },
      "page": { "type": "integer", "minimum": 1 },
      "tags": {
        "type": "array",
        "items": { "type": "string" },
        "maxItems": 8
      }
    },
    "required": []
  },
  "errors": ["empty_input", "unknown_key", "bad_page"],
  "forbidden": ["eval", "child_process", "fs"]
}
Enter fullscreen mode Exit fullscreen mode

Hash this file after you freeze it. That hash belongs in the lab header. Chat text is not a substitute hash.

Step 2: Pin the ticket to that id

Keep tickets/A.md under ten lines. State the contract id in one line. State the change that is in scope.

# Ticket A
Contract: parse-query.v1
Change: reject unknown keys with unknown_key
Out of scope: CSV parsing for tags
Do not edit contracts/
Session: ticket-a-only
Enter fullscreen mode Exit fullscreen mode

Ticket B gets a different file. Do not append B into this note. Mixed notes recreate mixed sessions.

Step 3: Refuse to prompt without both files

Use a gate script before any model call. Treat the script as a walkthrough. Stop when the contract is still writable.

#!/usr/bin/env bash
# walkthrough: gate-contract.sh
set -euo pipefail
ticket="${1:?ticket file}"
test -f "$ticket"
cid="$(awk '/^Contract:/{print $2}' "$ticket")"
test -n "$cid"
cf="contracts/${cid}.json"
test -f "$cf"
if [ -w "$cf" ]; then
  echo "contract_writable ${cf}" >&2
  exit 1
fi
echo "contract_ok ${cid} $(sha256sum "$cf" | awk '{print $1}')"
Enter fullscreen mode Exit fullscreen mode

Run the gate on the remote box.

chmod a-w contracts/parse-query.v1.json
bash gate-contract.sh tickets/A.md
Enter fullscreen mode Exit fullscreen mode

A writable contract invites silent edits. The agent will "help" by widening fields. You will not notice until review.

Mistake 2: You stored agent logs inside the worktree

Stdout landed in notes/agent.log. Git status became a second prompt. A later turn treated notes as fact.

The model quoted a rejected idea. You shipped that quote as code. The worktree could not prove origin.

Step 1: Create a sibling lab directory

Split source and evidence on disk. Keep git in $HOME/work/parse-query. Keep replay in $HOME/lab/ticket-a.

mkdir -p "$HOME/lab/ticket-a/replay"
mkdir -p "$HOME/work/parse-query"
# clone or copy the repo into $HOME/work/parse-query
Enter fullscreen mode Exit fullscreen mode

The lab tree is not a backup drive. It holds headers and model traces. It does not hold production secrets.

Step 2: Write a lab header, not a novel

Record ticket id, contract hash, git head. Record dirty file count as a number. Refuse to start when that count is not zero.

#!/usr/bin/env bash
# walkthrough: lab-header.sh
set -euo pipefail
root="${1:?repo}"
out="${2:?header file}"
cf="contracts/parse-query.v1.json"
dirty="$(git -C "$root" status --porcelain | wc -l | tr -d ' ')"
if [ "$dirty" != "0" ]; then
  echo "dirty_tree ${dirty}" >&2
  exit 1
fi
{
  echo "ticket=${TICKET_ID:?}"
  echo "contract=$(sha256sum "$root/$cf" | awk '{print $1}')"
  echo "head=$(git -C "$root" rev-parse HEAD)"
  echo "dirty=${dirty}"
  echo "started=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$out"
echo "header_ok $out"
Enter fullscreen mode Exit fullscreen mode

A dirty tree means you do not start. Commit or stash before the prompt. Mixed files make mixed patches.

Step 3: Keep model output in the lab tree

Append model traces to model.ndjson only. Do not copy that file into git. Review the patch from git diff.

export TICKET_ID=ticket-a
bash lab-header.sh "$HOME/work/parse-query" \
  "$HOME/lab/ticket-a/replay/header.txt"
# append model turns only to:
# $HOME/lab/ticket-a/replay/model.ndjson
Enter fullscreen mode Exit fullscreen mode

Review the chat from the lab tree. Those two views must stay separate. Mixing them recreates today's failure.

Mistake 3: You reused one session for two tickets

Ticket B inherited Ticket A state. CSV parsing leaked into allowlist work. You cannot replay that mixed context.

One ticket needs one session. One session needs one replay directory. Close the session before the next clone.

Step 1: Kill the session at merge or reject

#!/usr/bin/env bash
# walkthrough: close-ticket.sh
set -euo pipefail
id="${1:?ticket id}"
lab="$HOME/lab/${id}"
test -d "$lab"
date -u +%Y-%m-%dT%H:%M:%SZ > "$lab/replay/closed.txt"
rm -f "$lab/session-id"
echo "closed ${id}"
Enter fullscreen mode Exit fullscreen mode

A closed file is the stop rule. No closed file means no Ticket B. Do not negotiate that rule in chat.

Step 2: Open Ticket B from a cold tree

cd "$HOME/work/parse-query"
git switch main
git pull --ff-only
git switch -c ticket-b
export TICKET_ID=ticket-b
mkdir -p "$HOME/lab/ticket-b/replay"
# start a new model session only after close-ticket.sh ticket-a
Enter fullscreen mode Exit fullscreen mode

Cold tree means no leftover context. Cold tree also means a new branch. Ticket B must not patch Ticket A files by habit.

Step 3: Add an acceptance test the contract owns

The test file is also frozen. The model does not edit tests. Revert any agent patch to tests.

// walkthrough: test/parseQuery.contract.test.mjs
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { parseQuery } from "../src/parseQuery.js";

const contract = JSON.parse(
  readFileSync("contracts/parse-query.v1.json", "utf8")
);

{
  const out = parseQuery("?q=hi&page=2");
  assert.equal(out.q, "hi");
  assert.equal(out.page, 2);
  assert.equal(Object.hasOwn(out, "debug"), false);
}

{
  assert.throws(() => parseQuery("?q=hi&debug=1"), /unknown_key/);
}

{
  const src = readFileSync("src/parseQuery.js", "utf8");
  for (const bad of contract.forbidden) {
    assert.equal(src.includes(bad), false, bad);
  }
}

console.log("contract_ok", contract.id);
Enter fullscreen mode Exit fullscreen mode

Run it on the remote box.

node --test test/parseQuery.contract.test.mjs
Enter fullscreen mode Exit fullscreen mode

Green means v1 still holds. Green does not close Ticket B. Ticket B needs parse-query.v2.json.

A decision table for the remote lab

Signal Action Do not
No contract file Write it, then chmod a-w Prompt from chat memory
Contract still writable Freeze it and rehash Trust yesterday's hash
Dirty git status Commit or stash first Start the agent
Replay files in the repo Move them to $HOME/lab Commit the chat log
Second ticket, live session Run close-ticket.sh Continue the same chat
Agent edited the test file Revert the test Bargain inside the session
Unknown key now accepted Fail Ticket A Widen the schema in chat
Need CSV tags Add parse-query.v2.json Stretch v1 "just this once"

Tape that table beside the terminal. It is the whole working method. Prompts without a matching row should stall.

A minimal parser that satisfies Ticket A

Label this as sample code. It is not production hardened. It exists to make the contract testable.

// walkthrough: src/parseQuery.js
const ALLOWED = new Set(["q", "page", "tags"]);

export function parseQuery(search) {
  if (typeof search !== "string" || search.length === 0) {
    const err = new Error("empty_input");
    err.code = "empty_input";
    throw err;
  }
  const q = search.startsWith("?") ? search.slice(1) : search;
  const out = {};
  if (q.length === 0) return out;
  for (const part of q.split("&")) {
    if (!part) continue;
    const eq = part.indexOf("=");
    const key = decodeURIComponent(eq === -1 ? part : part.slice(0, eq));
    const val = decodeURIComponent(eq === -1 ? "" : part.slice(eq + 1));
    if (!ALLOWED.has(key)) {
      const err = new Error("unknown_key");
      err.code = "unknown_key";
      throw err;
    }
    if (key === "page") {
      const n = Number.parseInt(val, 10);
      if (!Number.isInteger(n) || n < 1) {
        const err = new Error("bad_page");
        err.code = "bad_page";
        throw err;
      }
      out.page = n;
      continue;
    }
    if (key === "tags") {
      out.tags = val ? val.split(",").slice(0, 8) : [];
      continue;
    }
    out[key] = val;
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Notice tags already splits on commas. That line is a trap for Ticket B. V1 must not grow extra CSV rules in chat.

Ticket B still needs a new version. Do not stretch v1 inside the session. Add v2, then open a fresh session.

Where the free lab fits

You can run this loop locally. You should not do that often. Laptop shells hold leftover secrets.

A free remote server gives a clean tree. Free model access covers the contract loop. Neither one replaces the files above.

Clone one repo onto that box. Mount one ticket into that session. Copy back the patch, nothing else.

Limitations

This method is slow on purpose. It rejects mixed sessions by design. It rejects writable contracts by design.

Do not use it during incidents. Do not paste secrets into prompts. Do not store customer data on shared labs.

Do not treat free access as an SLA. Capacity and names can change later. This article does not claim quotas.

Skip the loop if you have a locked runner. Skip it if third-party remotes are forbidden. Skip it for a one-line comment change.

The tests do not prove product quality. They prove the module matches the contract. That is a smaller, honest claim.

Close the letter

Wednesday-Me, skip the longer prompt. Write the frozen contract first. Keep logs outside the git tree.

Use one session per ticket. Write the lab header next. Run the gate, then prompt.

Close the session before Ticket B. Borrow a spare remote box if needed. Keep every contract file in your repo.

— Later You

Top comments (0)