A weekend agent demo stays reviewable when the sample input, the allowed write, and the stop check are frozen before any model call. A larger prompt does not hold that line. This build log cuts the work to one fixture, one patch tray, and one local command, then records what was left out.
The demo turns three markdown notes into one summary file. An agent may propose a unified diff. It may not edit the fixture, read secrets, or open a pull request. The demo is done when a checker applies that diff to a temporary copy and a tiny script exits zero.
The cut
Side-project scope dies in the second feature. The list below is the whole product for the weekend, not a preface to a platform.
- Keep one fixture directory with three notes.
- Allow one write path,
fixtures/notes/out/summary.md. - Accept a unified diff only from
inbox/proposal.diff. - Apply that diff on a copy, never on the working tree.
- Stop after one check script. No retry loop and no second tool.
A chat pane, a web search step, and an auto-merge button are skips. They do not become Sunday work.
Freeze inputs before the model
Live model output drifts between Saturday and the next show-and-tell. A demo that cannot be replayed against the same notes cannot be compared, reviewed, or trusted twice. The lock file stores hashes of the input notes and the only path a patch may create. The model is invited after that file exists, not before.
Hashing is a local step. It does not need an account, a server, or a network call.
python3 scripts/demo_lock.py hash --lock demo.lock.json
python3 scripts/demo_lock.py check --lock demo.lock.json --patch inbox/proposal.diff
The first command rewrites the inputs map. The second command is the demo. A non-zero exit leaves the inbox file in place and the fixture inputs unchanged.
The lock file
The lock is JSON checked into the repo. It names the fixture, the allowed write, a byte cap, forbidden prefixes, and the check command. The hashes below are placeholders for the shape of the file. They are not measured outputs from a run.
{
"name": "weekend-notes-demo",
"fixture_dir": "fixtures/notes",
"allowed_writes": ["fixtures/notes/out/summary.md"],
"forbidden_prefixes": [".git/", ".env", "secrets/"],
"max_patch_bytes": 8192,
"checks": ["python3 scripts/check_summary.py"],
"inputs": {
"fixtures/notes/in/01-standup.md": "<sha256>",
"fixtures/notes/in/02-bug.md": "<sha256>",
"fixtures/notes/in/03-ship.md": "<sha256>"
}
}
A lock without hashes is a wish list. Fill the hashes, commit the lock, and only then ask for a diff.
What stays, what goes
The cut is easier to keep when it is a table instead of a mood.
| Signal during the weekend | Decision |
|---|---|
Diff touches only summary.md
|
Keep. Run the checker. |
| Diff adds a dependency or a second file | Skip. Do not widen the lock. |
| Check fails on the first patch | Stop. Read the diff. No automatic retry. |
| A second fixture would make a nicer demo | Skip. One fixture is the demo. |
| The summary sounds fluent but misses the bug | Fail the check, or accept that prose quality is out of scope. |
| Someone asks for a hosted chat | Skip. The interface is an exit code. |
The checker
The script below is a proposal. It was not executed against a live model for this draft. It refuses an oversized patch, refuses paths outside the single allowed write, verifies input hashes, copies the tree, runs git apply --check, applies the diff on that copy, then runs the check command with the working directory pinned to the copy.
Path rules
Only +++ and --- lines are inspected, which is enough to reject a casual extra file and not enough to understand every diff feature. A hit on a forbidden prefix, or any path outside allowed_writes, stops the run before Git is invoked. /dev/null is ignored so a new-file diff can still name the one allowed summary path.
Apply on a copy
git apply --check is the real apply gate. The proposal script never points that command at the working tree. The copy drops .git and inbox so a dirty inbox cannot leak into the stage. The check command is read from the lock, split with shlex, and run without a shell, so a patch cannot rewrite the command that judges it. Keep the lock file out of allowed_writes.
#!/usr/bin/env python3
"""Proposal: freeze a weekend demo fixture before any model call."""
import argparse
import hashlib
import json
import shlex
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
digest.update(path.read_bytes())
return digest.hexdigest()
def load_lock(path: Path) -> dict:
data = json.loads(path.read_text(encoding="utf-8"))
required = (
"fixture_dir",
"allowed_writes",
"forbidden_prefixes",
"max_patch_bytes",
"checks",
)
missing = [key for key in required if key not in data]
if missing:
raise SystemExit("lock missing keys: " + ", ".join(missing))
return data
def verify_inputs(lock: dict, root: Path) -> None:
inputs = lock.get("inputs") or {}
if not inputs:
raise SystemExit("lock has no input hashes; run hash first")
for rel, expected in inputs.items():
path = root / rel
if not path.is_file():
raise SystemExit("missing input: " + rel)
if sha256_file(path) != expected:
raise SystemExit("fixture drift: " + rel)
def reject_patch(lock: dict, patch_text: str) -> None:
if len(patch_text.encode("utf-8")) > int(lock["max_patch_bytes"]):
raise SystemExit("patch exceeds max_patch_bytes")
for line in patch_text.splitlines():
if not (line.startswith("+++ ") or line.startswith("--- ")):
continue
raw = line[4:].strip()
if raw == "/dev/null":
continue
rel = raw[2:] if raw.startswith(("a/", "b/")) else raw
blocked = any(rel.startswith(prefix) for prefix in lock["forbidden_prefixes"])
if blocked or rel not in lock["allowed_writes"]:
raise SystemExit("path rejected: " + rel)
def apply_on_copy(lock: dict, root: Path, patch_path: Path) -> int:
fixture = root / lock["fixture_dir"]
if not fixture.is_dir():
raise SystemExit("missing fixture: " + str(fixture))
with tempfile.TemporaryDirectory(prefix="demo-lock-") as tmp:
stage = Path(tmp) / "stage"
shutil.copytree(root, stage, ignore=shutil.ignore_patterns(".git", "inbox"))
probe = subprocess.run(
["git", "apply", "--check", str(patch_path)],
cwd=stage,
capture_output=True,
text=True,
)
if probe.returncode != 0:
sys.stderr.write(probe.stderr)
return probe.returncode
applied = subprocess.run(
["git", "apply", str(patch_path)],
cwd=stage,
capture_output=True,
text=True,
)
if applied.returncode != 0:
sys.stderr.write(applied.stderr)
return applied.returncode
for command in lock["checks"]:
result = subprocess.run(shlex.split(command), cwd=stage)
if result.returncode != 0:
return result.returncode
return 0
def hash_inputs(lock_path: Path) -> None:
lock = load_lock(lock_path)
root = lock_path.parent
folder = root / lock["fixture_dir"] / "in"
inputs = {}
for path in sorted(folder.glob("*.md")):
rel = path.relative_to(root).as_posix()
inputs[rel] = sha256_file(path)
lock["inputs"] = inputs
lock_path.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Check a weekend demo lock")
parser.add_argument("action", choices=("hash", "check"))
parser.add_argument("--lock", required=True)
parser.add_argument("--patch")
args = parser.parse_args()
lock_path = Path(args.lock)
if args.action == "hash":
hash_inputs(lock_path)
return 0
if not args.patch:
raise SystemExit("check requires --patch")
lock = load_lock(lock_path)
verify_inputs(lock, lock_path.parent)
patch_path = Path(args.patch)
reject_patch(lock, patch_path.read_text(encoding="utf-8"))
return apply_on_copy(lock, lock_path.parent, patch_path)
if __name__ == "__main__":
raise SystemExit(main())
The companion check fails closed. A missing file, an empty file, a file past the character cap, or a leftover placeholder is a failed demo. Fluent wording is not a pass.
#!/usr/bin/env python3
"""Proposal: stop check for the notes demo. Not executed in this draft."""
from pathlib import Path
import sys
SUMMARY = Path("fixtures/notes/out/summary.md")
MAX_CHARS = 1200
def main() -> int:
if not SUMMARY.is_file():
print("missing summary", file=sys.stderr)
return 1
text = SUMMARY.read_text(encoding="utf-8").strip()
if not text:
print("empty summary", file=sys.stderr)
return 1
if len(text) > MAX_CHARS:
print("summary too long", file=sys.stderr)
return 1
if "TODO" in text or "FIXME" in text:
print("placeholder left in summary", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sample notes, not a fake project
The fixture is three short files under fixtures/notes/in/. One file is a standup. One file is a bug note. One file is a ship note. Each file should stay under a few hundred words so the summary cap means something. The summary should mention the bug and the ship decision, and it should not invent a customer, a metric, or a version that the notes do not contain.
This draft does not pretend those notes already exist in a running repo. Write them on Saturday morning, hash them, and stop editing them. A fixture that changes after the model call is not a fixture.
A stronger check can require two literals copied from the notes, such as a bug id and a ship date. That test stays local. It does not ask another model to grade the prose, and it avoids treating a confident paragraph as evidence.
Where a free model and a free server fit
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The lock, the fixture, and the checker stay on the laptop. Free model access is relevant only as a way to draft inbox/proposal.diff for this one summary path. A free server option is relevant only as a place to run that generate step, not as a home for the fixture or for secrets. This draft does not name a model, a token quota, a machine size, or an end date. Those details were not verified here, and they go stale. Read the current product page before treating either option as a weekend plan. Repository layout, license, and release status are omitted for the same reason.
Use a short generate brief, not a growing prompt. Hand over the three input filenames, the single allowed write path, and the byte cap. Ask for a unified diff and nothing else. If the reply is not a diff, discard it. Do not paste the checker source into the brief. The checker is the judge, not part of the suggestion.
Split the work so a product mention cannot swallow the demo:
- Local, required: hash the inputs, store the lock, apply the patch, run the check.
- Remote, optional: request one unified diff that touches only the summary path.
- Never remote: the lock file, the check script, or any secret.
If the remote step is down, write the diff by hand. The same checker still runs. Removing every product mention leaves a usable workflow.
What the working demo includes
A passing demo is a handful of files and one command, not a hosted app.
-
demo.lock.jsonwith hashes produced bydemo_lock.py hash - Three notes under
fixtures/notes/in/ -
scripts/demo_lock.pyandscripts/check_summary.py - One
inbox/proposal.diff, from a person or from one model call - A check command that exits 0
python3 scripts/demo_lock.py hash --lock demo.lock.json
python3 scripts/demo_lock.py check --lock demo.lock.json --patch inbox/proposal.diff
echo $?
Exit codes
Zero means the copy applied and the summary check passed. Any other code means the patch stays in the inbox. There is no silent rewrite of the working tree. Run the check twice. The second run should behave the same, because the apply happens in a temp directory that is deleted on the way out.
What was skipped
Skipping is part of the log.
- No multi-file refactor. The allowed write list is one path.
- No streaming chat, and no second turn when the check fails.
- No browser, no package install, and no network call inside the checker.
- No automatic commit, push, or pull request.
- No benchmark of model quality. A pass means the fixture contract held, not that the prose is good.
- No claim about uptime, later pricing, or which model id answered.
- No portfolio site, profile renderer, or visual theme. The demo is a command.
Failures to force before Sunday
Treat this list as a test plan. These are not recorded results from a run.
- A patch larger than 8192 bytes should exit before
git apply. - A diff that edits
.envorsecrets/notes.mdshould exit on the path rule. - A diff that creates
fixtures/notes/out/extra.mdshould exit because that path is not allowed. - A summary that contains
TODOshould failcheck_summary.py. - A missing
fixtures/notesdirectory should exit before any apply. - A changed input note should exit with
fixture driftbefore any apply. - A clean diff that writes a short summary without placeholders should be the only green path.
- Running the green path twice should not change files under
fixtures/notes/in/.
Review the patch with git apply --check on a throwaway clone if the proposal script is still unreviewed. The script is a sketch of the gate, not a substitute for reading the diff.
Limitations
The path parser is naive. Unified diffs can carry renames, quoted paths, and binary sections that this sketch does not fully classify. git apply --check is the real apply gate, and it requires Git on the machine. Patches with CRLF endings and Windows path separators are untested here. Copying the whole tree into a temp directory is fine for a tiny side project and wrong for a monorepo.
The checker does not understand intent. It will accept a fluent summary that still misstates the bug unless the check script encodes a literal from the notes. Hash equality proves the inputs did not change. It does not prove the summary is true.
Do not use this approach for a hosted agent, a multi-tool workflow, or a quality score on prose. Do not use it when the weekend goal is a portfolio site, a game, or a profile page. Those need a different cut. A team that already has a review bot should not replace that bot with this file.
Before calling the weekend done
- Confirm the lock hashes match the three notes on disk.
- Confirm the inbox holds one diff, not a chain of retries.
- Confirm the apply step used a temp copy and left the fixture inputs unchanged.
- Confirm the skipped list is still skipped.
- Confirm any product sentence in the README matches current docs, not this draft.
If a free server is already available, point only the generate step at it and leave the lock in the repo. If it is not, write the diff by hand and keep the same check. Call the demo done when that command exits 0 on a clean copy, and leave the skip list in the README so the next session does not quietly grow the scope.
Top comments (0)