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 a320000c226d629a8156f306760098126c067b1f
Author: Stephen Mallette <[email protected]>
AuthorDate: Fri Oct 9 15:07:54 2026 -0400

    Bring a PR's beads into tinker-review
    
    Reviews now find the beads recorded for a PR through its record beads and
    show the plan and decisions in a Project Memory section; beads (bd) is
    required. Reviews of already-merged PRs stop with a clear message.
    
    Assisted-by: Claude Code:claude-opus-5-5
---
 .beads/PRIME.md                                    |   4 +-
 .skills/tinker-review/DESIGN.md                    |   6 +-
 .skills/tinker-review/SKILL.md                     |  24 +-
 .skills/tinker-review/playbooks/general.md         |  39 ++-
 .skills/tinker-review/references/enrichment-cli.md |  18 ++
 .skills/tinker-review/references/interfaces.md     |  13 +-
 .skills/tinker-review/references/schema.md         |  36 ++-
 .skills/tinker-review/scripts/discovery/beads.js   | 289 +++++++++++++++++++++
 .skills/tinker-review/scripts/enrichment/api.js    | 102 ++++++++
 .skills/tinker-review/scripts/enrichment/cli.js    |  12 +-
 .../scripts/extraction/tree-sitter.js              |   3 +-
 .../tinker-review/scripts/graph/populate-beads.js  | 133 ++++++++++
 .skills/tinker-review/scripts/renderer/render.js   | 199 +++++++++++++-
 .../tinker-review/scripts/renderer/template.html   |   8 +
 .skills/tinker-review/scripts/review.js            |  29 ++-
 .skills/tinker-review/test/beads.test.js           | 281 ++++++++++++++++++++
 16 files changed, 1162 insertions(+), 34 deletions(-)

diff --git a/.beads/PRIME.md b/.beads/PRIME.md
index 0e35f6369b..68fd335cd5 100644
--- a/.beads/PRIME.md
+++ b/.beads/PRIME.md
@@ -102,7 +102,7 @@ Every session works under one **root bead**. Find it before 
writing code.
 
 ```bash
 bd list --status=all --json      # filter client-side: no parent, 
open/in_progress
-bd children <root>               # recursive — the whole subtree
+bd children <root>               # recursive — the whole subtree (--json: 
direct children only)
 ```
 
 - Show the operator open/`in_progress` beads with **no parent**, most recently 
updated
@@ -335,7 +335,7 @@ release  build
 ## Essential commands
 
 ```bash
-bd children <root>               # the subtree, recursive
+bd children <root>               # the subtree, recursive (--json: direct 
children only)
 bd ready --parent <root>         # startable now; ALWAYS scope to your root
 bd blocked --parent <root>       # what is waiting, and on what
 bd show <id>                     # one bead with dependencies
diff --git a/.skills/tinker-review/DESIGN.md b/.skills/tinker-review/DESIGN.md
index 130a3c655c..f6b2598a61 100644
--- a/.skills/tinker-review/DESIGN.md
+++ b/.skills/tinker-review/DESIGN.md
@@ -9,8 +9,9 @@ document is only the machinery and its extension points.
 The pipeline is two phases, split by what is mechanical and what needs 
judgment.
 
 **Phase 1 — deterministic** (`scripts/review.js`) builds the graph and writes
-`evidence.json`: fetch PR → worktree → start Gremlin Server → tree-sitter
-extract → populate → discover discussions → run the structural checks. It exits
+`evidence.json`: beads gate and pull → fetch PR → worktree → start Gremlin
+Server → tree-sitter extract → populate → discover discussions → discover beads
+→ run the structural checks. It exits
 cleanly (a `PHASE1_COMPLETE` sentinel) so a caller can detect completion; the
 Gremlin Server container stays up.
 
@@ -38,6 +39,7 @@ has already exited.
 |------|------|
 | `scripts/review.js` | Phase 1 orchestrator — `setup` / `phase1` / `teardown` 
|
 | `scripts/extraction/tree-sitter.js` | source → structural extraction |
+| `scripts/discovery/{discussions,beads}.js` | Phase 1 context discovery — 
JIRA, dev list, proposals and PR comments over HTTP (`discussions.js`); the 
PR's beads through the local `bd`, read-only (`beads.js`) |
 | `scripts/graph/*.js` | populate the graph; `confidence.js` / `externals.js` 
/ `references.js` hold the data-model vocabularies and shared edge helpers |
 | `scripts/patterns/*.js` | one structural check per file; each defines its 
own result `@typedef` |
 | `scripts/enrichment/{api,cli}.js` | Phase 2 read/write commands over the 
live graph |
diff --git a/.skills/tinker-review/SKILL.md b/.skills/tinker-review/SKILL.md
index 34564b25e5..80b0c41d93 100644
--- a/.skills/tinker-review/SKILL.md
+++ b/.skills/tinker-review/SKILL.md
@@ -6,7 +6,7 @@ description: >
   playbooks, runs structural analysis, and produces an HTML evidence package.
   Use when asked to review a TinkerPop PR by number.
 license: Apache-2.0
-compatibility: Requires Docker, Node.js 20+, git. Network access for fetching 
PR refs.
+compatibility: Requires Docker, Node.js 20+, git and beads (bd). Network 
access for fetching PR refs.
 metadata:
   version: "0.1.0"
   project: Apache TinkerPop
@@ -19,6 +19,9 @@ metadata:
 - Docker running (for Gremlin Server)
 - `upstream` remote pointing to `[email protected]:apache/tinkerpop.git` (fetch 
only)
 - Node.js 20+ with dependencies installed in `.skills/tinker-review/`
+- Beads (`bd`) installed, with the repo's beads database available 
(`bin/agent-setup.sh --contributor`).
+  The review stops before fetching the PR when `bd` is missing. It runs `bd 
dolt pull` first and
+  falls back to local state if the pull fails; it never writes to beads.
 
 
 ## References (load on demand)
@@ -46,9 +49,11 @@ npm install --prefix .skills/tinker-review  # only needed 
once
 node .skills/tinker-review/scripts/review.js <pr-number> <repo-path>
 ```
 
-This performs: fetch PR → create worktree → start Gremlin Server → extract
-structure via Tree-sitter → populate knowledge graph → discover discussions
-(JIRA, dev list, proposals, PR comments) → run pattern checks (completeness,
+This performs: check for beads and pull it → fetch PR → create worktree → start
+Gremlin Server → extract structure via Tree-sitter → populate knowledge graph →
+discover discussions (JIRA, dev list, proposals, PR comments) → discover the 
PR's
+beads (records naming the PR or those discussions, their roots and subtrees) →
+run pattern checks (completeness,
 coverage gaps, centrality, blast radius, cluster analysis) → write evidence 
JSON.
 
 **Output:** `/tmp/pr-review-<pr>/evidence.json`
@@ -177,8 +182,8 @@ command does and how to drive it.
 - Relevant Gherkin test features (for expected behavior reference)
 - The Gremlin Server URL (the `url` from the command above)
 
-The subagent does NOT get: source code, the knowledge graph, code review 
findings,
-or access to the analysis worktree. Brief it as a **minimally experienced
+The subagent does NOT get: source code, the knowledge graph, the PR's beads, 
code
+review findings, or access to the analysis worktree. Brief it as a **minimally 
experienced
 TinkerPop user** who has only the docs — it tests blind, and its stumbles are
 signal about how usable the feature is.
 
@@ -264,11 +269,14 @@ produce a complete evidence-with-narrative JSON file. 
Write it to
     symbol's lines highlighted. Two or three
     refs per question is plenty.
   - `refs` on the item — optional "Also see" chips for code that belongs to 
the whole area.
+  - A ref can cite a bead instead of code: `{ bead: "tp-abc.3", label? }` 
links to that bead
+    in the report's Project Memory. Use it where a question or finding rests 
on a recorded
+    decision or task; it must be a bead in `discussions.beads.beads`.
 
   `render.js` prints any ref it cannot resolve; fix the `file`/`symbol` and 
re-render. An
   item with only an HTML `body` still renders (older reports).
-- `findings` — array of `{ title, snippet, body }` objects, ordered 
most-severe-first (Interpret grades each blocking / high / low)
-- `openQuestions` — array of `{ title, body, meta }` objects
+- `findings` — array of `{ title, snippet, body, refs? }` objects, ordered 
most-severe-first (Interpret grades each blocking / high / low); `refs` take 
the same code or bead refs as `guidedWalk`
+- `openQuestions` — array of `{ title, body, meta, refs? }` objects
 - `functionalTest` — `{ plan, results: [{name, pass, output}], observations }` 
(if testing was done).
   `plan` is HTML and `observations` is an array of HTML strings (one per 
insight); both and surface **themes and insights** — what
   families of behavior were exercised and what was learned — not a per-scenario
diff --git a/.skills/tinker-review/playbooks/general.md 
b/.skills/tinker-review/playbooks/general.md
index ce4f197ea5..d044c93a67 100644
--- a/.skills/tinker-review/playbooks/general.md
+++ b/.skills/tinker-review/playbooks/general.md
@@ -17,6 +17,15 @@ inherits it. Run in order:
 - `auditConfidence` again — anything still `AMBIGUOUS` goes to `openQuestions`,
   never asserted as fact.
 
+**Project memory** — the PR's beads, when a record named the PR or its
+discussions (`discussions.beads.roots` is non-empty). Run these before the
+confidence pass so the `governs` edges they write go through it:
+- `listBeads` — read the roots, decisions (each chosen one with the 
alternatives
+  it beat) and tasks. The `primary` root is the PR's own work; `owner` and
+  `shared` roots are connected work that explains it.
+- `linkBead` — link each decision whose design is about changed code to that
+  Function, Type or File, chosen and rejected alike.
+
 ## Inspect
 **Style:**
 - Wildcard imports in Java (`import foo.*`)
@@ -35,6 +44,19 @@ inherits it. Run in order:
 - Error/exception paths that aren't tested
 - Test helpers without guard clauses (missing else/throw for invalid input)
 
+**Project memory** (only when beads were found; read decisions by `listBeads`):
+- Code against chosen decisions — does the diff do something a decision's 
design
+  rules out? Judge against the newest decision in a `supersededBy` chain.
+- Code against rejected alternatives — each one is a "why not X?" already
+  answered; don't raise it again. But if the diff *does* X, check whether the
+  reason X lost still holds in this code.
+- `in_progress` tasks under the PR's root against what the PR says it delivers 
—
+  claimed work that isn't here, or is here half-done.
+- Decisions that record a departure from the JIRA, proposal or dev@ thread —
+  does the PR description say so too?
+- When several roots were loaded, which one does this PR deliver, and what do
+  the others show about the connected work it depends on or affects?
+
 **Resource safety:**
 - Connections, channels, or streams opened without a clear cleanup path
 - Log levels: error for unexpected failures, info for expected lifecycle events
@@ -65,7 +87,8 @@ mistakes a real user would make:
   boundary values, and the feature used against the grain of the docs.
 
 The blind subagent designs and runs this battery from the docs alone — see
-SKILL.md step 4 for how it is briefed and isolated.
+SKILL.md step 4 for how it is briefed and isolated. It never gets the beads: 
the
+author's recorded reasoning is as much inside knowledge as the source code.
 
 ## Interpret
 - `checks.coverageGaps` / `checks.orphans` — missing tests on changed code; a
@@ -79,6 +102,20 @@ SKILL.md step 4 for how it is briefed and isolated.
 - Style nits and unused variables — low; note them, don't let them dominate.
 - Formatting mixed with functional changes — high; it makes the PR harder to
   review and should ideally be separate commits.
+- `discussions.beads` — the root is expected to be open: it closes at merge, so
+  an open root is never a finding, and neither is finding no beads or a failed
+  sync (the report says which).
+- Diff does what a rejected alternative ruled out and the reason it lost still
+  holds — high. If that reason no longer holds, an open question: was this a
+  deliberate reconsideration? Diff contradicts a chosen decision with no newer
+  decision superseding it — high.
+- An `in_progress` task under the PR's root that the PR doesn't finish — high
+  when the PR claims to be complete. An open, unclaimed task — an open question
+  only: an epic root spans several PRs, so it may belong to a later one.
+- An open bead labelled `human` under a loaded root — an open question no one
+  has answered; carry it into `openQuestions`.
+- The root's release label doesn't fit `meta.baseBranch` (a `3.7.x` root on a
+  PR to `master`) — an open question about where the work should land.
 - Deprecated API in new code — high. Deprecated API already present in modified
   code — low, unless the PR is specifically a migration away from it.
 
diff --git a/.skills/tinker-review/references/enrichment-cli.md 
b/.skills/tinker-review/references/enrichment-cli.md
index e42247c603..36cf4eaaf7 100644
--- a/.skills/tinker-review/references/enrichment-cli.md
+++ b/.skills/tinker-review/references/enrichment-cli.md
@@ -103,6 +103,16 @@ crucially, **flags any whose name matches a deleted 
symbol**: a changed file
 still calling a just-removed name is a dangling reference the graph catches on
 its own. Sorted so the dangerous ones surface first.
 
+### listBeads
+The beads Phase 1 loaded for this PR — the roots reached from records naming 
the
+PR or its discussions, and everything under them — in reading order: the 
primary
+root (the PR's own record) first, then owner and shared roots, each followed by
+its decisions, tasks and records. A chosen decision lists the rejected
+`alternatives` it beat and any decision it was `supersededBy`; `governs` shows
+code already linked with `linkBead`. Filter with `--root`, `--type` or 
`--status`
+(`--status in_progress` is the claimed, unfinished work). Empty when no record
+matched — the review then simply proceeds without beads.
+
 ---
 
 ## Agent-facing — write
@@ -137,6 +147,14 @@ enrichment turns up prior context the Phase-1 discovery 
pass missed, so the
 report can cite where the change was debated. `--source` is `jira | devlist |
 proposal`.
 
+### linkBead
+Records that a bead — usually a decision — governs a piece of the changed code,
+as a `governs` edge (Bead → Function, Type or File). Draw it when a decision's
+design is about that code, so Inspect checks the code against what the decision
+chose and ruled out, and the report can cite the decision beside it. Key a
+Function/Type by `--name` (add `--file` when the name repeats) and a File by
+`--file`. Defaults to `INFERRED`: the link is your judgment, not a bd fact.
+
 ### linkDoc
 Records that a documentation file documents a graph entity, via a `documents`
 edge from a Doc vertex to the named entity (`--entity` label + `--name`). Use 
it
diff --git a/.skills/tinker-review/references/interfaces.md 
b/.skills/tinker-review/references/interfaces.md
index 60043ef934..c78eed686b 100644
--- a/.skills/tinker-review/references/interfaces.md
+++ b/.skills/tinker-review/references/interfaces.md
@@ -105,12 +105,14 @@ interface PopulationSummary {
 //   ArchitectureResult   scripts/patterns/architecture.js
 //   CodeIndex            scripts/extraction/code-index.js
 //   CodeRef              scripts/renderer/code-links.js
+//   BeadsDiscovery       scripts/discovery/beads.js   (with Bead, BeadRoot)
 
 // === Evidence (evidence.json — what Phase 1 writes; the fields Interpret 
cites) ===
 
 interface Evidence {
   meta: {
     pr: number;
+    baseBranch: string;          // the branch the PR targets (master, 
3.8-dev, 3.7-dev)
     headSha: string | null;      // PR head commit reviewed; pins report code 
links
     title: string;
     domains: string[];           // e.g. ["general", "glv", "driver-server"]
@@ -130,7 +132,10 @@ interface Evidence {
     externals:    ExternalsResult;
     orphans:      OrphanResult;
   };
-  discussions: DiscussionsResult;   // jiras[], devList[], secondary[], 
proposals[], prComments{}
+  discussions: DiscussionsResult & { beads: BeadsDiscovery };
+    // jiras[], devList[], secondary[], proposals[], prComments{}; beads = the 
roots
+    // reached from record beads naming the PR or those discussions, their 
subtrees,
+    // and whether bd dolt pull succeeded
   changedFiles: string[];
   codeIndex: CodeIndex;             // changed files → hunks + symbol line 
ranges (scripts/extraction/code-index.js)
 }
@@ -154,8 +159,10 @@ interface ReportPackage extends Evidence {
     // CodeRef = { file; symbol?; lines?: [number, number]; label? } — see
     // scripts/renderer/code-links.js. The renderer resolves it against 
codeIndex;
     // the agent never writes line numbers or URLs. Legacy { body } items 
still render.
-  findings: { title; snippet; body }[];
-  openQuestions: { title; body; meta }[];
+    // A ref may instead be { bead; label? }: a chip linking to that bead in 
Project
+    // Memory; it must be one of discussions.beads.beads.
+  findings: { title; snippet; body; refs?: CodeRef[] }[];
+  openQuestions: { title; body; meta; refs?: CodeRef[] }[];
   functionalTest?: { plan; results: { name; pass; output }[]; observations: 
string[] };
     // plan: HTML; observations: array of HTML strings; both theme-level. 
results rows are THEMES, each `name`
     // naming the scenario labels it spans — not one row per scenario.
diff --git a/.skills/tinker-review/references/schema.md 
b/.skills/tinker-review/references/schema.md
index f80dbef8b4..26fd9de4a1 100644
--- a/.skills/tinker-review/references/schema.md
+++ b/.skills/tinker-review/references/schema.md
@@ -1,6 +1,6 @@
 # PR Knowledge Graph Schema
 
-## Vertices (9 labels)
+## Vertices (10 labels)
 
 ### Code structure
 
@@ -109,7 +109,29 @@ The PR itself is a Discussion with `source: "pr"`.
 **Comment** `{ author, body, timestamp }`
 A comment on a Discussion.
 
-## Edges (16 implemented + 1 planned)
+### Project memory
+
+**Bead** `{ beadId, type, status, title, description, design, labels, root, 
rejected?, parent?, externalRef?, closedAt?, rootRole? }`
+A bead from the reviewer's local beads database (`bd`), loaded because a record
+bead's external ref names one of the PR's artifacts (see `discovery/beads.js`).
+Every bead in the subtree of each root reached that way is loaded. `type` is
+bd's issue type (`epic`, `feature`, `task`, `bug`, `decision`, `record`, …);
+`root` is the id of the root the bead belongs to. `rejected` is set only on
+decisions — `true` marks the road not taken, and `design` then says what the
+option was, why it lost and what settled it. `externalRef` is set only on
+records. `labels` is the comma-joined label list.
+
+`rootRole` is set only on roots: `primary` — the root of this PR's own record
+(`apache/tinkerpop#N`); `owner` — the root a matched JIRA/dev@/proposal record
+belongs to; `shared` — a root that links a matched record with `related`
+(connected work). Query the PR's own work with `.has("rootRole", "primary")`
+and a root's subtree with `.has("root", id)`.
+
+Status reads against the beads workflow: a root stays open until the work
+merges, and decisions are closed as they are written, so at PR time status is
+informative mainly on tasks (`in_progress` = claimed, unfinished).
+
+## Edges (19 implemented, plus bd's dependency types, + 1 planned)
 
 One edge is marked ⚠️ *planned* below (`depends_on`) — documented but 
intentionally
 not populated.
@@ -182,3 +204,13 @@ renders this as the **Signal Confidence** panel.
 | `matched_in` | `reference`, `title`, `body` | How the proposal was linked: 
an explicit path reference in the PR (`EXTRACTED`), a keyword in the proposal's 
title/heading (`INFERRED`), or keywords in its body (`AMBIGUOUS`). |
 | `matched_keywords` | comma-separated terms | The keywords that matched, so 
the link is self-explanatory. |
 
+### Project memory
+
+All `EXTRACTED` except `governs`: each is a fact in the beads database.
+
+| Edge | From | To | Meaning |
+|------|------|----|---------|
+| `child_of` | Bead | Bead | bd's parent-child tree: the bead belongs to its 
parent |
+| `records` | Bead (record) | Discussion | The record's external ref names 
this Discussion (the PR, a JIRA, a dev@ thread, a proposal). Only drawn to 
Discussions discovery created. |
+| `governs` | Bead | Function, Type or File | The bead — usually a decision — 
is about this code. Written in Phase 2 by `linkBead`, so it is `INFERRED` by 
default, unlike the rest of this table. |
+| `blocks`, `caused_by`, `related`, `supersedes`, `discovered_from`, … | Bead 
| Bead | bd's other dependency types, carried over with `-` written as `_`. 
`related` pairs a decision with the alternative it was chosen over; `caused_by` 
runs from a task to the decision that shaped it; `supersedes` from a newer 
decision to the one it replaces. Drawn only between loaded beads. |
diff --git a/.skills/tinker-review/scripts/discovery/beads.js 
b/.skills/tinker-review/scripts/discovery/beads.js
new file mode 100644
index 0000000000..9896a1bd52
--- /dev/null
+++ b/.skills/tinker-review/scripts/discovery/beads.js
@@ -0,0 +1,289 @@
+/*
+ * 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.
+ */
+
+// Beads discovery: find the bead roots recorded for the change under review 
and
+// load their subtrees. The project's beads schema (.beads/PRIME.md section 6)
+// puts every external artifact — JIRA, PR, dev@ thread, proposal — on a 
`record`
+// bead that is a parent-child child of its root, and only records carry an
+// external ref. A second root that needs the same artifact links to the 
existing
+// record with a `related` edge. So a record whose ref names one of the PR's
+// artifacts leads to the owning root and any sharing roots, and everything
+// reached that way is EXTRACTED.
+//
+// The review reads beads and never writes them: every call except the sync
+// passes bd's --readonly flag, which makes bd itself refuse a write.
+
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+const exec = promisify(execFile);
+
+export const BEADS_REQUIRED_MESSAGE =
+  "tinker-review requires beads (bd). Install it and run `bin/agent-setup.sh 
--contributor`, then re-run the review.";
+
+/**
+ * @typedef {object} Bead
+ * A bead from the reviewer's local beads database.
+ * @property {string} id - Bead id (e.g. `tp-hv9.1`). Never parse it for 
structure; `parent` is the truth.
+ * @property {string} type - bd issue type: `epic`, `feature`, `task`, `bug`, 
`decision`, `record`, ...
+ * @property {string} status - `open`, `in_progress`, `closed`, `pinned`, ... 
At PR time the root is
+ *   expected to be open (it closes at merge), and decisions are closed as 
they are written, so
+ *   status means most on tasks: `in_progress` is claimed, unfinished work.
+ * @property {string} title
+ * @property {string} description
+ * @property {string} design - For a decision, the reasoning: why it was 
chosen, or what the rejected
+ *   option was, why it lost and what settled it.
+ * @property {boolean|null} rejected - On a decision, true marks the road not 
taken; null on other types.
+ * @property {string[]} labels
+ * @property {string|null} parent - Parent bead id; null on a root.
+ * @property {string|null} externalRef - The artifact a record names 
(`TINKERPOP-NNNN`,
+ *   `apache/tinkerpop#N`, `apache/tinkerpop@sha`, a dev@ URL); null on 
non-records.
+ * @property {string|null} closedAt
+ * @property {string} root - Id of the root this bead belongs to.
+ * @property {{ to: string, type: string }[]} edges - Outgoing bd dependencies 
other than
+ *   parent-child (`blocks`, `caused-by`, `related`, `supersedes`, 
`discovered-from`, ...).
+ */
+
+/**
+ * @typedef {object} BeadRoot
+ * A root reached from a matched record.
+ * @property {string} id - Root bead id.
+ * @property {"primary"|"owner"|"shared"} role - `primary`: the root of this 
PR's own record
+ *   (`apache/tinkerpop#N`), the work the PR delivers. `owner`: the root a 
matched record belongs
+ *   to. `shared`: a root that links a matched record with `related` — 
connected work elsewhere.
+ * @property {string[]} via - Ids of the matched records that reached this 
root.
+ */
+
+/**
+ * @typedef {object} BeadsDiscovery
+ * What beads discovery found for the PR. Present in 
`evidence.discussions.beads`.
+ * @property {boolean} synced - Whether `bd dolt pull` succeeded. When false 
the review used the
+ *   local database as it was, which may be missing beads pushed since the 
last sync.
+ * @property {string|null} syncError - Why the sync failed, when it did.
+ * @property {string[]} seeds - External refs searched: the PR, its JIRAs, 
explicitly linked dev@
+ *   threads and explicitly referenced proposals.
+ * @property {{ seed: string, record: string }[]} matches - Record beads whose 
external ref equals a seed.
+ * @property {BeadRoot[]} roots - Roots reached from the matches, primary 
first. Empty when no
+ *   record matched; the review then proceeds without beads, and that is not a 
finding.
+ * @property {Bead[]} beads - Every bead in the reached roots' subtrees, roots 
included.
+ * @property {Object<string, {author: string, text: string, createdAt: 
string}[]>} comments -
+ *   Comments by bead id, for roots and decisions (where facts and later 
context are recorded).
+ */
+
+/**
+ * A function that runs bd with the given arguments from the repo checkout and
+ * returns its parsed JSON output. Read commands get --readonly and --json 
added.
+ * @callback BdRunner
+ * @param {string[]} args
+ * @returns {Promise<any>}
+ */
+
+/**
+ * Build a {@link BdRunner} that runs the real bd from `repoPath`.
+ * @param {string} repoPath
+ * @returns {BdRunner}
+ */
+export function bdRunner(repoPath) {
+  return async (args) => {
+    const { stdout } = await exec("bd", [...args, "--readonly", "--json"], {
+      cwd: repoPath,
+      maxBuffer: 32 * 1024 * 1024,
+    });
+    return stdout.trim() ? JSON.parse(stdout) : null;
+  };
+}
+
+/**
+ * Fail early when beads is unavailable: bd must be installed and must open the
+ * repo's database. Throws an Error carrying {@link BEADS_REQUIRED_MESSAGE}.
+ * @param {string} repoPath
+ * @param {BdRunner} [run]
+ */
+export async function requireBeads(repoPath, run = bdRunner(repoPath)) {
+  try {
+    await run(["list", "-n", "1"]);
+  } catch (err) {
+    const detail = err.code === "ENOENT" ? "bd was not found on the PATH" : 
(err.stderr || err.message || "").trim();
+    throw new Error(`${BEADS_REQUIRED_MESSAGE}\n  (${detail})`);
+  }
+}
+
+/**
+ * Pull the shared beads database so the review sees the latest state. A failed
+ * pull is not fatal: the review continues on the local state and reports it.
+ * @param {string} repoPath
+ * @param {(args: string[]) => Promise<unknown>} [pull] - runs `bd <args>`; 
injectable for tests.
+ * @returns {Promise<{ synced: boolean, syncError: string|null }>}
+ */
+export async function syncBeads(repoPath, pull = (args) => exec("bd", args, { 
cwd: repoPath, timeout: 120000 })) {
+  try {
+    await pull(["dolt", "pull"]);
+    return { synced: true, syncError: null };
+  } catch (err) {
+    return { synced: false, syncError: (err.stderr || err.message || "bd dolt 
pull failed").toString().trim() };
+  }
+}
+
+// Trailing punctuation a URL picks up from surrounding prose.
+const stripTrailing = (s) => s.replace(/[.,)\]>]+$/, "");
+
+/**
+ * The external refs a record could carry for this PR. Only artifacts the PR
+ * itself names are seeds — its own ref, its JIRAs, explicitly linked dev@
+ * threads and explicitly referenced proposals. Keyword-searched dev@ threads 
and
+ * keyword-matched proposals are guesses, and an EXTRACTED match can't rest on 
one.
+ * @param {number} pr
+ * @param {object} discussions - Output of discoverDiscussions().
+ * @returns {string[]}
+ */
+export function beadSeeds(pr, discussions = {}) {
+  const seeds = [`apache/tinkerpop#${pr}`];
+  for (const j of discussions.jiras || []) seeds.push(j.id);
+  for (const d of discussions.devList || []) if (d.found_in === "pr") 
seeds.push(stripTrailing(d.url));
+  for (const p of discussions.proposals || []) if (p.matchedIn === 
"reference") seeds.push(p.path);
+  return [...new Set(seeds.filter(Boolean))];
+}
+
+function toBead(raw) {
+  return {
+    id: raw.id,
+    type: raw.issue_type,
+    status: raw.status,
+    title: raw.title || "",
+    description: raw.description || "",
+    design: raw.design || "",
+    rejected: raw.issue_type === "decision" ? raw.metadata?.rejected === true 
: null,
+    labels: raw.labels || [],
+    parent: raw.parent || null,
+    externalRef: raw.external_ref || null,
+    closedAt: raw.closed_at || null,
+    root: null,
+    edges: (raw.dependencies || [])
+      .filter((d) => d.type !== "parent-child")
+      .map((d) => ({ to: d.depends_on_id, type: d.type })),
+  };
+}
+
+/**
+ * Find the bead roots recorded for a PR and load their subtrees.
+ *
+ * 1. For each seed, `bd search <prefix> --external-contains <seed> --type 
record`,
+ *    keeping exact matches (the flag is a substring match). The positional 
query
+ *    is also applied, as a title/ID match, so it is the project's ID prefix — 
which
+ *    every bead matches — rather than the ref, which a record's title need 
not repeat.
+ * 2. Each matched record's top-most ancestor is its owner root; every bead 
that
+ *    links the record with `related` leads to a shared root. A PR record's 
owner
+ *    root is primary. Every root reached is loaded — connected work is 
context.
+ * 3. Load each root's subtree. `bd children --json` returns one level only 
(its
+ *    text output is the tree), so the candidates come from
+ *    `bd dep tree <root> --direction up`, which walks every
+ *    dependent in one call — including beads outside the subtree that merely
+ *    relate to one inside it. One `bd list --id` call then supplies each
+ *    candidate's real `parent` and full fields, and only beads whose parent
+ *    chain reaches the root are kept. Comments are read for the roots and
+ *    decisions that have any.
+ *
+ * @param {object} params
+ * @param {string[]} params.seeds - From {@link beadSeeds}.
+ * @param {string} [params.prRef] - This PR's ref, `apache/tinkerpop#N`; marks 
its root primary.
+ * @param {BdRunner} params.run
+ * @returns {Promise<Omit<BeadsDiscovery, "synced"|"syncError">>}
+ */
+export async function discoverBeads({ seeds, prRef, run }) {
+  const result = { seeds, matches: [], roots: [], beads: [], comments: {} };
+
+  const [sample] = (await run(["list", "-n", "1", "--all", "--flat"])) || [];
+  if (!sample) return result;
+  const idPrefix = sample.id.slice(0, sample.id.indexOf("-") + 1);
+
+  for (const seed of seeds) {
+    const hits = (await run(["search", idPrefix, "--external-contains", seed, 
"--type", "record", "--status", "all", "--limit", "100"])) || [];
+    for (const h of hits) {
+      if (h.external_ref === seed) result.matches.push({ seed, record: h.id });
+    }
+  }
+  if (result.matches.length === 0) return result;
+
+  const fetched = new Map();
+  const fetch = async (ids) => {
+    const missing = ids.filter((id) => !fetched.has(id));
+    if (missing.length === 0) return;
+    const rows = (await run(["list", "--id", missing.join(","), "--all", "-n", 
"0", "--flat"])) || [];
+    for (const r of rows) fetched.set(r.id, r);
+  };
+  const topOf = async (id) => {
+    let cur = id;
+    const seen = new Set();
+    for (;;) {
+      await fetch([cur]);
+      const parent = fetched.get(cur)?.parent;
+      if (!parent || seen.has(parent)) return cur;
+      seen.add(cur);
+      cur = parent;
+    }
+  };
+
+  const RANK = { primary: 0, owner: 1, shared: 2 };
+  const roots = new Map();
+  const reach = (rootId, role, record) => {
+    const r = roots.get(rootId) || { id: rootId, role, via: [] };
+    if (RANK[role] < RANK[r.role]) r.role = role;
+    if (!r.via.includes(record)) r.via.push(record);
+    roots.set(rootId, r);
+  };
+
+  for (const { seed, record } of result.matches) {
+    reach(await topOf(record), seed === prRef ? "primary" : "owner", record);
+    const sharers = (await run(["dep", "list", record, "--direction", "up", 
"-t", "related"])) || [];
+    for (const s of sharers) reach(await topOf(s.id), "shared", record);
+  }
+  result.roots = [...roots.values()].sort((a, b) => RANK[a.role] - 
RANK[b.role]);
+
+  for (const { id: rootId } of result.roots) {
+    if (result.beads.some((b) => b.root === rootId)) continue;
+    const tree = (await run(["dep", "tree", rootId, "--direction", "up"])) || 
[];
+    await fetch(tree.map((t) => t.id));
+    const inSubtree = new Set([rootId]);
+    let grew = true;
+    while (grew) {
+      grew = false;
+      for (const { id } of tree) {
+        if (!inSubtree.has(id) && inSubtree.has(fetched.get(id)?.parent)) {
+          inSubtree.add(id);
+          grew = true;
+        }
+      }
+    }
+    for (const id of inSubtree) {
+      const raw = fetched.get(id);
+      if (raw) result.beads.push({ ...toBead(raw), root: rootId });
+    }
+  }
+
+  for (const b of result.beads) {
+    if (b.id !== b.root && b.type !== "decision") continue;
+    if (!fetched.get(b.id)?.comment_count) continue;
+    const comments = (await run(["comments", b.id])) || [];
+    if (comments.length > 0) {
+      result.comments[b.id] = comments.map((c) => ({ author: c.author, text: 
c.text, createdAt: c.created_at }));
+    }
+  }
+
+  return result;
+}
diff --git a/.skills/tinker-review/scripts/enrichment/api.js 
b/.skills/tinker-review/scripts/enrichment/api.js
index 3f21cdf042..2952ac251c 100644
--- a/.skills/tinker-review/scripts/enrichment/api.js
+++ b/.skills/tinker-review/scripts/enrichment/api.js
@@ -180,6 +180,73 @@ export async function listExternalRefs(g) {
     });
 }
 
+/**
+ * The beads Phase 1 loaded for this PR — the author's recorded plan, decisions
+ * and the alternatives they turned down — so the reviewer can read the change
+ * against them. Roots come first (primary, then owner, then shared), then each
+ * root's decisions, tasks and records. A decision lists the rejected
+ * alternatives it was chosen over (`related` to a rejected decision) and any
+ * decision that supersedes it; judge the code against the newest decision in a
+ * supersedes chain. `governs` lists code already linked with `linkBead`.
+ * Empty when no record named the PR or its discussions.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource (already connected)
+ * @param {object} [filter]
+ * @param {string} [filter.root] - only this root's subtree
+ * @param {string} [filter.type] - bd type: decision | task | record | ...
+ * @param {string} [filter.status] - e.g. in_progress
+ * @returns {Promise<object[]>}
+ */
+export async function listBeads(g, filter = {}) {
+  let t = g.V().hasLabel("Bead");
+  if (filter.root) t = t.has("root", filter.root);
+  if (filter.type) t = t.has("type", filter.type);
+  if (filter.status) t = t.has("status", filter.status);
+  const rows = await t.project("bead", "alternatives", "supersededBy", 
"governs")
+    .by(__.valueMap())
+    .by(__.both("related").has("rejected", true).values("beadId").fold())
+    .by(__.in_("supersedes").values("beadId").fold())
+    .by(__.out("governs").coalesce(__.values("path"), 
__.values("name")).fold())
+    .toList();
+
+  const one = (m, k) => {
+    const v = m.get(k);
+    return Array.isArray(v) ? v[0] : v;
+  };
+  const beads = rows.map((r) => {
+    const m = r.get("bead");
+    const bead = {
+      beadId: one(m, "beadId"),
+      type: one(m, "type"),
+      status: one(m, "status"),
+      title: one(m, "title"),
+      design: one(m, "design") || "",
+      root: one(m, "root"),
+      rootRole: one(m, "rootRole") ?? null,
+      rejected: one(m, "rejected") ?? null,
+      externalRef: one(m, "externalRef") ?? null,
+      labels: one(m, "labels") || "",
+    };
+    if (bead.type === "decision") {
+      if (bead.rejected === false) bead.alternatives = r.get("alternatives");
+      bead.supersededBy = r.get("supersededBy");
+    }
+    const governs = r.get("governs");
+    if (governs.length > 0) bead.governs = governs;
+    return bead;
+  });
+
+  const ROLE = { primary: 0, owner: 1, shared: 2 };
+  const TYPE = { decision: 1, record: 3 };
+  const roleOf = new Map(beads.filter((b) => b.rootRole).map((b) => [b.beadId, 
ROLE[b.rootRole]]));
+  const rank = (b) => [roleOf.get(b.root) ?? 3, b.root, b.rootRole ? 0 : 
(TYPE[b.type] ?? 2), b.beadId];
+  return beads.sort((a, b) => {
+    const [x, y] = [rank(a), rank(b)];
+    for (let k = 0; k < x.length; k++) if (x[k] !== y[k]) return x[k] < y[k] ? 
-1 : 1;
+    return 0;
+  });
+}
+
 // === Write operations ===
 
 /**
@@ -327,6 +394,41 @@ export async function linkDiscussion(g, url, source, 
title, body, confidence) {
   return { linked: `${source}: ${title}`, confidence: conf };
 }
 
+/**
+ * Record that a bead — usually a decision — governs a piece of the changed
+ * code, as a `governs` edge (Bead → Function, Type or File). Draw it when a
+ * decision's design is about that code, so Inspect can check the code against
+ * what the decision chose and ruled out, and the report can cite the decision
+ * beside it. Key a Function or Type by `--name` (plus `--file` when the name
+ * repeats) and a File by `--file`.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource (already connected)
+ * @param {string} beadId - The bead id (e.g. tp-abc.3)
+ * @param {string} entityLabel - Function | Type | File
+ * @param {string} [name] - Function/Type name
+ * @param {string} [filePath] - File path; for a Function/Type, disambiguates 
the name
+ * @param {string} [confidence] - default INFERRED (the agent's judgment)
+ * @returns {Promise<object>}
+ */
+export async function linkBead(g, beadId, entityLabel, name, filePath, 
confidence) {
+  const conf = normalizeConfidence(confidence, CONFIDENCE.INFERRED);
+  if (!(await g.V().hasLabel("Bead").has("beadId", beadId).hasNext())) {
+    return { linked: false, reason: `no Bead ${beadId} in the graph` };
+  }
+  const match = (t) => {
+    if (entityLabel === "File") return t.has("path", filePath);
+    return filePath ? t.has("name", name).has("filePath", filePath) : 
t.has("name", name);
+  };
+  const found = (await 
match(g.V().hasLabel(entityLabel)).count().next()).value;
+  if (found === 0) return { linked: false, reason: `no ${entityLabel} ${name 
|| filePath} in the graph` };
+  if (found > 1) return { linked: false, reason: `${found} ${entityLabel} 
vertices match ${name}; pass --file` };
+  await g.V().hasLabel("Bead").has("beadId", beadId)
+    .addE("governs").property("confidence", conf)
+    .to(match(__.V().hasLabel(entityLabel)))
+    .next();
+  return { linked: `${beadId} governs ${entityLabel}(${name || filePath})`, 
confidence: conf };
+}
+
 /**
  * Set an arbitrary property on a vertex identified by label + name. The
  * general-purpose escape hatch for recording a fact the schema has no 
dedicated
diff --git a/.skills/tinker-review/scripts/enrichment/cli.js 
b/.skills/tinker-review/scripts/enrichment/cli.js
index cac84dcc07..f9fdfdfd92 100644
--- a/.skills/tinker-review/scripts/enrichment/cli.js
+++ b/.skills/tinker-review/scripts/enrichment/cli.js
@@ -23,7 +23,7 @@ import { pathToFileURL } from "node:url";
 import gremlin from "gremlin";
 import {
   listFunctions, listTypes, getCallsFrom, getCanonicalSteps,
-  listDeleted, listExternalRefs, addReference,
+  listDeleted, listExternalRefs, listBeads, addReference, linkBead,
   mapStep, setEdgeConfidence, linkDiscussion, linkDoc, addGrammarRule, 
annotate,
   linkRule, mapCoverage, createPrDiscussion,
 } from "./api.js";
@@ -47,11 +47,13 @@ const COMMANDS = {
   listInferred: { fn: listInferred, needsG: true, facing: "agent" },
   listDeleted: { fn: listDeleted, needsG: true, facing: "agent" },
   listExternalRefs: { fn: listExternalRefs, needsG: true, facing: "agent" },
+  listBeads: { fn: listBeads, needsG: true, facing: "agent" },
   // Agent-facing (Phase 2 enrichment) — write
   addReference: { fn: addReference, needsG: true, facing: "agent" },
   mapStep: { fn: mapStep, needsG: true, facing: "agent" },
   setEdgeConfidence: { fn: setEdgeConfidence, needsG: true, facing: "agent" },
   linkDiscussion: { fn: linkDiscussion, needsG: true, facing: "agent" },
+  linkBead: { fn: linkBead, needsG: true, facing: "agent" },
   linkDoc: { fn: linkDoc, needsG: true, facing: "agent" },
   addGrammarRule: { fn: addGrammarRule, needsG: true, facing: "agent" },
   linkRule: { fn: linkRule, needsG: true, facing: "agent" },
@@ -100,10 +102,12 @@ async function main() {
     console.log("  listInferred    [--relation implements_step|calls|...] 
[--limit 100]");
     console.log("  listDeleted");
     console.log("  listExternalRefs");
+    console.log("  listBeads       [--root <beadId>] [--type 
decision|task|record] [--status in_progress]");
     console.log("  addReference    --fromPath <path> --toPath <deletedPath> 
[--symbol <name>] [--location <L42>] [--confidence ...]");
     console.log("  mapStep         --function <name> --file <path> --step 
<canonicalName> [--confidence INFERRED|AMBIGUOUS|EXTRACTED]");
     console.log("  setEdgeConfidence --relation <label> --fromName <name> 
[--fromFile <path>] [--toName <name>] --confidence 
<EXTRACTED|INFERRED|AMBIGUOUS>");
     console.log("  linkDiscussion  --url <url> --source 
<jira|devlist|proposal> --title <title> [--body <body>] [--confidence ...]");
+    console.log("  linkBead        --bead <beadId> --entity 
<Function|Type|File> [--name <name>] [--file <path>] [--confidence ...]");
     console.log("  linkDoc         --entity <label> --name <name> --doc <path> 
[--section <section>] [--confidence ...]");
     console.log("  addGrammarRule  --name <name> [--production <production>]");
     console.log("  linkRule        --step <canonicalName> --rule <ruleName> 
[--confidence ...]");
@@ -198,6 +202,12 @@ async function main() {
           confidence: args.confidence,
         });
         break;
+      case "listBeads":
+        result = await fn(g, { root: args.root, type: args.type, status: 
args.status });
+        break;
+      case "linkBead":
+        result = await fn(g, args.bead, args.entity, args.name, args.file, 
args.confidence);
+        break;
       case "linkDiscussion":
         result = await fn(g, args.url, args.source, args.title, args.body, 
args.confidence);
         break;
diff --git a/.skills/tinker-review/scripts/extraction/tree-sitter.js 
b/.skills/tinker-review/scripts/extraction/tree-sitter.js
index c8f7d4bfca..fddd85cc60 100644
--- a/.skills/tinker-review/scripts/extraction/tree-sitter.js
+++ b/.skills/tinker-review/scripts/extraction/tree-sitter.js
@@ -996,7 +996,8 @@ export async function extractMulti(directory, languages, 
options = {}) {
     const one = await extract(directory, language, options);
     merged.languages.push(language);
     for (const key of ["files", "functions", "types", "calls", "imports", 
"tests", "declares"]) {
-      merged[key].push(...one[key]);
+      // Not push(...one[key]): spreading a large array exceeds the call stack.
+      for (const item of one[key]) merged[key].push(item);
     }
     if (one.hierarchyNeighborhood) {
       merged.hierarchyNeighborhood.files += one.hierarchyNeighborhood.files;
diff --git a/.skills/tinker-review/scripts/graph/populate-beads.js 
b/.skills/tinker-review/scripts/graph/populate-beads.js
new file mode 100644
index 0000000000..a772c3b972
--- /dev/null
+++ b/.skills/tinker-review/scripts/graph/populate-beads.js
@@ -0,0 +1,133 @@
+/*
+ * 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.
+ */
+
+import gremlin from "gremlin";
+import { CONFIDENCE } from "./confidence.js";
+
+const { process: { statics: __ } } = gremlin;
+
+const BATCH_SIZE = 50;
+
+async function submitBatch(batch) {
+  await Promise.allSettled(batch.map((t) => t.next()));
+}
+
+/**
+ * The Discussion url a record's external ref names, matching the urls
+ * populateDiscussions() writes. Commit refs (`apache/tinkerpop@sha`) name no
+ * Discussion and return null.
+ * @param {string|null} ref
+ * @returns {string|null}
+ */
+export function discussionUrlForRef(ref) {
+  if (!ref) return null;
+  const pr = ref.match(/^apache\/tinkerpop#(\d+)$/);
+  if (pr) return `https://github.com/apache/tinkerpop/pull/${pr[1]}`;
+  if (/^TINKERPOP-\d+$/.test(ref)) return 
`https://issues.apache.org/jira/browse/${ref}`;
+  if (ref.includes("@")) return null;
+  return ref;
+}
+
+// bd dependency types become edge labels with underscores: caused-by -> 
caused_by.
+const edgeLabel = (type) => type.replace(/-/g, "_");
+
+/**
+ * Load the beads found by discoverBeads() into the graph: a Bead vertex per
+ * bead, `child_of` for the parent-child tree, bd's other dependencies carried
+ * over as edges between loaded beads, and `records` from a record bead to the
+ * Discussion its external ref names. Every edge is EXTRACTED: each one is a
+ * fact in the beads database.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource (already connected)
+ * @param {import("../discovery/beads.js").BeadsDiscovery} beads
+ * @returns {Promise<{vertices: number, edges: number, breakdown: object}>}
+ */
+export async function populateBeads(g, beads) {
+  const counts = { vertices: 0, edges: 0, breakdown: { beads: 0, childOf: 0, 
records: 0, dependencies: 0 } };
+  if (!beads || !beads.beads || beads.beads.length === 0) return counts;
+
+  const roleOf = new Map((beads.roots || []).map((r) => [r.id, r.role]));
+  const loaded = new Set(beads.beads.map((b) => b.id));
+
+  for (const b of beads.beads) {
+    let t = g.addV("Bead")
+      .property("beadId", b.id)
+      .property("type", b.type)
+      .property("status", b.status)
+      .property("title", b.title)
+      .property("description", (b.description || "").slice(0, 4000))
+      .property("design", (b.design || "").slice(0, 4000))
+      .property("labels", (b.labels || []).join(", "))
+      .property("root", b.root);
+    if (b.rejected !== null) t = t.property("rejected", b.rejected);
+    if (b.parent) t = t.property("parent", b.parent);
+    if (b.externalRef) t = t.property("externalRef", b.externalRef);
+    if (b.closedAt) t = t.property("closedAt", b.closedAt);
+    if (roleOf.has(b.id)) t = t.property("rootRole", roleOf.get(b.id));
+    await t.next();
+    counts.vertices++;
+    counts.breakdown.beads++;
+  }
+
+  const bead = (id) => __.V().hasLabel("Bead").has("beadId", id);
+  let batch = [];
+  const push = async (traversal, key) => {
+    batch.push(traversal);
+    counts.edges++;
+    counts.breakdown[key]++;
+    if (batch.length >= BATCH_SIZE) { await submitBatch(batch); batch = []; }
+  };
+
+  for (const b of beads.beads) {
+    if (b.parent && loaded.has(b.parent)) {
+      await push(
+        g.V().hasLabel("Bead").has("beadId", b.id)
+          .addE("child_of").property("confidence", CONFIDENCE.EXTRACTED)
+          .to(bead(b.parent)),
+        "childOf",
+      );
+    }
+    for (const e of b.edges || []) {
+      if (!loaded.has(e.to)) continue;
+      await push(
+        g.V().hasLabel("Bead").has("beadId", b.id)
+          .addE(edgeLabel(e.type)).property("confidence", CONFIDENCE.EXTRACTED)
+          .to(bead(e.to)),
+        "dependencies",
+      );
+    }
+  }
+
+  // records: only to Discussions discovery already created, so a record for 
some
+  // other PR in a loaded subtree doesn't invent a Discussion vertex.
+  for (const b of beads.beads) {
+    if (b.type !== "record") continue;
+    const url = discussionUrlForRef(b.externalRef);
+    if (!url || !(await g.V().hasLabel("Discussion").has("url", 
url).hasNext())) continue;
+    await push(
+      g.V().hasLabel("Bead").has("beadId", b.id)
+        .addE("records").property("confidence", CONFIDENCE.EXTRACTED)
+        .to(__.V().hasLabel("Discussion").has("url", url)),
+      "records",
+    );
+  }
+
+  if (batch.length > 0) await submitBatch(batch);
+  return counts;
+}
diff --git a/.skills/tinker-review/scripts/renderer/render.js 
b/.skills/tinker-review/scripts/renderer/render.js
index aeafc0ea27..aa5dc2440f 100644
--- a/.skills/tinker-review/scripts/renderer/render.js
+++ b/.skills/tinker-review/scripts/renderer/render.js
@@ -87,11 +87,12 @@ function stripCodeWrapper(str) {
  *   guidedWalk: [{ title, badge: "attention|info|safe", badgeText,
  *                  intro: "HTML", questions: [{ text: "HTML", refs: [CodeRef] 
}], refs: [CodeRef] }]
  *     (CodeRef = { file, symbol?, lines?, label? } — see code-links.js; the 
renderer resolves it
- *      against evidence.codeIndex into a link. A legacy item with only body: 
"HTML" still renders.)
+ *      against evidence.codeIndex into a link. A ref may instead be { bead, 
label? }, which links
+ *      to that bead in Project Memory. A legacy item with only body: "HTML" 
still renders.)
  *   functionalTest: { plan: "HTML", results: [{name, pass, output}], 
observations: ["HTML"] }
  *     (results rows are THEME-level, each naming the scenario labels it 
covers)
- *   findings: [{ title, snippet: "code", body: "HTML" }]
- *   openQuestions: [{ title, body: "HTML", meta: "string" }]
+ *   findings: [{ title, snippet: "code", body: "HTML", refs?: [CodeRef] }]
+ *   openQuestions: [{ title, body: "HTML", meta: "string", refs?: [CodeRef] }]
  *   appendixFunctional: { environment: "HTML", testCode: "raw text", 
fullOutput: "raw text" }
  *     (testCode = the COMPLETE labeled battery; renderer wraps it in 
<pre><code>, do NOT pre-wrap)
  */
@@ -104,6 +105,14 @@ export function render(evidence, options = {}) {
   const { meta, graphStats, checks, discussions, summary, clusters, 
communityAssessment, communityNames,
     guidedWalk, functionalTest, findings, openQuestions, appendixFunctional } 
= evidence;
 
+  const beads = discussions?.beads;
+  const refCtx = {
+    pr: meta.pr,
+    headSha: meta.headSha,
+    codeIndex: evidence.codeIndex,
+    beadIds: new Set((beads?.beads || []).map((b) => b.id)),
+  };
+
   const parts = [];
   parts.push(renderHeader(meta));
   parts.push(renderNav());
@@ -111,11 +120,11 @@ export function render(evidence, options = {}) {
   parts.push(discussions ? renderContext(discussions) : notProvided("context", 
"Discovered Context"));
   parts.push(renderClusters(clusters, checks && checks.clusters, 
evidence.architecture));
   parts.push(renderCommunitySection(checks && checks.communities, 
communityAssessment, communityNames));
-  parts.push(guidedWalk && guidedWalk.length > 0 ? 
renderGuidedWalk(guidedWalk, { pr: meta.pr, headSha: meta.headSha, codeIndex: 
evidence.codeIndex }, warnings) : notProvided("guided-walk", "Guided Walk"));
+  parts.push(guidedWalk && guidedWalk.length > 0 ? 
renderGuidedWalk(guidedWalk, refCtx, warnings) : notProvided("guided-walk", 
"Guided Walk"));
   parts.push(functionalTest ? renderFunctionalTest(functionalTest) : 
notProvided("functional-test", "Functional Test"));
-  parts.push(findings && findings.length > 0 ? renderFindings(findings) : 
notProvided("findings", "Findings"));
-  parts.push(openQuestions && openQuestions.length > 0 ? 
renderOpenQuestions(openQuestions) : notProvided("open-questions", "Open 
Questions"));
-  parts.push(renderAppendixStructural(checks, graphStats, communityNames));
+  parts.push(findings && findings.length > 0 ? renderFindings(findings, 
refCtx, warnings) : notProvided("findings", "Findings"));
+  parts.push(openQuestions && openQuestions.length > 0 ? 
renderOpenQuestions(openQuestions, refCtx, warnings) : 
notProvided("open-questions", "Open Questions"));
+  parts.push(renderAppendixStructural(checks, graphStats, communityNames, 
beads));
   parts.push(appendixFunctional ? renderAppendixFunctional(appendixFunctional) 
: notProvided("appendix-functional", "Appendix: Functional Test Details"));
   parts.push(`<footer>Graph Review &mdash; Apache TinkerPop | PR 
#${esc(String(meta.pr))} | Generated ${esc(meta.timestamp)}</footer>`);
 
@@ -193,9 +202,163 @@ function renderContext(disc) {
     cards.push(`<div class="card">\n    <h3>Proposals</h3>\n    <p>No matching 
proposal in <code>docs/src/dev/future/</code>.</p>\n  </div>`);
   }
 
+  if (disc.beads) cards.push(renderProjectMemory(disc.beads));
+
   return `<section id="context">\n  <h2>Discovered Context</h2>\n  
${cards.join("\n  ")}\n</section>`;
 }
 
+// ---- Project Memory (beads) 
-------------------------------------------------
+
+const ROLE_TEXT = {
+  primary: "This PR's work",
+  owner: "Owns a linked record",
+  shared: "Connected work",
+};
+
+// Where an external ref points: a PR, a JIRA, a commit, a URL; null for a 
path.
+function externalRefUrl(ref) {
+  const pr = ref.match(/^apache\/tinkerpop#(\d+)$/);
+  if (pr) return `https://github.com/apache/tinkerpop/pull/${pr[1]}`;
+  if (/^TINKERPOP-\d+$/.test(ref)) return 
`https://issues.apache.org/jira/browse/${ref}`;
+  const sha = ref.match(/^apache\/tinkerpop@([0-9a-f]+)$/);
+  if (sha) return `https://github.com/apache/tinkerpop/commit/${sha[1]}`;
+  return /^https?:\/\//.test(ref) ? ref : null;
+}
+
+function externalRefHtml(ref) {
+  const url = externalRefUrl(ref);
+  return url ? `<a href="${esc(url)}">${esc(ref)}</a>` : 
`<code>${esc(ref)}</code>`;
+}
+
+function beadAnchor(b, text) {
+  return `<span id="bead-${esc(b.id)}"></span>${text}`;
+}
+
+// A short status chip; in_progress is the one that matters at PR time.
+function beadStatus(b) {
+  if (b.status === "in_progress") return `<span class="badge 
badge-attention">in progress</span>`;
+  return `<span class="bead-status">${esc(b.status)}</span>`;
+}
+
+function beadText(text) {
+  return text ? `<div class="bead-design">${esc(text)}</div>` : "";
+}
+
+function renderDecisions(decisions, beadsById) {
+  if (decisions.length === 0) return "";
+  const supersededBy = new Map();
+  for (const d of decisions) {
+    for (const e of d.edges || []) if (e.type === "supersedes") 
supersededBy.set(e.to, d.id);
+  }
+  // A chosen decision and its rejected alternatives are joined by `related`, 
in either direction.
+  const rejectedOf = new Map();
+  const paired = new Set();
+  for (const d of decisions) {
+    for (const e of d.edges || []) {
+      if (e.type !== "related") continue;
+      const other = beadsById.get(e.to);
+      if (!other || other.type !== "decision") continue;
+      const [chosen, alt] = d.rejected === false && other.rejected === true ? 
[d, other]
+        : d.rejected === true && other.rejected === false ? [other, d] : 
[null, null];
+      if (!chosen) continue;
+      if (!rejectedOf.has(chosen.id)) rejectedOf.set(chosen.id, []);
+      rejectedOf.get(chosen.id).push(alt);
+      paired.add(alt.id);
+    }
+  }
+  const superseded = (d) => supersededBy.has(d.id)
+    ? ` <span class="bead-superseded">superseded by <a 
href="#bead-${esc(supersededBy.get(d.id))}">${esc(supersededBy.get(d.id))}</a></span>`
 : "";
+
+  const items = decisions.filter((d) => d.rejected === false).map((d) => {
+    const alts = (rejectedOf.get(d.id) || []).map((a) =>
+      `<li>${beadAnchor(a, `<span class="bead-rejected">Instead of</span> 
${esc(a.title)} 
<code>${esc(a.id)}</code>${superseded(a)}`)}${beadText(a.design)}</li>`).join("");
+    return `<li>${beadAnchor(d, `<strong>Chose</strong> ${esc(d.title)} 
<code>${esc(d.id)}</code>${superseded(d)}`)}${beadText(d.design)}${alts ? `<ul 
class="bead-alternatives">${alts}</ul>` : ""}</li>`;
+  });
+  const unpaired = decisions.filter((d) => d.rejected === true && 
!paired.has(d.id)).map((d) =>
+    `<li>${beadAnchor(d, `<span class="bead-rejected">Not taken</span> 
${esc(d.title)} 
<code>${esc(d.id)}</code>${superseded(d)}`)}${beadText(d.design)}</li>`);
+  const chosen = decisions.filter((d) => d.rejected === false).length;
+  return `<details class="bead-decisions">
+      <summary>Decisions: ${chosen} chosen, ${decisions.length - chosen} not 
taken</summary>
+      <ul class="bead-list">${[...items, ...unpaired].join("")}</ul>
+    </details>`;
+}
+
+function renderBeadRoot(root, members, comments, beadsById, role, via) {
+  const records = members.filter((b) => b.type === "record" && b.externalRef);
+  const decisions = members.filter((b) => b.type === "decision");
+  const work = members.filter((b) => b.id !== root.id && !["record", 
"decision"].includes(b.type));
+  const active = work.filter((b) => b.status === "in_progress");
+  const waiting = work.filter((b) => b.status === "open");
+  const finished = work.filter((b) => b.status !== "in_progress" && b.status 
!== "open");
+  const human = members.filter((b) => (b.labels || []).includes("human") && 
!["closed", "pinned"].includes(b.status));
+
+  const recordList = records.length > 0
+    ? `<p><strong>Records:</strong> ${records.map((r) => beadAnchor(r, 
`${externalRefHtml(r.externalRef)}${via.includes(r.id) ? " <em>(matched)</em>" 
: ""}`)).join(", ")}</p>` : "";
+  const commentList = (comments[root.id] || []).length > 0
+    ? `<details><summary>Root comments 
(${comments[root.id].length})</summary><ul 
class="bead-list">${comments[root.id].map((c) => 
`<li>${beadText(c.text)}</li>`).join("")}</ul></details>` : "";
+  const humanList = human.length > 0
+    ? `<p><strong>Unanswered questions:</strong></p><ul 
class="bead-list">${human.map((b) => `<li>${beadAnchor(b, `${esc(b.title)} 
<code>${esc(b.id)}</code>`)}</li>`).join("")}</ul>` : "";
+  const workItem = (b) => `<li>${beadAnchor(b, `${beadStatus(b)} 
${esc(b.title)} <code>${esc(b.id)}</code>`)}</li>`;
+  const plan = work.length > 0
+    ? `<p><strong>Plan:</strong> ${active.length} in progress, 
${waiting.length} open, ${finished.length} done.</p>${active.length + 
waiting.length > 0 ? `<ul class="bead-list">${[...active, 
...waiting].map(workItem).join("")}</ul>` : ""}${finished.length > 0 ? 
`<details><summary>Done (${finished.length})</summary><ul 
class="bead-list">${finished.map(workItem).join("")}</ul></details>` : ""}` : 
"";
+
+  return `<div class="card card-context">
+    <h3>${beadAnchor(root, `<span class="badge badge-context">Beads</span> 
<span class="badge bead-role-${esc(role)}">${esc(ROLE_TEXT[role] || 
role)}</span> ${esc(root.title)} <code>${esc(root.id)}</code>`)}</h3>
+    <p class="discovery-meta" style="font-style: normal;">${esc(root.type)} · 
${esc(root.status)}${(root.labels || []).length ? ` · ${root.labels.map((l) => 
`<code>${esc(l)}</code>`).join(" ")}` : ""}</p>
+    ${recordList}
+    ${humanList}
+    ${plan}
+    ${renderDecisions(decisions, beadsById)}
+    ${commentList}
+  </div>`;
+}
+
+/**
+ * The Project Memory part of Discovered Context: the beads recorded for this
+ * change, one card per root (the PR's own root first), or a note that none 
were
+ * found. Says so when bd dolt pull failed and local state was used instead.
+ */
+function renderProjectMemory(beads) {
+  const syncNote = beads.synced === false
+    ? `<p class="discovery-meta">The latest beads data wasn't available 
(<code>bd dolt pull</code> failed${beads.syncError ? `: 
${esc(beads.syncError)}` : ""}); this shows the local beads database as it 
was.</p>` : "";
+  const roots = beads.roots || [];
+  if (roots.length === 0) {
+    const seeds = (beads.seeds || []).map((s) => 
`<code>${esc(s)}</code>`).join(", ");
+    return `<div class="card">
+    <h3>Project Memory</h3>
+    <p>The beads data wasn't available for this change: no bead record names 
this PR or its discussions.</p>
+    ${seeds ? `<p class="discovery-meta">Searched record beads for: 
${seeds}</p>` : ""}${syncNote}
+  </div>`;
+  }
+
+  const all = beads.beads || [];
+  const beadsById = new Map(all.map((b) => [b.id, b]));
+  const intro = `<h3>Project Memory</h3>
+  <p class="section-intro">Beads recorded for this change, found through 
record beads that name the PR or its discussions. Each root is the plan and the 
decisions behind a body of work: the PR's own root first, then connected work. 
An open root is expected before merge.</p>${syncNote}`;
+  const cards = roots.map((r) => {
+    const root = beadsById.get(r.id);
+    if (!root) return "";
+    return renderBeadRoot(root, all.filter((b) => b.root === r.id), 
beads.comments || {}, beadsById, r.role, r.via || []);
+  });
+  return [intro, ...cards].join("\n  ");
+}
+
+function renderBeadsAppendix(beads) {
+  const all = beads?.beads || [];
+  if (all.length === 0) return "";
+  const rows = all.map((b) => {
+    const decision = b.rejected === true ? "not taken" : b.rejected === false 
? "chosen" : "";
+    return `<tr><td class="fn-name"><a 
href="#bead-${esc(b.id)}">${esc(b.id)}</a></td><td>${esc(b.type)}${decision ? ` 
(${decision})` : 
""}</td><td>${esc(b.status)}</td><td><code>${esc(b.root)}</code></td><td>${esc(b.title)}</td></tr>`;
+  }).join("\n      ");
+  return `
+  <h3>Beads</h3>
+  <p class="section-intro">Every bead loaded from the roots in <a 
href="#context">Project Memory</a>. Each is a <code>Bead</code> vertex in the 
graph; query a root's subtree with <code>.has("root", id)</code>.</p>
+  <table class="gap-table">
+    
<thead><tr><th>Bead</th><th>Type</th><th>Status</th><th>Root</th><th>Title</th></tr></thead>
+    <tbody>\n      ${rows}\n    </tbody>
+  </table>`;
+}
+
 function generateClusterSvg(clusterData, architecture) {
   if (!clusterData || !clusterData.clusters || clusterData.clusters.length === 
0) return "";
 
@@ -420,6 +583,14 @@ function generateCommunitySvg(communityData, names) {
 function renderCodeRefs(refs, ctx, warnings, where, cls = "code-refs") {
   if (!Array.isArray(refs) || refs.length === 0) return "";
   const chips = refs.map((ref) => {
+    if (ref.bead) {
+      const label = ref.label || ref.bead;
+      if (!ctx.beadIds || !ctx.beadIds.has(ref.bead)) {
+        warnings.push(`${where}: unresolved: no bead "${ref.bead}" in 
discussions.beads (${JSON.stringify(ref)})`);
+        return `<span class="code-ref code-ref-unresolved" title="unresolved: 
no bead ${esc(ref.bead)}">${esc(label)}</span>`;
+      }
+      return `<a class="code-ref bead-ref" href="#bead-${esc(ref.bead)}" 
title="bead ${esc(ref.bead)} in Project Memory">${esc(label)}</a>`;
+    }
     const r = resolveRef(ref, ctx);
     if (!r.url) {
       warnings.push(`${where}: ${r.title} (${JSON.stringify(ref)})`);
@@ -484,21 +655,22 @@ function renderFunctionalTest(ft) {
 </section>`;
 }
 
-function renderFindings(findings) {
+function renderFindings(findings, ctx, warnings) {
   const cards = findings.map((f, i) => {
     const snippet = f.snippet ? `\n    
<pre><code>${esc(f.snippet)}</code></pre>` : "";
     return `<div class="card finding">
     <p class="finding-title">${i + 1}. ${esc(f.title)}</p>${snippet}
-    ${f.body}
+    ${f.body}${renderCodeRefs(f.refs, ctx, warnings, `findings[${i}].refs`)}
   </div>`;
   }).join("\n  ");
   return `<section id="findings">\n  <h2>Findings</h2>\n  
${cards}\n</section>`;
 }
 
-function renderOpenQuestions(questions) {
-  const cards = questions.map(q => {
+function renderOpenQuestions(questions, ctx, warnings) {
+  const cards = questions.map((q, i) => {
     const meta = q.meta ? `\n    <p class="discovery-meta">${esc(q.meta)}</p>` 
: "";
-    return `<div class="card">\n    <h3>${esc(q.title)}</h3>\n    
${q.body}${meta}\n  </div>`;
+    const refs = renderCodeRefs(q.refs, ctx, warnings, 
`openQuestions[${i}].refs`);
+    return `<div class="card">\n    <h3>${esc(q.title)}</h3>\n    
${q.body}${refs}${meta}\n  </div>`;
   }).join("\n  ");
   return `<section id="open-questions">\n  <h2>Open Questions</h2>\n  
${cards}\n</section>`;
 }
@@ -544,7 +716,7 @@ function renderConfidence(confidence) {
 `;
 }
 
-function renderAppendixStructural(checks, graphStats, communityNames) {
+function renderAppendixStructural(checks, graphStats, communityNames, beads) {
   const hotspots = checks?.centrality?.hotspots || [];
   const blast = checks?.blastRadius?.functions || [];
   const stats = graphStats || {};
@@ -609,6 +781,7 @@ function renderAppendixStructural(checks, graphStats, 
communityNames) {
   </table>
 ${hierarchyHtml}
 ${communitiesHtml}
+${renderBeadsAppendix(beads)}
 ${confidenceHtml}
   <h3>Graph Statistics</h3>
   <div class="stats-grid">
diff --git a/.skills/tinker-review/scripts/renderer/template.html 
b/.skills/tinker-review/scripts/renderer/template.html
index 0e2a6da0e6..8cdfe37c75 100644
--- a/.skills/tinker-review/scripts/renderer/template.html
+++ b/.skills/tinker-review/scripts/renderer/template.html
@@ -57,6 +57,14 @@ pre code { background: none; padding: 0; }
 .code-ref { display: inline-block; padding: 0.1rem 0.5rem; border: 1px solid 
var(--primary); border-radius: 3px; font-family: ui-monospace, SFMono-Regular, 
Menlo, monospace; font-size: 0.75rem; color: var(--primary); text-decoration: 
none; }
 .code-ref::after { content: " \2197"; } .code-ref:hover { background: 
var(--primary); color: #fff; }
 .code-ref-unresolved { border-color: var(--border); color: var(--text-muted); 
} .code-ref-unresolved::after { content: " ?"; }
+.bead-ref { border-color: #6f42c1; color: #6f42c1; } .bead-ref::after { 
content: " \2193"; } .bead-ref:hover { background: #6f42c1; color: #fff; }
+.bead-list { margin: 0.4rem 0 0.4rem 1.2rem; padding: 0; font-size: 0.875rem; 
} .bead-list li { margin-bottom: 0.45rem; }
+.bead-alternatives { margin: 0.3rem 0 0 1.2rem; padding: 0; list-style: none; 
border-left: 2px solid var(--border); padding-left: 0.6rem; }
+.bead-design { font-size: 0.8rem; color: var(--text-muted); white-space: 
pre-wrap; margin-top: 0.15rem; }
+.bead-rejected { color: var(--danger); font-weight: 600; } .bead-superseded { 
font-size: 0.75rem; color: var(--text-muted); font-style: italic; }
+.bead-status { font-size: 0.7rem; text-transform: uppercase; color: 
var(--text-muted); }
+.bead-decisions summary, .card details summary { cursor: pointer; font-weight: 
600; font-size: 0.9rem; margin-top: 0.4rem; }
+.bead-role-primary { background: var(--primary); color: #fff; } 
.bead-role-owner { background: #adb5bd; color: #000; } .bead-role-shared { 
background: var(--warning); color: #000; }
 .finding { border-left: 3px solid var(--warning); } .finding-title { 
font-weight: 600; margin-bottom: 0.25rem; }
 .test-pass { color: var(--success); font-weight: 600; } .test-fail { color: 
var(--danger); font-weight: 600; }
 .test-grid { display: grid; grid-template-columns: auto 1fr auto; gap: 0.25rem 
0.75rem; font-size: 0.875rem; align-items: baseline; }
diff --git a/.skills/tinker-review/scripts/review.js 
b/.skills/tinker-review/scripts/review.js
index 4ac6f24c4c..fc56951997 100644
--- a/.skills/tinker-review/scripts/review.js
+++ b/.skills/tinker-review/scripts/review.js
@@ -42,6 +42,8 @@ import { orphans } from "./patterns/orphans.js";
 import { buildCodeIndex } from "./extraction/code-index.js";
 import { createPrDiscussion } from "./enrichment/api.js";
 import { discoverDiscussions } from "./discovery/discussions.js";
+import { requireBeads, syncBeads, bdRunner, beadSeeds, discoverBeads } from 
"./discovery/beads.js";
+import { populateBeads } from "./graph/populate-beads.js";
 
 const exec = promisify(execFile);
 
@@ -278,6 +280,13 @@ async function detectBaseBranch(pr) {
 
 export async function setup(params) {
   const { pr, repoPath, options = {} } = params;
+
+  // Beads is required: fail before fetching anything. Then pull the latest 
state;
+  // a failed pull leaves the local database as it was, and the report says so.
+  await requireBeads(repoPath);
+  const beadsSync = await syncBeads(repoPath);
+  log(beadsSync.synced ? "Beads synced (bd dolt pull)" : `Beads sync failed — 
using local state: ${beadsSync.syncError}`);
+
   const remote = options.remote || await detectRemote(repoPath);
   const baseBranch = options.baseBranch || await detectBaseBranch(pr);
   const prBranch = `pr-review/${pr}`;
@@ -293,6 +302,12 @@ export async function setup(params) {
   await exec("git", ["worktree", "add", worktreePath, prBranch], { cwd: 
repoPath });
 
   const changedFiles = await getChangedFiles(repoPath, prBranch, remote, 
baseBranch);
+  // No diff against the base usually means the PR has already merged. 
Extraction
+  // treats an empty change list as "parse the whole repo", so stop here 
instead.
+  if (changedFiles.length === 0) {
+    await cleanupWorktree(repoPath, worktreePath, prBranch);
+    throw new Error(`PR #${pr} has no changes against ${remote}/${baseBranch}; 
it has probably already been merged. tinker-review reviews open PRs.`);
+  }
   const languages = detectLanguages(changedFiles);
   const language = languages[0];
   const domains = classifyDomains(changedFiles);
@@ -330,6 +345,7 @@ export async function setup(params) {
     language,
     languages,
     domains,
+    beadsSync,
     handle,
     connection,
     aConnection,
@@ -406,6 +422,13 @@ export async function phase1(session) {
   log(`Loading discussions into graph...`);
   await populateDiscussions(g, discussions, { pr, prTitle: prTitle.trim(), 
changedFiles });
 
+  log(`Discovering beads...`);
+  const prRef = `apache/tinkerpop#${pr}`;
+  const beads = await discoverBeads({ seeds: beadSeeds(pr, discussions), 
prRef, run: bdRunner(repoPath) });
+  discussions.beads = { ...(session.beadsSync || { synced: false, syncError: 
null }), ...beads };
+  log(`  beads: ${beads.matches.length} records matched, ${beads.roots.length} 
roots (${beads.roots.map((r) => `${r.id}:${r.role}`).join(", ") || "none"}), 
${beads.beads.length} beads`);
+  await populateBeads(g, beads);
+
   log(`Classifying external callees...`);
   const externalsResult = await classifyExternals(g, worktreePath);
   log(`  externals: ${externalsResult.library.length} library / 
${externalsResult.project.length} project / 
${externalsResult.unresolved.length} unresolved`);
@@ -449,6 +472,7 @@ export async function phase1(session) {
   const evidence = {
     meta: {
       pr,
+      baseBranch,
       headSha: headShaOut.trim() || null,
       title: prTitle.trim(),
       domains,
@@ -548,7 +572,10 @@ if (process.argv[1] && basename(process.argv[1]) === 
"review.js") {
   }
   const repoPath = process.argv[3] || process.cwd();
 
-  const session = await setup({ pr, repoPath });
+  const session = await setup({ pr, repoPath }).catch((err) => {
+    console.error(err.message);
+    process.exit(1);
+  });
   try {
     const { jsonPath } = await phase1(session);
     log(`Server running on port ${session.handle.port} — ready for 
enrichment.`);
diff --git a/.skills/tinker-review/test/beads.test.js 
b/.skills/tinker-review/test/beads.test.js
new file mode 100644
index 0000000000..7e0dbae873
--- /dev/null
+++ b/.skills/tinker-review/test/beads.test.js
@@ -0,0 +1,281 @@
+/*
+ * 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.
+ */
+
+// Beads discovery against a fake bd — record matching, root roles, subtree
+// loading, the beads gate and the sync fallback — and the Project Memory
+// section the renderer builds from it.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import {
+  BEADS_REQUIRED_MESSAGE, beadSeeds, discoverBeads, requireBeads, syncBeads,
+} from "../scripts/discovery/beads.js";
+import { render } from "../scripts/renderer/render.js";
+
+// A small beads database shaped like PRIME.md section 6. Each entry is the row
+// bd list --json returns; `deps` are outgoing dependencies as [target, type].
+function bead(id, type, { parent = null, ref = null, rejected, deps = [], 
comments = 0, title = id } = {}) {
+  return {
+    id, issue_type: type, status: type === "decision" ? "closed" : "open", 
title,
+    description: "", design: `design of ${id}`, labels: ["gremlin-core"], 
parent,
+    external_ref: ref, closed_at: null, comment_count: comments,
+    metadata: rejected === undefined ? null : { rejected },
+    dependencies: [
+      ...(parent ? [{ depends_on_id: parent, type: "parent-child" }] : []),
+      ...deps.map(([to, t]) => ({ depends_on_id: to, type: t })),
+    ],
+  };
+}
+
+const DB = [
+  bead("tp-a", "feature", { comments: 2 }),
+  bead("tp-a.1", "record", { parent: "tp-a", ref: "TINKERPOP-1234", title: "PR 
and JIRA" }),
+  bead("tp-a.2", "record", { parent: "tp-a", ref: 
"https://lists.apache.org/thread/abc";, title: "dev@ thread: topic" }),
+  bead("tp-a.3", "decision", { parent: "tp-a", rejected: false, deps: 
[["tp-a.4", "related"]], comments: 1 }),
+  bead("tp-a.4", "decision", { parent: "tp-a", rejected: true }),
+  bead("tp-a.5", "task", { parent: "tp-a", deps: [["tp-a.3", "caused-by"]] }),
+  bead("tp-a.5.1", "task", { parent: "tp-a.5" }),
+  // A second root that shares tp-a's JIRA record instead of duplicating it.
+  bead("tp-b", "epic", { deps: [["tp-a.1", "related"]] }),
+  bead("tp-b.1", "record", { parent: "tp-b", ref: "apache/tinkerpop#99" }),
+  bead("tp-b.2", "decision", { parent: "tp-b", rejected: false }),
+  // Unrelated work that relates to a bead inside tp-a: reachable by bd dep 
tree
+  // from tp-a, but not part of tp-a's subtree.
+  bead("tp-c", "task", { deps: [["tp-a.3", "related"]] }),
+  // Substring trap for --external-contains.
+  bead("tp-d", "feature"),
+  bead("tp-d.1", "record", { parent: "tp-d", ref: "TINKERPOP-12345" }),
+];
+
+const COMMENTS = {
+  "tp-a": [{ author: "a", text: "a fact", created_at: "2026-01-01" }, { 
author: "b", text: "another", created_at: "2026-01-02" }],
+  "tp-a.3": [{ author: "a", text: "later context", created_at: "2026-01-03" }],
+};
+
+const byId = new Map(DB.map((b) => [b.id, b]));
+const dependentsOf = (id) => DB.filter((b) => b.dependencies.some((d) => 
d.depends_on_id === id));
+
+// Emulates the bd commands discovery uses, including the quirks it relies on:
+// search's positional query is a title/ID match, and --external-contains is a
+// case-insensitive substring match.
+function fakeBd(calls = []) {
+  return async (args) => {
+    calls.push(args);
+    const opt = (name) => args[args.indexOf(name) + 1];
+    switch (args[0]) {
+      case "list":
+        if (args.includes("--id")) return opt("--id").split(",").map((id) => 
byId.get(id)).filter(Boolean);
+        return DB.slice(0, Number(opt("-n")) || DB.length);
+      case "search": {
+        const q = args[1].toLowerCase();
+        const ext = opt("--external-contains").toLowerCase();
+        return DB.filter((b) => b.issue_type === opt("--type")
+          && (b.id.toLowerCase().startsWith(q) || 
b.title.toLowerCase().includes(q))
+          && (b.external_ref || "").toLowerCase().includes(ext));
+      }
+      case "dep":
+        if (args[1] === "list") {
+          return dependentsOf(args[2]).filter((b) => b.dependencies.some((d) 
=> d.depends_on_id === args[2] && d.type === opt("-t")));
+        }
+        if (args[1] === "tree") {
+          const seen = new Set([args[2]]);
+          const out = [byId.get(args[2])];
+          for (let i = 0; i < out.length; i++) {
+            for (const d of dependentsOf(out[i].id)) if (!seen.has(d.id)) { 
seen.add(d.id); out.push(d); }
+          }
+          return out.map(({ id, issue_type, title }) => ({ id, issue_type, 
title }));
+        }
+        break;
+      case "comments":
+        return COMMENTS[args[1]] || [];
+    }
+    throw new Error(`fake bd: unhandled ${args.join(" ")}`);
+  };
+}
+
+test("seeds are the PR plus only the artifacts the PR names explicitly", () => 
{
+  const seeds = beadSeeds(42, {
+    jiras: [{ id: "TINKERPOP-1234" }],
+    devList: [
+      { url: "https://lists.apache.org/thread/abc).", found_in: "pr" },
+      { url: "https://lists.apache.org/thread/searched";, found_in: "search" },
+    ],
+    proposals: [
+      { path: "docs/src/dev/future/proposal-x.asciidoc", matchedIn: 
"reference" },
+      { path: "docs/src/dev/future/proposal-y.asciidoc", matchedIn: "title" },
+    ],
+  });
+  assert.deepEqual(seeds, [
+    "apache/tinkerpop#42",
+    "TINKERPOP-1234",
+    "https://lists.apache.org/thread/abc";,
+    "docs/src/dev/future/proposal-x.asciidoc",
+  ]);
+});
+
+test("a record matches only on an exact external ref", async () => {
+  const r = await discoverBeads({ seeds: ["TINKERPOP-1234"], prRef: 
"apache/tinkerpop#1", run: fakeBd() });
+  assert.deepEqual(r.matches, [{ seed: "TINKERPOP-1234", record: "tp-a.1" }], 
"TINKERPOP-12345 is not a match");
+});
+
+test("a record whose title does not repeat its ref is still found", async () 
=> {
+  const r = await discoverBeads({ seeds: 
["https://lists.apache.org/thread/abc";], run: fakeBd() });
+  assert.deepEqual(r.matches.map((m) => m.record), ["tp-a.2"]);
+});
+
+test("the owning root and every sharing root are loaded; the PR's own root is 
primary", async () => {
+  const r = await discoverBeads({
+    seeds: ["apache/tinkerpop#99", "TINKERPOP-1234"], prRef: 
"apache/tinkerpop#99", run: fakeBd(),
+  });
+  assert.deepEqual(r.roots, [
+    { id: "tp-b", role: "primary", via: ["tp-b.1", "tp-a.1"] },
+    { id: "tp-a", role: "owner", via: ["tp-a.1"] },
+  ]);
+});
+
+test("a shared record leads to the sharing root as shared", async () => {
+  const r = await discoverBeads({ seeds: ["TINKERPOP-1234"], prRef: 
"apache/tinkerpop#7", run: fakeBd() });
+  assert.deepEqual(r.roots.map(({ id, role }) => ({ id, role })), [
+    { id: "tp-a", role: "owner" },
+    { id: "tp-b", role: "shared" },
+  ]);
+});
+
+test("the subtree includes grandchildren and excludes beads that only relate 
to it", async () => {
+  const r = await discoverBeads({ seeds: 
["https://lists.apache.org/thread/abc";], run: fakeBd() });
+  const ids = r.beads.map((b) => b.id).sort();
+  assert.deepEqual(ids, ["tp-a", "tp-a.1", "tp-a.2", "tp-a.3", "tp-a.4", 
"tp-a.5", "tp-a.5.1"]);
+  assert.ok(r.beads.every((b) => b.root === "tp-a"));
+});
+
+test("beads carry the rejected flag, external ref and non-tree edges", async 
() => {
+  const r = await discoverBeads({ seeds: 
["https://lists.apache.org/thread/abc";], run: fakeBd() });
+  const get = (id) => r.beads.find((b) => b.id === id);
+  assert.equal(get("tp-a.3").rejected, false);
+  assert.equal(get("tp-a.4").rejected, true);
+  assert.equal(get("tp-a.5").rejected, null, "only decisions have a rejected 
flag");
+  assert.equal(get("tp-a.1").externalRef, "TINKERPOP-1234");
+  assert.deepEqual(get("tp-a.3").edges, [{ to: "tp-a.4", type: "related" }]);
+  assert.deepEqual(get("tp-a.5").edges, [{ to: "tp-a.3", type: "caused-by" }]);
+  assert.equal(get("tp-a.5.1").parent, "tp-a.5");
+});
+
+test("comments are read only for roots and decisions that have them", async () 
=> {
+  const calls = [];
+  const r = await discoverBeads({ seeds: 
["https://lists.apache.org/thread/abc";], run: fakeBd(calls) });
+  assert.deepEqual(Object.keys(r.comments).sort(), ["tp-a", "tp-a.3"]);
+  assert.equal(r.comments["tp-a.3"][0].text, "later context");
+  assert.deepEqual(calls.filter((c) => c[0] === "comments").map((c) => 
c[1]).sort(), ["tp-a", "tp-a.3"]);
+});
+
+test("no matching record means no roots and no beads", async () => {
+  const r = await discoverBeads({ seeds: ["apache/tinkerpop#5", 
"TINKERPOP-9"], run: fakeBd() });
+  assert.deepEqual([r.matches, r.roots, r.beads], [[], [], []]);
+});
+
+test("requireBeads fails early with the install message when bd is missing", 
async () => {
+  const missing = async () => { throw Object.assign(new Error("spawn bd 
ENOENT"), { code: "ENOENT" }); };
+  await assert.rejects(requireBeads("/repo", missing), (err) => {
+    assert.ok(err.message.startsWith(BEADS_REQUIRED_MESSAGE));
+    assert.match(err.message, /not found on the PATH/);
+    return true;
+  });
+});
+
+test("requireBeads passes when bd answers", async () => {
+  await requireBeads("/repo", fakeBd());
+});
+
+test("a failed pull falls back to local state instead of failing", async () => 
{
+  const offline = async () => { throw Object.assign(new Error("pull failed"), 
{ stderr: "remote unreachable" }); };
+  assert.deepEqual(await syncBeads("/repo", offline), { synced: false, 
syncError: "remote unreachable" });
+  assert.deepEqual(await syncBeads("/repo", async () => {}), { synced: true, 
syncError: null });
+});
+
+// ---- Project Memory rendering ----
+
+
+const meta = { pr: 99, headSha: "abc", title: "t", domains: [], timestamp: 
"now" };
+
+function shaped(id, type, extra = {}) {
+  return {
+    id, type, status: "closed", title: `title ${id}`, description: "", design: 
`why ${id}`,
+    rejected: type === "decision" ? false : null, labels: [], parent: null, 
externalRef: null,
+    closedAt: null, root: "tp-a", edges: [], ...extra,
+  };
+}
+
+const found = {
+  synced: true, syncError: null,
+  seeds: ["apache/tinkerpop#99"],
+  matches: [{ seed: "apache/tinkerpop#99", record: "tp-a.1" }],
+  roots: [{ id: "tp-a", role: "primary", via: ["tp-a.1"] }],
+  beads: [
+    shaped("tp-a", "feature", { status: "open", labels: ["gremlin-core", 
"3.7.7"] }),
+    shaped("tp-a.1", "record", { parent: "tp-a", externalRef: 
"apache/tinkerpop#99" }),
+    shaped("tp-a.2", "decision", { parent: "tp-a", edges: [{ to: "tp-a.3", 
type: "related" }] }),
+    shaped("tp-a.3", "decision", { parent: "tp-a", rejected: true }),
+    shaped("tp-a.4", "decision", { parent: "tp-a", edges: [{ to: "tp-a.2", 
type: "supersedes" }] }),
+    shaped("tp-a.5", "task", { parent: "tp-a", status: "in_progress" }),
+    shaped("tp-a.6", "task", { parent: "tp-a", status: "open" }),
+    shaped("tp-a.7", "task", { parent: "tp-a", status: "open", labels: 
["human"] }),
+  ],
+  comments: { "tp-a": [{ author: "a", text: "a fact on the root", createdAt: 
"x" }] },
+};
+
+test("Project Memory shows the root, its records, plan and decisions with 
alternatives nested", () => {
+  const html = render({ meta, discussions: { beads: found } });
+  assert.match(html, /This PR's work/);
+  assert.match(html, /id="bead-tp-a"/);
+  assert.match(html, 
/href="https:\/\/github.com\/apache\/tinkerpop\/pull\/99">apache\/tinkerpop#99<\/a>
 <em>\(matched\)<\/em>/);
+  assert.match(html, /Plan:<\/strong> 1 in progress, 2 open, 0 done/);
+  assert.match(html, /Unanswered questions:[\s\S]*tp-a\.7/);
+  assert.match(html, /Decisions: 2 chosen, 1 not taken/);
+  assert.match(html, /Chose<\/strong> title tp-a\.2[\s\S]*superseded 
by[\s\S]*bead-alternatives[\s\S]*Instead of<\/span> title tp-a\.3/);
+  assert.match(html, /a fact on the root/);
+  assert.match(html, /<h3>Beads<\/h3>/, "the appendix lists the loaded beads");
+  for (const b of found.beads) {
+    assert.match(html, new RegExp(`id="bead-${b.id.replace(/\./g, "\\.")}"`), 
`appendix link target for ${b.id}`);
+  }
+});
+
+test("Project Memory says the data wasn't available when nothing matched, and 
when the sync failed", () => {
+  const html = render({
+    meta,
+    discussions: { beads: { synced: false, syncError: "remote unreachable", 
seeds: ["apache/tinkerpop#99"], matches: [], roots: [], beads: [], comments: {} 
} },
+  });
+  assert.match(html, /beads data wasn't available for this change/);
+  assert.match(html, /Searched record beads for: 
<code>apache\/tinkerpop#99<\/code>/);
+  assert.match(html, /latest beads data wasn't available \(<code>bd dolt 
pull<\/code> failed: remote unreachable\)/);
+  assert.doesNotMatch(html, /<h3>Beads<\/h3>/);
+});
+
+test("bead refs link into Project Memory and an unknown bead is reported", () 
=> {
+  const warnings = [];
+  const html = render({
+    meta,
+    discussions: { beads: found },
+    findings: [{ title: "f", body: "<p>b</p>", refs: [{ bead: "tp-a.3", label: 
"rejected option" }, { bead: "tp-zzz" }] }],
+  }, { warnings });
+  assert.match(html, /<a class="code-ref bead-ref" 
href="#bead-tp-a\.3"[^>]*>rejected option<\/a>/);
+  assert.match(html, /code-ref-unresolved[^>]*>tp-zzz</);
+  assert.deepEqual(warnings.length, 1);
+  assert.match(warnings[0], /findings\[0\]\.refs: unresolved: no bead 
"tp-zzz"/);
+});

Reply via email to