Skip to content

✅ fresh

last synced 2026-09-29T21:12:19.681221+00:00 · coverage 100% (cli)

Validation by Beadloom doc_sync — same source as sync-check.

CLI Reference ​

Beadloom CLI is built on Click and provides a set of commands for managing the knowledge index.

What checks that this page is complete: nothing. Two instruments count the CLI surface and neither compares its count against this document. doctor's agent_instructions_cli_commands leg reports the registered names at OK severity unconditionally (application/doctor.py:_get_actual_cli_commands), and docs audit lists cli_command_count among the facts it declares and marks NOT VERIFIED, because no document states it. The two also count different populations -- doctor takes the top-level names off the Click group, while the audit walks nested groups and counts leaves and groups together -- so they answer with different numbers under one name, and no check reads either against this file.

The consequence is measured, not hypothetical: an S6 review derived the registered names and compared each against a ### beadloom <cmd> heading here, and found beadloom issue-number documented nowhere on this page four days after it shipped. The peer command of the same slice, beadloom clean-room, was documented in the commit that shipped it. A page kept current by convention drifts wherever the convention is missed, and the drift is invisible until somebody derives the comparison by hand.

Specification ​

Global Options ​

beadloom [--verbose|-v] [--quiet|-q] [--version] COMMAND
  • --verbose / -v -- verbose output
  • --quiet / -q -- errors only
  • --version -- show version

beadloom init ​

Project initialization. Four entry points, and three modes for the two that let you choose one:

bash
# Generate graph from code structure (auto-detects architecture)
beadloom init --bootstrap [--preset {monolith,microservices,monorepo}] [--project DIR]

# Import existing documentation
beadloom init --import DOCS_DIR [--project DIR]

# Non-interactive mode (for CI/scripting)
beadloom init --yes [--mode {bootstrap,import,both}] [--force] [--project DIR]

# Interactive mode (default when no flags given)
beadloom init [--project DIR]

--bootstrap scans source directories (src, lib, app, services, packages), classifies subdirectories using architecture-aware preset rules, infers edges from directory nesting, and generates .beadloom/_graph/services.yml + .beadloom/config.yml.

--preset selects an architecture preset:

  • monolith -- top dirs are domains; subdirs map to features, entities, services
  • microservices -- top dirs are services; shared code becomes domains
  • monorepo -- packages/apps are services; manifest deps become edges

When --preset is omitted, Beadloom auto-detects: services/ or cmd/ -> microservices, packages/ or apps/ -> monorepo, otherwise -> monolith.

--import classifies .md files (ADR, feature, architecture, other) and generates .beadloom/_graph/imported.yml.

--yes / -y enables non-interactive mode: no prompts, uses defaults. Combined with --mode to select the initialization strategy:

  • bootstrap (default) -- generate graph from code
  • import -- classify existing docs
  • both -- bootstrap graph and import docs

--force overwrites an existing .beadloom/ directory. Without it, non-interactive init skips if .beadloom/ already exists.

Exit codes ​

0 = a scaffold that passes the rules written beside it. 1 = a scaffold that does not.

init takes a verdict on the graph it wrote. Every entry point that writes a file under .beadloom/_graph/ — --yes in any mode, --bootstrap, --import, and the interactive wizard — re-indexes and then runs the Gate's own lint step (application.gate.lint_step, the same object beadloom ci runs) over the project. When that step does not pass, init withdraws the completion it has already printed, reports the failure on stderr and exits 1. The scaffold is left on disk either way: the exit code reports the state, it does not withdraw the graph. The non-zero code is what makes a scripted init && ci stop while the cause is still in view.

Before BDL-067 there was no verdict. A virgin beadloom init --yes --mode bootstrap printed Graph: 2 nodes, 0 edges, exited 0, and the adopter's next command — beadloom ci — was red on domain-needs-parent, a rule that same run had written one step earlier (BDL-UX #192).

Two conditions decide whether a verdict is taken at all, and both are asked of the tree rather than of the branch reporting:

  • Nothing under .beadloom/_graph/ changed during the run — no verdict. This is what keeps the wizard's re-init prompt, which is put before any writer runs, from reporting an existing tree's failures under a line saying a scaffold was written.
  • The wizard's edit review answer — no verdict, deliberately. The wizard has just handed the graph over to be edited by hand and told you to run beadloom reindex afterwards, so there is nothing settled to judge.

Two report shapes, because a rule that failed and a rules file that would not load are not the same news:

  1. Rules were evaluated and the graph fails them. One line per error-severity rule, naming the rule, the node, and the graph file that node was written into. Under it, one sentence saying whose the failure is, chosen from two facts about the tree: did this run write the failing node, and did this run write rules.yml. Only the corner where both are this run's calls the result a defect in Beadloom's bootstrap and asks for a bug report. The other three name what was already in .beadloom/_graph/ and ask for nothing.
  2. .beadloom/_graph/rules.yml could not be read. The loader's complaint is printed instead of a rule name, and the report states that no rule was evaluated, so the graph is unchecked rather than wrong. init leaves an existing rules file alone, so this is usually a hand edit.

The closing line names the step beadloom ci will fail by the step's name and summary rather than by quoting a rendered line: ci renders with rich on a TTY and with the github renderer everywhere else, and the two print different text for the same failure — which is exactly the scripted context --yes serves.

Known limitation — a graph file init cannot read still ends in a traceback (BDL-UX #220, open). The readers under onboarding/ share one skip policy, but the readers init reaches in other domains do not: application/reindex/indexing.py's read_declared_docs and graph/loader.py walk .beadloom/_graph/ with their own answers. Measured over init's eight (entry point x mode) cells crossed with three shapes of a hand-edited .beadloom/_graph/legacy.yml — a file that does not parse, a file whose top level is a list, and a file carrying an unquoted date (added: 2026-09-02) — 24 runs, of which the 15 that reach the file end in a Python traceback: --bootstrap, --import and all three wizard modes, on every shape. --yes reaches none of them, and not because a guard works: non-interactive init returns skipped when .beadloom/ already exists, and --force deletes the directory, and the unreadable file with it, before writing.

Projects without a docs/ directory work fine -- Beadloom operates in zero-doc mode with code-only context (graph nodes, annotations, context oracle).

--bootstrap also appends an ignore block to the project's .gitignore, once (BDL-061.35). Before it, Beadloom wrote an ignore entry nowhere, so an adopter collected untracked churn from the very first reindex. The block names the derived state only — .beadloom/**/*.db{,-wal,-shm} and .beadloom/guard-firings*.jsonl, a glob so the rotated generation is ignored beside the active one — and each pattern carries its reason in the file; the graph under .beadloom/_graph/ and flow.yml are source and stay committable. It is written once and never rewritten: a run that finds the marker does nothing, so deleting a line is a real override rather than an edit the next run undoes. The firing-record entry states what a team would be committing before it invites them to: one line per guarded edit carrying the verdict, the file an edit named, and for a shell edit the program that ran and the files it was seen to write — never the command line, which since beadloom-0mdo.43 is reduced at the door the context is built at and reaches no record. The sentence was written when the record held paths only, and binding the shell tool (BDL-UX #170) made it hold command lines; following the old invitation would have put an agent's shell history into git. Because the block is never rewritten, a project that already carries it keeps the older wording — the entry a beadloom init from before this change wrote is not revisited, and neither are the records already on disk (since beadloom-0mdo.40 the PATTERNS are compared — config-check reports at warn every pattern this version emits that the file does not declare, and names the declared line a wider pattern supersedes — but the why text is not compared, so the older wording stays invisible). Nothing is written outside a git working tree, a pattern the project already declares is not duplicated, and the project's own lines are untouched. The write is reported (✓ Ignored: N generated path(s) …), because silently editing someone's .gitignore is its own surprise.

beadloom reindex ​

Full reindex: drops all tables and reloads from scratch.

bash
beadloom reindex [--full] [--docs-dir DIR] [--project DIR]
  • --full -- force full rebuild (drop all tables and re-create)
  • --docs-dir -- documentation directory (default: from config.yml or docs/)

Default mode is incremental (only changed files). Use --full to force complete rebuild.

Order: drop tables -> create schema -> load graph YAML -> index docs -> index code -> resolve imports -> load rules -> build sync state -> populate FTS5 -> take health snapshot.

When no changes are detected, displays current DB totals (nodes, edges, docs, symbols) instead of reindex counts. Warns about missing tree-sitter parsers when symbols == 0.

On both branches the output ends its totals with a Tests: line (BDL-074 C1), read from the test_files table: how many test files are indexed, how many bind to a node — with (B beside the code) after the count when any test inside a node's source is bound (BDL-074 G2) — and how many are unplaced, reached by none of the ways a test binds, plus the unowned count when non-zero and, since BDL-074 F1, each recorded kind of the files a kind folder places, by its own count (acceptance step, self-check), where the line used to fold both into "bound by other means". On this repository, measured by beadloom reindex on 2026-09-28 at 909a0098: Tests: 620 files (275 bound to a node, 167 unplaced, 75 acceptance step, 103 self-check). Which files are tests, and where they are looked for, is the test layout of the tests: block in .beadloom/config.yml; see Configuration. A key of that block that cannot be used, and a node's tests: prefix that covers no indexed test file, are printed as warnings: Node 'billing': `tests:` prefix 'tests/e2e' covers no indexed test file, so it binds nothing (a folder is declared with a trailing '/'). An index built before the test tables prints no such line. A change to a test file alone is no longer reported as "no changes". See the Test Mapping SPEC.

The incremental path re-extracts imports for the code files it touched, deletes the imports of files that disappeared, and rebuilds the derived depends_on edge set (marked extra.derived='imports', so a graph-declared edge is never collateral damage). A boundary violation introduced between two incremental runs is therefore caught by lint without a full rebuild. Two counters in the summary do not describe that work: Imports: and Rules: are only populated on the --full path and print 0 on an incremental run that did refresh them.

Reindex sets the freshness baseline. sync_state is (re-)established from the tree being indexed, so a reindex into a fresh or deleted database makes every declared pair fresh by construction. That is why doc freshness must be checked after an incremental reindex on an existing index — see beadloom sync-check.

beadloom ctx ​

Get a context bundle for the specified ref_id(s).

bash
beadloom ctx REF_ID [REF_ID...] [--json|--markdown] [--depth N] [--max-nodes N] [--max-chunks N] [--intent|--no-intent] [--project DIR]

Outputs Markdown by default. --json for machine-readable format.

The Markdown Tests: line counts the test files BOUND to the node by the test binding. Its framework is named from the patterns the node's bound files matched (pytest, go_test, jest, junit, xctest). When any of the project's test files is unplaced — reached by no mirror, no place beside the code and no tests: declaration, so bound to no node — one more line follows it (BDL-074 C2), so a count of 0 does not read as "nobody tested this". The folders it names are those of the test layout the index recorded (BDL-074 G2), and , nor inside a node's source follows them when tests beside the code are read. A last line follows every time (beadloom-2mj3.15): which paths a test file is read under, the patterns of each framework group and the roots, because a file outside every root, test tree and node source is not read at all and a count of bound files is a count of the files those patterns matched. On this repository, measured on 2026-09-28 at 909a0098:

Tests: pytest, 100 tests in 10 files (high coverage)
  167 of 620 test file(s) are unplaced (not under tests/integration/ or tests/unit/) and bind to no node, so the count above can be short
  A test file is read when its path matches a pattern of pytest (test_*.py, *_test.py) under the root tests

--json carries the same counts as test_placements, test files by placement, the sentence itself as test_unplaced (null when no file is unplaced) and the last line's clause as test_recognition (null for an index with no recorded test layout).

The bundle carries an Intent (TO-BE) section: the epics whose planning documents declared this node, with the document and line to read the reason at. So the one command an agent is told to run before touching an area answers what the code IS and what it is FOR, rather than only the first.

It is on by default because a flag nobody passes protects nothing, and the cost is small and measured: on this repository the section adds about 330 bytes to a 157 KB bundle, and reading the whole TO-BE space costs 25 ms on a cold bundle and nothing on a cached one, since an edited planning document is now one of the inputs the bundle cache is invalidated by. --no-intent skips the read; the bundle then reports not_checked, which is not the same statement as no epic declares this node.

A node no epic declared — 69 of this repository's 84 — prints the size of what was searched instead of nothing:

## Intent (TO-BE)

No epic declares this node. 61 epic(s) read, 5 of them declare a node.

beadloom graph ​

Architecture graph visualization. Supports Mermaid, C4-Mermaid, and C4-PlantUML output formats.

bash
# Full graph in Mermaid format (default)
beadloom graph [--project DIR]

# Subgraph from specified nodes
beadloom graph REF_ID [REF_ID...] [--depth N] [--json]

# C4 architecture diagram (Mermaid C4 syntax)
beadloom graph --format c4 [--level {context,container,component}] [--project DIR]

# C4 architecture diagram (PlantUML C4 syntax)
beadloom graph --format c4-plantuml [--level container] [--project DIR]

# C4 component diagram scoped to a specific container
beadloom graph --format c4 --level component --scope graph [--project DIR]
  • --format -- output format: mermaid (default), c4 (Mermaid C4 syntax), or c4-plantuml (C4-PlantUML syntax).
  • --level -- C4 diagram level (only used with --format=c4 or --format=c4-plantuml):
    • context -- System-level nodes only (highest abstraction)
    • container (default) -- System and Container nodes
    • component -- Children of a specific container (requires --scope)
  • --scope -- ref_id of the container to zoom into when --level=component. Required for component-level diagrams.

C4 level assignment uses part_of depth: root nodes become Systems, depth 1 becomes Containers, depth 2+ becomes Components. Nodes can override this by setting c4_level in their YAML extra field. Nodes tagged external render as _Ext variants; nodes tagged database or storage render as Db variants.

beadloom status ​

Index statistics with health trends.

bash
beadloom status [--json] [--project DIR]

Shows Rich-formatted dashboard with: node count (broken down by kind), edges, documents, symbols, per-kind documentation coverage, stale docs, isolated nodes, empty summaries. Includes trend indicators comparing current reindex with previous snapshot. Also displays context metrics: average bundle token size, largest bundle (ref_id + tokens), total indexed symbols.

--json -- structured JSON output.

status --debt-report ​

Architecture debt report mode. Aggregates health signals from lint, sync-check, doctor, git activity, and the test binding into a single 0-100 debt score with category breakdown and top offending nodes.

bash
beadloom status --debt-report [--json] [--fail-if=EXPR] [--category=NAME] [--project DIR]
  • --debt-report -- show architecture debt report instead of the standard status dashboard.
  • --json -- output the debt report as structured JSON (with --debt-report).
  • --fail-if=EXPR -- CI gate: exit 1 if condition is met. Requires --debt-report. Supported expressions:
    • score>N -- fail if overall debt score exceeds N.
    • errors>N -- fail if rule violation error count exceeds N.
  • --category=NAME -- filter the debt report to a single category. Accepted names: rules, docs, complexity, tests (short names) or rule_violations, doc_gaps, test_gaps (internal names).

The debt score formula combines four categories:

  • Rule Violations -- weighted count of lint rule errors and warnings.
  • Documentation Gaps -- undocumented nodes, stale docs, untracked files.
  • Complexity -- oversized domains (by symbol count), high fan-out nodes, dormant domains.
  • Test Gaps -- nodes the test binding covers (those carrying extra["tests"]) with no bound test file. While any test file is unplaced the count is withheld (0), because an unplaced file binds to no node and a node with no bound test may still be tested by it (BDL-074 C2). The Rich report prints the reason under Test Gaps, and --json carries it as test_population: either what the count was taken over or not counted: ... with the unplaced share. Whenever the index recorded a test layout, the population ends with what a test file is read by (BDL-074 G2; every time since beadloom-2mj3.15): under the default layout a test file is read when its path matches a pattern of go_test (*_test.go), jest (...), junit (...), pytest (test_*.py, *_test.py) or xctest (...) under ..., where each (...) names that group's patterns and ... names the roots and test trees that exist, followed by or beside a node's code. With none of them, and tests beside the code read, the clause ends and it lies beside a node's code, since none of the roots tests, test, spec, __tests__ exists (beadloom-2mj3.17, reworded by beadloom-2mj3.19).

Severity classification: clean (0), low (1-10), medium (11-25), high (26-50), critical (51-100).

Examples:

bash
# Human-readable Rich output
beadloom status --debt-report

# Machine-readable JSON
beadloom status --debt-report --json

# CI gate: fail if score exceeds 30
beadloom status --debt-report --fail-if=score>30

# CI gate: fail if any lint errors
beadloom status --debt-report --fail-if=errors>0

# Filter to documentation gaps only
beadloom status --debt-report --category=docs

beadloom doctor ​

Architecture graph validation.

bash
beadloom doctor [--project DIR]

Checks:

  • Nodes with empty summary
  • Documents not linked to nodes
  • Nodes without documentation
  • Isolated nodes (no edges)

beadloom sync-check ​

Check doc-code synchronization.

bash
beadloom sync-check [--porcelain] [--json] [--report] [--ref REF_ID] [--staged]
                    [--since GIT_REF] [--record-surface] [--project DIR]

Exit codes: 0 = all OK, 1 = error, 2 = a pair is stale or missing.

  • --porcelain -- TAB-separated output for scripts. Format: status\tref_id\tdoc_path\tcode_path\treason.
  • --json -- structured JSON output with summary and pair details. Each pair includes status, ref_id, doc_path, code_path, reason, baseline, and optional details.
  • --report -- ready-to-post Markdown report for CI (GitHub/GitLab). Its closing instruction is chosen per reason: the sync-update line appears only when some stale pair is on a reason re-attesting clears, and every other stale pair is listed with what does clear it. Under --since no pair is told to re-attest, because that mode compares against the code at a git ref, which an attestation does not write — measured, attesting every pair left sync-check --since HEAD exactly as stale.
  • --ref -- filter results by ref_id.
  • --staged -- judge only the pairs this commit stages either side of, and state how many were left to the push gate. For a pre-commit hook in a shared working tree, where a whole-tree check fails one agent's commit on a neighbour's in-progress file (BDL-UX #118). The narrowing is counted, never silent: summary.not_checked_outside_commit and summary.commit_scope in --json (present in this mode only), a scope record in --porcelain, and a leading line in the human shape. When git cannot say what is staged, nothing is narrowed and commit_scope reads not_narrowed -- an absent answer is not "nothing staged". The content compared is the WORKING-TREE content of the staged paths, not the staged blobs.
  • --record-surface -- record the declared documentation surface (pair + declared-doc counts) to the committed .beadloom/sync-surface.json. A later run compares against it and says so when the surface SHRANK; no ordinary run rewrites it, because a check that silently re-records the number it checks against re-attests without evidence.
  • --since GIT_REF -- compute drift against the code state at a git ref (e.g. the push's parent commit) instead of the stored sync_state baseline. Reports pairs whose code drifted since the ref while the doc was not correspondingly updated. This makes drift detection work on a fresh CI checkout: a clean clone reindexes from scratch and re-baselines sync_state to the just-pushed code, so without a ref baseline sync-check sees 0 stale even when the push left a doc behind. Mirrors beadloom diff --since. Used by the AI tech-writer harness (it passes the push parent — github.event.before / $CI_COMMIT_BEFORE_SHA, falling back to HEAD~1).

What a green count covers. A node that declares docs: contributes pairs from its # beadloom: annotations or, when those yield none, from the files its source: owns — the pairing is independent of node kind. Whatever is still uncovered is listed BY NAME with a reason, as an advisory line that never changes the exit code: no_indexed_code (no indexed code under the node's source), files_owned_by_nested_nodes (every file under it belongs to a more specific node) and no_source (the node declares no source path). --json carries the same list in data.unchecked with summary.unchecked; --porcelain prints one unchecked line per unchecked doc.

Measured on this repository, 2026-08-24: 330 declared pairs, all of them checked and 0 listed as unchecked.

Six verdicts, because unverifiable is not clean. ok and stale are outcomes of a comparison that happened. missing (the doc file, the code file, or a doc the graph DECLARES is not on disk) fails the check at exit 2 — the gate is not satisfied by having less to check. unverified (reason=no_baseline) means nothing could be compared; it is printed as [not verified], counted separately, and never counted as fresh. incomplete (missing_sections, section_not_in_use, BDL-061 S4) is the only verdict about a document's STRUCTURE rather than its currency: the four content reasons all measure bytes changing, so none of them can see a README edited down to a title. It never blocks and is never written to sync_state. exempt (working_space, BDL-061 S5) means the document is in the WORKING space and a project's config declared that space exempt from freshness; the declared reason travels in details, and missing is decided BEFORE any exemption applies, so a deleted file is never made quieter by a declaration. Every pair also reports baseline — index, git:HEAD or none — so a green result says what it was green against.

The verdicts sum to the total. incomplete and exempt each had no summary counter, so ok + stale + missing + unverified + unchecked did not add up to total and a machine consumer reading only the summary saw neither. Both keys are in the summary now (stored-baseline mode; the --since shape is untouched), and the sum that holds is ok + stale + missing + unverified + exempt + incomplete. unchecked is deliberately outside it — it counts NODES that contribute no pair at all, a different population from the pairs the verdicts describe. Measured on this repository, 2026-08-24: total 330 = 326 ok + 4 incomplete, with exempt 0 and unchecked 0.

Where the baseline lives, and why a rebuild no longer blinds it. .beadloom/beadloom.db is a cache, not the record: a database built from scratch used to store the current tree AS the baseline, so sync-check reported every pair fresh, including pairs whose doc was never updated (measured before the fix: incremental reindex → exit 2 with 6 stale; rm .beadloom/beadloom.db* + reindex → exit 0 with 0 stale, same tree). Each pair now records where its baseline came from, and a pair whose baseline was fabricated at index-build time is corroborated against git HEAD — the baseline a rebuild cannot destroy, because it is committed. Where git cannot answer (not a repository, no commit, no git binary), the pair reads unverified rather than fresh. --since <ref> remains the strongest form and is what the CI harness passes on a fresh checkout. A clean database is still the right instrument for lint, and it is no longer a way to get a green sync-check for free.

The count is part of the contract. --record-surface writes .beadloom/sync-surface.json (committed, so a rebuild cannot lose it). A later run whose declared surface FELL says so by name — declared surface SHRANK since it was recorded: 275 → 269 pair(s) — instead of quietly printing the smaller number. It is a warning, not a verdict: the cause that matters (a declared doc that is gone) fails on its own.

Human-readable output includes reason-aware formatting. Every line names its pair — doc_path <-> code_path — whenever the row has a code file. A pair is a document AND a code file, so three files of one package give three pairs over one README; the missing, untracked_files and missing_modules lines printed the document alone until BDL-069, and three different pairs rendered as three identical lines:

  [stale] ledger: domains/ledger/README.md <-> src/ledger/__init__.py (missing modules: journal)
  [stale] ledger: domains/ledger/README.md <-> src/ledger/core.py (missing modules: journal)
  [stale] ledger: domains/ledger/README.md <-> src/ledger/journal.py (missing modules: journal)
  • missing status: [missing] with which side is gone (the linked doc file is gone, the paired code file is gone, declared in the graph, not on disk).
  • unverified status: [not verified] with the reason it was not verified — either there was no baseline, or this pair's own file did not move while a named sibling of the same node did (sibling_symbols_changed).
  • incomplete status: [warn] naming either the document and its missing sections, or the node KIND and the ratio behind a section its documents do not use (Source (5/39)).
  • untracked_files reason: displays list of untracked files in details.
  • missing_modules reason: displays list of missing modules in details.
  • Other stale reasons (e.g. symbols_changed, content_changed): displays reason next to the code path.

Reference surface drift (advisory). A high-traffic overview doc can opt in to freshness against a coarse interface surface with an in-doc annotation near its top:

markdown
<!-- beadloom:watches=cli,graph,flow.yml -->

The watched surfaces are cli (the Click command + flag tree), graph (the node + edge identity set), and flow.yml (the normalized .beadloom/flow.yml). reindex baselines the aggregate hash of the declared surfaces; sync-check recomputes it and, when the surface changed, emits a pair with reason = surface_drift and severity warning. In --json, summary.surface_drift and a references[] array carry these pairs (stored-baseline mode only; the --since shape is unchanged). Surface drift is advisory — it never changes the exit code or fails beadloom ci; it asks a human to re-read the overview and clear it with sync-update.

beadloom sync-update ​

Review and update stale documentation.

bash
# Show sync status for a ref_id
beadloom sync-update REF_ID --check [--project DIR]

# Interactive: open stale docs in $EDITOR, mark synced after editing
beadloom sync-update REF_ID [--project DIR]

# Non-interactive: re-baseline freshness without an editor or prompt
beadloom sync-update REF_ID --yes [--project DIR]

# Non-interactive, fixpoint loop: re-baseline every currently-stale ref
beadloom sync-update --all --yes [--project DIR]

--yes (-y) records that the doc(s) for the ref match the code now (recomputes file hashes + symbols hash, sets status='ok'), prints a concise summary, and exits 0 — no editor, no prompt. This is the primitive a CI/script fixpoint loop uses to re-baseline freshness after a doc is rewritten; it is the same operation the interactive path performs after an edit. --all re-baselines every ref sync-check currently flags stale (deterministic; requires --yes).

It names what it did not clear. An attestation rewrites recorded hashes, so it clears hash_changed, hash_changed_since_head and symbols_changed and cannot clear missing_modules or untracked_files, which read what the files say. After attesting, --yes re-runs the check — over the one ref, or over every ref with --all — and names every pair still stale, with what clears it. Measured on a repository whose ledger document does not name its journal module (BDL-069):

$ beadloom sync-update --yes --all
Re-baselined ledger: attested 3 pair(s).
Marked 1 ref(s) synced (3 pair(s) total).
Still stale after this run: 3 pair(s) — the verdict on these did not move:
  ledger: domains/ledger/README.md <-> src/ledger/__init__.py (missing_modules: journal) — name journal in domains/ledger/README.md; re-attesting cannot clear missing_modules, because the check reads what the document says, not a recorded hash
  …

Before BDL-069 the run stopped after the second line, and an operator read it as the defect fixed. A pair the run left unclaimed — a second document outside --pair — says not claimed by this run instead, and a run that cleared everything says Re-checked after attesting: no pair … is still stale. The exit code is unchanged: --yes exits 0 in every case, and what it attests is untouched.

REF_ID also accepts the path of a reference doc (one carrying a watches: annotation). In that case sync-update recomputes and stores the doc's aggregate surface hash, clearing a surface_drift warning — the same re-attestation as a symbol pair.

--check never writes, on either kind of argument:

bash
$ beadloom sync-update docs/architecture.md --check
  [surface drift] docs/architecture.md watches cli, graph

It did until BDL-061 S3b. The reference-doc branch was reached before the --check guard, so the flag whose whole contract is "tell me, do not change anything" re-baselined the doc and printed Re-baselined reference doc <path>; measured on this repository, the drift count fell from 7 to 6 on a run that asked for a report, and the next sync-check read clean for a reason nobody recorded. That is BDL-UX #147 (lint mutating its index) in another command, and #163 (re-attesting without evidence) reached by accident rather than by choice (BDL-UX #189).

For automated doc updates, use your AI agent (Claude Code, Cursor, etc.) with Beadloom's MCP tools. See .beadloom/AGENTS.md for agent instructions.

beadloom install-hooks ​

Install (or remove) Beadloom's git hooks: a pre-commit hook (the lighter synchronization check) and a pre-push hook (the authoritative blocking Beadloom Gate). By default both are installed.

bash
# Install BOTH hooks (pre-commit warn mode + pre-push Gate)
beadloom install-hooks [--mode warn|block] [--project DIR]

# Install only one
beadloom install-hooks --pre-commit [--mode warn|block] [--project DIR]
beadloom install-hooks --pre-push [--project DIR]

# Remove (both, or the selected one)
beadloom install-hooks --remove [--pre-commit|--pre-push] [--project DIR]

Pre-commit hook runs, in order: ruff lint over the Python files the commit stages (selected by suffix, so a package at the repository root is judged like one under src/), mypy over those staged files inside the surface the project declares typed (derived per run by typed-surface; a surface that could not be derived reads NOT CHECKED with its reason and never blocks), beadloom sync-check (--mode warn reports stale docs; --mode block fails the commit on stale docs; either way it closes with an instruction scoped to the reasons re-attesting clears, because the hook prints porcelain rows and cannot choose per row), and finally the ACTIVE / tracker coherence step. That last step is a guarded auto-fix: it runs only when BOTH bd and beadloom are on PATH, calls beadloom active-sync to reconcile each epic's ACTIVE.md bead-status table from bd and re-export .beads/issues.jsonl, then restages the touched .claude/development/docs/features/** files and .beads/issues.jsonl so the commit is coherent by construction. It never blocks the commit (it runs even in block mode without affecting the exit code), and in any repo without bd — or without ACTIVE tables — the block is a complete no-op (see active-sync).

Pre-push hook (Beadloom Gate) is the authoritative blocking enforcement of the hard invariant "no code in main without current docs." On every push it runs the full Gate (beadloom ci — incremental reindex → lint --strict (module-coverage included) → sync-check → docs-audit → docs-quality → config-check → doctor) and exits non-zero to block the push on red, printing an actionable message ("Beadloom Gate failed … run the tech-writer (or /coordinator) then re-push; git push --no-verify to override"). It is fail-safe: in any repo without beadloom on PATH the hook is a safe no-op and never blocks. The full Gate lives in pre-push (not duplicated on every commit) because pushes are less frequent than commits; the pre-commit hook stays the lighter warn/block check. --no-verify is the documented (discouraged) escape hatch.

Both hooks are idempotent — re-running install-hooks overwrites cleanly.

beadloom active-sync ​

Reconcile each epic's ACTIVE.md bead-status table from bd — the source of truth — and re-export the tracked .beads/issues.jsonl.

bash
# Fix mode (default): rewrite drifted Status cells + bd export the jsonl
beadloom active-sync [--epic KEY] [--no-export] [--project DIR]

# Check mode: report drift without writing; exit 1 if any drift, 0 if clean
beadloom active-sync --check [--epic KEY] [--project DIR]

# Machine-readable JSON (works with --check or fix mode)
beadloom active-sync --json [--check] [--epic KEY] [--project DIR]

For every epic's ACTIVE.md, it finds the bead-status table and rewrites each Status cell to match the bead's current bd status (closed → ✓ done, in_progress → in progress, open/ready → ready, and blocked for an open bead with an open blocker). A richer coordinator note is preserved when its state already agrees (e.g. ✓ done (PASS-WITH-FIXES) is left intact for a closed bead). Only Status cells change — prose, the Progress Log, and other columns are byte-preserved. The reconcile core (application/active_table/, reconcile_active_tables / bd_status_to_cell) is the same one the MCP S4 process-tools (checkpoint / complete_bead) use.

A row's first cell is read for the id it NAMES, not for the text it IS: a code span, bold, or a Markdown link around an id resolves to that id (BDL-UX #210). A row that still resolves to nothing is reported with the shape that made it unresolvable — no-bead-id, bead-and-text, more-than-one-bead, unknown-to-tracker or ambiguous-number — because one sentence over five populations is a report nobody can act on.

Beads the tracker holds that an epic's table did not reconcile are named too, in two lists, because the two have two remedies: a row this run could not read (the cell is quoted — fix it, and the shape above says how) and no row in their epic's table (add a row). They were one list until BDL-068 S5, and the run contradicted itself over them: 38 of the 79 beads reported as carried by no row had a row whose first cell's head was exactly that bead's id, already printed as bead-and-text or more-than-one-bead by the same run. Neither list is ever written into a table: inserting a row into somebody's document is the same decision-for-an-agent as adding a path to their commit.

This is the mechanism that keeps ACTIVE.md honest by construction — wired into the pre-commit hook (above), the coordinator no longer hand-edits bead-status rows; the table is reconciled from bd on every commit.

  • --epic KEY — reconcile only features/<KEY>/ACTIVE.md (default: every features/*/ACTIVE.md).
  • --check — report drift on a throwaway copy without writing; exit 1 if any row would change, exit 0 when clean. Never writes and never exports.
  • --json — machine-readable output: { "changed_files": [...], "drifted_rows": [ { "path", "bead_id", "old", "new" }, ... ], "rows_read", "rows_resolved", "unresolved_rows": [ { "path", "cell", "shape", "reason" }, ... ], "unresolved_by_shape", "unlisted_beads", "beads_named_by_an_unresolved_row": [ { "path", "bead_id", "cell" }, ... ], "staging" }.
  • --no-export — skip the bd export jsonl sync (fix mode only).
  • --stage — re-stage the reconciled ACTIVE.md(s) and the exported jsonl that this commit already stages, and name every path it corrected and did not. It never adds a path to a commit. Fix mode only; best-effort (no git → skip).
  • --project DIR — project root (default: current directory).

In fix mode (no --check), after rewriting it best-effort runs bd export -o .beads/issues.jsonl — but only when that file is already git-tracked — so the tracked tracker artifact stays honest across branch/squash-merge. --no-export skips that step.

--stage does not decide what your commit is. It used to git add every path the reconcile had written, which put another agent's tracker export back into a commit it had been deliberately taken out of (BDL-UX #207). The commit's scope is the set of paths whose index entry differs from HEAD; --stage re-stages the corrected content of a path inside that set and prints every correction outside it under a withheld: line, which is what the pre-commit hook shows. Under --stage, bd export runs only when the commit already carries .beads/issues.jsonl: a refresh that cannot be committed keeps no tracked artifact honest and dirties a shared working tree instead. Measured over the sixteen commits of features/BDL-068, the export moved that file in sixteen, so without the gate the hook would print one line on every commit.

No-op contract. active-sync exits 0 and writes nothing when there is no ACTIVE.md with a bead-status table, OR when bd is unavailable, OR when .beads/issues.jsonl is not tracked (the export is skipped). So a non-flow repo — or any adopter without the agentic flow — is never affected; the command (and the hook step that calls it) is a safe out-of-the-box no-op.

Manage external tracker links on graph nodes.

bash
# Add a link (label auto-detected from URL)
beadloom link REF_ID URL [--label LABEL] [--project DIR]

# List links for a node
beadloom link REF_ID [--project DIR]

# Remove a link
beadloom link REF_ID --remove URL [--project DIR]

Auto-detected labels: github, github-pr, jira, linear, link (fallback).

beadloom diff ​

Show graph changes since a git ref.

bash
beadloom diff [--since REF] [--json] [--project DIR]

Compares current graph YAML with state at the given ref (default: HEAD). Exit code 0 = no changes, 1 = changes detected.

beadloom export ​

Export the indexed graph as a deterministic cross-repo federation artifact (JSON).

bash
beadloom export [--out FILE] [--project DIR]

Reads the indexed graph from SQLite (read-only) and emits a self-describing JSON artifact (schema v1): repo, commit_sha, exported_at, generator, and the nodes / edges arrays (each carrying lifecycle; edges may carry AMQP contract meta). The edges array unions the local edges table and the cross-repo foreign_edges table so declared @repo: links survive. Output is byte-deterministic (sorted nodes/edges + sorted keys). --out writes to a file; otherwise prints to stdout. Exits 1 if the database is missing (run beadloom reindex first). See the federation SPEC.

beadloom federate ​

Aggregate ≥2 satellite export artifacts into one federated graph (hub).

bash
beadloom federate EXPORT1.json EXPORT2.json [...] [--project DIR]

Composes the namespaced node/edge union (@repo:ref_id identity), resolves @repo: foreign refs, assigns a three-valued intent-vs-reality verdict per edge (OK / DRIFT / EXPECTED / CLEANUP_CANDIDATE / UNDECLARED / DEAD), reconciles AMQP contracts (confirmed both-sides vs one-sided), and reports per-satellite staleness (commit_sha + age). Writes .beadloom/federated.json + .beadloom/federated.txt in the hub project root and echoes the report (with any DRIFT) to stdout. Requires at least two artifacts; exits 1 otherwise or if a file is not a JSON object. The --fail-on <csv> landscape gate (writes artifacts first, then exits 1 on matching verdicts) prints an agent-actionable fix: hint per failing verdict (BREAKING / ORPHANED_CONSUMER / UNDECLARED_PRODUCER / DRIFT). See the federation SPEC.

beadloom snapshot ​

Architecture snapshot management. Snapshots capture the current graph state (nodes, edges, symbols) for later comparison.

beadloom snapshot save ​

Save the current graph state as a snapshot.

bash
beadloom snapshot save [--label LABEL] [--project DIR]
  • --label -- optional label for the snapshot (e.g. pre-refactor).

beadloom snapshot list ​

List all saved architecture snapshots.

bash
beadloom snapshot list [--json] [--project DIR]

Shows snapshot ID, label, creation time, and counts (nodes, edges, symbols). --json for structured output.

beadloom snapshot compare ​

Compare two architecture snapshots to see what changed.

bash
beadloom snapshot compare OLD_ID NEW_ID [--json] [--project DIR]

Displays added/removed/changed nodes and added/removed edges between the two snapshots. Both OLD_ID and NEW_ID are required integer snapshot IDs.

Search nodes and documentation by keyword.

bash
beadloom search QUERY [--kind {domain,feature,service,entity,adr,to_be,as_is,working}] [--limit N] [--json] [--project DIR]

Uses FTS5 full-text search when available, falls back to SQL LIKE. Run beadloom reindex first to populate the search index.

--kind takes a node kind, or one of the three documentation SPACES. A document bound to no node — every planning document in the TO-BE space — is indexed under its space and keyed by its path, so --kind to_be narrows a search to recorded intent:

bash
beadloom search "sequencing principles" --kind to_be

beadloom why ​

Show impact analysis for a node -- upstream dependencies and downstream dependents.

bash
beadloom why REF_ID [--depth N] [--json] [--reverse] [--format {panel,tree}] [--project DIR]
  • --reverse -- focus on what this node depends on (upstream only) instead of the default full analysis.
  • --format -- output format: panel (Rich panels, default) or tree (plain text for CI/scripting).

beadloom lint ​

Run architecture lint rules against the project.

bash
beadloom lint [--format {rich,json,porcelain,github}] [--strict] [--fail-on-warn] [--no-reindex] [--project DIR]

Checks cross-boundary imports against rules defined in rules.yml. Format auto-detects: rich if TTY, porcelain if piped.

--format options:

  • rich -- human-readable text (default on a TTY).
  • json -- structured output: a backward-compatible violations array (now with an additive remediation key), a stable agent-actionable findings array ({kind, rule, severity, node, locations, why, remediation}), a suppressed array naming every crossing a forbid_import exemption excused, and a summary object (whose violations_suppressed is that array's length, and whose layer_populations[] states how much of its edge set each declared layer rule judged — rule, edge_kind, evaluated, total, skipped_untagged; additive, every key that was there keeps its name, BDL-070 A3. Release B left one population where A had stated two, so inherited_evaluated, inherited_total and unjudged are not emitted). Deterministic (violations are pre-sorted).
  • porcelain -- one colon-separated line per violation (default when piped), led by one # layer_population:<rule>:<edge_kind>:<evaluated>:<total>:<skipped> line per declared layer rule. The record lost its seventh field, the inherited population, in Release B, where the rule began deciding on one population instead of reporting two. The # marker is the one scope-check --porcelain already leads its verdict with, and no rule name can begin with it, so a consumer that drops the marked lines reads exactly the seven-field records it read before (BDL-070 A3).
  • github -- GitHub Actions workflow commands (::error file=…,line=…::<rule>: <message> — <remediation>) so violations surface as inline PR annotations; warnings use ::warning. One leading ::notice:: per declared layer rule states the population that rule judged — a notice and not a warning, because the fraction is not a finding against anyone's code and must not colour a pull request (BDL-070 A3).

Each violation carries an agent-actionable remediation hint derived per rule kind (deny/forbid → remove/reroute the import or edge; cycle → break the cycle at a named edge; layer → invert the dependency or extract a shared abstraction; cardinality → split the node; require → add the required edge).

Exit codes: 0 = clean (or violations without --strict/--fail-on-warn), 1 = violations with --strict (errors only) or --fail-on-warn (any finding a rule decided), 2 = configuration error or missing index.

A layer rule states how much of its edge set it judged. architecture-layers resolves each end of an edge to the layer it is IN — the node's own declared layer tag, else the nearest part_of container that declares one (BDL-070 B3) — and passes over an edge with an end in no declared layer at all. The green line 0 violations, N rules evaluated used to say the same words for a rule that had looked at a fraction of the graph as for one that had looked at all of it, so every run now states that fraction — on the rich summary line, GREEN and RED alike, and in each of the other three formats' own idiom (BDL-070 A3). The summary line in full, as beadloom lint --no-reindex --format rich printed it on this repository on 2026-09-13 over the carried-forward index:

text
Errors: 0, Warnings: 55 (15 rules evaluated, 10 crossings suppressed by an exemption, architecture-layers judged 357 of 365 live depends_on edge(s), 0.2s)

The clause is printed at FULL reach as well as partial, because a population is the denominator of the counts beside it. The layer_population FINDING behind it is silent at full reach, because a finding is something a person triages on every run. The statement is warn whatever severity the rule declares, and a project that declares no layer rule gets no clause. A green lint --strict says nothing about the edges outside that fraction: an edge with an end in no declared layer is not judged, and the clause is the only place the run says how many there were. The figure depends on how the index was built — the first build of an empty index resolves two import rows differently from every build after it (BDL-UX #290) — so hold the lineage constant across a before/after comparison.

--fail-on-warn excludes a layer rule's population and declaration statements. Those two report how far the rule reached and whether its declaration describes the project; neither decides anything about an edge, and both appear on a graph nobody changed, so a pipeline running the flag would have turned red on upgrade for a message that cannot be acted on in the run that reddened (BDL-070 A8). Every other warning — an expired exemption, an inert rule, an unbound scenario — still exits 1. A pipeline that wants the advisories to block reads the layer_population and layer_declaration records out of --format json. The exclusion stops at error: both advisories are emitted at warn today, and one emitted at error would still exit 1, because a flag meant to be harsher than --strict must not read softer than it on the same run.

A deny rule can only check a file it can place. An import's source end is attributed to a node by annotation OR by ownership — the same most-specific-source rule that derives the depends_on edges — so a file with no annotation, or one written where the extractor could not read it, is no longer invisible to every deny rule (measured before the fix on this repository: 22 of 128 import-source files, BDL-061.50). What still belongs to no node is counted rather than skipped: Files: N scanned, M imports resolved, K attributable to no node on the rich header, summary.files_unattributed in --format json, and the same clause on the no-violations summary line. The clause is absent when K is zero. A deny rule that never saw a file did not clear it.

A rule that cannot check anything reports itself. All 15 authoring keys the loader dispatches are covered: a matcher that selects no node, a has_edge_to naming a node the graph does not contain, an edge kind that never runs between two layered nodes, a check with no threshold set, a from:/to: glob matching zero candidates anywhere in the index, a source_root with no module under it. Each is a rule_liveness finding. A partial inertness is warn — it describes the configuration rather than the code, so one mistyped glob cannot turn an adopter's green project red — while a rule that could check NONE of its population reports at the severity the project declared, because at that point a pass and a no-op are the same output (BDL-062 .9; doc_area_coherence only, so far — BDL-UX #197). Either way it is printed by default, typed in --format json as kind: rule_liveness, and counted in summary.rules_inert. The rich summary line carries the count only when it is non-zero (N rules evaluated, M of them unable to check anything), so the advertised rule count cannot over-claim while the everyday line keeps its shape (BDL-061.48). Two silences are deliberate and are properties of the INDEX rather than of any rule: an index with zero resolved imports makes every deny rule inert, which the header's 0 imports resolved already says, and an empty graph silences the pass entirely so a fresh clone does not light up one warning per rule. Two rule types state their own diagnosis instead of the generic one, because a generic "cannot fire" cannot name which glob or which leg did it: forbid_import reports from the import scan it already runs, and scenario_coverage reports per leg. scenario_coverage is still COUNTED in summary.rules_inert — the report and the count are two questions, and one predicate answers both so they cannot disagree (BDL-061.66). The three suite rules of BDL-074 C3 (test_binding, test_import_boundary, scenario_binding) report their own liveness per leg in the same way, are counted through the same kind of shared predicate, and add one suite_population finding (warn, an advisory --fail-on-warn does not exit on) on every run, stating how much of the suite each judged.

Behaviour bound to an executable claim (BDL-061 S4). lint also evaluates the scenario_coverage rule: a behaviour-bearing node with no scenario, a scenario naming no bead, a scenario naming a @node: the graph does not contain, and a scenario a PRD or BRIEF references and the acceptance suite does not contain. All warn, each carrying the population it is a fraction of (none of 92 scenarios in 20 files carries @node:agent-prime). Measured on this repository, 2026-08-26: 59 findings. 32 of them, each naming a feature node that no scenario in the suite binds to; 26, each naming a scenario a document references and the suite does not contain; and one stating the rule's own reach: the feature nodes it selects, of the whole graph. See the BDD guide.

What an exemption excused is part of the answer. A forbid_import rule may carry exempt: entries that baseline a pre-existing crossing (see the rule-engine SPEC). Every run says how many crossings they suppressed — ", N crossings suppressed by an exemption" on the summary line, violations_suppressed plus the suppressed array under --format json, and the same clause on the 0 violations, N rules evaluated line printed when a piped run has nothing to report. Without it, 0 violations reads as "nothing crossed" when it means "what crossed was excused" (BDL-061.49). An entry whose until: leads with an ISO date that has passed, and which is still suppressing something, is reported as a rule_liveness finding (warn); it keeps suppressing, so no build reddens because a day passed. --fail-on-warn is the lever for a project that wants that deadline enforced.

Without --strict the exit code stays 0 even when error-severity violations were printed. That is deliberate — changing it would turn an adopter's green pipeline red on upgrade — so lint names the omission on stderr instead (warning: N error-severity violation(s) found, but the exit code stays 0 without --strict).

Which form writes the index. Plain beadloom lint reindexes first and therefore WRITES .beadloom/beadloom.db (measured: its sha256 changes). This is by design: the default must never lint a stale graph. --no-reindex is the read-only form — it leaves beadloom.db byte-identical (measured under both journal_mode=wal and journal_mode=delete) and refuses a missing index at exit 2 with index not found … Run 'beadloom reindex' first rather than creating one and reporting 0 violations against it. Two qualifications, both measured: on a WAL index the read-only form still creates and leaves the beadloom.db-wal / beadloom.db-shm sidecars, so byte-identity is a property of the FILE and not of .beadloom/; and --no-reindex answers about the INDEX rather than about the working tree, so with a stale index it reports 0 violations over a boundary violation that plain lint --strict catches on the same tree (BDL-UX, beadloom-mr2l). Use it when you have just reindexed, or when the read-only property is what you need.

beadloom tui ​

Launch interactive terminal dashboard (primary command).

bash
beadloom tui [--project DIR] [--no-watch]

Multi-screen architecture workstation with graph explorer, debt gauge, lint panel, doc status, and keyboard actions. Requires: pip install beadloom[tui].

  • --no-watch -- disable file watcher (for CI/testing)

beadloom ui ​

Launch interactive terminal dashboard (alias for tui).

bash
beadloom ui [--project DIR] [--no-watch]

Backward-compatible alias for beadloom tui. Requires: pip install beadloom[tui].

beadloom watch ​

Watch files and auto-reindex on changes.

bash
beadloom watch [--debounce MS] [--project DIR]

Monitors graph YAML, documentation, and source files. Graph changes trigger full reindex; other changes trigger incremental. Requires: pip install beadloom[watch].

beadloom docs generate ​

Generate documentation skeletons from the architecture graph.

bash
beadloom docs generate [--project DIR]

Creates docs/ tree: architecture.md, domain READMEs, service pages, feature SPECs. Never overwrites existing files. All generated files include <!-- enrich with: beadloom docs polish --> markers.

beadloom docs site ​

Generate a VitePress content tree from the architecture graph.

bash
beadloom docs site [--out DIR] [--federated FILE] [--project DIR]

Reads the indexed graph read-only and emits, under --out (default site/):

  • index.md -- architecture overview: domain/service/feature counts, the top-level C4/Mermaid diagram, and a health summary line (nodes/edges/docs/coverage/stale).
  • per-node pages (domains/<ref>.md, services/<ref>.md, features/<ref>.md) -- each with summary, source, public symbols, part_of/depends_on/uses edges rendered as Markdown links to the other node pages, linked hand-written docs, and an embedded scoped C4/Mermaid diagram.
  • dashboard.md + dashboard.data.json -- Showcase A, the AaC/DocAsCode metrics dashboard (lint count + severity, debt score + trend, doc coverage / sync-check freshness / stale count, doctor pass-fail, and an optional federated rollup). Every number comes from the SAME code path as its gate (lint / debt-report / sync-check / doctor / federate) -- honest by construction.
  • landscape.md -- Showcase B, the 🌟 cross-repo landscape map: a Mermaid diagram of the federated contract graph (with --federated) or the local graph (without), edges labelled by their verdict, a classDef health overlay, and clickable nodes linking to their intra-repo page.
  • docs/** + docs/index.md -- Showcase C, the published validated documentation: the REAL docs/** tree copied verbatim (the source of truth, rendered as-is) with a per-doc doc_sync freshness badge injected into the COPY only. The source docs/ is NEVER mutated.
  • .vitepress/config.generated.mjs -- the nav/sidebar config imported by the committed VitePress scaffold (site/.vitepress/config.mjs); sections: Dashboard / Architecture / Landscape / Documentation.

Beadloom produces, VitePress renders. Output is deterministic (sorted, stable frontmatter, no wall-clock in the diffed output) and is NEVER written into the source docs/ tree -- only under --out. --federated takes a federate hub artifact (federated.json) and drives the Showcase B landscape map. To render: cd site && npm install && npm run docs:build (preview with npm run docs:preview). See the VitePress Site guide.

beadloom docs audit ​

Detect stale numeric facts in project documentation.

bash
beadloom docs audit [--json] [--fail-if EXPR] [--stale-only] [--verbose] [--path GLOB]... [--project DIR]

Scans markdown documentation for numeric mentions (version strings, counts) and compares them against ground-truth facts collected from the project infrastructure (manifest files, graph DB, MCP tools, CLI commands). The audit is stable and runs as the docs-audit step inside beadloom ci, where it blocks the gate on stale>0.

  • --json -- structured JSON output with facts, findings, unmatched mentions, per-fact coverage, unverified_facts and the scan_surface.
  • --fail-if -- CI gate expression. Supported formats: stale>N / stale>=N (mentions that disagree with ground truth) and unverified>N / unverified>=N (declared facts the run checked nothing for). Exits with code 1 when the condition is met.
  • --stale-only -- show only stale findings (omit fresh matches).
  • --verbose -- include extra detail: unmatched mentions, the documents that were not read (with the reason each was skipped), and the ones scanned for versions only.
  • --path -- override default scan paths with custom glob patterns (can be specified multiple times).

Exit codes: 0 = no issues (or below threshold), 1 = --fail-if condition met.

What a green audit covers. N mention(s) fresh counts what the audit FOUND, not what it CHECKED, and the two were measured nine-fold apart on this repo: nine declared facts, thirteen verifications, all thirteen of the same fact (BDL-UX #173). Every declared fact therefore carries its own coverage — verified (something was compared), not_covered (no document states it) or unreadable (the extractor cannot read a claim of that value at all, with the reason) — printed against the fact in the Ground Truth block and summarised on one line:

2 of 9 declared fact(s) verified; NOT VERIFIED: cli_command_count, edge_count, ...
46 document(s) scanned, 33 not read, 1 scanned for versions only (file-type heuristic)

A fact nothing was found for is never counted as passing. Coverage does not fail the gate — documentation is not required to state every fact — but --fail-if unverified>N makes it enforceable for a project that wants it.

What counts as a claim. A line is split on whitespace and only a token whose whole core is a number is a candidate — all digits, or digits in thousands groups (6,390, read whole as 6390). A number inside a larger token is an identifier rather than a claim (BDL-061.33, v2.2.0, utf-8) and is never extracted; markdown emphasis, brackets and trailing punctuation around the token are stripped first. A claim also reaches only to the end of its own clause: a modifier or a noun on the far side of , ; : or a dash belongs to the rest of the sentence, so The graph holds 316 edges, one per import. is read (the per is not modifying the count) while the 14 in exposes 18 tools: 14 over the graph is not (it is a breakdown, not the total). See docs/domains/doc-sync/features/docs-audit/SPEC.md for the layer model and the declared blind spots.

Whose version a version is. A semantic version is attributed to the nearest subject NAME to its left inside its own clause, and only a version whose nearest name is this project's -- or that has no name at all -- is compared against this project's version. So Measured on bd 1.0.4 states the release of the tracker and The current release is 7.0.0 states this project's, and each number in bd 1.0.4 answers and beadloom 7.0.0 asks goes to the name beside it. The tokens given to another product are counted with their subjects in the audit's own output, and carried in --json under attributed_versions with the vocabulary that decided them under version_subjects, so the exemption is visible rather than silent.

The vocabulary is derived where a project already declares it: every distribution in pyproject.toml, package.json or Cargo.toml, the interpreter families implied by requires-python / engines.node / rust-version, and git when the project is a git repository. A name no manifest declares -- a CLI, a database, a service -- is named once under docs_audit.subjects. A name nobody declared still produces a finding, so an unknown subject fails loud rather than quietly going unchecked.

A subject the environment cannot confirm here is unresolved, not absent. git is confirmed by a .git rather than by a file the project ships, and a directory built by git archive HEAD -- every clean room beadloom clean-room builds -- carries none. Reading that absence as a denial compared git 2.49.0 against this project's own version, which made every clean-room Gate run on this repository rc 1 for one line of one document (BDL-UX #266). The name now stays in the vocabulary as unresolved: it still wins the attribution walk, and the audit reports the token it declined to judge instead of judging it. --json carries them under unjudged_versions with the reason under unresolved_version_subjects, and the beadloom ci docs-audit line names the count and the subject. Do not declare git under docs_audit.subjects to work around this -- a second, hand-written vocabulary entry is the drift the derivation exists to prevent.

Tuning false positives. The audit masks dates, hex, issue IDs, line refs, and version pins, and applies per-fact tolerances. Three .beadloom/config.yml keys handle the rest:

yaml
docs_audit:
  tolerances:
    node_count: 0.1          # accept counts within 10% of ground truth
  subjects:                  # products this project cites that no manifest declares
    - bd
  ignore:                    # suppress one {path, fact, value} false match each
    - path: docs/guides/vitepress-site.md
      fact: cli_command_count
      value: 404

docs_audit.subjects is a list of NAMES, not of documents. One entry covers every sentence in every document that measures that product, which is what it replaced: ten ignore triples stood on this repository for one sentence shape, and eight of them went inert when the attribution rule landed.

docs_audit.ignore is a list of {path, fact, value} triples. Each suppresses exactly one keyword-proximity false positive — for example a subset count stated next to the correct total, or an HTTP status code matched as a command count — without rewording correct prose and without masking a genuine stale fact of the same type elsewhere. Use it only for confirmed false positives; genuine stale facts must be corrected in the doc.

Examples:

bash
# Human-readable Rich output
beadloom docs audit

# CI gate: fail if any stale docs
beadloom docs audit --fail-if=stale>0

# Stricter: also fail when a declared fact is stated by no document at all
beadloom docs audit --fail-if=unverified>0

# JSON output for scripting
beadloom docs audit --json --stale-only

# Scan only specific paths
beadloom docs audit --path "docs/**/*.md" --path "README.md"

beadloom docs quality ​

Check the project's planning documents against the shipped writing standard (BDL-061 S4b).

bash
beadloom docs quality [--json] [--check NAME]... [--strict] [--project DIR]

Eleven checks, all warn:

CheckReportsWhere it looks
measurable-goala goal statement with no number in itthe ## Goal / ## Goals section
decision-reasona decision row whose reason cell is emptyany table with a Reason / Rationale / Why column
risk-mitigationa risk row with no mitigation, or one that names no action (monitor it)any table with a Mitigation column
pending-in-approveda question still answered Pending## Open Questions, in a document whose status is Approved or Accepted
unfilled-placeholdera shipped template token nobody replacedthe whole document, outside fenced blocks and inline code
missing-sectiona section this document's kind carries in its template AND a majority of its peers keepevery document of a kind the /templates command describes
empty-sectiona required section whose heading is there with nothing under itthe same, and not peer-relative: a heading answering nothing is a defect whatever the peers do
axes-without-a-seedan ## Axes section stating axes without naming the seed they were derived fromthe ## Axes section
axis-without-a-scope-decisionan axis row carrying the derivation's output and no decisionthe same
routed-without-axesa work item on the simplified route (bug, task, chore) carrying no ## Axes section in any of its documentsthe work-item FOLDER, which is the unit that has a type
route-not-supported-by-the-axesa work item on the simplified route whose kept axes name more than one graph nodethe same

The middle four arrived with BDL-068 S1.4 and the last two with S1.5. The last two take the work-item FOLDER as their unit, because a route is a property of the item rather than of any one document it holds, and they are absolute rather than peer-relative: missing-section reported BRIEF documents do not carry Axes (0/12) against the KIND and nothing against any document, which is right for a convention an archive never adopted and wrong for the input to a decision. Only the simplified route is judged — the full route writes a PRD and an RFC and each passes an approval gate, so a mis-route there meets a person.

The section requirements are DERIVED from the composed /templates command, so a project that appends a section to its own template layer makes it required by the same act. A required section no MAJORITY of a kind carries is reported once against the KIND, with its ratio (BRIEF documents do not carry Axes (0/12)) — the fix is in the template, not in every document.

The exit code is 0 even with findings — no adopter's green project turns red on upgrade. --strict exits 1 when anything is reported, for a project that wants to enforce it.

measurable-goal decides one named form, not measurability in general. A goal is reported only when its predicate is an unbounded improvement (improve, establish, clean up, make something better) AND it names no witness — no quantity, no named artifact, no observable outcome. It shipped as a numeral detector and reported 154 of 235 goal statements here, against 4 of 232 after beadloom-mr2l.70 re-scoped it; all four are in closed epics, which is why the remaining debt is a historical exclusion (beadloom-mr2l.71) rather than a rewrite. The stated limit: 27 of the 150 newly-accepted statements name no witness either, so this check now decides nothing about them — precision was bought with recall, deliberately. That number is not on the gate line; it is in the doc-quality SPEC, which also states the other four checks' limits.

The report ends with a per-check line stating how much there was to READ, and names any check that found nothing at all: a green count over documents that state no risks is not a statement about risks.

And per document KIND, because the line above is an OR over the whole corpus and goes silent the moment one document carries one row — so it can see a check that is blind everywhere and not one that is blind on an entire document kind. NO CHECK READS: <kind> names each kind no content check enters, with its document count. The judgement is made over the four checks that read items; unfilled-placeholder counts documents OPENED and would report every kind as read. Measured on this repository, 2026-08-24: measurable-goal 4 over 232, pending-in-approved 2 over 69, 0 over 272 / 138 / 243 for the other three, and NO CHECK READS: BRIEF, PLAN, SUMMARY — 56 of 243 documents (23%).

A document nobody could read is named, not dropped. A planning document is a UTF-8 contract; one that does not decode is counted, printed as UNREADABLE: <path> — <reason>; judged by nothing, and left out of its kind's denominators. Counting a file nobody read as a file carrying nothing would turn an encoding accident into evidence about a project's templates.

--check accepts the eleven check names above and nothing else; an unknown name is an error and exits 1. --json carries checks, read_nothing, kinds, kinds_read_by_nothing, unreadable and findings.

Documents are found under .claude/development/docs/features/*/*.md by default; a project with another layout declares its own globs:

yaml
# .beadloom/config.yml
doc_quality:
  paths:
    - docs/rfcs/*.md

A run that matches no document says so and names the globs it looked under, rather than printing a clean bill of health over nothing.

bash
# Everything, as a warning report
beadloom docs quality

# One check, machine-readable
beadloom docs quality --check pending-in-approved --json

# Enforce it
beadloom docs quality --strict

beadloom impact ​

Who else writes this, who else calls it, and how many branches it has — derived from the source over a seed the command finds for itself (BDL-068 S1.2).

bash
beadloom impact TARGET [--project DIR] [--root DIR] [--json] [--section]

TARGET is a path or a symbol name. The seed the answer is computed over is DERIVED from the target and named in the output together with the rule that found it, because the same derivation reports two writers under one seed and none under another — an answer that does not say what it was seeded with cannot be checked. A target no rule finds a sink for is reported as unresolved rather than answered over an empty set. Exits 1 when no file and no symbol matches TARGET.

  • --root — sweep this tree instead of the one derived from the target.
  • --section — render the answer as the ## Axes section a work item's document carries, with the In scope column left undecided.
  • --json — the whole answer as data: seeds, co_writers, callers, commands, boundary, unresolved and unread_ownership.

A row says what its node owns that this derivation did not read. impact reads Python, and an axis row that names a node says nothing about the rest of what that node owns. Three nodes of BDL-069 were ruled out of scope as blast radius and all three were work sites; onboarding was invisible, because the change it needed lived in the .md.txt templates that node owns (BDL-UX #284). So --section writes a sixth column, Owns unread, between Sites and In scope — the number of files the node owns whose suffix is not .py, with the first of them named, or none, or unknown — no index when there is no index to own anything, or — on a row that names no node. Run on this repository:

| Axis | Node | Sites | Owns unread | In scope | Why |
|---|---|---|---|---|---|
| callers | agent-prime | 1 — `src/beadloom/onboarding/scanner/bootstrap.py:37` | none | ? |  |
| branches | onboarding | `detect_preset`: 2 branch(es), 3 exit form(s), over every call | 49 — `src/beadloom/onboarding/templates/agentic_flow/CLAUDE.md.txt` | ? |  |

Each owning node is also a node-owns-unread-files entry in unresolved, and --json carries the whole population under unread_ownership as [{node, files}]. The column does not say the change reaches those files — whether a function reads a template is a runtime fact, and inferring it from string literals would be a confident guess. It says the node owns surface this answer is blind to, which is what a person needs before reading a quiet row as "not changed". A node's linked documents are not counted: every node has one, and sync-check owns the question of whether a change left it stale. What a generated directory costs the count, and the measured cost of the walk, are in the Impact SPEC.

A branch count names the seat it was taken from. Run on this repository:

$ beadloom impact src/beadloom/onboarding/scanner/bootstrap.py
root swept: src/beadloom
...
- bootstrap_project (src/beadloom/onboarding/scanner/bootstrap.py:36): 3 branch(es), reaching a seed
- init (src/beadloom/services/commands/setup.py:1255): 4 branch(es), reaching a seed, read from a caller's seat

Both numbers are right and they answer different questions. Three is the count of bootstrap_project, the function the target names, and it is the number BDL-067 carried through nine review passes while the fourth branch it needed lived in init — a caller, one hop out. So the count alone is not the answer: the seat is part of it, and --section spells the same distinction in its rows (init: 4 branch(es), 1 exit form(s), from a caller's seat).

co_writers and callers each carry resolved as well as their sites, because no population and an empty population are different statements. The swept root is printed as root swept: in every rendering and withdrawn as a claim when it is narrower than the project's source root (sweep-narrower-than-the-project). What that withdrawal does not yet reach is in the Impact SPEC under "Known ceilings". Read it before treating an empty axis as an absence.

beadloom scope-check ​

Judge the paths a commit stages against the axes its work item declared (BDL-068 S1.6).

bash
beadloom scope-check [--project DIR] [--since REF] [--branch NAME] [--porcelain] [--json]

The work item is the one the checked-out branch names, and its ## Axes section is the scope a person approved. A bead may narrow freely inside that scope. A path that falls OUTSIDE it means the approval no longer covers the change, which is the re-plan trigger. Exit 2 when a path falls outside, 0 otherwise.

This reports. It does not prevent. An agent with a shell can commit anything the file system allows. What the check raises is detectability — the crossing is named, with the axis it fell outside, at the moment it is made rather than at review.

  • --since REF — judge every path the branch changes against REF (REF...HEAD), which is what a pull request contains, instead of only the staged ones.
  • --branch NAME — name the work item's branch instead of reading the checked-out one.
  • --porcelain — the verdict first as a # -marked line, then one finding per line (path:line, check, excerpt). A hook splits the two on the marker: a finding line opens with a project-relative path and no path opens with # .
  • --json — the verdict plus checked, reason, work_item, document, scope, judged, unowned and undecided.

The verdict is printed whatever it says, on standard output, in both forms. Until BDL-068 S4 (beadloom-0mdo.32) the reason for having compared nothing went to standard error alone, and the pre-commit hook read this command as 2>/dev/null: a run that found nothing outside and a run that could attribute no work item were both the empty string there, so the gate printed the same nothing for both and an unattributable commit read as clean.

A clean run states its population rather than only its verdict. Measured on this branch, 2026-09-04:

$ beadloom scope-check --since origin/main
Declared axes (BDL-068, against origin/main): 11 staged path(s) a node owns, 28 no node owns.

The 28 are counted and stated, never reported: a path no node owns — a document, a test, a graph YAML — is not a call site and has no axis to be outside of. Counting them as checked would be the false green the check exists to remove. They are also the larger half: measured over the eleven commits of this branch, 52 paths carried 11 with an owner in the graph against 41 with none, so four paths in five were never compared and the count is the only thing that says so.

There are five ways to have checked nothing — no branch, no work item, no graph index, no ## Axes section and no answer from git — and each is reported as itself. A run that reports no findings and states no reason really did compare the paths.

The rule, the two candidates measured against this repository's own history before either was written, and what an undecided row does are in the Scope Check SPEC.

beadloom axes ​

Read a work item's ## Axes section back: what it declares, and the refs: line generated from it (BDL-068 S1.4).

bash
beadloom axes DOCUMENT [--refs] [--json]

DOCUMENT is a BRIEF or an RFC. The section records the derivation's output and the person's scope decision; this reads it back, so a bead's refs: is GENERATED from the document rather than written beside it — two authored homes for one fact are two things that can disagree.

beadloom impact <path|symbol> --section writes the other direction: the same answer, rendered as the section to paste into the document, with every row left undecided until a person rules on it.

$ beadloom axes .claude/development/docs/features/KEY-1/BRIEF.md --refs
refs: doc-quality, flow-composer

Exits 1 when the document carries no ## Axes section, naming the command that produces one.

beadloom docs spaces ​

Report the three documentation spaces, and where recorded intent never reached the documentation of reality.

bash
beadloom docs spaces [--json] [--strict] [--project DIR]
  • TO-BE — PRD, RFC, BRIEF, CONTEXT, PLAN. What the system is to become.
  • AS-IS — SPEC, DOC, README. What it is; the space sync-check holds against the code.
  • WORKING — ACTIVE. Ephemeral, exempt from freshness by declaration.

The names are deliberately not TODO/DONE. Nothing changes status: a planning document stays the record of what was intended, and a different artifact is what gets updated — so the checkable claim is a relation between two artifacts.

An epic with at least one closed bead that declared a graph node with no AS-IS document is reported: intent was recorded, the work finished, and reality was never written down. The node list is read only from the epic's Related Files section, because that list is a declaration; an epic that declares nothing is counted as unresolved and named, never counted as clean.

Roots, kinds and the intent documents an epic declares its nodes in are configurable under doc_roots in .beadloom/config.yml; the documentation directory itself comes from docs_dir. See the Doc Roots component for the keys and the Document Kinds guide for the decision behind the three spaces.

Every document a declared root matched is in exactly one population. When a document's kind sends it to a space whose declared roots do not reach it, it is counted in the space its kind chose and reported as document_outside_declared_root — once per kind, with the count, up to five example paths and the roots that failed to reach them. It used to fall out of every count instead.

What is not checked is named, never folded into a green count. An epic that declares no node, one whose intent document nothing could decode, and one a readable tracker does not name are three different ways of knowing nothing, and each is reported under its own reason.

Exits 0 with findings unless --strict is given, so no adopter's green project turns red on upgrade. The same check runs as the doc-spaces step of beadloom ci, where it reports and never blocks.

bash
# The report, with every denominator beside every count
beadloom docs spaces

# Machine-readable
beadloom docs spaces --json

# Enforce it
beadloom docs spaces --strict

--json carries the populations and every denominator behind the human report:

KeyWhat it holds
populations{to_be, as_is, working} document counts
epics, epics_with_closed_beads, epics_declaring_nodes, epics_declaring_nothingthe relation's denominators
refs_checkednode declarations actually held against the AS-IS space
relation_checkedfalse when nothing was related — reported as NOT CHECKED, never as clean
unresolved_epics, unresolved_reasonsevery epic the relation could not decide, with no_node_declared / no_intent_document / unreadable_intent_document
tracker_read, tracker_source, epics_unknown_to_trackerwhich tracker answered, and the epics it has no record of
documents_outside_declared_rootdocuments whose kind and root disagree
working{documents, exempt_from_freshness, reason, reach, pairs_excused}
findings{rule, path, line, why, remediation} per finding

working.reach names each declared kind and each declared root with how many documents it excused, so a declaration whose halves are half inert says which half. working.pairs_excused is null from this command rather than 0: docs spaces runs no freshness check, so it did not measure that number and does not print one. The beadloom ci doc-spaces line does carry it, because the sync-check step in the same run measured it and hands it over.

Measured on this repository, 2026-08-26: to_be 194, as_is 100, working 56; epics 62, epics_with_closed_beads 38, epics_declaring_nodes 5, refs_checked 17; epics_declaring_nothing 57 and epics_unknown_to_tracker 24, every one of them named rather than folded into a green count; and one epic_not_in_tracker finding. Every number here moves with the repository's own planning tree, so read them as a shape and re-run the command for a current value.

beadloom docs polish ​

Generate structured data for AI-driven documentation enrichment.

bash
beadloom docs polish [--format {text,json}] [--ref-id REF_ID] [--project DIR]
  • text (default) -- human-readable summary with enrichment instructions
  • json -- structured JSON with nodes (symbols, dependencies, existing docs), Mermaid diagram, and AI prompt
  • --ref-id -- filter to a single node

beadloom prime ​

Output compact project context for AI agent injection.

bash
beadloom prime [--json] [--update] [--project DIR]
  • --json -- structured JSON output
  • --update -- regenerate .beadloom/AGENTS.md before outputting context

Returns architecture summary, health status (stale doc-code pairs and lint violations), architecture rules, domain list, and agent instructions. The health line counts stale PAIRS (N stale pair(s)), because three code files of one package give three pairs over one document, and the ## Stale Pairs section lists each as - <doc> <-> <code> (<ref_id>), the pair as sync-check renders it. Both lists stop at ten entries and say how many they did not show.

beadloom setup-rules ​

Create IDE rules files that reference .beadloom/AGENTS.md.

bash
# Auto-detect installed IDEs
beadloom setup-rules [--project DIR]

# Target a specific IDE
beadloom setup-rules --tool {cursor,windsurf,cline} [--project DIR]

Creates thin adapter files (.cursorrules, .windsurfrules, .clinerules) that instruct agents to read AGENTS.md.

beadloom config-check ​

AgentConfigAsCode freshness gate: verify that generated agent-config is in sync with the graph.

bash
beadloom config-check [--project DIR]   # exit 1 on BLOCKING drift, 0 otherwise
beadloom config-check --fix [--project DIR]  # regenerate drifted artifacts, then re-check

Since BDL-061 S3 a drift carries a severity. error exits 1; warn is printed (! <file>: <reason> plus a -> <remediation> line) and exits 0, so an adopter upgrading into this release does not go red for a file scaffolded before the flow manifest existed. The clean line says which case it is — Agent-config in sync — no blocking drift (N warning(s) — see above).

Re-runs the same setup-rules --refresh generator in memory and diffs its output against on-disk content for .beadloom/AGENTS.md, the auto-managed sections of .claude/CLAUDE.md, and present IDE adapter files. For those three, only the auto-managed regions are compared — editing user-authored prose (the AGENTS.md custom block, CLAUDE.md content outside the auto-start/auto-end markers) never trips them. The composed artifacts are a separate check with its own rules, described below. Prints which file drifted, why, and the remediation; an absent target file is skipped unless the project adopted the flow, in which case it is missing. --fix regenerates via the refresh path (config_sync.apply_config_fixes), names every file it changed, declines any body Beadloom cannot prove it wrote, and re-checks. Delegates to onboarding/config_sync.py:check_config_drift().

As of BDL-048, when a repo has the agentic flow scaffolded (beadloom setup-agentic-flow), config-check also drift-checks the scaffolded flow files. As of BDL-061 S3 it checks them against their composition result rather than against fixed bytes: .claude/CLAUDE.md, each .claude/commands/* and each .claude/agents/* must equal CORE + the flow.yml overlays + the project layer in .beadloom/flow/. That is what makes a project extension legal — it is part of the expected output — while a change to a shipped fragment still differs from it and is reported.

Two things this closed, both measured:

  • The CLAUDE.md body was checked by nothing. config-check diffed only the marker-bounded auto-regions, so on a freshly scaffolded project, appending a project-local paragraph, deleting the whole of section 7, and replacing the entire file with the single line # gone all returned zero drifts — and beadloom ci printed config-check PASS: agent-config in sync over it (BDL-UX #177).
  • --fix used to delete hand edits. It restored every divergent file byte-identical, with no diff and no confirmation, which is why a team's standing engineering practice could not live in a role adapter at all (BDL-UX #139, #152). For the slash commands and CLAUDE.md it no longer does: it runs the scaffold's non-forcing path, names .beadloom/flow/<kind>/<name>.md and leaves the edit where it is.

--fix may only rewrite what Beadloom wrote (BDL-UX #186, closed in BDL-061 .59). The role adapters were the one kind left out: refresh_composed_adapters rewrote .claude/agents/<role>.md unconditionally, so doing what the closing line said (Run beadloom setup-rules --refresh (or config-check --fix) to fix.) undid what the line above it promised — and the re-check then printed Agent-config in sync — no blocking drift at exit 0 over the deletion. Verified by sha256 on a clean repository. Now:

  • an adapter classified hand_edited or unverified is declined: left byte-identical, named in the output, and its finding keeps the exit code honest;
  • everything else is recomposed as before, and the run names every file it created or rewrote, measured by digesting the artifact surface before and after rather than by trusting each writer's self-report;
  • the closing advice stops offering config-check --fix for a finding --fix will decline (ConfigDrift.fixable).

The remedy is unchanged and now actually terminates: move the additions into .beadloom/flow/roles/<role>.md, then re-run beadloom setup-agentic-flow.

And the remedy is now safe to follow literally (BDL-068 .67, BDL-UX #191). Until then setup-agentic-flow recomposed the adapter that this finding had just promised would not be rewritten, so an adopter who ran the remediation without doing the move first lost the edit — the same shape as #186, in the sibling command. Both commands now derive their preserve set from one function, config_sync.declined_adapter_rewrites(), so the sentence printed about a file and the decision taken about it cannot disagree whichever command took it.

Which of the two a divergence is, is decided by the flow manifest (.beadloom/flow-manifest.json): every write records the body's sha256, so stale (Beadloom wrote it, the composition moved — error, recompose), hand_edited (error, never rewritten) missing (we wrote it and it is gone — error) and unverified (nothing accounts for it, so the two cannot be told apart — warn) are separate findings and not one word. The CLAUDE.md body is JUDGED only when the file is Beadloom's: a manifest entry, or the <!-- beadloom:composed stamp the shipped core begins with — a project's own hand-written CLAUDE.md is never policed. Not judged is not the same as not mentioned: in a project that adopted the flow, a CLAUDE.md with neither signal is named at unverified/warn rather than passed over. Those two signals are independent on purpose: deleting the generated manifest used to downgrade a hand edit to warn and the command to exit 0, and deleting one scaffolded file used to switch the checks off for every other one. Neither does now — the deletions are themselves reported (BDL-061 .57). config-check also names, at warn, a project layer in effect (its prose is composed but not judged) and an overlays.suppress entry that has expired or that names no rule in the composed flow.

It also checks that a duty declared for a role reaches that role's composed core, in both directions (BDL-068 S4). A duty an agent is obliged to perform, written somewhere the performer does not read, is the class this check exists for: the clean-room rule lived in the coordinator's prose and occurred zero times in the role cores the roles receive. Duties are declared, never inferred — <!-- beadloom:duty=<id> roles=<a,b> --> in a composed flow artifact, <!-- beadloom:carries=<id> --> in a fragment that composes into one — because a detector over English role prose would repeat the docs-audit keyword-proximity class. Four findings, all error and none fixable (the repair is prose in a role core, and --fix writes compositions): undelivered (declared for a role whose composed core carries it nowhere), undeclared (carried and declared by nothing), unknown-role (a declaration naming a role no CORE fragment ships) and malformed (a duty= marker with no roles= list, which names no performer).

The duty line names the corpus it judged, which is the composition this flow would write and never the role files on disk:

Duties: 1 declared, checked over 10 composed artifact(s) — the COMPOSITION this flow would write, not the role files on disk.
  On disk: 5 role file(s), compared against their compositions by the agent-config drift check above, not by the duty check
  Not inspected (2):
    the coordinator's launch prompt — a prompt is not an artifact, so a duty carried only there is unreachable by any file-based check

The sentence exists because on a project holding no role file this check reported a duty delivered to five roles at exit 0, while beadloom guard --liveness reported the artifacts missing — two instruments answering "does a declared thing reach the role that must carry it?" of two different corpora, neither saying which (BDL-UX #241). Both questions are legitimate and the divergence is kept; what changed is that each states its own. With no role files on disk the count reads NOTHING TO CHECK, and the verdict is unchanged — an unscaffolded project is not in drift. not_inspected is printed on a clean run as well as a blocking one, and it is derived by subtraction rather than listed.

A downgrade across an upgrade is itself a finding (BDL-061 S3b). The constraint this project has always stated runs one way — no adopter's green project turns red on upgrade — and review .11 measured the other direction: a repo that hand-edited a role file before the flow manifest existed used to block at error and, after the manifest shipped, has no entry for that file, reads unverified, and warns at exit 0. A downgrade is the worse of the two, because a red is loud and correlates with the release while a downgrade is silent: the project was correctly failing, now passes, and the evidence it ever failed is gone. So every severity Beadloom reduced for want of evidence carries ConfigDrift.weakened_from, and the command says so — on the passing path as well as the blocking one:

Agent-config in sync — no blocking drift (5 warning(s) — see above).
  This pass is WEAKER than it would be: 5 finding(s) are `warn` only because
  Beadloom cannot prove what it wrote — each would be an `error` with the
  evidence. A verdict that got quieter across an upgrade is a finding, not a pass.
    -> restore `.beadloom/flow-manifest.json` (re-run `beadloom setup-agentic-flow`)
       to get the blocking verdict back.

The exit code deliberately does not change: a warn must not block, or fixing the silence would itself be the red-on-upgrade the rule exists to prevent. And nothing is recorded to compute it — the downgrade follows from the finding's own state, because config-check writing on every run to keep a verdict history would be BDL-UX #147/#189 in the one command whose job is to look without touching.

As of BDL-052 S3, when a valid .beadloom/flow.yml is present config-check also: (a) validates flow.yml itself (an invalid config is reported as drift; an absent one is not); and (b) byte-compares each composed role adapter (<tool>/agents/<role>.md for every tool the config names) against the freshly recomposed body (compose_role(...) for the configured architecture + stack overlays) — config_sync._composed_adapter_drifts. When a flow.yml is present the role agents are composer-owned, so the byte-vendor compare is skipped for agents (it would false-positive on a non-Python stack). --fix recomposes the per-tool adapter sets except the ones it declines (config_sync.refresh_composed_adapters, which returns an AdapterRefresh of rewritten + declined). Known limitation: the composed-adapter check iterates only the tools named in flow.yml, so adapters left behind by a tool dropped from a narrowed flow.yml (e.g. orphaned .cursor/agents/*) are neither flagged nor recomposed; a follow-up bead tracks an orphaned-adapter lint.

beadloom guard ​

Evaluate one flow guard — the enforcement primitive the agentic flow binds to (BDL-061 S1).

bash
beadloom guard NAME [--context KEY=VALUE ...] [--json] [--project DIR]
beadloom guard NAME --hook claude-code            # harness event as JSON on stdin
beadloom guard --liveness [--json] [--project DIR]

Returns a verdict {guard, outcome, why, not_covered[], remediation, context} — plus recorded and not_recorded_because under --json — where outcome is pass / warn / block / skip / error. Exit codes carry the outcome so a shell adapter needs no parsing: 0 for pass/skip, 1 for warn (shown, never blocking), 2 for block, and 3 for a usage or configuration error reported to a shell caller — deliberately not 2, which is Click's own usage code and would otherwise be indistinguishable from a genuine block. That distinction is answered only to the caller it means something to: reached through --hook, the same class exits 2 (BDL-061.33). It exited 3 there until S2, and 3 stops no tool call — so a .beadloom/flow.yml that would not parse left every bound guard announcing that it could not answer while every edit went through. The mapping lives in the CLI, keyed on the harness the adapter already declares, rather than in the generated script: a script that maps codes carries logic, and the next harness would have to re-implement it. warn, block and error are written to stderr (the stream a hook harness shows the agent); pass and skip go to stdout, as does --json in every case.

3 is for a defect in the project's declared configuration (an unparseable guards: block, an exclusion with no reason, a guard name nobody registered) and for a command line that could not be used at all (no guard named, --liveness with a name, a malformed --context pair, an unsupported --hook harness) — stable defects that fail the same way on every invocation. Anything that goes wrong while answering about this edit — a hook payload that cannot be decoded or parsed, a project that cannot be located, an exception anywhere — is an error verdict at 2, the one code the shipped adapter blocks on.

3 is not what a hook sees, and that is the point (BDL-061.33). The harness stops the tool call on 2 and on nothing else, so while the class exited 3 unconditionally, all five cases above were an error verdict — loud on stderr, and letting the edit through. The reachable one is a .beadloom/flow.yml that will not parse, the one file of this feature an adopter edits by hand: while it will not parse, every bound guard answered "could not tell" and nothing was enforced. The distinction 3 draws is worth keeping, so it is kept for the caller that can act on it and dropped for the one that cannot: beadloom guard run by a person or by CI still exits 3, and the same defect reached through --hook exits 2 and stops the edit. Mapping the whole class to 2 would have spent the distinction on a caller with no use for it; mapping it in the emitted script would have put logic in an adapter and left the next harness to re-derive it. An unsupported --hook harness blocks for the same reason, since Beadloom cannot know the exit vocabulary of a tool it does not support.

error means the guard could not answer — a refused path (see below), a guards: block that will not parse, a project that cannot be located, or any failure inside the evaluation. It exits 2, because the adapter's harness blocks on 2 and on nothing else, and because 1 is the warn code a harness reads as "carry on". Every invocation runs inside one boundary: argument parsing, the stdin read and the evaluation all come back through it, so a failure anywhere is a recorded verdict rather than a traceback — including a KeyboardInterrupt, which since BDL-061.31 is a recorded error at exit 2 rather than an escape to Click's exit 1. That means Ctrl-C during a guarded edit blocks that edit: an interrupted guard checked nothing, and "could not answer" must never read as "passed". Rendering the verdict happens after the boundary and is wrapped, so a failure while printing is reported on stderr and the exit code stays the verdict's. Every invocation that names a registered guard in a located project is recorded, error included — an evaluation missing from guard-firings.jsonl is invisible to --liveness. The four invocations that leave no record say so (not recorded: <reason> on stderr): a successful --liveness report evaluated nothing, an unlocatable project has nowhere to write, an invocation that named no guard asked about nothing, and an unregistered name has nothing to attribute the row to. The last two were one reason until BDL-061.34, which is why beadloom guard with no name used to report '(no guard named)' is not a registered guard — a placeholder quoted as though it had been typed, and identical to what a caller who really typed it got.

Shipped guards: bead-claimed (an edit happens under a claimed work item) and working-branch (work happens off the protected trunk; options.trunk, default main). Both skip — with a stated reason — when their evidence is unavailable (bd not present, no branch checked out), because a guard that silently does not apply is indistinguishable from one that passed.

Guards are declared in .beadloom/flow.yml; an absent guards: block means every guard runs at the shipped default (warn), so an upgrade never turns a green project red:

yaml
guards:
  bead-claimed:
    strictness: { default: warn, epic: block, chore: off }
    exclusions:
      - path: "scripts/**"
        reason: "operational scripts are not bead-scoped"
        until: "BDL-0xx introduces a scripts node"

Strictness resolves per work kind (--context work_kind=epic) with a default fallback. An exclusion must carry both reason and until — one without either is a configuration error (exit 3 from a shell, exit 2 through a hook), because an unnamed, undated exclusion disables a gate permanently by accident. until may name a deadline (it LEADS with an ISO YYYY-MM-DD, optionally followed by the prose that explains it) or an event (anything else, as above — what retires a real exclusion is usually a landed change, not a day). A deadline is parsed by the same function the forbid_import exemptions of rules.yml use, so the two surfaces cannot promise different things; once it passes, the exclusion says so in its own skip reason (… (until 2024-01-01 — EXPIRED)) and --liveness flags it as exit condition has passed: '<pattern>'. It is never enforced — the exclusion keeps applying, because a guard that starts blocking with no commit behind it is worse than the silence being reported (BDL-061.49). A guards: key naming an unregistered guard is likewise an error, not a no-op, and so is a key the loader does not read — a guard body carries strictness / exclusions / options, an exclusion carries path / reason / until (BDL-061.34): option: for options: used to drop the declared trunk and leave working-branch comparing against main, which passes an edit made on the project's real trunk at exit 0. There is no on: key: which tool invocations count as an edit, and which guards run on them, is decided by the harness adapter (in Claude Code, the matcher and the per-guard entries in .claude/settings.json), not by Beadloom. An on: key shipped in the S1 schema with no consumer and was deleted rather than quoted; it returns wired in S3.

That matcher is the enforcement surface, and it is narrower than "every edit". The emitted adapter is registered on PreToolUse for Edit|Write|MultiEdit|NotebookEdit, so a file written through Bash — sed -i, a heredoc, python3 - <<EOF — invokes no guard, produces no verdict and writes no firing. --liveness therefore cannot distinguish a session that edited entirely outside the matcher from one that complied: both leave the same record. This is a property of the binding rather than of a verdict, so no not_covered note can carry it (there is no evaluation to attach one to) — see BDL-UX #170 and the flow-guards SPEC.

With no --project, the project root is discovered by walking up from the working directory to the nearest ancestor containing .beadloom/; with --project, that directory is used verbatim and must carry .beadloom/ itself — the flag names a project, not a directory. A missing path, a file, and an ordinary directory with no marker are one refusal: a guard that cannot locate a project answers error (exit 2) and creates nothing, by any route. Until BDL-061.31 any existing directory was honoured, so --project <an ordinary directory> found no flow.yml, silently traded the project's declared block for the shipped default warn (a non-blocking exit 1), and manufactured .beadloom/ there when the firing was written — the record belongs to the project, not to wherever the process was pointed, and a firing written where --liveness does not read it is indistinguishable from no firing. A directory this process may not read is the same refusal since BDL-061.32: click.Path defaults readable=True, and that check runs in Click, so --project <an unreadable directory> used to be a usage error at exit 2 with no verdict, no record and nothing on stdout for --json to parse. No parameter of beadloom guard declares a conversion Click can refuse — every reason an argument cannot be used is answered by the guard, not by the argument parser.

--context KEY=VALUE is repeatable, and where a key is given twice the last occurrence wins. --context path=... is resolved against the project root before any exclusion is matched — .. collapsed, symlinks followed — so a declared exclusion cannot be turned into an opt-out by respelling the path (scripts/../src/app.py is guarded, not skipped). A path resolving outside the project root is matched against no exclusion at all, and the verdict names it in not_covered.

The path is model-supplied, so its shape is narrowed rather than repaired: a well-formed target carries no C0 control character or DEL, no separator spelling this platform does not read, no leading ~, and is encodable for the filesystem, and it is judged exactly as supplied — nothing is stripped first, because str.strip() also removes nine C0 characters the same rule refuses, which turned a block into a skip quoting a pattern that does not cover the file. Anything else is refused with an error verdict naming the offending rule — never normalised into a guess, and never a traceback. Each rule removes a spelling that means one file to the guard and another to the writer (src\app.py skipped a *.py exclusion while the write landed on src/app.py; a NUL crashed the process out on exit 1, the non-blocking code, leaving no record at all).

The separator rule is about the separator, not about the backslash (BDL-068 S4). The refused set is every separator spelling Python's own path modules declare — ntpath.sep, ntpath.altsep, posixpath.sep — minus this platform's own, read from os.sep and os.altsep. On a POSIX machine that is the same single character as before, so nothing moves here; on Windows it is nothing, where the literal-backslash rule had made every edit target MALFORMED while the reason it printed was false there. The Win32 name layer then owes three refusals the shape gate never made, each one a name that layer silently REWRITES: a trailing dot, a trailing space, and the twenty-two reserved device names (CON, PRN, AUX, NUL, COM1–COM9, LPT1–LPT9), which resolve to a character device in any directory and under any extension — docs/CON.md is the console. The characters Win32 forbids outright (<>:"|?*) are deliberately not refused: such a write fails loudly with nothing created, so the guard and the writer never look at different files. The boundary is silent rewriting, not illegality. The platform is a substitutable argument (PathFlavour), so both platforms' rules are measured on whichever machine runs the suite rather than behind an xfail.

--hook HARNESS reads the harness's own hook event as JSON on stdin and derives the context from it (claude-code: tool_input.file_path, tool_name, hook_event_name). The event is read as bytes and decoded as UTF-8 strictly, so a payload the harness could not encode is refused (error, exit 2) identically under every locale — reading it as text left the decode to sys.stdin, whose error handler is surrogateescape under LC_ALL=C/PYTHONUTF8=1 (the default in most containers), and there the undecodable bytes silently became a file name the guard then evaluated (BDL-061.36). The emitted adapter (.claude/hooks/beadloom-guard.sh, written by beadloom setup-agentic-flow) contains no logic — it is one exec beadloom guard "$1" --hook claude-code — so a hook and a shell cannot produce different verdicts.

--liveness reports, per guard, its effective strictness, how many times it fired, its last outcome, and four ways a gate stops protecting anything: never-fired (no firing that reached a verdict — an error is counted and shown, but does not clear the flag, because a guard that ran three times and answered none of them is not a live gate), excluded-everywhere (every strictness off, or nothing escapes the exclusion list — decided by matching the patterns against representative paths, not by comparing spellings, and asked of the list because * and */** are each narrow and together exempt everything), matches no file in the project: '<pattern>' (a declared exclusion that exempts nothing that currently exists — a typo'd scrpits/** is safe but was silent), and exit condition has passed: '<pattern>' (its until: names a date that is behind us). A gate that cannot demonstrate it ran is treated as not having run. Every CLI evaluation appends one line to .beadloom/guard-firings.jsonl, which is the only file guards write — never the index they inspect. Decision logic lives in application/guards/evaluation.py; the CLI only renders it.

--liveness also reports the binding's SURFACE, above the firings (BDL-068 S4, BDL-UX #170). The firings say how often a gate ran; the surface says what fraction of the write paths the emitted role adapters grant a registered matcher could have seen at all, so a write the binding never watched is a stated gap instead of a silence. It is derived — the matchers from .claude/settings.json, the tool population from the tools: line of every emitted role adapter — and it reports what it could not classify rather than assuming it harmless: a tool no declaration covers is unclassified, and a source it could not read is unresolved. The line names the corpus it answered about (read from: .claude/settings.json + .claude/agents/*.md as they are ON DISK), because config-check's duty report answers the neighbouring question about the COMPOSITION and the two looked like they agreed (BDL-UX #241). It has three sentences and not two: NOT CHECKED when a source was unreadable, NOTHING TO CHECK when the population was read and holds no write path, and otherwise the fraction. 0 of 0 write path(s) bound can no longer be printed, and covered is null in both non-fraction states in --liveness --json, so it cannot be parsed out either — an absent denominator is not a full one, and that false green was in the instrument built to report exactly that class (BDL-UX #239). On this repository the report reads surface (claude): 3 of 3 write path(s) bound, and it was 2 of 3 before Bash was named by the matcher.

What the record holds changed with it. A shell edit's firing carries the program that ran and the write targets a declared set of write shapes names — command_name and command_writes — and never the command line, which is reduced at the one door the context is built at, so --hook and --context command=… cannot disagree. This is reduction rather than redaction: redacting KEY=value and header-shaped operands is a denylist, and the next credential arrives in a spelling nobody enumerated. Measured on this repository, one rotated generation held 1 999 firings of which 1 927 had stored the line they fired on — 2.0 MB of one machine's shell history at 1 007 bytes a record — against 557 bytes a record for the same firings replayed through the reduction. It does not reach a record already written; nothing rewrites the file it is evidence in.

beadloom waves ​

Decide which of these beads may run at the same time.

bash
beadloom waves BEAD [BEAD ...] [--parent WORK-ITEM] [--json] [--project DIR]

Exit codes: 0 = a shape was decided and rests on nothing unstated; 1 = a shape was decided and carries findings (a bead whose declared scope could not be read, an override past its exit condition, an override that changed nothing, a shared medium that failed its check or that nobody measured, a ready list the tracker capped, an in-progress bead under the work item the tracker could not show) -- visible, never blocking; 2 = no shape could be decided (no index, no answer from the tracker, a bead the tracker does not have, a --parent whose beads could not be derived, neither a bead nor a --parent, a waves: block that would not parse).

Every plan says how many ready beads under the same work item it was not asked about, and that count is a notice rather than a finding. The bead list was the one input here a human typed, and this project's own coordinator lost three beads of a slice that way -- all three sat in bd ready --limit 0 through fifteen launches and no plan could report their absence, because nothing knew they should have been present. --parent WORK-ITEM is the other half: it derives the list from the tracker -- every bead ready under that work item -- so the caller states the work item instead of the list. Passing a subset stays legitimate; what the notice adds is that the narrowing is visible.

Every plan is compared against the beads already in progress under its work item, whether or not --parent was given. A bead in progress is not ready, so before BDL-UX #283 a running bead was compared against nothing and the plan printed 0 serialisation(s) beside it. A conflict with running work is printed apart from the plan's own serialisations, because it does not order the plan's waves -- it holds a planned bead back until the running one lands:

1 wave(s) for 1 bead(s), 0 serialisation(s), 1 against 1 running bead(s), 0 finding(s).

Wave 1: epic.2
  combined-tree gate: epic.2
  clean room: epic.2 -> room-epic.2
  waits for running work: epic.2 behind epic.1

In progress under this plan's work item, and compared against it:
  1 in-progress bead(s) under epic and not in this plan: epic.1
  Serialised against running work:
    epic.2 waits for epic.1 — shared_node: billing

A serialisation against running work is not a finding. An in-progress bead the tracker could not show is: the first line counts it as (N not compared) and the plan exits 1 with running_not_compared. A plan with no derived work item says running work not compared instead of a count. --json carries the same facts under running -- work_item, in_progress, compared, not_compared, conflicts (each with planned, running, reason, detail) and reason.

A work item's population is its parent-child closure plus every bead any member of that closure depends on. The parent link alone is not enough: two of the three lost beads had no parent at all and belonged to the slice because they blocked it.

It decides, it does not advise. Parallelism follows from the code-level independence of the beads' node scopes, which only the architecture graph holds: a tracker knows which beads block which, and nothing else knows which code they occupy.

A bead says what it occupies in the tracker, in its own words -- refs: billing, shipping (also ref:, also area:). The declaration opens a line and its list runs to the end of that line, separated by commas or semicolons; a refs: written inside a sentence is prose. A scope expands downward through part_of, so a bead scoped to a domain and a bead scoped to one of its components do not compare independent while editing the same package. A pair is serialised for exactly one named reason: blocked_by_bead, unresolved_scope, shared_node, shared_file, dependency_edge or override_serial.

A bead whose declaration cannot be read is serialised against every bead. An unknown scope is not an empty scope -- an empty one compares independent of everything, which would make the command's whole claim rest on silence. Four things count as unreadable, each printed with its own remedy: no declaration (no_declared_refs), a name the graph does not have (ref_not_in_graph), a refs: written inside a sentence (declaration_not_at_a_line_start), and a second ref written without a comma that the graph confirms is a node (declaration_dropped_a_node). The parser fails toward serialisation on purpose: a wave shape is acted on, so a parser whose errors widen a wave is worse than no parser.

Every wave prints the seven media it shares, whatever its width, each with the evidence it comes from: the graph the plan is derived from (#261), the working tree (#181, #235), the commit gate (#118), the landing order (#194, #237), the focus document (#257), the doc baseline (#163, #182, #133) and the tracker's id space (#171). Until BDL-068 S4 a wave of one printed not_applicable against three of them; that verdict is gone (beadloom-67t1). A plan is one slice of one epic, so a wave's width is not a claim that its bead is alone in the tree — and the working-tree check exists precisely to report paths that no bead in the plan owns, which is a question a wave of one can fail.

Each wave names one bead as its gate_owner -- the bead that measures the combined tree once the wave has landed. It is assigned deterministically rather than wisely; the point is that the step belongs to a named bead instead of to a coordinator's habit. Each bead is also given its own clean-room path, room-<bead-id>, printed per bead: two agents in one wave once each built a room called cleanroom under one shared scratchpad and one of them measured over its neighbour's untracked files (BDL-UX #235). A room whose name cannot say whose it is is a shared directory with a reassuring name.

Each of those media is also checked, and each check can fail. The command measures a precondition per medium before the wave runs: that no path differs from HEAD which no bead in the plan owns (git status), that the installed pre-commit hook judges the paths a commit stages (.git/hooks/pre-commit), that every instruction of the landing lock in the composed flow artifacts names its holder and asks for no queue (the flow artifacts), that no doc pair is stale already (the doc index), and that no bead's title numbers it differently from the id the tracker allocated (the bead records). A medium that could not be observed is reported unmeasured, which is a finding: a concurrent wave nobody measured is not a clean plan, it is an unmeasured one. What is NOT checked is the wave's conduct afterwards -- nothing here can know whether the gate owner ran the combined tree.

The tracker-ids check runs even when the plan is fully serial, because a concurrent bd create shifts an id out from under the number an author already wrote into the title, and that happens before any wave runs (#171).

The landing-order check reads what an agent is TOLD about the merge slot, not what the tracker does. Measured on bd 1.0.4 in an isolated rig with every exit code read without a pipe, the primitive is sound: acquire refuses a held slot with exit 1, and one of eight simultaneous acquires won in each of four rounds. Three things about the DEFAULT call form are not — an acquire with no --holder takes the tracker actor, which is one identity for every role on one machine; a release with no --holder frees whoever holds the slot and reports success; and --wait appends the caller to a queue nothing drains and returns at once. Each is reported per site, judged from the FLAGS of the invocation rather than from the prose around it, and a subcommand this check has not measured is reported as unknown-form rather than passing. The population is the composed flow artifacts an agent is handed — the agent directories, the slash commands and the project layer — so a project that has never scaffolded a flow instructs the lock nowhere and the verdict says that instead of reading as a pass over something.

The declaration is also held against the derivation the work item recorded (BDL-068 S4, BDL-UX #232). A bead's refs: was the one input to this command that a human authored, so two beads editing one document read as independent whenever neither declaration happened to name the node that owns it — measured, not hypothesised: beadloom-0mdo.21 (refs: review-brief) and beadloom-0mdo.26 (refs: mutation-scope, ci-gate) both edited docs/services/cli.md, and the plan reported 1 wave, 2 beads, 0 serialisations and 0 findings. The fix is not to stop reading the declaration: the declaration still decides the shape, and what is added is the comparison against the ## Axes section of the work item's own document, composed at the services edge from the same read beadloom scope-check makes, so the commit gate and the plan cannot come to disagree about what one approval covered.

The report prints one line per axis — agrees, ruled_out_of_scope, no_scope_decision, not_derived or not_attributed — and three of the five are deliberately NOT findings. A ref the table never names is the derivation not reaching (BDL-UX #225: seeded under tests/, beadloom impact attributed a node to none of the 148 caller sites it found); an axis row attributing no node is compared against nothing; and a row nobody ruled on belongs to axis-without-a-scope-decision in docs-quality, not here. The two that are findings are declared_outside_the_axes — a bead declares a ref the work item ruled out — and unguarded_axis, reported per wave and only where a wave holds a pair, naming the approved nodes none of the wave's beads declares. Per plan it would print a finding on every single-bead plan, and an always-red check is an ignored check. The unit is the WORK ITEM's axes and never the claimed bead's, so a bead narrowing inside them raises nothing. A plan whose caller gathered no ## Axes section reports declarations_not_compared rather than agreement.

The unguarded_axis remedy — generate each bead's refs: from the ## Axes section — is filed as BDL-UX #245 and routed to S6. beadloom axes <doc> --refs prints one line generated from every kept row, and there is no per-bead selection, so following it literally hands every bead an identical scope and collapses every wave to a wave of one. The verdict is correct; the advice beside it is not yet.

A human outranks the decision by declaring it in .beadloom/flow.yml, with a reason and an exit condition like every other stand-down in this tool:

yaml
waves:
  overrides:
  - beads: [proj-1, proj-2]
    decision: parallel        # or: serial
    reason: "the two touch one vocabulary module and nothing else"
    until: "2026-09-01"

Every key is required, and required by its content: a key present but blank is a configuration error too, because an override with no reason and no deadline outranks the graph permanently by accident. Each override is reported with the number of decisions it changed -- the number of its pairs the shape decides differently when that entry is removed -- and one that changed none is a finding, because an override nobody can see doing anything is how a check gets switched off without anybody saying so.

$ beadloom waves proj-1 proj-2
2 wave(s) for 2 bead(s), 1 serialisation(s), 0 against 0 running bead(s), 0 finding(s).

Wave 1: proj-1
  combined-tree gate: proj-1
  clean room: proj-1 -> room-proj-1
Wave 2: proj-2
  combined-tree gate: proj-2
  clean room: proj-2 -> room-proj-2

Serialised because:
  proj-1 | proj-2 - shared_node: billing

0 declared override(s).

Plan-time precondition of each shared medium:
  working-tree: passed - no path differs from HEAD that no bead in this plan owns
  commit-gate: passed - the installed pre-commit hook judges the paths a commit stages
  landing-order: passed - all 18 instruction(s) of `bd merge-slot` name the holder
  doc-baseline: passed - no doc pair is stale
  tracker-ids: passed - every bead's title agrees with the number the tracker allocated

Every plan prints that block and every medium is measured in it, a fully serial plan included.

--json carries the same facts: waves[] (index, beads, gate_owner), rooms (bead id to clean-room path), beads[], scopes[], conflicts[], overrides[], shared_media[], media_checks[] (medium, status, detail), axes (the work item, its document, the seed and what the derivation could not reach), agreements[] (bead, ref, verdict, detail), unguarded_axes[], findings[] and exit_code.

beadloom clean-room ​

Build the clean room a bead measures in, from HEAD plus the files you name.

bash
beadloom clean-room BEAD [--at DIR] [--carry PATH]... [--extras LIST] [--no-environment]
                    [--rebuild] [--project DIR] [--json]

beadloom waves prints the room each bead owes (clean room: <bead> -> room-<bead>); this command is what creates it. The two are one spelling — the path comes from the same room_for(bead_id) the plan prints — so a room cannot be named after the concept instead of after its occupant.

Why the command exists, measured. In BDL-068 S4 wave 1 two agents each built a room at the same session-scratchpad path. Reconstructed from mtimes: one agent's git archive at 22:53, the other's files copied in at 23:16, the first's at 23:26. The suite run there reported 8 failures and five of them belonged to the neighbour, none a defect in either bead; rebuilt under a bead-unique name it reported 1, a stated property of the room (BDL-UX #235). Separately, copying changed files into a room that had already been indexed produced sync-check exit 2 with stale: 2 against a change that is clean at HEAD, because the copy postdates the room's own freshness baseline (BDL-UX #243). Both are the same missing guarantee, and both are answered by deriving the path from the bead and creating the directory rather than entering one.

So a directory that already exists is refused, never written into, and --rebuild REPLACES a room rather than refreshing it. A rebuild deletes only a directory whose .beadloom-room.json names this bead: a directory that merely carries the right name is refused, because removing a path chosen by a caller's typing is a worse failure than the one this command was written for.

--carry copies exactly the files you name, and there is deliberately no "copy everything that differs from HEAD" mode — on a shared working tree that set holds your neighbour's work, which is #235 reached by a second route. A room under the project root is refused too: it would become untracked work in the tree it copies.

--rebuild does not make you retype that list. It reads the request out of the .beadloom-room.json it is about to delete: the carried files, and the extras you pinned. Measured on the bead that built this command, 16 --carry flags were entered twice, once after each fix the room itself caught — and the alternative an agent reaches for under that friction is to copy files into the live room, which is #243 again. What is reused is the LIST and never the content: the files are copied from the working tree at build time, so a rebuild is still a room nothing inside postdates. A --carry or --extras given beside --rebuild REPLACES its remembered counterpart rather than adding to it, so the remembered list cannot grow into the mode that deliberately does not exist, and a remembered path the tree no longer holds refuses the rebuild with file_missing while the room is still there. --no-environment is recorded and NOT reused: remembering a decline would hand back a room whose verdict the machine decides (BDL-UX #256) with no way to ask for one short of deleting the room, while forgetting it costs 3.6 s and gives the room its own interpreter. reused[] in --json, and one line in the human shape, name what was taken from the replaced room.

$ beadloom clean-room proj-1 --at /tmp/rooms --carry src/billing.py
room-proj-1 built at /tmp/rooms/room-proj-1
  from commit 4f2c1ab9…, holder recorded as proj-1
  carried from the working tree: src/billing.py
  extras the invocation's interpreter has: all+dev+graphql+languages+tui+watch
  environment: built by uv in 1.07s with extras dev+graphql+languages+tui+watch — the
    union of every extra the 8 installing leg(s) of this project name, because a missing
    extra removes tests from a run without failing it and a surplus one removes nothing
  tracker status: in_progress

Measure in the room, not in the tree:
  PYTHONPATH=/tmp/rooms/room-proj-1/src /tmp/rooms/room-proj-1/.venv/bin/python -c "import beadloom; print(beadloom.__file__)"  # must print a path under /tmp/rooms/room-proj-1
  PYTHONPATH=/tmp/rooms/room-proj-1/src /tmp/rooms/room-proj-1/.venv/bin/python -m pytest /tmp/rooms/room-proj-1/tests

What this room cannot answer: it carries no .git, so a freshness check inside it has no
baseline; and its verdict is a claim about these files only, never about the combined
tree — that measurement belongs to the wave's gate owner. Report it in those words, with
the extras above: on this project one code base gave 0 mypy errors under `[all,dev]` and
82 under `[dev]` (BDL-UX #236).

The PYTHONPATH line is a measured trap rather than a formality: with an editable install, running the suite from inside the room under the project's environment imports the tree's source, and the first run that did it was caught from a warning path rather than from a failure — a green that is a measurement of the tree wearing a room's name. The import line is the check, and it must print a path under the room.

The extras line is the second half of that trap (BDL-UX #236). A room's name isolates its FILES; which optional extras its interpreter has is a separate question, and it decides the verdict. Measured on this project at 6c4d0a9, over one code base at one commit: mypy src/ reported 0 errors under .[all,dev] and 82 under .[dev], and under the second the whole tui suite left the run — three of its four modules skipped and the fourth stopped the collection with an error. The room therefore STATES the extras its invocation's interpreter has — the same derivation beadloom rooms reports, and recorded in .beadloom-room.json under interpreter.extras so a report can be checked against the room it was taken in.

And the room BUILDS that interpreter (BDL-UX #256). A room isolates the files a verdict is taken over; until this landed, nothing isolated the interpreter they run under, so a correctly-named room still returned a verdict decided by whatever the machine held. The command therefore creates a virtual environment inside the room and installs the room's own sources into it, and the invocation above names that interpreter rather than the project's.

Which extras: the union of every extra any leg of this project's workflows installs, not a constant and not the set most legs declare. The modal reading is wrong on this repository — of the 8 installing jobs, four install dev, languages to build a site or run a release gate and two run the suite — and the two errors are not symmetric: a missing extra removes tests from a run without failing it, a surplus one removes nothing. Measured warm, macOS/APFS: the union installs in 1.07 s for 169 MB against 1.78 s and 160 MB for .[all,dev]. A leg spelling --all-extras is expanded from [project.optional-dependencies].

--extras dev,tui names them instead, to reproduce one particular leg; --extras "" asks for an environment with no extras, which is a different request from naming none. A set you pinned survives a --rebuild and a set the legs derived is derived again — pinning that one would carry a set nobody named into every later room. To leave a pinned set, name another: beadloom rooms --dimension extras prints the sets the legs declare. --no-environment builds the files and no interpreter, and is the only way to get that without a finding.

Cost, paid per room and never cached. uv venv 0.082 s, uv pip install -e 1.07 s, room 184 MB apparent — under half a percent of a seven-minute suite. Every --rebuild pays it again on purpose: an environment kept outside the room and reused is a directory two rooms share, which is BDL-UX #235. The reuse that matters is uv's own content-addressed package cache. Without uv, python -m venv plus pip install -e is used instead and measured 1.84 s plus 39.6 s over the same tree, so the room records which installer built it.

Exit codes: 0 the room was built, it holds its own interpreter, and the tracker says the bead is in_progress; 1 the room was built and something about the measurement it supports is unconfirmed — the bead is not in progress, the tracker could not be reached, or the room holds no interpreter of its own and its verdict will be the project environment's; 2 no room was built. A run that exits 2 leaves the directory it declined to enter exactly as it found it.

Refusals are named rather than described, so a caller can branch on them: already_exists, not_a_room, inside_the_project, no_commit, file_missing, not_a_file, file_outside_the_project and unknown_bead.

--json carries the same facts: bead, room, built, refusal, detail, commit, carried[], reused[], invocation[], extras, environment, claim, findings[] and exit_code. reused[] names the request parts a rebuild took from the room it replaced, carry and extras, and is empty on every build that was not one. environment carries built, source (legs, caller or underived), asked[], installer, seconds, python and detail.

The room's .beadloom-room.json records the bead, the room's name, the project, the commit, the build time, the carried files, the REQUEST that built it, two interpreters, the extras, the environment and the Beadloom version. request carries carry[], extras (a list, or null when the derivation was left to the legs) and environment, and it is what a --rebuild reads. It is recorded beside the outcome rather than read back out of it because the two come apart: a room given no environment records no extras choice at all, so a request reconstructed from the outcome would lose the set you pinned. The two interpreters are not the same one: the invocation names the environment the SUITE runs under — the room's own when it has one — while built_by is the process that made the room, which under a uv tool install is a different interpreter with neither pytest nor the project's development dependencies. interpreter.extras carries distribution, resolved, label, installed[] and absent[], read off the ROOM's interpreter when it has one; resolved: false means that interpreter holds no distribution of that name, and it is not the same answer as no extras. What environment.asked records is a request and what interpreter.extras records is the answer, and the two can differ.

beadloom review-brief ​

Hand a reviewer the change and the specification, and not the author's account of either.

bash
beadloom review-brief BEAD [--since REF] [--release] [--json] [--project DIR]

Exit codes: 0 = the brief rests on nothing unstated, or --release released the account; 1 = the brief carries findings (an undeclared scope, an ambiguous one, an unknown ref, a change nobody could measure, a change outside the declared scope, no bound scenario -- or, under --release, a verdict whose independence the tracker could not confirm); 2 = no brief could be assembled (no index, no answer from the tracker, no such bead); 3 = --release was refused because no verdict is recorded. 3 is distinct from 2 on purpose: nothing failed, the account is simply still withheld, and a caller that could not tell those apart would retry the wrong one.

The brief carries the assignment (the bead's title and description), the declared scope, the specification (the graph's documents for those nodes and every scenario whose @bead: tag names the bead) and the change (git diff <base>...HEAD, the working tree, and the untracked files, each path carrying the node that owns it). It does not carry the bead's comments.

The measurement behind that ordering: in hidden-profile tasks a group that hears one member's conclusion first scores 17-36% where a single holder of all the facts scores ~100%. So the account is released after the reviewer's own judgement is recorded rather than never.

The reachability block ​

The brief closes with a statement of what can reach the reviewer, per channel -- not with a count of what this command holds back. Four channels, each either inspected or named as one nothing here can inspect (the output is wrapped for this page):

REACHABLE — what can reach you about this change, per channel. This command withholds one
of them and closes none of the others; declaring what actually reached you is still yours
to do.
  bead comments: 0 item(s) — counted on <bead> and on no other bead — the beads that made
    this change are neither read nor counted here; withheld by this command until a verdict
    recorded on the bead; then `beadloom review-brief <bead> --release`; the author's account
    of the change converges the reviewer on the author's framing before the reviewer has
    looked at the code
  the work item's documents: 5 item(s) — the folder
    .claude/development/docs/features/BDL-068, against the 9 document name(s) this
    project's composed prompts mention
    .claude/development/docs/features/BDL-068/ACTIVE.md — named by roles/dev,
      commands/checkpoint, commands/coordinator, commands/task-init, commands/templates
  the commit bodies of the reviewed range: 19 item(s) — read over the range since main;
    19 of 19 carry a body, and your protocol sends you to this diff
    ab7e9fa9 [BDL-068] feat: review-brief states what is reachable — 24 body line(s)
  the launch prompt: NOT INSPECTED — nothing in this process can see one, so: if your launch
    prompt carried anything about this change that you did not derive yourself — the author's
    summary, or the coordinator's own observation of it — this withholding was defeated
    before it ran; say so in your verdict, you are the only party that can see it

The count that was printed before S2 said 4 author comment(s) withheld and nothing else, and a reviewer read it as a statement about its own knowledge. It is not one: all three defeats of the withholding measured so far reached the reviewer through a channel that count never mentioned -- ACTIVE.md (BDL-UX #212), the commit bodies of the reviewed range (BDL-UX #219), and the launch prompt (BDL-UX #204). Each was known only because a reviewer declared it unprompted.

This raises detectability and closes nothing. The review protocol itself sends the reviewer to the diff, and the commit bodies come with it; no report changes that. What changed is that a reviewer can now see what it could reach and say so, which is how all three defeats were found in the first place.

A channel found empty never reads like one nobody could inspect.0 item(s) and NOT INSPECTED — <reason> are different sentences. A flow.yml that will not parse costs the documents channel and not the brief: the channel reports NOT INSPECTED — the project's flow.yml will not parse, so no prompt could be composed, where before S2 the malformed file raised out of the command and no brief was produced at all.

A commit body is counted, never quoted -- subject and non-empty body-line count only, so a report about a leak is not itself one.

The documents channel is derived rather than listed: the names come from the composed role and command prompts for this project's flow.yml, project layer included, matched by shape (an upper-case name ending in .md). A team that names DECISIONS.md in .beadloom/flow/roles/review.md moves this report by that act.

Two channels the block does not name ​

Both are measured, filed, and open. A reader of this section should not take the four channels as the whole list.

  • The tracker export inside the reviewed diff (BDL-UX #229). Where the project commits its tracker, the diff under review carries the author's comments as data. Measured on this feature's own S2 review: 16 added record lines, 30 author comments, 81,270 characters of comment text. The brief's own change inventory lists that file and prints read it: git diff <base>...HEAD -- <path> beneath it, so the report sends the reviewer to a channel it does not count -- BDL-UX #219's mechanism one step further along. Widening the count to that export and to the slice's sibling beads is filed, not done.
  • The work item's documents on a branch whose name carries a suffix (BDL-UX #230). work_item_of_branch matches a /-separated segment that equals a work-item key, so features/BDL-068 names the work item and features/BDL-068-S2S3 names none. Measured on this feature's own development branch: the channel read NOT INSPECTED — the branch 'features/BDL-068-S2S3' names no work item among the project's planning documents while the reviewer was reading RFC.md and CONTEXT.md out of exactly that folder. The fix belongs in application/declared_scope.py, its one home, and is filed there.

The release half ​

--release prints the account once a verdict comment is on the bead, so the deferrals, sabotage tables and measured numbers stay available to a reviewer who would otherwise re-derive them. A verdict is a comment whose first non-blank line opens with REVIEW PASSED:, REVIEW ISSUES: or REVIEW FINDINGS:, the colon included -- the exact openings the review role is instructed to write.

A refusal names the bead its count was taken over, in the same vocabulary the reachability block uses:

$ beadloom review-brief <bead> --release
WITHHELD — bead comments on <bead>: 0 item(s) stay withheld: no verdict is recorded on
this bead — the author's account stays withheld until one is. Record it with
`bd comments add <bead> "REVIEW PASSED: ..."` or a findings comment opening `REVIEW ISSUES:`

0 item(s) there says this bead carries no account. It says nothing about the beads that made the change, and on a wave-structured slice — where the brief is for a review bead — that is the ordinary case: the S2 review of this feature read 0 item(s) while 31,544 characters of the author's account sat on the two beads that made the change.

The verdict comment's author is compared with the bead's assignee, and the answer is reported, not enforced. A self-recorded verdict still releases, prints why its independence cannot be established before the account rather than after it, and exits 1:

$ beadloom review-brief <bead> --release
FINDING: the verdict was recorded under the same tracker identity as the bead's own
author (v.zoologov), so this gate cannot tell an independent verdict from the author's
own — say which it was in your review
RELEASED — 5 author comment(s), on the verdict 'REVIEW ISSUES' already recorded.

A tracker that names no author for the verdict comment gets its own note rather than that one — the tracker named no author for the verdict comment, so this gate could not tell whether the account was released by its own author. Two different facts, so two sentences: a shared identity and an absent author field are not the same finding, and the S2 review of this feature met the second where it predicted the first.

Refusing was rejected on a measurement: where every role writes under one tracker identity, a refusal refuses every release, and a gate nobody can pass is bypassed rather than obeyed. What this command withholds is an input, not a door -- a reviewer with a shell can read the comments directly, and the value is in the default and in the reachability statement being printed where the reviewer will read it.

The change is measured over the branch, not over the bead, because no per-bead attribution exists in the commits. On a branch carrying five beads all five briefs report the same files, so the changed-outside-scope finding names its window (measured over the branch since <ref>). --since <ref> narrows it.

--json carries bead, title, assignment, refs, unknown_refs, docs, base_ref, change_measured, changed, scenarios, reachability, findings and exit_code. reachability is an array of objects carrying channel, inspected, carries, reason and items — it replaced the withheld object in S2, a declared break. Under --release it carries withheld_count, verdict_marker, verdict_author, independence_note, refused_reason, released and exit_code; withheld_count is unchanged, because the break was declared for the before half only. The account never appears in the non-release --json.

beadloom mutation ​

The score a run produced, held against the mutation scope the project declared (BDL-068 S3.1). It reports a mutation run. It does not perform one.

bash
beadloom mutation [--project DIR] [--stats FILE] [--target PATH]... [--only PATH]...
                  [--tool NAME] [--min-score FRACTION] [--changed-since REF]
                  [--survivors FILE] [--sample-of N] [--json]

Beadloom ships no mutation runner, and this command needs none installed. The tool is the project's choice, because owning one would tie the flow to a language (BDL-061 CONTEXT Q5) — which is why BDL-061 S4 shipped the mutation duty with no score behind it. What ships here is the seam the project's own runner meets: --stats names a JSON object of counters that whatever tool the project ran wrote, and this command reads them by name. killed and survived are required; timeout, no_tests, skipped and suspicious are optional; total is accepted as a second spelling of mutants. Nothing under src/ imports a runner, and a test asserts it.

A counter it did not find is reported, never read as zero (mutation-counters-missing). A missing killed read as zero produces "0%", and a number is what gets pasted into a bead comment. The same refusal covers a non-integer, a negative value, and a stats file that is absent or is not a JSON object.

Timeouts count as killed; mutants no test covers do not. A mutant that hung was detected. A mutant nothing executed was not, and leaving that class out of the denominator is how a slice with no tests scores 100%.

  • --stats FILE — the counters a run wrote. Without it the command still reports: every declared target is then measured by no run, which is a finding rather than silence.
  • --target PATH — a path the run covered. Repeatable, and required whenever --stats is given without --changed-since: a run that does not say what it covered exits 2 rather than being assumed to cover the declared scope.
  • --only PATH — judge only these declared targets; the rest print as Not judged by this run. A first slice measures one target of several, and both obvious alternatives are wrong. Reporting the rest as findings makes a scheduled job permanently red, which is how a check stops being read; dropping them from mutation.targets deletes a duty to make a job green.
  • --tool NAME — the runner that produced the counters. Omitted, the report says an unnamed runner: a score whose producer is unnamed is a weaker claim and should look like one.
  • --min-score FRACTION — the floor the score must clear (0.95 is 95%). A floor declared against a score that does not exist is missed, not passed.
  • --changed-since REF — cover one change instead of the declared scope (BDL-074 D1). The change is the diff between the merge base of REF and the working tree, so uncommitted edits count and untracked files do not. The report states its POPULATION: the files changed, those inside the declared scope, the functions touched there (a top-level function or Class.method), the node owning each, the test files the binding ties to that node whatever placement bound them, the acceptance step files whose loaded scenarios carry that node's @node: tag (BDL-074 G1, printed as , N acceptance step file(s) by tag on the node's line), and the test files bound to no node stated by why (BDL-074 F1): the unplaced count ctx and the debt report state, over the folders of the test layout the index recorded (beadloom-2mj3.15), then the unowned files, then each other kind by its count. That Binding: line is printed only when some test file is bound to no node, and --json carries the counts as the change's test_placements and other_kinds, each node's acceptance_tests, and the unplaced_tests list the runner falls back to. unplaced_tests replaced unbound_tests in BDL-074 G1: it holds the unplaced files only, never a self-check or an acceptance step file. Changed lines outside any function are counted, and a file that is not parseable Python is named as not read. With --stats the run is taken to cover the changed files, --target is not needed, and the declared targets print as Judged by this run: the functions above — a change covers functions, not declared targets. Without --stats it prints the population a runner is given. It reads the index.
  • --survivors FILE — a JSON list of {path, mutant} objects, printed as Survivors: N over K node(s) and one line per node, each file placed under the node that owns it. An empty list prints Survivors: none. It reads the index.
  • --sample-of N — the counters are a random sample drawn from a population of N mutants. N is the population, not the sample: the sample's size S is what the counters scored. Prints Sample: a random sample of S of N mutants; 95% interval L% to H% (Wilson). With --min-score the floor is missed only when the WHOLE interval lies under it: a sample of 150 from a scope at 0.89 reads under 0.88 about a third of the time, and a floor that fails on that is a coin. Requires --stats.
  • --json — the same facts as the human report: declared, not_judged, covered, tool, room, score, counters, missing_counters, min_score, below_floor and findings, plus change, survivors_by_node and sample, each null when its option was not given.

Exit 0 when every judged target was measured by a run that produced mutants and the score clears the floor — and also when the project declares no mutation scope at all, because not opting in is not a violation. Exit 1 on findings or a missed floor. Exit 2 when the invocation cannot be answered: --stats without --target or --changed-since, a change git cannot read, a survivor list that is not one, --sample-of without --stats, a sample larger than its population, or no index for an option that reads it.

A change's population, and a sample's interval, measured on this tree (2026-09-28, features/BDL-074 at d8b1790d). The branch changes 56 functions of the declared scope, all owned by rule-engine; the function list is elided below. A change that touches no function of the scope prints Population: empty — the change touches no function of the declared scope, so there is nothing to mutate and no score instead. The binding line states the unplaced count ctx states, and names the acceptance step files and self-checks beside it by their own count:

$ beadloom mutation --changed-since main
Room: Darwin arm64 · CPython 3.13.7 · 10 cores · locale utf-8
Change since main: 704 file(s) changed, 12 of them in the declared scope
Population: 56 function(s) in 11 file(s) of the declared scope, over 1 node(s): rule-engine
  rule-engine: _remediation_for, evaluate_all, … ; 36 test file(s) bound
600 changed line(s) in the declared scope lie outside any function, where no mutant exists
Binding: 170 of 597 test file(s) are unplaced (not under tests/integration/ or tests/unit/) and bind to no node; 72 acceptance step and 103 self-check file(s) bind to no node by their kind — so the tests bound to a node can be short of the tests that exercise it
Declared scope: …
Judged by this run: the functions above — a change covers functions, not declared targets
No run was reported: the population above is what a runner is given.

Over hand-written counters of 130 killed and 20 survived — a sample of 150 drawn from a population of 6 992 — the point estimate is under the floor and the interval is not, so the command exits 0:

$ beadloom mutation --stats counters.json --target src/beadloom/graph/rules/ \
    --only src/beadloom/graph/rules/ --sample-of 6992 --survivors survivors.json \
    --tool 'mutmut 3.7' --min-score 0.88
…
Score: 86.7% of 150 scored mutants
Sample: a random sample of 150 of 6992 mutants; 95% interval 80.3% to 91.2% (Wilson)
Survivors: 1 over 1 node(s)
  rule-engine: 1 survivor(s) — x__cycle_reasons__mutmut_4
Floor: 0.88 — the sample's interval reaches it.

Every report names its room, including one carrying no run. A report over declared targets nothing covered exits 1, so it is a verdict, and until BDL-068 S3.3 it printed no room at all. The room is a property of the process rather than of the run.

An empty population is a finding, not a 100% (BDL-068 S3.3). Before that slice this command never asked the scope half anything, and each of these scored 100.0% of 10 scored mutants at exit 0: a declared target not on disk, a target outside the configured scan_paths, and a target inside them holding no source file. The score is now folded with check_mutation_scope over the targets the run is answerable for, so the same invocation still prints the number the counters state and no longer prints it alone. Measured on a temporary project whose only declared target is absent:

$ beadloom mutation --stats counters.json --target src/gone/
Room: Darwin arm64 · CPython 3.13.7 · 10 cores
Declared scope: src/gone/
Measured: src/gone/
Tool: an unnamed runner
Counters: killed 10, survived 0
Score: 100.0% of 10 scored mutants
WARN [mutation-target-missing] src/gone/: the mutation target 'src/gone/' is not on disk — the run produces zero mutants and a mutation score computed over nothing
  fix: update `mutation.targets` in .beadloom/flow.yml to the path the code moved to, or drop the target
$ echo $?
1

Two more populations are refused the same way. A run whose counters produce no mutants, and a run that produced mutants and reached a verdict on none of them, both raise mutation-run-zero-mutants: a suite that cannot start in the runner's copied tree skips every mutant and leaves counters that look like a clean sheet.

The findings, all at warn severity: mutation-target-unmeasured, mutation-run-zero-mutants and mutation-counters-missing from the score half, plus the three scope checks config-check has raised since BDL-061 S4b — mutation-target-missing, mutation-outside-source and mutation-zero-mutants.

A measured report. The counters below are the ones a mutmut run over src/beadloom/graph/rules/ wrote — 3 989 mutants, 54 min 55 s, in the room the output's first line names. The runner's own release is whatever --tool was handed and is printed back verbatim: this document does not restate it, because a third-party version quoted here goes stale in a way that says nothing about the command. The Declared scope line is the one that run read; the scope has since grown to fifteen targets, and the paragraphs after the sample state how.

$ beadloom mutation --stats mutants/mutmut-cicd-stats.json \
    --target src/beadloom/graph/rules/ --only src/beadloom/graph/rules/ \
    --tool 'mutmut 3.7.0' --min-score 0.95
Room: Darwin arm64 · CPython 3.13.7 · 10 cores
Declared scope: src/beadloom/doc_sync/doc_quality.py, src/beadloom/doc_sync/doc_shape.py, src/beadloom/graph/rules/
Not judged by this run: src/beadloom/doc_sync/doc_quality.py, src/beadloom/doc_sync/doc_shape.py
Measured: src/beadloom/graph/rules/
Tool: mutmut 3.7.0
Counters: killed 3836, mutants 3989, no_tests 0, skipped 0, survived 152, suspicious 0, timeout 1
Score: 96.2% of 3989 scored mutants
Floor: 0.95 — the score is at or over it.

That 96.2% is one room's figure and was not taken on a CI runner.

This repository runs the command per change and on a weekly sample (BDL-074 D1, which retired the whole-scope nightly on 2026-09-27). .github/workflows/mutation.yml has three jobs:

  • mutation-per-change runs on every pull request. --changed-since origin/<base> states the population, repository tooling that is never shipped (.github/scripts/mutmut_adapter.py) turns the touched functions into mutmut's exact mutant names and a per-run test selection, and the command scores the run with --survivors at --min-score 0.88.
  • mutation-sample runs weekly (cron 17 3 * * 1, Monday 03:17 UTC) and by hand. It draws 150 mutants from the whole declared scope, seeded by the ISO week (2026-W40) so a week's sample is reproducible from the commit and the seed, and scores them over every declared target with --sample-of, the floor 0.88 held against the interval.
  • announce speaks for the sample, and is described below.

The workflow is enabled. The owner enabled it on 2026-09-28, after PR #84 merged, and dispatched one sample by hand (run 36373061140). It had been disabled through the API on 2026-09-27 (state disabled_manually), and a disabled workflow runs neither job on any event. Reading one run of each job is the verification bead's work (beadloom-paze, open). Should the workflow be disabled again: gh workflow enable mutation.yml, then gh workflow run mutation.yml for one sample by hand.

Neither job is a required status check. The sample is scheduled and produces no check-run on a pull request, so requiring its context would make main unmergeable. The per-change job does report on a pull request, and it is still not required: a disabled workflow reports nothing, and its budget has not yet been measured on the runner. tests/self_check/config/test_mutation_ci_job.py fails if a job of this workflow becomes one of the contexts DEFAULT_STATUS_CHECK_CONTEXTS names.

What a per-change run selects, and the fallback. The adapter (.github/scripts/mutmut_adapter.py) chooses each kind of test file by what it is (BDL-074 G1), from the population --changed-since states:

  • bound — the files the binding ties to the changed node;
  • acceptance — the step files whose loaded scenarios carry the node's @node: tag;
  • fallback — the files of the coverage-derived pool in pyproject.toml that are unplaced, because they may exercise the node and the binding cannot say;
  • excluded — every self-check, because it tests this repository's files rather than the changed code.

The select step prints all four: Tests: N file(s) - B bound, A acceptance step file(s) by the @node tags of their scenarios, F of U unplaced file(s) from the pool as the FALLBACK; S self-check file(s) excluded. Only the fallback is a guess, and it empties as unplaced files are laid out. Measured on 2026-09-28 by beadloom-2mj3.10 on a one-line change to liveness._cycle_reasons (rule-engine): 36 bound + 10 acceptance by tag + 60 fallback = 106 files, 103 self-checks excluded. The selection it replaced took every pool file bound to no node: 36 bound + 134 fallback = 170 files, and when D1 landed the binding bound none of the 464 files in the test index, so the fallback was the whole pool of 202 files.

The per-change budget is 10 minutes on ubuntu-latest, and it has not been measured on the runner. Its largest cost is mutmut's stats pass over the selection, so it moves with the selection. Measured locally on the same one-line change (Darwin arm64, 10 cores, CPython 3.13.7, mutmut 3.7.0, other agents' suites running on the machine): the select step took 180 s over the 106 files and 342 s over the 170. D1's first measurement was 496 s over the 202-file pool, after which the four exact mutants ran in 37 s (3 killed, 1 survived). At the 1.63 runner factor below, 180 s projects to about 5 minutes on the runner before install. That is a projection, not a runner measurement: no pull request has run this job on the runner yet. The job prints its own time against the 600 s budget and warns when it is over; its timeout-minutes: 30 is above the budget so that an over-budget run still ends with its numbers.

The sample size is derived, not chosen (2026-09-27, the same local room). The job should end by about 50 minutes, under a timeout-minutes: 70 that is itself under the 73 minutes at which the nightly was first killed. About 28 minutes of it are fixed: the stats pass took 469 s and 496 s in two local runs, the clean run over the sampled mutants' covering tests is about the whole pool (471 s), both scaled by 1.63, plus about 2 minutes of install and reindex. The mean worst-case cost of a mutant is 18.5 s, from the last full stats pass (2026-09-26, 4 888 pytest items timed). (50 − 28) × 60 × 2 children / 18.5 = 143, so the sample is 150, which gives a 95% Wilson interval of about ±4.8 points at a 90% kill rate. A sample of 6 run locally (seed 2026-W40) killed 4, left 2 survivors under bd-seam and rule-engine, and stated an interval of 30.0% to 90.3%. No sample has run on a runner yet.

A scheduled run nobody opens is the same silence as a check that never reports, so since BDL-072 the workflow speaks outside the Actions tab. Between 2026-09-10 and 2026-09-18 the retired nightly reached a verdict on 0 of 7 187 mutants nine nights in a row and nothing said so. 643 mutants entered the declared scope while it was dead. The job announce holds issues: write and opens ONE issue labelled mutation-weekly when a weekly sample produces no verdict or one under its floor, and its title says which (BDL-074 G1): Mutation weekly sample: no verdict when the job ended before it judged and scored every mutant it drew, and Mutation weekly sample: under its floor when every drawn mutant was judged and the whole interval of the score lies below the floor. The under-floor body carries the score, the interval, the sample size and the survivors by node. Both states fail the job, so the job result cannot tell them apart: the score step names its floor verdict as the output floor (held, under or unscored) and hands on its whole report as report. While that issue is open each further failed week adds a comment to it instead of a new issue, and retitles it to that week's state, so the comment count is the length of the outage. The first run whose sample is judged and HOLDS its floor comments and closes it, with the comment worded by the state it ends. Held means the sample's interval reaches the floor, since the command fails only when the whole interval lies under it, so a held week can still show a score under the floor (84.0% [77.4, 88.9] holds 0.88). The close comment, the log line and the issue footer therefore say that the interval reaches the floor, never that the score is at or above it (beadloom-2mj3.15). A pull request's run is not announced, because its red is on the pull request. The job reads three things rather than the job status alone, because a dead run can be green and a judged run can be red: needs.mutation-sample.result for the shape where a step exits non-zero, the verdict output of the adapter's judge step for the shape where every step succeeds, and the score step's floor output to tell a verdict under the floor from no verdict. judge compares the counters with the names that were drawn: every drawn mutant must carry a verdict, or the run is silent and says how many never ran.

What the announcement does not cover is stated in the workflow rather than discovered later, and each shape named there is declined for a stated reason rather than missed. One of them is a scheduled run that never starts: GitHub disables a scheduled workflow after 60 days without repository activity, and a run that does not happen runs no job that could speak. The workflow header is the list, and this page does not keep a second copy of it. tests/self_check/config/test_mutation_weekly_announcement.py runs the announcement's shell in twelve tests against a stubbed gh that records the calls, among them a failed job that judged its sample, which is titled under its floor, and the score step against a stubbed uv. tests/self_check/config/test_mutation_adapter.py runs judge over its counter shapes. No test reaches gh itself. Two of its three paths were measured on GitHub by the nightly's last two killed runs (beadloom-e8m4): the first opened issue #79, and the second commented on #79 instead of opening another. The close path — a run that judges its scope — has never run, because no nightly ever scored. #79 was closed as not planned when the nightly was retired. Under the new label the announcement opened issue #85 on 2026-09-28, titled Mutation weekly sample: no verdict for a sample whose verdict was under its floor: that misnaming is what BDL-074 G1 corrected. The issue is still open under that title. The next failing run retitles it when its week's state differs, and the next run whose interval reaches its floor closes it.

The whole-scope nightly, BDL-068 S3.1 to 2026-09-27. What follows is its record, in the past tense, because the floor, the runner factor and the sample size above were derived from it.

The nightly ran on a GitHub runner, and the numbers moved two decisions (BDL-068 S4, run 33851288658, 2026-09-04, the first in this project's history). Over identical mutants it measured 95.56% against the 96.19% taken on the macOS machine — 25 more survivors, which is the room and not a regression — so the graph/rules/ floor was recalibrated from 0.95 to 0.94 in the room the job actually entered. It took 1 h 29 min 18 s against 54 min 55 s locally, a factor of 1.63, so timeout-minutes moved 180 → 240. The nightly then ran the runner twice and scored twice: graph/rules/ kept its own floor, and the whole declared scope was judged separately at --min-score 0.88. One aggregate floor would have let the rules slice fall from 96.19% to 94.1% before tripping. The rules slice's own floor did not outlive the nightly: neither job that replaced it scores graph/rules/ separately, and both hold their mutants to 0.88.

BDL-068 S5 took the declared scope to fourteen targets (beadloom-0mdo.62). S5 added seven pure cores — bd_seam/assumptions.py, bd_seam/invocations.py, bd_seam/answers.py, bd_seam/creation.py, active_table/row_ids.py, active_table/staging.py and waves/landing.py — and every one of them was RUN rather than counted: 764 mutants, 639 killed, 125 survived, 0 unrun, 83.64%, in 36 min 35 s. The scope is fifteen targets on 2026-09-27.

Room: Darwin arm64 · CPython 3.13.7 · 10 cores · mutmut 3.7.0 · six workers

Per file, because an aggregate cannot say which target lost: waves/landing.py 96.55%, active_table/row_ids.py 94.26%, bd_seam/answers.py 88.79%, bd_seam/creation.py 88.71%, bd_seam/invocations.py 82.32%, bd_seam/assumptions.py 79.23%, active_table/staging.py 65.00%. The scope went from 5 700 to 6 464 mutants, which the runner's own denominator confirmed.

A target's cost is its mutant count multiplied by the cost of reaching a killing test. The seven are 13.4% more mutants than the six file targets already declared and eight times the cost per mutant — 36 min 35 s against 9 min 34 s for 1 711 mutants on the same machine. The reason is the covering tests and not the cores: invocations.py and assumptions.py carry ten covering test files each, and those ten walk this repository's harness and template files. No target is excluded on that cost. Scaled by the 1.63 the runner measured against this machine the seven cost about 60 minutes, taking the nightly from a projected 110 to 171, so timeout-minutes moved 240 → 340 by the method the nightly stated. Not 360, because that is GitHub's own ceiling for a hosted job, where a timeout-minutes equal to it can never be the thing that trips.

The aggregate floor was re-derived and did not move. The nightly stated the rule itself — the number is a property of the scope and is re-derived whenever the scope changes — and the previous pass widened the scope and left the floor where a scope of 5 700 had put it. Re-derived over 6 464 the aggregate falls from 89.98% to 89.23%, so 0.88 keeps 1.23 points of headroom where it had 2.00, which is 79 mutants against the 62-mutant margin at which the rules-slice floor was called adequate. Two of its three components are macOS figures applied to a floor enforced on ubuntu-latest. Drop both by the 0.63 points the rules slice actually fell between those two rooms and the aggregate is 88.99%, still above the floor. It survives its own worst case, so it stayed at 0.88, and both jobs that replaced the nightly inherit it.

The 83.64% is a macOS figure feeding a floor enforced on ubuntu-latest. No nightly ever completed, so these seven were never measured per file in the room that judges them. The weekly sample measures the whole scope on the runner, as one interval, and does not attribute a loss to a file.

The runner killed the nightly, and the killer was never identified (beadloom-5isv, closed as superseded on 2026-09-27). Eight runs at --max-children 4 died after 73-102 minutes, far under timeout-minutes: 340: seven at queue positions 4146-4226, where the load_rules mutants sit, and one at 3281. None printed a score. The first run at two children died after 262.4 minutes, described below. The dispatched verification run (beadloom-kj8t, two children) died at position 4 125 of 6 992 after 153.6 minutes, having classified 4 125 mutants without a harness error — 3 856 killed, 268 survived, 1 timeout. The death position moved neither with the number of children nor with the load_rules shrink below.

Mutation runs two mutmut children, not four (BDL-073). Both jobs that replaced the nightly keep it. Two measurements decided it.

  • A false kill through the shared live index. At four children a load_rules mutant was counted killed in 16 s by an IntegrityError from the index the children share (beadloom-qq6m), and survived in 49 s when run alone — measured 2026-09-19. A false kill raises the score the floors are held to. Two children reduced that class and did not remove it: over the 136 load_rules mutants, two runs at two children counted 128 and 126 kills and a serial run counted 123, and every disagreement was a kill that disappears when the mutant runs alone — two of them on loader lines no test executes (B5, Darwin arm64, CPython 3.13.7). BDL-074 A3 then took every test off the live index (beadloom-qq6m closed, 0 contacts traced). Whether that removes the false kills under mutmut has not been measured.
  • GitHub's own diagnosis of a killed run. The first run at two children (run 36101121952, 2026-09-25, before the dispatch table and the memo below) lasted 262.4 minutes against 73-102 for the eight at four, then failed with its log gone and this annotation on the job: "The hosted runner lost communication with the server. Anything in your workflow that terminates the runner process, starves it for CPU/Memory, or blocks its network access can cause this error." Halving the children moved the death and did not prevent it. The annotation names three causes and does not choose between CPU and memory. For the loader, memory is ruled out in one room: no load_rules child peaked above 399 MiB and the parent stayed flat at about 550 MiB (B5, exact per-child peak RSS, Darwin arm64). That room is not the 4-vCPU Linux runner, and it covers the loader's mutants, not the rest of the slice.

mutmut 3.7.0 already runs each mutant's covering tests cheapest first, and no ordering patch is carried. Inside the forked child it sorts the covering tests by their recorded durations (mutmut/__main__.py:1478-1479) and hands them to pytest with -p no:randomly, which keeps that order. Stock mutmut 3.7.0 killed six load_rules mutants in a mean of 1.16 s, one child, in a clean room (2026-09-25). tests/test_mutmut_runs_covering_tests_cheapest_first.py pins both facts by reading the installed package's source, green on mutmut 3.7.0 and on mutmut 3.8.0, where the sort moved to workers/isolation.py. Its two cases that read the installed runner run only where the mutation extra is installed; no CI test leg installs it, so on CI only the matcher cases run. tests/self_check/config/test_mutation_ci_job.py fails if an invocation asks for more than two children.

The cost of the tail is its survivors. A killed mutant stops at its first failing test; a survivor runs its whole covering set — 855 tests and about 57 s for a load_rules mutant, serially. BDL-073 attacked that count rather than the order: load_rules went from 333 mutants to 136, counted with mutmut 3.7.0's own generator after its dispatch became one table and the memo landed, and B1's tests killed five of its survivors. The serial run still leaves 13 survivors, 2 of them equivalent. timeout-minutes: 340 was derived from a 171-minute projection at four children, and halving the children took that projection to about the cap itself; the dispatched run died at 153.6 minutes, before either.

beadloom ci asks whether a mutant COULD run at a declared path and never whether one DID, and beadloom mutation --only prints "this run did not cover it" and "no run has ever covered it" as the same sentence. That is filed as BDL-UX #246 and routed to S6; until it lands, a declared target that no run ever covers passes every green Gate.

The three findings, the counter vocabulary and the report's own invariants are in the Mutation Scope DOC.

beadloom rooms ​

The room this run is in, the rooms the project declares, and which of them the run did not enter (BDL-068 S3.2).

bash
beadloom rooms [--project DIR] [--dimension AXIS] [--json]

The census is derived, never listed. The supported interpreters come from the Programming Language :: Python :: X.Y classifiers in the packaging metadata; the legs come from every job of every .github/workflows/*.y*ml, each matrix expanded as a product and a matrix.<axis> expression in runs-on resolved through it. The module owns a runner-label vocabulary (ubuntu / macos / windows) and no room list, so a leg added to a workflow is covered by the same act that adds it. Since BDL-068 S6 a leg's optional extras are derived the same way — from the install step the job declares (uv sync --extra …, --all-extras, or a pip install of a local path with a bracket) against what the project's own distribution declares in its installed metadata. A hand-written list satisfies every test beside it and goes stale the first time a leg moves: this repository's own DEFAULT_STATUS_CHECK_CONTEXTS has drifted from what CI reports three times, and a required check that never reports makes main unmergeable.

Naming the room does not make a verdict stronger. It makes it answerable — a reader can see which declared rooms the run covers and which it does not. It is not a step and carries no status.

Measured on this repository, 2026-09-08, with rows and reasons elided. The block stands as history and is deliberately not re-taken, because the environment it was taken in carried the mutation extra and that is the difference the paragraph below it rests on. Its count is the count of that day, and the sentence after the block states today's.

$ beadloom rooms
Rooms — derived from this project's declaration, never from a list

  This run is in: Darwin arm64 · CPython 3.13.7 · 10 cores · extras all+dev+graphql+languages+mutation+tui+watch

  Declared rooms: 21, entered by this run: 0
    [  ] extras=all+dev+graphql+languages+tui+watch os=ubuntu-latest python=3.13    .github/workflows/ci.yml: tests
         extras: this run has mutation and the leg does not; os: the leg is ubuntu-latest (Linux) and this run is Darwin
    [  ] extras=dev+languages os=ubuntu-latest    .github/workflows/ci.yml: site-build
         extras: the leg installs all, graphql, mutation, tui, watch and this run has not; os: ...
    [  ] extras=all+dev+graphql+languages+mutation+tui+watch os=ubuntu-latest    .github/workflows/mutation.yml: mutation
         os: the leg is ubuntu-latest (Linux) and this run is Darwin
    ... and 9 more

  Interpreters this project supports: 3.10, 3.11, 3.12, 3.13 (floor >=3.10)

  Extras of `beadloom` installed here: all+dev+graphql+languages+mutation+tui+watch
    not installed: search — needs fastembed, sqlite-vec

  Unresolved (2):
    .github/workflows/ci.yml: gate — the job installs the project through a local action, so the optional extras its verdict is taken under are declared somewhere this report does not follow
    .github/workflows/ci.yml: ai-techwriter — the runner label `self-hosted+ai-techwriter` names no platform this report knows, so no run can be said to have entered it

The extras axis found a difference nothing had named. This development environment carries mutation, which only mutation.yml installs, so it differs from every tests leg by an extra that was invisible before the dimension existed. That is BDL-UX #236: measured at 6c4d0a9, one code base at one commit gave 0 mypy errors under .[all,dev] and 82 under .[dev], and the whole tui suite left the run under the second, three modules skipping and one erroring. A verdict that does not state its extras cannot be reproduced from what it prints.

A local run is in 0 of the 23 rooms this project declares — measured 2026-09-27 — and that is the point rather than a caveat: nine "green on the tree" reports across BDL-067 were taken in exactly this room. The mutation leg above was added by the slice that added it and appeared in the census with no edit to the census's own code, which is the property the required-contexts tuple lacks.

The count was 21 until 2026-09-18, when mutation.yml gained its announce job, and 22 until 2026-09-27, when BDL-074 D1 replaced the job mutation with mutation-per-change and mutation-sample. The mutation.yml: mutation row in the block above names a job that no longer exists. A job is a declared room, so a workflow that grows a job grows the census by the same act, and the number a document states in the present tense goes stale without anything in the census being wrong. beadloom sync-check read [ok] over that change on a fresh index, and that is the machinery answering the question it was asked rather than a failure of it: this page declares watches=cli,graph,flow.yml, and none of those three surfaces moved. A workflow is not among them, so a reader who states a derived number here is the one who owns re-measuring it.

A run enters a declared room only when every dimension is comparable and equal. A runner label naming no platform (self-hosted) and a dimension this run cannot describe (the two locale legs) both resolve to NOT ENTERED with the deciding dimension named, and an unresolved job is listed rather than dropped: a comparison that cannot be made must never manufacture coverage.

  • --dimension AXIS — the distinct values of one axis, one per line: the form a checklist loops over instead of spelling out a set that goes stale. The Python overlay's type-check step is for v in $(beadloom rooms --dimension python), and the honest limit of that local form is that it varies the TARGET version only — the interpreter the checker runs under is still one, which is a difference only CI measures. --dimension extras prints the distinct environments the legs declare, four on this repository.
  • --json — current, declared (each with dimensions, source, entered and why), extras (distribution, resolved, label, installed, and absent as {extra, needs} pairs), supported, floor, supported_without_a_leg and unresolved.

Exit 0 when the census was taken; a project declaring no leg also exits 0, because this command grades nothing. Exit 2 when --dimension names an axis no declared room carries, and the refusal names the axes that exist (no declared room carries a 'nonesuch' axis; the axes declared are: extras, locale, os, python). An empty answer would read as "this project has no such axis", which is the clean list an agent trusts and stops at. Values of one axis are printed in a stable order — an axis whose values are not versions was previously printed in the hash order of a set, which differs between processes.

The derivation, the floor-is-not-a-set rule and why the packaging metadata is read without a TOML parser are in the Verdict Room DOC.

beadloom typed-surface ​

The files this project declares type-checked, derived from its own mypy configuration (BDL-068 S4, BDL-UX #231).

bash
beadloom typed-surface [--project DIR] [--filter] [--json]

The surface is derived, never listed. [tool.mypy]'s packages, modules and files are resolved against mypy_path (split on : and ,, with the project root appended) into the directories and modules a type check covers. [[tool.mypy.overrides]] is outside that read by construction: an override changes which findings are reported, never which files are in the surface.

Measured on this repository:

$ beadloom typed-surface
Typed surface — derived from this project's own declaration, never listed

  Covered (1):
    src/beadloom    [tool.mypy] packages = 'beadloom'

--filter is the form the pre-commit hook consumes: staged paths in on standard input, the ones inside the surface out, led by a verdict line carrying the same # marker scope-check --porcelain leads with, so a hook written in sh splits verdict from payload on one shape rather than on two spellings agreeing.

$ printf 'src/beadloom/a.py\ntests/t.py\n' | beadloom typed-surface --filter
# Typed surface (src/beadloom): 1 of 2 staged Python file(s) inside it, 1 outside.
src/beadloom/a.py

The verdict has three sentences, not two. Typed surface: NOT CHECKED -- <reason> when no surface could be derived; NOTHING TO CHECK -- 0 of N staged Python file(s) are inside it when the surface exists and the commit staged nothing in it; and the count above when there is something to check. A check whose population is empty reading as a check that passed is the phantom gate BDL-068 exists to remove, so the second and third are different sentences rather than the same sentence with a zero in it.

What the derivation could not resolve is reported rather than dropped: a package that resolves to no path, a files glob matching nothing or several roots, a declared exclude (not applied, because mypy does not apply it to files named on the command line, and that is how the hook invokes it), and a mypy.ini / setup.cfg / tox.ini beside the pyproject.toml.

Exit 0 when the surface was derived — a commit staging nothing inside it also exits 0, because an empty population is a fact and not a failure. Exit 2 when the surface could not be derived, with the reason on the verdict line and therefore on standard output, because the caller that needs it most is a hook that reads one stream.

The reading rule, why the declaration is parsed without a TOML parser, and the 24-commit measurement behind the hook's scope are in the Typed Surface DOC.

beadloom version-surface ​

Every place this project states its own version, what checks each one, and the places checked by nothing (BDL-069 S3, BDL-UX #281).

bash
beadloom version-surface [--project DIR] [--json]

The places are derived; the instruments are named. An instrument's name is a fact about the codebase and changes when an instrument is added. A place is a fact about the tree and changes on every release, so no place is written down anywhere in the derivation, and a test parses that module with its docstrings stripped and fails if one appears. Five instruments are named, and what each holds is read from the project's own declarations: packaging-manifest (the manifest chain a build back end follows), docs-audit (the documents DocScanner resolves as the audit's surface), graph-summary-facts (the summary: lines under .beadloom/_graph/, where the rules file declares the rule), doctor (the beadloom:auto-start project-info region of the adapters the flow manifest records), and test-suite (the assert statements under the manifest's testpaths).

Measured on this repository on 2026-09-11, against 4.0.0:

$ beadloom version-surface
Version surface — every place this project states its version, derived, never listed

  Source of truth: 4.0.0
    src/beadloom/__init__.py:6    pyproject.toml dynamic version through [tool.hatch.version] path

  Checked (10 place(s) in 8 file(s)):
    .beadloom/_graph/beadloom.yml (1)    graph-summary-facts
      5    summary: "Beadloom CLI + MCP server — architecture graph, Context Oracle, Doc S…
    ...

  Checked by nothing (44 place(s) in 22 file(s)):
    .beadloom/flow/claude/CLAUDE.md (2)    — outside docs audit's scan globs
      12    ### `setup-branch-protection` — the gap is closed as of 4.0.0, and will reopen
      46    is the normal state, not an incident — and closing it, as 4.0.0 did, is a moment
    .claude/CLAUDE.md (2)    — outside docs audit's scan globs; outside the project-info
          auto-region: doctor reads the claim in that region and no other line of the file
      427    ### `setup-branch-protection` — the gap is closed as of 4.0.0, and will reopen
      461    is the normal state, not an incident — and closing it, as 4.0.0 did, is a moment
    ...

Rows are grouped by file and reason together, so a file whose lines fall outside for two different reasons reads as two facts rather than one averaged sentence. The excerpt is cut at 80 characters and every other line is wrapped at 100: a reason is the actionable half of an unjudged row, so it is wrapped rather than cut. A wrapped header continues deeper than the rows under it, because at a row's indent its second line reads as a place with no line number.

The sweep is by the current literal, which is the limit to read first. A place that ALREADY states an older version is invisible to it, so run this BEFORE the bump and not after. The alternative was measured rather than assumed: reading every version token this project's prose attributes to itself returns 674 claims across 137 files, because the planning archive records every version the project ever had. Catching a place that has gone stale is what the instruments are for, and which places have one is what this report names.

The report carries its own population — the files read, the directories pruned, the suffixes read, the suffixes NOT read with a count each, and every file that could not be decoded with its reason.

Exit 0 when the surface was derived. Places checked by nothing do not make it non-zero: the gap is what the report exists to state, and a release that has to read it is not a release that failed. Exit 2 when no version could be derived, with the reason on standard output, because an empty answer and an empty answer with a reason are different answers.

The instruments' populations, what the derivation cannot distinguish, and why two paths are restated rather than imported are in the Version Surface SPEC.

beadloom bd-calls ​

Every place this project reaches bd, and what each call form assumes about the answer (BDL-068 S5, beadloom-0mdo.51).

bash
beadloom bd-calls [--project DIR] [--assumption NAME] [--unsettled] [--strict] [--json]

A report, not a wrapper. BDL-068's CONTEXT Q4 decided it: an External bd finding is answered by deriving our own call sites, because a wrapper is a second thing to keep in step with upstream and a derived population fails on a call site added later.

Four channels, and the majority of them are not code. The composed flow artifacts, the templates this project ships, the installed package's Python (via the seam's run_bd) and the scripts in .git/hooks/ — which reaches post-merge, written by bd init, tracked nowhere and named nowhere under src/.

Nine assumptions and four verdicts, because two of each are not enough. secured (the call form makes the assumption true), unsecured (it relies on a default measurement shows is narrower than the question), holds (no flag can secure it and it is measured true on the recorded release) and unmeasured (a subcommand this derivation has not measured — never a clean site).

Measured on this repository, against bd 1.0.4:

$ beadloom bd-calls
349 `bd` call site(s), measured against bd 1.0.4: 2 hook, 335 instruction, 12 python

    60  untruncated-population     secured
    49  unblocked-is-ready         secured
    48  unmeasured-subcommand      unmeasured
    36  exclusive-hold             secured
    31  untruncated-population     unsecured
    17  complete-population        secured
    15  allocated-id               secured
     8  intended-id                secured
     2  legacy-alias               holds

The findings worth naming are the ones that remain. The 48 unmeasured sites are bd swarm (26) and bd gate (22), the two commands the coordinator orchestrates every wave with, and nobody has measured either. The 31 unsecured sites are prose mentions of bd ready, whose cap is 100 rows and is announced on standard error; the remedy now ships into every composed role by the _tracker fragment, so a reader of any role file is told. Every bd list call in this project's Python names both of the filters bd list applies by default, which is BDL-UX #187 answered at the consumer, and every Python call site is settled.

Two of the assumptions went to zero unsecured in beadloom-0mdo.53. allocated-id is settled by --json, which answers with the id bd allocated, and by --graph, whose plan names beads by key and allocates a flat id that takes no number from the positional sequence at all. intended-id is settled at the ARTIFACT rather than the line, like unblocked-is-ready: an artifact that instructs bd dep add and also names bd dep tree tells its reader how to check the edge it just built. A ninth assumption, echoed-titles, applies only to bd dep add --file: the one-by-one form echoes both beads' full titles and the bulk form prints a count and none, so the fast spelling of the wiring half discards the check that caught BDL-UX #171. No artifact of this project instructs the bulk form today, and that rule reddens on the day one does.

--assumption narrows to one assumption and --unsettled to the sites nothing settles. --json emits the same facts as data. --strict exits 1 when any site is unsettled; the default exits 0, because most unsettled sites are instructions to a person and the fix for an instruction is a role duty rather than an exit code. --assumption with a name the derivation does not judge exits 2 rather than printing an empty list, which would read as "no site makes that assumption".

Every verdict is pinned to BD_MEASURED_VERSION, and a test fails when a different bd is installed. Three premises this population was built to check were re-measured and destroyed — BDL-UX #194 and #237, and beadloom-l2f2 — so a verdict carried across a release without re-measuring is how a withdrawn defect comes back as a guard over nothing. A fourth withdrawal, of #97, was made and then reversed by this same population. Its mechanism has now been characterised three times and no characterisation survived the next measurement: given one rig per shape rather than ten shapes sharing one, --suggest-next named a still-blocked bead in sixteen of twenty-three, on no shape rule any of the three sessions found. What is stable is the observation — it names beads that are still blocked, and bd ready was correct in all twenty-three. A verdict states the release it was measured against and the shape it was measured over, or it states nothing.

The grammar, the assumption table and the regions the derivation cannot reach are in the bd Seam DOC.

beadloom issue-number ​

Allocate an issue-log number, or check the ones already taken (BDL-068 S6, beadloom-0mdo.66).

bash
beadloom issue-number allocate --holder BEAD-ID [--project DIR] [--json]
beadloom issue-number check [--project DIR] [--json]

Allocated, not read off the end of a shared file. The convention this replaces was "read the last number in the log and add one", and following it exactly produced five collisions on this repository -- BDL-UX #187, #211, #253 and, within one hour on 2026-09-09, a number another bead already held and a number that never reached the file. allocate is an exclusive create of one claim file per number, which is a thing a command can make indivisible and a paragraph cannot. --holder is required and names a bead, so a held number says who is holding it.

Three legs, over a population one filesystem cannot span. check reports duplicate-number (one number, two entries), unwritten-claim (a number claimed and never written into the log) and unclaimed-number (an entry above the ledger's floor that no claim holds). It states the population each leg REACHED and not only what it found: the entries below the floor are the ones unclaimed-number never entered because they predate the ledger, and the numbers below the highest that are stated nowhere are NAMED rather than counted, because a count is not something a reader can go and look for (BDL-UX #267). The naming is bounded at twelve, so an adopter with a hundred unaccounted numbers gets a line that is still readable.

Measured on this repository, 2026-09-10:

$ beadloom issue-number check
253 entr(ies), 18 claim(s), floor 262
  235 of 253 entr(ies) are below floor 262: `unclaimed-number` did not enter them, and no claim holds their numbers
  1 number(s) below the highest are stated nowhere; they are unaccounted for, not free: #196
No duplicate, unwritten or unclaimed number.

Exit codes are the contract a caller may rely on: 0 the number was allocated, or the check found nothing, or the check could not establish whether a log is declared at all; 1 the check found at least one finding, or the project declared an issue_log: block this reader could not use; 2 nothing was allocated, because the project declares no block or wrote a config that could not be read.

Three states about the declaration, and the check tells them apart (BDL-069, beadloom-rqma.9). A project that declared no log is told so. A project that declared one and mistyped a key gets 1 entr(ies) declared, 1 unusable — no leg ran. and the refusal naming the key. A project whose .beadloom/config.yml could not be read at all is told that whether it declares a log is unknown — the check never saw the key, so reporting an opt-out would be an assertion about a declaration nobody read:

$ beadloom issue-number check
Whether this project declares an issue log is unknown — no leg ran.
  .beadloom/config.yml could not be read: it could not be parsed as YAML — repair .beadloom/config.yml so it parses as a YAML mapping

That is exit 0, matching the Gate's own answer to the same state, which is a non-blocking WARN. The --json payload separates it from a clean run for a machine: "undetermined": true, and "declared" is null rather than false, because the wire format carries three states and a boolean holds two.

Two further states report that a leg ran over nothing rather than passing: a project that declares no log is told so and no leg runs, and a ledger holding no claim leaves unwritten-claim and unclaimed-number with no number to enter. An absent log is not an empty one.

The grammar, the two populations a numbered log states and the regions the legs cannot reach are in the Issue Numbers SPEC.

beadloom ci ​

The unified enforcement gate — the single CI convergence point (principle 7: identical for Cursor / Claude Code / human authors).

bash
beadloom ci [--hub EXPORT.json ...] [--fail-on CSV] [--format {rich,json,github}] [--no-reindex] [--project DIR]

Composes the existing checkers, in order, into ONE verdict with a single exit code (0 = all steps passed, 1 = any step failed):

  1. reindex (incremental) — unless --no-reindex.
  2. lint --strict — architecture boundary rules at error severity.
  3. sync-check — doc↔code freshness (stale pairs fail).
  4. docs audit — stale numeric facts in documentation (stale>0 fails). The step line also states its coverage — M/N declared fact(s) verified plus the names of the facts it checked nothing for — because a count of findings says nothing about the facts nobody stated.
  5. docs-quality — the eleven planning-document checks (the five writing-standard ones, the four shape ones and the two route ones) over the project's planning documents. Warn only: it never fails the gate, and a project with no planning document is a NAMED skip stating the globs. Three states set the step to WARN rather than PASS: a check that read nothing anywhere (NOT CHECKED), a document KIND no content check enters (NO CHECK READS), and a document nothing could decode (UNREADABLE: N). Measured on this repository, 2026-09-03: WARN | 259 document(s) read; measurable-goal 4, pending-in-approved 7, missing-section 102, routed-without-axes 12; NO CHECK READS: BRIEF, PLAN, SUMMARY. The line prints the finding count and not the 27 accepted-without-witness statements the re-scope stopped deciding about; that limit is stated in the doc-quality SPEC.
  6. issue-log — the issue log's numbers (BDL-068 S6): duplicate-number, unwritten-claim and unclaimed-number over a log declared under issue_log: in .beadloom/config.yml. Unlike its two document neighbours it BLOCKS, because a duplicate number is a reference that resolves to two entries and to neither rather than an opinion about prose. A project declaring no log is a NAMED skip. This step was missing from this list until BDL-069 S4 renumbered it.
  7. readme-pair — the document pairs declared under document_pairs: in .beadloom/config.yml, compared by SHAPE (BDL-069 S4). What is compared is the sequence of blocks each document is built from — heading, paragraph, code, list, table — with the heading levels and the row counts, never the text: the pair this repository declares is README.ru.md and README.md, and a text comparison over a translation is a check somebody has to switch off. It BLOCKS, on the issue-log terms rather than the docs-quality ones, and a declared path nothing could read fails too, because a declaration pointing at nothing would otherwise report 0 finding(s) having compared no document at all. A project that declares no pair is a NAMED skip stating the key to add, so the upgrade shipping the step reddens nobody. The line states the population it held: 1 pair(s) held, 109 block(s) compared, 0 finding(s); README.ru.md <-> README.md (109 block(s)), measured on this repository 2026-09-11, with UNREADABLE: naming each declared path nothing read and NOT COMPARED: counting the pairs read that hold no block between them — the second sets not_verified, so the step reports WARN.
  8. doc-spaces — the TO-BE → AS-IS relation over the project's documentation spaces (BDL-061 S5). Warn only, on the same terms as the step above, and a project with no TO-BE document is a NAMED skip stating the roots it looked under. FOUR states set not_verified and the step then reports WARN: no tracker was readable, no epic with closed beads declared a node, some epics declare none, and some epics the tracker does not name. The line states both WORKING populations apart — N WORKING document(s) in the exempt space, M sync pair(s) excused — because one word for two populations is how a reader takes the document count as the excused-pair count; the pair count is carried from the sync-check step that measured it, never recomputed here.
  9. scope-check — did this branch leave the axes its work item declared? Branch-scoped (<trunk>...HEAD, what the pull request contains) rather than tree-scoped, because the tree is shared by several agents and judging it would fail one agent's push on a neighbour's edit. Warn only, and passed=True unconditionally: one work item in 64 on this repository carries an ## Axes section, so a check that blocked would meet a repository that cannot satisfy it. A run with no branch, no work item, no index or no section is SKIPPED with its reason, and a run over a branch whose changed paths no node owns is SKIPPED too, because a comparison over an empty population is not a pass. The report names each path and the axis it fell outside. It does not prevent the commit that made it.
  10. config-check — AgentConfigAsCode drift, plus the mutation-SCOPE findings (a declared mutation.targets entry outside scan_paths, absent from disk, or holding no source a runner could mutate). All warn. The SCORE half is not a gate step: it needs counters a runner wrote, and beadloom mutation is where they are read.
  11. doctor — graph/data integrity; ONLY ERROR-severity checks fail the gate (WARNING/INFO advisories never block — no false gate).
  12. federate --fail-on — the cross-service landscape gate, only when --hub export(s) are given (safe-default fail-set breaking,drift,orphaned_consumer,undeclared_producer; no-false-gate verdicts rejected).

What --no-reindex changes about the verdict. It skips step 1, so every later step describes the INDEX rather than the working tree. With an index older than the tree, the lint step reports PASS over a live error-severity violation that the same gate catches after a reindex (measured), and the sync-check step compares against whatever baseline the index holds. Use it only where something else has just reindexed.

Honest gate (the Phase-0 lesson): the report names every step that ran and its outcome — PASS / WARN / FAIL / SKIP — never a green that silently skipped a step, and never a PASS over something the step could not check (WARN: it ran, found nothing wrong, and part of what it reports on was not verifiable — see sync-check above). No short-circuit: all steps run and ALL findings are collected even after an earlier failure, so one run surfaces every problem. --format applies uniformly across every step; findings share the agent-actionable {kind, rule, severity, node, locations, why, remediation} shape (github emits valid ::error file=<path>,line=<n>::<msg> workflow-command annotations, matching lint --format github; json emits {ok, steps[]}). The per-repo beadloom-aac-lint.yml reindex+lint+sync steps collapse into one beadloom ci call. Orchestration lives in application/gate.py:run_ci_gate(); the CLI only parses options and renders.

The verdict names the room it was taken in (BDL-068 S3.2), and does not change because of it. GateResult carries a RoomCensus populated by run_ci_gate(), and all three output shapes print it: a Room: block under the rich verdict (the current room, then N of M declared room(s) not entered by this run: with the first three named, or every declared room entered (M)); a room object in --format json with current, entered, not_entered and unresolved; and one ::notice::room <room> — N of M declared room(s) entered by this run line in --format github. It is printed UNDER the verdict rather than beside it, because it is not a step and has no status — a passing gate still passes with zero findings and a failing gate still exits 1. Measured on this repository, 2026-09-10: a local macOS run enters NONE of the declared rooms -- --format github prints 0 of 21 declared room(s) entered by this run and the rich block prints the complement, 21 of 21 declared room(s) not entered by this run, naming the first three. The two shapes count opposite populations and a number carried between them inverts the claim: this passage previously read 0 of 21 declared room(s) not entered, which says a local run misses nothing. It is the verdict's address rather than a caveat on it, and the address of a local run is a room this project's CI declares no leg for. The census itself is beadloom rooms.

The verdict also names what no step of it performed (BDL-068 S6, BDL-UX #247). beadloom ci does not run the test suite, and until this slice it never said so. GateResult carries a GateCoverage beside the census: the verifications this project's pipeline declares that no step of the run performed, each with the command the pipeline runs for it and the workflow job it was read from. All three shapes print it — a Not run by this gate: block under the rich verdict, a not_run object in --format json (performed, not_performed, unresolved, inspected) and one ::notice::not run by this gate: ... line in --format github. Measured on this repository: the block names three — the test suite, the style linter and the type checker, all read from the tests job of .github/workflows/ci.yml. Both sides are derived: what the run performed comes from its own step list, so a suite step added later removes the line by the same act, and what the project verifies comes from its workflows through the reader the room census uses. A pipeline verifying under a name the vocabulary does not read (pytest; ruff/flake8/pylint; mypy/pyright) is told the population is empty with the vocabulary named, never that nothing is left to run. Like the room, it is not a step: same verdict, same exit code, same findings.

And the verdict names who owns what it found (BDL-068 S6). This project's own branch carried a red Gate across two waves — two stale docs owned by no bead in the running plan — and every gate owner in those waves had to be told by the coordinator, by hand, that the red was not theirs. GateResult carries a GateOwnership: one verdict per finding, held against the beads the tracker reports claimed while the run happened. owned names the beads; unowned says a node was derived and no claim covers it; unattributed says no node could be derived from the finding at all. A tracker that cannot answer, and a project with no index, are a reason on the whole report rather than a page of unowned — telling every gate owner "not yours" when nobody was asked would be the same false green in a new vocabulary. All three shapes print it: a Findings by owner: block under the rich verdict, an ownership object in --format json (reason, claimed, none_owned, findings, unread_claims) and one ::notice:: per owning bead in --format github, plus the headline no finding of this run is owned by a bead claimed now. The owner is a BEAD and never the work item: the work item's ## Axes answer whether a change is inside the approval, which the scope-check step of the same run already asks and which every agent on one branch shares. Like the room and the coverage block, it is not a step — same verdict, same exit code, same findings — and the tracker is asked only when the run produced a finding, so a green run shells out to nothing.

beadloom setup-mcp ​

Configure MCP server for your editor.

bash
beadloom setup-mcp [--tool {claude-code,cursor,windsurf}] [--project DIR]
beadloom setup-mcp --remove [--tool {claude-code,cursor,windsurf}] [--project DIR]
  • claude-code (default) -- .mcp.json in project root
  • cursor -- .cursor/mcp.json in project root
  • windsurf -- ~/.codeium/windsurf/mcp_config.json (global)

beadloom setup-ai-techwriter ​

Scaffold the AI tech-writer (BDL-047 / F4.1; harness packaged in BDL-051 / S2) into this repo for one-command, 3-step opt-in. In the setup-* family alongside setup-mcp / setup-rules.

bash
beadloom setup-ai-techwriter --platform {github,gitlab} [--project DIR]

Idempotently scaffolds (clean overwrite on re-run):

  • No Python vendoring. As of BDL-051 / S2 the harness ships inside the installed beadloom package as the beadloom.ai_agents.ai_techwriter domain, so adopters depend on beadloom and invoke it directly via python -m beadloom.ai_agents.ai_techwriter (the BDL-047/048 tools/ Python vendoring + drift-guard machinery is retired). Only the operator artifacts — the Goose recipe.yaml (a readable reference of the agent's blast radius) and provision-runner.sh — are copied (from package data via importlib.resources) into tools/ai_techwriter/ for operator convenience.
  • The chosen platform's CI wrapper: .github/workflows/ai-techwriter.yml (GitHub) or an ai-techwriter job in .gitlab-ci.yml (GitLab). As of BDL-049 both trigger on a PR to main/master — GitHub on: pull_request (opened/synchronize/reopened), GitLab merge_request_event — plus a manual fallback (workflow_dispatch). They call the same python -m beadloom.ai_agents.ai_techwriter entrypoint; the PR path passes --target pr-branch and --since $(git merge-base origin/<base> HEAD) so the agent commits its refresh into the PR branch; the manual path uses --target branch-pr. A loop-guard skips the agent's own [skip ai-techwriter] commit, and cancel-in-progress: true supersedes older runs. Only the trigger, the secret naming (QWEN_API_KEY repo secret vs CI/CD variable), and --platform differ. An existing .gitlab-ci.yml is appended to (job-only, stripping the standalone stages: header) — never blindly clobbered; an already-wired file is left as-is.
  • tools/ai_techwriter/provision-runner.sh — a hardened, idempotent, executable (0o755) self-hosted-runner provisioner (--platform/--repo/--token): guarantees swap before any apt/build (the OOM lesson), RAM (~2 GB min, ~4 GB recommended) + disk (~5 GB) prechecks, fail-hard on the critical steps (toolchain + runner register/start), and best-effort + verified Goose/beadloom/bd installs reported at the end.
  • docs/guides/ai-techwriter.md — the 3-step getting-started guide.

Delegates to onboarding/ai_techwriter_setup.py:scaffold().

beadloom setup-agentic-flow ​

Scaffold Beadloom's proven multi-agent dev flow into this repo (BDL-048 / 052). In the setup-* family alongside setup-rules / setup-mcp / setup-ai-techwriter.

bash
beadloom setup-agentic-flow [--project DIR] [--force] \
    [--tool claude|cursor]...        # repeatable; default: flow.yml or claude
    [--architecture ddd|fsd]         # default: flow.yml or ddd
    [--stack python,fastapi,javascript,typescript,vuejs]  # CSV; default: flow.yml or auto-detected

Since BDL-061 S3 every flow artifact is composed from four layers in a fixed order — the shipped stack-neutral CORE, one architecture overlay (ddd/fsd), each selected stack overlay sorted, and the project fragment under .beadloom/flow/ — for all three kinds:

kindwritten toproject fragment
roles.claude/agents/<role>.md, .cursor/agents/<role>.md.beadloom/flow/roles/<role>.md
commands.claude/commands/<cmd>.md.beadloom/flow/commands/<cmd>.md
claude.claude/CLAUDE.md.beadloom/flow/claude/CLAUDE.md

cursor additionally gets a .cursor/rules/beadloom-flow.md orchestrator pointer. Selection comes from .beadloom/flow.yml, overridden by the --tool/--architecture/--stack flags (defaults claude / ddd / auto-detected stack — flag → flow.yml → default precedence). An invalid selection raises a FlowConfigError naming the bad value + the allowed set. config-check compares each artifact against its composition, so a project fragment is part of the expected output while a change to a shipped fragment is not. See the Project Overlays guide.

.claude/CLAUDE.md keeps two auto-regions generated for THIS project via the same refresh_claude_md machinery setup-rules --refresh uses: project-info and doc-language (rendered from language: in flow.yml). Every bullet in project-info is read from the target project — its declared version (pyproject.toml including a dynamic one, package.json, Cargo.toml), its requires-python verbatim, its declared dependencies, its own src/ packages and the architecture its flow.yml names. A fact that cannot be read is omitted, never substituted.

Until BDL-061 S3b it was not. The version bullet rendered Beadloom's own __version__ (a JavaScript project a major version behind was told ours), the architecture line said DDD packages whatever the project declared, the stack line matched the project's manifest against Beadloom's dependency names and printed our Python floor as theirs, and the package scan fell back to looking for src/beadloom/ inside the adopter's tree. Each read correct on this one repository by coincidence, which is why four slices of scrutiny passed over it (BDL-UX #183). The same coincidence covered beadloom doctor, which audited those four claims in an adopter's file against our state; it now reads theirs, and reports not verified for a fact the project does not declare.

The command records the selection it composed from. A first run writes .beadloom/flow.yml — and never writes over an existing one, since that file is the adopter's policy (language and overlays.suppress have no flag and live only there). Before BDL-061 S3b it resolved the selection in memory and never wrote it down, so a virgin scaffold on a fresh TypeScript project left beadloom config-check — the command this one's own closing advice recommends — at exit 1 with four errors, remediated by advice to add a flow.yml by running the command just run (BDL-UX #187). The same fix closed a divergence found alongside it: scaffold() re-resolved from disk without the flags, so --architecture fsd composed the role adapters as fsd and the commands and CLAUDE.md as ddd.

It also writes the flow-guard binding (BDL-061 S1): .claude/hooks/beadloom-guard.sh — one exec beadloom guard "$1" --hook claude-code — and one PreToolUse entry per registered guard in .claude/settings.json, matched on Edit|Write|MultiEdit|NotebookEdit|Bash. The guard names come from the registry, so a guard added in a later release is wired by re-running this command. Registration is a merge: existing hooks survive, re-running adds only the missing entries, and a settings.json that cannot be parsed is reported and left untouched. The merge is on the command string, so a project scaffolded before Bash joined the matcher (BDL-068 S4, BDL-UX #170) keeps the narrower one across the upgrade; beadloom guard --liveness reports that gap rather than leaving it silent (see beadloom guard).

The command makes the same whole-working-set .gitignore call init makes (see beadloom init), for a project initialised by a Beadloom older than the block: the guards' firing record is one entry in that set, not a special case owned by the guard scaffolder.

One policy for all three artifact kinds (BDL-068 .67, BDL-UX #191). An artifact that already matches its composition is left alone; one Beadloom wrote and nobody touched is recomposed, so an upgrade lands; one whose body the flow manifest cannot prove Beadloom wrote — hand_edited or unverified — is skipped, named on stdout as Skipped <path> (hand-edited) and reported in the Left alone block with the project-layer path the edit belongs in. --force is the one door that adopts the composed body over it. Delegates to onboarding/role_adapters.py:generate_adapters() (the adapters) + onboarding/agentic_flow_setup.py:scaffold() (the commands + CLAUDE.md), and both are given the same declined set, config_sync.declined_adapter_rewrites(), that config-check --fix reads.

Until BDL-068 .67 the role adapters were the exception: they were composed with no preserve argument and recomposed over silently, so the same command answered one hand edit two ways and nothing an adopter could read said which was intended. Measured on a scratch project scaffolded by the shipped command, with the same two lines appended to .claude/agents/dev.md, .claude/commands/coordinator.md and .claude/CLAUDE.md and one re-run with no flags: the first was destroyed and reported as Wrote, the other two were preserved and reported. config-check printed "hand-edited: … It will NOT be rewritten" over both of the first two, under a remediation that says to re-run this command — so following that remediation literally destroyed one of the two edits it was printed to protect. The --force help had promised the new behaviour since the flag shipped. The one artifact still rewritten unconditionally is .cursor/rules/beadloom-flow.md, a four-line pointer whose own body says it is generated and which no check compares — stated here rather than left to be discovered.

The same run also stopped printing two false lines about CLAUDE.md: a preserved body was reported as Wrote .claude/CLAUDE.md, and its skip travelled in ScaffoldResult.commands_skipped, where the caller rendered it through the commands path template and printed Skipped .claude/commands/CLAUDE.md.md — a path that exists in no project. ScaffoldResult.claude_md_skipped carries it now.

The command prints what it found, not only what it wrote: the files an older layout left behind, each with the exact rm -f command (BDL-UX #137), and a migration note naming the project-layer path a hand edit belongs in.

Left alone (1) — your edits are the only copy of an intent, so Beadloom did not
recompose over them:
  = .claude/commands/coordinator.md: hand-edited … move the additions to
    .beadloom/flow/commands/coordinator.md

Left by an older flow layout (5) — reported, never deleted:
  ? .claude/commands/dev.md: left by an older flow layout (the role moved to
    .claude/agents/dev.md) — remove with `rm -f .claude/commands/dev.md`

Until BDL-061 S3b both lists were computed on every run and read by nothing outside the library, so BDL-UX #137's closure and S3's migration-guidance criterion were true of scaffold() and false of the command anybody runs. What the user saw instead was Skipped .claude/commands/coordinator.md (hand-edited; use --force) — advice to run the destructive flag, naming nowhere the edit could safely go (BDL-UX #188). NO CALLER, NO CAPABILITY.

The command prints the honest boundary: the coordinator + Agent-spawn are Claude-Code-native (orchestration stays in the harness); the Beadloom MCP process-tools are the deterministic, tool-agnostic substrate the flow calls; and the single source of TRUE enforcement remains beadloom ci in CI (the in-flow gates are advisory-strong, not a substitute for CI). See the Agentic Dev Flow guide.

beadloom setup-branch-protection ​

Configure trunk-based branch protection on main via gh api (BDL-049), so the CI gate becomes true enforcement rather than advisory. GitHub only.

bash
beadloom setup-branch-protection --repo OWNER/NAME [--branch main] [--check CONTEXT]... [--dry-run]

Idempotently sets main (or --branch) protection with a declarative PUT repos/{owner}/{repo}/branches/{branch}/protection: a PR is required (no direct push), the scaffolded default set of ci.yml checks — gate, tests (3.10), tests (3.11), tests (3.12), tests (3.13), tests-locale (C), tests-locale (en_US.ISO-8859-1), site-build, ai-techwriter (ci.yml's job names + matrix legs) — are required status checks (strict: true), and enforce_admins: true + 0 required reviews so the solo owner is never locked out (can self-merge). PUT .../protection is declarative, so re-running re-settles the same state.

  • --repo OWNER/NAME (required) — the GitHub repository (e.g. acme/widget).
  • --branch (default main) — the trunk to protect.
  • --check CONTEXT (repeatable) — overrides the default required-check contexts (the consolidated ci.yml job check-runs listed above) entirely. A context MUST match a real GitHub check-run name EXACTLY and must NOT be a path-filtered workflow's check: such a check does not run on PRs that miss the filter, so under strict: true the PR — and therefore main — would never become mergeable. BDL-050 dropped the tests paths: filter precisely so each matrix leg runs on every PR and is a reliable required check.
  • --dry-run — print the exact gh api call + JSON payload without touching GitHub.

A required context must be a check that can go green. PUT .../protection is declarative and re-running it is harmless in itself, but the payload is the DEFAULT set above, and strict: true means every context in it must pass before a pull request can merge. Requiring a check-run that is red — or that does not exist in your pipeline at all — makes the branch permanently unmergeable until it is green or the context is removed. On this repository today that is a live constraint, and the gap is now small enough to close deliberately: the two tests-locale legs added in BDL-061.38 were knowingly red (108 ASCII / 83 8-bit locale-attributable failures, measured) and BDL-061.42 turned both green. A tests-windows leg (BDL-061.39) briefly made the declared set ten and was the red one; the owner withdrew it in beadloom-mr2l.64 on a measured cost — ~16-28 runner-minutes per PR and the pipeline's critical path, roughly tripling PR-to-merge latency, for a platform outside this project's target audience — so the declared set is nine again. main still carries the seven pre-BDL-061.38 contexts, and the two it lacks have both been observed green. Sequencing: before re-running beadloom setup-branch-protection here, compare the declared contexts against what actually reports green on an open PR (gh pr checks), because the payload is the whole default set. Alternatively pass --check explicitly for the set your pipeline can satisfy.

Delegates to onboarding/branch_protection.py:apply_branch_protection() (the gh invocation is injected via a GhRunner seam for mockable tests).

beadloom mcp-serve ​

Launch MCP stdio server.

bash
beadloom mcp-serve [--project DIR]

API ​

As of BDL-059 S4, src/beadloom/services/cli.py is a thin registration shell: it imports each command module for its registration side effects and re-exports main (plus the private helpers tests import) so beadloom.services.cli.main stays the stable entry point. The command implementations live in the src/beadloom/services/commands/ package — one cohesive module per command group: _root (the main group + global options), query (ctx/search/why/graph/diff), index_ops (reindex/link), status (status + --debt-report rendering), docsync (sync-check/sync-update/install-hooks/active-sync/ci), federation (export/federate), docs (the docs group: generate/site/audit/polish), setup (the init/setup-*/mcp commands), dashboard (tui/ui/watch), snapshot (the snapshot group), waves (waves), review_brief (review-brief), mutation (mutation) and rooms (rooms). The status command's data-gathering was moved DOWN to the application layer (application/status.py: gather_status/compute_context_metrics/StatusData); the command keeps only the Rich/JSON presentation. The CLI surface (every command, option, help text, output, and exit code) is unchanged by the split.

Commands (re-exported from the package via the registration shell):

  • main -- Click group: beadloom [--verbose|-v] [--quiet|-q] [--version] COMMAND
  • reindex -- rebuild SQLite index (incremental by default, --full for complete rebuild)
  • ctx -- get context bundle for ref_id(s)
  • graph -- show architecture graph (Mermaid, C4-Mermaid, C4-PlantUML, or JSON) with --format, --level, --scope options
  • export -- export the indexed graph as a deterministic federation artifact (JSON, schema v1) with --out
  • federate -- aggregate >=2 satellite export artifacts into one federated graph (drift verdicts + staleness)
  • doctor -- run validation checks
  • status -- show index statistics with health trends and context metrics (data gathered by application/status.py:gather_status); --debt-report mode with --fail-if, --category flags
  • sync_check -- check doc-code sync with reason/details (reason-aware output for untracked_files, missing_modules, symbols_changed); --since GIT_REF measures drift against a git ref instead of the stored baseline (fresh-checkout / per-push drift detection)
  • sync_update -- review and update stale docs interactively; --check for status-only; --yes/-y for a non-interactive re-baseline; --all (with --yes) re-baselines every stale ref
  • install_hooks -- install/remove the pre-commit hook (lint -> mypy over the declared typed surface -> sync-check -> declared-axes verdict -> guarded ACTIVE/tracker-coherence auto-fix step) AND/OR the pre-push Beadloom Gate hook (beadloom ci, blocks the push on red; command -v beadloom guard -> safe no-op outside a flow repo); --pre-commit/--pre-push selectors (default both), --remove, idempotent
  • active_sync -- reconcile each epic's ACTIVE.md bead-status table from bd (--epic/--check/--json/--no-export); fix mode also bd exports the tracked .beads/issues.jsonl; safe no-op when no ACTIVE table or no bd; delegates to application/active_table/reconcile.py:reconcile_active_tables()
  • link -- manage external tracker links
  • search -- FTS5 search with LIKE fallback
  • why -- impact analysis (upstream + downstream) with --reverse and --format {panel,tree}
  • diff_cmd -- graph changes since a git ref
  • snapshot -- Click group for snapshot commands (save, list, compare)
  • snapshot_save -- save current graph state as a snapshot
  • snapshot_list -- list all saved snapshots
  • snapshot_compare -- compare two snapshots (added/removed/changed nodes and edges)
  • lint -- architecture lint with --strict, --fail-on-warn, auto-format detection, agent-actionable remediation, and --format {rich,json,porcelain,github} (GitHub annotations)
  • prime -- compact project context for AI agents
  • setup_mcp -- configure MCP server for editor
  • setup_rules -- create IDE rules files
  • setup_ai_techwriter -- scaffold the AI tech-writer (vendored harness + recipe + chosen platform CI wrapper + getting-started guide) for one-command opt-in; delegates to onboarding/ai_techwriter_setup.py:scaffold()
  • setup_agentic_flow -- scaffold the packaged multi-agent dev flow (.claude/agents/* + commands/* vendored byte-identical + CLAUDE.md auto-regions per-project); idempotent, --force overwrites hand-edited flow files; delegates to onboarding/agentic_flow_setup.py:scaffold()
  • config_check -- AgentConfigAsCode drift gate (--fix regenerates); reuses the setup-rules --refresh generator; also drift-checks/restores the scaffolded agentic-flow files when the flow is present
  • ci -- unified enforcement gate composing reindex -> lint -> sync-check -> docs-audit -> docs-quality -> issue-log -> readme-pair -> doc-spaces -> scope-check -> config-check -> doctor -> (optional --hub) federate into one exit code; the verdict carries the room it was taken in (GateResult.room), printed in all three formats and changing no step's status; the docs-audit step blocks on stale facts (stale>0); honest per-step PASS/WARN/FAIL/SKIP; uniform --format {rich,json,github} (github = valid ::error file=,line= annotations); delegates to application/gate.py:run_ci_gate()
  • waves -- decide which of the named beads may run at the same time, from the code-level independence of their declared node scopes; prints one named reason per serialised pair, the media a concurrent wave shares, one plan-time verdict per medium and each wave's gate_owner (--json; exit 0 clean / 1 findings / 2 undecidable); delegates to application/waves/planner.py:plan_waves()
  • review_brief -- assemble a reviewer's input (assignment, declared scope, specification documents, bound scenarios, changed files) while withholding the bead's own comments, and state what is REACHABLE per channel: bead comments (counted on that bead and on no other), the documents of the work item the branch names, the commit bodies of the reviewed range, and the launch prompt, which is named as a channel nothing here can inspect; --release prints the account once a verdict is recorded and reports whether that verdict's independence can be established (--since, --json; exit 0 clean / 1 findings / 2 unassemblable / 3 release refused); delegates to application/review_brief/
  • mutation -- the score a run produced over the declared mutation.targets, from the counters the project's own runner wrote (--stats/--target/--only/--tool/--min-score/--json); reads counters by NAME and reports one it did not find rather than as zero; folds the scope check in, so an empty population is a finding and not a 100%; exit 0 clean or nothing declared / 1 findings or under the floor / 2 counters named without the scope they cover; delegates to application/mutation_scope/score.py:report_mutation_score()
  • rooms -- the room this run is in and the rooms the project declares, derived from the packaging classifiers and every CI workflow rather than from a list (--dimension prints one axis, one value per line, for a checklist to loop over; --json); exit 0 census taken / 2 --dimension names an axis no declared room carries; delegates to application/rooms.py:take_census()
  • mcp_serve -- run MCP stdio server
  • docs -- Click group for doc commands (generate, polish, audit)
  • tui -- launch TUI dashboard (primary command, multi-screen with --no-watch)
  • ui -- launch TUI dashboard (alias for tui)
  • watch_cmd -- watch files and auto-reindex
  • init -- project initialization (bootstrap, import, interactive, non-interactive with --yes/--mode/--force)

All commands accept --project DIR to specify the project root. The current directory is used by default.

Testing ​

CLI is tested via click.testing.CliRunner, and a command's tests are named test_cli_<command>.py. Where such a file tests one node it lives under that node's mirrored path (BDL-074): test_cli_status.py, test_cli_diff.py, test_cli_why.py and test_cli_snapshot.py under tests/integration/infrastructure/console_streams/, and test_cli_docs.py under tests/integration/onboarding/doc_generator/. The rest still sit at the top of tests/, unplaced, until their mixed contents are split by node: test_cli_reindex.py, test_cli_ctx.py, test_cli_graph.py, test_cli_sync_check.py, test_cli_sync_update.py, test_cli_hooks.py, test_cli_link.py, test_cli_mcp.py, test_cli_watch.py, test_cli_lint.py, test_cli_config_check.py, test_cli_setup_agentic_flow.py, test_cli_active_sync.py (+ test_cli_active_sync_hardening.py), test_cli_waves.py, test_cli_review_brief.py. init is tested by the tests/test_init_*.py files.

Two commands carry their command-level tests outside that naming, beside the application tests they render. mutation: tests/test_mutation_command.py and tests/test_mutation_phantom_gate.py, with tests/integration/application/mutation_scope/test_mutation_score.py; the workflow checks test_mutation_runner_scope.py, test_mutation_ci_job.py and the phantom-gate pins are self-checks under tests/self_check/config/. rooms: tests/integration/application/rooms/test_rooms_command.py, with tests/integration/application/rooms/test_verdict_room_derivation.py, tests/unit/application/rooms/test_verdict_room_census.py, tests/test_verdict_room_population.py and tests/test_gate_verdict_room.py; their checks against this repository's own CI legs are under tests/self_check/config/.

beadloom ctx <ref-id> prints, on its Tests: line, the files bound to a node and how many of the project's test files are unplaced.