claude-skills/markdown-html/skills/markdown-html-orchestrator/scripts/route_explainer.py
Claude 8d1f2dec97
feat(markdown-html): add v2.10.0 foundation — orchestrator + design-system
Operationalizes Shihipar's "Claude Code HTML output" essay (Medium, 2026):
markdown collapses past ~100 lines for agent-generated artifacts; HTML
restores density, clarity, shareability, and lightweight interaction.

This foundation PR ships 2 of 5 planned skills:

- markdown-html-orchestrator (context: fork): deterministic doctype
  classifier scoring filename hints + content signals across DOCUMENT /
  REVIEW / SLIDES; silent-routes above two-signal threshold; refuses
  inputs < 100 lines per Shihipar; refuses without design-system
  onboarding. 3 stdlib tools: doctype_classifier.py, route_explainer.py
  (the never-silently-chain enforcer + onboarding gate), and
  output_path_resolver.py (kebab slug + collision suffix).

- design-system: one-time onboarding wizard (10 questions for brand
  primary/accent HEX + heading + body Google Fonts + editorial/technical/
  minimal/playful style + default output dir + syntax theme + TOC
  behavior + optional logo/company). WCAG-AA-validated 12 CSS custom
  properties derived in HSL space; refuses to save if body-text or link
  contrast fails 4.5:1, or if the output dir is unwritable. Pattern
  lifted from research-ops/skills/clinical-research/scripts/ (onboard.py
  + config_loader.py shape) and marketing/landing/scripts/
  (brand_palette_validator.py WCAG + HSL math). Precedence: project >
  global > defaults; MARKDOWN_HTML_NO_CONFIG=1 bypasses.

Plus cs-markdown-html-orchestrator agent, 3 slash commands
(/cs:markdown-html router, /cs:grill-markdown-html 5-question grill,
/cs:design-system onboarding surface), 6 reference docs each citing 5-7
authoritative sources (Tufte, Shihipar, Bret Victor, Maggie Appleton,
Bartosz Ciechanowski, Amelia Wattenberger; WCAG 2.2, Ellen Lupton,
Adobe Spectrum, Sara Soueidan, Material Design 3), 1 JSON schema asset,
domain README + CLAUDE.md.

Converter sub-skills (md-document, md-review, md-slides) land in v2.10.1
follow-up PRs. All three will import design-system/scripts/config_loader.py
for shared brand tokens.

Repo-level updates: scripts/sync-codex-skills.py SKILL_DOMAINS adds
markdown-html → documentation category; .claude-plugin/marketplace.json
adds markdown-html-skills plugin entry (63 → 64 plugins, 16 → 17
domains); root CLAUDE.md gets nav-map row, structure-tree entry, and
v2.10.0 release-notes block.

Validation:
- check_plugin_json.py → OK
- sync-codex-skills.py --dry-run → 2 new symlinks, total 338 → 340
- skill_description_validator.py → PASS on both SKILL.md files
- skill_review_checklist_runner.py → 5/6 PASS (under-100-lines advisory
  fires; same as research-ops orchestrator)
- All 6 Python tools smoke-tested with --help and --sample
- End-to-end onboarding round-trip works (--defaults, --set, --reset)
- WCAG-fail hard refusal fires correctly on impossible color combos
- Classifier on the repo's own CLAUDE.md correctly returns
  needs-clarification (15 vs 13 — document signals tie with slides
  signals because of multiple --- HRs); orchestrator asks rather than
  silently chains.

Distinct from Anthropic's official Playground plugin (interactive
prompt-tuning controls with sliders/knobs/prompt-copy-back) and from
marketing/landing/ (landing-page generator from scratch).

https://claude.ai/code/session_01BK2KoQot1U7J5oSosrCQdc
2026-05-29 14:19:06 +00:00

168 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""route_explainer.py - Print the routing decision in a form the LLM can act on.
Stdlib-only. Takes the JSON output of doctype_classifier.py (or runs the
classifier itself), and prints a short routing brief: which sub-skill to
invoke, what evidence supports the decision, and what to ask the user if
the verdict is ambiguous.
This is the "never silently chain" enforcer — it prints the recommendation
in a structured form that makes it obvious whether the orchestrator should
route silently, ask one clarifying question, or refuse outright (because
the input is below the 100-line threshold or design-system isn't onboarded).
NO LLM CALLS. Pure formatting + decision-tree branching.
Usage:
python doctype_classifier.py --input X.md --output json | python route_explainer.py
python route_explainer.py --classification-file classification.json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
# Bridge to the design-system config so we can refuse if not onboarded
_DESIGN_SYSTEM_SCRIPTS = (
Path(__file__).resolve().parent.parent.parent / "design-system" / "scripts"
)
sys.path.insert(0, str(_DESIGN_SYSTEM_SCRIPTS))
try:
import config_loader as cfg
except ImportError:
cfg = None
def _design_system_status() -> dict[str, Any]:
if cfg is None:
return {"onboarded": False, "reason": "config_loader not importable"}
if os.environ.get("MARKDOWN_HTML_NO_CONFIG") == "1":
return {"onboarded": True, "reason": "bypass env set", "bypass": True}
if cfg.setup_completed():
c = cfg.load_config()
return {
"onboarded": True,
"default_output_dir": c.get("default_output_dir"),
"design_style": c.get("design_style"),
"brand_primary": (c.get("brand") or {}).get("primary"),
"completed_at": c.get("setup_completed_at"),
}
return {"onboarded": False, "reason": "no setup_completed_at in config"}
def explain(classification: dict[str, Any]) -> dict[str, Any]:
verdict = classification["verdict"]
line_count = classification["line_count"]
below_min = classification["below_min_lines"]
ds = _design_system_status()
refusals: list[str] = []
if below_min:
refusals.append(
f"Input is {line_count} lines (< {classification['min_lines_threshold']}). "
f"Per Shihipar's threshold, markdown wins below 100 lines. "
f"Recommend keeping this as markdown and re-running only on longer documents."
)
if not ds.get("onboarded"):
refusals.append(
"Design-system has not been onboarded. Run "
"`python3 markdown-html/skills/design-system/scripts/onboard.py` "
"(or `--defaults`) before conversion, so the converters have brand tokens to apply."
)
next_action = ""
sub_skill = None
if refusals:
next_action = "REFUSE — fix the issues above before routing."
elif verdict in ("document", "review", "slides"):
sub_skill = f"md-{verdict}"
next_action = (
f"ROUTE_SILENTLY -> {sub_skill}. "
f"Evidence: {classification['winner']} won with score "
f"{classification['winner_score']} (runner-up {classification['runner_up']}="
f"{classification['runner_up_score']})."
)
elif verdict == "needs-clarification":
winner = classification["winner"]
runner = classification["runner_up"]
next_action = (
f"ASK_USER one question: 'I see signals for both md-{winner} (score "
f"{classification['winner_score']}) and md-{runner} (score "
f"{classification['runner_up_score']}). Recommended: md-{winner}. "
f"Confirm or override?'"
)
else: # ambiguous
next_action = (
"ASK_USER one question: 'Which document type is this — long-form "
"document, code review with diff, or slide deck? "
"Recommended: md-document (safe default).'"
)
return {
"decision": "REFUSE" if refusals else next_action.split(" ", 1)[0],
"sub_skill": sub_skill,
"next_action": next_action,
"refusals": refusals,
"classification_verdict": verdict,
"line_count": line_count,
"design_system": ds,
}
def render_human(explanation: dict[str, Any]) -> str:
out = []
out.append(f"Routing decision: {explanation['decision']}")
if explanation["sub_skill"]:
out.append(f" sub-skill: {explanation['sub_skill']}")
out.append(f" next action: {explanation['next_action']}")
if explanation["refusals"]:
out.append("")
out.append("Refusals:")
for r in explanation["refusals"]:
out.append(f" - {r}")
out.append("")
out.append("Design-system:")
ds = explanation["design_system"]
out.append(f" onboarded: {ds.get('onboarded')}")
if ds.get("onboarded"):
out.append(f" default_output_dir: {ds.get('default_output_dir')}")
out.append(f" design_style: {ds.get('design_style')}")
out.append(f" brand_primary: {ds.get('brand_primary')}")
else:
out.append(f" reason: {ds.get('reason')}")
return "\n".join(out)
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--classification-file",
help="Path to a doctype_classifier JSON output. Default: read stdin.")
parser.add_argument("--output", choices=["human", "json"], default="human")
args = parser.parse_args(argv)
if args.classification_file:
with open(args.classification_file, encoding="utf-8") as f:
classification = json.load(f)
else:
if sys.stdin.isatty():
parser.print_help()
return 0
classification = json.load(sys.stdin)
explanation = explain(classification)
if args.output == "json":
print(json.dumps(explanation, indent=2))
else:
print(render_human(explanation))
return 0 if explanation["decision"] != "REFUSE" else 3
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))