Unbounded AI coding sessions fail on weekends because the diff grows faster than the review window. A file-and-line change budget, recorded against a git snapshot, is a cheaper control than another prompt rule. The tool below is a small, local gate: it freezes HEAD, lists what the model may touch, and refuses to call the session done when the patch spills past that fence.
This is a worked weekend recipe, not a production platform. It assumes a dirty-but-tracked git repo, a finite evening, and a coding assistant that may run off the laptop. The conclusion stays the same if the assistant is local, remote, or a mix of both: size the change, then keep a one-command rollback.
The weekend constraint
A two-day side project has a hard stop. Sleep, errands, and Monday morning all cut the review budget. AI assistants do not inherit that stop. They keep opening files, inventing helpers, and “cleaning up” adjacent modules until the patch is no longer reviewable in one sitting.
Remote sessions make the spill worse. The editor on the desk is no longer the only writer. A free remote coding server can apply edits while the operator is away from the keyboard. Git remains the only durable record of what landed.
The useful control is not a longer system prompt. It is a numeric cap on files and insertions, plus a stored reset point. Prompts argue. Diff stats do not.
Scope that survived the cut
The weekend goal was one artifact: a stdlib Python script that can freeze, check, and rollback a session. No web UI. No language server. No model-specific client.
Kept
- A YAML-like config file committed next to the repo (plain text, no extra parser dependency; the script reads a tiny subset).
- A freeze record under
.change-budget/that stores the git commit, timestamp, and caps. - A check that uses
git diff --numstatso binary noise is visible but not guessed. - A rollback that only runs if the freeze file matches
HEADat freeze time and the working tree is still the same branch.
Skipped on purpose
- Multi-agent planners and tool-calling loops. They expand scope; this project cuts it.
- Token accounting. Token burn and diff size are different failure modes. A cheap session can still wreck twelve files.
- Secret redaction. Redaction belongs in the request path, not in the merge path.
- Automatic test runners inside the script. Tests stay in the project’s existing command, invoked by the operator after
checkpasses. - Pretty HTML reports. A failing exit code and a short table are enough for a Sunday night.
If a feature did not help a human decide “keep, trim, or reset,” it was dropped.
Artifact: change_budget.py
The script uses only the Python standard library. Label this as a recipe to copy into a throwaway branch and run locally. It is not a published package and has no claimed benchmark.
#!/usr/bin/env python3
"""Freeze, check, and roll back a weekend AI coding session."""
from __future__ import annotations
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path.cwd()
BUDGET_DIR = ROOT / ".change-budget"
FREEZE_PATH = BUDGET_DIR / "freeze.json"
CONFIG_PATH = ROOT / "change-budget.conf"
def run_git(*args: str) -> str:
proc = subprocess.run(
["git", *args],
cwd=ROOT,
check=True,
text=True,
capture_output=True,
)
return proc.stdout.strip()
def load_conf() -> dict:
# Tiny key=value config. No YAML dependency for a weekend build.
conf = {
"max_files": 6,
"max_insertions": 180,
"allow": "*.py,*.md",
"deny": "*.env,secrets/*,*.pem",
}
if not CONFIG_PATH.exists():
return conf
for raw in CONFIG_PATH.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key, value = key.strip(), value.strip()
if key in {"max_files", "max_insertions"}:
conf[key] = int(value)
else:
conf[key] = value
return conf
def parse_globs(raw: str) -> list[str]:
return [part.strip() for part in raw.split(",") if part.strip()]
def path_allowed(path: str, allow: list[str], deny: list[str]) -> bool:
from fnmatch import fnmatch
if any(fnmatch(path, pattern) for pattern in deny):
return False
if not allow:
return True
return any(fnmatch(path, pattern) for pattern in allow)
def freeze() -> None:
branch = run_git("rev-parse", "--abbrev-ref", "HEAD")
sha = run_git("rev-parse", "HEAD")
conf = load_conf()
BUDGET_DIR.mkdir(exist_ok=True)
payload = {
"frozen_at": datetime.now(timezone.utc).isoformat(),
"branch": branch,
"sha": sha,
"max_files": conf["max_files"],
"max_insertions": conf["max_insertions"],
"allow": parse_globs(conf["allow"]),
"deny": parse_globs(conf["deny"]),
}
FREEZE_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(f"froze {branch}@{sha[:12]} files<={payload['max_files']} +<={payload['max_insertions']}")
def numstat_since(sha: str) -> list[tuple[int, int, str]]:
out = run_git("diff", "--numstat", sha)
rows = []
for line in out.splitlines():
parts = line.split("\t")
if len(parts) != 3:
continue
added, deleted, path = parts
added_n = 0 if added == "-" else int(added)
deleted_n = 0 if deleted == "-" else int(deleted)
rows.append((added_n, deleted_n, path))
return rows
def check() -> int:
if not FREEZE_PATH.exists():
print("no freeze; run: python3 change_budget.py freeze", file=sys.stderr)
return 2
freeze_data = json.loads(FREEZE_PATH.read_text(encoding="utf-8"))
branch = run_git("rev-parse", "--abbrev-ref", "HEAD")
if branch != freeze_data["branch"]:
print(f"branch moved: froze {freeze_data['branch']}, now {branch}", file=sys.stderr)
return 2
rows = numstat_since(freeze_data["sha"])
allow, deny = freeze_data["allow"], freeze_data["deny"]
blocked = [path for _, _, path in rows if not path_allowed(path, allow, deny)]
files = len(rows)
insertions = sum(added for added, _, _ in rows)
print("path\t+\t-")
for added, deleted, path in rows:
print(f"{path}\t{added}\t{deleted}")
print(f"files={files} cap={freeze_data['max_files']}")
print(f"insertions={insertions} cap={freeze_data['max_insertions']}")
failed = False
if blocked:
print("blocked paths: " + ", ".join(blocked), file=sys.stderr)
failed = True
if files > freeze_data["max_files"]:
print("file cap exceeded", file=sys.stderr)
failed = True
if insertions > freeze_data["max_insertions"]:
print("insertion cap exceeded", file=sys.stderr)
failed = True
if failed:
print("session over budget; trim or rollback", file=sys.stderr)
return 1
print("inside budget")
return 0
def rollback() -> int:
if not FREEZE_PATH.exists():
print("no freeze file", file=sys.stderr)
return 2
freeze_data = json.loads(FREEZE_PATH.read_text(encoding="utf-8"))
branch = run_git("rev-parse", "--abbrev-ref", "HEAD")
if branch != freeze_data["branch"]:
print("refusing rollback on a different branch", file=sys.stderr)
return 2
sha = freeze_data["sha"]
# Hard reset is destructive. Require an explicit second argument.
run_git("reset", "--hard", sha)
run_git("clean", "-fd", "-e", ".change-budget")
print(f"reset to {sha[:12]}")
return 0
def main() -> int:
if len(sys.argv) < 2 or sys.argv[1] not in {"freeze", "check", "rollback"}:
print("usage: change_budget.py freeze|check|rollback [--yes]", file=sys.stderr)
return 2
cmd = sys.argv[1]
if cmd == "freeze":
freeze()
return 0
if cmd == "check":
return check()
if cmd == "rollback":
if "--yes" not in sys.argv:
print("rollback is destructive; re-run with --yes", file=sys.stderr)
return 2
return rollback()
return 2
if __name__ == "__main__":
raise SystemExit(main())
Pair it with a committed config. Keep the numbers small enough that a tired reviewer can still read the patch before Monday.
# change-budget.conf
max_files=6
max_insertions=180
allow=*.py,*.md,tests/*
deny=*.env,secrets/*,*.pem,.change-budget/*
Commands that define the weekend
Run these from the repo root. They are ordinary git plus the script above.
chmod +x change_budget.py
git status -sb
python3 change_budget.py freeze
# ... coding assistant session, local or remote ...
python3 change_budget.py check
# if the check fails and the patch is not worth trimming:
python3 change_budget.py rollback --yes
A passing check is not a merge. After check exits 0, run the project’s real tests. Example shape, not a claimed suite:
python3 -m compileall -q .
pytest -q tests/test_change_budget_smoke.py
A minimal smoke test for the budget tool itself can live in the same weekend branch. It should assert that a synthetic extra file trips the cap. Do not treat the snippet as executed CI on this article’s machine.
# tests/test_change_budget_smoke.py — proposed local check
from pathlib import Path
import json
def test_freeze_file_has_caps():
freeze = json.loads(Path(".change-budget/freeze.json").read_text())
assert freeze["max_files"] >= 1
assert freeze["max_insertions"] >= 1
assert freeze["sha"]
Where a free remote coding server fits
The budget is most useful when the model is not confined to the laptop. A session that can edit many paths needs an external fence, because the operator cannot watch every write.
MonkeyCode’s free model access and free server option are relevant here as one way to run that off-laptop session without standing up private inference first. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The freeze file still lives in the operator’s clone. Caps, allow-lists, and rollback stay local even if generation happens elsewhere. No model names, quotas, or hardware claims are attached to that option; those change and should be read from the product’s current docs at the time of use.
The workflow is the same regardless of vendor: freeze, let the assistant work, check the diff, keep or reset. The product is a transport for edits. Git remains the contract.
Worked session (unexecuted example)
Suppose the weekend target is a single HTTP handler and its test. The freeze captures main at abc1234. The assistant is asked only for that handler. Two hours later git diff --stat shows eight files, including a drive-by formatter pass on utils.py and a new config loader.
python3 change_budget.py check should fail on max_files and print the extra paths. The operator then has three honest moves:
- Restore the extra files and keep the handler.
- Raise the cap in
change-budget.confand freeze again — only if the review window actually grew. -
rollback --yesand rewrite the prompt with an explicit file list.
Option 3 is the one this artifact is for. A weekend project that cannot be explained in six files is not a weekend project.
Failure modes the script does not catch
- Semantic breakage inside the cap. A 40-line patch can still delete the wrong branch in a parser.
- History rewriting.
git commit --amendand rebase after freeze make the stored SHA misleading. Freeze again after any rewrite. - Untracked secrets that never appear in
git diff. Deny-globs only apply to paths git already sees as changed. - Submodules, generated vendor trees, and lockfile churn. Those blow insertion counts without proving the feature landed.
- Shared branches. Rollback is a hard reset. It is unsafe if anyone else pushed onto the same branch during the session.
Add .change-budget/ to .gitignore if the freeze SHA should stay private to the machine. Keep change-budget.conf tracked so the caps are reviewable.
Who should not use this approach
Skip this recipe when the work is an incident hotfix on a shared production branch. A hard reset is the wrong tool next to other people’s commits. Skip it when policy forbids sending repository contents to a remote coding service at all; a local cap does not create a legal exception. Skip it when the repo is not in git, or when the “patch” is data, not source. Skip it when the real need is architecture, not size: a six-file cap will not invent a missing bounded context.
Teams with existing CODEOWNERS, required reviewers, and CI size limits already have a stronger form of this gate. They can steal the freeze/check idea and ignore the rollback.
What the weekend actually produced
The durable output is not a framework. It is a three-verb habit: freeze the SHA, cap the diff, reset without shame. The skipped pieces — agents, dashboards, token graphs — would have consumed the same weekend the patch needed.
If a free remote coding session is already in the plan, drop change_budget.py in first and treat a failed check as the session’s real stopping condition, not the model’s last sentence.
Top comments (0)