Suppose a documentation PR arrives with every sentence already written. The prose is clean, the headings are right, and nothing in the diff looks like a placeholder. Now try to review it.
You cannot tell which sentences came from the repository and which came from the model's sense of what usually goes in a paragraph like that. Reviewers in this situation do what everyone does: they skim, they approve, and one wrong number ships.
The failure is not that the model writes badly. It is that we asked it to produce sentences when the thing we were missing was facts. Sentences are the part it already has.
Invert the task: make the holes the deliverable
Hole-first drafting flips the order of work. In the draft phase, the model's job is not to fill the page; it is to find every place where the page needs a fact it cannot source, and mark it. Filling those marks is a separate phase with a separate owner.
That gives you three properties you do not get from a better prompt:
- The set of missing facts is enumerable before review starts, so review has a scope instead of a vibe.
- The prose frame and the fact fills are separate artifacts, so you can freeze one and audit the other.
- "The model invented a number" stops being a tone problem and becomes a failing check with a file and line number.
The slot grammar
The whole protocol rests on two shapes, both greppable and both human-readable in a raw diff:
open slot [[?name]]
filled slot [[!name=value @ citation]]
Rules that make it enforceable rather than decorative:
- Every causal or numeric claim in a fact-bearing doc must be a slot, not free prose.
- A filled slot without
value @ citationis invalid. - A citation is a repo path with an optional
#anchor, or anhttp(s)URL. If the path does not resolve, the fill is invalid. - A fill PR may change slot contents and nothing else. The prose frame is hashed at draft time and must match at fill time.
The artifact: slotcheck.py
One file, standard library only, two modes that map to the two PR types. Save it as tools/slotcheck.py.
#!/usr/bin/env python3
"""slotcheck.py - hole-first drafting gate.
Usage:
python slotcheck.py draft docs/*.md # model PR: prose frame + open slots
python slotcheck.py fill docs/*.md # human PR: slots resolved, frame frozen
"""
from __future__ import annotations
import hashlib
import json
import pathlib
import re
import sys
SLOT = re.compile(r"\[\[(\?|!)([a-z0-9_]+)(?:=(.+?)\s*@\s*(\S+))?\]\]")
FREEZE = pathlib.Path("docs/.slots/frozen.json")
MIN_SLOTS = 3 # per doc; tune per doc class, do not set to 0 to "clean up CI"
def frame(text: str) -> str:
"""Collapse every slot to its open form: the part the model owns."""
return SLOT.sub(lambda m: f"[[?{m.group(2)}]]", text)
def digest(text: str) -> str:
return hashlib.sha256(frame(text).encode("utf-8")).hexdigest()[:16]
def citation_ok(cite: str) -> bool:
if cite.startswith(("http://", "https://")):
return "/" in cite.split("//", 1)[1] # shape check only, see limitations
return pathlib.Path(cite.split("#", 1)[0]).exists()
def scan(path: pathlib.Path):
open_slots, filled, errors = [], [], []
text = path.read_text(encoding="utf-8")
for lineno, line in enumerate(text.splitlines(), 1):
for kind, name, value, cite in SLOT.findall(line):
if kind == "?":
open_slots.append(f"{path}:{lineno}:{name}")
continue
if not value or not cite:
errors.append(f"{path}:{lineno}: {name} filled without value@cite")
elif not citation_ok(cite):
errors.append(f"{path}:{lineno}: {name} cites a dead path -> {cite}")
else:
filled.append(f"{path}:{lineno}:{name}")
return text, open_slots, filled, errors
def main() -> int:
if len(sys.argv) < 3:
print(__doc__)
return 2
mode, targets = sys.argv[1], [pathlib.Path(p) for p in sys.argv[2:]]
if mode not in {"draft", "fill"}:
print(f"unknown mode: {mode}")
return 2
frozen = json.loads(FREEZE.read_text()) if FREEZE.exists() else {}
errors: list[str] = []
for path in targets:
text, open_slots, filled, scan_errors = scan(path)
errors += scan_errors
key = str(path)
if mode == "draft":
total = len(open_slots) + len(filled)
if total < MIN_SLOTS:
errors.append(f"{path}: draft has {total} slot(s), need >= {MIN_SLOTS}")
frozen[key] = digest(text)
else:
if open_slots:
errors.append(f"{path}: {len(open_slots)} unresolved slot(s): {open_slots}")
if key not in frozen:
errors.append(f"{path}: no frozen draft; run `draft` mode first")
elif digest(text) != frozen[key]:
errors.append(f"{path}: prose changed outside slots (frame hash drift)")
if mode == "draft":
FREEZE.parent.mkdir(parents=True, exist_ok=True)
FREEZE.write_text(json.dumps(frozen, indent=2, sort_keys=True) + "\n")
for line in errors:
print(f"FAIL {line}")
print(f"{mode}: {len(targets)} file(s), {len(errors)} problem(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
The interesting function is frame. It rewrites every filled slot back to [[?name]] before hashing, which means the hash is invariant across draft and fill. A human can change 250 ms to 300 ms without breaking the gate. A human cannot quietly rewrite the surrounding paragraph, and neither can a second model pass.
What it looks like on a real doc
Draft, as the model produces it:
## Retry budget
The upload worker retries a failed part [[?retry_budget]] before it surfaces an error.
Backoff starts at [[?backoff_base]] and doubles on each attempt, capped at [[?backoff_cap]].
Fill, as a human produces it after reading deploy/worker.yaml:
## Retry budget
The upload worker retries a failed part [[!retry_budget=3 attempts @ deploy/worker.yaml#L41]] before it surfaces an error.
Backoff starts at [[!backoff_base=250 ms @ deploy/worker.yaml#L52]] and doubles on each attempt, capped at [[!backoff_cap=8 s @ deploy/worker.yaml#L53]].
Example output from the two runs (illustrative, not a benchmark):
$ python slotcheck.py draft docs/upload-worker.md
draft: 1 file(s), 0 problem(s)
$ python slotcheck.py fill docs/upload-worker.md
FAIL docs/upload-worker.md: 2 unresolved slot(s): ['docs/upload-worker.md:9:backoff_base', 'docs/upload-worker.md:9:backoff_cap']
fill: 1 file(s), 1 problem(s)
Wire the two modes into CI
Two jobs, two identities. The draft job runs on the bot's PR; the fill job runs on the human's PR. Keep the checks dumb and the messages line-numbered.
jobs:
draft-gate:
if: github.event.pull_request.user.login == 'docs-bot'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python tools/slotcheck.py draft docs/*.md
- run: git diff --exit-code docs/.slots/frozen.json || echo '::notice::freeze updated, commit it with the draft'
fill-gate:
if: github.event.pull_request.user.login != 'docs-bot'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python tools/slotcheck.py fill docs/*.md
Decision table: which sentences become slots
The protocol only pays off if you decide this once, per doc class, and write it down.
| Question the sentence answers | Who may answer | Becomes a slot? |
|---|---|---|
| What does this function do today? | Model, citing the file | No — but prose must name the file |
| Why was this built this way? | Human | Yes |
| Any number: latency, limit, price, timeout | Human | Always |
| What a user or team experienced | Human | Always |
| Migration order and rollback steps | Human | Yes |
| Config key names, API signatures | Model, verified by a repo grep test | No |
| Planned rewrite or deprecation date | Human | Always |
| Restating a README that the repo already owns | Nobody — link it | No |
The middle column is the contract. If a doc class has no human-owned rows, the model can draft it end to end and the slot gate is wasted ceremony.
A reproducible test plan
Run this before you trust the gate on your own repo. It takes about ten minutes.
- Draft a doc with three open slots. Confirm
draftmode exits 0 and writesdocs/.slots/frozen.json. - Fill all three with valid repo paths. Confirm
fillmode exits 0. - Change one filled value (
250 msto300 ms), leaving the citation. Confirmfillmode still exits 0 — this is the case that must not be blocked. - Add a sentence outside any slot. Confirm
fillmode fails withframe hash drift. - Point one citation at
deploy/nope.yaml. Confirmfillmode fails withcites a dead path. - Delete a slot entirely and run
fill. Confirm the frame hash catches it. - Regression case: run the whole thing on a doc with zero facts (a changelog index). If
MIN_SLOTSforces slot padding there, your doc classes need splitting, not a lower threshold.
Limitations, and who should not use this
Be honest about the ceiling. A citation check proves the path exists; it does not prove the path supports the number you wrote next to it. A tired human can cite L52 while reading L51, and slotcheck.py will pass. This gate moves the failure from "invisible" to "one link away", which is better but not the same as verified.
The frame hash also blocks legitimate concurrent editing. Two people fixing different paragraphs in the same file will trip it, so this works best with small docs and short fill windows.
Do not adopt this if:
- Your docs have no stable artifact to cite — early design RFCs, positioning pages, anything that is itself the source of truth.
- Your doc set is small enough that one reviewer reads every line anyway; the ceremony outweighs the catch rate.
- You need sub-hour turnaround on doc fixes, since the protocol adds a phase boundary by design.
- You cannot enforce two PR identities. Without a separate bot identity,
draftandfillcollapse into one, and the whole point evaporates.
Where free model access fits the loop
The draft phase is high-volume and low-trust: you want many passes at "where are the holes in this page", and you want the ability to throw all of them away. That is exactly the workload where paid per-token calls get rationed by habit rather than by need, and rationed drafting means fewer passes and more unmarked invented facts.
MonkeyCode is relevant here for one specific reason: it offers free model access and a free server option, which lets you run the hole-finding pass repeatedly and host the slot checker plus its freeze file without opening a spend conversation first. Check the project's own site for the current free allocation and server terms — quotas and hardware change, and this article deliberately does not restate numbers that may be stale by the time you read it.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Nothing above depends on the product being free. The slot grammar, the script, and the two CI jobs run identically against any model endpoint, which is the point: the protocol is the durable part, and the drafting budget is a temporary detail.
The review question changes
With the protocol in place, a doc reviewer stops asking "is any of this wrong?" and starts asking "are the holes filled with things I can open?" That is a question a person can actually answer at 5 p.m. on a Friday.
The model keeps the part it is good at — structure, transitions, complete sentences around a gap — and you keep the part it cannot have: knowing what is true in your system this week. If you want to try the draft half without a procurement detour, MonkeyCode's free models and free server are one way to get the loop running today.
Top comments (0)