Output Formatting
Output formatting for text addressed to the operator: copy-pasteable content goes in fenced code blocks, and a medium ladder (prose, diagram, page, video) picks the lowest medium that carries the content. Ships /explain and ccgm-md-page, a markdown-to-HTML page renderer for installs without the Artifact tool.
Tags
README
output-formatting
Formatting rules for output addressed to the operator: copy-pasteable content, and a ladder that picks the lowest medium that carries the content.
What It Does
Copy-paste output
A rule governs content the user intends to paste somewhere else (emails, texts, social posts, bios, form answers, prompts for other tools, config snippets):
- Pasteable content goes in a fenced code block containing exactly the text that should land at the destination
- Never blockquotes (
>), which render as a vertical line in the terminal and copy dirty - No decorative quotation marks, indentation, or labels mixed into the content
- Commentary and option labels stay outside the block
- Plain text by default; markdown source only when the destination renders markdown
Medium ladder
A short always-loaded rule (output-medium.md) tells the agent to pick the lowest medium that carries the content:
| Content shape | Medium |
|---|---|
| Status, result, decision, short explanation | Prose |
| Flow, sequence, dependency, state machine, architecture, before/after | Diagram: ASCII (terminal), Mermaid (GitHub), inline SVG (page) |
| More than 5 items on more than 3 attributes, a long report, data to chart | Page: the Artifact tool when present, else ccgm-md-page |
| A new concept the operator asked to learn | Video, only on request (the explainer-video module) |
The prose answer leads. A diagram or page adds to it. The rule applies to text addressed to the operator, not to a subagent reporting to its caller. The output-medium skill holds the conventions for each medium.
/explain <topic> [as prose|diagram|page|video]
Reads what it needs, picks the medium (or honours as), checks the result, and ends with a summary of 5 lines or fewer plus the path or link. as video without explainer-video says so and offers a page.
ccgm-md-page IN.md [-o OUT.html] [--title T]
Turns markdown into one self-contained HTML page for installs without the harness Artifact tool. Stdlib Python. It writes the file, prints the path, and never opens it.
- Renders in the browser with pinned
markedandDOMPurify(exact versions, SRI hashes,crossorigin="anonymous"). Hostile markdown cannot run code. - Loads
mermaidonly when the markdown has amermaidblock. - Shows the markdown source when JavaScript is off (
<noscript>) or a CDN file fails to load. - Light and dark themes, a readable column, a table of contents above 4
##headings, no horizontal scroll at 390 px. - The pinned versions and hashes are constants at the top of the script.
Callers run test -x "$HOME/.claude/bin/ccgm-md-page" first, then "$HOME/.claude/bin/ccgm-md-page", each as its own command, because ~/.claude/bin is not on PATH. Pass absolute paths: "$HOME/.claude/bin/ccgm-md-page" /abs/path/report.md -o /abs/path/report.html.
Tests
python3 -m pytest modules/output-formatting/tests -q
bash modules/output-formatting/tests/test-output-formatting.sh
bash modules/output-formatting/tests/test-md-page-e2e.sh # headless Chromium via orrery's toolchain
The browser test skips with a note when orrery's Playwright toolchain is absent. Set CCGM_E2E_REQUIRED=1 to fail instead.
Manual Installation
mkdir -p ~/.claude/rules ~/.claude/skills/output-medium ~/.claude/commands ~/.claude/bin
cp rules/copy-paste-output.md ~/.claude/rules/copy-paste-output.md
cp rules/output-medium.md ~/.claude/rules/output-medium.md
cp skills/output-medium/SKILL.md ~/.claude/skills/output-medium/SKILL.md
cp commands/explain.md ~/.claude/commands/explain.md
cp bin/ccgm-md-page ~/.claude/bin/ccgm-md-page
chmod +x ~/.claude/bin/ccgm-md-page
mkdir -p ~/.claude/lib
cp bin/ccgm-output-metrics ~/.claude/bin/ccgm-output-metrics
cp lib/comprehension-patterns.json ~/.claude/lib/comprehension-patterns.json
chmod +x ~/.claude/bin/ccgm-output-metrics
Files
| File | Description |
|---|---|
rules/copy-paste-output.md |
Rule file: fenced code blocks for pasteable content, never blockquotes |
rules/output-medium.md |
Rule file (20 lines or fewer): pick the lowest medium that carries the content |
skills/output-medium/SKILL.md |
Conventions for ASCII and Mermaid diagrams and for pages |
commands/explain.md |
/explain: explain a topic in the right medium |
bin/ccgm-md-page |
Markdown to a self-contained HTML page |
bin/ccgm-output-metrics |
Read-only report on operator-facing output from Claude Code transcripts |
lib/comprehension-patterns.json |
Phrases ("what do you mean", "tl;dr") that mark a message the operator did not follow |
Output Metrics
ccgm-output-metrics [--since ISO] [--until ISO] [--project=SLUG] [--json] reads top-level transcripts in ~/.claude/projects/*/*.jsonl and prints, to stdout, how the agent's final message in each turn reads:
- Prose: share of sentences over the
ccgm-prose-lintlimits, p90 sentence length, paragraph-length and two-actions counts. - Length and medium: lines and words per final message; ASCII diagrams, Mermaid blocks, Artifact uses, printed
.htmland.mp4paths. - Over-climbing: a diagram under 3 boxes or nodes, or a page whose final message is under 8 prose lines and whose source markdown is under 40.
- Comprehension (primary outcome): events per 100 operator turns where the next short prompt, or a free-text AskUserQuestion answer, matches
lib/comprehension-patterns.json.
Every figure is split by model and by project. A rate with fewer than 20 events prints "insufficient data". --help has the exact definitions.
It imports prose_lint (writing-system module) and transcript_miner (dreaming module) from $CCGM_LIB or ~/.claude/lib, and exits 2 naming the missing one. CCGM_PROJECTS overrides the transcripts root.
Will install
| Path | Action | Target | Type |
|---|---|---|---|
rules/copy-paste-output.md | → | rules/copy-paste-output.md | rule |
rules/output-medium.md | → | rules/output-medium.md | rule |
skills/output-medium/SKILL.md | → | skills/output-medium/SKILL.md | skill |
commands/explain.md | → | commands/explain.md | command |
bin/ccgm-md-page | → | bin/ccgm-md-page | script |
bin/ccgm-output-metrics | → | bin/ccgm-output-metrics | script |
lib/comprehension-patterns.json | → | lib/comprehension-patterns.json | lib |
Dependencies
No dependencies.
Required by
No other module depends on this one.
Included in presets
Install this module
Agent prompt
Recommended for agent users -- hands the whole install off to your assistant.
Fetch https://ccgm.dev/modules/output-formatting.md and install this module into my Claude Code setup.
Native plugin marketplace
One command via the native plugin marketplace -- additive, does not merge settings.json.
claude plugin install output-formatting@ccgm
The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.
Files
rule (2)
rules/copy-paste-output.md
# Copy-Paste Output Content meant to be copy-pasted goes in a fenced code block, never a blockquote. When output is destined for somewhere else (an email, a text, a social post, a bio, a form field, a prompt for another tool, a commit message or PR body presented for approval, a config value, a command for another machine), the deliverable is the exact text the user will paste, and any decoration becomes cleanup work at the destination. A blockquote renders with a vertical bar and copies with `>` markers, soft line breaks, and indentation. - The block contains only the content: no "Draft:" or "Subject:" labels, no trailing commentary. - Commentary, options, and explanation stay outside the block. - Match the destination's format: plain text for emails, messages, and forms; markdown source only when the destination renders markdown (GitHub comments, READMEs). - One block per variant, with a short label in prose above each. - Preserve intentional structure (blank lines between paragraphs, list markers the destination expects) and add nothing else; no quotation marks "for clarity," no bold headed for a plain-text field. When in doubt: will the user select this text and paste it somewhere? If yes, fence it. Blockquote, copies dirty: > Hi Sarah, > > Thanks for reaching out about the timeline. We're on track to deliver by Friday. Fenced block, copies clean: ```text Hi Sarah, Thanks for reaching out about the timeline. We're on track to deliver by Friday. ```
rules/output-medium.md
# Output Medium: Lowest Rung That Carries the Content Applies to text addressed to the operator; a subagent reporting to its caller writes prose or its mandated envelope unless its spec asks for more. | Content shape | Medium | |---|---| | Status, result, decision, short explanation | Prose | | Flow, sequence, dependency, state machine, architecture, before/after | Diagram: ASCII in a fenced block (terminal), Mermaid (GitHub), inline SVG (page) | | More than 5 items on more than 3 attributes; a long report to scan or revisit; data to chart | Page: the Artifact tool when present, else `ccgm-md-page` | | A new concept the operator asked to learn | Video, only on request | - The prose answer leads. A diagram or page adds to it, never replaces it. - Climb a rung only when the content's shape needs it. Never choose video unprompted. - A mandated format governs content, not medium. - Missing tool: skip the rung and print a one-line note. Never fail the task. - Write files and print paths. Never open a page or play a video; the operator does. - Load the `output-medium` skill before drawing a diagram or writing a page.
command (1)
commands/explain.md
--- description: Explain a topic in the lowest medium that carries it - prose, diagram, page, or video - then verify the artifact and summarize in 5 lines or fewer argument-hint: "<topic> [as prose|diagram|page|video]" --- # /explain - Explain Something in the Right Medium Explain `$ARGUMENTS`. Read what you need, pick the medium, produce it, check it, and finish with a short summary. ## Step 1: Parse Split `$ARGUMENTS` at the last ` as ` followed by `prose`, `diagram`, `page`, or `video`. Everything before it is the topic. With no `as`, you pick the medium in Step 3. With no topic, ask what to explain. ## Step 2: Read Read the code or docs the topic needs. Stop when you can explain it from what you read. Do not survey the repo. ## Step 3: Pick the Medium Honour `as` when given. Otherwise pick the lowest rung that carries the content: | Content | Medium | |---|---| | A fact, result, decision, or short explanation | prose | | A flow, sequence, dependency, state machine, architecture, or before/after | diagram | | More than 5 items on more than 3 attributes, a long report, data to chart, anything that needs filters | page | | A new concept the operator wants to build intuition for | video, only with `as video` | Load the `output-medium` skill before you draw a diagram or write a page. It holds the conventions for each medium. ## Step 4: Produce and Check - **prose**: write plain sentences. No file. - **diagram**: ASCII in a fenced block for the terminal, 100 columns or fewer, with a one-line caption. Write the diagram (or the whole final message) to a file in the scratchpad. Run `test -x "$HOME/.claude/bin/ccgm-prose-lint"` as its own command. If it exits non-zero, note that the check was skipped. Otherwise run `"$HOME/.claude/bin/ccgm-prose-lint" --strict /abs/path/FILE` as its own command and fix any `diagram-width` error until it exits 0. Use Mermaid only when the destination is GitHub, and check it with `npx -y @mermaid-js/mermaid-cli` when `npx` exists. - **page**: with the Artifact tool, follow its publish guidance. Without it, write the content as markdown and run `test -x "$HOME/.claude/bin/ccgm-md-page"` as its own command. If that exits non-zero, the tool is missing: print a one-line not-installed note and give the markdown path. Otherwise run `"$HOME/.claude/bin/ccgm-md-page" /abs/path/FILE.md -o /abs/path/FILE.html` as its own command, with absolute paths. Never open the page. - **video**: use the `explainer-video` module when it is installed. When it is missing, say so in one line and offer a page instead. Do not build a page unasked. ## Step 5: Finish End with a summary of 5 lines or fewer in plain prose: the answer first, then the path or link to the diagram file or page. The prose answer comes before any artifact. Then add one last line with the check result: `Check: diagram width ok (prose-lint exit 0)`, `Check: diagram width skipped (ccgm-prose-lint not installed)`, or `Check: page written to <absolute path>`. For prose and video, write `Check: none needed`.
skill (1)
skills/output-medium/SKILL.md
--- name: output-medium description: Conventions for each output medium above prose - ASCII and Mermaid diagrams, and HTML pages (Artifact tool or the ccgm-md-page fallback). Load before drawing a diagram or writing a page for the operator. disable-model-invocation: false --- # Output Medium Conventions The `output-medium` rule picks the medium. This skill holds the conventions for each one. The prose answer still leads the final message; the diagram or page adds to it. Every rung degrades. A missing tool means a skipped check and a one-line note, never a failed task. Write files and print their paths. Never open a page. ## Diagrams Use a diagram for a flow, sequence, dependency, state machine, architecture, or before/after structure. One idea per diagram. End with a one-line caption. ### ASCII (terminal, question previews) - Put it in a fenced code block. - Use box-drawing characters: `┌ ─ ┐ │ └ ┘ ▶ ▼`. - Keep every line at 100 columns or fewer. - Put labels inside boxes. Make arrows show direction. - In `AskUserQuestion`, put the diagram in the option's `preview` field, which renders monospace beside the options. Never put it in the question text. Check the width before you send it. Write the diagram, or the whole final message, to a file. Run these as separate commands: ```bash test -x "$HOME/.claude/bin/ccgm-prose-lint" ``` If that exits non-zero, skip the check and say so in one line. Otherwise run: ```bash "$HOME/.claude/bin/ccgm-prose-lint" --strict /abs/path/diagram.txt ``` A `diagram-width` error names each line over 100 columns. Shorten those lines and run it again until it exits 0. The linter counts characters, so a box-drawing character counts as one column. Prose errors in the same file also fail the run; fix those too. Report the result in your reply. ### Mermaid (GitHub destinations: PR bodies, issues, READMEs) - Use `flowchart`, `sequenceDiagram`, or `stateDiagram-v2`. - Use at most 12 nodes. Split larger diagrams. - Use short IDs and quoted labels: `A["Request"] --> B["Handler"]`. - Check the syntax when `npx` exists: ```bash npx -y @mermaid-js/mermaid-cli -i diagram.mmd -o diagram.svg ``` When `npx` is missing, skip the check and say so in one line. Do not use Mermaid in the terminal; the operator sees raw text there. ## Pages Use a page for more than 5 items on more than 3 attributes, a long report the operator will scan or revisit, data worth charting, or anything that needs filters. The first screen holds the answer. ### With the Artifact tool Load `artifact-design`, plus `artifact-diagramming` or `dataviz` when the page has diagrams or charts. Follow the tool's own publish guidance. Print the link. ### Without the Artifact tool Write the content as markdown, then render it: ```bash test -x "$HOME/.claude/bin/ccgm-md-page" ``` If that exits non-zero, `ccgm-md-page` is missing: print a one-line "not installed" note, give the markdown file path, and continue. Otherwise run it as its own command: ```bash "$HOME/.claude/bin/ccgm-md-page" /abs/path/report.md -o /abs/path/report.html --title "Report title" ``` Use absolute paths; in advisor mode relative paths are denied. The output path must end in `.html`. The command prints the page path. Print that path to the operator. Keep each step a separate command; do not join them with `if` or `&&`, and do not assign variables. What the page tool does: - Renders in the browser with pinned `marked` and `DOMPurify`, so hostile markdown cannot run code. - Loads `mermaid` only when the markdown has a `mermaid` block, so put diagrams in that fence. - Shows the markdown source when offline or when JavaScript is off. - Adds a table of contents above 4 `##` headings and fits a 390 px screen. - Needs network access to the CDN to render. Offline, the operator reads the source text. ## Video Video is never chosen unprompted. The `explainer-video` module owns it. `/explain <topic> as video` uses it when installed.
lib (1)
lib/comprehension-patterns.json
{
"_description": "Phrases that signal the operator did not follow the agent's last message. ccgm-output-metrics applies them to a short follow-up prompt (<= 300 chars, not a slash command) and to a free-text AskUserQuestion answer. A text matches when the phrase starts it or makes up at least half of it. Texts are lowercased, curly apostrophes become straight, and surrounding punctuation is trimmed before matching. Every pattern carries examples; the test suite requires each example to match.",
"patterns": [
{
"name": "what_do_you_mean",
"regex": "what\\s+do\\s+you\\s+mean",
"description": "Operator asks what the agent meant.",
"examples": ["what do you mean?", "What do you mean by that"]
},
{
"name": "dont_understand",
"regex": "i\\s+(?:really\\s+)?(?:don'?t|do\\s+not)\\s+(?:really\\s+)?(?:understand|get\\s+(?:it|this|that))",
"description": "Operator says they do not understand.",
"examples": ["I don't understand", "i dont really get it"]
},
{
"name": "what_context",
"regex": "what\\s+context",
"description": "Operator asks what context a question or message rests on.",
"examples": ["what context?", "What context are you talking about"]
},
{
"name": "huh",
"regex": "huh\\b",
"description": "Operator answers with 'huh'.",
"examples": ["huh?", "Huh"]
},
{
"name": "lost_me",
"regex": "(?:you\\s+)?lost\\s+me",
"description": "Operator says the agent lost them.",
"examples": ["you lost me", "lost me there"]
},
{
"name": "too_long",
"regex": "too\\s+long",
"description": "Operator says the message is too long.",
"examples": ["too long", "this is too long"]
},
{
"name": "tldr",
"regex": "tl;?\\s?dr",
"description": "Operator asks for a summary.",
"examples": ["tl;dr", "TLDR please"]
}
]
}
script (2)
bin/ccgm-md-page
#!/usr/bin/env python3
"""ccgm-md-page: turn a markdown file into one self-contained HTML page.
Usage: ccgm-md-page IN.md [-o OUT.html] [--title T]
The page holds the markdown as JSON and renders it in the browser with pinned
marked, then DOMPurify, then innerHTML. Mermaid loads only when the markdown
has a mermaid block. Every remote script has an exact version, an SRI hash,
and crossorigin="anonymous". The tool writes the file and prints its path.
It never opens the page; the operator does.
Offline or with JavaScript off, the page still shows the markdown source: a
<noscript> copy for JavaScript-off browsers, and a script fallback when a CDN
file fails to load.
Exit codes: 0 written, 2 bad input.
"""
import argparse
import html
import json
import re
import sys
from pathlib import Path
# Pinned third-party scripts. To bump one: download the file, compute
# openssl dgst -sha384 -binary FILE | openssl base64 -A
# confirm the CDN answers with Access-Control-Allow-Origin, and update both
# fields. cdnjs does not carry mermaid 11.17.x, so mermaid comes from jsdelivr.
MARKED = (
"https://cdnjs.cloudflare.com/ajax/libs/marked/18.0.13/lib/marked.umd.min.js",
"sha384-EDNybkEeN9yYwIqheQpxHmn7Xdh180RKiMROtjDRy+SVH8Sl0EqpmIPOolCUbdyE",
)
DOMPURIFY = (
"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.15/purify.min.js",
"sha384-uUMu9JDY09vBzRf9SPcK2VgUj+W/70J6Soc+Dded5P474ElQ63iv9j5N3DE7Kp3N",
)
MERMAID = (
"https://cdn.jsdelivr.net/npm/mermaid@11.17.2/dist/mermaid.min.js",
"sha384-EOXBFmc3gx5mb+vn0vPvvGqACToJD24hhacX5Yx+8NUUQrHIle/Qi5Bg9o3zKwW2",
)
MERMAID_BLOCK = re.compile(r"^[ ]{0,3}(```|~~~)[ \t]*mermaid\b", re.M)
H1 = re.compile(r"^#[ \t]+(.+?)[ \t#]*$", re.M)
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>
:root {
--bg: #ffffff; --panel: #f6f8fa; --border: #d0d7de; --text: #1f2328;
--muted: #59636e; --accent: #0969da;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1116; --panel: #151a21; --border: #2a3038; --text: #e6edf3;
--muted: #8b949e; --accent: #58a6ff;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text); }
body {
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow-wrap: anywhere;
}
main { max-width: 72ch; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
h1, h2, h3, h4 { line-height: 1.25; margin: 1.6em 0 0.5em; }
h1 { margin-top: 0; font-size: 1.8rem; }
h2 { font-size: 1.4rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25em; }
a { color: var(--accent); }
nav#toc { background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
padding: 0.75rem 1rem; margin: 1rem 0; }
nav#toc ul { margin: 0; padding-left: 1.2rem; }
pre, code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, monospace; }
pre { background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
padding: 0.75rem; overflow-x: auto; }
:not(pre) > code { background: var(--panel); padding: 0.1em 0.3em; border-radius: 4px; }
.table-wrap { overflow-x: auto; margin: 1rem 0; }
table { border-collapse: collapse; }
th, td { border: 1px solid var(--border); padding: 0.4rem 0.7rem; text-align: left; }
th { background: var(--panel); }
blockquote { margin: 1rem 0; padding: 0 1rem; border-left: 4px solid var(--border);
color: var(--muted); }
img, svg { max-width: 100%; height: auto; }
pre.mermaid { background: transparent; border: 0; text-align: center; }
#fallback { white-space: pre-wrap; }
</style>
</head>
<body>
<main>
<article id="content"></article>
</main>
<noscript><pre style="white-space:pre-wrap;padding:1rem">__NOSCRIPT__</pre></noscript>
<script id="md-src" type="application/json">__JSON__</script>
__SCRIPTS__
<script>
(function () {
var src = JSON.parse(document.getElementById("md-src").textContent);
var root = document.getElementById("content");
var ready = window.marked && window.DOMPurify;
if (!ready) {
// A CDN file failed to load (offline, blocked). Show the source as text.
var pre = document.createElement("pre");
pre.id = "fallback";
pre.textContent = src;
root.appendChild(pre);
return;
}
root.innerHTML = DOMPurify.sanitize(marked.parse(src));
Array.prototype.forEach.call(root.querySelectorAll("table"), function (t) {
var wrap = document.createElement("div");
wrap.className = "table-wrap";
t.parentNode.insertBefore(wrap, t);
wrap.appendChild(t);
});
Array.prototype.forEach.call(root.querySelectorAll("a[href]"), function (a) {
if (/^https?:/i.test(a.getAttribute("href"))) { a.rel = "noopener noreferrer"; a.target = "_blank"; }
});
var heads = root.querySelectorAll("h2");
Array.prototype.forEach.call(root.querySelectorAll("h1,h2,h3,h4"), function (h, i) {
h.id = "s" + i + "-" + h.textContent.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
});
if (heads.length > 4) {
var nav = document.createElement("nav");
nav.id = "toc";
var ul = document.createElement("ul");
Array.prototype.forEach.call(heads, function (h) {
var li = document.createElement("li");
var a = document.createElement("a");
a.href = "#" + h.id;
a.textContent = h.textContent;
li.appendChild(a);
ul.appendChild(li);
});
nav.appendChild(ul);
var first = root.querySelector("h1");
if (first && first.nextSibling) { first.parentNode.insertBefore(nav, first.nextSibling); }
else { root.insertBefore(nav, root.firstChild); }
}
if (window.mermaid) {
var dark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: dark ? "dark" : "default" });
var blocks = root.querySelectorAll("pre > code.language-mermaid");
Array.prototype.forEach.call(blocks, function (code) {
var pre = code.parentNode;
pre.className = "mermaid";
pre.textContent = code.textContent;
});
mermaid.run({ querySelector: "pre.mermaid" }).catch(function () {});
}
})();
</script>
</body>
</html>
"""
def script_tag(spec):
url, integrity = spec
return (
f'<script src="{url}" integrity="{integrity}" '
f'crossorigin="anonymous"></script>'
)
def json_for_script(text):
"""JSON safe inside <script type=application/json>: no raw '<', '>', '&'."""
out = json.dumps(text, ensure_ascii=False)
for ch, esc in (("<", "\\u003c"), (">", "\\u003e"), ("&", "\\u0026"),
("\u2028", "\\u2028"), ("\u2029", "\\u2029")):
out = out.replace(ch, esc)
return out
def build_page(markdown, title):
scripts = [script_tag(MARKED), script_tag(DOMPURIFY)]
if MERMAID_BLOCK.search(markdown):
scripts.append(script_tag(MERMAID))
# One regex pass, so a placeholder inside the markdown is never re-expanded.
values = {
"__TITLE__": html.escape(title, quote=True),
"__NOSCRIPT__": html.escape(markdown, quote=True),
"__JSON__": json_for_script(markdown),
"__SCRIPTS__": "\n".join(scripts),
}
return re.sub("|".join(values), lambda m: values[m.group(0)], PAGE)
def main(argv=None):
ap = argparse.ArgumentParser(description="Render a markdown file as one HTML page.")
ap.add_argument("input", help="markdown file")
ap.add_argument("-o", "--output", help="output HTML path (default: next to the input)")
ap.add_argument("--title", help="page title (default: first # heading, else file name)")
args = ap.parse_args(argv)
src = Path(args.input)
try:
markdown = src.read_text(encoding="utf-8")
except OSError as err:
print(f"ccgm-md-page: cannot read {src}: {err.strerror}", file=sys.stderr)
return 2
title = args.title
if not title:
m = H1.search(markdown)
title = m.group(1).strip() if m else src.stem
out = Path(args.output) if args.output else src.with_suffix(".html")
try:
out.write_text(build_page(markdown, title), encoding="utf-8")
except OSError as err:
print(f"ccgm-md-page: cannot write {out}: {err.strerror}", file=sys.stderr)
return 2
print(out.resolve())
return 0
if __name__ == "__main__":
sys.exit(main())
bin/ccgm-output-metrics
#!/usr/bin/env python3
"""ccgm-output-metrics: measure operator-facing agent output from Claude Code transcripts.
Read-only. Reads top-level transcripts (~/.claude/projects/*/*.jsonl, never
subagent transcripts) and prints a report to stdout. Writes nothing.
Environment:
CCGM_LIB directory holding prose_lint.py and transcript_miner.py
(default ~/.claude/lib)
CCGM_PROJECTS transcripts root (default ~/.claude/projects)
"""
import argparse
import json
import math
import os
import re
import statistics
import sys
from datetime import datetime, timezone
EPILOG = """\
definitions:
turn Starts at a human-origin user record (transcript_miner's gate)
and ends before the next one. Task notifications, isMeta skill
expansions, tool results and harness wrappers start no turn.
A turn whose prompt is a slash command (<command-name> wrapper
or text starting with "/") is tagged slash: its output is
measured, but it never counts toward comprehension.
final message The turn's last assistant text block. Records stamped at or
after --until are ignored, so a window stays fixed while
sessions that began before the cutoff keep running.
prose prose_lint stats over final messages: sentences over limit,
p90 sentence length over all sentences, rule finding counts.
length Lines and words per final message (median, p90).
medium ASCII diagrams (a fenced block with box-drawing characters),
```mermaid blocks, Artifact tool uses, printed .html and .mp4
paths, prose lines (non-blank lines outside fenced blocks).
over-climbing A diagram with fewer than 3 boxes (count of corner glyphs) or
Mermaid nodes; or a page (printed .html path or Artifact use)
whose final message has under 8 prose lines AND whose source
markdown, when a .md path is printed (or sits beside the .html)
and is readable, has under 40 lines. One event per diagram or
page.
long, no medium A final message with over 40 prose lines and no diagram,
page or .mp4 path.
comprehension Primary outcome. Events per 100 operator turns where (a) the
next human prompt is <= 300 chars, not a slash command, and
starts with (or is at least half) a phrase from
comprehension-patterns.json, or (b) an AskUserQuestion answer
typed as free text ("Other") matches the same patterns. The
event belongs to the turn whose message confused the operator.
splits Every group is also reported per assistant model and per
project directory. A rate backed by fewer than 20 events is
flagged "insufficient data".
"""
MIN_EVENTS = 20
SHORT_PROMPT_CHARS = 300
LONG_PROSE_LINES = 40
THIN_PAGE_PROSE_LINES = 8
THIN_SOURCE_LINES = 40
MIN_BOXES = 3
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})\s*([\w+-]*)")
_BOX_CHARS_RE = re.compile("[─-╿]")
_BOX_CORNERS = "┌╭╔" # light, rounded, double top-left corners
_HTML_RE = re.compile(r"[\w./~@+-]+\.html?\b")
_MP4_RE = re.compile(r"[\w./~@+-]+\.mp4\b")
_MD_RE = re.compile(r"[\w./~@+-]+\.md\b")
_MM_SHAPED_RE = re.compile(r"\b([A-Za-z_]\w*)\s*(?:\[\[|\[\(|\[|\(\(|\(|\{\{|\{|>)")
_MM_EDGE_RE = re.compile(
r"(?:^|[\s;])([A-Za-z_]\w*)\s*(?:-->|---|==>|-\.->|--[ox>])", re.M)
_MM_EDGE_TARGET_RE = re.compile(
r"(?:-->|---|==>|-\.->)\s*(?:\|[^|]*\|\s*)?([A-Za-z_]\w*)")
_MM_PARTICIPANT_RE = re.compile(r"^\s*(?:participant|actor)\s+(\w+)", re.M)
_MM_KEYWORDS = {"graph", "flowchart", "subgraph", "end", "style", "classDef", "class",
"click", "linkStyle", "direction", "TD", "TB", "LR", "RL", "BT"}
# --- library loading ---------------------------------------------------------
def _load_libs():
lib = os.environ.get("CCGM_LIB") or os.path.expanduser("~/.claude/lib")
sys.path.insert(0, lib)
mods = {}
for name, module in (("prose_lint", "writing-system"), ("transcript_miner", "dreaming")):
try:
mods[name] = __import__(name)
except ImportError as exc:
sys.stderr.write(
f"ccgm-output-metrics: cannot import {name} from {lib} "
f"({module} module not installed?): {exc}\n")
sys.exit(2)
return mods["prose_lint"], mods["transcript_miner"]
def _load_patterns():
here = os.path.dirname(os.path.realpath(__file__))
lib = os.environ.get("CCGM_LIB") or os.path.expanduser("~/.claude/lib")
candidates = [os.environ.get("CCGM_COMPREHENSION_PATTERNS"),
os.path.join(here, "..", "lib", "comprehension-patterns.json"),
os.path.join(lib, "comprehension-patterns.json")]
for path in candidates:
if path and os.path.isfile(path):
with open(path) as fh:
data = json.load(fh)
return [re.compile(p["regex"]) for p in data["patterns"]]
sys.stderr.write("ccgm-output-metrics: comprehension-patterns.json not found\n")
sys.exit(2)
# --- small helpers -----------------------------------------------------------
def _parse_iso(value):
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def _pct90(values):
ranked = sorted(values)
return ranked[math.ceil(0.9 * len(ranked)) - 1] if ranked else 0
def _median(values):
return statistics.median(values) if values else 0
def _normalize(text):
text = text.lower().replace("’", "'").replace("‘", "'")
return " ".join(text.split()).strip(" \t\n.,!?:;-\"'()[]")
def _matches(patterns, text):
"""True when a pattern starts the text or covers at least half of it."""
norm = _normalize(text)
if not norm:
return False
for rx in patterns:
m = rx.search(norm)
if m and (m.start() == 0 or len(m.group(0)) * 2 >= len(norm)):
return True
return False
def _split_fences(text):
"""Return (prose_lines, blocks) where blocks is a list of (lang, body)."""
prose, blocks = [], []
fence, lang, body = None, "", []
for line in text.splitlines():
m = _FENCE_RE.match(line)
if fence is None:
if m:
fence, lang, body = m.group(1)[0], m.group(2).lower(), []
elif line.strip():
prose.append(line)
elif m and m.group(1)[0] == fence and not m.group(2):
blocks.append((lang, "\n".join(body)))
fence = None
else:
body.append(line)
if fence is not None:
blocks.append((lang, "\n".join(body)))
return prose, blocks
def _mermaid_nodes(body):
ids = set(_MM_SHAPED_RE.findall(body))
ids.update(_MM_EDGE_RE.findall(body))
ids.update(_MM_EDGE_TARGET_RE.findall(body))
ids.update(_MM_PARTICIPANT_RE.findall(body))
return len(ids - _MM_KEYWORDS)
def _source_lines(html_paths, md_paths):
"""Line count of the largest readable source markdown, or None."""
candidates = list(md_paths) + [os.path.splitext(p)[0] + ".md" for p in html_paths]
counts = []
for p in candidates:
p = os.path.expanduser(p)
if os.path.isfile(p):
try:
with open(p, errors="replace") as fh:
counts.append(sum(1 for _ in fh))
except OSError:
continue
return max(counts) if counts else None
def _human_text(tm, obj):
"""Typed text of a human-origin user record, or None when it starts no turn."""
if not tm._is_human_origin_turn(obj) or obj.get("isMeta"):
return None
content = (obj.get("message") or {}).get("content")
if isinstance(content, list) and any(
isinstance(b, dict) and b.get("type") == "tool_result" for b in content):
return None
text = tm._REMINDER_RE.sub("", tm._text_from_content(content)).strip()
if not text:
return None
low = text.lower()
if low.startswith(("<command-name>", "<command-message>")):
return text
if low.startswith(tm._HARNESS_PREFIXES):
return None
return text
def _is_slash(text):
low = text.lower()
return low.startswith(("/", "<command-name>", "<command-message>"))
def _free_text_answers(answers, labels):
"""Answers that are not made only of offered option labels."""
out = []
for answer in (answers or {}).values():
if not isinstance(answer, str):
continue
rest = answer
for label in sorted(labels, key=len, reverse=True):
rest = rest.replace(label, "")
if rest.strip(" ,;\n") or not answer.strip():
out.append(answer)
return out
# --- transcript scan ---------------------------------------------------------
def scan_file(path, project, tm, patterns, since, until):
"""Return the window's turns from one transcript, with confusion flags set."""
turns = []
cur = None
labels = set()
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
if '"type":"user"' not in line and '"type":"assistant"' not in line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(obj, dict) or obj.get("isSidechain"):
continue
if until is not None and _at_or_after(obj.get("timestamp"), until):
continue # written after the cutoff: not part of the measured window
msg = obj.get("message") or {}
content = msg.get("content")
if obj.get("type") == "user":
text = _human_text(tm, obj)
if text is not None:
cur = {"project": project, "ts": obj.get("timestamp", ""),
"prompt": text, "slash": _is_slash(text), "model": None,
"final": None, "artifacts": 0, "ask_confused": False,
"confused": False}
if turns:
prev = turns[-1]
if (not cur["slash"] and len(text) <= SHORT_PROMPT_CHARS
and _matches(patterns, text)):
prev["confused"] = True
turns.append(cur)
elif cur is not None and isinstance(obj.get("toolUseResult"), dict):
answers = obj["toolUseResult"].get("answers")
for ans in _free_text_answers(answers, labels):
if _matches(patterns, ans):
cur["ask_confused"] = True
elif cur is not None and isinstance(content, list):
model = msg.get("model")
if model == "<synthetic>":
continue
for block in content:
if not isinstance(block, dict):
continue
kind = block.get("type")
if kind == "text" and isinstance(block.get("text"), str) and block["text"].strip():
cur["final"] = block["text"]
cur["model"] = model or cur["model"]
elif kind == "tool_use":
cur["model"] = cur["model"] or model
name = block.get("name")
if name == "Artifact":
cur["artifacts"] += 1
elif name == "AskUserQuestion":
for q in (block.get("input") or {}).get("questions", []):
for opt in q.get("options", []):
if isinstance(opt, dict) and opt.get("label"):
labels.add(opt["label"])
return [t for t in turns if _in_window(t["ts"], since, until)]
def _at_or_after(ts, limit):
try:
return _parse_iso(ts) >= limit
except (ValueError, AttributeError):
return False
def _in_window(ts, since, until):
if not (since or until):
return True
try:
dt = _parse_iso(ts)
except ValueError:
return False
return (since is None or dt >= since) and (until is None or dt < until)
# --- aggregation -------------------------------------------------------------
def analyze_final(turn, pl):
"""Per-turn measurements of the final message."""
text = turn["final"]
prose_lines, blocks = _split_fences(text)
ascii_boxes = [sum(body.count(c) for c in _BOX_CORNERS)
for lang, body in blocks
if lang != "mermaid" and _BOX_CHARS_RE.search(body)]
mermaid = [_mermaid_nodes(body) for lang, body in blocks if lang == "mermaid"]
html = _HTML_RE.findall("\n".join(prose_lines) + "\n" + "\n".join(b for _, b in blocks))
html = [h for h in html if h.lower().endswith(".html")]
mp4 = _MP4_RE.findall(text)
md = _MD_RE.findall(text)
page = bool(html) or turn["artifacts"] > 0
small = sum(1 for n in ascii_boxes + mermaid if n < MIN_BOXES)
thin_page = False
if page and len(prose_lines) < THIN_PAGE_PROSE_LINES:
src = _source_lines(html, md)
thin_page = src is None or src < THIN_SOURCE_LINES
return {
"stats": pl.stats(text),
"sentence_words": pl.sentence_word_counts(text),
"lines": len(text.splitlines()),
"words": len(text.split()),
"ascii": len(ascii_boxes), "mermaid": len(mermaid),
"html": len(html), "mp4": len(mp4),
"prose_lines": len(prose_lines),
"over_climb": small + (1 if thin_page else 0),
"long_no_medium": (len(prose_lines) > LONG_PROSE_LINES
and not (ascii_boxes or mermaid or page or mp4)),
}
def _rate(events, turns):
return {"events": events,
"per_100_turns": round(events / turns * 100, 1) if turns else 0.0,
"insufficient_data": events < MIN_EVENTS}
def summarize(turns, pl):
operator = [t for t in turns if not t["slash"]]
finals = [analyze_final(t, pl) for t in turns if t["final"]]
words = [w for f in finals for w in f["sentence_words"]]
sentences = sum(f["stats"]["sentences"] for f in finals)
over = sum(f["stats"]["sentences_over_limit"] for f in finals)
by_rule = {k: sum(f["stats"]["by_rule"][k] for f in finals) for k in pl.RULES}
followups = sum(1 for t in operator if t["confused"])
asks = sum(1 for t in operator if t["ask_confused"])
comp = _rate(followups + asks, len(operator))
comp.update(followup_events=followups, ask_events=asks)
lines = [f["lines"] for f in finals]
word_counts = [f["words"] for f in finals]
return {
"turns": len(operator),
"slash_turns": len(turns) - len(operator),
"final_messages": len(finals),
"prose": {
"sentences": sentences,
"sentences_over_limit": over,
"pct_over_limit": round(over / sentences * 100, 1) if sentences else 0.0,
"p90_sentence_words": _pct90(words),
"by_rule": by_rule,
"insufficient_data": over < MIN_EVENTS,
},
"length": {"lines_median": _median(lines), "lines_p90": _pct90(lines),
"words_median": _median(word_counts), "words_p90": _pct90(word_counts)},
"medium": {
"ascii_diagrams": sum(f["ascii"] for f in finals),
"mermaid_blocks": sum(f["mermaid"] for f in finals),
"artifact_uses": sum(t["artifacts"] for t in turns),
"html_paths": sum(f["html"] for f in finals),
"mp4_paths": sum(f["mp4"] for f in finals),
"prose_lines_total": sum(f["prose_lines"] for f in finals),
},
"over_climbing": _rate(sum(f["over_climb"] for f in finals), len(operator)),
"comprehension": comp,
"long_no_medium": _rate(sum(1 for f in finals if f["long_no_medium"]), len(operator)),
}
def build_report(turns, pl, since, until, project, files):
def group(key):
buckets = {}
for t in turns:
buckets.setdefault(key(t), []).append(t)
return {k: summarize(v, pl) for k, v in sorted(buckets.items())}
return {
"schema_version": 1,
"window": {"since": since.isoformat() if since else None,
"until": until.isoformat() if until else None},
"project": project,
"files_scanned": files,
"overall": summarize(turns, pl),
"by_model": group(lambda t: t["model"] or "unknown"),
"by_project": group(lambda t: t["project"]),
}
# --- output ------------------------------------------------------------------
def _fmt_rate(label, r):
flag = " (insufficient data)" if r["insufficient_data"] else ""
return f" {label}: {r['events']} events, {r['per_100_turns']} per 100 turns{flag}"
def render_group(name, g):
p, ln, m, c = g["prose"], g["length"], g["medium"], g["comprehension"]
flag = " (insufficient data)" if p["insufficient_data"] else ""
return "\n".join([
f"[{name}] {g['turns']} operator turns, {g['slash_turns']} slash, "
f"{g['final_messages']} final messages",
f" prose: {p['sentences_over_limit']}/{p['sentences']} sentences over limit "
f"({p['pct_over_limit']}%), p90 {p['p90_sentence_words']} words{flag}; "
f"paragraph-length {p['by_rule']['paragraph-length']}, "
f"two-actions {p['by_rule']['two-actions']}",
f" length: lines median {ln['lines_median']} p90 {ln['lines_p90']}; "
f"words median {ln['words_median']} p90 {ln['words_p90']}",
f" medium: ascii {m['ascii_diagrams']}, mermaid {m['mermaid_blocks']}, "
f"artifact {m['artifact_uses']}, html {m['html_paths']}, mp4 {m['mp4_paths']}, "
f"prose lines {m['prose_lines_total']}",
_fmt_rate("over-climbing", g["over_climbing"]),
_fmt_rate("long, no medium", g["long_no_medium"]),
_fmt_rate("comprehension", c) + f" (followup {c['followup_events']}, ask {c['ask_events']})",
])
def render_text(report):
w = report["window"]
out = [f"window: {w['since'] or 'start'} .. {w['until'] or 'now'}; "
f"{report['files_scanned']} transcripts", render_group("overall", report["overall"])]
for title, key in (("model", "by_model"), ("project", "by_project")):
for name, g in report[key].items():
out.append(render_group(f"{title} {name}", g))
return "\n".join(out) + "\n"
# --- main --------------------------------------------------------------------
def main(argv=None):
ap = argparse.ArgumentParser(
prog="ccgm-output-metrics",
description="Measure operator-facing agent output from Claude Code transcripts (read-only).",
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--since", help="ISO time; keep turns that start at or after it")
ap.add_argument("--until", help="ISO time; keep turns that start before it")
ap.add_argument("--project", help="keep project directories whose name contains this slug; write --project=SLUG, since slugs start with a dash")
ap.add_argument("--json", action="store_true", help="emit JSON instead of text")
args = ap.parse_args(argv)
try:
since = _parse_iso(args.since) if args.since else None
until = _parse_iso(args.until) if args.until else None
except ValueError as exc:
ap.error(f"bad ISO time: {exc}")
pl, tm = _load_libs()
patterns = _load_patterns()
root = os.environ.get("CCGM_PROJECTS") or os.path.expanduser("~/.claude/projects")
turns, files = [], 0
for proj in sorted(os.listdir(root)) if os.path.isdir(root) else []:
pdir = os.path.join(root, proj)
if not os.path.isdir(pdir) or (args.project and args.project not in proj):
continue
for name in sorted(os.listdir(pdir)):
path = os.path.join(pdir, name)
if not name.endswith(".jsonl") or not os.path.isfile(path):
continue
if since and os.path.getmtime(path) < since.timestamp():
continue
found = scan_file(path, proj, tm, patterns, since, until)
files += 1 if found else 0 # only files with in-window turns, so the count is fixed
turns.extend(found)
report = build_report(turns, pl, since, until, args.project, files)
sys.stdout.write(json.dumps(report, indent=2) + "\n" if args.json else render_text(report))
if __name__ == "__main__":
main()