#!/usr/bin/env python3 """The development cycle, checked. The knowledge flow (00-META/process/00-overview.md) says work moves idea -> research -> decision -> to-be design -> code, and symptom -> issue -> diagnosis -> fix. Those are rules, and a rule states how it is checked (AGENTS.md) -- this is how. Everything here reads only frontmatter, because a status this repository holds lives in frontmatter and nowhere else. What is enforced: design every 03-DESIGN doc parses, carries `layer:` matching its directory, and a known `status:`. A TO-BE doc names at least one decision (`decisions:`) -- no design without a decision -- and once `in-progress` or `implemented` it names its owning code (`code:`) -- no development without a design that says where. issues every report has frontmatter, and no two records share a number -- the number is how a record is cited. An issue's status, owner, fix and replay live in the issue tracker since the import of slice 3 (ADR 0275, to-be 53), where `issue-tracker.move` refuses at the write what this check refused at merge until then: a status outside the five, `located` without an owner, `resolved` without a fix, a core issue resolved without its replay (ADR 0237), `wontfix` without a reason. A report carries none of those keys, only the day it was `opened:` -- the tracker holds them, and two places holding one fact drift. The keys the reports opened before the import carried were removed once the import held them; the commit it read is on every imported issue. research a known `status:`; a `graduated` overview says what it `became:`, and every target it names exists. decisions every accepted record is REACHABLE from the cycle: cited by a design doc's frontmatter, a research overview, an issue report, a 00-META document, or another record's extends/supersedes chain. A decision nothing points at is one nobody will find by following pointers -- which is how records go stale in people's heads. Deliberately NOT enforced: as-is docs need no decisions (they describe what exists, not what was decided). python3 00-META/checks/cycle.py """ import glob import os import re import sys ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) DESIGN_STATUSES = {"proposed", "designed", "in-progress", "implemented", "abandoned"} RESEARCH_STATUSES = {"active", "graduated", "abandoned"} # The keys an issue report carried while its status lived here. The issue tracker holds them now (ADR 0275, # slice 3 of to-be 53), and `issue-tracker.move` enforces the cycle's rules on them at the write. A report # carrying one of them is a status written in two places, whenever it was opened. TRACKER_KEYS = ("status", "located-in", "fixed-by", "amended-design", "replay", "replay-none", "resolved") # A decision that defers work names the issue that tracks it (issue 354). ADR 0244 left the code # renames of the node-engine and the tool runner "for a later rename", with no issue, owner or date; # two days later the operator believed them done, and nothing in the record said otherwise. A deferral # nothing tracks is indistinguishable from work that was done. Held to it: an accepted record dated # from DEFERRALS_FROM, and an older one corrected under the rule (a progressive insight dated from then # on): an edit made under the rule is held to it. The phrases are the ones records use to put work off; # "for later" alone is not one, because records say "never queued for later" and "too narrow for later". # The tracking issue is named as tracking it, beside the deferral or in a dated insight in the same section # or quoting it (ISSUE_REF, covers). DEFERRALS_FROM = "2026-10-09" DEFERRAL = re.compile(r"(?i)until (?:it is |they are |the code (?:is )?)?renamed|until the code rename|" r"\ba later rename|not renamed here|identifier to rename|" r"left (?:for|to) (?:a )?later|not decided here|\bpostponed\b|future work|" r"\bdeferred\b|later work|not built here|\ba later change") # The issue that tracks a deferral is named as tracking it -- "tracked by issue N", or "issue N tracks" -- # because a record cites issues for many reasons, and an issue cited as context beside a deferral (ADR 0273 # names issue 340 in the very sentence that leaves a channel undecided) tracks nothing. ISSUE_REF = re.compile(r"(?i)tracked (?:by|in|as)\s+\[?issue (\d{3})|\[?issue (\d{3})\]?(?:\([^)]*\))?\s+tracks\b") INSIGHT = re.compile(r"\*\*Progressive insights?[ \t]*[—–-][ \t]*(\d{4}-\d{2}-\d{2})") def paragraphs(path): """A record's body as (first line number, section heading, chapter, text) paragraphs: frontmatter and fenced code left out, a block quote's markers stripped, so a quoted insight reads as the paragraph it is. The section is the nearest heading above the paragraph; a heading is its own paragraph and its own section. The chapter is the nearest `#` or `##` heading, which a `###` subheading inside it does not change.""" text = open(path, encoding="utf-8").read() m = re.match(r"^---\n.*?\n---\n?", text, re.S) body, first = (text[m.end():], text[: m.end()].count("\n") + 1) if m else (text, 1) body = re.sub(r"(?ms)^\s*```.*?^\s*```", lambda x: re.sub(r"[^\n]", " ", x.group(0)), body) out, line, section, chapter = [], first, "", "" for chunk in body.split("\n\n"): stripped = "\n".join(re.sub(r"^\s*>\s?", "", l) for l in chunk.split("\n")).strip() if stripped: heading = re.match(r"^#{1,6}\s+(.*)", stripped) if heading: section = heading.group(1).strip() if len(stripped) - len(stripped.lstrip("#")) <= 2: chapter = section # A list's top-level items are units of their own: a Consequences list is one paragraph, and an # issue named in one item tracks nothing in the next. items = re.split(r"\n(?=[-*] |\d+\. )", stripped) at = line + chunk[: len(chunk) - len(chunk.lstrip())].count("\n") for item in items: out.append((at, section, chapter, item)) at += item.count("\n") + 1 line += chunk.count("\n") + 2 return out QUOTE = re.compile(r"\"([^\"]{12,})\"|\u201c([^\u201d]{12,})\u201d") def flat(text): """Text compared for a quotation: markup and case dropped, whitespace collapsed.""" return re.sub(r"\s+", " ", re.sub(r"[*`_]", "", text)).strip().lower() def covers(insight_section, insight_text, section, text): """An insight speaks about a deferral paragraph when it stands in the same section, or quotes it.""" if insight_section == section: return True body = flat(text) return any(flat(a or b) in body for a, b in QUOTE.findall(insight_text)) def issue_refs(text, issues): return {n for a, b in ISSUE_REF.findall(text) for n in (a, b) if n and n in issues} def rel(path): return os.path.relpath(path, ROOT) def frontmatter(path): """The YAML block between the first two --- lines, as {key: raw-value-string}. Minimal on purpose, like records.py: enough for the fields these checks read. A list value (block or inline) is joined into its items; a scalar stays a string. """ text = open(path, encoding="utf-8").read() m = re.match(r"^---\n(.*?)\n---", text, re.S) if not m: return None front, out, key = m.group(1), {}, None for line in front.split("\n"): item = re.match(r"^\s+-\s*(.+?)\s*$", line) if item and key: out[key].append(item.group(1)) continue kv = re.match(r"^([A-Za-z-]+):\s*(.*)$", line) if not kv: continue key, value = kv.group(1), kv.group(2).strip() if value.startswith("[") and value.endswith("]"): out[key] = [v.strip() for v in value[1:-1].split(",") if v.strip()] elif value == "": out[key] = [] # a block list may follow; stays [] if nothing does else: out[key] = value return out def listy(front, key): v = front.get(key) if v is None: return [] return v if isinstance(v, list) else ([v] if str(v).strip() else []) def main(): failures = [] def bad(path, why): failures.append(" %s: %s" % (rel(path), why)) # ---- design ------------------------------------------------------------------------ for layer, name in (("00-as-is", "as-is"), ("01-to-be", "to-be")): for path in sorted(glob.glob(os.path.join(ROOT, "03-DESIGN", layer, "*.md"))): if os.path.basename(path) == "README.md": continue front = frontmatter(path) if front is None: bad(path, "no frontmatter") continue if front.get("layer") != name: bad(path, "layer is %r; this directory is %s" % (front.get("layer"), name)) status = front.get("status") if status not in DESIGN_STATUSES: bad(path, "status %r is not one of %s" % (status, sorted(DESIGN_STATUSES))) if name == "to-be": if not listy(front, "decisions"): bad(path, "names no decisions -- no design without a decision") if status in ("in-progress", "implemented") and not listy(front, "code"): bad(path, "status %s but code: names no owner -- no development " "without a design that says where" % status) # ---- issues ------------------------------------------------------------------------ # Two records may not share a number. Numbers are taken as "next free after main", and work # sits on unmerged branches for days -- so two people reading the same main allocate the same # number, and nothing said so. It happened twice in one evening between two machines, and the # second collision landed on main with all three checks passing (issue 155). An issue number is # how every other record cites this one; two records answering to it means a pointer that # resolves to whichever the reader happened to open. seen = {} for folder in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", ""))): name = os.path.basename(os.path.normpath(folder)) number = name.split("-", 1)[0] if not number.isdigit(): continue if number in seen: bad(os.path.join("04-ISSUES", name), "is numbered %s, and so is %s -- an issue number is how it is cited, and two " "records answering to one means a citation that resolves to whichever the reader " "opened. Take the next free number across main AND every open pull request" % (number, seen[number])) else: seen[number] = name # And decision records, which 155's fix left out: on 2026-10-02 two ADRs numbered 0169 landed # on main from two sessions within the hour, and every check passed. seen_records = {} for path in sorted(glob.glob(os.path.join(ROOT, "02-DECISIONS", "[0-9]*.md"))): name = os.path.basename(path) number = name.split("-", 1)[0] if not number.isdigit(): continue if number in seen_records: bad(os.path.join("02-DECISIONS", name), "is numbered %s, and so is %s -- a record's number is how it is cited. Take the next " "free number across main AND every open pull request; the branch that lands last " "renumbers" % (number, seen_records[number])) else: seen_records[number] = name # Since the import of slice 3 (ADR 0275), an issue's status, owner, fix and replay live in the issue # tracker, and its `move` refuses at the write what this check refused at merge. A report carries none # of those keys. for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))): front = frontmatter(path) if front is None: bad(path, "no frontmatter") continue opened = str(front.get("opened") or "") if not opened: bad(path, "opened: says nothing -- the day the issue was opened, YYYY-MM-DD") continue carried = [key for key in TRACKER_KEYS if key in front] if carried: bad(path, "carries %s, but an issue's status, owner, fix and replay live in the issue tracker " "(ADR 0275): move it with `issue-tracker.move`, and keep no copy here" % ", ".join("`%s:`" % key for key in carried)) # ---- research ---------------------------------------------------------------------- for path in sorted(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md"))): front = frontmatter(path) if front is None: bad(path, "no frontmatter") continue status = front.get("status") if status not in RESEARCH_STATUSES: bad(path, "status %r is not one of %s" % (status, sorted(RESEARCH_STATUSES))) if status == "graduated": became = listy(front, "became") if not became: bad(path, "graduated but became: names nothing") for target in became: if not os.path.exists(os.path.join(ROOT, target)): bad(path, "became names %s, which does not exist" % target) # ---- decisions ------------------------------------------------------------------- records = {} for path in sorted(glob.glob(os.path.join(ROOT, "02-DECISIONS", "[0-9]*.md"))): front = frontmatter(path) records[os.path.basename(path)] = (path, (front or {}).get("status")) cited = set() sources = (glob.glob(os.path.join(ROOT, "03-DESIGN", "*", "*.md")) + glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md")) + glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md")) + glob.glob(os.path.join(ROOT, "00-META", "**", "*.md"), recursive=True)) for path in sources: text = open(path, encoding="utf-8").read() if os.sep + "03-DESIGN" + os.sep in path: # A design doc's governing citations live in frontmatter; a prose mention is # commentary, not a home. m = re.match(r"^---\n(.*?)\n---", text, re.S) text = m.group(1) if m else "" for m in re.finditer(r"([0-9]{4}-[^\s\)\],#]+\.md)", text): cited.add(os.path.basename(m.group(1))) for name in records: front = frontmatter(records[name][0]) or {} for key in ("extends", "supersedes", "superseded-by"): v = front.get(key) if isinstance(v, str) and v: cited.add(os.path.basename(v)) for name, (path, status) in records.items(): if status == "accepted" and name not in cited: bad(path, "an accepted decision nothing in the cycle cites -- give it a home in a " "design doc's decisions:, a research became:, an issue, or 00-META") # ---- deferrals ------------------------------------------------------------------- # A paragraph or list item that puts work off names the issue that tracks it, or a dated # progressive insight that speaks about it (its section, or a quotation of it) does. An issue that has # since resolved still counts: the deferred work landed, which is what the pointer was for. for name, (path, status) in records.items(): front = frontmatter(path) or {} decided = str(front.get("date") or "") if status != "accepted": continue paras = paragraphs(path) insights = [(d, sec, text) for line, sec, _, text in paras for d in INSIGHT.findall(text)] held = decided >= DEFERRALS_FROM or any(d >= DEFERRALS_FROM for d, _, _ in insights) if not held: continue # A dated insight naming an issue covers the deferrals it speaks about -- those in its own section, # or ones it quotes -- and no other: one insight must not excuse every deferral in the record. tracking = [(sec, text) for d, sec, text in insights if issue_refs(text, seen)] for line, section, chapter, text in paras: m = DEFERRAL.search(text) # A rejected option is a decision not to do it, not work put off -- judged by its chapter, so an # option under a `###` subheading of "## Considered Options" is skipped as well. if re.match(r"(?i)(options? rejected|considered options|rejected)", chapter): continue if not m or issue_refs(text, seen): continue if any(covers(isec, itext, section, text) for isec, itext in tracking): continue bad(path, "line %d defers work (%r) and names no issue that tracks it -- a deferral nothing " "tracks reads as done (issue 354). Name the issue beside it, with its owner, or in a " "dated progressive insight that says what it tracks" % (line + text.count("\n", 0, m.start()), m.group(0))) checked = ( len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md"))) + len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))) + len(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md"))) + len(records) ) if failures: print("cycle: %d document(s) break the development cycle:" % len(failures)) print("\n".join(failures)) return 1 print("cycle: %d documents checked, the chain holds" % checked) return 0 if __name__ == "__main__": sys.exit(main())