DEV Community

Avery Lin
Avery Lin

Posted on

After the Merge: Split Documentation Refresh Into Two Work Queues

Generated documentation usually fails after the merge, not at the moment a model produces the first draft. Mechanical claims age with every renamed flag, while judgment claims age only when a decision actually changes. A single regenerate-all pass therefore overwrites the wrong sentences or leaves the right ones stale. The workable protocol is a split refresh: one queue the model may redraft, and one queue a human must rewrite.

This article treats that split as a documentation-generation workflow rather than as a style preference. It defines claim classes, a section annotation convention, and a queue builder that reads a git diff. The builder emits two files a reviewer can act on without rereading the entire guide. A constructed example later maps changed source paths onto queued headings without claiming production metrics.

Why a blob refresh fails in practice

Most teams regenerate an entire markdown file when a related source path changes in the same pull request. That habit collapses two different failure modes into one edit, which hides the more expensive error from reviewers. Mechanical drift is visible when a flag string no longer exists; judgment drift stays invisible until someone follows a rationale that production already abandoned.

A blob refresh also trains reviewers to skim, because the pull request now contains one undifferentiated wall of generated prose. Once a model rewrites the threat-model section beside the flag table, ownership becomes a sea of green. The cheaper move is to never send the threat-model section to the model after a purely mechanical change.

Four patterns show up in review logs even when a team has not collected a large quantitative sample.

  1. Flag tables that still document removed switches after a CLI refactor lands.
  2. Copy-pasted rationale paragraphs that survive a decision reversal without a named owner.
  3. Example commands that were regenerated while the surrounding operational warning was left untouched.
  4. Security assumptions rewritten in fluent prose that no accountable reviewer actually signed.

The queue split attacks those four patterns directly instead of hoping a later edit will catch them. Mechanical headings enter the model queue after a related diff. Decision, policy, and warning headings enter the human queue even when neighboring tables are stale. Ambiguous headings default to the human queue, because a missed mechanical refresh is cheaper than an unauthorized rewrite of a guarantee.

Claim classes that decide the queue

Treat every documentation heading as a claim with a class, not as a blob of helpful text. The class is a review contract: it says who may change the sentences under that heading after a source diff. Keep the taxonomy small enough that authors can apply it without forming a committee for each file.

Claim class Typical heading After a related code diff Queue
mechanical Flags, paths, status codes, env vars Model may redraft from the cited files model_refresh
executable Runnable examples, curl recipes Model may propose; a test must run them model_refresh plus CI
rationale Why this design, why not the alternative Human rewrites or confirms unchanged human_rewrite
guarantee SLA, threat model, data retention, support Human rewrites; model context is denied human_rewrite
migration Breaking-change notes, rollback, dual-run Human rewrites from the release ticket human_rewrite
unknown Anything unclassified Default to human human_rewrite

Executable examples sit in the model queue only because their truth is checkable against a command. If the example cannot be executed in CI, reclassify it as unknown and keep it off the model. Guarantees never enter the model queue, even when the surrounding file is mostly mechanical, because fluency is not ownership and a smooth paragraph can still be false.

Annotate headings in the document itself

A mapping file that lives far from the prose will drift the first time someone adds a heading. Put a one-line annotation under each heading so the queue builder and the human author share the same signal. HTML comments survive most static-site pipelines and stay invisible in the rendered page that readers actually see.

## CLI flags

<!-- claim-class: mechanical sources: src/cli/flags.go, src/cli/help.go -->

`--region` selects the partition. `--json` writes machine-readable output.

## Why the CLI is region-scoped

<!-- claim-class: rationale sources: docs/adrs/adr-014-region-scope.md -->

Region scope exists so a single credential cannot mutate every partition.
Enter fullscreen mode Exit fullscreen mode

Numbered authoring rules keep the comments boring, parseable, and hard to bikeshed during review.

  1. Place the comment on the line immediately after the heading, never before the heading text.
  2. Use only mechanical, executable, rationale, guarantee, migration, or unknown.
  3. List sources as repository-relative paths, comma-separated, with no globs inside the comment.
  4. If a heading has no comment, the builder assigns unknown and routes that heading to the human queue.

Globs belong in the mapping file, not in the document, because authors should name the files they actually read. A glob in a comment looks precise and then silently matches a generated path that no reviewer opened.

Keep a mapping file for path-to-doc edges

The comment names sources for a heading after a human already opened the file. The mapping file names the reverse edge: which source globs can dirty which documents when git reports a path. Both are required, because a git diff speaks in paths, while reviewers speak in headings and need a short list.

{
  "default_class": "human_rewrite",
  "documents": [
    {
      "path": "docs/cli.md",
      "globs": ["src/cli/**", "cmd/root.go"]
    },
    {
      "path": "docs/security.md",
      "globs": ["src/auth/**", "src/tokens/**"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The mapping does not repeat claim classes, and that omission is deliberate rather than incomplete. Classes stay in the markdown so a docs-only edit can change ownership without touching a second file. The JSON file answers a narrower question: given this diff, which documents must be scanned for annotated headings.

Build the two queues from a git diff

The following script is a constructed stdlib-only example, and it is not a production service with measured throughput. Label it as a starting harness, then add tests before you enforce it on protected branches. The only policy it encodes is routing: mechanical and executable headings may be drafted, and every other class must not.

Step 1 — Save the builder

#!/usr/bin/env python3
"""Emit model_refresh and human_rewrite queues from a git diff."""

from __future__ import annotations

import argparse
import fnmatch
import json
import re
import subprocess
from collections import defaultdict
from pathlib import Path

HEADING_RE = re.compile(r"^(#{2,6})\s+(.*\S)\s*$")
CLASS_RE = re.compile(
    r"<!--\s*claim-class:\s*(\w+)(?:\s+sources:\s*([^>]*))?\s*-->"
)
MODEL_CLASSES = {"mechanical", "executable"}


def git_changed_files(since: str) -> list[str]:
    out = subprocess.check_output(
        ["git", "diff", "--name-only", since],
        text=True,
    )
    return [line.strip() for line in out.splitlines() if line.strip()]


def load_map(path: Path) -> dict:
    return json.loads(path.read_text(encoding="utf-8"))


def docs_hit_by_diff(mapping: dict, changed: list[str]) -> list[str]:
    hits: list[str] = []
    for doc in mapping.get("documents", []):
        for glob in doc["globs"]:
            if any(fnmatch.fnmatch(item, glob) for item in changed):
                hits.append(doc["path"])
                break
    return hits


def parse_sections(markdown: str) -> list[dict]:
    lines = markdown.splitlines()
    sections: list[dict] = []
    current = None
    for index, line in enumerate(lines):
        heading = HEADING_RE.match(line)
        if heading:
            current = {
                "level": len(heading.group(1)),
                "title": heading.group(2).strip(),
                "class": "unknown",
                "sources": [],
                "start": index,
            }
            sections.append(current)
            continue
        if current is None:
            continue
        comment = CLASS_RE.search(line)
        if comment and current["class"] == "unknown":
            current["class"] = comment.group(1)
            raw = comment.group(2) or ""
            current["sources"] = [p.strip() for p in raw.split(",") if p.strip()]
    return sections


def classify(section: dict) -> str:
    if section["class"] in MODEL_CLASSES:
        return "model_refresh"
    return "human_rewrite"


def write_queue(path: Path, rows: list[dict], queue_name: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    lines = [
        f"# {queue_name}",
        "",
        "Constructed queue. Confirm each heading before any model call.",
        "",
    ]
    if not rows:
        lines.append("_Empty._")
    for row in rows:
        sources = ", ".join(row["sources"]) or "(none listed)"
        lines.extend(
            [
                f"## {row['title']}",
                "",
                f"- File: `{row['doc']}`",
                f"- Class: `{row['class']}`",
                f"- Sources: {sources}",
                "",
            ]
        )
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--since", default="HEAD~1")
    parser.add_argument("--map", default="doc-refresh.json")
    parser.add_argument("--out", default=".refresh-queues")
    args = parser.parse_args()

    mapping = load_map(Path(args.map))
    changed = git_changed_files(args.since)
    queues = defaultdict(list)

    for doc_path in docs_hit_by_diff(mapping, changed):
        text = Path(doc_path).read_text(encoding="utf-8")
        for section in parse_sections(text):
            queues[classify(section)].append(
                {
                    "doc": doc_path,
                    "title": section["title"],
                    "class": section["class"],
                    "sources": section["sources"],
                }
            )

    out = Path(args.out)
    write_queue(out / "model_refresh.md", queues["model_refresh"], "model_refresh")
    write_queue(out / "human_rewrite.md", queues["human_rewrite"], "human_rewrite")
    print(f"model_refresh={len(queues['model_refresh'])}")
    print(f"human_rewrite={len(queues['human_rewrite'])}")


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

Step 2 — Add a failing unit check around the classifier

Keep these tests in test_refresh_queues.py so the policy cannot silently invert during a later refactor. They do not prove that a redraft is factually correct; they only prove the heading was allowed into the intended queue.

from refresh_queues import classify


def test_unknown_headings_never_enter_the_model_queue():
    section = {"class": "unknown", "title": "Notes", "sources": []}
    assert classify(section) == "human_rewrite"


def test_mechanical_headings_enter_the_model_queue():
    section = {
        "class": "mechanical",
        "title": "CLI flags",
        "sources": ["src/cli/flags.go"],
    }
    assert classify(section) == "model_refresh"


def test_guarantee_headings_stay_on_the_human_queue():
    section = {
        "class": "guarantee",
        "title": "Token lifetime",
        "sources": ["src/auth/tokens.go"],
    }
    assert classify(section) == "human_rewrite"
Enter fullscreen mode Exit fullscreen mode

Those three tests encode the only non-negotiable policy in the harness. Unknown is not an invitation to generate missing prose. Mechanical is not an invitation to skip a human skim of the identifiers. Guarantee is not a special case that a fluent model is allowed to paraphrase.

Step 3 — Run it against the last merge

python3 refresh_queues.py --since HEAD~1 --map doc-refresh.json
cat .refresh-queues/model_refresh.md
cat .refresh-queues/human_rewrite.md
Enter fullscreen mode Exit fullscreen mode

Read both files before any drafting tool is invoked on the repository. If human_rewrite.md is not empty, open tickets or assign reviewers from CODEOWNERS for those headings only. Do not paste the human file into a prompt in order to finish the pull request faster.

Step 4 — Publish the queues from CI without merge-blocking on a model

Pin action versions and Python versions to whatever the GitHub Marketplace and your support window currently list. The workflow below is a shape, not a claim about a particular marketplace release.

name: doc-refresh-queues
on:
  pull_request:
    paths:
      - "src/**"
      - "docs/**"
      - "doc-refresh.json"
      - "refresh_queues.py"
jobs:
  queues:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Build refresh queues
        run: python3 refresh_queues.py --since origin/${{ github.base_ref }}
      - name: Upload queues
        uses: actions/upload-artifact@v4
        with:
          name: refresh-queues
          path: .refresh-queues
Enter fullscreen mode Exit fullscreen mode

CI here does not merge-block on a model completing a draft, which would couple documentation truth to generator availability. It publishes the two queues so the review checklist cannot pretend the whole file is equally draftable. Reviewers then spend time on the human queue and only spot-check identifiers in the model queue.

Where a free model and a free server fit

Only the model_refresh file should ever be sent to a drafting model after the queues exist. The human queue is a work list, not a prompt, and mixing the two files recreates blob refresh with nicer headings. Free model access is enough for mechanical redrafts because the input is bounded: one heading, its listed sources, and a requirement to quote identifiers from those sources. A free server option is enough to host the queue builder and the drafting job, because the workload is a short git diff plus a handful of markdown files rather than a standing index of the whole repository.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

If you use that free model access, pass the model queue and the cited source files only, and keep guarantee headings out of the context window. The server does not change the ownership rule; it only removes the need to provision a private host for a job that should stay small. Run the queue builder on one recent merge before wiring any model to the output, and compare the two files with the headings reviewers actually touched.

Constructed walkthrough

Suppose src/cli/flags.go changes and src/auth/tokens.go does not change in the same merge. The mapping marks docs/cli.md dirty and leaves docs/security.md clean, which already prevents a security rewrite that nobody requested. Inside docs/cli.md, CLI flags is mechanical and Why the CLI is region-scoped is rationale. The builder therefore lists the flags heading in model_refresh.md and the rationale heading in human_rewrite.md.

The model may rewrite the flags table from flags.go and must fail closed if an identifier cannot be found in that file. The human must either rewrite the rationale or record that the decision is unchanged, with a name on the change. Mixing those two edits in one undifferentiated generation pass is the failure mode this workflow exists to prevent, and the queues make that mix visible before anyone reviews prose.

Limitations

The comment parser is line-oriented and will miss classes wrapped in shortcode fences or MDX components that the regex never sees. Path globs cannot see generated files that are not committed, so API reference trees built only in CI need a separate pipeline. The script treats every heading in a dirty document as potentially stale, which over-queues on large guides that share a source glob. It also cannot prove that a mechanical redraft is correct; it only proves that the heading was allowed into the model queue.

Defaulting unknown to the human queue will annoy teams that leave most headings unclassified for months. That annoyance is the point of the default, not a defect to configure away on day one. Classification is the cost of letting a model touch production documentation after a merge, and unpaid classification work will show up as an oversized human queue.

Who should not use this

Do not use this workflow on a single-page README whose entire contents are mechanical and already generated from --help. Do not use it for regulated documents that forbid any model-authored sentence, because the model queue would be a policy violation rather than a convenience. Do not use it when CODEOWNERS cannot name a human for guarantee headings, because the human queue then becomes a graveyard of unassigned work. Do not send the human queue to a model just to save time, which collapses the split and returns the team to blob refresh.

Teams that already generate API reference from source should keep that pipeline, and should not route those pages through this heading classifier. This harness is for narrative guides that mix tables with decisions, not for generated symbol lists that have no rationale sections. If a repository has no merge discipline around docs paths, the mapping file will be empty and both queues will lie.

Close the loop on the next merge

Run the queue builder on one recent merge and compare the two files with what reviewers actually edited in that pull request. If mechanical headings sat in the human queue, the comments are missing and authors need a lint, not a longer prompt. If guarantee headings sat in the model queue, the classifier is wrong and should fail closed until the annotation is fixed. Keep the model on the mechanical side of that line, and keep a named human on the side that still requires a decision.

Top comments (0)