✅ fresh
last synced 2026-09-29T21:12:19.681221+00:00 · coverage 100% (
onboarding)Validation by Beadloom
doc_sync— same source assync-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
mainprotection (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.ymlschema + loader (FlowConfig) — declares tools/architecture/stack/quality, the documentlanguage, andoverlays.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 andCLAUDE.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, sobeadloom.onboarding.scannerimport 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 viasetup_rules_auto(). Providesimport_docs()for classifying existing .md files (ADR, feature, architecture, other) and writing them into.beadloom/_graph/imported.yml, each with apart_ofedge to the graph's root — the same post-conditionbootstrap_project()holds, which this second writer ofdomainnodes did not receive until BDL-067.14and did not SHARE until.21: it is now one function inscanner/parent_edges.pythat both writers import, rather than two same-named private copies that had already drifted apart once.generate_rules()creates structural rules with emptyhas_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.mdwith the MCP tool list (built from the canonicalMCP_TOOL_CATALOG, currently 18 tools) and architecture rules; preserves user content between<!-- beadloom:custom-start -->/<!-- beadloom:custom-end -->HTML comment markers (migrates old## Customformat automatically). It delegates tobuild_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 fromgraph.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_typestill holds the two together. What the label says stays here, in_LABEL_FOR_KEY:checkreads ascardinalityandforbidasforbid_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 fromagent-primeintorule-engineand rides the exemption.beadloom/_graph/rules.ymlalready declared for that edge, whoseuntilnow says the lint port that retires it must also hand over the authoring keys. The copy it replaced fell behind once: until BDL-062.4the map knew seven of twelve keys, and.beadloom/AGENTS.mddescribed three of this repository's rules —module-coverage,scenario-coverageanddoc-area-coherence— to every agent that reads that file as kind(unknown). The label is cosmetic for enforcement (graph/rules/loader.pyloads 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 inrules.ymlthrough_detect_rule_type().prime_context()returns compact project context (static config + dynamic DB queries) for AI agent sessions; itsHealth:line and its JSONhealth.layer_populationsstate how much of its edge set each declared layer rule judged, in the sharedpopulation_phrasewording, andLintSnapshotholds 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()infersdepends_onedges 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.pystates what onboarding needs; the service suppliesapplication.reindex.reindex, because a domain importing an application use case runs against the declared layer direction -- BDL-070beadloom-46am).non_interactive_init()runs initialization without prompts for CI/script use, supportingmode(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 theimported.ymlthe 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 bothclassified them and wrote Beadloom's own scaffolding into the adopter's graph (BDL-UX #216).auto_link_docs()fuzzy-matches existingdocs/markdown files to graph nodes by ref_id similarity (exact path, stem match, partial match) and patches thedocs:field inservices.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.mdbetween<!-- beadloom:auto-start SECTION -->/<!-- beadloom:auto-end -->marker pairs by regenerating dynamic content (project info facts) while preserving everything outside markers. Supportsdry_runmode 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 throughscanner/project_facts.py, and OMITTED rather than substituted when it cannot be read. Until BDL-UX #183 the version bullet renderedapplication.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 saidDDD packageswhateverflow.ymldeclared; 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 forsrc/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 thedoc-languageregion fromflow.yml'slanguage, 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], adynamicversion through[tool.hatch.version]or[tool.setuptools.dynamic], thenpackage.json, thenCargo.toml),detect_source_packages(),detect_requires_python()(verbatim, never normalised to our floor),detect_declared_dependencies()(first six, declared order) andmanifest_text(). Unknown isNoneand 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 layeronboardingmust not import. Also backsapplication/doctor.py's audit of an adopter'sCLAUDE.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 ofscan_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 priordict[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.Presetdataclass definesname,description,dir_rules,default_kind,infer_part_of,infer_deps_from_manifests.PresetRuledataclass maps directory name patterns to node kinds with confidence levels.detect_preset()checks for mobile app indicators (React Native/Expo viapackage.json, Flutter viapubspec.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 undertemplates/docs/core/and compose throughcomposer.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.mdthat appends## RunbookmakesRunbookrequired 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/templatesslash command rather thandocsartifacts 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.## Axesis 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 toDEFAULT_DOC_CONFIGwhen a project records noflow.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), writesdocs:field back to the graph file each node came from via_patch_docs_field(), and generates.beadloom/README.mdquick-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 fromMCP_TOOL_CATALOG— because a sentence written into a template propagates into every repository that ever ranbeadloom initand 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 fromdoc_templates.render_docrather 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-069beadloom-6rgrand asksinfrastructure.node_source.NodeSourcewhether a file lies under it sincebeadloom-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 fromnodes.extravia_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()catchsqlite3.OperationalErrorand 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. Parsespyproject.toml([project.scripts], [tool.pytest], [tool.ruff], [build-system]),package.json(scripts, workspaces, engines),tsconfig.json(compilerOptions.paths, baseUrl),Cargo.toml([workspace] members, [features]), andbuild.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 installedbeadloompackage (beadloom.ai_agents.ai_techwriter), the scaffold no longer vendors any Python (the BDL-047/048HARNESS_MODULES/vendor_harness/sync_vendored_harnessdrift-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 invokespython -m beadloom.ai_agents.ai_techwriter; the operator artifactstools/ai_techwriter/{recipe.yaml, provision-runner.sh}(_scaffold_recipe()/_scaffold_provision_runner()) copied from the harness package data viaimportlib.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-stepdocs/guides/ai-techwriter.md(_scaffold_guide()). RaisesValueErroron 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.ymlon a first run (never over an existing one — that file is the adopter's policy). Without it a virginsetup-agentic-flowleftconfig-checkat exit 1 with four errors on an untouched repository, remediated by advice to run the command just run (BDL-UX #187); and becausescaffold()re-resolved the config from disk without the CLI flags,--architecture fsdcomposed the role adapters asfsdand the commands +CLAUDE.mdasddd. The caller's resolvedconfigis now threaded in. The slash commands and.claude/CLAUDE.mdare now composed —composed_command()/composed_claude_md()callcomposer.compose()for the repo'sflow.ymlplus 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 inScaffoldResult.migration_noteswith the project-layer path the edit belongs in;--forceoverwrites regardless.orphaned_flow_files()reports files a PRIOR layout left behind — the four role files andepic-init.mdin.claude/commands/— with the exactrm -fcommand, and never deletes them (BDL-UX #137).ScaffoldResultcarries files written/skipped, the CLAUDE.md path and changed sections, plusorphans,migration_notesandflow_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 ofscaffold()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-068beadloom-iur5.sync_agentic_flow(live_claude_root)used to refresh five packagedagents/*.md.txtassets from the live.claude/agents/; BDL-061 S3 had already stopped it snapshottingCLAUDE.mdand 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=Truecomposes through_scaffold_composed()like every other artifact kind, soconfig-check --fixon a repository with noflow.ymlwrites that project's own composition and records each write in the manifest. That also closes #132: nothing writes the CLAUDE.md core, so--forcecannot overwrite its placeholder.AGENT_FILESisrole_composer.ROLE_NAMESitself (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 inbeadloom-mr2l.64). An idempotentmain-branch-protection helper for the trunk-based flow (CLAUDE.md §6): every change integrates via a PR (no direct push) and the consolidatedci.ymlchecks 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-filteredci.ymlchecks).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 consolidatedci.ymlcheck-run names (BDL-050 — the job names + the un-filtered 3.10-3.13 matrix legs — plus the twotests-localelegs added in BDL-061.38: the same whole suite run with the locale varied, not pinned (Canden_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: atests-windowscontext was added in BDL-061.39 and withdrawn by the owner inbeadloom-mr2l.64on 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 sixskipif(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 (understrictit would never run → permanently-unmergeable PR/main), which is why BDL-050 dropped thetestspaths 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 ownmainstill requires the seven pre-BDL-061.38 contexts. The twotests-localelegs went green inbeadloom-mr2l.42, so the declared nine and the live seven now differ only by those two — seedocs/services/cli.mdfor the sequencing before re-runningsetup-branch-protectionhere.BranchProtectionRequest(frozen dataclass: owner/repo/branch/status_check_contexts) exposesendpoint(),payload_json()(deterministicsort_keys), andgh_args()(thegh api --method PUT … --input -argv).apply_branch_protection(owner, repo, *, branch="main", status_check_contexts=..., runner=None)builds the declarativePUT .../protectionand runs it through an injectableGhRunnerseam (defaults to the realghCLI; tests pass a fake that records argv + stdin without touching GitHub).PUT .../protectionis 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 aConfigDrift(file, reason, severity, remediation)per drifted artifact (deterministically sorted). It reusesbuild_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.mdagainstcompose("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 againstcompose_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 printedconfig-check PASS: agent-config in sync(BDL-UX #177)._state_drift()maps the manifest states onto severities:stale,hand_editedandmissingare errors,unverifiedis 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 carriesConfigDrift.weakened_from, andconfig-checkprints how many findings arewarnonly 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.11MAJOR 5). Since BDL-061.57nothing 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 asunverifiedrather 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 anoverlays.suppressentry that names no rule in the composed corpus or whoseuntil:date has passed._unverifiable_body_drift()covers the degraded path the rest of that work exposed: when aCLAUDE.mdcarries 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, atunverified/warn, instead of the check falling back to silence at the moment it lost its evidence. A deletedCLAUDE.mdthat the manifest records ismissing/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 towarn; 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:composedstamp — so a project's ownCLAUDE.mdis never policed (the #73 false-positive class).refresh_agentic_flow_files()is theconfig-check --fixcompanion: 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 classifiedhand_editedorunverifiedis left byte-identical and returned inAdapterRefresh.declined, because until BDL-061.59it wrote unconditionally and deleted the edit one line after the check promised it would not be rewritten (BDL-UX #186). Since BDL-068.67that set isdeclined_adapter_rewrites(), a functionsetup-agentic-flowreads too, so the repair and the scaffold cannot disagree about whose file a body is (BDL-UX #191).apply_config_fixes()is the whole--fixseam: 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 ofrole_duties.duty_report()atseverity="error", neverfixable: the repair is the duty's TEXT in a role core and--fixwrites compositions, not prose._ignore_block_drifts()(BDL-068 S6, BDL-UX #238) is the third artifactinitwrites into a repository Beadloom does not own and the last one to get a check: it reports, atwarnand neverfixable, every patternignore_block.GENERATED_WORKING_SETemits that the project's.gitignoredoes 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 ofrole_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 neverfixablefor the same reason: the repair is a sentence in the map. Since BDL-068.84the 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 withfor 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 forclaudeandcursorand then narrowed toclaude, the same two lines appended to.claude/agents/dev.mdare anerrorand the same two appended to.cursor/agents/dev.mdare exit 0, and theOn disk:count falls from 10 to 5 with nothing said about the other five. It reports each recorded adapter under an undeclared tool atwarnand neverfixable, 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 editingtools:in their ownflow.yml, and both repairs — re-declaring the tool, deleting the file — are theirs, while--fixwrites compositions and deletes nothing. Backsbeadloom 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 ofddd/fsd),stack(python/fastapi/javascript/typescript/vuejs),quality, pluslanguageandoverlays.suppress.languageis 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.txtfragment in every layer, with the fallback reported (BDL-UX #136).overlays.suppressis validated throughflow_suppression, so a suppression without a reason or an exit condition is a configuration error; an unknown key underoverlaysis rejected with the reminder that project additions are files under.beadloom/flow/, not keys here.FlowConfigis 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, carryinglanguageand 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
.84for the tool axis). The third direction of the graphrole_duties.pychecks 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 —Exploreshipped in S1, was invoked four times each by the/coordinatorand/task-inittemplates, and was named ZERO times in the shippedCLAUDE.mdwhose section 0.0 draws the role map and whose section 4 is the Agent Roles table.config-checkalready 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 fromconfig.tools— the composed.claude/CLAUDE.mdforclaude, the.cursor/rules/beadloom-flow.mdorchestrator pointerrole_adapters.pyrenders forcursor— 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 reasonrole_duties.pyrefuses one: it would readtestin "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 —errorfrom a designation,warnfrom an inferred roster) andunbacked(a name a designation claims is a role and no CORE fragment ships,error).unbackednever 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 todev,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 acursor-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: theclaudecorpus 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 tocomposer.compose("roles", role, ...), so roles, slash commands andCLAUDE.mdshare 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; omittingproject_rootyields the shipped-only composition, which is the drift baseline for a repo with no project layer.ROLE_NAMESis DERIVED from the shipped CORE fragments rather than declared (BDL-068 S1.5):roles_in(core_dir)readstemplates/roles/core/*.md.txtand takes a fragment as a role when its opening front matter names itself, which is already the difference between a role and the shared_writinglayer. Addingexplore.md.txttherefore madeexplorea role in every reader by the same act, andagentic_flow_setup.AGENT_FILESis 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).ArtifactKindrecords where each kind's CORE fragment and overlay root live;Compositioncarries the orderedfragments(each labelledcore/architecture:<a>/stack:<s>/project), thenotesthat make an unshipped localisation an audible skip rather than a silent English fallback, and the appendedsuppression_notice.COMPOSED_MARKER(<!-- beadloom:composed) is the provenance stamp the shippedCLAUDE.mdcore now begins with — its presence, or a manifest entry, is what tellsconfig-checkthe body is Beadloom's to verify.PROJECT_FLOW_DIRNAMEis.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, andreasonanduntilare both mandatory (the barguards.<name>.exclusionsalready holds). Expiry is decided byinfrastructure.exit_condition.exit_condition_deadline— the same function both exemption lists inrules.ymluse, 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— EXPIREDat 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) andunverified(nothing accounts for it, so a hand edit and an upgrade cannot be told apart and the check says so, insync-check's word).alternateslets a caller name other bodies Beadloom itself could have written, so a repo predating the manifest that was never edited still readsstale.accountedis BDL-061.57's answer torm .beadloom/flow-manifest.jsondowngrading a hand edit from error to warn: a project that keeps a usable manifest, or an artifact carrying the<!-- beadloom:composedstamp, 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_editedandmissingare errors,unverifieda 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 isexec beadloom guard "$1" --hook claude-code— and registers onePreToolUseentry 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 inGuardHookResult.settings_skipped_reasonand left untouched.hook_command(name)builds the$CLAUDE_PROJECT_DIR-rooted command string;GUARD_HOOK_RELPATH/SETTINGS_RELPATH/HOOK_EVENT/EDIT_MATCHERare the emitted-shape constants. Guard names are a parameter, not an import — the registry lives in the application layer above this domain, sobeadloom setup-agentic-flow(services) supplies them.EDIT_MATCHERisEdit|Write|MultiEdit|NotebookEdit|Bashsince BDL-068 S4 closed BDL-UX #170: beforeBashwas on it, a file written throughsed -ior a heredoc invoked no guard and left no firing, which--livenesscannot 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 — including1for anunresolvedverdict, 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 ofsrc/— so an adopter collected untracked churn from the firstreindex(the SQLite index) and again from the first guarded edit (guard-firings.jsonl); only this repository was clean, because its.gitignorewas hand-edited.ensure_ignore_block(project_root)appends the block and returnsIgnoreBlockResult(path, added, skipped_reason);GENERATED_WORKING_SETis theIgnoreEntry(pattern, why)list — thewhyis 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 toinit, not to the guard scaffolder: it is a property of the directory Beadloom creates, sobootstrap_projectwrites it (and reportsignore_added) andsetup-agentic-flowrepeats 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_MARKERpresent ⇒ 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. Sincebeadloom-0mdo.43the entry'swhyalso 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 sincebeadloom-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 sincebeadloom-0mdo.40half of it is reported rather than only documented:undeclared_patterns(text)returns every generated pattern a file does not declare — the predicateensure_ignore_blocknow writes from, so the writer and the check cannot disagree — andconfig-checkreports each as awarn. This repository's own.gitignorecarried the exact filename.beadloom/guard-firings.jsonlagainst that glob until S4 and nobody found it; it surfaced only when an unrelated change made the record rotate for the first time. Thewhytext 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.mdorchestrator 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 adaptersconfig_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 overROLE_NAMESrather than spelled as prose, which is where a fifth role used to go missing.AdapterResultreports the paths written and, in.preserved, the ones left alone;TOOL_AGENT_DIRSmaps 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 toolconfig.toolsno longer names. Its population is the flow MANIFEST rather thanTOOL_AGENT_DIRScrossed withROLE_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.divergedseparates a body that has already changed from one that has merely stopped being watched..cursor/rules/beadloom-flow.mdis 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.mdfor the repo'sflow.ymlplus 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), andmalformed(aduty=marker with noroles=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_inspectednames 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), soDutyReport.role_filescounts the adapters that exist on disk (counted, never read — reading them isconfig_sync's job) andconfig-checkprintsNOTHING TO CHECKwhen 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: thetemplates/agentic_flow/agents/*.md.txtsnapshot 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-iur5deleted 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 -> 376lines, each removed line mapped to a replacement — two sections that restated §0 command for command, and the Python anti-patterns plus theuv run pytest/ruff/mypyblock, 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-checkverifies the composition RESULT, not file bytes. Byte-guarding against a fixed template makes extension impossible: any project addition is drift and--fixdeletes 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_suppressiongave 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
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 snapshotsWhat 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 Truthblock carries what the run checked for it:N mention(s) checked,NOT VERIFIED: no document states it, orNOT VERIFIED:followed by the reason the value cannot be read (a value of0or1is 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.versionleft that list in BDL-062.7: the install section ofdocs/getting-started.mdnow states the current release as a claim, which is the ONE place a document does so, and the audit compares it againstpyproject.tomlon 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_frameworkandtest_countare simply not stated anywhere, whilelanguage_countcannot 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--verbosenames 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, andgitis confirmed by the environment rather than by a file the project ships, so a directory built bygit archive HEADcannot confirm it -- and reading the absent marker as a denial comparedgit 2.49.0against 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].mdprints as written rather than asdocs/app//.md(beadloom-2mj3.19). The JSON output was never affected. --jsongainscoverage,verified_facts,unverified_facts,not_applicable,unjudged_versions,unresolved_version_subjectsandscan_surfacebeside the existing arrays, and six counts undersummary(declared_fact_count,verified_fact_count,unverified_count,unreadable_count,not_applicable_count,unjudged_version_count).--fail-ifacceptsunverified>N/unverified>=Nalongsidestale>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 ofscan_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, languagesclassify_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 callsensure_ignore_block, and the result dict carriesignore_added/ignore_skipped_reasonso the caller can report the.gitignorewrite instead of performing it silentlybootstrap_projectholds one stated post-condition: every node it writes carries at least one outgoingpart_ofedge — to its classified parent where one exists, to the root service node otherwise, and to nothing at all when the node'sref_idis 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 Pythonsrc/<project>/layout, where root and domain were handed the sameref_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 theref_idsbullet 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 readkind: domain, which is the kind today's generated rules require a parent for.generate_rulesalso writesfeature-needs-parent, and the sibling statement of the same post-condition inimport_docsalready 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 writesdomain-needs-parentinto the adopter'srules.ymlwhenever it writes a domain, and the loader defaults that rule toerror. Until BDL-067 the post-condition held on every branch but one: a project whose source directory has no code-bearing subdirectory (a flatsrc/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 bootstraprc 0 andGraph: 2 nodes, 0 edges, thenlint --strictrc 1 andcirc 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.21it is ONE function, shared withimport_docs— the two writers carried the same private name in two modules with the same loop body, differing in astr()call and in their parameter order, and the only thing binding them was a docstring here naming a symbol.17had renamed away (the review of.20, major 3). Its companionparented_by(edges)answers what counts as a parent — only apart_ofedge — 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 isparented: 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_idand the root ref does not, so a recomputed destination resolves to nothing for a project whose name contains parentheses.ref_ids.RefIdAllocator(taken=())withtake(preferred, *, qualifier=None)hands out theref_idof every node BOTH writers commit, so no two of them carry one name. The graph identifies a node by itsref_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 calledmyappholdingsrc/myapp/,init --yes --mode bootstrapreportedGraph: 2 nodesandbeadloom statusthen reportedNodes: 1, and the node dropped was the one carryingsource: src/myapp/(BDL-UX #214).takereturns preferred whenever it is free — every node on every project the collision does not touch — thenpreferred-qualifier(callers pass the node's kind, so the secondmyappis writtenmyapp-domain), then numbered forms. Which asker keeps the plain name is the CALLER's judgement:bootstrap_projectgives the root the project's name before any cluster asks, because thatref_idtitles the architecture document and is whatgenerate_rulesnames as the parent every domain must have;import_docsseeds the allocator with_existing_graph(...).ref_ids— the graph already on disk, minus theimported.ymlit is about to replace — so a document named after a node, or two documents sharing a file name, each get aref_idof their own. The rename reaches the EDGES too: the manifest dependency loop,_quick_import_scan(which now takes the cluster-to-ref_idmapping as a parameter) and the top-level attachment loop all name theref_idthe 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.inittakes 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/—--yesin any mode,--bootstrap,--importand 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 stepbeadloom ciwill fail. The enumeration is over branches that WRITE rather than over branches that bootstrap, since BDL-067.17. Until then--mode importwas 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 nextiniton the same tree: the wizard's re-init does not delete.beadloom/, soimported.ymlsurvived into a later bootstrap that wrotedomain-needs-parentand met nodes an earlier run had left unparented (the review of.16, major 2). The--importbranch 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 ofbootstrap_project, the wizard shares the--yesbinding, 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 --strictrc 1,circ 1, reproduced by the review of.4). One path still takes no verdict, deliberately: the wizard'seditreview 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'scancelanswer was a second such path until BDL-067.21, and never deliberately:bootstrap_projectwritesservices.ymland leaves the adopter'srules.ymlin place BEFORE "Proceed with this graph?" is asked, socancelstopped the rest of the run and not the part that touched the tree — the wizard exited 0 over a graph the same tree'slint --strictrejected, 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 throughsys.exit, so the walk stepped over it and found the verdict below. Both halves are closed:initcontains nosys.exitat 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. Arules.ymlthe 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 inruleand the reason inwhy— printing the name told an adopter that a rule calledlinthad 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 scriptedinit && cirun 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:initsamples.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_HALFand_ATTRIBUTIONtables, 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, andgenerate_skeletonsannotates 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.ymlkeeps the file grain, because it holds no nodes. The finer grain does not move the case where the annotated node IS the failing node — thedocs: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 intests/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 byrules.ymldenies writing that FILE. Both node-chosen sentences saidgraph file(s)until BDL-067.27..24had moved the key to the node and left the words behind, and the review of.26measured 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, whichgit diffshows modified. Until.17one boolean aboutrules.ymlchose both sentences, so a run that bootstrapped over an inheritedimported.ymlsaid 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'sInitialization complete!,--bootstrap's four check marks,--yes's summary — and until.17only the wizard passed the withdrawal in, under a docstring asserting that--bootstrapnever made the claim (the review of.16, major 3). Two facts were measured on--mode bothand--importby the review of.13and closed in.14. First, the verdict read an index written before the last graph file the command wrote: on--mode boththe reindex sat inside the bootstrap block and the import step wroteimported.ymlafterwards, soinit --yes --mode bothexited 0 whilelint --stricton 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 conditionlint_stepwas made public to make unrepresentable. The reindex now runs after every block that writes a graph file. Second, the report namedservices.ymlfor a node that came fromimported.yml; each line now names the graph file its node was written into, and the advice sends the adopter to those files. The line aboutbeadloom cistates the step's name and its summary rather than quoting a rendering:cipicksrichonly on a TTY andgithubotherwise, and the github renderer builds its own step line, so the quoted[FAIL] lint: ...was false in exactly the scripted context--yesserves — 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 thegraphdomain is stated once, ingraph_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 readdata["nodes"]without asking again whether it can.rules.ymlbelongs 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 ingraph/loader.pyand is re-exported here, becauseonboardingmay importgraphand the reverse is a cycle.also_skipis the one genuine difference between the callers and is passed at the call site:doc_classify._existing_graphnamesimported.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_graphandsetup._graph_file_of_each_node— with four policies, two of them carrying no guard at all. BDL-069beadloom-4ad3measured 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_fileshashes each file to decide whether a reindex is needed andsetup._graph_files_nowdigests 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_docsandlinkwere routed here;update_node_in_yaml,load_graphandcompute_diffcould 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_graphmust report a file it cannot parse rather than skip it (BDL-UX #86), andcompute_diffcompares a working tree against content at a git ref, where a directory walk covers only one side. The remaining duplication is filed asbeadloom-4axf. BDL-UX #220 is closed except for one shape: on all eight (entry point x mode) cells ofinit's own table, alegacy.ymlthat does not parse and one whose top level is a list now leaveinit --bootstrapat exit 0, and a file carryingadded: 2026-09-02still ends inTypeErroringraph/loader.load_graph, because that file is readable and no skip policy reaches it. The runs are pinned intests/test_graph_files_are_read_under_one_policy.py, and the population measurement intests/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.pyrather thangraph_files.pybecause it is the WRITERS' question:each_graph_filesays which files may be read, andlayout_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 primitivebeadloom-0mdo.66took for issue numbers withO_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 itssrcwhere 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.GraphLayoutreportssharedandshared_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) andmisplaced_edges.shared_files(files)takes a name-to-ref-ids mapping rather than a directory, so this module andapplication/waves/media_checks.pycompute the surface with one body. It REPORTS rather than refuses:bootstrap_projectstill writes oneservices.yml, a single-file graph stays valid for every reader, and what an adopter gets is the number through thegraph-filesmedium ofbeadloom waves. This repository took the split in BDL-UX #265; the measured cost and what it does not preserve (git blamethrough a 1-to-100 split) are indocs/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 outgoingpart_ofedge to the graph's root — the one node of kindservicethat nopart_ofedge 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.17the candidates were collected into a list and counted there, andbootstrap_projectproduced 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 unparentedserviceentries under one ref_id. The import then attached nothing andinit --yes --mode bothexited 1 on every run; measured on a project namedcoreholdingsrc/core/andsrc/orders/, andcore,api,webandappare ordinary repository names (the review of.16, major 1). Since BDL-069 no writer produces that shape — ref_ids come fromRefIdAllocator— 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 asExistingGraph(root_ref_id, parented, ref_ids);ref_idsis 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 isparent_edges.missing_parent_edges, the same objectbootstrap_projectappliesgenerate_rules(nodes, edges, project_name, rules_path)-- generate architecture rules with empty matcher for hierarchy validationsetup_mcp_auto(project_root)-- auto-detect editor and create MCP configsetup_rules_auto(project_root)-- auto-detect IDEs and create adapter files (.cursorrules,.windsurfrules,.clinerules); content-aware: skips user-edited filesgenerate_agents_md(project_root)-- generate.beadloom/AGENTS.mdwith the MCP tool list (fromMCP_TOOL_CATALOG, currently 18 tools) and rules; preserves content between<!-- beadloom:custom-start -->/<!-- beadloom:custom-end -->HTML comment markers (auto-migrates old## Customformat)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 fmtMAX_LISTED_FINDINGS = 10(BDL-061 S4) -- how many findings of a kindprimeLISTS. 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 intoscenario-coverage(68 findings) grew the output from 2.6 KB to 13.1 KB, which is a context budget spent on one rule's backloginteractive_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_skeletonsis one: it patches adocs:field into the graph YAML). The reindex sat inside the bootstrap block until BDL-067.14, so on--mode boththe import step wroteimported.ymlafter it and the verdict — which reads the index without re-indexing — judged a graph the command had not finished writing.reindexis handed in and is required (BDL-070beadloom-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 reportingsymbols_indexed/imports_indexed/edges_loaded/docs_indexed-- andservices/commands/setup.py, a service above both, suppliesapplication.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.14defect with a different causeauto_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.ymlfrombootstrap_project(),imported.ymlfrom doc classification,rules.ymlfrom rule generation, and the_patch_docs_fieldwriteback) go through the atomic-io primitive (write_yaml_atomic: temp file +fsync+ atomicos.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.mdbetween 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 nonedetect_source_packages(project_root)->set[str]-- the target's ownsrc/<pkg>/<child>/packagesdetect_requires_python(project_root)->str | None-- the declared constraint, verbatimdetect_declared_dependencies(project_root)->tuple[str, ...]-- first six runtime dependencies, declared ordermanifest_text(project_root)->str | None-- every readable dependency manifest concatenated;Nonedistinguishes "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 containsfiles,children, andsource_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]]-- inferdepends_onedges between clusters by sampling up to 10 code files per cluster via tree-sitterextract_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_manifestsPresetRule-- frozen dataclass: pattern, kind, confidencePreset.classify_dir(dir_name)-- return (kind, confidence) for a directory namePRESETS-- dict mapping preset names toPresetinstancesdetect_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, writedocs:back to the graph file each node came from, generate.beadloom/README.md. Every node document whosesourceis a directory names the Python files directly inside it, read off the disk, becausemissing_modulesrequires it andinit --yeswrites 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 oninit --yes,init --bootstrapand a clone — only the wizard had one, so one project got two different skeletons (BDL-069beadloom-8lmj)generate_polish_data(project_root, ref_id?)-- return structured JSON with SQLite dependency edges, symbol change detection, routes/activity/testsformat_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;severityis"error"(blocks the Gate) or"warn"(printed, does not block),remediationis the concrete next move orNonecheck_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 sortedlist[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 (callingpython -m beadloom.ai_agents.ai_techwriter) + the operator artifacts (recipe.yaml/provision-runner.shfrom package data) + the getting-started guide (idempotent); raisesValueErroron an unknown platform. No Python vendoring (BDL-051 / S2 — the harness ships in the wheel)._scaffold_provision_runner(target_root)-- drop the hardened, idempotent, executableprovision-runner.sh(swap-first, RAM/disk prechecks, GitHub/GitLab runner registration) intotools/ai_techwriter/(from harness package data)_scaffold_recipe(target_root)-- drop a readable copy of the Goose recipe (harness package data) intotools/ai_techwriter/for operator referencetemplates_root()-- locate the packaged workflow/guide scaffold assetsPLATFORMS-- 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 andCLAUDE.mdare 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; returnsScaffoldResult.include_agents=Falseleaves the role adapters togenerate_adapters, which is howbeadloom setup-agentic-flowcalls itcomposed_command(name, config, project_root)/composed_claude_md(config, project_root, *, project_name)-- the composed body of one slash command / ofCLAUDE.md, for the scaffold and forconfig-checkto compare against; the latter substitutes the target's detected name for the core's neutral__BEADLOOM_PROJECT_NAME__tokenorphaned_flow_files(project_root)-- report files a PRIOR layout left behind, each with the exactrm -fcommand; never deletes anything (BDL-UX #137)templates_root()-- locate the packaged scaffold assetsAGENT_FILES/COMMAND_FILES-- the role + slash-command file stemsSUPERSEDED_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.orphansand.migration_notesare populated and have no caller:beadloom setup-agentic-flowprints 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 orCLAUDE.mdsurvives--fix(BDL-UX #151); gated on the flow already being presentdeclined_adapter_rewrites(project_root)->tuple[DeclinedRewrite, ...]-- the role adapters no writer may recompose over (hand_editedorunverified), each with the reason and remediationconfig-checkprints for the same file. Read byconfig-check --fixand bysetup-agentic-flow; empty when the project declares no validflow.ymlorphaned_adapters(project_root, config)->tuple[OrphanedAdapter, ...](role_adapters.py) -- the manifest-recorded role adapters sitting under a toolconfig.toolsdoes not name, sorted by path. Rendered byconfig_sync._orphaned_adapter_drifts()as onewarn, non-fixable drift eachrefresh_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 sentenceconfig-checkprints about them is true (BDL-UX #186). A no-op whenflow.ymlis absent or invalidapply_config_fixes(project_root)->FixReport-- run every--fixwriter and reportrewritten/created(measured against the disk, not self-reported) plusdeclined
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 fragmentComposition--text(the composed body),fragments(orderedLayerFragments, each labelledcore/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) andsuppression_noticeArtifactKind-- where a kind's CORE fragment and overlay root live, and whether itcarries_suppressions(BDL-061 S4b:Falsefordocs, 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;_roomsadded by BDL-068 S3.2,_landingand_trackerby BDL-068 S5) -- CORE fragments every artifact of the kind carries, composed as labelledcore:<name>layers between the core and the architecture overlay._writingis the writing standard,_roomsthe room statement every role that reports a measurement is held to,_landingwhat the merge slot grants and what it does not for every role that lands a commit in a tree it shares, and_trackerwhich 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._roomsalso carries the clean-room limit,_landingthelanding-lockduty and_trackerthetracker-answersduty, 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._trackerexists because three of the four role cores instructedbd close --suggest-nextwhile the caveat that it names still-blocked beads lived only inCLAUDE.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.txtand_tracker.ru.md.txtship)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 inputsbuild_suppression(entry)/build_suppressions(value)-- validateoverlays.suppress; a missingrule,reasonoruntilraisesFlowSuppressionErrorrender_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 outputcomposed_headings(texts)/suppresses_nothing(suppression, headings)-- the dead-declaration check;ruleis read as a/-separated heading path over the whole composed corpusexpired_suppressions(suppressions, *, today=None)-- expiry as a check-time findingSUPPRESSION_KEYS(rule,reason,until) /FlowSuppressionError
Module src/beadloom/onboarding/flow_manifest.py (BDL-061 S3):
ArtifactState--CLEAN/STALE/HAND_EDITED/MISSING/UNVERIFIEDclassify(on_disk, expected, recorded, alternates, accounted)->ArtifactState--alternatesnames other bodies Beadloom itself could have written, so a repo predating the manifest that was never edited readsstale;accountedis 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 usstate_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 mapFLOW_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,suppressionsbuild_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 onedetect_stack(project_root)-- infer a default stack from file extensionsresolve_flow_config(project_root, *, tools, architecture, stack)-- flag overflow.ymlover default, carryinglanguageand the suppressions through verbatim because they are project policy rather than a per-run choicepersist_flow_config(project_root, config)->Path | None-- record the resolved selection as.beadloom/flow.ymlon a first scaffold; returnsNone(and writes nothing) when the project already declares oneSUPPORTED_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 ontocomposer.compose("roles", ...)compose_all_roles(config, project_root=None)-- compose every role; omittingproject_rootyields the shipped-only composition, which is the drift baseline for a repo with no project layerroles_templates_root()/roles_in(core_dir)/ROLE_NAMES-- the role population, DERIVED from the shipped CORE fragments and not spelled here;beadloom config-checkprints its size, androle_map.pyis what holds the composedCLAUDE.mdto 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 againstAdapterResult--agents(tool -> paths written) andextra(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 directionsDutyReport--declarations,carried,findings,inspected,not_inspected(reported on a clean run too) androle_files, the adapters on disk the check counts and never readsDutyDeclaration/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:rolesdefaults 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, andconfigdefaults to the resolvedflow.yml, so the unreached-tool branch can be measured before a release makes it reachableRoleMapReport--roles,tools,artifacts,unreached(both reported on a clean run too),references,rosters,findings,not_judged(also reported on a clean run) andinspectedMapArtifact(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.shand register onePreToolUseentry per guard in.claude/settings.json; registration is a merge, never a rewrite, and an unparseable settings file is reported insettings_skipped_reasonand left untouchedhook_command(guard_name)-- the$CLAUDE_PROJECT_DIR-rooted command stringGUARD_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 duplicatedGENERATED_WORKING_SET-- theIgnoreEntry(pattern, why)list; thewhyis rendered above its pattern, because a bare pattern in someone else's ignore file is indistinguishable from a mistakeundeclared_patterns(text)/ignore_block_findings(project_root)->list[IgnoreFinding]-- the generated patterns a file does not declare, each carrying theIgnoreEntryitself plus the declared lines itsupersedes(computed withfnmatchcase, not looked up in a table of renames); empty outside a git working tree and where no.beadloom/existsBLOCK_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 consolidatedci.ymlchecks required understrict,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 viagh apiPUT (idempotent/declarative);runneris an injectableGhRunner(defaults to the realghCLI); returns theBranchProtectionRequestsentBranchProtectionRequest-- frozen dataclass (owner/repo/branch/status_check_contexts);endpoint(),payload_json()(deterministic),gh_args()GhRunner--Protocolfor the injectedghrunner ((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 consolidatedci.ymlrequired 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}/.