DEV Community

Harper Zhu
Harper Zhu

Posted on

A Write Fence for Time-Boxed Agent Spikes

On a quiet Thursday afternoon, a backend engineer asked a coding assistant to add a health route and leave the rest of the service untouched. Twenty minutes later the pull request also rewrote the Dockerfile, introduced a new secrets client, and dropped a staging URL into an example file. The route itself worked, yet the change set felt like a stranger unpacking a suitcase across the apartment and calling the mess a cleanup. Time-boxed agent spikes fail in that same way when the only stop condition is a clock and a hopeful prompt.

This article describes a write fence, which is a machine-checked boundary that decides whether a ninety-minute agent spike may ship. The human spends the opening slice of the clock declaring allowed paths, installing one failing characterization test, and starting a silent witness. The agent then works while the witness samples the working tree and records every write that steps outside the fence. At minute ninety the spike ships only if the test is green, the witness stays quiet, and the git diff remains inside declared paths.

The opening scene is a composite of a common review failure, not a measured incident from a named production team. Disclosure: This article was prepared as part of MonkeyCode's product outreach, so the product mention that follows is not an independent review. MonkeyCode matters here only because free model access and a free server option can host the spike away from laptops. The same fence still works on any throwaway host, and the scripts assume no vendor quota, model name, or hardware profile.

Prompts that say do not touch deploy files behave like velvet ropes at a museum, because they look official until someone walks through them. A write fence is closer to a cheap hotel keycard that either opens the listed doors or leaves a timestamped denial in the log. Reviewers then judge the spike from files the agent could not narrate away, which keeps the ninety-minute clock honest. The rest of this note is a proposed, unexecuted workflow that a reader can copy into a disposable clone and adapt.

Before anyone starts an agent, the clone should be clean, the branch should be throwaway, and secrets should be absent from the tree. The first fifteen minutes belong to the human, who writes the fence file, a failing test, and the witness entrypoint. That order matters because an agent that authors its own pass condition will usually pass, which is not evidence of a shippable spike. The hypothesis in this example is narrow: add a JSON health payload by editing only application code, tests, and the spike directory.

{
  "hypothesis": "Add GET health payload {\"status\": \"ok\"} without touching deploy, CI, or env files.",
  "deadline_minutes": 90,
  "sample_seconds": 15,
  "allowed_prefixes": [
    "src/",
    "tests/",
    "spike/"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Allowed prefixes should be boring on purpose, because a wide fence is just a prompt with extra ceremony and no teeth. Deploy scripts, GitHub workflows, environment samples, and lockfiles stay outside unless the hypothesis truly names them as in scope. The deadline field is documentation for humans and for the later judge script, not a claim about server lifetime or model throughput. If a team needs a longer investigation, that work is no longer a spike and should not hide inside a stretched fence.

Save that document as spike/fence.json, then add a witness that never argues and never accepts a story about intended files. The proposed sampler below reads porcelain status on an interval, drops its own log path from the accusation list, and appends one JSON line per tick. A later judge can then treat the log as the timeline of the spike rather than trusting the assistant's recap of what it claims it touched.

#!/usr/bin/env python3
"""Proposed unexecuted witness: sample git status and flag unfenced writes."""

from __future__ import annotations

import json
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
FENCE_PATH = ROOT / "spike" / "fence.json"
LOG_PATH = ROOT / "spike" / "witness.jsonl"
STOP_PATH = ROOT / "spike" / "STOP"


def load_fence() -> dict:
    data = json.loads(FENCE_PATH.read_text(encoding="utf-8"))
    allowed = data.get("allowed_prefixes", [])
    if not allowed:
        raise SystemExit("fence.json must list allowed_prefixes")
    return data


def git_porcelain() -> list[str]:
    proc = subprocess.run(
        ["git", "status", "--porcelain", "-uall"],
        cwd=ROOT,
        check=True,
        capture_output=True,
        text=True,
    )
    return [line for line in proc.stdout.splitlines() if line.strip()]


def path_from_porcelain(line: str) -> str:
    body = line[3:]
    if " -> " in body:
        body = body.split(" -> ", 1)[1]
    return body.strip().strip('"')


def is_allowed(path: str, prefixes: list[str]) -> bool:
    posix = path.replace("\\", "/")
    for prefix in prefixes:
        if posix == prefix.rstrip("/") or posix.startswith(prefix):
            return True
    return False


def main() -> int:
    fence = load_fence()
    prefixes = fence["allowed_prefixes"]
    interval = int(fence.get("sample_seconds", 15))
    LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
    while not STOP_PATH.exists():
        rows = git_porcelain()
        violations = []
        for line in rows:
            path = path_from_porcelain(line)
            if path.startswith("spike/witness.jsonl") or path.startswith("spike/STOP"):
                continue
            if not is_allowed(path, prefixes):
                violations.append({"status": line[:2], "path": path})
        event = {
            "ts": datetime.now(timezone.utc).isoformat(),
            "dirty_count": len(rows),
            "violations": violations,
        }
        with LOG_PATH.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(event) + "\n")
        time.sleep(interval)
    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

The witness is a night watchman who does not argue with the agent and does not accept a story about intended files. Every sample appends one JSON line with a timestamp, a dirty-file count, and any paths that sit outside the allowed prefixes. Writes that appear and later vanish still remain in the log, which catches the helpful cleanup that reviewers never see in the final diff. The witness ignores its own log file so the sampling loop does not accuse itself of trespassing on the working tree.

A small driver script starts that process, leaves a clearly marked gap for whatever coding agent the team already uses, then stops the clock. Local rehearsals may shorten the sleep for a dry run, but a published spike still treats ninety minutes as the ship-or-kill horizon rather than a performance score. After the stop file appears, the judge runs the characterization test, reads the witness log, and inspects git status against the fence. Any violation, any failing test, or any dirty path outside the prefixes must print KILL without bargaining.

#!/usr/bin/env bash
# Proposed driver for a ninety-minute fenced spike. Unexecuted example.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
mkdir -p spike src tests
: > spike/witness.jsonl
rm -f spike/STOP

python3 spike/witness.py &
WITNESS_PID=$!
cleanup() {
  touch spike/STOP
  wait "$WITNESS_PID" || true
}
trap cleanup EXIT

echo "Witness pid ${WITNESS_PID}. Attach the coding agent in this clone now."
echo "Allowed paths are listed in spike/fence.json. Stop at minute ninety."

# 5400 seconds is ninety minutes of wall clock on the chosen host.
sleep 5400

touch spike/STOP
wait "$WITNESS_PID" || true
python3 spike/judge.py
Enter fullscreen mode Exit fullscreen mode

The judge is deliberately unimpressed by fluent chat. A green test with a quiet witness and a fenced diff prints SHIP, which is the only passing outcome the spike recognizes. Chat transcripts stay out of that rule, because fluent explanation is not the same thing as a bounded change. The proposed checker below should be edited only by the human who owns the fence.

#!/usr/bin/env python3
"""Proposed judge: SHIP only if the test passes and the witness stayed quiet."""

import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def main() -> int:
    fence = json.loads((ROOT / "spike" / "fence.json").read_text(encoding="utf-8"))
    prefixes = fence["allowed_prefixes"]
    log_path = ROOT / "spike" / "witness.jsonl"
    violations = []
    if log_path.exists():
        for line in log_path.read_text(encoding="utf-8").splitlines():
            if not line.strip():
                continue
            event = json.loads(line)
            violations.extend(event.get("violations") or [])

    test = subprocess.run(
        [sys.executable, "-m", "pytest", "-q", "tests/test_health.py"],
        cwd=ROOT,
    )

    porcelain = subprocess.run(
        ["git", "status", "--porcelain", "-uall"],
        cwd=ROOT,
        check=True,
        capture_output=True,
        text=True,
    )
    dirty = []
    for raw in porcelain.stdout.splitlines():
        if not raw.strip():
            continue
        path = raw[3:]
        if " -> " in path:
            path = path.split(" -> ", 1)[1]
        path = path.strip().strip('"')
        if path.startswith("spike/witness.jsonl") or path.startswith("spike/STOP"):
            continue
        allowed = any(path == p.rstrip("/") or path.startswith(p) for p in prefixes)
        if not allowed:
            dirty.append(path)

    if test.returncode != 0:
        print("KILL: characterization test is still red")
        return 1
    if violations:
        print("KILL: witness recorded fence violations")
        print(json.dumps(violations[:10], indent=2))
        return 1
    if dirty:
        print("KILL: final tree still has unfenced paths")
        print("\n".join(dirty))
        return 1
    print("SHIP: test green, witness quiet, diff inside the fence")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

The failing test should be written by the human and left stable, even if the filesystem cannot truly freeze it without extra tooling. In this proposal the assertion is tiny, because a ninety-minute spike that also redesigns the test suite is already out of control. Teams can swap the example for an HTTP probe or a golden file, as long as the check starts red and stays mechanical. A reader creates tests/test_health.py so pytest fails before the agent starts, which is the whole point of a characterization gate.

# tests/test_health.py — proposed failing test the human writes first
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from src.health import health_payload


def test_health_payload_is_ok():
    assert health_payload() == {"status": "ok"}
Enter fullscreen mode Exit fullscreen mode

A baseline command sequence keeps the clock from starting on a dirty tree. After the files above exist, chmod +x spike/run_spike.sh spike/witness.py spike/judge.py makes the driver executable, and git status --porcelain should show only the intended spike scaffolding. The agent is then attached in that clone, on a laptop or on a disposable free server, with no production environment variables exported into the session. The human does not extend the clock because the assistant asked for five more minutes of cleanup.

Imagine the agent adds src/health.py, updates the tests, and also helpfully creates an env example that looks like a real host. The test may turn green while the witness records a violation, and the judge must still kill the spike without bargaining. The opposite failure is a quiet fence with a red test, which means the agent stayed polite and still missed the only hypothesis that mattered. Both outcomes are useful, because a killed spike returns a concrete artifact instead of a vague sense that the assistant was almost right.

The fence does not replace sandboxing, because a process that can execute arbitrary commands can still rewrite the witness or ignore git entirely. Free servers help only when they are disposable and empty of production credentials, customer data, and internal package tokens. Clock time is wall time, not proof of model quality, and a single spike must never be treated as a benchmark or a buying guide. Path prefixes are easy to bypass with unusual git layouts, worktrees, or writes that never appear in git status if the agent deletes .git.

People who need statistical assistant evaluation should not use this workflow, because one fenced clone cannot rank models or vendors. People working in regulated trees, shared production hosts, or repositories that already contain live secrets should not start a spike there either. Windows path quirks, submodules, and generated vendor directories can flood the witness, so those layouts need a tighter fence or a different host. Overnight unsupervised runs are also a poor fit, since the value of the method is a human reading the kill record at minute ninety.

The useful habit is to spend the first minutes making failure cheap, then to let the agent work inside a space that fails closed. A ninety-minute spike with a write fence does not make assistants trustworthy; it only makes overreach visible before a reviewer inherits it. When the witness is noisy or the test stays red, the honest move is to kill the branch and keep the log as the lesson.

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

Top comments (0)