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"]
}
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
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}')"
Run the gate on the remote box.
chmod a-w contracts/parse-query.v1.json
bash gate-contract.sh tickets/A.md
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
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"
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
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}"
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
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);
Run it on the remote box.
node --test test/parseQuery.contract.test.mjs
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;
}
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)