DEV Community

Quinn Li
Quinn Li

Posted on

Letter to Thursday-Me: Freeze the Diff Before Apply

Thursday-Me,

You sat down at 09:12 with a small parser fix. The coding agent offered a plan and a patch in one breath.

This is a teaching scene, not a billed postmortem. The failure pattern is common on mixed agent sessions. The clock times exist only to slow you down.

By noon the tree was wider than the ticket. Tests failed on files you never named. Rollback consumed the rest of Thursday.

What actually failed

The model was not the root cause. Your session layout was.

Plan and apply shared one context window. The remote box had no restore point. A dotenv file rode along for convenience.

Those three choices compound fast. Fix them in order. Do not skip the snapshot step.

Mistake 1: The planner could write files

You asked for analysis. You left write tools enabled.

The same loop proposed a diff and applied it. No frozen artifact sat between intent and disk. There was no second reader of the change set.

A planner with write access is an applier. Name the role before you prompt. If both roles share a session, you already lost the gate.

Check. If the session can edit files, it is not planning.

Mistake 2: The free box had no restore point

You called the remote host disposable. You also did unique work on it.

Disposable means you can burn the machine. It does not mean you can rewind the tree. Those are different properties.

When apply drifted, laptop git disagreed with the box. You spent hours reconciling two histories. The ticket never moved.

Check. Snapshot before apply. Use a git bundle plus a worktree tarball.

Mistake 3: Secrets traveled with the demo

The tests needed config. You copied .env onto the apply host.

The suite started. The agent later echoed paths from that file. Those traces left your control plane.

A free shared server is not a private laptop. Credentials stay off it. Fake config is enough to prove the gate.

Check. Inject stub env only. Rotate anything that touched the box.

Split the workflow into two surfaces

Keep planning local and read-only. Keep apply on a throwaway host.

Freeze a plan file on disk first. Apply only from that file. New files after freeze mean a new plan.

Follow these seven steps. Stop on the first failure. Do not add “one more path” mid-apply.

  1. Clone a clean worktree from a known commit SHA.
  2. Disable write tools for the planning session entirely.
  3. Save plan JSON that names paths, budgets, and tests.
  4. Validate the plan with the gate script below.
  5. Snapshot the apply host with bundle and tarball files.
  6. Apply the frozen diff. Run only the recorded test command.
  7. Copy the test log back. Then destroy the remote tree.

If step 4 fails, you do not SSH. If step 6 fails, you restore. You do not prompt harder.

Artifact: plan_apply_gate.py

The script is a local template. It does not call a vendor API.

Run it before any remote apply. Treat a non-zero exit as a hard stop. Review it before you trust it on real trees.

#!/usr/bin/env python3
"""Freeze a plan, then apply only if the tree matches it.

Template only. Review before use. Not a security boundary.
"""
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

MAX_FILES_HARD_CAP = 12
MAX_LINES_HARD_CAP = 200
FORBIDDEN_NAMES = {".env", ".env.local", "id_rsa", "id_ed25519"}


def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
    return subprocess.run(cmd, text=True, capture_output=True, check=False)


def git_output(cmd: list[str]) -> str:
    proc = run(["git", *cmd])
    if proc.returncode != 0:
        raise SystemExit(f"git {' '.join(cmd)} failed: {proc.stderr.strip()}")
    return proc.stdout.strip()


def load_plan(path: Path) -> dict:
    data = json.loads(path.read_text())
    required = [
        "base_sha",
        "allow_paths",
        "max_files",
        "max_changed_lines",
        "test_command",
        "diff_path",
    ]
    missing = [k for k in required if k not in data]
    if missing:
        raise SystemExit(f"plan missing keys: {missing}")
    if data["max_files"] > MAX_FILES_HARD_CAP:
        raise SystemExit("plan max_files exceeds hard cap")
    if data["max_changed_lines"] > MAX_LINES_HARD_CAP:
        raise SystemExit("plan max_changed_lines exceeds hard cap")
    return data


def assert_clean_and_pinned(base_sha: str) -> None:
    head = git_output(["rev-parse", "HEAD"])
    if head != base_sha:
        raise SystemExit(f"HEAD {head} != plan base_sha {base_sha}")
    status = git_output(["status", "--porcelain"])
    if status:
        raise SystemExit("worktree is dirty; refuse apply")


def changed_files(diff_text: str) -> list[str]:
    files = []
    for line in diff_text.splitlines():
        if line.startswith("+++ b/"):
            files.append(line[6:])
    return files


def count_changed_lines(diff_text: str) -> int:
    n = 0
    for line in diff_text.splitlines():
        if line.startswith("+++") or line.startswith("---"):
            continue
        if line.startswith("+") or line.startswith("-"):
            n += 1
    return n


def validate_diff(plan: dict, diff_text: str) -> None:
    files = changed_files(diff_text)
    if not files:
        raise SystemExit("diff touches no files")
    if len(files) > plan["max_files"]:
        raise SystemExit("diff exceeds max_files")
    allow = set(plan["allow_paths"])
    for path in files:
        name = Path(path).name
        if name in FORBIDDEN_NAMES:
            raise SystemExit(f"forbidden file in diff: {path}")
        if path not in allow:
            raise SystemExit(f"path not allowlisted: {path}")
    changed = count_changed_lines(diff_text)
    if changed > plan["max_changed_lines"]:
        raise SystemExit("diff exceeds max_changed_lines")


def apply_and_test(plan: dict, diff_path: Path) -> None:
    check = run(["git", "apply", "--check", str(diff_path)])
    if check.returncode != 0:
        raise SystemExit(f"git apply --check failed: {check.stderr}")
    applied = run(["git", "apply", str(diff_path)])
    if applied.returncode != 0:
        raise SystemExit(f"git apply failed: {applied.stderr}")
    test = run(plan["test_command"])
    log = Path("apply_test.log")
    log.write_text(test.stdout + "\n" + test.stderr)
    if test.returncode != 0:
        raise SystemExit(f"test_command failed; see {log}")


def main() -> None:
    if len(sys.argv) != 2:
        raise SystemExit("usage: plan_apply_gate.py plan.json")
    plan_path = Path(sys.argv[1])
    plan = load_plan(plan_path)
    assert_clean_and_pinned(plan["base_sha"])
    diff_path = Path(plan["diff_path"])
    diff_text = diff_path.read_text()
    validate_diff(plan, diff_text)
    apply_and_test(plan, diff_path)
    print("apply gate passed")


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

Artifact: plan file and snapshot commands

Keep the plan boring. Boring plans are reviewable. Wide plans are how Thursday vanished.

{
  "goal": "fix parser flake on empty tokens",
  "base_sha": "REPLACE_WITH_REV_PARSE_HEAD",
  "allow_paths": ["src/parser.py", "tests/test_parser.py"],
  "max_files": 2,
  "max_changed_lines": 80,
  "diff_path": "plan.diff",
  "test_command": ["pytest", "-q", "tests/test_parser.py"]
}
Enter fullscreen mode Exit fullscreen mode

Pin the SHA from git, not from chat. Then snapshot the apply host before git apply.

git rev-parse HEAD > plan.base_sha
git bundle create /tmp/pre-apply.bundle HEAD
tar -czf /tmp/pre-apply-worktree.tgz \
  --exclude .git .
python3 plan_apply_gate.py plan.json
Enter fullscreen mode Exit fullscreen mode

Restore is explicit. You do not “undo in the agent.” You unpack the tarball or clone from the bundle.

# recover the tree, not the conversation
mkdir -p /tmp/restore && tar -xzf /tmp/pre-apply-worktree.tgz -C /tmp/restore
git clone /tmp/pre-apply.bundle /tmp/restore-repo
Enter fullscreen mode Exit fullscreen mode

Test plan for the gate itself

Do not trust the script on a live branch first. Use a throwaway clone. Expect these five outcomes.

  1. Dirty worktree: exit non-zero before git apply.
  2. HEAD mismatch: exit non-zero when SHA drifts.
  3. Path outside allow_paths: exit non-zero, tree unchanged.
  4. .env in the diff: exit non-zero, tree unchanged.
  5. Matching plan: tests run, apply_test.log is written.

If test 3 mutates files, the gate is wrong. Stop using it. Fix the script before any remote host is involved.

Decision table

Use the table before you open a model session. If two rows apply, take the stricter one.

Situation Planner Apply host Refuse
Read-only design question Local, no write tools None Any patch
Small diff, public fixtures Local freeze of plan.json Throwaway remote tree Secrets, prod creds
Needs real network services Do not use this workflow Do not use this workflow Agent-owned cloud keys
Regulated or customer data Human review only Isolated private runner Free shared hosts
Wide refactor, unknown blast radius Split into several plans One snapshot per plan One giant diff

The table is the policy. The script only enforces the policy you wrote. A missing row means stop, not improvise.

Where a free model and free server fit

The split does not require a particular vendor. A laptop planner plus any disposable apply host is enough.

MonkeyCode is one option when you want that split without standing up hardware. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Operator-supplied facts here are limited to free model access and a free server option. I am not attaching model names, quotas, hardware, or uptime claims.

Use the free model session as the planner only. Use the free server as the apply host only. Freeze plan.json on your machine first. If either surface disappears, you still have the bundle and the diff.

Limitations

This gate is not a sandbox kernel. It is not an audit log for a company. It will not catch a wrong algorithm inside an allowlisted file.

Line budgets are crude. A 60-line change can still delete the wrong branch. Tests in the plan can be too narrow. git apply --check does not prove runtime behavior.

Free shared hosts can vanish or change terms. Do not store unique state there. Do not treat remote disk as a backup. Do not paste tokens, customer dumps, or private keys into the planner.

The seven steps add latency. That latency is the point. Thursday was lost to missing latency, not to extra commands.

Who should not use this

Skip this workflow if you cannot clone the repo twice. Skip it if the apply step needs live production credentials.

Skip it for incident response on a burning service. Skip it when a human patch is shorter than the plan file. Skip it if your org already has a reviewed CI runner with secret isolation.

Do not use a free shared server for anything you cannot publish. Do not use the gate as proof that generated code is safe. Review the diff as if a stranger wrote it. Because one did.

Close the loop, then close the laptop

Thursday-Me, the model can draft. It cannot own the tree.

Write the plan. Freeze the diff. Snapshot the box. Apply once. Copy the log. Destroy the remote tree.

If you run the gate, start on a throwaway clone. Confirm the five failing cases before a real branch ever sees git apply.

Top comments (0)