Jesús Pérez 13b03d6edf
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
feat: mode guards, convergence, manifest coverage, doc authoring pattern
## Mode guards and convergence loops (ADR-011)

  - `Guard` and `Converge` types added to `reflection/schema.ncl` and
    `reflection/defaults.ncl`. Guards run pre-flight checks (Block/Warn);
    converge loops iterate until a condition is met (RetryFailed/RetryAll).
  - `sync-ontology.ncl`: 3 guards + converge (zero-drift condition, max 2 iter).
  - `coder-workflow.ncl`: guard (coder-dir-exists) + `novelty-check` step.
  - Rust types in `ontoref-reflection/src/mode.rs`; executor in `executor.rs`
    evaluates guards before steps and convergence loop after.
  - `adrs/adr-011-mode-guards-and-convergence.ncl` added.

  ## Manifest capability completeness

  - `.ontology/manifest.ncl`: 3 → 19 declared capabilities covering the full
    action surface (daemon API, modes, Task Composer, QA, bookmarks, etc.).
  - `sync.nu`: `audit-manifest-coverage` + `sync manifest-check` command.
  - `validate-project.ncl`: 6th category `manifest-cov`.
  - Pre-commit hook `manifest-coverage` added.
  - Migrations `0010-manifest-capability-completeness`,
    `0011-manifest-coverage-hooks`.

  ## Rust doc authoring pattern — canonical `///` convention

  - `#[onto_api]`: `description = "..."` optional when `///` doc comment exists
    above handler — first line used as fallback. `#[derive(OntologyNode)]` same.
  - `ontoref-daemon/src/api.rs`: 42 handlers migrated to `///` doc comments;
    `description = "..."` removed from all `#[onto_api]` blocks.
  - `sync diff --docs --fail-on-drift`: exits 1 on crate `//!` drift; used by
    new `docs-drift` pre-commit hook. `docs-links` hook checks rustdoc broken links.
  - `generator.nu`: mdBook `crates/` chapter — per-crate page from `//!` doc,
    coverage badge, feature flags, implementing practice nodes.
  - `.claude/CLAUDE.md`: `### Documentation Authoring (Rust)` section added.
  - Migration `0012-rust-doc-authoring-pattern`.

  ## OntologyNode derive fixes

  - `#[derive(OntologyNode)]`: `name` and `paths` attributes supported; `///`
    doc fallback for `description`; `artifact_paths` correctly populated.
  - `Core::from_value` calls `merge_contributors()` behind `#[cfg(feature = "derive")]`.

  ## Bug fixes

  - `sync.nu` drift check: exact crate path match (not `str starts-with`);
    first-path-only rule; split on `. ` not `.` to avoid `.ontology/` truncation.
  - `find-unclaimed-artifacts`: fixed absolute vs relative path comparison.
  - Rustdoc broken intra-doc links fixed across all three crates.
  - `ci-docs` recipe now sets `RUSTDOCFLAGS` and actually fails on errors.

  mode guards/converge, manifest coverage validation, 19 capabilities (ADR-011)

  Extend the mode schema with Guard (pre-flight Block/Warn checks) and Converge
  (RetryFailed/RetryAll post-execution loops) — protocol pushes back on invalid
  state and iterates until convergence. ADR-011 records the decision to extend
  modes rather than create a separate action subsystem.

  Manifest expanded from 3 to 19 capabilities covering the full action surface
  (compose, plans, backlog graduation, notifications, coder pipeline, forms,
  templates, drift, quick actions, migrations, config, onboarding). New
  audit-manifest-coverage validator + pre-commit hook + SessionStart hook
  ensure agents always see complete project self-description.

  Bug fix: find-unclaimed-artifacts absolute vs relative path comparison —
  19 phantom MISSING items resolved. Health 43% → 100%.

  Anti-slop: coder novelty-check step (Jaccard overlap against published+QA)
  inserted between triage and publish in coder-workflow.

  Justfile restructured into 5 modules (build/test/dev/ci/assets).
  Migrations 0010-0011 propagate requirements to consumer projects.
2026-03-30 19:08:25 +01:00

683 lines
25 KiB
Plaintext

#!/usr/bin/env nu
# reflection/modules/generator.nu — project output generators.
# Composes full documentation from ontology, ADRs, modes, crates, and scenarios.
# Output formats: md (human), json (agent/MCP), mdbook (publishable HTML).
#
# This is the implementation behind the generate-docs and generate-mdbook modes.
# Modes declare steps; this module does the actual work.
def project-root []: nothing -> string {
let pr = ($env.ONTOREF_PROJECT_ROOT? | default "")
if ($pr | is-not-empty) and ($pr != $env.ONTOREF_ROOT) { $pr } else { $env.ONTOREF_ROOT }
}
use ../modules/store.nu [daemon-export-safe]
def nickel-import-path [root: string]: nothing -> string {
let entries = [
$"($root)/.ontology"
$"($root)/adrs"
$"($root)/.ontoref/ontology/schemas"
$"($root)/.ontoref/adrs"
$"($root)/.onref"
$root
$"($env.ONTOREF_ROOT)/ontology"
$"($env.ONTOREF_ROOT)/ontology/schemas"
$"($env.ONTOREF_ROOT)/adrs"
$env.ONTOREF_ROOT
]
let valid = ($entries | where { |p| $p | path exists } | uniq)
let existing = ($env.NICKEL_IMPORT_PATH? | default "")
if ($existing | is-not-empty) {
($valid | append $existing) | str join ":"
} else {
$valid | str join ":"
}
}
def ncl-export-safe [root: string, file: string]: nothing -> record {
let ip = (nickel-import-path $root)
daemon-export-safe $file --import-path $ip | default {}
}
# ── Data extraction ──────────────────────────────────────────────────────────
def extract-identity [root: string]: nothing -> record {
let cargo = $"($root)/Cargo.toml"
mut name = ($root | path basename)
mut version = ""
mut description = ""
mut crates = []
if ($cargo | path exists) {
let cargo_data = (open $cargo)
if ($cargo_data | get -o package.name | is-not-empty) {
$name = ($cargo_data | get package.name)
$version = ($cargo_data | get -o package.version | default "")
$description = ($cargo_data | get -o package.description | default "")
}
# Workspace members
let members = ($cargo_data | get -o workspace.members | default [])
if ($members | is-not-empty) {
$crates = ($members | each { |m|
let member_cargo = $"($root)/($m)/Cargo.toml"
# Expand globs: crates/* → actual directories
if ($m | str contains "*") {
glob $"($root)/($m)/Cargo.toml" | each { |f|
let crate_dir = ($f | path dirname)
let crate_data = (open $f)
{
name: ($crate_data | get -o package.name | default ($crate_dir | path basename)),
path: ($crate_dir | str replace $"($root)/" ""),
description: ($crate_data | get -o package.description | default ""),
version: ($crate_data | get -o package.version | default ""),
}
}
} else if ($member_cargo | path exists) {
let crate_data = (open $member_cargo)
[{
name: ($crate_data | get -o package.name | default ($m | path basename)),
path: $m,
description: ($crate_data | get -o package.description | default ""),
version: ($crate_data | get -o package.version | default ""),
}]
} else { [] }
} | flatten)
}
}
{ name: $name, version: $version, description: $description, crates: $crates, root: $root }
}
def extract-ontology [root: string]: nothing -> record {
let core_file = $"($root)/.ontology/core.ncl"
if not ($core_file | path exists) { return { nodes: [], edges: [] } }
let core = (ncl-export-safe $root $core_file)
if ($core | is-empty) { return { nodes: [], edges: [] } }
{
nodes: ($core.nodes? | default []),
edges: ($core.edges? | default []),
}
}
def extract-state [root: string]: nothing -> list<record> {
let state_file = $"($root)/.ontology/state.ncl"
if not ($state_file | path exists) { return [] }
let state = (ncl-export-safe $root $state_file)
if ($state | is-empty) { return [] }
$state.dimensions? | default []
}
def extract-gates [root: string]: nothing -> list<record> {
let gate_file = $"($root)/.ontology/gate.ncl"
if not ($gate_file | path exists) { return [] }
let gate = (ncl-export-safe $root $gate_file)
if ($gate | is-empty) { return [] }
$gate.gates? | default [] | where { |g| ($g.active? | default false) == true }
}
def extract-adrs [root: string]: nothing -> list<record> {
let files = (glob $"($root)/adrs/adr-*.ncl")
$files | each { |f|
let data = (ncl-export-safe $root $f)
if ($data | is-not-empty) {
{
id: ($data.id? | default ""),
title: ($data.title? | default ""),
status: ($data.status? | default ""),
date: ($data.date? | default ""),
decision: ($data.decision? | default ""),
constraints: ($data.constraints? | default []),
}
} else { null }
} | compact
}
def extract-modes [root: string]: nothing -> list<record> {
let ontoref_modes = (glob $"($env.ONTOREF_ROOT)/reflection/modes/*.ncl")
let project_modes = if $root != $env.ONTOREF_ROOT {
glob $"($root)/reflection/modes/*.ncl"
} else { [] }
let all_files = ($ontoref_modes | append $project_modes | uniq)
$all_files | each { |f|
let data = (ncl-export-safe $root $f)
if ($data | is-not-empty) and ($data.id? | is-not-empty) {
{
id: ($data.id? | default ""),
trigger: ($data.trigger? | default ""),
steps: ($data.steps? | default [] | length),
source: (if ($f | str starts-with $root) { $f | str replace $"($root)/" "" } else { $f | str replace $"($env.ONTOREF_ROOT)/" "onref/" }),
}
} else { null }
} | compact
}
def extract-scenarios [root: string]: nothing -> list<record> {
let scenarios_dir = $"($root)/reflection/scenarios"
if not ($scenarios_dir | path exists) { return [] }
let dirs = (ls $scenarios_dir | where type == dir | get name)
$dirs | each { |d|
let scenario_ncl = $"($d)/scenario.ncl"
let meta = if ($scenario_ncl | path exists) {
ncl-export-safe $root $scenario_ncl
} else { {} }
let files = (ls $d | where type == file | get name | each { |f| $f | path basename })
{
category: ($d | path basename),
path: ($d | str replace $"($root)/" ""),
files: $files,
actor: ($meta.actor? | default ""),
purpose: ($meta.purpose? | default ""),
}
}
}
# ── Compose full document ────────────────────────────────────────────────────
def compose-doc-data [root: string]: nothing -> record {
let identity = (extract-identity $root)
let ontology = (extract-ontology $root)
let state = (extract-state $root)
let gates = (extract-gates $root)
let adrs = (extract-adrs $root)
let modes = (extract-modes $root)
let scenarios = (extract-scenarios $root)
# Classify ontology nodes by level.
let nodes = ($ontology.nodes? | default [])
let axioms = ($nodes | where { |n| ($n.invariant? | default false) == true })
let tensions = ($nodes | where { |n| ($n.level? | default "") == "Tension" })
let practices = ($nodes | where { |n| let l = ($n.level? | default ""); $l == "Practice" or $l == "Spiral" })
{
identity: $identity,
architecture: {
axioms: ($axioms | each { |n| { id: $n.id, name: ($n.name? | default ""), description: ($n.description? | default ""), artifact_paths: ($n.artifact_paths? | default []) } }),
tensions: ($tensions | each { |n| { id: $n.id, name: ($n.name? | default ""), description: ($n.description? | default "") } }),
practices: ($practices | each { |n| { id: $n.id, name: ($n.name? | default ""), description: ($n.description? | default ""), artifact_paths: ($n.artifact_paths? | default []) } }),
edges: ($ontology.edges? | default []),
},
state: ($state | each { |d| { id: ($d.id? | default ""), name: ($d.name? | default ""), current: ($d.current_state? | default ""), desired: ($d.desired_state? | default "") } }),
gates: ($gates | each { |g| { id: ($g.id? | default ""), protects: ($g.protects? | default []), condition: ($g.opening_condition? | default "") } }),
decisions: ($adrs | where { |a| $a.status == "Accepted" }),
decisions_all: $adrs,
modes: $modes,
scenarios: $scenarios,
}
}
# ── Format: Markdown ─────────────────────────────────────────────────────────
def render-md [data: record]: nothing -> string {
let id = $data.identity
mut lines = [$"# ($id.name)"]
if ($id.version | is-not-empty) { $lines = ($lines | append $"**Version**: ($id.version)") }
if ($id.description | is-not-empty) {
$lines = ($lines | append "")
$lines = ($lines | append $id.description)
}
$lines = ($lines | append "")
# Crates
if ($id.crates | is-not-empty) {
$lines = ($lines | append "## Crates")
$lines = ($lines | append "")
$lines = ($lines | append "| Crate | Description |")
$lines = ($lines | append "|-------|-------------|")
for c in $id.crates {
$lines = ($lines | append $"| `($c.name)` | ($c.description) |")
}
$lines = ($lines | append "")
}
# Architecture — Axioms
if ($data.architecture.axioms | is-not-empty) {
$lines = ($lines | append "## Invariants")
$lines = ($lines | append "")
for a in $data.architecture.axioms {
$lines = ($lines | append $"### ($a.name)")
$lines = ($lines | append "")
$lines = ($lines | append $a.description)
if ($a.artifact_paths | is-not-empty) {
let paths = ($a.artifact_paths | each { |p| $"`($p)`" } | str join ", ")
$lines = ($lines | append $"Artifacts: ($paths)")
}
$lines = ($lines | append "")
}
}
# Architecture — Tensions
if ($data.architecture.tensions | is-not-empty) {
$lines = ($lines | append "## Tensions")
$lines = ($lines | append "")
for t in $data.architecture.tensions {
let poles = if ($t.poles | is-not-empty) {
let pole_strs = ($t.poles | each { |p| $"($p.pole? | default '')" })
let joined = ($pole_strs | str join " ↔ ")
$" (char lparen)($joined)(char rparen)"
} else { "" }
$lines = ($lines | append $"- **($t.name)**($poles): ($t.description)")
}
$lines = ($lines | append "")
}
# Architecture — Systems/Practices
if ($data.architecture.practices | is-not-empty) {
$lines = ($lines | append "## Systems")
$lines = ($lines | append "")
for p in $data.architecture.practices {
$lines = ($lines | append $"### ($p.name)")
$lines = ($lines | append "")
$lines = ($lines | append $p.description)
if ($p.artifact_paths | is-not-empty) {
$lines = ($lines | append "")
for ap in $p.artifact_paths { $lines = ($lines | append $"- `($ap)`") }
}
$lines = ($lines | append "")
}
}
# State dimensions
if ($data.state | is-not-empty) {
$lines = ($lines | append "## State")
$lines = ($lines | append "")
$lines = ($lines | append "| Dimension | Current | Desired |")
$lines = ($lines | append "|-----------|---------|---------|")
for d in $data.state {
let marker = if $d.current == $d.desired { " ✓" } else { "" }
$lines = ($lines | append $"| ($d.name) | `($d.current)` | `($d.desired)`($marker) |")
}
$lines = ($lines | append "")
}
# Gates
if ($data.gates | is-not-empty) {
$lines = ($lines | append "## Active Gates")
$lines = ($lines | append "")
for g in $data.gates {
$lines = ($lines | append $"- **($g.id)**: protects `($g.protects)` — ($g.condition)")
}
$lines = ($lines | append "")
}
# Decisions
if ($data.decisions | is-not-empty) {
$lines = ($lines | append "## Decisions (ADRs)")
$lines = ($lines | append "")
for a in $data.decisions {
$lines = ($lines | append $"### ($a.id): ($a.title)")
$lines = ($lines | append "")
$lines = ($lines | append $a.decision)
if ($a.constraints | is-not-empty) {
let hard = ($a.constraints | where { |c| ($c.severity? | default "") == "Hard" })
if ($hard | is-not-empty) {
$lines = ($lines | append "")
$lines = ($lines | append "**Hard constraints:**")
$lines = ($lines | append "")
for c in $hard {
$lines = ($lines | append $"- ($c.description? | default $c.scope)")
}
}
}
$lines = ($lines | append "")
}
}
# Modes
if ($data.modes | is-not-empty) {
$lines = ($lines | append "## Operational Modes")
$lines = ($lines | append "")
for m in $data.modes {
let step_count = $"(char lparen)($m.steps) steps(char rparen)"
$lines = ($lines | append $"- **($m.id)** ($step_count): ($m.trigger)")
}
$lines = ($lines | append "")
}
# Scenarios
if ($data.scenarios | is-not-empty) {
$lines = ($lines | append "## Scenarios")
$lines = ($lines | append "")
for s in $data.scenarios {
let purpose_str = if ($s.purpose | is-not-empty) { $" [($s.purpose)]" } else { "" }
let file_count = $"(char lparen)($s.files | length) files(char rparen)"
$lines = ($lines | append $"- **($s.category)**($purpose_str): `($s.path)/` ($file_count)")
}
$lines = ($lines | append "")
}
$lines | str join "\n"
}
# ── Crate doc helpers ────────────────────────────────────────────────────────
def read-crate-module-doc [crate_full_path: string]: nothing -> string {
for entry in [$"($crate_full_path)/src/lib.rs", $"($crate_full_path)/src/main.rs"] {
if not ($entry | path exists) { continue }
let lines = (open $entry | lines)
let past_attrs = (
$lines
| skip while { |l|
let t = ($l | str trim)
($t | is-empty) or ($t | str starts-with "#!")
}
)
let doc_lines = (
$past_attrs
| take while { |l|
let t = ($l | str trim)
($t | is-empty) or ($t | str starts-with "//!")
}
| where { |l| $l | str trim | str starts-with "//!" }
| each { |l| $l | str trim | str replace --regex '^//! ?' "" }
)
return ($doc_lines | str join "\n")
}
""
}
def count-pub-coverage [crate_full_path: string]: nothing -> record {
let src_dir = $"($crate_full_path)/src"
if not ($src_dir | path exists) { return { total: 0, documented: 0, percent: 0 } }
let rs_files = (glob $"($src_dir)/**/*.rs")
if ($rs_files | is-empty) { return { total: 0, documented: 0, percent: 0 } }
mut total = 0
mut documented = 0
for rs in $rs_files {
let lines = (open $rs | lines)
let indexed = ($lines | enumerate)
for row in $indexed {
let t = ($row.item | str trim)
let is_pub = (
($t | str starts-with "pub fn ") or ($t | str starts-with "pub struct ") or
($t | str starts-with "pub enum ") or ($t | str starts-with "pub trait ") or
($t | str starts-with "pub type ") or ($t | str starts-with "pub const ") or
($t | str starts-with "pub mod ") or ($t | str starts-with "pub async fn ")
)
if not $is_pub { continue }
$total = ($total + 1)
if $row.index > 0 {
let prev = ($indexed | where { |r| $r.index < $row.index } | reverse | where { |r| ($r.item | str trim | is-not-empty) } | if ($in | is-not-empty) { first } else { null })
if ($prev != null) {
let pt = ($prev.item | str trim)
if ($pt | str starts-with "///") {
$documented = ($documented + 1)
} else if ($pt | str starts-with "#[") {
let before = ($indexed | where { |r| $r.index < $prev.index } | reverse | where { |r| ($r.item | str trim | is-not-empty) } | if ($in | is-not-empty) { first } else { null })
if ($before != null) and (($before.item | str trim) | str starts-with "///") {
$documented = ($documented + 1)
}
}
}
}
}
}
let pct = if $total > 0 { ($documented * 100 / $total | math round) } else { 100 }
{ total: $total, documented: $documented, percent: $pct }
}
# ── Format: mdBook ───────────────────────────────────────────────────────────
def render-mdbook [data: record, root: string] {
let docs_src = $"($root)/docs/src"
mkdir $docs_src
mkdir $"($docs_src)/architecture"
mkdir $"($docs_src)/decisions"
mkdir $"($docs_src)/modes"
# README / intro
let intro = ([
$"# ($data.identity.name)"
""
($data.identity.description)
""
$"Generated from project ontology and reflection data."
] | str join "\n")
$intro | save -f $"($docs_src)/README.md"
# Architecture page
let arch_data = {
identity: $data.identity,
architecture: $data.architecture,
state: $data.state,
gates: $data.gates,
decisions: [],
decisions_all: [],
modes: [],
scenarios: [],
}
let arch_md = (render-md $arch_data)
$arch_md | save -f $"($docs_src)/architecture/overview.md"
# Individual ADR pages
for adr in $data.decisions_all {
let adr_data = {
identity: { name: "", version: "", description: "", crates: [], root: "" },
architecture: { axioms: [], tensions: [], practices: [], edges: [] },
state: [],
gates: [],
decisions: (if $adr.status == "Accepted" { [$adr] } else { [] }),
decisions_all: [$adr],
modes: [],
scenarios: [],
}
let status_badge = match $adr.status {
"Accepted" => "✅ Accepted",
"Proposed" => "📝 Proposed",
"Superseded" => "🔄 Superseded",
_ => $adr.status,
}
let content = ([
$"# ($adr.id): ($adr.title)"
""
$"**Status**: ($status_badge) **Date**: ($adr.date)"
""
$adr.decision
] | str join "\n")
$content | save -f $"($docs_src)/decisions/($adr.id).md"
}
# ADR index
let adr_index_lines = (["# Decisions (ADRs)" ""] | append (
$data.decisions_all | each { |a|
let link = $"(char lparen)./($a.id).md(char rparen)"
$"- [($a.id): ($a.title)]($link) — ($a.status)"
}
))
($adr_index_lines | str join "\n") | save -f $"($docs_src)/decisions/README.md"
# Modes page
let modes_lines = (["# Operational Modes" ""] | append (
$data.modes | each { |m|
let step_count = $"(char lparen)($m.steps) steps(char rparen)"
$"- **($m.id)** ($step_count): ($m.trigger)"
}
))
($modes_lines | str join "\n") | save -f $"($docs_src)/modes/README.md"
# Crates chapter — one page per workspace member, sourced from //! docs
mut crate_pages = []
if ($data.identity.crates | is-not-empty) {
mkdir $"($docs_src)/crates"
for c in $data.identity.crates {
let full_path = $"($root)/($c.path)"
let module_doc = (read-crate-module-doc $full_path)
let coverage = (count-pub-coverage $full_path)
# Which practice nodes list this crate as first artifact_path?
let c_norm = ($c.path | str replace --regex '/$' "")
let implementing = (
$data.architecture.practices
| where { |p|
let first = ($p.artifact_paths? | default [] | if ($in | is-not-empty) { first } else { "" } | str replace --regex '/$' "")
$first == $c_norm
}
)
# Cargo feature flags from Cargo.toml
let cargo_file = $"($full_path)/Cargo.toml"
let features = if ($cargo_file | path exists) {
let cargo_data = (open $cargo_file)
let feat_map = ($cargo_data | get -o features | default {})
$feat_map | transpose key value | where { |r| $r.key != "default" } | each { |r|
let deps = ($r.value | each { |d| $" - `($d)`" } | str join "\n")
if ($deps | is-not-empty) {
$"- `($r.key)` — enables:\n($deps)"
} else {
$"- `($r.key)`"
}
}
} else { [] }
let coverage_badge = if $coverage.percent >= 80 {
$"✅ ($coverage.percent)% \(($coverage.documented)/($coverage.total) pub items\)"
} else if $coverage.percent >= 50 {
$"⚠️ ($coverage.percent)% \(($coverage.documented)/($coverage.total) pub items\)"
} else {
$"❌ ($coverage.percent)% \(($coverage.documented)/($coverage.total) pub items\)"
}
mut page_lines = [$"# ($c.name)" ""]
if ($module_doc | is-not-empty) {
$page_lines = ($page_lines | append [$module_doc ""])
} else {
$page_lines = ($page_lines | append [$"> ⚠️ No `//!` module doc found in `src/lib.rs`." ""])
}
$page_lines = ($page_lines | append [$"**Doc coverage:** ($coverage_badge)" ""])
if ($features | is-not-empty) {
$page_lines = ($page_lines | append (["## Feature Flags" ""] | append $features | append ""))
}
if ($implementing | is-not-empty) {
let practice_links = ($implementing | each { |p| $"- **($p.id)** — ($p.name)" })
$page_lines = ($page_lines | append (["## Implements" ""] | append $practice_links | append ""))
}
let slug = ($c.name | str replace "--" "-")
let page_path = $"($docs_src)/crates/($slug).md"
($page_lines | str join "\n") | save -f $page_path
$crate_pages = ($crate_pages | append { name: $c.name, slug: $slug })
}
# Crates index
let crates_index = (["# Crates" ""] | append (
$crate_pages | each { |cp| $"- [($cp.name)](($cp.slug).md)" }
))
($crates_index | str join "\n") | save -f $"($docs_src)/crates/README.md"
}
# SUMMARY.md
mut summary = [
"# Summary"
""
"[Introduction](README.md)"
""
"# Architecture"
""
"- [Overview](architecture/overview.md)"
""
"# Decisions"
""
]
for adr in $data.decisions_all {
$summary = ($summary | append $"- [($adr.id)](decisions/($adr.id).md)")
}
$summary = ($summary | append ["" "# Operations" "" "- [Modes](modes/README.md)"])
if ($crate_pages | is-not-empty) {
$summary = ($summary | append ["" "# Crates" "" "- [Overview](crates/README.md)"])
for cp in $crate_pages {
$summary = ($summary | append $" - [($cp.name)](crates/($cp.slug).md)")
}
}
($summary | str join "\n") | save -f $"($docs_src)/SUMMARY.md"
# book.toml
let book_toml = $"[book]
authors = [\"Generated by ontoref\"]
language = \"en\"
multilingual = false
src = \"src\"
title = \"($data.identity.name) Documentation\"
[output.html]
default-theme = \"navy\"
preferred-dark-theme = \"navy\"
"
let book_file = $"($root)/docs/book.toml"
if not ($book_file | path exists) {
$book_toml | save -f $book_file
}
print $" (ansi green)Generated:(ansi reset) ($docs_src)/SUMMARY.md"
print $" (ansi green)Generated:(ansi reset) ($docs_src)/README.md"
print $" (ansi green)Generated:(ansi reset) ($docs_src)/architecture/overview.md"
let adr_count = ($data.decisions_all | length)
print $" (ansi green)Generated:(ansi reset) ($adr_count) ADR pages in ($docs_src)/decisions/"
print $" (ansi green)Generated:(ansi reset) ($docs_src)/modes/README.md"
if ($crate_pages | is-not-empty) {
let crate_count = ($crate_pages | length)
print $" (ansi green)Generated:(ansi reset) ($crate_count) crate pages in ($docs_src)/crates/"
}
# Build if mdbook is available
let has_mdbook = (do { ^which mdbook } | complete | get exit_code) == 0
if $has_mdbook {
print ""
print $" (ansi cyan)Building mdBook...(ansi reset)"
let result = (do { ^mdbook build $"($root)/docs/" } | complete)
if $result.exit_code == 0 {
print $" (ansi green)Book built:(ansi reset) ($root)/docs/book/"
} else {
print $" (ansi yellow)Build failed:(ansi reset) ($result.stderr | str trim)"
print $" (ansi dark_gray)Run manually: mdbook build docs/(ansi reset)"
}
} else {
print ""
print $" (ansi dark_gray)mdbook not found. Install: cargo install mdbook(ansi reset)"
print $" (ansi dark_gray)Then: mdbook build docs/(ansi reset)"
}
}
# ── Public API ───────────────────────────────────────────────────────────────
export def "docs generate" [
--fmt (-f): string = "", # Output format: md | json | yaml | mdbook (short: m j y)
]: nothing -> nothing {
let root = (project-root)
let actor = ($env.ONTOREF_ACTOR? | default "developer")
let raw_fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "md" }
let f = match $raw_fmt {
"j" => "json",
"y" => "yaml",
"m" => "md",
_ => $raw_fmt,
}
let data = (compose-doc-data $root)
match $f {
"json" => { print ($data | to json) },
"yaml" => { print ($data | to yaml) },
"md" => { print (render-md $data) },
"mdbook" => { render-mdbook $data $root },
_ => {
print $" Unknown format: ($f). Available: md | json | yaml | mdbook"
},
}
}
export def "docs formats" []: nothing -> nothing {
print ""
print " Available documentation formats:"
print ""
print $" (ansi cyan)md(ansi reset) Markdown document to stdout (default for humans)"
print $" (ansi cyan)json(ansi reset) Structured JSON to stdout (default for agents)"
print $" (ansi cyan)yaml(ansi reset) YAML to stdout"
print $" (ansi cyan)mdbook(ansi reset) Generates docs/src/ + SUMMARY.md, builds if mdbook installed"
print ""
print $" (ansi dark_gray)Usage: strat docs generate --fmt <format>(ansi reset)"
print $" (ansi dark_gray)Short: strat docs generate -f j(ansi reset)"
print ""
}