bmod.toml
[skill] bmod = "bmod-core-tools" source = "github:bmad-code-org/BMAD-METHOD/skills"
bmad-code-org/bmad-method · GitHub
Coaches the user through writing, updating or validating a document. Use when invoked by skill name only
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add bmad-code-org/bmad-method --skill bmad-facilitate설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
bmod.toml[skill] bmod = "bmod-core-tools" source = "github:bmad-code-org/BMAD-METHOD/skills"
customize.toml# DO NOT EDIT -- overwritten on every update.
#
# Override files:
# {project-root}/_bmad/custom/bmad-facilitate.toml (team)
# {project-root}/_bmad/custom/bmad-facilitate.user.toml (personal)
[workflow]
# --- Configurable below. Overrides merge per BMad structural rules: ---
# scalars: override wins • arrays: append
# arrays-of-tables with `id`: replace matching items, append new ones.
# Runs after customization resolves, before facts, config and greet.
activation_steps_prepend = []
# Runs after greet, before the run begins.
activation_steps_append = []
# Loaded once per document: literal sentences, or `file:` paths and globs under {project-root}.
persistent_facts = []
# Consulted on demand beside web research; each entry names the tool and when to use it.
# "For internal standards, consult corp:kb_search (database='engineering') before web search."
external_sources = []
# Polish passes at finalize, in order, structural before prose: `skill:NAME [args]`, `file:PATH` or plain text.
doc_standards = [
"skill:bmad-review lenses=structure,prose",
]
# Run beside the rubric walker, same prefixes; resolved only when the review runs.
finalize_reviewers = []
# Where finished documents go beyond local files; each entry names the tool, destination and fields.
# "Upload the document to Confluence via corp:confluence_upload (space_key='ENG')."
external_handoffs = []
# Run after close: one instruction, or an array run in order.
on_complete = ""
# Run folder for a template with no `output`; `{active_initiative}` is dropped when none is set.
output = "{output_folder}/{active_initiative}/{id}-{slug}"
# Where templates live, searched in order; the first template with an id wins. To add, replace or
# extend (`extends: <id>`) a template, put a file in the first folder. Each entry is a
# folder, one template file, a GitHub URL (pin a version with @ref) or a sentence naming the tool
# that reaches it. Installed modules' doc-templates/ folders are searched after these. A template
# overrides this skill, so point only at sources you control.
template_libraries = [
"{project-root}/_bmad/custom/facilitate-templates",
"~/.bmad/facilitate-templates",
]
# A CSS file applied to every rendered view, after the default styles; empty uses the default.
style = ""
# "offer", "always" or "never"; a template that sets `view` renders unless "never", and "never" also stops a page a `view` comment asks for.
html_view = "offer"
references/rubric-general.mdRate each strong, adequate, thin or broken, judged against the document's purpose and stakes: 1. Serves its purpose: it does the job it exists for, for the reader it names. 2. Usable: the reader can act on it or enjoy it without this conversation. 3. Earned: no padding, boilerplate or theater; each section pays for its place. 4. Honest: what is unknown or inferred says so; open items fit the stakes. 5. Fit: voice, terms, depth and length suit the reader and the stakes. Then check the template's Done when. Write review-rubric.md: a two-sentence verdict, the three fixes that matter most, then findings by dimension with severity, location and fix.
references/rubric-planning.mdRate each strong, adequate, thin or broken, judged against the document's purpose and stakes: 1. Decision-ready: decisions and trade-offs stated; one clear thesis. 2. Earned: no persona, novelty, boilerplate-NFR or generic-vision theater; no padding. 3. Testable: every requirement can be checked; flag vague adjectives. 4. Honest scope: non-goals stated, inferences tagged, open items fit the stakes. 5. Usable downstream: consistent terms, ids resolve, depth and length fit the stakes. Then check the template's Done when. Write review-rubric.md: a two-sentence verdict, the three fixes that matter most, then findings by dimension with severity, location and fix.
scripts/check_ids.py#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""check-ids — find broken id cross-references and a stale table of contents.
An id is a token like FR-3, UJ-12 or CAP-4. It is defined where it opens a heading, a table
row's first cell, or a table cell, list item or line in bold. An unbolded list item or line
defines it only when punctuation follows (`- FR-3: ...`, `FR-3 (withdrawn)`). An id that opens
a list of ids (`FR-1, FR-2`, `FR-1 to FR-8`) is not a definition. Every other occurrence is a
reference, and a range such as `FR-1 to FR-8` references each id in it. A table most of whose
defining ids were already defined above it, such as a traceability table, only references its ids. Fenced code blocks
and HTML comments are skipped. Definitions in any listed file count for references in all of
them, so one file can cite another's.
A file with a `## Table of Contents` has its links compared to its own headings, using
GitHub's anchors. Every `##` section needs an entry; entries may also link deeper headings.
Prints JSON:
undefined referenced but never defined, where other ids with that prefix are defined
unknown_prefix referenced, and no id with that prefix is defined anywhere; often an outside
name such as UTC-5, sometimes a whole family that was removed
duplicate defined more than once
withdrawn defined as withdrawn, removed, dropped or superseded, yet still referenced
toc missing: `##` sections with no entry; stale: entries whose heading is gone;
out_of_order: tables whose entries do not follow the sections' order
counts per prefix: distinct ids defined and referenced
Locations are `file:line`. Exits 0, or 2 when a file is missing.
"""
from __future__ import annotations
import json
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
ID = r"(?<![A-Za-z0-9-])[A-Z][A-Z0-9]{0,5}-\d+(?![A-Za-z0-9])"
TOKEN = re.compile(ID)
FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})")
COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
QUOTE = re.compile(r"^(?: {0,3}> ?)+")
HEADING = re.compile(r"^ {0,3}#{1,6}[ \t]+")
LIST_ITEM = re.compile(r"^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?")
CONNECTOR = re.compile(rf"^(?:\*\*)?\s*(?:,|/|&|and\b|or\b|to\b|through\b|[–—-])\s*(?:\*\*)?{ID}")
SEPARATOR = re.compile(r"^\s*(?:[:.()/–—]|-\s)")
RANGE = re.compile(rf"({ID})(?:\*\*)?\s*(?:to|through|[–—-])\s*(?:\*\*)?({ID})")
WITHDRAWN = re.compile(r"^[\s*:.)(—–-]*(?:withdrawn|removed|dropped|superseded)\b", re.IGNORECASE)
TITLED_HEADING = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.+?)[ \t#]*$")
TOC_LINK = re.compile(r"\[[^\]]*\]\(#([^)\s]+)\)")
TOC_ANCHOR = "table-of-contents"
MAX_RANGE = 200
def code_mask(lines: list[str]) -> list[bool]:
"""True for each line inside (or fencing) a fenced code block."""
mask, fence = [], None
for line in lines:
m = FENCE_OPEN.match(line)
if fence is None:
if m:
fence = m.group(1)
mask.append(fence is not None)
else:
mask.append(True)
if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not line.strip(" `~"):
fence = None
return mask
def prose_lines(text: str) -> list[str]:
"""The document's lines with fenced code and HTML comments blanked, numbering kept."""
lines = text.replace("\r\n", "\n").split("\n")
lines = ["" if in_code else line for line, in_code in zip(lines, code_mask(lines), strict=True)]
joined = COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), "\n".join(lines))
return joined.split("\n")
def opening_id(text: str, *, bold_only: bool = False, needs_separator: bool = False) -> re.Match | None:
"""The id that opens `text` as a definition, or None."""
bold = text.startswith("**")
if bold_only and not bold:
return None
m = TOKEN.match(text, 2 if bold else 0)
if not m:
return None
rest = text[m.end() :]
if CONNECTOR.match(rest):
return None
if needs_separator and not bold and not SEPARATOR.match(rest):
return None
return m
def is_table_row(line: str) -> bool:
quote = QUOTE.match(line)
return line[quote.end() if quote else 0 :].lstrip().startswith("|")
def definition(line: str) -> tuple[int, str, str] | None:
"""(column, id, text after it) for the id a line defines, or None."""
quote = QUOTE.match(line)
start = quote.end() if quote else 0
body = line[start:]
if body.lstrip().startswith("|"):
offset = start + body.index("|") + 1
for i, cell in enumerate(body.lstrip()[1:].split("|")):
lead = len(cell) - len(cell.lstrip())
m = opening_id(cell.strip(), bold_only=i > 0)
if m:
return offset + lead + m.start(), m.group(0), cell.strip()[m.end() :]
offset += len(cell) + 1
return None
for pattern in (HEADING, LIST_ITEM):
lead = pattern.match(body)
if lead:
m = opening_id(body[lead.end() :], needs_separator=pattern is LIST_ITEM)
return (start + lead.end() + m.start(), m.group(0), body[lead.end() + m.end() :]) if m else None
lead = len(body) - len(body.lstrip())
m = opening_id(body.lstrip(), needs_separator=True)
return (start + lead + m.start(), m.group(0), body.lstrip()[m.end() :]) if m else None
def split_id(token: str) -> tuple[str, int]:
prefix, number = token.rsplit("-", 1)
return prefix, int(number)
def anchor(heading: str) -> str:
"""GitHub's anchor for a heading: lowercase, punctuation dropped, spaces as hyphens."""
return re.sub(r"[^\w\- ]", "", heading.lower()).replace(" ", "-")
def check_toc(name: str, text: str, toc: dict) -> None:
"""Add this file's table-of-contents problems to `toc`."""
lines = prose_lines(text)
headings: list[tuple[int, int, str, str]] = []
seen: Counter[str] = Counter()
for number, line in enumerate(lines, 1):
m = TITLED_HEADING.match(line)
if m:
base = anchor(m.group(2))
headings.append((number, len(m.group(1)), f"{base}-{seen[base]}" if seen[base] else base, m.group(2)))
seen[base] += 1
table = next((i for i, h in enumerate(headings) if h[1] == 2 and h[2] == TOC_ANCHOR), None)
if table is None:
return
start = headings[table][0]
end = headings[table + 1][0] - 1 if table + 1 < len(headings) else len(lines)
links = [
(number, m.group(1)) for number, line in enumerate(lines[start:end], start + 1) for m in TOC_LINK.finditer(line)
]
anchors = {h[2] for i, h in enumerate(headings) if i != table}
sections = [h for i, h in enumerate(headings) if i != table and h[1] == 2]
targets = [target for _, target in links]
for number, _, slug, title in sections:
if slug not in targets:
toc["missing"].setdefault(title, []).append(f"{name}:{number}")
for number, target in links:
if target not in anchors:
toc["stale"].setdefault(target, []).append(f"{name}:{number}")
order = [slug for _, _, slug, _ in sections]
if [t for t in targets if t in order] != [slug for slug in order if slug in targets]:
toc["out_of_order"].append(f"{name}:{start}")
def scan(files: list[tuple[str, str]]) -> dict:
defined: dict[str, list[str]] = defaultdict(list)
referenced: dict[str, list[str]] = defaultdict(list)
withdrawn: set[str] = set()
toc: dict = {"missing": {}, "stale": {}, "out_of_order": []}
table: list[tuple[str, str, str]] = []
def close_table() -> None:
# A table most of whose ids were defined above it is a reference table, such as traceability.
cites = 2 * sum(token in defined for _, token, _ in table) > len(table)
for where, token, rest in table:
if cites:
referenced[token].append(where)
continue
defined[token].append(where)
if WITHDRAWN.match(rest):
withdrawn.add(token)
table.clear()
for name, text in files:
check_toc(name, text, toc)
for number, line in enumerate(prose_lines(text), 1):
where = f"{name}:{number}"
if table and not is_table_row(line):
close_table()
found = definition(line)
if found:
column, token, rest = found
if is_table_row(line):
table.append((where, token, rest))
else:
defined[token].append(where)
if WITHDRAWN.match(rest):
withdrawn.add(token)
for m in TOKEN.finditer(line):
if not (found and m.start() == found[0]):
referenced[m.group(0)].append(where)
for m in RANGE.finditer(line):
(prefix, low), (other, high) = split_id(m.group(1)), split_id(m.group(2))
if prefix == other and 0 < high - low <= MAX_RANGE:
for n in range(low + 1, high):
referenced[f"{prefix}-{n}"].append(where)
close_table()
prefixes = {split_id(token)[0] for token in defined}
undefined, unknown = {}, {}
for token in sorted(referenced, key=split_id):
if token not in defined:
(undefined if split_id(token)[0] in prefixes else unknown)[token] = list(dict.fromkeys(referenced[token]))
counts: dict[str, dict[str, int]] = defaultdict(lambda: {"defined": 0, "referenced": 0})
for token in defined:
counts[split_id(token)[0]]["defined"] += 1
for token in referenced:
counts[split_id(token)[0]]["referenced"] += 1
return {
"undefined": undefined,
"unknown_prefix": unknown,
"duplicate": {t: defined[t] for t in sorted(defined, key=split_id) if len(defined[t]) > 1},
"withdrawn": {
t: list(dict.fromkeys(referenced[t])) for t in sorted(withdrawn, key=split_id) if referenced.get(t)
},
"toc": toc,
"counts": dict(sorted(counts.items())),
}
def main(argv: list[str]) -> int:
if not argv:
print("usage: check_ids.py <document.md> [<companion.md> ...]", file=sys.stderr)
return 2
files = []
for name in argv:
path = Path(name)
if not path.is_file():
print(f"not found: {name}", file=sys.stderr)
return 2
files.append((name, path.read_text(encoding="utf-8")))
print(json.dumps(scan(files), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
scripts/render_view.py#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""render-view — turn a finished markdown document into one self-contained HTML view.
The markdown travels in the page as JSON and is rendered in the browser by marked, with
mermaid drawing every mermaid code block; both load from jsDelivr at pinned versions. With
no network the page still shows the raw markdown in <pre> blocks. Raw HTML in the markdown
is shown as text and javascript:, vbscript: and non-image data: links are dropped.
--layout document the whole document as one readable page (default)
--layout slides the `# ` title is slide 1, then one slide per `## ` section. A bold
first line replaces the section heading as the slide title, and a
trailing blockquote becomes speaker notes, kept out of the slide DOM:
`n` shows them under the slide, `p` opens them in a presenter window.
A `## Table of Contents` section gets no slide.
--layout <file> an .html view file, its markup and inline CSS kept as written. Slots are
attributes: `data-section="Heading"` takes that `## ` section (inside its
`data-body` child when it has one, so a label can sit beside the content),
`data-field="key"` takes a frontmatter value, and `data-rest` takes every
section no slot names. A slot with nothing to show is removed with its
element. With no `data-rest`, a section with no slot is named on stderr.
Frontmatter and <!-- comments --> are stripped. `--style` inlines a .css file after the
default CSS; any other file is ignored with a warning. Prints the output path on success;
exits 2 when the document is missing.
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
MARKED = "https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"
MERMAID = "https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"
FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})")
COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
FRONTMATTER = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*(?:\n|\Z)", re.DOTALL)
H1 = re.compile(r"^# +(.+?)[ \t#]*$")
H2 = re.compile(r"^## +(.+?)[ \t#]*$")
QUOTE = re.compile(r"^ {0,3}> ?")
BOLD_LINE = re.compile(r"^(\*\*|__)(.+)\1[ \t]*$")
LAYOUTS = ("document", "slides")
SLOT_ATTRS = {"data-section", "data-field", "data-rest", "data-body"}
VOID_TAGS = {
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr",
} # fmt: skip
def code_mask(lines: list[str]) -> list[bool]:
"""True for each line inside (or fencing) a fenced code block."""
mask, fence = [], None
for line in lines:
m = FENCE_OPEN.match(line)
if fence is None:
if m:
fence = m.group(1)
mask.append(fence is not None)
else:
mask.append(True)
if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not line.strip(" `~"):
fence = None
return mask
def strip_comments(text: str) -> str:
"""Remove HTML comments outside fenced code blocks."""
lines = text.split("\n")
out, prose = [], []
for line, in_code in zip(lines, code_mask(lines), strict=True):
if in_code:
if prose:
out.append(COMMENT.sub("", "\n".join(prose)))
prose = []
out.append(line)
else:
prose.append(line)
if prose:
out.append(COMMENT.sub("", "\n".join(prose)))
return "\n".join(out)
def split_frontmatter(text: str) -> tuple[str, str]:
"""Return (frontmatter block without fences, body). No frontmatter: ("", text)."""
m = FRONTMATTER.match(text)
if not m:
return "", text
return m.group(1), text[m.end() :]
def frontmatter_value(frontmatter: str, key: str) -> str:
m = re.search(rf"^{re.escape(key)}:[ \t]*(.*?)[ \t]*$", frontmatter, re.MULTILINE)
if not m:
return ""
value = m.group(1)
if len(value) >= 2 and value[0] == value[-1] == '"':
return value[1:-1].replace('\\"', '"')
if len(value) >= 2 and value[0] == value[-1] == "'":
return value[1:-1].replace("''", "'")
return value
def frontmatter_title(frontmatter: str) -> str:
return frontmatter_value(frontmatter, "title")
def clean(text: str) -> tuple[str, str]:
"""Strip comments. Returns (frontmatter block, markdown body)."""
frontmatter, body = split_frontmatter(strip_comments(text.replace("\r\n", "\n")).lstrip())
return frontmatter, body.strip("\n") + "\n"
def prepare(text: str) -> tuple[str, str]:
"""Strip comments and frontmatter. Returns (title from frontmatter, markdown body)."""
frontmatter, body = clean(text)
return frontmatter_title(frontmatter), body
def first_h1(body: str) -> str:
lines = body.split("\n")
for line, in_code in zip(lines, code_mask(lines), strict=True):
m = not in_code and H1.match(line)
if m:
return m.group(1)
return ""
def split_notes(lines: list[str]) -> tuple[list[str], list[str]]:
"""Split a trailing blockquote off as speaker notes. Returns (content lines, notes lines)."""
end = len(lines)
while end and not lines[end - 1].strip():
end -= 1
start = end
while start and (QUOTE.match(lines[start - 1]) or not lines[start - 1].strip()):
start -= 1
while start < end and not lines[start].strip():
start += 1
if start == end:
return lines[:end], []
return lines[:start], [QUOTE.sub("", line) for line in lines[start:end]]
def make_slide(heading: str, lines: list[str], *, title_slide: bool = False) -> dict:
content, notes = split_notes(lines)
while content and not content[0].strip():
content.pop(0)
title = heading
m = not title_slide and content and BOLD_LINE.match(content[0].strip())
if m and m.group(1) not in m.group(2):
title = m.group(2).strip()
content = content[1:]
return {
"section": heading,
"title": title,
"body": "\n".join(content).strip("\n"),
"notes": "\n".join(notes).strip("\n"),
"title_slide": title_slide,
}
def h2_groups(body: str) -> list[tuple[str | None, list[str]]]:
"""The lines before the first `## ` under None, then each `## ` heading with its lines."""
lines = body.split("\n")
groups: list[tuple[str | None, list[str]]] = [(None, [])]
for line, in_code in zip(lines, code_mask(lines), strict=True):
m = not in_code and H2.match(line)
if m:
groups.append((m.group(1), []))
else:
groups[-1][1].append(line)
return groups
def split_slides(body: str, fallback_title: str = "") -> list[dict]:
"""One slide for the part before the first `## ` (the `# ` title), then one per `## ` section."""
groups = h2_groups(body)
slides = []
_, preamble = groups[0]
if any(line.strip() for line in preamble):
heading = fallback_title
mask = code_mask(preamble)
for i, line in enumerate(preamble):
m = not mask[i] and H1.match(line)
if m:
heading = m.group(1)
preamble = preamble[:i] + preamble[i + 1 :]
break
slides.append(make_slide(heading, preamble, title_slide=True))
slides.extend(make_slide(heading, group) for heading, group in groups[1:] if heading.lower() != "table of contents")
return slides
class SlotElements(HTMLParser):
"""Finds each element with a slot attribute: `start` and `end` around it, `inner` and `close` around its contents."""
def __init__(self, page: str):
super().__init__(convert_charrefs=False)
self.page = page
self.line_starts = [0] + [m.end() for m in re.finditer("\n", page)]
self.stack: list[dict] = []
self.found: list[dict] = []
self.feed(page)
self.close()
def position(self) -> int:
line, column = self.getpos()
return self.line_starts[line - 1] + column
def handle_starttag(self, tag, attrs):
if tag not in VOID_TAGS:
start = self.position()
self.stack.append(
{"tag": tag, "attrs": dict(attrs), "start": start, "inner": start + len(self.get_starttag_text())}
)
def handle_startendtag(self, tag, attrs):
pass
def handle_endtag(self, tag):
for i in range(len(self.stack) - 1, -1, -1):
if self.stack[i]["tag"] == tag:
element = self.stack[i]
del self.stack[i:]
if SLOT_ATTRS & element["attrs"].keys():
close = self.position()
self.found.append({**element, "close": close, "end": self.page.index(">", close) + 1})
return
def fill_view(view: str, markdown_text: str) -> tuple[str, list[str], list[str]]:
"""Fill a view's slots. Returns (page, markdown for the browser to render, sections with no slot)."""
frontmatter, body = clean(markdown_text)
sections = {
heading.lower(): (heading, "\n".join(lines).strip())
for heading, lines in h2_groups(body)[1:]
if heading.lower() != "table of contents"
}
elements = SlotElements(view).found
pieces: list[str] = []
edits: list[tuple[int, int, str]] = []
def fill(element: dict, text: str) -> None:
if text:
pieces.append(text)
slot = f'<div data-md="{len(pieces) - 1}">{fallback(text)}</div>'
edits.append((element["inner"], element["close"], slot))
else:
edits.append((element["start"], element["end"], ""))
claimed = set()
for element in elements:
key = element["attrs"].get("data-section")
if key is None:
continue
claimed.add(key.strip().lower())
text = sections.get(key.strip().lower(), ("", ""))[1]
inside = (
e
for e in elements
if "data-body" in e["attrs"] and element["start"] < e["start"] and e["end"] < element["end"]
)
fill(next(inside, element) if text else element, text)
for element in elements:
key = element["attrs"].get("data-field")
if key is None:
continue
value = frontmatter_value(frontmatter, key.strip()) or (first_h1(body) if key.strip() == "title" else "")
if value:
edits.append((element["inner"], element["close"], html.escape(value)))
else:
edits.append((element["start"], element["end"], ""))
left = [(heading, text) for key, (heading, text) in sections.items() if key not in claimed and text]
rest = [element for element in elements if "data-rest" in element["attrs"]]
for element in rest[:1]:
fill(element, "\n\n".join(f"## {heading}\n\n{text}" for heading, text in left))
# An edit inside one that replaces or removes its surroundings has nothing left to change.
edits.sort(key=lambda edit: (edit[0], -edit[1]))
kept, reach = [], 0
for edit in edits:
if edit[0] >= reach:
kept.append(edit)
reach = edit[1]
for start, end, text in reversed(kept):
view = view[:start] + text + view[end:]
return view, pieces, [] if rest else [heading for heading, _ in left]
def insert_before(page: str, closing_tag: str, text: str) -> str:
"""`text` before the last `closing_tag`, or at the end of a page without one."""
at = page.lower().rfind(closing_tag)
return page + text if at < 0 else page[:at] + text + page[at:]
def load_style(path: str | None) -> str:
"""CSS text to inline after the default, or "" with a warning for anything that is not a .css file."""
if not path:
return ""
p = Path(path)
if p.suffix.lower() != ".css":
print(f"render_view: ignoring --style {path}: not a .css file", file=sys.stderr)
return ""
try:
return p.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as e:
print(f"render_view: ignoring --style {path}: {e}", file=sys.stderr)
return ""
def script_json(value) -> str:
"""JSON safe to place inside a <script> element."""
return json.dumps(value, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
def fallback(text: str) -> str:
return f'<pre class="fallback">{html.escape(text)}</pre>' if text else ""
def scripts(payload: dict) -> str:
return SCRIPTS.format(payload=script_json(payload), marked=MARKED, mermaid=MERMAID, js=JS)
def render_into_view(markdown_text: str, view: str, style_css: str = "", view_name: str = "the view") -> str:
page, pieces, unplaced = fill_view(view, markdown_text)
if unplaced:
print(f"render_view: no slot in {view_name} for: {', '.join(unplaced)}", file=sys.stderr)
page = insert_before(page, "</head>", f"<style>\n{VIEW_CSS}\n{style_css}\n</style>\n")
return insert_before(page, "</body>", scripts({"layout": "view", "slots": pieces}))
def render(markdown_text: str, layout: str, style_css: str = "", name: str = "document") -> str:
fm_title, body = prepare(markdown_text)
title = fm_title or first_h1(body) or name
if layout == "slides":
slides = split_slides(body, title)
sections = "\n".join(
f'<section class="slide{" title-slide" if s["title_slide"] else ""}" data-n="{i + 1}">'
f'<div class="slide-inner"><h1 class="slide-title">{html.escape(s["title"])}</h1>'
f'<div class="slide-body">{fallback(s["body"])}</div></div></section>'
for i, s in enumerate(slides)
)
main = (
f'<main id="deck">\n{sections}\n</main>\n<aside id="notes-panel" hidden></aside>\n<div id="counter"></div>'
)
payload = {"layout": "slides", "slides": slides}
else:
main = f'<main id="doc">{fallback(body)}</main>'
payload = {"layout": "document", "markdown": body}
custom = f"<style>\n{style_css}\n</style>\n" if style_css else ""
return PAGE.format(
title=html.escape(title),
layout=layout,
css=CSS,
custom=custom,
main=main,
scripts=scripts(payload),
)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Render a markdown document as a self-contained HTML view.")
ap.add_argument("document", help="the markdown document")
ap.add_argument("--layout", default="document", help="document, slides, or an .html view file")
ap.add_argument("--style", help="a .css file to inline; any other file is ignored")
ap.add_argument("--out", help="output path (default: the document path with .html)")
args = ap.parse_args(argv)
doc = Path(args.document)
if not doc.is_file():
print(f"render_view: {doc} not found", file=sys.stderr)
return 2
out = Path(args.out) if args.out else doc.with_suffix(".html")
text = doc.read_text(encoding="utf-8-sig")
if args.layout in LAYOUTS:
page = render(text, args.layout, load_style(args.style), doc.stem)
else:
view = Path(args.layout)
if view.suffix.lower() != ".html" or not view.is_file():
print(f"render_view: --layout {view}: not a layout or an .html view file", file=sys.stderr)
return 2
page = render_into_view(text, view.read_text(encoding="utf-8-sig"), load_style(args.style), view.name)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(page, encoding="utf-8")
print(out)
return 0
CSS = """
:root {
color-scheme: light dark;
--bg: #ffffff; --fg: #1f2328; --muted: #59636e; --accent: #0b5cad;
--rule: #d8dee4; --code-bg: #f3f5f7; --quote: #59636e;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #15181d; --fg: #e3e6ea; --muted: #9aa3ad; --accent: #6cb0ff;
--rule: #30363d; --code-bg: #1e232a; --quote: #9aa3ad; }
}
@media print {
:root { --bg: #ffffff; --fg: #000000; --muted: #444444; --accent: #000000;
--rule: #bbbbbb; --code-bg: #f3f3f3; --quote: #444444; }
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--fg); }
body { font: 17px/1.6 var(--font); -webkit-text-size-adjust: 100%; }
a { color: var(--accent); }
h1, h2, h3, h4 { line-height: 1.25; margin: 1.6em 0 0.5em; }
h1 { font-size: 2em; margin-top: 0; }
h2 { font-size: 1.45em; padding-bottom: 0.25em; border-bottom: 1px solid var(--rule); }
h3 { font-size: 1.2em; }
p, ul, ol, table, pre, blockquote { margin: 0 0 1em; }
li > ul, li > ol { margin: 0.25em 0; }
code { font: 0.9em var(--mono); background: var(--code-bg); padding: 0.1em 0.35em; border-radius: 4px; }
pre { background: var(--code-bg); padding: 0.9em 1em; border-radius: 6px; overflow-x: auto; }
pre code { background: none; padding: 0; font-size: 0.85em; }
pre.fallback { white-space: pre-wrap; word-wrap: break-word; font: 0.9em/1.55 var(--mono); }
blockquote { margin-left: 0; padding: 0.1em 1em; border-left: 4px solid var(--rule); color: var(--quote); }
table { border-collapse: collapse; display: block; overflow-x: auto; }
th, td { border: 1px solid var(--rule); padding: 0.4em 0.7em; text-align: left; vertical-align: top; }
th { background: var(--code-bg); }
hr { border: 0; border-top: 1px solid var(--rule); margin: 2em 0; }
img, svg { max-width: 100%; }
.mermaid { text-align: center; margin: 0 0 1em; }
#doc { max-width: 46rem; margin: 0 auto; padding: 3rem 1.25rem 4rem; }
body.slides { overflow: hidden; }
#deck { position: fixed; inset: 0; }
.slide { position: absolute; inset: 0; visibility: hidden; overflow: auto;
display: flex; padding: 5vh 7vw;
font-size: clamp(16px, 1.25vw + 1.1vh, 34px); }
.slide.active { visibility: visible; }
.slide-inner { width: 100%; max-width: 62em; margin: auto; }
.slide-title { font-size: 1.75em; margin: 0 0 0.8em; border: 0; }
.title-slide .slide-inner { text-align: center; }
.title-slide .slide-title { font-size: 2.4em; }
.slide-body > :last-child { margin-bottom: 0; }
.slide-body h2, .slide-body h3 { border: 0; }
.slide-body .mermaid svg { max-height: 60vh; }
#counter { position: fixed; right: 1.2em; bottom: 0.8em; font-size: 14px; color: var(--muted); }
#notes-panel { position: fixed; left: 0; right: 0; bottom: 0; max-height: 35vh; overflow: auto;
padding: 1em 7vw 2.2em; background: var(--code-bg); border-top: 1px solid var(--rule); font-size: 18px; }
#notes-panel[hidden] { display: none; }
@media print {
#doc { max-width: none; padding: 0; }
pre, blockquote, table, .mermaid { break-inside: avoid; }
h2, h3 { break-after: avoid; }
body.slides { overflow: visible; }
@page { size: landscape; margin: 0.5in; }
#deck { position: static; }
.slide { position: relative; inset: auto; visibility: visible; overflow: visible;
height: 7in; padding: 0.4in 0.6in; font-size: 18pt; break-after: page; break-inside: avoid; }
.slide:last-child { break-after: auto; }
#counter, #notes-panel { display: none !important; }
}
"""
VIEW_CSS = "pre.fallback { white-space: pre-wrap; word-wrap: break-word; }"
JS = r"""
(function () {
var data = JSON.parse(document.getElementById('view-data').textContent);
var hasMarked = false;
function md(text) { return hasMarked ? window.marked.parse(text) : null; }
function inline(text) { return hasMarked ? window.marked.parseInline(text) : null; }
function load(src) {
return new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = src; s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
}
function drawDiagrams() {
var blocks = document.querySelectorAll('pre > code.language-mermaid');
if (!blocks.length) return;
var nodes = [];
blocks.forEach(function (code) {
var div = document.createElement('div');
div.className = 'mermaid';
div.textContent = code.textContent;
code.parentNode.replaceWith(div);
nodes.push(div);
});
load(MERMAID_SRC).then(function () {
var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
window.mermaid.initialize({ startOnLoad: false, theme: dark ? 'dark' : 'default', securityLevel: 'strict' });
return window.mermaid.run({ nodes: nodes, suppressErrors: true });
}).catch(function () {
nodes.forEach(function (div) {
if (div.querySelector('svg')) return;
var pre = document.createElement('pre');
pre.className = 'fallback';
pre.textContent = div.textContent;
div.replaceWith(pre);
});
});
}
function renderDocument() {
var html = md(data.markdown);
if (html === null) return;
var doc = document.getElementById('doc');
doc.innerHTML = html;
// marked adds no heading ids, and table-of-contents links need GitHub's anchors.
var seen = {};
doc.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(function (h) {
var base = h.textContent.trim().toLowerCase().replace(/[^\p{L}\p{N}_\- ]/gu, '').replace(/ /g, '-');
h.id = seen[base] ? base + '-' + seen[base] : base;
seen[base] = (seen[base] || 0) + 1;
});
}
function renderSlides() {
document.querySelectorAll('.slide').forEach(function (el, i) {
var s = data.slides[i];
var t = inline(s.title);
if (t !== null) el.querySelector('.slide-title').innerHTML = t;
var b = md(s.body);
if (b !== null) el.querySelector('.slide-body').innerHTML = b;
});
}
function notesHtml(i) {
var s = data.slides[i];
if (!s.notes) return '<p class="empty">No notes for this slide.</p>';
var h = md(s.notes);
if (h !== null) return h;
var pre = document.createElement('pre');
pre.className = 'fallback';
pre.textContent = s.notes;
return pre.outerHTML;
}
function startDeck() {
var slides = document.querySelectorAll('.slide');
var total = slides.length;
var current = 0;
var panel = document.getElementById('notes-panel');
var counter = document.getElementById('counter');
var presenter = null;
document.body.classList.add('slides');
function fromHash() {
var m = /^#(\d+)$/.exec(location.hash);
if (!m) return null;
var n = parseInt(m[1], 10);
return n >= 1 && n <= total ? n - 1 : null;
}
function updatePresenter() {
if (!presenter || presenter.closed) return;
var d = presenter.document;
var s = data.slides[current];
d.getElementById('pos').textContent = (current + 1) + ' / ' + total;
d.getElementById('section').textContent = s.section && s.section !== s.title ? s.section : '';
d.getElementById('title').innerHTML = slides[current].querySelector('.slide-title').innerHTML;
d.getElementById('notes').innerHTML = notesHtml(current);
d.getElementById('next').textContent = current + 1 < total ? 'Next: ' + data.slides[current + 1].title : 'Last slide';
}
function show(i) {
if (!total) return;
current = Math.max(0, Math.min(total - 1, i));
slides.forEach(function (el, j) { el.classList.toggle('active', j === current); });
counter.textContent = (current + 1) + ' / ' + total;
if (!panel.hidden) panel.innerHTML = notesHtml(current);
// Some browsers refuse replaceState on file:// pages.
try { if (location.hash !== '#' + (current + 1)) history.replaceState(null, '', '#' + (current + 1)); } catch (e) {}
updatePresenter();
}
function toggleNotes() {
panel.hidden = !panel.hidden;
if (!panel.hidden) panel.innerHTML = notesHtml(current);
}
function openPresenter() {
if (presenter && !presenter.closed) { presenter.focus(); return; }
presenter = window.open('', 'deck-presenter', 'width=760,height=600');
if (!presenter) { toggleNotes(); return; }
var d = presenter.document;
d.open();
d.write(PRESENTER_HTML);
d.close();
d.addEventListener('keydown', onKey);
updatePresenter();
}
function onKey(e) {
if (e.ctrlKey || e.metaKey || e.altKey) return;
var k = e.key;
if (k === 'ArrowRight' || k === 'ArrowDown' || k === 'PageDown' || (k === ' ' && !e.shiftKey)) show(current + 1);
else if (k === 'ArrowLeft' || k === 'ArrowUp' || k === 'PageUp' || (k === ' ' && e.shiftKey)) show(current - 1);
else if (k === 'Home') show(0);
else if (k === 'End') show(total - 1);
else if (k === 'n' || k === 'N') toggleNotes();
else if (k === 'p' || k === 'P') openPresenter();
else if (k === 'f' || k === 'F') {
if (document.fullscreenElement) document.exitFullscreen();
else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen();
} else return;
e.preventDefault();
}
document.addEventListener('keydown', onKey);
document.getElementById('deck').addEventListener('click', function (e) {
if (e.target.closest('a') || String(window.getSelection())) return;
show(e.clientX < window.innerWidth / 3 ? current - 1 : current + 1);
});
window.addEventListener('hashchange', function () {
var n = fromHash();
if (n !== null && n !== current) show(n);
});
window.addEventListener('beforeunload', function () { if (presenter && !presenter.closed) presenter.close(); });
var start = fromHash();
show(start === null ? 0 : start);
}
function renderSlots() {
document.querySelectorAll('[data-md]').forEach(function (el) {
var h = md(data.slots[+el.getAttribute('data-md')]);
if (h !== null) el.innerHTML = h;
});
}
function renderAll() {
if (data.layout === 'slides') renderSlides();
else if (data.layout === 'view') renderSlots();
else renderDocument();
if (hasMarked) drawDiagrams();
}
if (data.layout === 'slides') startDeck();
// A document can carry text from outside sources, so raw HTML shows as text and script URLs are dropped.
function escapeHtml(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function unsafeUrl(href) {
var h = String(href || '').replace(/[\s\u0000-\u001f]/g, '').toLowerCase();
return /^(javascript|vbscript):/.test(h) || (/^data:/.test(h) && !/^data:image\/(png|gif|jpe?g|webp);/.test(h));
}
function harden() {
window.marked.use({ renderer: {
html: function (h) { return escapeHtml(h); },
link: function (href, title, text) { return unsafeUrl(href) ? text : false; },
image: function (href, title, text) { return unsafeUrl(href) ? escapeHtml(text) : false; }
} });
}
load(MARKED_SRC).then(function () { hasMarked = !!window.marked; if (hasMarked) harden(); }, function () {}).then(renderAll);
})();
"""
PRESENTER = """<!doctype html><html><head><meta charset="utf-8"><title>Speaker notes</title><style>
:root { color-scheme: light dark; --bg: #ffffff; --fg: #1f2328; --muted: #59636e; }
@media (prefers-color-scheme: dark) { :root { --bg: #15181d; --fg: #e3e6ea; --muted: #9aa3ad; } }
body { margin: 0; padding: 1.2em 1.6em; background: var(--bg); color: var(--fg);
font: 20px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
header { display: flex; justify-content: space-between; color: var(--muted); font-size: 15px; }
h1 { font-size: 1.3em; margin: 0.4em 0 0.8em; }
#next { color: var(--muted); font-size: 15px; border-top: 1px solid var(--muted); padding-top: 0.6em; margin-top: 1.5em; }
.empty { color: var(--muted); }
pre { white-space: pre-wrap; font: inherit; }
</style></head><body><header><span id="section"></span><span id="pos"></span></header>
<h1 id="title"></h1><div id="notes"></div><div id="next"></div></body></html>"""
PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>{css}</style>
{custom}</head>
<body class="layout-{layout}">
{main}
{scripts}</body>
</html>
"""
SCRIPTS = """<script type="application/json" id="view-data">{payload}</script>
<script>
var MARKED_SRC = "{marked}";
var MERMAID_SRC = "{mermaid}";
var PRESENTER_HTML = {presenter};
{js}
</script>
""".replace("{presenter}", script_json(PRESENTER).replace("{", "{{").replace("}", "}}"))
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.exit(main())
scripts/tests/test_check_ids.py# /// script
# requires-python = ">=3.11"
# dependencies = ["pytest>=8.0"]
# ///
"""Tests for check_ids.py. Run: uv run --with pytest pytest scripts/tests/test_check_ids.py
Which positions define an id and which reference it, ranges, skipped code and comments, how
companion files share definitions, the report's sections, and the exit code for a missing file.
"""
import importlib.util
import json
import sys
from pathlib import Path
_SPEC = importlib.util.spec_from_file_location("check_ids", Path(__file__).resolve().parent.parent / "check_ids.py")
check_ids = importlib.util.module_from_spec(_SPEC)
sys.modules["check_ids"] = check_ids
_SPEC.loader.exec_module(check_ids)
DOC = """# PRD
**UJ-1: Mara answers.** She replies (FR-1).
### F-1. Prompts
- **FR-1**: A member is prompted.
- FR-2: A member can answer.
- FR-2 holds on mobile.
1. FR-3. A member can flag a blocker.
| Id | Risk |
|---|---|
| R-1 | Answers are late. |
| Phase | **M-1, end Q1:** prompts live |
SM-1: Answers rise. Validates FR-1 to FR-3 and FR-9.
"""
def report(*texts: str) -> dict:
return check_ids.scan([(f"doc{i}.md", text) for i, text in enumerate(texts)])
def defined(line: str) -> str | None:
found = check_ids.definition(line)
return found and found[1]
def test_definition_positions():
assert defined("### FR-3 Prompts") == "FR-3"
assert defined("**FR-3** text") == "FR-3"
assert defined("**D-1 Approve this charter** as the mandate") == "D-1"
assert defined("- FR-3: text") == "FR-3"
assert defined("- **FR-3**: text") == "FR-3"
assert defined("2. FR-3. text") == "FR-3"
assert defined("- [ ] FR-3: text") == "FR-3"
assert defined("> - FR-3: quoted") == "FR-3"
assert defined("| FR-3 | text |") == "FR-3"
assert defined("| Wave 1 | text | **M-2, end Q3:** live |") == "M-2"
assert defined("FR-3 (withdrawn)") == "FR-3"
def test_reference_positions():
assert defined("Realizes UJ-3.") is None
assert defined(" - SM-1 can be computed from the reports.") is None
assert defined("FR-3 depends on UJ-1.") is None
assert defined("| Finance | Tolerance (OQ-1) |") is None
assert defined("| Wave 1 | IN-1 live |") is None
def test_an_id_opening_a_list_of_ids_is_not_a_definition():
assert defined("- FR-1 to FR-8 are in scope.") is None
assert defined("- **FR-1, FR-2**: grouped") is None
assert defined("FR-1–FR-8: in scope") is None
assert defined("| FR-1 / FR-2 | both |") is None
def test_planted_undefined_reference_found():
result = report(DOC)
assert result["undefined"] == {"FR-9": ["doc0.md:17"]}
assert result["duplicate"] == {}
def test_counts_per_prefix():
counts = report(DOC)["counts"]
assert counts["FR"] == {"defined": 3, "referenced": 4}
assert counts["M"] == {"defined": 1, "referenced": 0}
assert counts["UJ"] == {"defined": 1, "referenced": 0}
def test_range_references_each_id_in_it():
result = report("- **BR-1**: a\n- **BR-2**: b\n- **BR-4**: d\n\nUnder BR-1 to BR-4.\n")
assert result["undefined"] == {"BR-3": ["doc0.md:5"]}
def test_outside_names_reported_apart_from_undefined():
result = report("- **FR-1**: Prompted at 9:00 in UTC-5.\n")
assert result["undefined"] == {}
assert result["unknown_prefix"] == {"UTC-5": ["doc0.md:1"]}
def test_code_and_comments_skipped_with_line_numbers_kept():
text = "- **FR-1**: a\n\n```\nFR-7 in code\n```\n<!-- FR-8\nin a comment -->\nCites FR-9.\n"
result = report(text)
assert result["undefined"] == {"FR-9": ["doc0.md:8"]}
def test_companion_definitions_count_for_all_files():
doc = "- **FR-1**: a\n"
addendum = "Expands FR-1 and FR-2.\n"
result = report(doc, addendum)
assert result["undefined"] == {"FR-2": ["doc1.md:1"]}
def test_duplicate_definitions_across_files():
result = report("- **FR-1**: a\n", "### FR-1 again\n")
assert result["duplicate"] == {"FR-1": ["doc0.md:1", "doc1.md:1"]}
def test_traceability_table_references_ids_defined_above_it():
text = (
"## Business requirements\n\n| Id | Requirement |\n|---|---|\n| BR-1 | a |\n| BR-2 | b |\n\n"
"## Traceability\n\n| Id | PRD line | Test |\n|---|---|---|\n| BR-1 | | |\n| BR-2 | | |\n| BR-3 | | |\n"
)
result = report(text)
assert result["duplicate"] == {}
assert result["undefined"] == {"BR-3": ["doc0.md:14"]}
def test_duplicate_row_in_a_defining_table_still_found():
text = "| Id | Requirement |\n|---|---|\n| BR-1 | a |\n| BR-2 | b |\n\n| Id | Again |\n|---|---|\n| BR-2 | c |\n| BR-4 | d |\n"
assert report(text)["duplicate"] == {"BR-2": ["doc0.md:4", "doc0.md:8"]}
def test_references_to_withdrawn_items():
text = "- **OQ-7.** Withdrawn: met by FR-1.\n- **OQ-8.** Open.\n- **FR-1**: a (OQ-7, OQ-8)\n"
assert report(text)["withdrawn"] == {"OQ-7": ["doc0.md:3"]}
TOC = """# Doc
## Table of Contents
- [Why it matters](#why-it-matters): the problem
- [Point](#point): first
- [Point](#point-1): second
- [Detail](#detail): one part
## Why it matters?
## Point
### Detail
## Point
"""
def test_table_of_contents_in_step_with_headings():
assert report(TOC)["toc"] == {"missing": {}, "stale": {}, "out_of_order": []}
def test_table_of_contents_section_with_no_entry():
toc = report(TOC + "\n## Risks\n")["toc"]
assert toc["missing"] == {"Risks": ["doc0.md:18"]}
def test_table_of_contents_entry_for_a_removed_heading():
toc = report(TOC.replace("## Why it matters?", "## Reasons"))["toc"]
assert toc["stale"] == {"why-it-matters": ["doc0.md:5"]}
assert toc["missing"] == {"Reasons": ["doc0.md:10"]}
def test_table_of_contents_out_of_order():
swapped = TOC.replace(
"- [Point](#point): first\n- [Point](#point-1): second", "- [Point](#point-1): second\n- [Point](#point): first"
)
assert report(swapped)["toc"]["out_of_order"] == ["doc0.md:3"]
def test_document_without_a_table_of_contents_has_no_toc_hits():
assert report(DOC)["toc"] == {"missing": {}, "stale": {}, "out_of_order": []}
def test_linked_table_of_contents_entries_define_no_ids():
text = "## Table of Contents\n\n- [FR-1: Login](#fr-1-login): sign in\n\n## FR-1: Login\n"
assert report(text)["duplicate"] == {}
def test_main_prints_json(tmp_path, capsys):
doc = tmp_path / "prd.md"
doc.write_text(DOC, encoding="utf-8")
assert check_ids.main([str(doc)]) == 0
assert json.loads(capsys.readouterr().out)["undefined"] == {"FR-9": [f"{doc}:17"]}
def test_missing_file_exits_2(tmp_path, capsys):
doc = tmp_path / "prd.md"
doc.write_text(DOC, encoding="utf-8")
assert check_ids.main([str(doc), str(tmp_path / "absent.md")]) == 2
captured = capsys.readouterr()
assert captured.out == ""
assert "not found" in captured.err
scripts/tests/test_render_view.py# /// script
# requires-python = ">=3.11"
# dependencies = ["pytest>=8.0"]
# ///
"""Tests for render_view.py. Run: uv run --with pytest pytest scripts/tests/test_render_view.py
The deterministic half of the view: what is stripped before rendering, the page title, how a
deck splits into slides with titles and speaker notes, how a view file's slots fill, which style
files are inlined, where the page is written, and the exit code for a missing document. Browser
behavior is not tested.
"""
import importlib.util
import json
import re
import sys
from pathlib import Path
_SPEC = importlib.util.spec_from_file_location("render_view", Path(__file__).resolve().parent.parent / "render_view.py")
render_view = importlib.util.module_from_spec(_SPEC)
sys.modules["render_view"] = render_view
_SPEC.loader.exec_module(render_view)
DECK = """---
title: "Measure first"
template: pitch-deck
view: slides
---
<!-- purpose: Move the room to measure first. -->
# Measure first
A talk for the meetup.
## Problem
<!-- required. Whose pain. -->
**CI took 40 minutes.**
- 300 engineers
> Ours was 40 minutes.
>
> Keep your hand up.
## Open questions
- Which tools?
> A quote that is the slide's content, mid-slide.
Closing line.
"""
def payload(page: str) -> dict:
return json.loads(re.search(r'id="view-data">(.*?)</script>', page, re.DOTALL).group(1))
def test_comments_and_frontmatter_stripped():
title, body = render_view.prepare(DECK)
assert title == "Measure first"
assert "<!--" not in body and "required. Whose pain" not in body
assert "template: pitch-deck" not in body
assert body.startswith("# Measure first")
def test_comments_inside_code_fences_kept():
text = "# T\n\n```html\n<!-- keep me -->\n```\n\n<!-- drop me -->\n"
_, body = render_view.prepare(text)
assert "<!-- keep me -->" in body
assert "drop me" not in body
def test_title_quotes_unwrapped():
assert render_view.frontmatter_title('title: "A \\"quoted\\" title"') == 'A "quoted" title'
assert render_view.frontmatter_title("title: 'It''s here'") == "It's here"
assert render_view.frontmatter_title("title: plain") == "plain"
assert render_view.frontmatter_title("template: x") == ""
def test_page_title_falls_back_to_h1_then_filename():
assert "<title>From the heading</title>" in render_view.render("# From the heading\n\ntext\n", "document")
assert "<title>my-doc</title>" in render_view.render("no heading\n", "document", name="my-doc")
assert "<title>A & B</title>" in render_view.render('---\ntitle: "A & B"\n---\n# x\n', "document")
def test_slides_split_on_h2_with_h1_first():
_, body = render_view.prepare(DECK)
slides = render_view.split_slides(body)
assert [s["section"] for s in slides] == ["Measure first", "Problem", "Open questions"]
assert slides[0]["title_slide"] and slides[0]["title"] == "Measure first"
assert slides[0]["body"] == "A talk for the meetup."
def test_table_of_contents_gets_no_slide():
text = "# T\n\n## Table of Contents\n\n- [Problem](#problem)\n\n## Problem\n\nPain.\n"
assert [s["section"] for s in render_view.split_slides(text)] == ["T", "Problem"]
def test_bold_first_line_becomes_title_and_heading_is_dropped():
_, body = render_view.prepare(DECK)
problem = render_view.split_slides(body)[1]
assert problem["title"] == "CI took 40 minutes."
assert problem["body"] == "- 300 engineers"
def test_heading_stays_title_without_bold_line():
slides = render_view.split_slides("## Team\n\n**Ana** and **Bo** lead it.\n")
assert slides[0]["title"] == "Team"
assert slides[0]["body"] == "**Ana** and **Bo** lead it."
def test_trailing_blockquote_becomes_notes():
_, body = render_view.prepare(DECK)
problem = render_view.split_slides(body)[1]
assert problem["notes"] == "Ours was 40 minutes.\n\nKeep your hand up."
assert ">" not in problem["body"]
def test_blockquote_before_more_content_is_not_notes():
_, body = render_view.prepare(DECK)
last = render_view.split_slides(body)[2]
assert last["notes"] == ""
assert "> A quote that is the slide's content" in last["body"]
def test_h2_inside_code_fence_does_not_split():
slides = render_view.split_slides("## One\n\n```md\n## not a slide\n```\n")
assert len(slides) == 1
assert "## not a slide" in slides[0]["body"]
def test_notes_stay_out_of_slide_markup():
page = render_view.render(DECK, "slides")
markup = page.split('id="view-data"')[0]
assert "Ours was 40 minutes" not in markup
assert payload(page)["slides"][1]["notes"].startswith("Ours was 40 minutes")
def test_embedded_markdown_cannot_close_the_script():
page = render_view.render("# T\n\n</script><script>alert(1)</script>\n", "document")
data_block = re.search(r'id="view-data">(.*?)</script>', page, re.DOTALL).group(1)
assert "</script" not in data_block
assert payload(page)["markdown"].startswith("# T")
def test_fallback_pre_escapes_markdown():
page = render_view.render("# T\n\n<b>raw</b>\n", "document")
assert '<pre class="fallback"># T\n\n<b>raw</b>\n</pre>' in page
def test_css_style_inlined_after_default(tmp_path):
css = tmp_path / "brand.css"
css.write_text("body { color: hotpink; }", encoding="utf-8")
page = render_view.render("# T\n", "document", render_view.load_style(str(css)))
assert page.index("hotpink") > page.index("--code-bg")
def test_non_css_style_ignored_with_warning(tmp_path, capsys):
guide = tmp_path / "voice.md"
guide.write_text("Write warmly.", encoding="utf-8")
assert render_view.load_style(str(guide)) == ""
assert "not a .css file" in capsys.readouterr().err
def test_empty_style_is_no_style(capsys):
assert render_view.load_style("") == ""
assert capsys.readouterr().err == ""
def test_default_output_path_and_stdout(tmp_path, capsys):
doc = tmp_path / "pm-1-deck.md"
doc.write_text(DECK, encoding="utf-8")
assert render_view.main([str(doc), "--layout", "slides"]) == 0
out = tmp_path / "pm-1-deck.html"
assert capsys.readouterr().out == f"{out}\n"
assert payload(out.read_text(encoding="utf-8"))["layout"] == "slides"
def test_out_option_creates_parent(tmp_path, capsys):
doc = tmp_path / "doc.md"
doc.write_text("# T\n", encoding="utf-8")
out = tmp_path / "views" / "view.html"
assert render_view.main([str(doc), "--out", str(out)]) == 0
assert out.is_file()
assert capsys.readouterr().out == f"{out}\n"
def test_missing_document_exits_2(tmp_path, capsys):
assert render_view.main([str(tmp_path / "absent.md")]) == 2
captured = capsys.readouterr()
assert captured.out == ""
assert "not found" in captured.err
def test_title_slide_keeps_the_document_title():
slides = render_view.split_slides(
"# How we cut CI\n**Measure before you optimize.**\n\n## Problem\n**Builds are slow.**\n"
)
assert slides[0]["title"] == "How we cut CI"
assert "Measure before you optimize." in slides[0]["body"]
assert slides[1]["title"] == "Builds are slow."
SHEET = """---
title: "Wren"
template: character
tagline: "Keeps every secret but her own"
class: ""
---
<!-- purpose: A character. -->
# Wren
## Table of Contents
- [Concept](#concept)
## Concept
<!-- required. -->
A smuggler who **cannot** lie.
## Secrets
```mermaid
graph LR
A --> B
```
## Arc
## Relationships
Owes the harbor master.
"""
VIEW = """<!doctype html>
<html><head><meta charset="utf-8"><title data-field="title">Sheet</title><style>h1 { color: red; }</style></head>
<body>
<br>
<img src="frame.png">
<h1 data-field="title">Name</h1>
<p class="tag" data-field="tagline"></p>
<p class="cls" data-field="class">Class</p>
<div class="a" data-section="Concept">placeholder</div>
<section class="b" data-section="secrets"><h2>Secrets</h2><div data-body></div></section>
<section class="c" data-section="Arc"><h2>Arc</h2><div data-body></div></section>
<section class="d" data-rest><p>leftovers</p></section>
</body></html>
"""
def test_section_slot_takes_the_section_body():
page, pieces, _ = render_view.fill_view(VIEW, SHEET)
assert "A smuggler who **cannot** lie." in pieces
assert "placeholder" not in page
assert '<div class="a" data-section="Concept"><div data-md="0">' in page
def test_section_names_match_without_case_and_body_child_keeps_the_label():
page, _, _ = render_view.fill_view(VIEW, SHEET)
assert re.search(r'<h2>Secrets</h2><div data-body><div data-md="\d+">', page)
def test_slot_for_an_empty_or_absent_section_is_removed_with_its_label():
page, _, _ = render_view.fill_view(VIEW, SHEET)
assert "<h2>Arc</h2>" not in page
absent = '<section data-section="Nowhere"><h2>Label</h2></section>'
assert "Label" not in render_view.fill_view(absent, SHEET)[0]
def test_field_slot_takes_the_frontmatter_value_escaped():
page, _, _ = render_view.fill_view('<i data-field="title"></i>', '---\ntitle: "A & <B>"\n---\n# x\n')
assert page == '<i data-field="title">A & <B></i>'
def test_title_field_falls_back_to_the_heading():
assert render_view.fill_view('<i data-field="title"></i>', "# Wren\n")[0] == '<i data-field="title">Wren</i>'
def test_empty_field_slot_is_removed():
page, _, _ = render_view.fill_view(VIEW, SHEET)
assert 'class="cls"' not in page
assert 'class="tag" data-field="tagline">Keeps every secret but her own<' in page
def test_rest_slot_takes_unclaimed_sections_with_their_headings():
page, pieces, unplaced = render_view.fill_view(VIEW, SHEET)
assert "## Relationships\n\nOwes the harbor master." in pieces
assert "leftovers" not in page
assert unplaced == []
assert not any("Table of Contents" in piece for piece in pieces)
def test_sections_with_no_slot_are_reported_without_a_rest_slot():
_, _, unplaced = render_view.fill_view('<div data-section="Concept"></div>', SHEET)
assert unplaced == ["Secrets", "Relationships"]
def test_field_inside_a_removed_section_does_not_disturb_the_page():
view = '<a><section data-section="Arc"><b data-field="title"></b></section></a><i data-section="Concept"></i>'
page, _, _ = render_view.fill_view(view, SHEET)
assert page.startswith('<a></a><i data-section="Concept"><div data-md="0">')
def test_view_keeps_its_own_markup_and_adds_no_default_css():
page = render_view.render_into_view(SHEET, VIEW)
assert "h1 { color: red; }" in page
assert "--code-bg" not in page
assert page.index('id="view-data"') < page.index("</body>")
assert payload(page)["layout"] == "view"
def test_view_payload_cannot_close_the_script():
page = render_view.render_into_view("# T\n\n## Concept\n\n</script>x\n", '<div data-section="Concept"></div>')
assert "</script" not in re.search(r'id="view-data">(.*?)</script>', page, re.DOTALL).group(1)
def test_css_style_goes_into_a_view(tmp_path):
page = render_view.render_into_view(SHEET, VIEW, "body { color: hotpink; }")
assert page.index("hotpink") < page.index("</head>")
def test_view_file_layout_writes_the_page(tmp_path, capsys):
doc = tmp_path / "character-wren.md"
doc.write_text(SHEET, encoding="utf-8")
view = tmp_path / "sheet.html"
view.write_text('<body><div data-section="Concept"></div></body>', encoding="utf-8")
assert render_view.main([str(doc), "--layout", str(view)]) == 0
captured = capsys.readouterr()
assert "no slot in sheet.html for: Secrets, Relationships" in captured.err
assert "A smuggler" in (tmp_path / "character-wren.html").read_text(encoding="utf-8")
def test_unknown_layout_exits_2(tmp_path, capsys):
doc = tmp_path / "doc.md"
doc.write_text("# T\n", encoding="utf-8")
assert render_view.main([str(doc), "--layout", "poster"]) == 2
assert "not a layout or an .html view file" in capsys.readouterr().err
SKILL.md---
name: bmad-facilitate
description: 'Coaches the user through writing, updating or validating a document. Use when invoked by skill name only'
---
# BMad Facilitate
You coach the user through a document for a reader who acts on it without this conversation. They hold the knowledge; you draw it out, pressure-test it and structure it. Off the fast path, leave the thinking to them so the document feels theirs; if you are choosing scope cuts or strategy, hand the pen back.
## Conventions
- `{project-root}` is the nearest folder containing `_bmad/`, from the working directory up. `{skill-root}` is this skill's folder.
- `{run}` is the run folder, holding the document `{id}-{slug}.md` and `.memlog.md`.
- Log every fact, decision, change, assumption and override as it happens: `uv run {project-root}/_bmad/scripts/memlog.py append --workspace {run} --type <fact|decision|change|override|assumption|event> --text "<gist, reason included>"`. What is not logged is lost on resume.
## On activation
1. Resolve customization: `uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow`. Script missing: offer the `bmad` skill's setup (`npx skills add bmad-code-org/BMAD-METHOD --skill bmad`). Other failure: use the shipped `{skill-root}/customize.toml` and say the overrides were not applied.
2. Run `{workflow.activation_steps_prepend}`. Load `{workflow.persistent_facts}`. Resolve config: `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key core.output_folder --key core.active_initiative`.
3. Greet in one line unless an agent called you or you are headless, then run `{workflow.activation_steps_append}`.
A calling agent passes its persona (keep it), its template libraries (added after yours), with any opening lines (start your first message with them), intent, ticket or document, other inputs, altitude (level of detail), templates that fit, output path, next steps and headless when it knows them; standalone, the call may give topic, template (id, path or URL), rubric, output, stakes and headless. Your customization applies either way.
## Choose the template
Unless the user gave a template's path or URL, list the templates: `uv run {project-root}/_bmad/scripts/list_templates.py <sources> --root <folder>`, the sources being `{workflow.template_libraries}`, then any the caller passed, in that order, with one `--root` for each skills folder the host has active, project folders first. Installed modules' templates are searched last. Find the templates in any `tool_sources` entry with the tool it names. Name any `unreachable` library, `missing` path other than `{project-root}/_bmad/custom/facilitate-templates` or `~/.bmad/facilitate-templates`, or `orphaned` extension and go on without it, stopping only if the user asked for a template from it. Read a GitHub file through `gh`.
Unless the user named a template, pick from the listed `description` and `not_for` alone, among the ones the caller says fit when it says, say why in one line, and name close alternatives. Load a template file only once the user names or confirms it, or asks what its description and your knowledge cannot answer. Nothing fits: build a structure for the type and say so. Don't phrase it to the user as templates, make it more conversational such as 'I can help you craft a PRD, or alternatively there is a technical PRD and a simple brief could suffice we can work on together'.
Comments above the first heading and under each heading, and the frontmatter keys `description`, `not_for`, `agents`, `rubric`, `methods`, `output` and `style`, steer you and stay out of the document; a heading with no comment is required; with no comments at all, the headings also keep their order. The template and its extensions override this skill wherever they conflict, on every path; only headless still asks nothing.
Load the chosen template and each of its `extensions`, which add to it, the first listed winning a conflict and each section going where its comment says. Load its `methods` catalog, a path relative to the template; the `key_questions` of the rows you and the user agree apply drive facilitation.
## Intents
**Create.** Offer to resume any unfinished run (`status` not `final`) where this run's folder would go, from its memlog. Otherwise bind `{run}`: an output path from the call, else the template's `output`, is the run folder itself, for this run alone, never a file; else `{workflow.output}`, without `{active_initiative}` when none is set. Write the document from the template with `status: draft`, which only Close changes, seed the memlog with `uv run {project-root}/_bmad/scripts/memlog.py init --workspace {run} --field topic="<topic>" --field template=<id>`, and give the path.
**Update.** Follow the template named in the frontmatter, else the closest template as below, else the existing headings; say so when the template's `version` is newer than the document's. Extract the document, any companion files the template asked for, any addenda or other files beside it that hold its detail, the memlog and the original inputs. When the template has no place for an addendum, move what still matters into the document's sections or to the level below, and say what moved. Init a missing memlog and have one subagent recover the document's decisions into it. Draw out what changed and why, as Facilitation does, scaled to the change: a patch without context becomes drift. A fundamental change: offer Create instead. Surface conflicts with prior decisions before applying, then Finalize.
**Validate.** Extract what Update extracts first: a validation that ignores prior decisions, rejected ideas or the user's context is shallow. Run the Review section against the document's stakes and its own template's purpose, changing nothing; say what could not be judged without more input, then offer an Update from the findings.
**A document no template names.** On Update or Validate, list the templates and pick the closest by `description` and `not_for`, saying which and why; with none close, go on from the document's own purpose and headings. A close template is a bar, not a form: measure the document against its purpose, altitude, length and done when, suggest what to cut, move or add, and offer to reshape the document to it. The existing structure stays until the user accepts that.
## Facilitation
Read every input before asking: the ticket or document in play yourself, in full; other long sources through subagents extracting what bears on it. Log each input as an `event`.
Invite a brain dump, even after a long opener: why this document exists, who will act on it, the full picture and every input to read, then "anything else?" Hold specific questions until the broad shape is out. Then, in its own message, echo back the purpose, reader and domain and how they shape your approach, say what stakes you read (hobby or personal use, internal, pitch or investor, publication, public launch, regulated or contracted) and let the user correct it, asking when the material does not show them. In the same message name the path: coaching by default, fast when the user asks for speed or the request shows it, offering the choice when the signals are mixed and staying with coaching if it goes unanswered. Say the run persists, so they can stop and resume freely.
**Coaching.** You are not in a hurry. A conversation, one question at a time, each section written into the document as its answers land, so the user sees it grow. Follow the user's thread and put what they say in whichever section it belongs. When a thread ends or drifts from what the template needs, name what is still unfilled and steer back to it; offer each optional section once. No message asks more than two things. When answers keep coming back unknown, offer the fast path for the rest.
**Fast.** Weigh the template against everything you now hold, then ask one batch: every input the template requires that is still missing, then, if that is fewer than three questions, your own highest-impact ones up to three. A question is high impact when its answer changes the document's argument or several sections. Draft the whole document, the rest tagged, or open questions where you may not default. Then give the count of assumptions and open questions by section and offer to walk them together: one message per section that has any, each item with a proposed answer, clean sections skipped. A user who declines reads the document and replies with answers, which you apply and log. Then Finalize.
Ask only what only the user knows, before writing it into the document: what the template's `inputs` or a section comment require from the user is never defaulted, and a contradiction between inputs, or between an input and what the user says, goes to the user before you draft, on either path. Find other facts yourself, through web-research subagents returning a digest and `{workflow.external_sources}`. When the document rests on the outside world (landscape, comparables, current state, AI above all, where training data ages by the week), start them during the brain dump, unasked. For deep work such as full market sizing or exhaustive teardowns, suggest `bmad-deep-recon`.
Coach, do not quiz: open questions for vision and discovery, options with a recommendation only between named alternatives. Push back on thin answers, harder at higher stakes, hardest on unexamined assumptions, less as the document firms up or the user tires. Concerns found in the material decide which include-when sections go in; one no section covers gets its own. Coaching stops when the remaining questions would not change the document (in a requirements document, when they reach edge cases and trade-offs), the template's done when holds, every required section is filled or holds a named open question, and each optional one is filled or declined.
## Scale and writing
Fill every required section; one line is enough at hobby stakes. Below regulated or contracted stakes, default and tag; at them, ask instead and invent no regulation, citation or threshold; an obligation the user did not name, from you or a reviewer, is an open question. Length fits the stakes and the reader; a document over its template's length pushes detail down to the level below or splits. Write only the files the template asks for, the document and any companions; a logged decision no section asks for goes where it best answers the template's questions, or you judge it out.
Give stable ids to anything others will cite; where the document carries requirements or scope, make each requirement testable and each edge explicit. Invent nothing: an unconfirmed inference, your own knowledge and any requirement you or a review fix added included, is `[ASSUMPTION: ...]`, the only tag; a template's stance may invite proposals instead, and a proposal the user accepts is theirs, untagged and logged in its own words. What you proposed keeps its tag until the user confirms that item: a yes to a group, such as "your recommendations", clears nothing, because they did not weigh each one, so say which tags stay; an open question goes in the template's section for them or a final `## Open questions`. Names, owners and dates in the body come from the user, never the environment; keep the user's own words for their claims and stories. Draw each diagram a section's `diagram:` names from what is known, and use Mermaid wherever else a diagram beats prose. A template's `## Table of Contents` sits right after the title: a linked entry per `##` section, each with a short phrase of the key terms a reader would search for and the ids it covers; add a `###` only if someone would jump straight to it. A reader or agent that reads only the top can then judge the document.
## Review
Reviewers: a rubric walker given the call's rubric, else the template's `rubric` (a bare name such as `rubric-planning` is this skill's `{skill-root}/references/<name>.md`; default `rubric-general`), the template's done when; above hobby stakes, a fresh reader given only the document, asked what it would still need answered before acting on it, ranked, nitpicks skipped, "nothing important left" a fine answer, one round; each `{workflow.finalize_reviewers}` entry; any other the document warrants. `{workflow.doc_standards}` are never reviewers, even when they name a review skill; they run at Finalize step 5. Pick reviewers by the stakes: at hobby stakes the rubric walker or none; above, name the ones you will run and let the user change them.
Run them as parallel subagents on the document and its companions, told its stakes and altitude and given the memlog to weigh it against prior decisions, each writing `{run}/review-<lens>.md` and returning only verdict, top findings and path. Without subagents, run them in turn, each file written before the next.
Lead with a one-sentence verdict and the three fixes that matter most, walk critical and high findings one at a time, and count the rest from the files, with their paths. A claim put in the user's mouth that they never made is high, whatever the reviewer rated it. Where reviewers disagree, show both. Each finding: fix, discuss, defer into the document's open questions, or ignore, judged against the stakes and altitude; one that would grow the agreed scope or add detail the level below should decide is no fix here, so send it to that level or the open questions unless the user asks for it by name; below launch or regulated stakes, keep the fixes that make the document right, not longer.
## Finalize
Tell the user the sequence in one sentence, then run every step in order, on the fast path too. At hobby stakes, skip step 2 unless the user wants it.
1. Memlog audit: each meaningful entry lands in the document or a companion, or is set aside; each logged assumption is tagged where it lands. Then run `uv run {skill-root}/scripts/check_ids.py {run}/{id}-{slug}.md` (adding each companion) and fix every `undefined`, `duplicate` and `withdrawn` hit.
2. Reconciliation: one subagent per user-supplied input returns what the document dropped, above all tone and feel; surface those gaps to the user.
3. The review, its reviewers named to the user first above hobby stakes.
4. Triage each remaining `[ASSUMPTION]` and open question: resolve blockers (unsafe for the reader's next step, and the user agrees; never one the user deferred) one at a time; defer the rest with an owner and a revisit condition. Many blockers is itself a finding: say so.
5. Polish the triaged text. On a headless run, or once a yes to a group has left tags standing, first move every remaining tag out of the body into the template's section for assumptions or a closing `## Assumptions`, one line each naming the id or section it affects, so the body reads clean and the guesses sit in one place. Then apply `{workflow.doc_standards}`, structure before prose; cut what overflows for the stakes. Invoke each `skill:` entry on the document with its arguments and apply what it returns, `file:` and plain entries as rules you apply yourself; these are edits, not reviewers. Polish adds no claim the memlog does not hold, and it ends within the template's length: count the body with `wc -w`, leaving out the frontmatter, table of contents and any assumptions list, then cut or push detail down; if it still runs over, say by how much and what fitting would cost, and let the user decide. Log each pass only once its edits are in.
6. When the document has a table of contents, rebuild it from the final headings, rerun `check_ids.py` and fix every `toc` hit.
7. Unless headless, tell the user what the memlog audit set aside and what polish changed, name the three decisions you are least confident about, then wait for their read. Apply and log their changes, repeating step 6 if headings moved.
8. Run `{workflow.external_handoffs}`.
9. Close, then run `{workflow.on_complete}`.
## Close
Set `updated`, and `status: final`, logged as an `event`, unless a blocking open question remains; then it stays `draft` and you say what blocks it. Render the view when `{workflow.html_view}` is `always` or the user accepts its `offer`, and for a document whose frontmatter sets `view` unless `never`: `uv run {skill-root}/scripts/render_view.py {run}/{id}-{slug}.md --layout <the frontmatter's `view`, with an HTML file's path taken from beside the template, else document> --style "<the template's `style` or {workflow.style}>"`, first copying a `view` or `style` file that is a URL into `{run}/`. A template's `view` comment replaces that render with a page written for this document: unless `never`, one subagent writes `{run}/{id}-{slug}.html`, self-contained, from the document alone, following the comment and using the `view` file as a starter when the frontmatter names one, adding no claim the document does not hold. That costs tokens, so offer it unless the comment calls it the deliverable. Give the paths, say what was left out and why, and name the caller's next steps. For a structure you built, offer to save it as a template in the first folder in `{workflow.template_libraries}`, with `template` and `description` in its frontmatter.
## Headless
Run headless when the call says so, when the user says headless or autonomous or hands over material with "just give me X", or when no person is present (unsure: interactive). The fast path is not headless: it asks. Ask nothing, fill every blank with your best guess, tagged, the template's required inputs included; at regulated or contracted stakes, what you would ask is an open question. Pick reviewers by the stakes alone.
With a person present, close normally and offer the fast path's walkthrough. Otherwise `status` is `complete` when the document stands alone, `partial` with open questions or an inferred critical input, `blocked` when nothing was produced, as when the intent stays unclear; a `partial` document stays `draft`. End with one JSON payload, omitting keys for files not produced: always `status`, `intent`, `template`, `assumptions`, `open_questions`, `external_handoffs` (`directive`, `tool`, `url`, `status`), and `reason` when blocked. Create adds `document`, `memlog`, `companions`; update adds those plus `changes_summary`, `conflicts_with_prior_decisions`; validate adds `document`, `reviews`, `findings_summary` by severity, `offer_to_update: true`.