Skip to content

✅ fresh

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

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

Onboarding ​

Project bootstrap, documentation import, and architecture-aware initialization.

Features ​

Each feature has its own SPEC.md:

  • Agent Prime — compact project context for AI sessions (beadloom prime).
  • Doc Generator — doc skeletons + polish data from the graph (beadloom docs generate / polish).
  • Config Check — AgentConfigAsCode drift detection (beadloom config-check).
  • Branch Protection — strict trunk-based main protection (beadloom setup-branch-protection).
  • Agentic Flow Setup — compose and write the multi-agent dev flow — roles, slash commands and CLAUDE.md (beadloom setup-agentic-flow).
  • AI Tech-Writer Setup — scaffold the packaged AI tech-writer harness (beadloom setup-ai-techwriter).
  • Flow Config — .beadloom/flow.yml schema + loader (FlowConfig) — declares tools/architecture/stack/quality, the document language, and overlays.suppress.
  • Role Composer — the roles-shaped door onto the composer: CORE + architecture + stack overlays + the project fragment (compose_role).
  • Role Adapters — per-tool role adapter sets (.claude/agents/*, .cursor/agents/*) from composed roles (generate_adapters).
  • Flow Composer — compose(core, architecture, stack, project), the one layered assembly behind role files, slash commands and CLAUDE.md; the project layer lives in .beadloom/flow/.
  • Flow Suppression — a declared stand-down of a shipped core rule: reason + exit condition mandatory, appended as a visible notice rather than applied as a deletion.
  • Flow Manifest — the sha256 of every composed artifact Beadloom writes, so a later run tells its own output (recomposable) from a hand edit (reported, never rewritten).

Specification ​

Modules ​

  • scanner/ — package (decomposed by cohesion in BDL-059 S4 into one module per responsibility — constants, project_scan, summary, entry_points, import_scan, readme, doc_classify, rules_gen, agents_md, prime, claude_md, bootstrap, init_flow, reindex_port, parent_edges, types; the package __init__ re-exports the full public surface, so beadloom.onboarding.scanner import paths are unchanged). bootstrap_project() scans source directories, classifies subdirectories using preset rules, infers edges from directory nesting, generates .beadloom/_graph/services.yml + .beadloom/config.yml. Also detects project name, generates architecture rules, configures MCP for the detected editor, and creates IDE adapter files via setup_rules_auto(). Provides import_docs() for classifying existing .md files (ADR, feature, architecture, other) and writing them into .beadloom/_graph/imported.yml, each with a part_of edge to the graph's root — the same post-condition bootstrap_project() holds, which this second writer of domain nodes did not receive until BDL-067 .14 and did not SHARE until .21: it is now one function in scanner/parent_edges.py that both writers import, rather than two same-named private copies that had already drifted apart once. generate_rules() creates structural rules with empty has_edge_to: {} matcher for hierarchy validation. _detect_framework_summary() detects 20+ framework patterns (Django, Flask, FastAPI, NestJS, Angular, Next.js, Expo, React Native, Spring Boot, Actix, Gin, Vue, SwiftUI, Jetpack Compose, UIKit, Express, etc.). _build_contextual_summary() combines framework detection, tree-sitter symbol counts, README excerpts, and entry-point labels into a 120-char summary. _sanitize_ref_id() strips parentheses from Expo router dirs. generate_agents_md() creates .beadloom/AGENTS.md with the MCP tool list (built from the canonical MCP_TOOL_CATALOG, currently 18 tools) and architecture rules; preserves user content between <!-- beadloom:custom-start --> / <!-- beadloom:custom-end --> HTML comment markers (migrates old ## Custom format automatically). It delegates to build_agents_md_content() — a pure in-memory builder (extracting the preserved custom block via _extract_agents_custom_content()) — so the AgentConfigAsCode drift checker (config_sync.py) can re-run the exact same generation logic without writing to disk. _detect_rule_type() labels a rule by the authoring key that selects its type, and returns "unknown" for anything else. Which keys select a type it READS from graph.rules.loader.AUTHORING_KEYS — the keys of the loader's own dispatch table — rather than keeping a copy (BDL-073 B3), so a rule type added to the loader is labelled here by the same act; test_every_authoring_key_the_loader_accepts_has_a_type still holds the two together. What the label says stays here, in _LABEL_FOR_KEY: check reads as cardinality and forbid as forbid_edge, and every other key is its own label. The rule's keys are walked in the order its author wrote them, so a rule naming two kinds — which the loader rejects — is labelled by the first. The import crosses from agent-prime into rule-engine and rides the exemption .beadloom/_graph/rules.yml already declared for that edge, whose until now says the lint port that retires it must also hand over the authoring keys. The copy it replaced fell behind once: until BDL-062 .4 the map knew seven of twelve keys, and .beadloom/AGENTS.md described three of this repository's rules — module-coverage, scenario-coverage and doc-area-coherence — to every agent that reads that file as kind (unknown). The label is cosmetic for enforcement (graph/rules/loader.py loads and enforces the rule either way) and is not cosmetic for a reader: it was one of the disagreeing sources of the rule-type count recorded as BDL-UX #179. _build_rules_section() labels every rule present in rules.yml through _detect_rule_type(). prime_context() returns compact project context (static config + dynamic DB queries) for AI agent sessions; its Health: line and its JSON health.layer_populations state how much of its edge set each declared layer rule judged, in the shared population_phrase wording, and LintSnapshot holds the findings and that population together so both come from one lint run (BDL-070 A4). _discover_entry_points() detects CLI, script, server, and app entry points across 8 languages (Python Click/Typer/argparse, Go, Rust, Java, Kotlin, Swift, JS/TS). _quick_import_scan() infers depends_on edges between clusters by sampling code imports via tree-sitter. interactive_init() runs the interactive initialization wizard with re-init detection, mode selection, and a re-index run through the callable its caller hands it (reindex_port.py states what onboarding needs; the service supplies application.reindex.reindex, because a domain importing an application use case runs against the declared layer direction -- BDL-070 beadloom-46am). non_interactive_init() runs initialization without prompts for CI/script use, supporting mode (bootstrap/import/both), force (delete existing .beadloom/), and automatic doc linking, in the order the wizard has always run: bootstrap, then import, then doc skeletons, then one reindex after every block that writes a graph file (BDL-067 .14; from inside the bootstrap block it produced an index older than the imported.yml the same run went on to write). The skeletons moved to the end in BDL-067 .18: generated inside the bootstrap block, they were on disk before the import step ran, so --mode both classified them and wrote Beadloom's own scaffolding into the adopter's graph (BDL-UX #216). auto_link_docs() fuzzy-matches existing docs/ markdown files to graph nodes by ref_id similarity (exact path, stem match, partial match) and patches the docs: field in services.yml. _ingest_readme() extracts project metadata (description, tech stack, architecture notes) from README/CONTRIBUTING/ARCHITECTURE files. refresh_claude_md() refreshes auto-managed sections in .claude/CLAUDE.md between <!-- beadloom:auto-start SECTION --> / <!-- beadloom:auto-end --> marker pairs by regenerating dynamic content (project info facts) while preserving everything outside markers. Supports dry_run mode for preview. _parse_markers() extracts marker pairs from text. _auto_insert_markers() inserts markers around section 0.1 on first refresh if no markers exist. _render_project_info_section() generates the dynamic project-info content (stack + declared dependencies, tests, linter, type checking, architecture, version) — every bullet read from the target project through scanner/project_facts.py, and OMITTED rather than substituted when it cannot be read. Until BDL-UX #183 the version bullet rendered application.doctor.get_actual_version(), i.e. Beadloom's own __version__, into the section describing the adopter's project (a JavaScript project a major version behind was told ours); the architecture line said DDD packages whatever flow.yml declared; the stack line matched the target's manifest against Beadloom's own dependency names and stated our Python floor as theirs; and the package scan fell back to looking for src/beadloom/ inside the adopter's tree. Each was true on this repository by coincidence, which is why four slices of scrutiny read a correct line. A section with nothing readable now says what it looked for instead of printing nothing; _render_doc_language_section() renders the doc-language region from flow.yml's language, so the shipped flow no longer states "MUST be written in English" unconditionally (BDL-UX #136). blank_auto_regions() replaces every region BODY with a fixed token — the composition check compares what was composed and leaves the per-project facts to the region check, so neither reports the other's drift.
  • scanner/project_facts.py — What the TARGET project declares about itself, and nothing else: detect_project_version() (pyproject.toml's [project] / [tool.poetry], a dynamic version through [tool.hatch.version] or [tool.setuptools.dynamic], then package.json, then Cargo.toml), detect_source_packages(), detect_requires_python() (verbatim, never normalised to our floor), detect_declared_dependencies() (first six, declared order) and manifest_text(). Unknown is None and the caller renders nothing — a plausible substitute is worse than a gap, because a gap is visibly a gap. A VCS tag is deliberately not consulted: a tag is a release marker on a commit rather than a statement the project makes about itself, and reading one would need the infrastructure layer onboarding must not import. Also backs application/doctor.py's audit of an adopter's CLAUDE.md (BDL-UX #183).
  • scanner/types.py — TypedDicts that sharpen the static types for scanner results (BDL-059 S5). ScanResult (keys: manifests, source_dirs, file_count, languages) is the return type of scan_project(); ClusterEntry (keys: files, children, source_dir) is the value type returned by _cluster_with_children() and consumed by _quick_import_scan(). Same runtime shape as the prior dict[str, Any]; the TypedDicts only improve static analysis for callers.
  • presets.py — Architecture presets (monolith, microservices, monorepo) with directory classification rules and edge inference logic. Preset dataclass defines name, description, dir_rules, default_kind, infer_part_of, infer_deps_from_manifests. PresetRule dataclass maps directory name patterns to node kinds with confidence levels. detect_preset() checks for mobile app indicators (React Native/Expo via package.json, Flutter via pubspec.yaml) before falling back to directory heuristics.
  • doc_templates.py — the shape of a generated document (BDL-061 S4b). The five doc kinds (overview, domain, service, feature, beadloom-readme) ship as package data under templates/docs/core/ and compose through composer.compose("docs", ...), so a project extends a document exactly the way it extends a role. render_doc(name, values, ...) substitutes the template's doubled-brace placeholder tokens and RAISES on a placeholder with no value; required_sections(name, ...) derives the sections a document must carry from the composed template's literal ## headings with placeholders erased first — so a project fragment at .beadloom/flow/docs/domain.md that appends ## Runbook makes Runbook required by the same act, and a heading that arrives through a placeholder (## Public API) stays conditional. required_sections_by_document_kind(...) runs the SAME extraction over the other family of composed templates — the PLANNING skeletons (BRIEF, RFC, PRD, CONTEXT, PLAN, ACTIVE), which are fenced blocks inside the composed /templates slash command rather than docs artifacts of their own (BDL-068 S1.4). Only the fenced text is read: the prose around a skeleton is commentary, and reading it would make the commentary's headings required of the document. ## Axes is required of a BRIEF and an RFC because those two skeletons carry it, and for no other reason. doc_flow_config(project_root) falls back to DEFAULT_DOC_CONFIG when a project records no flow.yml, because generating documentation must not require the agentic flow to be scaffolded. (SPEC)
  • doc_generator.py — generate_skeletons() creates docs/ tree from graph nodes (architecture.md, domain READMEs, service pages, feature SPECs with _doc_path_for_node() resolution), writes docs: field back to the graph file each node came from via _patch_docs_field(), and generates .beadloom/README.md quick-start. That scaffold's Beadloom-facing prose is DERIVED rather than written into the template — beadloom_readme_values() supplies the product description from the package docstring and the MCP tool list from MCP_TOOL_CATALOG — because a sentence written into a template propagates into every repository that ever ran beadloom init and goes stale there. Measured at BDL-062 .15: it carried the 1.x product description three majors on, and named 8 of 18 MCP tools as though that were the list (BDL-UX #211). Since BDL-061 S4b the shape of every generated document comes from doc_templates.render_doc rather than from a string literal in this module; the renderers here compute the VALUES and nothing else. generate_polish_data() returns structured JSON for AI-driven doc enrichment with each node's index symbols via _symbols_for_node(), which takes the files under the node's source by path component rather than by string prefix since BDL-069 beadloom-6rgr and asks infrastructure.node_source.NodeSource whether a file lies under it since beadloom-rqma.4, the one rule the route attribution and git activity of a reindex call too, SQLite dependency edges via _enrich_edges_from_sqlite(), symbol change detection (_detect_symbol_changes() / _detect_symbol_changes_with_conn()), and routes/activity/tests from nodes.extra via _load_extra_from_sqlite(). format_polish_text() renders multi-line human-readable polish output including symbol drift warnings, routes, activity level, and test metadata. SQLite operations in _load_symbols_by_source() and _load_extra_from_sqlite() catch sqlite3.OperationalError and log at debug level (non-fatal degradation when tables are missing).
  • config_reader.py — read_deep_config() extracts scripts, workspaces, path aliases, and build metadata from project configuration files. Parses pyproject.toml ([project.scripts], [tool.pytest], [tool.ruff], [build-system]), package.json (scripts, workspaces, engines), tsconfig.json (compilerOptions.paths, baseUrl), Cargo.toml ([workspace] members, [features]), and build.gradle/build.gradle.kts (plugins, dependencies via regex). Merges results from multiple config sources with deduplication for scripts and workspaces.
  • ai_techwriter_setup.py — Backs beadloom setup-ai-techwriter --platform {github,gitlab} (BDL-047 / F4.1, G8; BDL-051 / S2). Since the harness now ships INSIDE the installed beadloom package (beadloom.ai_agents.ai_techwriter), the scaffold no longer vendors any Python (the BDL-047/048 HARNESS_MODULES / vendor_harness / sync_vendored_harness drift-guard machinery is retired). scaffold(target_root, platform=...) idempotently drops: the chosen platform's CI wrapper (_scaffold_github() / _scaffold_gitlab() — GitLab appends job-only to an existing .gitlab-ci.yml, never blindly clobbering, and skips an already-wired file) which invokes python -m beadloom.ai_agents.ai_techwriter; the operator artifacts tools/ai_techwriter/{recipe.yaml, provision-runner.sh} (_scaffold_recipe() / _scaffold_provision_runner()) copied from the harness package data via importlib.resources — the recipe a readable reference of the agent's blast radius, the provisioner a hardened, idempotent (0o755) runner-stand-up script (--platform/--repo/--token, set -euo pipefail, fail-hard RAM (~2 GB min, ~4 GB recommended) + disk (~5 GB) prechecks, swap guaranteed before apt, GitHub Actions runner or GitLab Runner registration, best-effort+verified Goose/beadloom/bd installs); and the ≤3-step docs/guides/ai-techwriter.md (_scaffold_guide()). Raises ValueError on an unknown platform. templates_root() locates the packaged workflow/guide assets; _read_harness_data() reads the recipe/provisioner from the harness package.
  • agentic_flow_setup.py — Backs beadloom setup-agentic-flow (BDL-048; recomposed in BDL-061 S3). scaffold(project_root, *, force=False, include_agents=True, config=None) drops the flow into a target repo, and records the selection it composed from as .beadloom/flow.yml on a first run (never over an existing one — that file is the adopter's policy). Without it a virgin setup-agentic-flow left config-check at exit 1 with four errors on an untouched repository, remediated by advice to run the command just run (BDL-UX #187); and because scaffold() re-resolved the config from disk without the CLI flags, --architecture fsd composed the role adapters as fsd and the commands + CLAUDE.md as ddd. The caller's resolved config is now threaded in. The slash commands and .claude/CLAUDE.md are now composed — composed_command() / composed_claude_md() call composer.compose() for the repo's flow.yml plus its .beadloom/flow/ project layer — and each write is fingerprinted in the flow manifest. _scaffold_composed() therefore recomposes a file Beadloom wrote and nobody touched, and skips a hand-edited one, reporting it in ScaffoldResult.migration_notes with the project-layer path the edit belongs in; --force overwrites regardless. orphaned_flow_files() reports files a PRIOR layout left behind — the four role files and epic-init.md in .claude/commands/ — with the exact rm -f command, and never deletes them (BDL-UX #137). ScaffoldResult carries files written/skipped, the CLAUDE.md path and changed sections, plus orphans, migration_notes and flow_config_written — all of which now reach stdout: until BDL-061 S3b the orphan list and the migration notes were computed on every run and read by nothing outside this module, so BDL-UX #137's closure and S3's migration-guidance criterion were true of scaffold() and false of the command anybody runs, while the user saw (hand-edited; use --force) — the destructive flag, naming nowhere safe (BDL-UX #188, NO CALLER NO CAPABILITY). The shipped CLAUDE.md core carries a neutral __BEADLOOM_PROJECT_NAME__ token in its ## 0.1 Project: heading, substituted on scaffold. No function here writes package data since BDL-068 beadloom-iur5. sync_agentic_flow(live_claude_root) used to refresh five packaged agents/*.md.txt assets from the live .claude/agents/; BDL-061 S3 had already stopped it snapshotting CLAUDE.md and the commands, because doing so pinned the distributed artifact to this project's local text by construction — a bead id and a false claim about this repo's branch protection reached the shipped template twice, the second time over the correction (BDL-UX #177). The role leg was the one it did not reach, and it was harmless only while this repository declared no .beadloom/flow/roles/ fragment. The assets and the function are gone: include_agents=True composes through _scaffold_composed() like every other artifact kind, so config-check --fix on a repository with no flow.yml writes that project's own composition and records each write in the manifest. That also closes #132: nothing writes the CLAUDE.md core, so --force cannot overwrite its placeholder. AGENT_FILES is role_composer.ROLE_NAMES itself (BDL-068 S1.5), so the scaffold and the composer cannot disagree about which roles exist. templates_root() locates the packaged assets.
  • branch_protection.py — Backs beadloom setup-branch-protection (BDL-049, contexts updated in BDL-050 and BDL-061.38; the BDL-061.39 platform context was withdrawn again in beadloom-mr2l.64). An idempotent main-branch-protection helper for the trunk-based flow (CLAUDE.md §6): every change integrates via a PR (no direct push) and the consolidated ci.yml checks are required status checks, so the pipeline becomes true enforcement (hardening BDL-048 G5). build_protection_payload(*, status_check_contexts=DEFAULT_STATUS_CHECK_CONTEXTS) builds the GitHub request body — required_status_checks {strict: true, contexts}, enforce_admins: true, required_pull_request_reviews {required_approving_review_count: 0}, restrictions: null — so a PR IS required and even admins cannot direct-push (strict trunk-based), but the owner is NOT locked out (can self-merge once the pipeline is green, since 0 required reviews + the un-filtered ci.yml checks). DEFAULT_STATUS_CHECK_CONTEXTS = ("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") are the real consolidated ci.yml check-run names (BDL-050 — the job names + the un-filtered 3.10-3.13 matrix legs — plus the two tests-locale legs added in BDL-061.38: the same whole suite run with the locale varied, not pinned (C and en_US.ISO-8859-1), which is an environment DIMENSION rather than more coverage — the defect it exists to catch (BDL-061.36) failed on all four python legs at once and was invisible to the entire local suite, because every test in it ran on one UTF-8 host). There is deliberately no PLATFORM dimension: a tests-windows context was added in BDL-061.39 and withdrawn by the owner in beadloom-mr2l.64 on a measured cost (~16-28 runner-minutes per PR, and — unlike the locale rows, which finish inside the ubuntu legs' shadow — the pipeline's critical path, roughly tripling PR-to-merge latency; Windows is not in this project's target audience), and the job and the context left in the same change because a required context whose check-run nothing produces is an unmergeable branch. What the withdrawn leg taught did NOT leave with it: the six skipif(sys.platform == "win32") guard tests are gated on a MEASURED symlink capability and run on any runner that holds it. A required context MUST match a real check-run name EXACTLY and must NOT be a path-filtered workflow's check (under strict it would never run → permanently-unmergeable PR/main), which is why BDL-050 dropped the tests paths filter. The constant is the SCAFFOLDED DEFAULT, not a description of any repository's live protection: applying it requires every named check-run to be able to go green, and this repository's own main still requires the seven pre-BDL-061.38 contexts. The two tests-locale legs went green in beadloom-mr2l.42, so the declared nine and the live seven now differ only by those two — see docs/services/cli.md for the sequencing before re-running setup-branch-protection here. BranchProtectionRequest (frozen dataclass: owner/repo/branch/status_check_contexts) exposes endpoint(), payload_json() (deterministic sort_keys), and gh_args() (the gh api --method PUT … --input - argv). apply_branch_protection(owner, repo, *, branch="main", status_check_contexts=..., runner=None) builds the declarative PUT .../protection and runs it through an injectable GhRunner seam (defaults to the real gh CLI; tests pass a fake that records argv + stdin without touching GitHub). PUT .../protection is declarative, so re-running re-settles the same state (idempotent).
  • config_sync.py — AgentConfigAsCode drift detection. check_config_drift(project_root, conn) re-runs the SAME generators in memory and diffs their output against disk, returning a ConfigDrift(file, reason, severity, remediation) per drifted artifact (deterministically sorted). It reuses build_agents_md_content() (AGENTS.md), refresh_claude_md(dry_run=True) (the CLAUDE.md auto-regions) and the _RULES_ADAPTER_TEMPLATE / _is_beadloom_adapter() adapter helpers — never a parallel reimplementation. Since BDL-061 S3 it verifies the composition result, not file bytes: _claude_md_body_drift() compares .claude/CLAUDE.md against compose("claude", ...) with the auto-region bodies blanked, _agentic_flow_drifts() compares each slash command against its composition, and _composed_adapter_drifts() compares each role adapter against compose_all_roles(config, project_root). A project extension in .beadloom/flow/ is therefore part of the expected output and is not drift, while a change to a shipped fragment still is (BDL-UX #139, #152). Before that, the CLAUDE.md body was checked by nothing — measured on a scaffolded project, replacing the whole file with a single line still returned [] and the Gate printed config-check PASS: agent-config in sync (BDL-UX #177). _state_drift() maps the manifest states onto severities: stale, hand_edited and missing are errors, unverified is a warning so an adopter's green project does not go red on upgrade. The inverse now holds too: a severity reduced for want of evidence carries ConfigDrift.weakened_from, and config-check prints how many findings are warn only because Beadloom cannot prove what it wrote, plus the command that restores the blocking verdict. The exit code deliberately does not change — a downgrade is silent where a red is loud, so what it needed was to be said, not to start blocking (review .11 MAJOR 5). Since BDL-061 .57 nothing an editor deletes makes the check quieter: _flow_scaffold() splits the canonical flow files into present/missing so one deletion no longer switches the checks off for the others (_missing_file_drifts()), _flow_manifest_drift() reports an absent manifest as unverified rather than reading every artifact as unmanaged-and-therefore-warn, _project_layer_drift() names the project fragments in effect because their prose is composed but not judged, and _suppression_drifts() reports an overlays.suppress entry that names no rule in the composed corpus or whose until: date has passed. _unverifiable_body_drift() covers the degraded path the rest of that work exposed: when a CLAUDE.md carries neither a manifest entry nor the provenance stamp, the body cannot be JUDGED — but in a project that adopted the flow it is still NAMED, at unverified/warn, instead of the check falling back to silence at the moment it lost its evidence. A deleted CLAUDE.md that the manifest records is missing/error, like the other two kinds. The honest floor is stated in the config-check SPEC: every ownership signal is in-band, so deleting both still downgrades the body to warn; what is guaranteed is that the deletion is visible and the file is named. The body check runs only when the file is Beadloom's — a manifest entry or the <!-- beadloom:composed stamp — so a project's own CLAUDE.md is never policed (the #73 false-positive class). refresh_agentic_flow_files() is the config-check --fix companion: it runs the scaffold's non-forcing path, so it recomposes what it owns and leaves a hand edit in place instead of deleting it. refresh_composed_adapters() recomposes the per-tool role adapter sets except the ones it declines — an adapter classified hand_edited or unverified is left byte-identical and returned in AdapterRefresh.declined, because until BDL-061 .59 it wrote unconditionally and deleted the edit one line after the check promised it would not be rewritten (BDL-UX #186). Since BDL-068 .67 that set is declined_adapter_rewrites(), a function setup-agentic-flow reads too, so the repair and the scaffold cannot disagree about whose file a body is (BDL-UX #191). apply_config_fixes() is the whole --fix seam: it runs every writer and reports what changed by digesting the artifact surface before and after, so a run that changes a file names it. _flow_config_drift() validates .beadloom/flow.yml. _duty_drifts() is _suppression_drifts()'s sibling over the same corpus — a declaration in the flow checked against the artifacts it describes — and reports every finding of role_duties.duty_report() at severity="error", never fixable: the repair is the duty's TEXT in a role core and --fix writes compositions, not prose. _ignore_block_drifts() (BDL-068 S6, BDL-UX #238) is the third artifact init writes into a repository Beadloom does not own and the last one to get a check: it reports, at warn and never fixable, every pattern ignore_block.GENERATED_WORKING_SET emits that the project's .gitignore does not declare, and names the declared line a wider pattern supersedes. _role_map_drifts() (BDL-068 S6, BDL-UX #252) is _duty_drifts()'s neighbour one level up — it reports every finding of role_map.role_map_report(), taking each finding's own severity because a designation was written on purpose and an inferred roster is a guess about punctuation, and never fixable for the same reason: the repair is a sentence in the map. Since BDL-068 .84 the corpus is one map per declared tool and each finding names the artifact it is about; a declared tool with no map artifact produces no drift at all and is printed as an unreached population instead. _orphaned_adapter_drifts() (BDL-068 S6, beadloom-ec1a) closes the gap the other adapter checks could not have: every one of them opens with for tool in config.tools, so narrowing the tool subset REMOVED the dropped tool's files from the check instead of reporting them. Measured with a control on 2026-09-09 — in a project scaffolded for claude and cursor and then narrowed to claude, the same two lines appended to .claude/agents/dev.md are an error and the same two appended to .cursor/agents/dev.md are exit 0, and the On disk: count falls from 10 to 5 with nothing said about the other five. It reports each recorded adapter under an undeclared tool at warn and never fixable, which is _ignore_block_drifts()'s policy applied to a different file rather than a fourth one invented: an orphan comes from one act, an adopter editing tools: in their own flow.yml, and both repairs — re-declaring the tool, deleting the file — are theirs, while --fix writes compositions and deletes nothing. Backs beadloom config-check [--fix].
  • flow_config.py — The flow config (BDL-052 S3, extended in BDL-061 S3). Backs .beadloom/flow.yml: tools, architecture (exactly one of ddd/fsd), stack (python/fastapi/javascript/typescript/vuejs), quality, plus language and overlays.suppress. language is a BCP-47-ish tag validated for SHAPE rather than against a closed list — the set of languages a team writes in is not ours to enumerate — and it selects a <name>.<lang>.md.txt fragment in every layer, with the fallback reported (BDL-UX #136). overlays.suppress is validated through flow_suppression, so a suppression without a reason or an exit condition is a configuration error; an unknown key under overlays is rejected with the reminder that project additions are files under .beadloom/flow/, not keys here. FlowConfig is the validated frozen result; build_flow_config(data) validates a parsed mapping; load_flow_config() reads the file; load_flow_config_or_default() falls back when absent but still raises on a present-but-bad file; detect_stack() infers a default stack from file extensions; resolve_flow_config(...) applies flag-over-flow.yml-over-default precedence, carrying language and the suppressions through verbatim because they are project policy rather than a per-run choice.
  • role_map.py — Checks that every role this flow composes is named in the map each DECLARED TOOL's reader opens, in both directions (BDL-068 S6, BDL-UX #252, and .84 for the tool axis). The third direction of the graph role_duties.py checks two directions of: #228 was a duty declared for a role does not reach that role's core, and this is a role that exists does not reach the document that lists roles. The edge was never built because nobody had added a role since the map was written — Explore shipped in S1, was invoked four times each by the /coordinator and /task-init templates, and was named ZERO times in the shipped CLAUDE.md whose section 0.0 draws the role map and whose section 4 is the Agent Roles table. config-check already counted it (On disk: 5 role file(s)) while answering two other questions. role_map_report(project_root, config=None, *, roles=None) derives ONE MAP ARTIFACT PER DECLARED TOOL from config.tools — the composed .claude/CLAUDE.md for claude, the .cursor/rules/beadloom-flow.md orchestrator pointer role_adapters.py renders for cursor — and reads two construct kinds per fragment, so a finding names the file and line to open: a DESIGNATION (subagent_type: <names>, agents/<name>.md, agents/{<names>}.md) claims each name IS a role, and an INFERRED roster (a run joined by ·, or a run of backticked names joined by , or |) is recognised only when it already names two composed roles. A bare word search is refused for the reason role_duties.py refuses one: it would read test in "Committing with failing tests" as the role (BDL-UX #205, #190, #209). Three findings: unmapped (a composed role no construct names, error), partial (a composed role omitted from a roster that names two others — error from a designation, warn from an inferred roster) and unbacked (a name a designation claims is a role and no CORE fragment ships, error). unbacked never fires from an inferred roster, because a punctuated run's other tokens are ordinary words and reporting them would turn an adopter's ``we deploy to dev, staging``` into a release-introduced error in their own prose. RoleMapReport.not_judgednames every line mentioning two or more roles in a shape no construct reads — a wave order names four roles andExploreis not a wave — and prints on a clean run too. Judgement runs PER ARTIFACT, because each map owes the whole role population on its own. A declared tool this release names no map artifact for isRoleMapReport.unreached: a stated population and never a drift, because the gap is Beadloom's and failing an adopter for it would report the release's hole as theirs. Until BDL-068 .84this module composed Claude's map unconditionally and never readtools:, so a cursor-only project was judged against a document its flow does not declare while the one its agent reads was asked nothing — BDL-UX #252's own class one axis over, inside the check written to close it. Measured on 2026-09-10: the claudecorpus carries 16 designations, 6 of them rosters, 5 not-judged lines; thecursor` corpus carries 1 designation (the brace expansion over every composed role), 1 roster and 2 not-judged lines.
  • role_composer.py — The roles-shaped door onto the composer (BDL-052 S3; generalised in BDL-061 S3). compose_role(role, *, architecture, stack, language, suppressions, project_root) validates the role/architecture/stack and delegates the layering to composer.compose("roles", role, ...), so roles, slash commands and CLAUDE.md share ONE implementation instead of three. The composed order is CORE (templates/roles/core/<role>.md.txt) → the single architecture overlay → each stack overlay sorted → the project fragment at .beadloom/flow/roles/<role>.md. compose_all_roles(config, project_root=None) composes every role; omitting project_root yields the shipped-only composition, which is the drift baseline for a repo with no project layer. ROLE_NAMES is DERIVED from the shipped CORE fragments rather than declared (BDL-068 S1.5): roles_in(core_dir) reads templates/roles/core/*.md.txt and takes a fragment as a role when its opening front matter names itself, which is already the difference between a role and the shared _writing layer. Adding explore.md.txt therefore made explore a role in every reader by the same act, and agentic_flow_setup.AGENT_FILES is this tuple rather than the second literal it used to be — the two homes had eight readers between them, so a fifth role added to one reached the composer and was absent from the scaffold's present/missing split. Today: dev/explore/review/tech-writer/test.
  • composer.py — compose(kind, name, *, config, project_root) — the layered assembly for all four artifact kinds (roles, commands, claude, docs). ArtifactKind records where each kind's CORE fragment and overlay root live; Composition carries the ordered fragments (each labelled core / architecture:<a> / stack:<s> / project), the notes that make an unshipped localisation an audible skip rather than a silent English fallback, and the appended suppression_notice. COMPOSED_MARKER (<!-- beadloom:composed) is the provenance stamp the shipped CLAUDE.md core now begins with — its presence, or a manifest entry, is what tells config-check the body is Beadloom's to verify. PROJECT_FLOW_DIRNAME is .beadloom/flow.
  • flow_suppression.py — FlowSuppression(rule, reason, until) + build_suppressions() + render_suppression_notice(). Overlays are append-only, so a core rule can only be stood down by declaration, and reason and until are both mandatory (the bar guards.<name>.exclusions already holds). Expiry is decided by infrastructure.exit_condition.exit_condition_deadline — the same function both exemption lists in rules.yml use, not a restatement; it moved below both domains in BDL-070 B2 so reading it is no longer a peer-domain import — and an expired suppression renders — EXPIRED at the point it suppresses something.
  • flow_manifest.py — The record of what the composer wrote: .beadloom/flow-manifest.json, {project-relative path: sha256}. classify(on_disk, expected, recorded, alternates, accounted) returns one of five states — clean, stale (recomposable), hand_edited (reported, never rewritten), missing (we wrote it and it is gone) and unverified (nothing accounts for it, so a hand edit and an upgrade cannot be told apart and the check says so, in sync-check's word). alternates lets a caller name other bodies Beadloom itself could have written, so a repo predating the manifest that was never edited still reads stale. accounted is BDL-061 .57's answer to rm .beadloom/flow-manifest.json downgrading a hand edit from error to warn: a project that keeps a usable manifest, or an artifact carrying the <!-- beadloom:composed stamp, is accounted for, and a body then absent from the record was not written by us. read_manifest() returns the map and that flag together. Severity follows the state: hand_edited and missing are errors, unverified a warning.
  • guard_hooks.py — Emits the harness hook adapter that binds the flow guards to a tool (BDL-061 S1). scaffold_guard_hooks(project_root, *, guard_names) writes .claude/hooks/beadloom-guard.sh — a logic-free adapter whose only executable line is exec beadloom guard "$1" --hook claude-code — and registers one PreToolUse entry per guard in .claude/settings.json. Registration is a merge, never a rewrite: existing hooks (ours or anyone else's) survive, re-running adds nothing, and a settings file that cannot be parsed as JSON is reported in GuardHookResult.settings_skipped_reason and left untouched. hook_command(name) builds the $CLAUDE_PROJECT_DIR-rooted command string; GUARD_HOOK_RELPATH / SETTINGS_RELPATH / HOOK_EVENT / EDIT_MATCHER are the emitted-shape constants. Guard names are a parameter, not an import — the registry lives in the application layer above this domain, so beadloom setup-agentic-flow (services) supplies them. EDIT_MATCHER is Edit|Write|MultiEdit|NotebookEdit|Bash since BDL-068 S4 closed BDL-UX #170: before Bash was on it, a file written through sed -i or a heredoc invoked no guard and left no firing, which --liveness cannot distinguish from a compliant session. The script's comment enumerates the three codes an invocation through it can return and what each means for the edit — including 1 for an unresolved verdict, where the guard could not evaluate itself and the edit went through unchecked rather than being blocked by a gate whose only repair is a file write (BDL-UX #254). See the guard-hooks component.
  • ignore_block.py — Names Beadloom's generated working set under .beadloom/ and appends it ONCE to the project's .gitignore (BDL-061.35). Before it, Beadloom wrote an ignore entry nowhere — measured across all of src/ — so an adopter collected untracked churn from the first reindex (the SQLite index) and again from the first guarded edit (guard-firings.jsonl); only this repository was clean, because its .gitignore was hand-edited. ensure_ignore_block(project_root) appends the block and returns IgnoreBlockResult(path, added, skipped_reason); GENERATED_WORKING_SET is the IgnoreEntry(pattern, why) list — the why is rendered above its pattern in the file, because a bare pattern in someone else's ignore file is indistinguishable from a mistake. The entry belongs to init, not to the guard scaffolder: it is a property of the directory Beadloom creates, so bootstrap_project writes it (and reports ignore_added) and setup-agentic-flow repeats the identical whole-set call for a project initialised by an older Beadloom — otherwise the flow guards would be a special case while the larger churn, the index, stayed unignored. Written once and never rewritten (BLOCK_MARKER present ⇒ hands off), which is what makes the override real: delete a line and it does not come back. A config key would be the opposite trade — it would force the block to become managed, so flipping the key rewrote somebody's ignore file. The firing record is ignored by default because it is machine-local and append-only (committing it makes every edit a working-tree change and every branch a conflict on the same last line), and the block says so. Since beadloom-0mdo.43 the entry's why also names what a team would be committing if it took the block's own invitation up: 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. The sentence was written when the record held paths, and binding the shell tool (BDL-UX #170) made it hold command lines — advice that stayed true while the artifact under it changed, which is this epic's subject arriving in the one place it ships to adopters. Its pattern is .beadloom/guard-firings*.jsonl, which covers the generation the record rotates into since beadloom-mr2l.56 — ignoring the active file while leaving its archive untracked would reintroduce the churn the entry exists to remove. Nothing is written outside a git working tree, no pattern the project already declares is duplicated, and the project's own lines are never touched. Written-once has a cost, and since beadloom-0mdo.40 half of it is reported rather than only documented: undeclared_patterns(text) returns every generated pattern a file does not declare — the predicate ensure_ignore_block now writes from, so the writer and the check cannot disagree — and config-check reports each as a warn. This repository's own .gitignore carried the exact filename .beadloom/guard-firings.jsonl against that glob until S4 and nobody found it; it surfaced only when an unrelated change made the record rotate for the first time. The why text is still not compared, so a stale reason stays invisible. See the ignore-block component.
  • role_adapters.py — Writes per-tool role adapters from composed roles (BDL-052 S3). generate_adapters(config, project_root) composes each role once — CORE + overlays + the project fragment at .beadloom/flow/roles/<role>.md — and writes every configured tool's set: claude → .claude/agents/<role>.md, cursor → .cursor/agents/<role>.md + a .cursor/rules/beadloom-flow.md orchestrator pointer. Each write is recorded in the flow manifest, which is how a later run tells its own output from a hand edit. Idempotent; the single writer the drift-guard verifies against. preserve= names project-relative paths to leave exactly as they are: both callers pass the adapters config_sync.declined_adapter_rewrites() reports, so a body Beadloom cannot prove it wrote is neither written nor recorded (BDL-UX #186, #191). The pointer's role list is rendered over ROLE_NAMES rather than spelled as prose, which is where a fifth role used to go missing. AdapterResult reports the paths written and, in .preserved, the ones left alone; TOOL_AGENT_DIRS maps a tool to its agent dir. orphaned_adapters(project_root, config) answers the other direction, which nothing asked until BDL-068 S6: which recorded adapters sit under a tool config.tools no longer names. Its population is the flow MANIFEST rather than TOOL_AGENT_DIRS crossed with ROLE_NAMES, so a file Beadloom never recorded writing is left to whoever owns it and a role a later release retires is still reported; OrphanedAdapter.diverged separates a body that has already changed from one that has merely stopped being watched. .cursor/rules/beadloom-flow.md is excluded for the reason stated above — no check compares that pointer in either state.
  • role_duties.py — Checks that a duty declared for a role is carried by that role's composed core, in both directions (BDL-068 S4). One class, measured four times across two epics before it was named: a duty an agent is obliged to perform is written somewhere the performer does not read — the clean-room rule lives in the coordinator's prose and occurs zero times in the role cores the roles receive. Duties are declared, never inferred: a detector over English role prose would repeat the docs-audit keyword-proximity class (BDL-UX #205, #190, #209), so a duty carries a marker the way a scenario carries @bead: and @node: — <!-- beadloom:duty=<id> roles=<a,b> --> in any composed flow artifact, <!-- beadloom:carries=<id> --> in a fragment that composes into one. duty_report(project_root) composes roles + slash commands + CLAUDE.md for the repo's flow.yml plus its project layer, reads both marker kinds per fragment (so a finding names the file to open rather than the artifact the text ended up in), and reports four kinds: 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 — scenario-coverage's dead node), and malformed (a duty= marker with no roles= list, which names no performer). Carriage is recorded for every composed artifact, not only role files: dropping a duty because its artifact is not a role would be this check committing the class it exists to report. DutyReport.not_inspected names what the check cannot see, on a clean run too — the coordinator's launch prompt, because a prompt is not an artifact and no glob can reach it, plus every fragment carrying a marker that no composition read, derived by subtraction rather than listed. The report also names WHICH question it answered: the composition this flow would write, never the role files on disk — on an unscaffolded project it reported a duty delivered to five roles over a corpus no role could receive (BDL-068 S4.x, BDL-UX #241), so DutyReport.role_files counts the adapters that exist on disk (counted, never read — reading them is config_sync's job) and config-check prints NOTHING TO CHECK when there are none. The verdict is unchanged: an unscaffolded project is not in drift. The subtraction base carries no exclusion. It used to carry one: the templates/agentic_flow/agents/*.md.txt snapshot was a byte-identical copy of the composed role files, so it carried every marker they carry and named all five roles as unreachable the moment a role core first declared a duty (beadloom-67t1). beadloom-iur5 deleted the snapshot, so the derivation no longer has its own output in its input and no longer needs a rule saying to skip it.

The domain's surface reaches past src/beadloom/onboarding/. Three modules under src/beadloom/services/commands/ carry # beadloom:domain=onboarding sections and are therefore part of this node's checked pairs: docs.py (the whole module — docs generate, docs polish, docs site, docs audit), setup.py (the setup-ai-techwriter, setup-agentic-flow, setup-branch-protection, config-check and init command bodies) and query.py (the prime command body). The behaviour lives in the modules above; what lives here is the option surface and the printed shape — which is why a change to either makes this document stale.

The composed flow, and the project layer ​

Since BDL-061 S3 every flow artifact this domain writes is composed rather than copied. composer.compose(kind, name, ...) concatenates four layers in a fixed order — the shipped stack-neutral CORE, one architecture overlay, each stack overlay sorted, and the adopting repository's own fragment under .beadloom/flow/ — for three kinds: roles (.claude/agents/*, .cursor/agents/*), commands (.claude/commands/*) and claude (.claude/CLAUDE.md).

Three consequences shape the rest of the domain:

  • The core shrank because layer 4 exists. Measured: 440 -> 376 lines, each removed line mapped to a replacement — two sections that restated §0 command for command, and the Python anti-patterns plus the uv run pytest / ruff / mypy block, which moved into the Python stack overlay. A project composing no stack overlay keeps the 376-line core, and its critical rules name no Python tooling.
  • config-check verifies the composition RESULT, not file bytes. Byte-guarding against a fixed template makes extension impossible: any project addition is drift and --fix deletes it. Verifying the result keeps drift detection while making the project layer possible (BDL-UX #139, #152).
  • The composition is a function of its inputs and of nothing else. No clock, no ambient state. That property is the entire licence for the previous point, and flow_suppression gave up rendering an expiry verdict into the bytes to keep it.

Adopter-facing procedure — adding a fragment, declaring a suppression, migrating a hand-edited scaffolded file — is in the Project Overlays guide.

CLI Commands ​

bash
beadloom init --bootstrap [--preset {monolith,microservices,monorepo}]
beadloom init --import DOCS_DIR
beadloom init  # interactive mode
beadloom init --yes [--mode {bootstrap,import,both}] [--force]  # non-interactive mode
beadloom docs generate   # create doc skeletons from graph
beadloom docs polish     # structured data for AI enrichment (text or JSON)
beadloom docs audit      # check documented facts against project state (text or JSON)
beadloom docs audit --verbose            # also name every document the scan did not read
beadloom docs audit --fail-if unverified>0  # fail when a declared fact was checked against nothing
beadloom docs spaces     # the three documentation spaces + where intent never reached AS-IS
beadloom docs spaces --json [--strict]   # machine-readable; --strict exits 1 on any finding
                         # the JSON names the tracker it read (`tracker_source`), lists the
                         # epics that tracker does not name (`epics_unknown_to_tracker`),
                         # says why each unresolved epic is unresolved (`unresolved_reasons`),
                         # names the documents whose kind overruled their space's roots
                         # (`documents_outside_declared_root`) and, under `working`, how many
                         # documents each declared half of the exemption reached (`reach`);
                         # `working.pairs_excused` is null because this command runs no
                         # freshness check and states no pair count it did not measure
beadloom prime           # compact project context for AI agent injection
beadloom setup-rules     # create IDE adapter files (.cursorrules, etc.)
beadloom setup-rules --refresh           # refresh auto-managed CLAUDE.md sections
beadloom setup-rules --refresh --dry-run # preview changes without writing
beadloom setup-agentic-flow              # compose the multi-agent dev flow into .claude/
beadloom setup-agentic-flow --tool claude --architecture ddd --stack python,fastapi
beadloom setup-agentic-flow --force      # overwrite hand-edited composed flow files
beadloom config-check                    # detect agent-config drift (exit 1 on error-severity drift)
beadloom config-check --fix              # recompose what Beadloom owns, then re-check
beadloom setup-branch-protection --repo OWNER/NAME            # protect main: PR required + consolidated ci.yml checks required (gate/tests (3.10..3.13)/tests-locale (C, en_US.ISO-8859-1)/site-build/ai-techwriter) (GitHub)
beadloom setup-branch-protection --repo OWNER/NAME --dry-run  # print the gh api call + payload without touching GitHub
beadloom snapshot save [--label LABEL]           # save current graph state
beadloom snapshot list [--json]                  # list all saved snapshots
beadloom snapshot compare OLD_ID NEW_ID [--json] # compare two snapshots

What docs audit reports ​

services/commands/docs.py renders the doc_sync audit result, and since BDL-061.45 it states the run's coverage as well as its findings. N mention(s) fresh counts what the audit found; it says nothing about the facts nothing was found for, or about the documents that were never opened, and both of those used to read as a clean bill of health (BDL-UX #173).

  • Each declared fact in the Ground Truth block carries what the run checked for it: N mention(s) checked, NOT VERIFIED: no document states it, or NOT VERIFIED: followed by the reason the value cannot be read (a value of 0 or 1 is too common in prose to be read as a claim).
  • One summary line gives the fraction and names the shortfall. Measured on this repository: 5 of 9 declared fact(s) verified; NOT VERIFIED: edge_count, language_count, nodes_with_framework, test_count. version left that list in BDL-062 .7: the install section of docs/getting-started.md now states the current release as a claim, which is the ONE place a document does so, and the audit compares it against pyproject.toml on every run. Before that no document stated it at all, and the audit said so rather than passing quietly -- which is the point of the line. The four that remain are unverified for two different reasons, and the line distinguishes them: edge_count, nodes_with_framework and test_count are simply not stated anywhere, while language_count cannot be verified at all here because its value is 1, and 0 and 1 are too common in prose to be read as claims.
  • A second line publishes the scan surface — 59 document(s) scanned, 43 not read, 1 scanned for versions only (file-type heuristic) on this repository — and --verbose names each excluded document with the pattern that skipped it. Those two numbers move whenever a document is added or an exclude pattern changes; they describe a run, not a property of the tool.
  • A line per fact the audit declared no value for in this project, with the reason: NOT APPLICABLE to this project: mcp_tool_count — the MCP tool catalog describes the running beadloom package, not this project (this project declares itself as 'invoice-svc', not 'beadloom'); declare docs_audit.extra_facts.mcp_tool_count in .beadloom/config.yml to audit this project's own. The lines appear only when something was declined, so this repository's output is unchanged.
  • A line naming the version tokens the run declined to judge, with the reason it declined: 1 version token(s) the audit could not judge here: git x1 (no .git here, and its absence cannot tell a project that never used git from an export of one). A version belongs to the subject named beside it, and git is confirmed by the environment rather than by a file the project ships, so a directory built by git archive HEAD cannot confirm it -- and reading the absent marker as a denial compared git 2.49.0 against this project's own version in every clean room (BDL-UX #266). The line appears only when something was declined, so a run in a git working tree is unchanged.
  • Document paths, fact values, mention values and exclusion reasons are escaped before Rich reads a line as markup, so a path such as docs/app/[slug]/[draft].md prints as written rather than as docs/app//.md (beadloom-2mj3.19). The JSON output was never affected.
  • --json gains coverage, verified_facts, unverified_facts, not_applicable, unjudged_versions, unresolved_version_subjects and scan_surface beside the existing arrays, and six counts under summary (declared_fact_count, verified_fact_count, unverified_count, unreadable_count, not_applicable_count, unjudged_version_count). --fail-if accepts unverified>N / unverified>=N alongside stale>N / stale>=N; coverage is reported on every run and enforced only when asked for.

API ​

Module src/beadloom/onboarding/scanner/ (package; public surface re-exported from __init__):

  • ScanResult -- TypedDict (manifests, source_dirs, file_count, languages); return type of scan_project()
  • ClusterEntry -- TypedDict (files, children, source_dir); value type for _cluster_with_children() output, consumed by _quick_import_scan()
  • scan_project(project_root) -> ScanResult -- scan project structure, return manifests, source_dirs, file_count, languages
  • classify_doc(doc_path) -- classify a markdown document (adr, feature, architecture, other)
  • bootstrap_project(root, *, preset_name=None) -- auto-generate graph from code structure (incl. root node, rules, MCP config, AGENTS.md, IDE rules). Also calls ensure_ignore_block, and the result dict carries ignore_added / ignore_skipped_reason so the caller can report the .gitignore write instead of performing it silently
  • bootstrap_project holds one stated post-condition: every node it writes carries at least one outgoing part_of edge — to its classified parent where one exists, to the root service node otherwise, and to nothing at all when the node's ref_id is the root's own, because an edge from a node to itself is not a parent. That last clause is the docstring's and was missing from this sentence until BDL-067 .6. The self-edge case WAS reachable on the classic Python src/<project>/ layout, where root and domain were handed the same ref_id, the loader kept one of the two nodes and the rule went inert rather than red. BDL-069 closed it in the writers — see the ref_ids bullet below — and the carve-out stays because this function also runs over a graph a hand edit can reach. The post-condition ranges over EVERY kind since BDL-067 .17; until then it read kind: domain, which is the kind today's generated rules require a parent for. generate_rules also writes feature-needs-parent, and the sibling statement of the same post-condition in import_docs already covered every kind — so the two writers of one invariant disagreed about its population, and a post-condition that tracks the current rule set goes stale the next time a rule is added (the review of .16, minor 2). The post-condition has to hold at all because the same call writes domain-needs-parent into the adopter's rules.yml whenever it writes a domain, and the loader defaults that rule to error. Until BDL-067 the post-condition held on every branch but one: a project whose source directory has no code-bearing subdirectory (a flat src/index.ts) produced no clusters, took the fallback branch, and got one node per source dir at the preset's default kind and no edge — while the loop that attaches top-level nodes to the root iterates the clusters and therefore reached none of them. Measured on a TypeScript project: init --yes --mode bootstrap rc 0 and Graph: 2 nodes, 0 edges, then lint --strict rc 1 and ci rc 1 on a rule the same command had authored one step earlier (BDL-UX #192). parent_edges.missing_parent_edges(nodes, root_ref_id, parented) names the edges the post-condition is short of and runs over the whole node list, so a branch that forgets the edge is closed whether or not anyone remembers it exists. Since BDL-067 .21 it is ONE function, shared with import_docs — the two writers carried the same private name in two modules with the same loop body, differing in a str() call and in their parameter order, and the only thing binding them was a docstring here naming a symbol .17 had renamed away (the review of .20, major 3). Its companion parented_by(edges) answers what counts as a parent — only a part_of edge — for both callers; left as a comprehension at each call site that filter was untestable, because the bootstrap writes no edge of another kind before it holds the post-condition. What each writer still computes for itself is parented: the bootstrap produces the whole graph and reads it off the edges it is about to write, the importer adds to a graph and reads it off the one on disk. Its root_ref_id is read back from the root node rather than recomputed from the project name: cluster refs pass through _sanitize_ref_id and the root ref does not, so a recomputed destination resolves to nothing for a project whose name contains parentheses.
  • ref_ids.RefIdAllocator(taken=()) with take(preferred, *, qualifier=None) hands out the ref_id of every node BOTH writers commit, so no two of them carry one name. The graph identifies a node by its ref_id, so a writer that emits one name twice writes ONE node and reports two: measured on the published 4.0.0 wheel over a project called myapp holding src/myapp/, init --yes --mode bootstrap reported Graph: 2 nodes and beadloom status then reported Nodes: 1, and the node dropped was the one carrying source: src/myapp/ (BDL-UX #214). take returns preferred whenever it is free — every node on every project the collision does not touch — then preferred-qualifier (callers pass the node's kind, so the second myapp is written myapp-domain), then numbered forms. Which asker keeps the plain name is the CALLER's judgement: bootstrap_project gives the root the project's name before any cluster asks, because that ref_id titles the architecture document and is what generate_rules names as the parent every domain must have; import_docs seeds the allocator with _existing_graph(...).ref_ids — the graph already on disk, minus the imported.yml it is about to replace — so a document named after a node, or two documents sharing a file name, each get a ref_id of their own. The rename reaches the EDGES too: the manifest dependency loop, _quick_import_scan (which now takes the cluster-to-ref_id mapping as a parameter) and the top-level attachment loop all name the ref_id the cluster was WRITTEN under, because an edge built from a recomputed name would replace a lost node with a dangling edge the loader drops just as quietly.
  • init takes a verdict on the graph it wrote, and exits 1 rather than reporting success over one that fails the rules on disk beside it. Two paths are exempt and both are stated below. After the graph is written and indexed, EVERY branch that writes a file under .beadloom/_graph/ — --yes in any mode, --bootstrap, --import and the default interactive wizard — runs the Gate's own lint step (application.gate.lint_step, i.e. passed = not result.has_errors) over the project and exits 1 when it does not pass, naming each error-severity rule, the graph file its node came from, and the step beadloom ci will fail. The enumeration is over branches that WRITE rather than over branches that bootstrap, since BDL-067 .17. Until then --mode import was carved out on the stated ground that both of the report's headlines opened with the graph this command just wrote — which held only until the next init on the same tree: the wizard's re-init does not delete .beadloom/, so imported.yml survived into a later bootstrap that wrote domain-needs-parent and met nodes an earlier run had left unparented (the review of .16, major 2). The --import branch now re-indexes what it wrote before judging it, instead of telling the adopter to re-index by hand. The wizard took no verdict at all until BDL-067 .6: the tests covering the other two were parametrised over the two bindings of bootstrap_project, the wizard shares the --yes binding, and two bindings were counted as two branches — so the branch a human adopter meets first carried #192's exact shape through four green waves (wizard rc 0, lint --strict rc 1, ci rc 1, reproduced by the review of .4). One path still takes no verdict, deliberately: the wizard's edit review answer hands the graph to the user to edit by hand and tells them to re-index afterwards, so there is nothing settled to judge. The wizard's cancel answer was a second such path until BDL-067 .21, and never deliberately: bootstrap_project writes services.yml and leaves the adopter's rules.yml in place BEFORE "Proceed with this graph?" is asked, so cancel stopped the rest of the run and not the part that touched the tree — the wizard exited 0 over a graph the same tree's lint --strict rejected, which is BDL-UX #192's sixth instance on the branch a human adopter meets first (the review of .20, major 2). It went unseen because the branch enumerator's termination set was {Return, Raise} and that path left through sys.exit, so the walk stepped over it and found the verdict below. Both halves are closed: init contains no sys.exit at all, so the cancelled result falls through to the same guard the other answers use, and the enumerator's terminator classifier now resolves a call and reads its return annotation. WHETHER a verdict is owed is now asked of the TREE rather than answered by a branch's position in the source: the verdict returns without linting when nothing under .beadloom/_graph/ changed since the run started. That is what makes the wizard's OTHER cancelled answer — the re-init prompt, asked before any writer runs — correctly unjudged, where reporting it would name an existing tree's failures under a withdrawal line saying a scaffold was written. The wizard also no longer prints "Cancelled." over files it has written: it names .beadloom/_graph/ and says how to undo it. A rules.yml the loader refuses is reported as the loader's complaint rather than as a rule name, because the finding the Gate raises there carries the step's own name in rule and the reason in why — printing the name told an adopter that a rule called lint had failed. This is the half of BDL-067 that prevents the class rather than the instance. The step is shared with the Gate rather than restated here, so the two verdicts cannot drift. The rc is non-zero rather than a loud zero because a zero would let a scripted init && ci run on to the point where the cause is no longer in view. The scaffold is left on disk either way: the rc reports the state, it does not withdraw the graph. WHOSE the failure is comes from two facts about the tree, not from the branch that is reporting: init samples .beadloom/_graph/ before any writer runs and again at verdict time, at two grains at once — the bytes of each file and each node as written, keyed by ref_id. The headline's two halves and the sentence under them are chosen from (this run wrote the failing node, this run wrote rules.yml) through the _GRAPH_HALF, _RULES_HALF and _ATTRIBUTION tables, which are written over the full product so a corner cannot be left out. The node is the grain of the first half since BDL-067 .24 (the review of .23, major 4): read at the file grain it said yes whenever any writer touched the file the failing node sat in, and generate_skeletons annotates inherited files by default, so a node no writer in this run produced was announced as this run's and the adopter was asked for a bug report about it. rules.yml keeps the file grain, because it holds no nodes. The finer grain does not move the case where the annotated node IS the failing node — the docs: field goes into that node's own entry, so it is a node this run changed at either grain — and that measurement is recorded with the corners it was taken on in tests/test_init_report_says_whose_failure_it_is.py. Only the corner where both are this run's calls the red a defect in Beadloom's bootstrap and asks for a report; the other three name what was already there and ask for nothing. Each of those three denials is made at the grain its own half of the key is read at, which is what keeps it checkable against the tree: the two corners chosen by the node deny writing the NODE, and the corner chosen by rules.yml denies writing that FILE. Both node-chosen sentences said graph file(s) until BDL-067 .27. .24 had moved the key to the node and left the words behind, and the review of .26 measured the result twice: a run that rewrote an inherited graph file to annotate the failing node's sibling then told the adopter it had not written that file, which git diff shows modified. Until .17 one boolean about rules.yml chose both sentences, so a run that bootstrapped over an inherited imported.yml said the graph this command just wrote about nodes it had not written and sent the adopter to file a bug about a writer that had not run. The completion claim is withdrawn by the verdict itself rather than by a caller that remembers to: every branch announces a scaffold above it — the wizard's Initialization complete!, --bootstrap's four check marks, --yes's summary — and until .17 only the wizard passed the withdrawal in, under a docstring asserting that --bootstrap never made the claim (the review of .16, major 3). Two facts were measured on --mode both and --import by the review of .13 and closed in .14. First, the verdict read an index written before the last graph file the command wrote: on --mode both the reindex sat inside the bootstrap block and the import step wrote imported.yml afterwards, so init --yes --mode both exited 0 while lint --strict on the same tree exited 1 on three nodes — and the wizard, which re-indexes after importing, exited 1 on the same project shape. Two halves of one command disagreed, which is the condition lint_step was made public to make unrepresentable. The reindex now runs after every block that writes a graph file. Second, the report named services.yml for a node that came from imported.yml; each line now names the graph file its node was written into, and the advice sends the adopter to those files. The line about beadloom ci states the step's name and its summary rather than quoting a rendering: ci picks rich only on a TTY and github otherwise, and the github renderer builds its own step line, so the quoted [FAIL] lint: ... was false in exactly the scripted context --yes serves — the same tree printed ::notice::lint FAIL: ... (the review of .16, the minor).
  • The skip policy for a reader of .beadloom/_graph/ that reads it FOR NODES and is outside the graph domain is stated once, in graph_files.each_graph_file(graph_dir, *, also_skip=frozenset()): a file whose name is not a graph file's is skipped, a file that will not read or will not parse is skipped, and a file that parses to anything other than a mapping is skipped — so a caller may read data["nodes"] without asking again whether it can. rules.yml belongs to the policy rather than to a caller, because a rules file is not a graph file for any reader; the constant naming it lives in graph/loader.py and is re-exported here, because onboarding may import graph and the reverse is a cycle. also_skip is the one genuine difference between the callers and is passed at the call site: doc_classify._existing_graph names imported.yml, because the run that asks is about to replace it. There were four bodies until BDL-067 .24 — doc_generator._load_graph_from_yaml, doc_generator._patch_docs_field, doc_classify._existing_graph and setup._graph_file_of_each_node — with four policies, two of them carrying no guard at all. BDL-069 beadloom-4ad3 measured what each of the SEVEN readers of that directory reads for, by asking each the same question over two directories holding the same nodes and different bytes. Five read for nodes and two read for bytes — change_detection._scan_project_files hashes each file to decide whether a reindex is needed and setup._graph_files_now digests them for the failure report's attribution — and for those two the policy is inapplicable by nature, because a file that will not parse still has bytes. read_declared_docs and link were routed here; update_node_in_yaml, load_graph and compute_diff could not be, for one boundary rather than three judgements, and each names this policy in its own docstring instead. Two of the three would keep a behavioural exemption regardless: load_graph must report a file it cannot parse rather than skip it (BDL-UX #86), and compute_diff compares a working tree against content at a git ref, where a directory walk covers only one side. The remaining duplication is filed as beadloom-4axf. BDL-UX #220 is closed except for one shape: on all eight (entry point x mode) cells of init's own table, a legacy.yml that does not parse and one whose top level is a list now leave init --bootstrap at exit 0, and a file carrying added: 2026-09-02 still ends in TypeError in graph/loader.load_graph, because that file is readable and no skip policy reaches it. The runs are pinned in tests/test_graph_files_are_read_under_one_policy.py, and the population measurement in tests/test_what_each_reader_of_the_graph_directory_reads_for.py.
  • Where each node is DECLARED is a second question about the same directory, and it is graph_layout.py rather than graph_files.py because it is the WRITERS' question: each_graph_file says which files may be read, and layout_of(graph_dir) says whether two agents adding two nodes can collide. The property: every node is declared in a graph file of its own named after the node (node_file_name(ref_id) is the ref id verbatim plus .yml), and every edge is declared in a file named after one of its two endpoints. The first clause removes the shared write — two node-adding beads create two files, so the collision cannot be attempted rather than being detected afterwards, which is the primitive beadloom-0mdo.66 took for issue numbers with O_CREAT|O_EXCL. The second is the weaker clause an edge can carry, since an edge is a fact about two nodes and one file per node gives it one home: under its src where a reader looks for what a node depends on, and under the NEW node where a bead adds one, because that placement writes no existing node's file. GraphLayout reports shared and shared_nodes (the surface where a shared write is still possible), holds, declared, misnamed (a node in a file not named after it — the findability half, reported apart because a project can have the property without the name) and misplaced_edges. shared_files(files) takes a name-to-ref-ids mapping rather than a directory, so this module and application/waves/media_checks.py compute the surface with one body. It REPORTS rather than refuses: bootstrap_project still writes one services.yml, a single-file graph stays valid for every reader, and what an adopter gets is the number through the graph-files medium of beadloom waves. This repository took the split in BDL-UX #265; the measured cost and what it does not preserve (git blame through a 1-to-100 split) are in docs/domains/onboarding/components/graph-layout/DOC.md.
  • import_docs(root, docs_dir) -- classify and import existing documentation. Post-condition: every node it writes carries an outgoing part_of edge to the graph's root — the one node of kind service that no part_of edge leaves — unless the graph holds no single such node, in which case no parent is named rather than one guessed. "No single such node" counts DISTINCT ref_ids, not node entries: the graph identifies a node by its ref_id and the loader keeps one node per ref_id, so a root written twice is one candidate. Until BDL-067 .17 the candidates were collected into a list and counted there, and bootstrap_project produced the duplicate on an ordinary project shape — it wrote the root service node under the project name and its top-level attachment loop skipped the cluster whose sanitized name equalled that name, so a repository named after one of its own source directories yielded two unparented service entries under one ref_id. The import then attached nothing and init --yes --mode both exited 1 on every run; measured on a project named core holding src/core/ and src/orders/, and core, api, web and app are ordinary repository names (the review of .16, major 1). Since BDL-069 no writer produces that shape — ref_ids come from RefIdAllocator — and the distinct-ref_id count stays because this reader also meets graph files a hand edit or an older version wrote. The read itself answers three questions off one pass and returns them as ExistingGraph(root_ref_id, parented, ref_ids); ref_ids is what the allocator is seeded with, and it joined the other two in BDL-069 because a document named after an existing node was being written as a second node under that node's ref_id. The root's ref_id is read off the node as written, not recomputed from the project name. The rule it applies is parent_edges.missing_parent_edges, the same object bootstrap_project applies
  • generate_rules(nodes, edges, project_name, rules_path) -- generate architecture rules with empty matcher for hierarchy validation
  • setup_mcp_auto(project_root) -- auto-detect editor and create MCP config
  • setup_rules_auto(project_root) -- auto-detect IDEs and create adapter files (.cursorrules, .windsurfrules, .clinerules); content-aware: skips user-edited files
  • generate_agents_md(project_root) -- generate .beadloom/AGENTS.md with the MCP tool list (from MCP_TOOL_CATALOG, currently 18 tools) and rules; preserves content between <!-- beadloom:custom-start --> / <!-- beadloom:custom-end --> HTML comment markers (auto-migrates old ## Custom format)
  • prime_context(project_root, *, fmt="markdown") -> str | dict[str, Any] -- compact project context for AI agent injection (static + dynamic layers, <=2K tokens); returns markdown string or JSON dict depending on fmt
  • MAX_LISTED_FINDINGS = 10 (BDL-061 S4) -- how many findings of a kind prime LISTS. The COUNT is never truncated; only the list is, and the cut line names how many are not shown and the command that shows them. Measured: opting this repository into scenario-coverage (68 findings) grew the output from 2.6 KB to 13.1 KB, which is a context budget spent on one rule's backlog
  • interactive_init(project_root, *, reindex) -- interactive wizard with re-init detection, mode selection, review table, auto-reindex. What the scan found (manifests, source dirs, languages, preset, the import folder, the edit path) and the review table with its [high] / [low] confidence tags are escaped before Rich reads them as markup (beadloom-2mj3.19)
  • non_interactive_init(project_root, *, reindex, mode="bootstrap", force=False) -- non-interactive init for CI/scripts; supports bootstrap/import/both modes, force-deletes existing .beadloom/ when force=True, auto-links docs, and runs its steps in the wizard's order — bootstrap, import, doc skeletons — then reindexes after every block that writes a graph file (generate_skeletons is one: it patches a docs: field into the graph YAML). The reindex sat inside the bootstrap block until BDL-067 .14, so on --mode both the import step wrote imported.yml after it and the verdict — which reads the index without re-indexing — judged a graph the command had not finished writing. reindex is handed in and is required (BDL-070 beadloom-46am): the re-index is an application use case and onboarding is a domain, so the domain declares what it needs -- scanner/reindex_port.py, a callable taking a project root and reporting symbols_indexed / imports_indexed / edges_loaded / docs_indexed -- and services/commands/setup.py, a service above both, supplies application.reindex.reindex. The two function-local imports it replaces were the only import in this repository that ran against the declared direction -- the count is a moving measurement and lives in the bead (beadloom-46am) rather than here, where it would go stale on the next commit. Required rather than defaulted: a default meaning "do not re-index" would let a caller take its verdict over an index the run never refreshed, which is the .14 defect with a different cause
  • auto_link_docs(project_root, nodes) -- fuzzy-match existing docs/ files to graph nodes by ref_id (exact path, stem, partial match); patches docs: field in services.yml via _patch_docs_field; returns count of linked docs

All scanner YAML writes (services.yml / config.yml from bootstrap_project(), imported.yml from doc classification, rules.yml from rule generation, and the _patch_docs_field writeback) go through the atomic-io primitive (write_yaml_atomic: temp file + fsync + atomic os.replace), so an interrupted scaffold never truncates a source-of-truth *.yml. Integrity is guaranteed; the last write may be lost (never corrupted) across an OS/power crash — see the atomic-io durability boundary.

  • refresh_claude_md(project_root, *, dry_run=False) -> list[str] -- refresh auto-managed sections in .claude/CLAUDE.md between marker pairs; returns list of change descriptions; supports dry_run for preview without writing

Module src/beadloom/onboarding/scanner/project_facts.py:

  • detect_project_version(project_root) -> str | None -- the version the TARGET declares; None (and no bullet) when it declares none
  • detect_source_packages(project_root) -> set[str] -- the target's own src/<pkg>/<child>/ packages
  • detect_requires_python(project_root) -> str | None -- the declared constraint, verbatim
  • detect_declared_dependencies(project_root) -> tuple[str, ...] -- first six runtime dependencies, declared order
  • manifest_text(project_root) -> str | None -- every readable dependency manifest concatenated; None distinguishes "we could not look" from "nothing declares this"

Module src/beadloom/onboarding/scanner/project_scan.py:

  • _cluster_by_dirs(project_root, source_dirs=None) -> dict[str, list[str]] -- cluster source files by top-level subdirectories
  • _cluster_with_children(project_root, source_dirs=None) -> dict[str, ClusterEntry] -- two-level directory scan for preset-aware bootstrap; each entry contains files, children, and source_dir
  • _detect_project_name(project_root) -> str -- detect project name from pyproject.toml / package.json / go.mod / Cargo.toml (fallback: directory name)
  • _read_manifest_deps(package_dir) -> list[str] -- read internal dependency names from package.json workspace/file/link dependencies

Module src/beadloom/onboarding/scanner/import_scan.py:

  • _quick_import_scan(project_root, clusters: dict[str, ClusterEntry], seen_ref_ids) -> list[dict[str, str]] -- infer depends_on edges between clusters by sampling up to 10 code files per cluster via tree-sitter extract_imports(); capped at _MAX_IMPORT_EDGES (50)

Module src/beadloom/onboarding/presets.py:

  • Preset -- frozen dataclass: name, description, dir_rules, default_kind, infer_part_of, infer_deps_from_manifests
  • PresetRule -- frozen dataclass: pattern, kind, confidence
  • Preset.classify_dir(dir_name) -- return (kind, confidence) for a directory name
  • PRESETS -- dict mapping preset names to Preset instances
  • detect_preset(root) -- auto-detect architecture (mobile-aware: checks React Native/Expo/Flutter first)

Module src/beadloom/onboarding/doc_generator.py:

  • generate_skeletons(project_root) -- create docs/ tree from the graph on disk, write docs: back to the graph file each node came from, generate .beadloom/README.md. Every node document whose source is a directory names the Python files directly inside it, read off the disk, because missing_modules requires it and init --yes writes the skeletons before any index exists. The pair is NOT attested at write time (BDL-069 S1, BDL-UX #282). The Public API table is parsed off the disk as well, for every file under the node's source and only for a document that does not exist yet, because the index it used to come from is absent on init --yes, init --bootstrap and a clone — only the wizard had one, so one project got two different skeletons (BDL-069 beadloom-8lmj)
  • generate_polish_data(project_root, ref_id?) -- return structured JSON with SQLite dependency edges, symbol change detection, routes/activity/tests
  • format_polish_text(data) -- render polish data as multi-line human-readable text with symbol drift, routes, activity, tests

Module src/beadloom/onboarding/config_sync.py:

  • ConfigDrift(file, reason, severity="error", remediation=None) -- frozen dataclass describing one drifted agent-config artifact; severity is "error" (blocks the Gate) or "warn" (printed, does not block), remediation is the concrete next move or None
  • check_config_drift(project_root, conn) -- re-run the same generators in memory, compare every composed artifact against its composition, and classify the result against the flow manifest; returns a sorted list[ConfigDrift] covering the AGENTS.md and CLAUDE.md auto-regions, the IDE adapters, the composed roles/commands/CLAUDE.md, the flow config, the project layer in effect and suppression liveness

Module src/beadloom/onboarding/ai_techwriter_setup.py:

  • scaffold(target_root, platform=...) -- drop the platform CI wrapper (calling python -m beadloom.ai_agents.ai_techwriter) + the operator artifacts (recipe.yaml/provision-runner.sh from package data) + the getting-started guide (idempotent); raises ValueError on an unknown platform. No Python vendoring (BDL-051 / S2 — the harness ships in the wheel).
  • _scaffold_provision_runner(target_root) -- drop the hardened, idempotent, executable provision-runner.sh (swap-first, RAM/disk prechecks, GitHub/GitLab runner registration) into tools/ai_techwriter/ (from harness package data)
  • _scaffold_recipe(target_root) -- drop a readable copy of the Goose recipe (harness package data) into tools/ai_techwriter/ for operator reference
  • templates_root() -- locate the packaged workflow/guide scaffold assets
  • PLATFORMS -- supported CI platforms (github, gitlab)

Module src/beadloom/onboarding/agentic_flow_setup.py:

  • scaffold(project_root, *, force=False, include_agents=True) -- scaffold the packaged dev flow: the slash commands and CLAUDE.md are composed and each write is fingerprinted in the flow manifest; a file Beadloom wrote and nobody touched is recomposed, a hand-edited one is skipped and reported. Idempotent; returns ScaffoldResult. include_agents=False leaves the role adapters to generate_adapters, which is how beadloom setup-agentic-flow calls it
  • composed_command(name, config, project_root) / composed_claude_md(config, project_root, *, project_name) -- the composed body of one slash command / of CLAUDE.md, for the scaffold and for config-check to compare against; the latter substitutes the target's detected name for the core's neutral __BEADLOOM_PROJECT_NAME__ token
  • orphaned_flow_files(project_root) -- report files a PRIOR layout left behind, each with the exact rm -f command; never deletes anything (BDL-UX #137)
  • templates_root() -- locate the packaged scaffold assets
  • AGENT_FILES / COMMAND_FILES -- the role + slash-command file stems
  • SUPERSEDED_COMMAND_FILES -- the stems an older layout left in .claude/commands/ (the five roles + epic-init)
  • ScaffoldResult -- dataclass: files written/skipped + CLAUDE.md path + changed sections + orphans + migration_notes

ScaffoldResult.orphans and .migration_notes are populated and have no caller: beadloom setup-agentic-flow prints neither, so the orphan list and the "move your additions to .beadloom/flow/…" guidance reach a library caller and not the person running the command (BDL-UX #188).

Module src/beadloom/onboarding/config_sync.py (the config-check --fix companions):

  • refresh_agentic_flow_files(project_root) -- recompose the scaffolded flow files through the scaffold's own non-forcing path, so a hand-edited command or CLAUDE.md survives --fix (BDL-UX #151); gated on the flow already being present
  • declined_adapter_rewrites(project_root) -> tuple[DeclinedRewrite, ...] -- the role adapters no writer may recompose over (hand_edited or unverified), each with the reason and remediation config-check prints for the same file. Read by config-check --fix and by setup-agentic-flow; empty when the project declares no valid flow.yml
  • orphaned_adapters(project_root, config) -> tuple[OrphanedAdapter, ...] (role_adapters.py) -- the manifest-recorded role adapters sitting under a tool config.tools does not name, sorted by path. Rendered by config_sync._orphaned_adapter_drifts() as one warn, non-fixable drift each
  • refresh_composed_adapters(project_root) -> AdapterRefresh -- recompose every configured tool's role adapter set from CORE + overlays + the project layer, except an adapter whose body Beadloom cannot prove it wrote (hand_edited, unverified): those are left byte-identical and returned in .declined, so the sentence config-check prints about them is true (BDL-UX #186). A no-op when flow.yml is absent or invalid
  • apply_config_fixes(project_root) -> FixReport -- run every --fix writer and report rewritten / created (measured against the disk, not self-reported) plus declined

Module src/beadloom/onboarding/composer.py (BDL-061 S3):

  • compose(kind, name, *, config, project_root=None) -> Composition -- the one layered assembly for all four artifact kinds: CORE -> each SHARED core fragment -> one architecture overlay -> each stack overlay sorted -> the project fragment
  • Composition -- text (the composed body), fragments (ordered LayerFragments, each labelled core / architecture:<a> / stack:<s> / project), notes (why a layer could not do what was asked -- an unshipped localisation is an audible skip, never a silent English fallback) and suppression_notice
  • ArtifactKind -- where a kind's CORE fragment and overlay root live, and whether it carries_suppressions (BDL-061 S4b: False for docs, because a suppression stands down a rule addressed to an AGENT and a generated README has none to stand down)
  • templates_dir() / project_fragment_path(kind, name, project_root)
  • SHARED_ROLE_FRAGMENTS = ("_writing", "_rooms", "_landing", "_tracker") (BDL-061 S4; _rooms added by BDL-068 S3.2, _landing and _tracker by BDL-068 S5) -- CORE fragments every artifact of the kind carries, composed as labelled core:<name> layers between the core and the architecture overlay. _writing is the writing standard, _rooms the room statement every role that reports a measurement is held to, _landing what the merge slot grants and what it does not for every role that lands a commit in a tree it shares, and _tracker which population each of bd's answers covers for every role that reads one -- so each rule has one text instead of five copies that drift the moment one is edited. _rooms also carries the clean-room limit, _landing the landing-lock duty and _tracker the tracker-answers duty, all of which were previously stated only in the coordinator command or nowhere -- the loop that orchestrates rather than the roles that commit, measure and close. _tracker exists because three of the four role cores instructed bd close --suggest-next while the caveat that it names still-blocked beads lived only in CLAUDE.md, which a subagent reading its role core alone never sees. All four are layers and not roles: compose_role("_writing", ...) raises, and being layers they are language-selectable like every other one (_writing.ru.md.txt, _rooms.ru.md.txt, _landing.ru.md.txt and _tracker.ru.md.txt ship)
  • ARTIFACT_KINDS (roles, commands, claude, docs) / CLAUDE_ARTIFACT_NAME / COMPOSED_MARKER (<!-- beadloom:composed) / PROJECT_FLOW_DIRNAME (.beadloom/flow)

Module src/beadloom/onboarding/flow_suppression.py (BDL-061 S3):

  • FlowSuppression(rule, reason, until) with .expired(today=None) and .describe() -- describe() names the rule, the reason and the exit condition and says nothing about today, so the composition stays a function of its inputs
  • build_suppression(entry) / build_suppressions(value) -- validate overlays.suppress; a missing rule, reason or until raises FlowSuppressionError
  • render_suppression_notice(suppressions) -> str -- the notice appended to every composed artifact; empty when there are none, so a project that suppresses nothing gets byte-identical output
  • composed_headings(texts) / suppresses_nothing(suppression, headings) -- the dead-declaration check; rule is read as a /-separated heading path over the whole composed corpus
  • expired_suppressions(suppressions, *, today=None) -- expiry as a check-time finding
  • SUPPRESSION_KEYS (rule, reason, until) / FlowSuppressionError

Module src/beadloom/onboarding/flow_manifest.py (BDL-061 S3):

  • ArtifactState -- CLEAN / STALE / HAND_EDITED / MISSING / UNVERIFIED
  • classify(on_disk, expected, recorded, alternates, accounted) -> ArtifactState -- alternates names other bodies Beadloom itself could have written, so a repo predating the manifest that was never edited reads stale; accounted is true when the project keeps a usable manifest or the artifact carries the provenance stamp, and a body then absent from the record was not written by us
  • state_of(...) / digest(text) / record(project_root, entries)
  • read_manifest(project_root) -> (entries, usable) -- the map and whether anything accounts for it, together; load_manifest(project_root) is the thin wrapper returning only the map
  • FLOW_MANIFEST_RELPATH -- .beadloom/flow-manifest.json, generated state that belongs in git

Module src/beadloom/onboarding/flow_config.py:

  • FlowConfig -- frozen: tools, architecture, stack, quality, language, suppressions
  • build_flow_config(data) / load_flow_config(project_root) / load_flow_config_or_default(project_root) -- the last falls back when the file is absent but still raises on a present-but-bad one
  • detect_stack(project_root) -- infer a default stack from file extensions
  • resolve_flow_config(project_root, *, tools, architecture, stack) -- flag over flow.yml over default, carrying language and the suppressions through verbatim because they are project policy rather than a per-run choice
  • persist_flow_config(project_root, config) -> Path | None -- record the resolved selection as .beadloom/flow.yml on a first scaffold; returns None (and writes nothing) when the project already declares one
  • SUPPORTED_TOOLS / SUPPORTED_ARCHITECTURES / SUPPORTED_STACKS / SUPPORTED_QUALITY / DEFAULT_LANGUAGE / FLOW_CONFIG_RELPATH / FlowConfigError

Module src/beadloom/onboarding/role_composer.py:

  • compose_role(role, *, architecture, stack, language, suppressions, project_root) -- the roles-shaped door onto composer.compose("roles", ...)
  • compose_all_roles(config, project_root=None) -- compose every role; omitting project_root yields the shipped-only composition, which is the drift baseline for a repo with no project layer
  • roles_templates_root() / roles_in(core_dir) / ROLE_NAMES -- the role population, DERIVED from the shipped CORE fragments and not spelled here; beadloom config-check prints its size, and role_map.py is what holds the composed CLAUDE.md to it

Module src/beadloom/onboarding/role_adapters.py:

  • generate_adapters(config, project_root) -> AdapterResult -- compose each role once and write every configured tool's adapter set, recording each write in the flow manifest; idempotent, and the single writer the drift-guard verifies against
  • AdapterResult -- agents (tool -> paths written) and extra (the Cursor orchestrator pointer)
  • cursor_rules_relpath() / cursor_rules_body() / TOOL_AGENT_DIRS

Module src/beadloom/onboarding/role_duties.py (BDL-068 S4):

  • duty_report(project_root, config=None) -> DutyReport -- every declared duty checked against the composed core of each role it names, in both directions
  • DutyReport -- declarations, carried, findings, inspected, not_inspected (reported on a clean run too) and role_files, the adapters on disk the check counts and never reads
  • DutyDeclaration / DutyFinding / NotInspected / DUTY_MARKER / CARRIES_MARKER

Module src/beadloom/onboarding/role_map.py (BDL-068 S6):

  • role_map_report(project_root, config=None, *, roles=None) -> RoleMapReport -- every composed role checked against the map each DECLARED TOOL's reader opens, in both directions; two seams, neither used by production: roles defaults to the derived population, so the check can be shown red on a sixth role without writing a sixth fragment into a shared templates directory, and config defaults to the resolved flow.yml, so the unreached-tool branch can be measured before a release makes it reachable
  • RoleMapReport -- roles, tools, artifacts, unreached (both reported on a clean run too), references, rosters, findings, not_judged (also reported on a clean run) and inspected
  • MapArtifact (tool, name, authored_in, fragments) / UnreachedTool (tool, why)
  • RoleReference (names, source, tool, text, designated) / RoleMapFinding (kind, role, tool, artifact, sites, severity, why, remediation) / UnjudgedLine (source, tool, artifact, roles, text, why)

Module src/beadloom/onboarding/guard_hooks.py (BDL-061 S1):

  • scaffold_guard_hooks(project_root, *, guard_names) -> GuardHookResult -- write .claude/hooks/beadloom-guard.sh and register one PreToolUse entry per guard in .claude/settings.json; registration is a merge, never a rewrite, and an unparseable settings file is reported in settings_skipped_reason and left untouched
  • hook_command(guard_name) -- the $CLAUDE_PROJECT_DIR-rooted command string
  • GUARD_HOOK_RELPATH / SETTINGS_RELPATH / HOOK_EVENT / EDIT_MATCHER

Module src/beadloom/onboarding/ignore_block.py (BDL-061.35):

  • ensure_ignore_block(project_root) -> IgnoreBlockResult(path, added, skipped_reason) -- append Beadloom's generated working set to the project's .gitignore, once; nothing is written outside a git working tree and no pattern the project already declares is duplicated
  • GENERATED_WORKING_SET -- the IgnoreEntry(pattern, why) list; the why is rendered above its pattern, because a bare pattern in someone else's ignore file is indistinguishable from a mistake
  • undeclared_patterns(text) / ignore_block_findings(project_root) -> list[IgnoreFinding] -- the generated patterns a file does not declare, each carrying the IgnoreEntry itself plus the declared lines it supersedes (computed with fnmatchcase, not looked up in a table of renames); empty outside a git working tree and where no .beadloom/ exists
  • BLOCK_MARKER / IGNORE_RELPATH

Module src/beadloom/onboarding/branch_protection.py (BDL-049):

  • build_protection_payload(*, status_check_contexts=DEFAULT_STATUS_CHECK_CONTEXTS) -- build the GitHub branch-protection request body (PR required, the consolidated ci.yml checks required under strict, enforce_admins: true → strict trunk-based even for admins, 0 required reviews, restrictions: null → owner NOT locked out, can self-merge once the pipeline is green)
  • apply_branch_protection(owner, repo, *, branch="main", status_check_contexts=..., runner=None) -- configure branch protection via gh api PUT (idempotent/declarative); runner is an injectable GhRunner (defaults to the real gh CLI); returns the BranchProtectionRequest sent
  • BranchProtectionRequest -- frozen dataclass (owner/repo/branch/status_check_contexts); endpoint(), payload_json() (deterministic), gh_args()
  • GhRunner -- Protocol for the injected gh runner ((argv, stdin) -> stdout)
  • DEFAULT_STATUS_CHECK_CONTEXTS = ("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") / DEFAULT_BRANCH = "main" -- the consolidated ci.yml required check-runs (BDL-050) + the default trunk

Module src/beadloom/onboarding/config_reader.py:

  • read_deep_config(project_root) -- extract scripts, workspaces, path aliases from pyproject.toml, package.json, tsconfig.json, Cargo.toml, build.gradle

Testing ​

Tests: tests/integration/onboarding/scanner/test_onboarding.py, tests/unit/onboarding/test_presets.py, tests/integration/onboarding/doc_generator/test_doc_generator.py, tests/integration/onboarding/doc_generator/test_cli_docs.py, tests/test_integration_onboarding.py, tests/integration/onboarding/scanner/test_bead06_misc_fixes.py, tests/integration/onboarding/test_config_reader.py, tests/test_auto_link_docs.py, tests/test_init_doc_generation.py, tests/integration/graph/snapshot/test_snapshot.py, tests/integration/infrastructure/console_streams/test_cli_snapshot.py, tests/integration/onboarding/scanner/test_refresh_claude_md.py, tests/test_config_sync.py, tests/test_cli_config_check.py, tests/integration/onboarding/ai_techwriter_setup/test_cli_setup_ai_techwriter.py, tests/test_cli_setup_agentic_flow.py, tests/integration/onboarding/branch_protection/test_branch_protection.py

The composition and its guard (BDL-061 S3): tests/test_flow_composition.py, tests/test_role_configurator.py, tests/test_role_configurator_hardening.py, tests/test_s3_config_check_residual.py (the adversarial half — the guard cannot be silently disabled, a suppression must earn its place, an overlay survives an upgrade of the CORE underneath it), tests/test_config_check_names_what_it_could_not_verify.py, tests/integration/onboarding/ignore_block/test_ignore_block.py, tests/integration/onboarding/guard_hooks/test_guard_hook_adapter.py. The checks of this repository's own tree that were in test_onboarding.py and test_guard_hook_adapter.py are self-checks since BDL-074 A3: tests/self_check/architecture/test_onboarding.py and test_guard_hook_adapter.py under tests/self_check/{docs,process}/.