diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index bbc8bf1..6bf27ac 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -42,7 +42,7 @@ native config. The four layers are how the brain is expressed; the compiler is h
is delivered.
```mermaid
-%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%%
+%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%%
flowchart TD
S["source/
rules.json · substrate.json · mcp.json"] -->|"forge sync
content-hash + DO-NOT-EDIT headers"| N["native configs
CLAUDE.md · AGENTS.md · .cursor · .gemini · .aider · …"]
S -. configures .-> L
@@ -87,7 +87,7 @@ checks and returns a single verdict. It composes the individually-callable stage
`anchor`, `verify`) into one pre-action contract.
```mermaid
-%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%%
+%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%%
flowchart TD
RE["referenced entities"] --> INTAKE
subgraph INTAKE["intake"]
@@ -136,7 +136,7 @@ claims into `.forge/ledger/`. Because a claim's bytes are a pure function of
fold together over plain git with no conflicts.
```mermaid
-%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%%
+%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%%
flowchart LR
subgraph EV["local events"]
direction TB
@@ -171,7 +171,7 @@ dependencies still resolve. Otherwise it falls through to generation and mints a
claim on the way back.
```mermaid
-%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%%
+%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%%
flowchart LR
SP["spec"] --> FP["fingerprint
MinHash + LSH"]
FP --> LD["match ladder
exact → near → adapt → miss"]
@@ -510,3 +510,31 @@ asserts all three resolve to `global/`.
See [ROADMAP.md](ROADMAP.md) for direction and [`docs/adr/`](docs/adr/) for the recorded
architecture decisions (zero runtime deps, the SKILL.md standard, guard-over-prose).
+
+## Repo map (generated)
+
+Top-level directories sized by file count, edges = import counts between them —
+rendered from the live import graph by `forge docs render`, so it can never drift
+from the tree it describes.
+
+
+```mermaid
+%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%%
+flowchart LR
+ test["test
98 files"]
+ src["src
93 files"]
+ landing["landing
60 files"]
+ research["research
35 files"]
+ bench["bench
2 files"]
+ global["global
2 files"]
+ scripts["scripts
2 files"]
+ docs["docs
1 file"]
+ examples["examples
1 file"]
+ test -- 191 --> src
+ bench -- 7 --> src
+ examples -- 4 --> src
+ test -- 2 --> scripts
+ scripts --> src
+ test --> bench
+```
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3cceb1..0c93d98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,21 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+### Added
+
+- **`forge docs render` — the docs that can write themselves, do.** `docs check` could
+ only detect drift; every fix was still a human hand-editing tables across five files.
+ The derivable doc surfaces are now generated from the same registries the check reads,
+ into marker-managed blocks (the `reports/benchmarks.md` pattern): the README command
+ table and the GUIDE group map from `COMMANDS`/`GROUPS`, the MCP tool table from the
+ `TOOLS` registry, every literal "N MCP tools" count phrase across all six files it
+ lives in, one shared mermaid theme derived from `brand.json` (change the brand,
+ re-render, every diagram in every tracked markdown file re-themes), and a repo map in
+ `ARCHITECTURE.md` drawn from the live import graph. `docs check` gains a `render`
+ reconciler: a stale registry-derived block is an error whose message is the fix
+ (`forge docs render`); tree-derived output (repo map, diagram theme) warns without
+ failing unrelated PRs.
+
## [0.28.0] - 2026-08-07
### Added
diff --git a/ONBOARDING.md b/ONBOARDING.md
index dc72dce..590e50c 100644
--- a/ONBOARDING.md
+++ b/ONBOARDING.md
@@ -11,7 +11,7 @@ This page is the fast path: install, configure a repo, do a task, and watch the
start paying off on day two.
```mermaid
-%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%%
+%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%%
flowchart TD
I["forge init"] --> Cfg["every tool configured
from one source"]
Cfg --> Work["you work as usual"]
diff --git a/README.md b/README.md
index 1f75552..f1485fb 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,7 @@ same instructions and MCP tools to invoke. Every recorded outcome flows back int
proof-carrying memory.
```mermaid
-%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%%
+%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%%
flowchart LR
T["task"] --> G["substrate gate
assume · route · reuse
context · impact"]
G -->|unclear| Q["ask clarifying
questions first"]
@@ -210,52 +210,54 @@ this wiring for you via `forge init --settings-only` — an idempotent, marker-g
that never clobbers your existing settings (skip it with `install.sh --no-settings`;
`install.sh --uninstall` or `forge init --remove-settings` reverses it).
-| Group | Command | Does |
-| -------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Config layer** | `forge init` | emit every tool's native config from one source |
-| | `forge sync` | recompile canonical source → each tool's native files (idempotent) |
-| | `forge tools` | primary-tool config — gitignore secondary-tool artifacts (`.cursor/`, `.gemini/`, …) for tools this repo doesn't use; `forge tools ` sets it, `--reset` clears |
-| | `forge doctor` | pass/fail health check: tools, guards, MCP, drift, update |
-| | `forge update` | self-update — `--check` reports if a newer version exists, bare applies it, `--to ` pins/downgrades |
-| | `forge docs` | docs↔code drift — `check` reconciles commands/env/MCP/CHANGELOG; `sync` sweeps the diff for stale doc mentions; `impact` maps which docs reference the entities THIS diff changed |
-| | `forge config` | provider setup — show / switch / add providers, set the default model |
-| | `forge integrations` | opt-in third-party MCP servers (e.g. context7) — `add` records the managed set and writes only with `--yes` (`--adopt` claims a same-name entry you already had); `remove` reverses it |
-| | `forge harden` | wire the pre-commit gate (gitleaks + commit gate) + sandbox settings |
-| | `forge catalog` | Start-Here index of every tool / crew / guard |
-| | `forge brand` | print the brand token map |
-| **Memory & team** | `forge ledger` | proof-carrying memory — stats / verify / show / blame / query / at / diff / root / ratify / retract / merge / sync / import |
-| | `forge recall` | cross-session personal memory — list / add / consolidate |
-| | `forge remember` | durable, repo-committable fact |
-| | `forge brain` | portable project-memory index |
-| | `forge cortex` | self-correcting lessons — `status` / `why` |
-| | `forge deja` | anti-repetition — ranks prior solved/verified sessions for a task you're about to start (`FORGE_DEJA=0` disables) |
-| | `forge reuse` | proof-carrying code cache — query / mint / stats |
-| | `forge handoff` | bounded session snapshot (`.forge/state.md`) — rewritten each handoff, re-injected every session start |
-| | `forge decide` | append-only decision log (`.forge/decisions.md`, D-#### ADR-lite) — future sessions read it instead of re-deciding |
-| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total routing, an unsure fact still lands |
-| **Substrate (pre-action)** | `forge substrate` | the full pre-action gate in one pass |
-| | `forge preflight` | assumption / info-gap check |
-| | `forge route` | cheapest capable model tier (`route gateway` emits LiteLLM config) |
-| | `forge impact` | predict blast radius for a symbol or file |
-| | `forge rank` | load-bearing code — PageRank centrality × past-incident history, circular imports, chokepoint files |
-| | `forge scope` | cluster + surface coupled files |
-| | `forge imagine` | consequence sim + minimal dry-run suite (`--run` executes it sandboxed) |
-| | `forge context` | budgeted context assembly + completeness gate |
-| | `forge atlas` | build / query / has (hallucinated-symbol check) the code graph |
-| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests |
-| | `forge anchor` | goal-drift check (advisory) — `set`/`show`/`clear` persists the goal across sessions |
-| | `forge diagnose` | doom-loop: same failure 3× → diagnosis + escalation |
-| | `forge lean` | scope-minimality footprint (advisory) |
-| | `forge cost` | real per-day spend · measured stage factors (`--stages`) |
-| **Verification & safety** | `forge verify` | independent gate — tests + hallucinated-symbol flag + provenance; `--deep` multi-lens consensus (`--llm` reviewer panel) |
-| | `forge precommit` | commit-level gate rung — staged code w/o docs + secret scan (`FORGE_COMMIT_GATE=block\|warn\|0`) |
-| | `forge radar` | dependency-currency rings (adopt/trial/assess/hold) from registry evidence — cached, offline-honest |
-| | `forge scan` | skill-gate: vet a SKILL.md / .mcp.json for injection / RCE / exfil |
-| | `forge spec` | spec-as-contract drift — init / lock / check |
-| **UI / design** | `forge taste` | pick one visual direction → DESIGN.md |
-| | `forge uicheck` | contrast · fingerprint · design · visual (WCAG · slop+conformance · Playwright) |
-| **Observability** | `forge dash` | localhost-only live dashboard: ledger, metrics trends, radar rings, memory browser, session timeline, blast radius (default port 4242) |
-| | `forge report` | static, self-contained HTML snapshot of `.forge/` (`.forge/report.html`) — opens offline, no server |
+
+| Group | Command | Does |
+| ----------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Core** | `forge init` | scaffold this repo's config — emits every tool from one shared source |
+| | `forge sync` | recompile the canonical source into each tool's native config files |
+| | `forge doctor` | health-check installed tools, guards, MCP auth, and config drift |
+| | `forge tools` | primary-tool config — gitignore secondary-tool artifacts (.cursor/.gemini/…) for tools this repo doesn't use (`forge tools ` sets it, `--reset` clears) |
+| | `forge catalog` | Start Here — list every tool, crew, and guard with a one-line why |
+| | `forge docs` | docs↔code drift — check (registry reconcile) / render (regenerate machine-owned tables + diagrams) / sync (diff-driven stale-docs sweep) / impact (reusable doc-reference graph: which docs mention what THIS diff changed) |
+| | `forge update` | self-update — `--check` reports if a newer version is available, bare applies it, `--to ` pins/downgrades |
+| | `forge config` | provider setup — show / switch / add providers, set default model |
+| **Substrate** | `forge substrate` | one pre-action gate: assumptions, route, impact, scope, memory, verify |
+| | `forge preflight` | assumption check — what a task names that the repo doesn't define |
+| | `forge impact` | predict blast radius for a symbol or file from the atlas graph |
+| | `forge scope` | decompose files into independent clusters (+ coupled files you didn't name) |
+| | `forge context` | budgeted context assembly + completeness gate — what an edit NEEDS known |
+| | `forge route` | recommend the cheapest capable model for a task (+ gateway config) |
+| | `forge verify` | independent verification gate — tests + hallucinated-symbol + provenance (--deep: multi-lens consensus) |
+| | `forge precommit` | commit-level gate — staged code w/o docs + secret scan (FORGE_COMMIT_GATE=block|warn|0) |
+| **Memory** | `forge cortex` | self-correcting project memory — status / why |
+| | `forge recall` | manage cross-session memory (list / add / consolidate) |
+| | `forge remember` | add a durable fact to this repo's portable memory (forge brain) |
+| | `forge brain` | show / rebuild the portable project memory index |
+| | `forge ledger` | evidence-referenced memory — stats / verify / show / blame / query / at / diff / root / ratify / retract / merge / sync / import |
+| | `forge handoff` | bounded session snapshot — rewrite .forge/state.md, re-injected each session start |
+| | `forge decide` | append-only decision log — D-#### ADR-lite entries in .forge/decisions.md |
+| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total, never dropped |
+| **Quality** | `forge scan` | vet a skill/MCP for injection/RCE/exfil before install (skill-gate) |
+| | `forge spec` | spec-as-contract — init (OpenSpec) / lock / check drift |
+| | `forge harden` | wire security controls — pre-commit gate (gitleaks + commit gate) + sandbox settings |
+| | `forge radar` | dependency-currency rings — staleness/major-lag/advisories from live registry evidence, cached 24h |
+| **Config** | `forge brand` | print the active brand token map |
+| | `forge atlas` | build / query the code-graph (where-is-Y, has-symbol) |
+| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests |
+| | `forge integrations` | opt-in third-party MCP servers (e.g. context7) — add records the managed set and writes only with --yes (--adopt claims a same-name entry); remove reverses it |
+| | `forge cost` | real per-day spend via ccusage + measured stage factors (--stages) |
+| **Labs (experimental)** | `forge taste` | enable one UI-taste tool for this repo (no arg = list) |
+| | `forge uicheck` | deterministic UI checks — contrast · fingerprint · design · visual |
+| | `forge imagine` | consequence simulation — predicted breaks + the minimal dry-run test suite for a task |
+| | `forge lean` | scope-minimality (M5) — measure the diff's footprint vs what the task asked for |
+| | `forge anchor` | goal-drift check — are your actual (git) changes still on the stated goal? |
+| | `forge diagnose` | doom-loop check — record a failure; 3× the same signature mints a diagnosis + escalation |
+| | `forge dash` | live dashboard: ledger, metrics trends, radar, memory browser, timeline, blast radius |
+| | `forge report` | emit a static, self-contained HTML snapshot of .forge/ — opens offline, no server |
+| | `forge deja` | anti-repetition — have you done this task before? ranks prior solved/verified sessions |
+| | `forge reuse` | proof-carrying code cache — query / mint --file / stats |
+| | `forge rank` | load-bearing code — PageRank centrality × past-incident history, circular-dependency clusters, chokepoint files |
+
**→ Every command with a worked example and real output:
[`docs/GUIDE.md`](docs/GUIDE.md).**
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index b7671fb..55da132 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -25,15 +25,16 @@ recipes, and how to extend each piece. If you just want to get going, the
Every command is real and wired. Grouped by what it does:
-| Group | Commands |
-| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Config / cross-tool sync** | `forge init` · `forge sync` · `forge tools` · `forge doctor` · `forge update` · `forge docs` · `forge config` · `forge harden` · `forge catalog` · `forge brand` |
-| **Memory & ledger (PCM)** | `forge ledger` · `forge recall` · `forge remember` · `forge brain` · `forge cortex` · `forge reuse` · `forge handoff` · `forge decide` · `forge know` |
-| **Code graph & retrieval** | `forge atlas` · `forge stack` · `forge context` |
-| **Substrate / pre-action** | `forge substrate` · `forge preflight` · `forge route` · `forge impact` · `forge rank` · `forge scope` · `forge imagine` · `forge anchor` · `forge diagnose` · `forge lean` · `forge cost` |
-| **Verification & safety** | `forge verify` · `forge precommit` · `forge radar` · `forge scan` · `forge spec` |
-| **UI / design** | `forge taste` · `forge uicheck` |
-| **Dashboard** | `forge dash` |
+
+| Group | Commands |
+| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Core** | `forge init` · `forge sync` · `forge doctor` · `forge tools` · `forge catalog` · `forge docs` · `forge update` · `forge config` |
+| **Substrate** | `forge substrate` · `forge preflight` · `forge impact` · `forge scope` · `forge context` · `forge route` · `forge verify` · `forge precommit` |
+| **Memory** | `forge cortex` · `forge recall` · `forge remember` · `forge brain` · `forge ledger` · `forge handoff` · `forge decide` · `forge know` |
+| **Quality** | `forge scan` · `forge spec` · `forge harden` · `forge radar` |
+| **Config** | `forge brand` · `forge atlas` · `forge stack` · `forge integrations` · `forge cost` |
+| **Labs (experimental)** | `forge taste` · `forge uicheck` · `forge imagine` · `forge lean` · `forge anchor` · `forge diagnose` · `forge dash` · `forge report` · `forge deja` · `forge reuse` · `forge rank` |
+
Storage in one line: the code graph is `.forge/atlas.json` (plain JSON, not SQLite); the
ledger is content-addressed claims under `.forge/ledger/` (git-committable, union-merge).
@@ -70,7 +71,7 @@ The daily loop — every outcome an oracle observes lands in the team ledger, an
ledger informs the next task:
```mermaid
-%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%%
+%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%%
flowchart LR
W["work — substrate pre-checks,
then edit"] --> O["oracles — forge verify ·
imagine --run · CI · human accept/revert"]
O -->|"outcomes move claim val"| L[("team ledger
.forge/ledger/")]
@@ -413,6 +414,37 @@ dispatch, like every forge store. With `ENABLE_CORTEX_DISTILL=1`, distilled Cort
that read like decisions or durable facts are auto-routed to those homes too (fail-open,
best-effort).
+### `forge docs render` — the docs that can write themselves, do
+
+`forge docs check` can only _detect_ drift; every fix was still a human hand-editing
+tables in five files. `docs render` closes the loop: the derivable doc surfaces are
+generated from the same registries the check reads, into marker-managed blocks
+(`` — the same pattern `npm run bench` uses for
+`reports/benchmarks.md`). Prose stays human; tables and diagrams become machine-owned:
+
+- the README command table and this guide's group map — from `COMMANDS`/`GROUPS`
+ (exactly the `--help` grouping, one source of truth);
+- the MCP tool table — from the `TOOLS` registry;
+- every literal "N MCP tools" count phrase, across all six files it lives in;
+- one shared mermaid theme derived from `brand.json` — change the brand, re-render,
+ every diagram in every tracked markdown file re-themes (deliberate bad examples
+ opted out with `docs-check-ignore` are left alone);
+- the repo map in `ARCHITECTURE.md` — drawn from the live import graph, so it cannot
+ drift from the tree it describes.
+
+```console
+$ forge docs render
+ rendered: README.md (block commands-table)
+$ forge docs render --check
+ all generated doc surfaces current
+```
+
+`--check` reports without writing (exit 1 on drift), and `docs check` runs the same
+reconciler in CI: a stale registry-derived block is an **error** whose message is the
+fix (`run forge docs render`), while tree-derived output (the repo map, diagram theme)
+is a warning — moving a file never fails an unrelated PR, but a new command with a
+stale table always does.
+
### `forge docs sync` — which prose did this diff make stale?
`forge docs check` reconciles the registries; `docs sync` answers the diff-shaped
@@ -1207,27 +1239,30 @@ emitted `.mcp.json`):
-| MCP tool | Does |
-| ---------------------------------------- | ------------------------------------------------------ |
-| `substrate_check` | full pre-action check |
-| `preflight_check` | assumption / info-gap check |
-| `assumption_gate` | ask/proceed + questions |
-| `predict_impact` | blast radius (code **and** the docs that reference it) |
-| `route_task` | model recommendation |
-| `scope_files` | independent vs. coupled |
-| `cortex_lessons` | learned lessons for given files/symbols |
-| `cortex_status` | memory lifecycle summary |
-| `forge_brain` | durable project facts |
-| `forge_ledger_query` | ranked retrieval over the PCM ledger |
-| `forge_remember` | **write**: add a durable project fact |
-| `forge_ledger_ratify` | **write**: human-ratify a claim into a decision |
-| `forge_ledger_retract` | **write**: tombstone a claim |
-| `rank_code` | load-bearing files/symbols, cycles, chokepoints |
-| `forge_diagnose` | doom-loop failure check |
-| `forge_doctor` | health check |
-| `forge_provider_status` | provider detection + gateway reachability |
-| `forge_cost` | spend + stage factors |
-| `forge_dash_data` / `forge_dash_summary` | dashboard data feeds |
+
+| MCP tool | Does |
+| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `cortex_lessons` | Lessons Forge Cortex learned from past mistakes on THIS repo, for the given files/symbols. |
+| `cortex_status` | Summary of learned lessons on this repo (counts by state, top by confidence). |
+| `preflight_check` | BEFORE starting a task, check what it names that the repo doesn't define — the things you'd otherwise ASSUME. |
+| `route_task` | Recommend the cheapest CAPABLE model for a task by code-task complexity (files, fan-out, churn, past mistakes, ambiguity). |
+| `assumption_gate` | Score specification completeness before work starts. |
+| `predict_impact` | Predict blast radius for a symbol or file using Forge atlas reverse-dependency traversal. |
+| `substrate_check` | Full Forge cognitive-substrate pre-action check: assumption gate, route, impact, scope, memory, minimality, and verification checklist. |
+| `scope_files` | Decompose files into INDEPENDENT clusters (run as separate sessions) vs coupled, and surface coupled files you didn't name (the 'forgot the related module' guard). |
+| `forge_cost` | Cost report — measured stage factors (gate, cache, route, context) from .forge/metrics.jsonl with multiplicative composition. |
+| `forge_dash_data` | Dashboard JSON payload — ledger stats, metrics, atlas info. |
+| `forge_dash_summary` | Lightweight dashboard health check — just counts (claims, tombstoned, contested, atlas built, metric events). |
+| `forge_brain` | Project memory index — list all remembered facts stored in .forge/brain/. |
+| `forge_ledger_query` | Query the proof-carrying memory ledger with a natural language query. |
+| `forge_diagnose` | Doom-loop check — record a failure and check if the same signature has recurred (3x = escalation). |
+| `forge_doctor` | Health check — verify installed tools, guards, MCP auth, config drift, and system state. |
+| `forge_provider_status` | Provider detection — which API provider is active (auto-detected or configured), env vars set, and health checks. |
+| `forge_remember` | Store a durable fact in this repo's portable memory (.forge/brain/). |
+| `forge_ledger_ratify` | Promote a ledger claim's confidence — record an independent oracle ratification (the claim held under test). |
+| `rank_code` | Which code is load-bearing and dangerous to touch — PageRank centrality over the Forge atlas graph joined with past-incident history from the evidence ledger, plus circular-dependency clusters and chokepoint files whose removal disconnects the import graph. |
+| `forge_ledger_retract` | Tombstone a ledger claim with a reason — mark it as no longer valid so it stops influencing routing and memory. |
+
Forge never pretends it can force a hook into a tool that has none — **ambient on Claude
Code, agent-invoked everywhere else.**
diff --git a/docs/cognitive-substrate/README.md b/docs/cognitive-substrate/README.md
index 19759ce..d1622b5 100644
--- a/docs/cognitive-substrate/README.md
+++ b/docs/cognitive-substrate/README.md
@@ -179,7 +179,7 @@ call proposes a completeness reading (M2), a complexity band (M1), the coupled e
regex graph misses (impact), and whether an off-goal file actually serves the goal (M4).
```mermaid
-%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%%
+%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%%
flowchart LR
T["task / edit"] --> R["deterministic rubric"]
T --> P["LLM proposer"]
diff --git a/docs/plans/substrate-v2/00-overview.md b/docs/plans/substrate-v2/00-overview.md
index 0e9e98c..96aa52d 100644
--- a/docs/plans/substrate-v2/00-overview.md
+++ b/docs/plans/substrate-v2/00-overview.md
@@ -68,7 +68,7 @@ later phase stores its state as PCM claims. **All phases have shipped** (v0.5.0)
All nodes below are shipped (green); the color is the legend.
```mermaid
-%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%%
+%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%%
flowchart LR
P0["P0 specs"] --> P1["P1 ledger core"]
P1 --> P2["P2 team sync"]
diff --git a/mintlify/cli/core.mdx b/mintlify/cli/core.mdx
index 3037d17..72736b7 100644
--- a/mintlify/cli/core.mdx
+++ b/mintlify/cli/core.mdx
@@ -90,12 +90,16 @@ Docs ↔ code drift.
```bash
forge docs check # registry reconcile — commands, env vars, MCP tools, CHANGELOG
+forge docs render # regenerate machine-owned tables, counts, and diagrams from the registries
forge docs sync # diff-driven stale-docs sweep
```
`docs check` fails CI when commands, env vars, MCP tools, or the CHANGELOG drift from
- the code. `docs sync` sweeps the diff and reports UPDATED / STALE / VERIFIED-UNAFFECTED.
+ the code — and when a generated block is stale, the error names the fix: `forge docs
+ render` regenerates the command tables, MCP tool table, count phrases, shared mermaid
+ theme, and the repo map from the code registries. `docs sync` sweeps the diff and
+ reports UPDATED / STALE / VERIFIED-UNAFFECTED.
## `forge update`
diff --git a/src/cli.js b/src/cli.js
index f4a0d27..d781642 100755
--- a/src/cli.js
+++ b/src/cli.js
@@ -513,6 +513,28 @@ HANDLERS.docs = async (argv) => {
if (argv.includes("--strict") && r.impacted.length) process.exitCode = 1;
return;
}
+ // `render` — regenerate the machine-owned doc surfaces (command tables, MCP tool
+ // table, count phrases, mermaid theme, repo map) from the registries. The write-side
+ // twin of `check`: check tells you docs drifted, render is the one-command repair.
+ if (sub === "render") {
+ const { renderDocs } = await import("./docs_render.js");
+ const check = argv.includes("--check");
+ const r = renderDocs(undefined, { write: !check });
+ if (json) {
+ console.log(JSON.stringify(r, null, 2));
+ if (check && !r.ok) process.exitCode = 1;
+ return;
+ }
+ for (const f of r.files)
+ console.log(
+ ` ${check ? "stale" : "rendered"}: ${f.file} ${paint(`(${f.why.join(", ")})`, "dim")}`,
+ );
+ for (const m of r.missing)
+ console.error(` ${paint(`missing markers for block ${m.name} in ${m.file}`, "err")}`);
+ if (!r.files.length && !r.missing.length) console.log(" all generated doc surfaces current");
+ if (check && !r.ok) process.exitCode = 1;
+ return;
+ }
// `check` — self-check of the forge package's own docs against its code (commands
// table, env reads, MCP registry, CHANGELOG).
const { docsCheck } = await import("./docs_check.js");
diff --git a/src/commands.js b/src/commands.js
index fb6db8b..5ab7369 100644
--- a/src/commands.js
+++ b/src/commands.js
@@ -124,8 +124,9 @@ export const COMMANDS = {
brand: "print the active brand token map",
docs: {
summary:
- "docs↔code drift — check (registry reconcile) / sync (diff-driven stale-docs sweep) / impact (reusable doc-reference graph: which docs mention what THIS diff changed)",
- usage: "forge docs [check | sync | impact] [--since [ | --staged] [--strict] [--json]",
+ "docs↔code drift — check (registry reconcile) / render (regenerate machine-owned tables + diagrams) / sync (diff-driven stale-docs sweep) / impact (reusable doc-reference graph: which docs mention what THIS diff changed)",
+ usage:
+ "forge docs [check | render | sync | impact] [--check] [--since ][ | --staged] [--strict] [--json]",
flags: [
{
flag: "--since ][",
@@ -143,9 +144,14 @@ export const COMMANDS = {
flag: "--strict",
desc: "exit non-zero when stale/impacted docs are found (for CI; advisory otherwise)",
},
+ {
+ flag: "--check",
+ desc: "render: report stale generated blocks without writing (exit 1 on strict drift)",
+ },
],
examples: [
"forge docs check",
+ "forge docs render",
"forge docs sync",
"forge docs impact",
"forge docs impact --since main",
diff --git a/src/docs_check.js b/src/docs_check.js
index fbc0c38..102a1f8 100644
--- a/src/docs_check.js
+++ b/src/docs_check.js
@@ -8,6 +8,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
import { BRAND } from "./brand.js";
import { COMMANDS, HIDDEN_COMMANDS } from "./commands.js";
+import { renderDocs } from "./docs_render.js";
import { TOOLS } from "./mcp_tools.js";
import { allPricePairs } from "./model_tiers.js";
import { git } from "./util.js";
@@ -73,6 +74,27 @@ export function envVarsRead(root = BRAND.root) {
return vars;
}
+/** Generated doc surfaces vs the registries: a stale machine-owned block is not a
+ * judgement call — `forge docs render` IS the fix, so the error says exactly that.
+ * Registry-derived blocks (tables, counts) are errors; tree-derived output (repo map,
+ * mermaid theme) is a warning so moving a file never fails an unrelated PR. */
+function checkRendered(root, issues) {
+ let r;
+ try {
+ r = renderDocs(root, { write: false });
+ } catch {
+ return; // never let the renderer take the whole check down
+ }
+ for (const f of r.files) {
+ if (!f.changed) continue;
+ issues.push({
+ check: "render",
+ severity: f.strict ? "error" : "warn",
+ detail: `${f.file}: generated ${f.why.join(" + ")} out of date — run \`${BRAND.cli} docs render\``,
+ });
+ }
+}
+
/** Commands table vs README/GUIDE: every command documented, nothing phantom. */
function checkCommands(docs, issues) {
for (const target of ["README.md", "docs/GUIDE.md"]) {
@@ -558,6 +580,7 @@ export function docsCheck({ root = BRAND.root } = {}) {
const docs = Object.fromEntries(DOC_FILES.map((f) => [f, readDoc(root, f)]));
const issues = [];
checkCommands(docs, issues);
+ checkRendered(root, issues);
checkEnvVars(root, docs, issues);
checkMcpTools(docs, issues);
checkChangelog(root, issues);
@@ -573,6 +596,7 @@ export function docsCheck({ root = BRAND.root } = {}) {
issues,
checked: [
"commands",
+ "render",
"env-vars",
"mcp-tools",
"changelog",
diff --git a/src/docs_render.js b/src/docs_render.js
new file mode 100644
index 0000000..57c3e6f
--- /dev/null
+++ b/src/docs_render.js
@@ -0,0 +1,282 @@
+// forge docs render — the docs write themselves where they can. docs_check.js can only
+// DETECT drift between the registries and the prose; every fix was still a human
+// hand-editing tables in five files. This module closes the loop: the derivable parts
+// of the docs (command tables, the MCP tool table, the "N MCP tools" count phrases, the
+// mermaid theme, the repo map) are RENDERED from the same registries the check reads —
+// COMMANDS/GROUPS, mcp_tools.TOOLS, brand.json, the live import graph — into
+// marker-managed blocks, exactly the pattern bench/bench.mjs already uses for
+// reports/benchmarks.md. Prose stays human; tables and diagrams become machine-owned.
+// `forge docs render` regenerates; `--check` (and the docs-check reconciler) fails CI
+// with the exact command to run, so "docs out of sync" becomes a one-command repair.
+//
+// Registry-derived blocks (tables, counts) are enforced as ERRORS; tree-derived output
+// (the repo-map diagram, mermaid theme normalization) is advisory — a moved file should
+// not fail an unrelated PR, but a new command with a stale table must.
+import { readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { BRAND } from "./brand.js";
+import { commandSummary, GROUPS } from "./commands.js";
+import { TOOLS } from "./mcp_tools.js";
+import { directedImportGraph } from "./scope.js";
+import { git } from "./util.js";
+
+const BEGIN = (name) =>
+ ``;
+const END = (name) => ``;
+
+/** Pad a markdown table so the raw text stays readable (the repo's tables are aligned). */
+function mdTable(headers, rows) {
+ const all = [headers, ...rows];
+ const width = headers.map((_, i) => Math.max(...all.map((r) => String(r[i]).length)));
+ const line = (r) => `| ${r.map((c, i) => String(c).padEnd(width[i])).join(" | ")} |`;
+ const rule = `| ${width.map((w) => "-".repeat(w)).join(" | ")} |`;
+ return [line(headers), rule, ...rows.map(line)].join("\n");
+}
+
+/**
+ * One mermaid theme for every diagram, derived from brand.json (never hardcoded hexes) —
+ * change the brand, run `forge docs render`, every diagram re-themes. Carries the two
+ * load-bearing identity hexes docs_check requires (brand → lineColor, bg → tertiaryColor)
+ * plus the readability settings the hand-written init strings never had.
+ */
+export function mermaidInit() {
+ const d = BRAND.colors?.dark ?? {};
+ const vars = [
+ `'primaryColor':'${d["bg-2"]}'`,
+ `'primaryTextColor':'${d.text}'`,
+ `'primaryBorderColor':'${d.line}'`,
+ `'lineColor':'${d.brand}'`,
+ `'secondaryColor':'${d["panel-2"]}'`,
+ `'tertiaryColor':'${d.bg}'`,
+ `'edgeLabelBackground':'${d["bg-2"]}'`,
+ `'clusterBkg':'${d.bg}'`,
+ `'clusterBorder':'${d["line-strong"]}'`,
+ `'fontFamily':'ui-sans-serif, system-ui, sans-serif'`,
+ `'fontSize':'14px'`,
+ ].join(",");
+ const layout = `'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}`;
+ return `%%{init: {'theme':'base','themeVariables':{${vars}},${layout}}}%%`;
+}
+
+/** The README command table — one row per command, grouped exactly like `--help`. */
+export function renderCommandsTable() {
+ const rows = [];
+ for (const [group, names] of Object.entries(GROUPS)) {
+ names.forEach((name, i) => {
+ rows.push([i === 0 ? `**${group}**` : "", `\`${BRAND.cli} ${name}\``, commandSummary(name)]);
+ });
+ }
+ return mdTable(["Group", "Command", "Does"], rows);
+}
+
+/** The GUIDE group map — same grouping as `--help`, one row per group. */
+export function renderGroupsTable() {
+ const rows = Object.entries(GROUPS).map(([group, names]) => [
+ `**${group}**`,
+ names.map((n) => `\`${BRAND.cli} ${n}\``).join(" · "),
+ ]);
+ return mdTable(["Group", "Commands"], rows);
+}
+
+/** The MCP tool table — straight from the TOOLS registry, first sentence per tool. */
+export function renderMcpToolsTable() {
+ const firstSentence = (s) => {
+ const cut = s.indexOf(". ");
+ return (cut > 0 ? s.slice(0, cut + 1) : s).trim();
+ };
+ return mdTable(
+ ["MCP tool", "Does"],
+ TOOLS.map((t) => [`\`${t.name}\``, firstSentence(t.description)]),
+ );
+}
+
+/**
+ * The repo map: top-level directories sized by file count, edges = import counts between
+ * them, from the live import graph (scope.directedImportGraph). Deterministic ordering.
+ * @param {string} root
+ * @param {{maxDirs?:number}} [opts]
+ */
+export function renderRepoMap(root, { maxDirs = 9 } = {}) {
+ const g = directedImportGraph(root);
+ const dirOf = (f) => (f.includes("/") ? f.slice(0, f.indexOf("/")) : "(root)");
+ const files = new Map(); // dir → count
+ for (const f of g.nodes) files.set(dirOf(f), (files.get(dirOf(f)) ?? 0) + 1);
+ const kept = [...files.entries()]
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .slice(0, maxDirs)
+ .map(([dir]) => dir);
+ const keptSet = new Set(kept);
+ const edges = new Map(); // "a→b" → count
+ for (const [from, targets] of [...g.edges.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
+ for (const to of [...targets].sort()) {
+ const a = dirOf(from);
+ const b = dirOf(to);
+ if (a === b || !keptSet.has(a) || !keptSet.has(b)) continue;
+ const key = `${a}→${b}`;
+ edges.set(key, (edges.get(key) ?? 0) + 1);
+ }
+ }
+ const id = (dir) => dir.replace(/[^A-Za-z0-9]/g, "_");
+ const lines = [mermaidInit(), "flowchart LR"];
+ for (const dir of kept)
+ lines.push(
+ ` ${id(dir)}["${dir}]
${files.get(dir)} file${files.get(dir) === 1 ? "" : "s"}"]`,
+ );
+ for (const [key, n] of [...edges.entries()].sort(
+ (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]),
+ )) {
+ const [a, b] = key.split("→");
+ lines.push(` ${id(a)} ${n > 1 ? `-- ${n} --> ` : "--> "}${id(b)}`);
+ }
+ return `\`\`\`mermaid\n${lines.join("\n")}\n\`\`\``;
+}
+
+/** Replace one managed block. Returns the new text plus whether markers were found/changed. */
+export function spliceBlock(text, name, body) {
+ const begin = text.indexOf(BEGIN(name));
+ const end = text.indexOf(END(name));
+ if (begin === -1 || end === -1 || end < begin) return { text, found: false, changed: false };
+ const next = `${text.slice(0, begin)}${BEGIN(name)}\n${body}\n${text.slice(end)}`;
+ return { text: next, found: true, changed: next !== text };
+}
+
+/** Rewrite every literal "N MCP tools" phrase to the registry's real count. */
+export function updateCounts(text) {
+ return text.replace(/\b\d+ MCP tools\b/g, `${TOOLS.length} MCP tools`);
+}
+
+const MERMAID_BLOCK_RE = /```mermaid\n([\s\S]*?)```/g;
+const INIT_LINE_RE = /%%\{init[\s\S]*?\}%%/;
+
+/** Normalize every mermaid block's `%%{init` line to the one shared brand theme.
+ * Blocks opted out with `docs-check-ignore` (deliberate bad examples) are untouched;
+ * blocks with no init line are left for docs_check to flag. */
+export function normalizeMermaid(text) {
+ return text.replace(MERMAID_BLOCK_RE, (block, body, offset) => {
+ if (/docs-check-ignore/.test(text.slice(Math.max(0, offset - 80), offset))) return block;
+ if (!INIT_LINE_RE.test(body)) return block;
+ return block.replace(INIT_LINE_RE, mermaidInit());
+ });
+}
+
+// Which generated block lives in which file. Registry-derived blocks are ERRORS when
+// stale (a new command MUST regenerate); the tree-derived repo map is advisory.
+const BLOCK_TARGETS = [
+ {
+ file: "README.md",
+ name: "commands-table",
+ render: () => renderCommandsTable(),
+ strict: true,
+ },
+ {
+ file: "docs/GUIDE.md",
+ name: "command-groups",
+ render: () => renderGroupsTable(),
+ strict: true,
+ },
+ {
+ file: "docs/GUIDE.md",
+ name: "mcp-tools",
+ render: () => renderMcpToolsTable(),
+ strict: true,
+ },
+ {
+ file: "ARCHITECTURE.md",
+ name: "repo-map",
+ render: (root) => renderRepoMap(root),
+ strict: false,
+ },
+];
+
+// Files whose "N MCP tools" phrases are auto-corrected (the six the count lives in).
+const COUNT_FILES = [
+ "README.md",
+ "docs/GUIDE.md",
+ "ARCHITECTURE.md",
+ "ROADMAP.md",
+ "mintlify/quickstart.mdx",
+ "mintlify/concepts/config-compiler.mdx",
+];
+
+/** Every git-tracked markdown file (mermaid theme normalization scope). */
+function trackedMarkdown(root) {
+ const out = git(root, ["ls-files", "*.md"]);
+ return out ? out.split("\n").filter(Boolean) : [];
+}
+
+/**
+ * Render every managed doc surface. With {write:true} stale files are rewritten;
+ * otherwise this is a pure report (what `--check` and the docs-check reconciler use).
+ * @param {string} [root]
+ * @param {{write?: boolean}} [opts]
+ * @returns {{ok:boolean, files:{file:string, changed:boolean, strict:boolean, why:string[]}[],
+ * missing:{file:string, name:string}[]}}
+ */
+export function renderDocs(root = BRAND.root, { write = false } = {}) {
+ /** @type {Map} */
+ const touched = new Map();
+ const missing = [];
+ const load = (file) => {
+ if (!touched.has(file)) {
+ let text;
+ try {
+ text = readFileSync(join(root, file), "utf8");
+ } catch {
+ return null;
+ }
+ touched.set(file, { text, orig: text, strict: false, why: [] });
+ }
+ return touched.get(file);
+ };
+
+ for (const t of BLOCK_TARGETS) {
+ const doc = load(t.file);
+ if (!doc) continue;
+ if (doc.text.indexOf(BEGIN(t.name)) === -1) {
+ // No markers → nothing to manage here (fixture roots, forks that opted out).
+ // Don't render (the repo map walks the tree) and don't flag — the registry
+ // reconcilers still cover the content the old hand-written way.
+ missing.push({ file: t.file, name: t.name });
+ continue;
+ }
+ const r = spliceBlock(doc.text, t.name, t.render(root));
+ if (!r.found) {
+ missing.push({ file: t.file, name: t.name });
+ continue;
+ }
+ if (r.changed) {
+ doc.why.push(`block ${t.name}`);
+ if (t.strict) doc.strict = true;
+ }
+ doc.text = r.text;
+ }
+ for (const file of COUNT_FILES) {
+ const doc = load(file);
+ if (!doc) continue;
+ const next = updateCounts(doc.text);
+ if (next !== doc.text) {
+ doc.why.push("MCP tool count");
+ doc.strict = true;
+ doc.text = next;
+ }
+ }
+ for (const file of trackedMarkdown(root)) {
+ const doc = load(file);
+ if (!doc) continue;
+ const next = normalizeMermaid(doc.text);
+ if (next !== doc.text) {
+ doc.why.push("mermaid theme");
+ doc.text = next;
+ }
+ }
+
+ const files = [];
+ for (const [file, doc] of touched) {
+ const changed = doc.text !== doc.orig;
+ if (changed && write) writeFileSync(join(root, file), doc.text);
+ if (changed || doc.why.length) files.push({ file, changed, strict: doc.strict, why: doc.why });
+ }
+ // `missing` is informational (a root without markers manages nothing) — only STALE
+ // strict surfaces fail the check.
+ return { ok: files.every((f) => !f.strict), files, missing };
+}
diff --git a/test/docs_render.test.js b/test/docs_render.test.js
new file mode 100644
index 0000000..92d00f0
--- /dev/null
+++ b/test/docs_render.test.js
@@ -0,0 +1,125 @@
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import { BRAND } from "../src/brand.js";
+import { COMMANDS } from "../src/commands.js";
+import {
+ mermaidInit,
+ normalizeMermaid,
+ renderCommandsTable,
+ renderDocs,
+ renderGroupsTable,
+ renderMcpToolsTable,
+ renderRepoMap,
+ spliceBlock,
+ updateCounts,
+} from "../src/docs_render.js";
+import { TOOLS } from "../src/mcp_tools.js";
+
+const dir = () => mkdtempSync(join(tmpdir(), "forge-render-"));
+
+test("renderCommandsTable names every command exactly as the docs check requires", () => {
+ const table = renderCommandsTable();
+ for (const name of Object.keys(COMMANDS))
+ assert.ok(
+ new RegExp(`\\b${BRAND.cli} ${name}\\b`).test(table),
+ `table carries \`${BRAND.cli} ${name}\``,
+ );
+});
+
+test("renderGroupsTable and renderMcpToolsTable derive fully from their registries", () => {
+ const groups = renderGroupsTable();
+ assert.ok(groups.includes("**Labs (experimental)**"), "group labels come from GROUPS keys");
+ const tools = renderMcpToolsTable();
+ for (const t of TOOLS) assert.ok(tools.includes(`\`${t.name}\``), `tool row for ${t.name}`);
+});
+
+test("mermaidInit carries the two brand identity hexes the diagram check enforces", () => {
+ const init = mermaidInit();
+ const d = BRAND.colors.dark;
+ assert.ok(init.includes("%%{init"), "is an init directive");
+ assert.ok(init.toLowerCase().includes(d.brand.toLowerCase()), "ember accent present");
+ assert.ok(init.toLowerCase().includes(d.bg.toLowerCase()), "warm-black canvas present");
+});
+
+test("spliceBlock replaces only the managed span and is idempotent", () => {
+ const begin = ``;
+ const doc = `prose before\n\n${begin}\nOLD\n\n\nprose after`;
+ const r1 = spliceBlock(doc, "x", "NEW");
+ assert.ok(r1.found && r1.changed, "first splice replaces");
+ assert.ok(r1.text.includes("NEW") && !r1.text.includes("OLD"));
+ assert.ok(r1.text.startsWith("prose before") && r1.text.endsWith("prose after"));
+ const r2 = spliceBlock(r1.text, "x", "NEW");
+ assert.ok(r2.found && !r2.changed, "same body → no change");
+ assert.equal(spliceBlock(doc, "missing", "NEW").found, false, "unknown block untouched");
+});
+
+test("updateCounts rewrites any stale N-MCP-tools phrase to the registry count", () => {
+ const out = updateCounts("we ship 3 MCP tools today and 999 MCP tools tomorrow");
+ assert.equal(
+ out,
+ `we ship ${TOOLS.length} MCP tools today and ${TOOLS.length} MCP tools tomorrow`,
+ );
+});
+
+test("normalizeMermaid unifies init lines, skips ignored examples and init-less blocks", () => {
+ const old =
+ "%%{init: {'theme':'base','themeVariables':{'lineColor':'#f26430','tertiaryColor':'#171310'}}}%%";
+ const themed = `\`\`\`mermaid\n${old}\nflowchart LR\n a --> b\n\`\`\``;
+ assert.ok(
+ normalizeMermaid(themed).includes(mermaidInit()),
+ "old init upgraded to the shared theme",
+ );
+ const ignored = `\n\`\`\`mermaid\n${old}\nflowchart LR\n a --> b\n\`\`\``;
+ assert.equal(normalizeMermaid(ignored), ignored, "opted-out example blocks stay untouched");
+ const bare = "```mermaid\nflowchart LR\n a --> b\n```";
+ assert.equal(normalizeMermaid(bare), bare, "no init line → left for the diagram check to flag");
+});
+
+test("renderRepoMap draws directories and import edges from the real tree", () => {
+ const root = dir();
+ mkdirSync(join(root, "app"));
+ mkdirSync(join(root, "lib"));
+ writeFileSync(join(root, "app", "main.js"), 'import "../lib/util.js";\n');
+ writeFileSync(join(root, "lib", "util.js"), "export const u = 1;\n");
+ const map = renderRepoMap(root);
+ assert.ok(map.startsWith("```mermaid"), "is a mermaid fence");
+ assert.ok(map.includes(mermaidInit()), "uses the shared brand theme");
+ assert.ok(map.includes('app["app
1 file"]') && map.includes('lib["lib
1 file"]'));
+ assert.ok(map.includes("app --> lib"), "the import edge points importer → imported");
+});
+
+test("renderDocs fills managed blocks, is idempotent, and reports tampering as strict drift", () => {
+ const root = dir();
+ const begin = ``;
+ writeFileSync(
+ join(root, "README.md"),
+ `# fixture\n\n${begin}\n\n`,
+ );
+ const first = renderDocs(root, { write: true });
+ assert.ok(first.files.some((f) => f.file === "README.md" && f.changed && f.strict));
+ assert.ok(readFileSync(join(root, "README.md"), "utf8").includes(`\`${BRAND.cli} rank\``));
+ const second = renderDocs(root, { write: false });
+ assert.equal(second.files.length, 0, "freshly rendered → nothing to report");
+ assert.ok(second.ok);
+ writeFileSync(
+ join(root, "README.md"),
+ readFileSync(join(root, "README.md"), "utf8").replace(`\`${BRAND.cli} rank\``, "`TAMPERED`"),
+ );
+ const third = renderDocs(root, { write: false });
+ assert.ok(!third.ok, "a hand-edited generated block fails the check");
+ assert.ok(third.files.some((f) => f.strict && f.why.includes("block commands-table")));
+});
+
+test("renderDocs on a root without markers manages nothing and stays ok (fixture safety)", () => {
+ const root = dir();
+ writeFileSync(join(root, "README.md"), "# plain fixture, hand-written table\n");
+ const r = renderDocs(root, { write: false });
+ assert.ok(r.ok, "no markers → nothing stale");
+ assert.ok(
+ r.missing.some((m) => m.name === "commands-table"),
+ "absence is reported as informational",
+ );
+});