A Handoff Log for Model-Drafted Docs: Two Lanes and a Script That Counts Who Wrote What
Most documentation pipelines fail not because the model writes badly, but because nobody records who was allowed to write which part. Teams hand an entire page to a model, get plausible prose back, and only discover mont
Most documentation pipelines fail not because the model writes badly, but because nobody records who was allowed to write which part. Teams hand an entire page to a model, get plausible prose back, and only discover months later that the confident paragraph about rate limits was never checked. A handoff log fixes that with far less ceremony than a policy file: every section gets a lane, an evidence stamp, and a row in a CSV you can sum when the sprint ends.
What follows is a two-lane generation workflow for reference documentation, plus a small standard-library script that reports the split. The lane rule is the entire design; the script exists only to make the rule visible and to stop the log from quietly drifting into fiction.
The lane rule: re-derivable or owned
A claim belongs to the draft lane when a reader can re-derive it by running something against the repository. "Retries back off exponentially from a two-second base delay" is draftable if grep -n base_delay src/retry.py returns the constant. A claim belongs to the human lane when only a person can be accountable for it: why a default was chosen, what is deprecated and what replaces it, which failure modes are safe to ignore, and any sentence containing "do not".
That split is intentionally crude. Section-level granularity is coarse enough to plan in an afternoon and fine enough that a wrongly assigned section shows up as a review disagreement rather than a silent factual error. The table below is the whole rubric; resist adding a third lane until you have run at least two sprints with two.
| Signal in the section | Lane | Who signs | Evidence stamp |
|---|---|---|---|
| Constants, flags, error strings, exit codes | draft | reviewer | cmd:<command> |
| Schema fields that exist in a checked-in file | draft | reviewer | cmd:<command> |
| Trade-offs, defaults, deprecations, warnings | human | author | human:<initials>@<date> |
| Anything a reader could lose data by misreading | human | author | human:<initials>@<date> |
The workflow, in six steps
-
Freeze the outline before writing any prompt. Split the page into sections of 150β400 words and give each a stable id such as
03-retries. An id that survives the sprint is what lets you join the log to the diff later. - Build a bounded evidence pack per section. Give the model the outline slice, the files the last release touched, and one test log, rather than the whole repository. A byte budget keeps the pack auditable and turns "the model had no way to know that" into a checkable claim.
- Draft only the draft-lane sections. Ask for the section body plus the exact command supporting each factual sentence. If the model cannot name a command, that sentence moves to the human lane instead of into the draft.
- Write the human-lane sections from scratch. Consulting the model as a reader is fine; pasting its prose is not, because the point of the lane is that a person can defend every word in review.
- Log each section in the same CSV before the sprint closes. Records reconstructed from memory a week later are guesses, and contemporaneity is the only property that makes the log worth keeping.
- Run the report and read the violations before the totals. Lane-rule violations matter more than aggregates, because they mark sections that were never genuinely assignable to a lane.
The log schema and the evidence pack
Seven columns are enough for a first sprint: section_id,title,lane,evidence,words,minutes,model_calls. The rows below are format examples only, not measurements from a real sprint; replace every value with your own before drawing any conclusion from them.
section_id,title,lane,evidence,words,minutes,model_calls
02-auth,Authenticating requests,draft,cmd:pytest -q tests/test_auth.py,320,25,3
03-retries,Retry behaviour,draft,cmd:grep -n base_delay src/retry.py,240,18,2
04-limits,Rate limits and fair use,human,human:AL@2026-10-19,180,55,1
05-migration,Migrating from v1,human,human:AL@2026-10-19,410,90,0
The pack builder is a shell script because the inputs are all files you already have. It is deliberately lossy: truncation is visible in the output file, so a reviewer can tell whether the model saw the fixture or only its summary.
#!/usr/bin/env bash
# pack.sh <section-id> - build a bounded evidence bundle for one section
set -euo pipefail
section="${1:?usage: pack.sh 03-retries}"
budget=$((64 * 1024))
out="packs/${section}.md"
mkdir -p packs
{
printf '# section: %s\n\n' "$section"
printf '## outline slice\n'; sed -n '1,40p' "outline.d/${section}.md"
printf '\n## recently touched files\n'
git log --name-only --pretty=format: -n 20 -- . | sort -u | head -40
printf '\n## test log (truncated to %s bytes)\n' "$budget"
head -c "$budget" "out/test-${section}.log" 2>/dev/null || echo '(no test log for this section)'
} > "$out"
wc -c "$out"
The report script
The script below is plain Python 3 with only the standard library, small enough to audit in one sitting. It sums the lanes, computes minutes per hundred words, and prints every row that breaks a lane rule; --strict makes those violations fail a CI step if you want that behavior later.
#!/usr/bin/env python3
"""handoff_report.py - summarize a documentation sprint handoff log.
Reads handoff.csv with the header:
section_id,title,lane,evidence,words,minutes,model_calls
Rules enforced:
lane=draft -> evidence must start with "cmd:" (a command that reproduces the claim)
lane=human -> evidence must start with "human:" (initials@YYYY-MM-DD of the owner)
"""
import argparse
import csv
import sys
from collections import defaultdict
DRAFT, HUMAN = "draft", "human"
REQUIRED = ["section_id", "title", "lane", "evidence", "words", "minutes", "model_calls"]
def load(path):
with open(path, newline="", encoding="utf-8") as fh:
reader = csv.DictReader(fh)
missing = [c for c in REQUIRED if c not in (reader.fieldnames or [])]
if missing:
sys.exit("missing columns: " + ", ".join(missing))
rows = []
for i, raw in enumerate(reader, start=2): # row 1 is the header
try:
rows.append({
"section_id": raw["section_id"].strip(),
"title": raw["title"].strip(),
"lane": raw["lane"].strip().lower(),
"evidence": raw["evidence"].strip(),
"words": int(raw["words"]),
"minutes": float(raw["minutes"]),
"model_calls": int(raw["model_calls"]),
"line": i,
})
except ValueError as exc:
sys.exit(f"line {i}: not a number ({exc})")
return rows
def violations(rows):
out = []
for r in rows:
if r["lane"] not in (DRAFT, HUMAN):
out.append((r["line"], r["section_id"], f"unknown lane {r['lane']!r}"))
elif r["lane"] == DRAFT and not r["evidence"].startswith("cmd:"):
out.append((r["line"], r["section_id"],
f"draft lane needs cmd: evidence, got {r['evidence']!r}"))
elif r["lane"] == HUMAN and not r["evidence"].startswith("human:"):
out.append((r["line"], r["section_id"],
f"human lane needs human: evidence, got {r['evidence']!r}"))
return out
def summarize(rows):
totals = defaultdict(lambda: {"sections": 0, "words": 0, "minutes": 0.0, "calls": 0})
for r in rows:
t = totals[r["lane"]]
t["sections"] += 1
t["words"] += r["words"]
t["minutes"] += r["minutes"]
t["calls"] += r["model_calls"]
return totals
def line_for(lane, t, all_words, all_minutes):
share_w = 100.0 * t["words"] / all_words if all_words else 0.0
share_m = 100.0 * t["minutes"] / all_minutes if all_minutes else 0.0
pace = 100.0 * t["minutes"] / t["words"] if t["words"] else 0.0
return (f"{lane:<6} {t['sections']:>4} {t['words']:>7} {share_w:>6.1f}% "
f"{t['minutes']:>8.1f} {share_m:>6.1f}% {pace:>9.1f}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("log", nargs="?", default="handoff.csv")
ap.add_argument("--strict", action="store_true",
help="exit 1 when the log breaks a lane rule")
args = ap.parse_args()
rows = load(args.log)
if not rows:
sys.exit("empty log")
totals = summarize(rows)
all_words = sum(t["words"] for t in totals.values())
all_minutes = sum(t["minutes"] for t in totals.values())
print(f"{'lane':<6} {'sec':>4} {'words':>7} {'w%':>7} {'min':>8} {'m%':>7} {'min/100w':>9}")
for lane in (DRAFT, HUMAN):
if lane in totals:
print(line_for(lane, totals[lane], all_words, all_minutes))
secs = sum(t["sections"] for t in totals.values())
print(f"{'all':<6} {secs:>4} {all_words:>7} {'':>7} {all_minutes:>8.1f}")
bad = violations(rows)
for line, sid, msg in bad:
print(f"line {line} [{sid}] {msg}", file=sys.stderr)
print(f"\n{len(bad)} lane-rule violation(s) in {len(rows)} section(s)")
return 1 if (bad and args.strict) else 0
if __name__ == "__main__":
sys.exit(main())
Run against the placeholder rows above, it prints the arithmetic below. This is a check that your understanding of the columns matches mine, not a finding about drafting costs.
lane sec words w% min m% min/100w
draft 2 560 48.7% 43.0 22.9% 7.7
human 2 590 51.3% 145.0 77.1% 24.6
all 4 1150 188.0
0 lane-rule violation(s) in 4 section(s)
Where the free options fit
Steps 2 and 6 touch your repository and your test logs, and neither of them needs a permanent machine. Drafting is where free model access is genuinely useful: the packs are small, the sections are independent, and a bad draft costs one regeneration rather than a rebuilt pipeline. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The availability of free model access and a free server option is supplied by the operator; I have not measured model names, quotas, hardware, or duration here, so confirm the current terms before you build a habit around them.
A free server is a defensible place to run pack.sh and the report against a clone you can discard, because neither script needs your CI secrets or your release tags. Keep the CSV in version control and the packs out of it, since packs are large, contain test logs, and are rebuildable from the id alone.
Limitations and who should skip this
- The log measures effort, not accuracy. A cheap draft lane with three factual errors is a bad trade, and only review disagreements reveal that, not the totals.
-
A fully rewritten section should be re-logged in the human lane. Otherwise the report flatters the draft lane; the script then flags the missing
human:stamp, which is the correct outcome rather than a bug. - Docs generated from a single source of truth rarely need this. When an OpenAPI file or a schema registry already emits the page, the model is not the bottleneck and the output is re-derivable by construction.
- Small teams should skip it. Below roughly one doc page a week, the logging overhead exceeds what the log saves, and a short review checklist is cheaper.
- Regulated or contractual documentation does not fit. Formal approval per statement is a human-lane activity with no draft counterpart, so the rubric collapses.
What the numbers can and cannot settle
Word counts and minutes per lane tell you where the sprint's time actually went; they say nothing about whether the page is now correct. Read violations first, then compare min/100w between lanes for sections of similar risk, and treat any gap under ten minutes as noise from one slow review. If you run a sprint this way, keep the log even when it goes badly, because the section that blew up is the one that prices your lanes for the next sprint.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.