Chat is not a merge receipt. Require a receipt file before any write tool runs. Kill the spike if that file is missing at minute 90.
This is a time-boxed test, not a platform tour. One hypothesis. One evidence file. Ship or kill.
Core conclusion
Agents narrate work they did not finish. Tool logs look busy. The tree stays unchanged.
A write-gate fixes that gap. No apply_patch, no file create, no shell redirect until a receipt exists. The receipt is JSON on disk. Chat text does not count.
Hypothesis
H1: A 90-minute write-gate spike can prove mutation. Proof is a receipt file plus a git diff. If either is absent, kill the agent path. Do not add tools. Do not add models. Do not extend the clock.
Label this as a proposed spike. It is not a production study. It is not a benchmark.
Why this spike now
Agent threads keep growing. Loop talk is cheap. Filesystem proof is not.
Public debate still mixes three different things:
- token spend
- tool-call count
- repository change
Those are not the same signal. A loop can spend tokens and call tools with zero bytes changed. A second runtime can repeat the same lie. The receipt file is the only shared object both runtimes must emit.
Scope for 90 minutes
Keep the surface tiny. One repo. One mutating tool. One receipt path.
In scope:
- a JSON receipt schema
- a write-gate wrapper
- a fail-closed test
- a ship-or-kill table
Out of scope:
- multi-agent planners
- model ranking
- latency contests
- prompt libraries
- UI chrome
If the spike needs those, the hypothesis is already too wide. Kill it.
Minute 0–10: pin the receipt path
Pick one path. Do not let the model choose it.
.spike/receipt.json
Pin the schema too. Fields stay boring on purpose.
{
"hypothesis_id": "H1",
"started_unix": 0,
"ended_unix": 0,
"mutated_paths": [],
"git_head_before": "",
"git_head_after": "",
"status": "pending"
}
Allowed status values:
pendingwroteblockedkilled
No extra keys in this spike. Extra keys hide missing proof.
Minute 10–25: freeze git identity
Record HEAD before the agent starts. Store it in the receipt. Compare it at the end.
#!/usr/bin/env bash
set -euo pipefail
mkdir -p .spike
git rev-parse HEAD > .spike/head_before.txt
python3 - <<'PY'
import json, time, pathlib
p = pathlib.Path(".spike/receipt.json")
receipt = {
"hypothesis_id": "H1",
"started_unix": int(time.time()),
"ended_unix": 0,
"mutated_paths": [],
"git_head_before": pathlib.Path(".spike/head_before.txt").read_text().strip(),
"git_head_after": "",
"status": "pending",
}
p.write_text(json.dumps(receipt, indent=2) + "\n")
print(p.resolve())
PY
If git rev-parse fails, stop. An unclean tree is not this spike. Do not invent a baseline.
Minute 25–45: install the write-gate
Wrap every mutating tool. Read tools stay open. Write tools stay closed until the receipt is pending or wrote and the path list is non-empty after the write. The gate checks the receipt before the write. After the write, a verifier fills mutated_paths.
Proposed Python gate. Treat it as spike code, not a library.
# spike_write_gate.py
from __future__ import annotations
import json
from pathlib import Path
from typing import Callable
RECEIPT = Path(".spike/receipt.json")
WRITE_TOOLS = {"apply_patch", "write_file", "shell_redirect"}
class WriteBlocked(RuntimeError):
pass
def load_receipt() -> dict:
if not RECEIPT.exists():
raise WriteBlocked("missing receipt file")
data = json.loads(RECEIPT.read_text())
required = {
"hypothesis_id",
"started_unix",
"ended_unix",
"mutated_paths",
"git_head_before",
"git_head_after",
"status",
}
missing = required - set(data)
if missing:
raise WriteBlocked(f"receipt missing keys: {sorted(missing)}")
if data["status"] not in {"pending", "wrote"}:
raise WriteBlocked(f"status={data['status']} blocks writes")
if data["hypothesis_id"] != "H1":
raise WriteBlocked("wrong hypothesis_id")
return data
def gated_write(tool_name: str, mutate: Callable[[], list[str]]) -> list[str]:
if tool_name not in WRITE_TOOLS:
raise WriteBlocked(f"unknown write tool: {tool_name}")
load_receipt()
changed = mutate()
if not changed:
raise WriteBlocked("mutate() returned no paths")
data = load_receipt()
data["mutated_paths"] = sorted(set(changed))
data["status"] = "wrote"
RECEIPT.write_text(json.dumps(data, indent=2) + "\n")
return changed
The gate does one job. It refuses silent mutation. It does not score the model.
Minute 45–60: fail-closed tests
Do not trust a demo chat. Run tests that never start a model.
# test_spike_write_gate.py
import json
from pathlib import Path
import pytest
import spike_write_gate as gate
def write_receipt(tmp_path: Path, **overrides):
data = {
"hypothesis_id": "H1",
"started_unix": 1,
"ended_unix": 0,
"mutated_paths": [],
"git_head_before": "abc",
"git_head_after": "",
"status": "pending",
}
data.update(overrides)
path = tmp_path / ".spike" / "receipt.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps(data))
return path
def test_missing_receipt_blocks(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(gate, "RECEIPT", tmp_path / ".spike" / "receipt.json")
with pytest.raises(gate.WriteBlocked, match="missing receipt"):
gate.gated_write("write_file", lambda: ["a.py"])
def test_killed_status_blocks(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
receipt = write_receipt(tmp_path, status="killed")
monkeypatch.setattr(gate, "RECEIPT", receipt)
with pytest.raises(gate.WriteBlocked, match="blocks writes"):
gate.gated_write("write_file", lambda: ["a.py"])
def test_empty_mutate_blocks(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
receipt = write_receipt(tmp_path)
monkeypatch.setattr(gate, "RECEIPT", receipt)
with pytest.raises(gate.WriteBlocked, match="no paths"):
gate.gated_write("write_file", lambda: [])
def test_write_updates_receipt(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
receipt = write_receipt(tmp_path)
monkeypatch.setattr(gate, "RECEIPT", receipt)
changed = gate.gated_write("write_file", lambda: ["src/app.py"])
data = json.loads(receipt.read_text())
assert changed == ["src/app.py"]
assert data["status"] == "wrote"
assert data["mutated_paths"] == ["src/app.py"]
Run them before any model session.
pytest -q test_spike_write_gate.py
If tests fail, do not start the agent. The spike is already killed.
Minute 60–80: one mutating task
Give the agent one file-level job. Example: add a function and a unit test. Nothing else.
Proposed task card:
Task: add `clamp(n, lo, hi)` in src/mathutil.py
Also add test_clamp in tests/test_mathutil.py
Do not edit other paths.
Do not write until .spike/receipt.json exists.
After writes, stop.
Shell verifier after the agent stops:
#!/usr/bin/env bash
set -euo pipefail
test -f .spike/receipt.json
python3 - <<'PY'
import json, subprocess, pathlib, sys
r = json.loads(pathlib.Path(".spike/receipt.json").read_text())
if r["status"] != "wrote":
sys.exit("kill: status is not wrote")
if not r["mutated_paths"]:
sys.exit("kill: mutated_paths empty")
before = pathlib.Path(".spike/head_before.txt").read_text().strip()
after = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
diff = subprocess.check_output(["git", "diff", "--name-only", before], text=True)
changed = [line for line in diff.splitlines() if line]
if not changed:
# uncommitted writes still count if working tree changed
changed = subprocess.check_output(
["git", "status", "--porcelain"], text=True
).splitlines()
if not changed:
sys.exit("kill: git shows no change")
unexpected = [p for p in r["mutated_paths"] if p not in "\n".join(changed)]
print("receipt_paths", r["mutated_paths"])
print("git_signal", changed)
if r["hypothesis_id"] != "H1":
sys.exit("kill: hypothesis mismatch")
print("ship-candidate")
PY
Chat transcripts are ignored here. The verifier never reads them.
Minute 80–90: ship-or-kill table
Score only evidence. Do not score tone.
| Signal | Ship | Kill |
|---|---|---|
.spike/receipt.json exists |
yes | missing file |
status |
wrote |
pending, blocked, killed
|
mutated_paths |
non-empty, expected files | empty or extra files |
| git / working tree | matches receipt paths | no diff |
| tests |
pytest -q green |
red or skipped |
| clock | <= 90 minutes | overtime |
Ship means keep the write-gate. Kill means delete the agent path. Do not “almost ship.”
Where a free runtime fits
Run the same spike twice. Once locally. Once on a second machine or server. The receipt path stays identical. The tests stay identical. The kill rules stay identical.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode is relevant only as a second place to execute the spike. Operator-supplied facts for this draft: free model access, and a free server option. Use them if you need a clean runtime without standing up your own box. Do not treat that as a quality claim. Do not treat that as a quota sheet. Do not name models the spike does not pin.
If the second runtime cannot write .spike/receipt.json, kill H1 there. A hosted chat window is not a substitute receipt.
One check only: can the free server persist the receipt and the git signal. If persistence is missing, the runtime is out of scope.
What the spike does not prove
- It does not prove the model is “better at coding.”
- It does not prove agents beat if-statements.
- It does not prove a loop is production-safe.
- It does not measure token cost.
- It does not compare vendors.
Those claims need different designs. Mixing them poisons the receipt.
Failure modes to expect
Watch these during the 90 minutes:
- The model claims a write and skips the gate.
- The receipt stays
pendingafter a long chat. -
mutated_pathslists files git never saw. - The agent edits files outside the task card.
- Tests are described, not run.
- The second runtime has no writable workspace.
Each failure is a kill. Do not patch the prompt to hide it. Record the kill reason in the receipt.
def kill(reason: str) -> None:
data = json.loads(RECEIPT.read_text())
data["status"] = "killed"
data["ended_unix"] = int(time.time())
RECEIPT.write_text(json.dumps(data, indent=2) + "\n")
raise SystemExit(f"kill: {reason}")
Who should not use this
Skip this spike if you need any of the following:
- live production writes
- secrets or customer data in context
- unbounded tool catalogs
- overnight autonomous loops
- a model bake-off
- a marketing demo with no git repo
This method is for a throwaway branch. It is for local or disposable runtimes. It is not an access-control system. The gate is a file check. A hostile process can skip the wrapper.
Limits of the artifact
The receipt is not cryptography. It is not an audit log. It is a spike contract.
Known limits:
- no signature over
mutated_paths - no enforcement inside the model vendor
- no handling of binary files
- no parallel agents on one tree
- no recovery if git is dirty
If you need those, you left the 90-minute box. Start a different hypothesis. Do not grow H1.
Close
Keep the rule small. No receipt, no write. No diff, no ship. Ninety minutes is enough to learn that. If you already have a free coding runtime, replay the same receipt test there and stop.
Top comments (0)