This is an automated email from the ASF dual-hosted git repository. spmallette pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit 751e6f86bec659ef1dc711cca9ad9a488befa890 Author: Stephen Mallette <[email protected]> AuthorDate: Thu Aug 20 12:43:22 2026 -0400 Link merged commits back to their beads When the operator says work has merged, bin/beads-commits.py --suggest proposes the commits from the root's own JIRA and PR records. Once confirmed, it records them as a pinned record bead, so `bd query 'notes="<sha>"' --all` turns a line of git log back into the reasoning behind it. Assisted-by: Claude Code:claude-opus-5 --- .beads/PRIME.md | 27 +- bin/beads-commits.py | 331 +++++++++++++++++++++++++ bin/beads-report.py | 20 +- docs/src/dev/developer/for-committers.asciidoc | 77 +++++- docs/src/dev/developer/release.asciidoc | 3 + 5 files changed, 448 insertions(+), 10 deletions(-) diff --git a/.beads/PRIME.md b/.beads/PRIME.md index b71d5f043f..b445e4e32e 100644 --- a/.beads/PRIME.md +++ b/.beads/PRIME.md @@ -179,11 +179,23 @@ Tasks closed as they finished (section 3), decisions as they were written (secti ```bash bd children <root> # the whole subtree; nothing should still be in_progress +bin/beads-commits.py --root <root> --branch 3.7-dev --suggest # propose; operator confirms +bin/beads-commits.py --root <root> --branch 3.7-dev --commits <sha>... bd close <root> bd update <id1> <id2> ... -s pinned bd dolt pull && bd dolt push ``` +**The operator says when the work has merged**, the way they say a JIRA issue is ready to +resolve. Never infer it. Before the merge the shas are not final, because squash and rebase +rewrite a branch until it lands; after it, TinkerPop's forward merges carry the published sha +unchanged into 3.8-dev and master, so what is recorded then stays correct. + +**Then suggest, confirm, record.** `--suggest` finds the commits from the root's own JIRA and PR +records, lists what else landed in the same window, and writes nothing. Show what it returns, take the operator's corrections, and only then +run the recording form. It refuses any sha not reachable from `origin/<branch>`, so a refusal +means the work has not actually landed — say so and leave the root open. + **Pin every bead in the subtree** — root, decisions, records, tasks. No judgment about which ones matter: the work shipped, so all of it is the project's history. Show the operator the list first if they want a review gate. @@ -212,6 +224,7 @@ Pinning is what makes a bead permanent — every destructive operation keys on root (feature/epic/task) ├─relates-to──▶ record TINKERPOP-3456 ├─relates-to──▶ record apache/tinkerpop#2891 + ├─relates-to──▶ record apache/tinkerpop@374b0c76d0 (written at merge) ├─parent-child─▶ decision "chose X" {rejected: false} │ └─related─▶ decision "Y" {rejected: true} ├─parent-child─▶ task A "implement X" ──caused-by──▶ decision "chose X" @@ -224,10 +237,15 @@ a list, and `bd ready` cannot tell you anything useful about it. - `--parent` builds the tree, and copies the parent's labels onto the child once, at birth — see section 7. -- **`record` beads** hold external artifacts — JIRA, PR, dev@ thread, proposal. The ticket or - URL goes in `--external-ref`, which identifies the kind as well. Attach them to the - **root**, not to every bead. Create them pinned. Search first — duplicates are the risk. -- Records are the only link between beads and code. There is no bead ID in commit messages. +- **`record` beads** hold external artifacts — JIRA, PR, dev@ thread, proposal, and the + commits a landing merged as. The ticket, URL or `owner/repo@sha` goes in `--external-ref`, + which identifies the kind as well. Attach them to the **root**, not to every bead. Create + them pinned. Search first — duplicates are the risk. +- Records are the only link between beads and code; commit messages carry no bead ID. The + commit record written at merge (section 4) is what makes that link work in reverse — + `bd query 'notes="<sha>"' --all` returns the record and its `parent`, for any commit in the + landing. Quote the sha: an unquoted one starting with a digit is lexed as a number and the + query fails to parse. - Only **one dependency type per pair** — `blocks` and `discovered-from` cannot coexist between the same two beads. - Never construct a bead ID; use whatever `bd create` returns. Child IDs encode birth @@ -306,6 +324,7 @@ bd dep add <a> <b> -t caused-by|related|discovered-from|supersedes bd dep cycles # a plan with a cycle cannot execute bd update <id> --claim | -s pinned | --external-ref=TINKERPOP-NNNN bd search <text> +bd query 'notes="<sha>"' --all # which root did this commit come from; quote the sha ``` Priority is `0-4` (0 = critical), never "high"/"medium"/"low". diff --git a/bin/beads-commits.py b/bin/beads-commits.py new file mode 100755 index 0000000000..38350a1988 --- /dev/null +++ b/bin/beads-commits.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Record the commits a root bead's work merged as. + +Run at merge, as part of the pin step -- and only then. A commit's sha is not +final until the work has landed on a published branch, because squash and rebase +rewrite whatever the branch held before that. TinkerPop merges forward with real +merge commits, so a sha published on 3.7-dev is byte-identical on 3.8-dev and +master, and the sha recorded here stays correct for good. + +Creates one `record` bead per landing, under the root: + + title / external-ref apache/tinkerpop@<tip> GitHub's owner/repo@sha form, + the sibling of the PR record + apache/tinkerpop#3578 + notes every sha, full length + +The shas go in `notes` because that is the one free-text field bd can query. +`bd search` indexes title and id only, and `bd query` has no field for comments +or design, so a sha recorded anywhere else is unreachable except by grepping an +export. `bd query 'notes=...'` is a substring match, which is why the full +40-character sha is stored: a short sha pasted from `git log --oneline` is a +prefix of it, so both forms of the query find the bead. Quote the sha inside the +query: most shas begin with a digit, and an unquoted one is lexed as a number and +fails to parse, the same way an unquoted version label does. + + bd query 'notes="e1ca7ea3e4"' --all --json # -> the record, and its parent root + +That is the reverse direction, from a line of `git log` back to the reasoning. +The forward direction is `bd show <record>`. + +The operator says when work has merged, in the way they would say a JIRA issue is +ready to resolve. `--suggest` answers with the commits that appear to belong to +the root, for them to confirm or correct: + + bin/beads-commits.py --root tp-abc --branch 3.7-dev --suggest + +The root's own record beads are what identify them. A JIRA record gives the +ticket id that TinkerPop commit subjects carry, and a PR record gives a number +GitHub can resolve to the commit a squash merge produced. A root with neither +falls back to the commits on the branch since it was created, which is a list to +choose from rather than an answer. Nothing is written in this mode. + +Once the operator confirms, the same command records them: + + bin/beads-commits.py --root tp-abc --branch 3.7-dev --pr 3610 + bin/beads-commits.py --root tp-abc --branch 3.7-dev --commits 374b0c76d0 e1ca7ea3e4 + bin/beads-commits.py --root tp-abc --branch 3.7-dev --range 74fe2a64d0..374b0c76d0 + +What this script guarantees is the part that is easy to get wrong: every sha is +verified reachable from origin/<branch> before anything is written, and a re-run +finds the existing record rather than creating a second one. +""" + +import argparse +import json +import re +import subprocess +import sys + + +def git(*args): + """Run a git command and return its stripped stdout.""" + proc = subprocess.run(["git", *args], capture_output=True, text=True) + if proc.returncode != 0: + sys.exit(f"git {' '.join(args)} failed: {proc.stderr.strip()}") + return proc.stdout.strip() + + +def bd(*args): + """Run a bd command with --json and return the parsed result.""" + proc = subprocess.run(["bd", *args, "--json"], capture_output=True, text=True) + if proc.returncode != 0: + sys.exit(f"bd {' '.join(args)} failed: {proc.stderr.strip()}") + if not proc.stdout.strip(): + return [] + return json.loads(proc.stdout) + + +def as_list(payload): + """bd sometimes returns a bare object where a list is expected.""" + if isinstance(payload, dict): + return payload.get("issues", [payload]) + return payload + + +def repo_slug(): + """owner/repo for the origin remote, so a fork records its own commits.""" + url = git("remote", "get-url", "origin") + match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", url) + if not match: + sys.exit(f"cannot read owner/repo out of origin url: {url}") + return match.group(1) + + +def commits_from_pr(number): + """The commit GitHub recorded for the merge -- one, for a squash merge.""" + proc = subprocess.run( + ["gh", "pr", "view", str(number), "--json", "mergeCommit,title,url"], + capture_output=True, text=True) + if proc.returncode != 0: + sys.exit(f"gh pr view {number} failed: {proc.stderr.strip()}") + payload = json.loads(proc.stdout) + merge_commit = payload.get("mergeCommit") or {} + if not merge_commit.get("oid"): + sys.exit(f"PR {number} has no merge commit -- is it merged?") + return [merge_commit["oid"]] + + +def bd_show(bead_id): + """One bead, or None when the id does not resolve.""" + found = as_list(bd("show", bead_id)) + return found[0] if found else None + + +def pr_merge_commit(number): + """The merge commit for a PR, or None when it is unmerged or unreachable.""" + proc = subprocess.run( + ["gh", "pr", "view", str(number), "--json", "mergeCommit"], + capture_output=True, text=True) + if proc.returncode != 0: + return None + return ((json.loads(proc.stdout).get("mergeCommit") or {}).get("oid")) or None + + +def suggest(root, branch): + """Print the commits that look like this root's, for the operator to confirm. + + The root's record beads are the discovery mechanism, which is what PRIME.md + section 6 means by calling records the link between beads and code. A JIRA + record names a ticket that TinkerPop commit subjects carry, and a PR record + names a pull request GitHub resolves to a commit. Neither involves guessing. + """ + bead = bd_show(root) + if not bead: + sys.exit(f"no bead {root}") + ref = f"origin/{branch}" + git("rev-parse", "--verify", ref) + + print(f"{root} · {bead.get('title', '')}\n") + + # why[sha] collects every record that points at the same commit + why = {} + for child in as_list(bd("children", root)): + if child.get("issue_type") != "record": + continue + external = child.get("external_ref") or child.get("title") or "" + if re.fullmatch(r"[A-Z]+-\d+", external): + found = git("log", "--format=%H", f"--grep={external}", ref).splitlines() + elif re.search(r"#(\d+)$", external): + oid = pr_merge_commit(re.search(r"#(\d+)$", external).group(1)) + found = [oid] if oid else [] + else: + continue + for sha in found: + why.setdefault(sha, []).append(f"{child['id']} ({external})") + + # The window is shown even when records identified something. A ticket id only + # finds the commits that quote it, and a follow-up fix or a docs pass usually + # does not, so returning here would hide exactly the commits most easily lost. + since = (bead.get("started_at") or bead.get("created_at") or "")[:10] + window = git("log", "--format=%H", f"--since={since}", "-25", ref).splitlines() if since else [] + mine = (git("config", "user.email") or "").lower() + + def line(sha): + who = git("log", "-1", "--format=%ae %ce", sha).lower() + return f" {sha[:10]} {subject(sha)}{' [you]' if mine and mine in who else ''}" + + if why: + print(f"identified by this root's records, on {ref}:") + for sha, reasons in why.items(): + print(line(sha)) + print(f" {', '.join(reasons)}") + else: + print("no JIRA or PR record on this root, so nothing identifies its commits directly.") + + # When records already answered, the window is a prompt rather than a list: it + # exists so a follow-up commit that quotes no ticket is not silently lost. A + # long-lived root accumulates unrelated work, so showing all of it would bury + # the answer. When records answered nothing, the window is all there is. + rest = [sha for sha in window if sha not in why] + shown = rest[:5] if why else rest + if shown: + print(f"\nalso on {ref} since {since}, identified by nothing:") + for sha in shown: + print(line(sha)) + if len(rest) > len(shown): + print(f" ... and {len(rest) - len(shown)} older, " + f"'git log {ref} --since={since}' for the rest") + elif not why: + print(f"and no commits on {ref} since {since or 'the root was created'}.") + return + + confirmed = " ".join(sha[:10] for sha in why) if why else "<sha>..." + print(f"\nconfirm the list with the operator, adding any of the above, then:\n" + f" bin/beads-commits.py --root {root} --branch {branch} --commits {confirmed}") + + +def resolve(args): + """The commit list, oldest first, in whichever way the caller named it.""" + if args.pr: + return commits_from_pr(args.pr) + if args.range: + listed = git("log", "--format=%H", "--reverse", args.range).splitlines() + if not listed: + sys.exit(f"{args.range} names no commits") + return listed + return args.commits + + +def verify(shas, branch): + """Expand to full length and refuse anything not on the published branch.""" + ref = f"origin/{branch}" + git("rev-parse", "--verify", ref) # exits if the branch was never fetched + + verified, unreachable = [], [] + for sha in shas: + full = git("rev-parse", f"{sha}^{{commit}}") + reachable = subprocess.run( + ["git", "merge-base", "--is-ancestor", full, ref], + capture_output=True, text=True).returncode == 0 + (verified if reachable else unreachable).append(full) + + if unreachable: + print(f"not reachable from {ref}:", file=sys.stderr) + for full in unreachable: + print(f" {full[:10]} {subject(full)}", file=sys.stderr) + sys.exit( + f"\nRefusing to record these. A commit that has not landed on {ref} can still\n" + "be rewritten by a squash or a rebase, and the sha would then point at nothing.\n" + "Run this once the work has merged. If it has, fetch first (--fetch).") + return verified + + +def subject(sha): + return git("log", "-1", "--format=%s", sha) + + +def chronological(shas): + """Oldest first, so the tip is genuinely the last commit to land. + + The tip names the record, so the caller's argument order must not decide it. + `--suggest` emits in record-discovery order and a human types them in any + order at all, both of which would otherwise title the record with the wrong + commit and date the landing to the wrong day. + """ + return sorted(shas, key=lambda sha: int(git("log", "-1", "--format=%ct", sha))) + + +def existing_record(root, tip): + """The record for this landing, if a previous run already wrote it.""" + # The value has to be quoted inside the query. A sha that starts with a digit + # is otherwise lexed as a number and the parse fails -- the same trap + # release.asciidoc documents for version labels, and most shas start with one. + for bead in as_list(bd("query", f'notes="{tip}"', "--all")): + if bead.get("parent") == root: + return bead + return None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--root", required=True, help="root bead the work hangs off") + parser.add_argument("--branch", required=True, + help="published branch the work merged to, e.g. 3.7-dev") + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--pr", type=int, help="pull request number; asks gh for the merge commit") + source.add_argument("--commits", nargs="+", metavar="SHA", help="the commits, oldest first") + source.add_argument("--range", metavar="A..B", help="a git revision range") + source.add_argument("--suggest", action="store_true", + help="print the commits that look like this root's; writes nothing") + parser.add_argument("--fetch", action="store_true", + help="refresh origin/<branch> first; otherwise nothing touches the network") + parser.add_argument("--dry-run", action="store_true", help="print the record, write nothing") + args = parser.parse_args() + + if args.fetch: + git("fetch", "origin", args.branch) + + if args.suggest: + suggest(args.root, args.branch) + return + + shas = chronological(verify(resolve(args), args.branch)) + tip = shas[-1] + slug = repo_slug() + ref = f"{slug}@{tip[:10]}" + + merged_on = git("log", "-1", "--format=%cs", tip) + lines = [f"merged {args.branch} {merged_on}"] + lines += [f" {sha} {subject(sha)}" for sha in shas] + notes = "\n".join(lines) + + found = existing_record(args.root, tip) + if found: + print(f"{found['id']} already records this landing under {args.root}; nothing to do") + return + + print(f"{ref}\n{notes}\n") + if args.dry_run: + print(f"--dry-run: would create a record under {args.root} and pin it") + return + + created = as_list(bd("create", "--type=record", f"--parent={args.root}", + f"--title={ref}", f"--external-ref={ref}", f"--notes={notes}")) + record = created[0]["id"] + # Records are created pinned (PRIME.md section 6), but `bd create` has no + # status flag, so pinning is a second call. + bd("update", record, "-s", "pinned") + print(f"created {record} under {args.root}, pinned, {len(shas)} commit(s)") + + +if __name__ == "__main__": + main() diff --git a/bin/beads-report.py b/bin/beads-report.py index f53a4f33c9..343ee2e010 100755 --- a/bin/beads-report.py +++ b/bin/beads-report.py @@ -40,9 +40,10 @@ Checks divide into two kinds, and the distinction matters: Defects and facts. No judgment involved. HEURISTIC decisions with no rejected alternative recorded; roots with - several tasks and no decisions. These cannot distinguish "no - decisions were made" from "decisions were not captured", so they - are questions for a human, never verdicts. + several tasks and no decisions; pinned roots with no commit + record. These cannot distinguish "no decisions were made" from + "decisions were not captured", so they are questions for a + human, never verdicts. Neither kind can judge whether design text is real reasoning or fluent filler. Structure is checkable; substance is not. @@ -51,10 +52,15 @@ Structure is checkable; substance is not. import argparse import collections import json +import re import subprocess import sys +# A commit record is titled and referenced owner/repo@<sha> -- PRIME.md section 6. +COMMIT_REF = re.compile(r"@[0-9a-f]{7,40}$") + + def bd(*args): """Run a bd command with --json and return the parsed result.""" proc = subprocess.run(["bd", *args, "--json"], capture_output=True, text=True) @@ -208,6 +214,14 @@ def main(): flags.append("no record") if kinds.get("task", 0) >= 3 and not kinds.get("decision"): flags.append(f"{kinds['task']} tasks, 0 decisions") + # Only a pinned root has merged, so only a pinned root can be missing the + # commit record that `bin/beads-commits.py` writes at merge. A root pinned + # before the convention existed trips this legitimately. + if beads[root].get("status") == "pinned" and not any( + x.get("issue_type") == "record" + and COMMIT_REF.search(x.get("external_ref") or x.get("title") or "") + for x in kids + linked): + flags.append("no commits") if flags: thin.append(f"{root:18} {', '.join(flags):26} {beads[root]['title'][:34]}") report("Roots that look thin (a question, not a verdict)", thin) diff --git a/docs/src/dev/developer/for-committers.asciidoc b/docs/src/dev/developer/for-committers.asciidoc index 0525c1ecbd..9713b86684 100644 --- a/docs/src/dev/developer/for-committers.asciidoc +++ b/docs/src/dev/developer/for-committers.asciidoc @@ -133,6 +133,8 @@ a proposal, or a thread on the dev mailing list. That reference belongs on the r bead, and supplying it falls to the committer, because an agent has no way to know which ticket or thread a piece of work belongs to. The link carries weight because records are the only connection between a bead and the code, given that commit messages hold no bead identifiers. +The commits themselves become a record as well, though not until the work merges, as described +in <<beads-commits,Linking Commits to Beads>>. It is not mandatory. A small fix that never had a ticket needs no record, and `bin/beads-report.py` accordingly reports a root without one as a question rather than a defect. @@ -190,9 +192,9 @@ root (feature/epic/task) ---- Four kinds of bead appear in that graph. A `task` is a unit of work. A `decision` is a choice -and carries its reasoning. A `record` points at an external artifact such as a JIRA issue or a -pull request, and is the only link between a bead and the code, because commit messages carry no -bead identifiers. The root itself is usually a task or an epic. +and carries its reasoning. A `record` points at an external artifact such as a JIRA issue, a +pull request, or the commits that a landing merged as, and is the only link between a bead and +the code, because commit messages carry no bead identifiers. The root itself is usually a task or an epic. The edges carry as much meaning as the beads. A `parent-child` edge establishes membership in the effort, while a `blocks` edge establishes order, so a subtree without any `blocks` edges is @@ -217,6 +219,7 @@ motion that resolves its JIRA issue, the whole subgraph beneath the root is pinn [source,text] ---- bd children <root> +bin/beads-commits.py --root <root> --branch 3.7-dev --pr <n> bd close <root> bd update <id1> <id2> ... -s pinned bd dolt pull && bd dolt push @@ -227,6 +230,74 @@ and skip pinned ones, so a subgraph left closed but unpinned remains eligible fo cleanup. Pinning covers the entire subgraph rather than the beads that seem most interesting in hindsight. The work shipped, so all of it is project history. +[[beads-commits]] +=== Linking Commits to Beads + +Git history and the bead graph describe the same work from two directions. A commit message +carries no bead identifier, so a line of `git log` offers no route into the reasoning behind it, +and a bead names no commit until one is recorded against it. + +Recording one is only safe after the work has merged, because a commit hash is not stable while +work is in flight. A squash or a rebase replaces whatever a topic branch held, so a hash taken +during development often points at nothing by the time the change lands. TinkerPop merges +forward with ordinary merge commits, so a commit published on `3.7-dev` keeps its hash on +`3.8-dev` and on `master`, and a hash recorded after the merge stays correct permanently. That is +also the moment the subgraph is pinned, which is why the two steps belong together. + +`bin/beads-commits.py` performs the linking. It creates one record bead for the landing, titled +with GitHub's `owner/repo@hash` form so that it announces its own kind in the way that +`apache/tinkerpop#3578` and `TINKERPOP-3278` announce theirs. Every commit in the landing is +written to that bead's notes. + +Linking happens when a committer says the work has merged, in the same way that a JIRA issue is +resolved once its change is in. An agent does not detect that moment and should not attempt to, +since the hashes are not final until the merge has happened. Asked to pin, it proposes the +commits that appear to belong to the root and waits for confirmation. + +[source,text] +---- +bin/beads-commits.py --root tp-abc --branch 3.7-dev --suggest +---- + +The root's own record beads are what identify those commits, which is the practical sense in +which records are the link between a bead and the code. A JIRA record supplies a ticket +identifier that TinkerPop commit subjects carry, and a pull request record supplies a number that +GitHub resolves to the commit a squash merge produced. Commits that landed on the branch since +the root was created are listed alongside, marking those belonging to the current committer, +because a follow-up fix or a documentation pass often quotes no ticket and would otherwise go +unnoticed. That second list is a prompt to check rather than an answer, and a root carrying no +record at all has nothing else to offer. Nothing is written in this mode. + +Once the committer has confirmed the list, the same command records it. + +[source,text] +---- +bin/beads-commits.py --root tp-abc --branch 3.7-dev --pr 3610 +bin/beads-commits.py --root tp-abc --branch 3.7-dev --commits 374b0c76d0 e1ca7ea3e4 +bin/beads-commits.py --root tp-abc --branch 3.7-dev --range 74fe2a64d0..374b0c76d0 +---- + +Every hash is checked against `origin/<branch>` before anything is written. A hash that has not +landed there is refused rather than recorded, because it is precisely the hash that a later +squash would invalidate. Running the command a second time finds the existing record and creates +nothing. + +The notes field is what makes the link work in reverse. It is the only free-text field that the +query language searches, so a hash held in a comment or in design text can be read but never +found. + +[source,text] +---- +bd query 'notes="e1ca7ea3e4"' --all --json +---- + +The reply carries the record bead and its `parent`, which is the root that the work hung from, so +a single command turns a line of `git log` into the reasoning behind it. Hashes are stored at full +length and matched as substrings, so an abbreviated hash copied from `git log --oneline` finds the +bead as readily as a complete one. Quoting matters. Most hashes begin with a digit, and an +unquoted value that begins with a digit is read as a number and fails to parse, in the same way +that an unquoted version label does. + [[beads-curation]] === Curating the Record diff --git a/docs/src/dev/developer/release.asciidoc b/docs/src/dev/developer/release.asciidoc index 313b7d49b4..72326056a3 100644 --- a/docs/src/dev/developer/release.asciidoc +++ b/docs/src/dev/developer/release.asciidoc @@ -143,6 +143,9 @@ closed beads must be included, as in `bd query 'labels="xx.yy.zz"' --all`, becau number fails to parse and closed beads are hidden by default. ... Each root carries a record bead for its JIRA issue, pull request, proposal or dev mailing list thread. A small fix that never had one of these is a legitimate exception. +... Each merged root carries a commit record, written by `bin/beads-commits.py` as described in +<<beads-commits,Linking Commits to Beads>>. A root pinned before that convention existed is a legitimate +exception, and `bin/beads-report.py` reports a root without one as a question rather than a defect. ... `bin/beads-report.py` reports nothing unexpected. Run it per root with `--root` to scope it to a single subgraph. Its structural findings, such as a dangling edge, are defects, while the remainder are questions rather than verdicts and a one-line fix will legitimately trip several of them.
