diff --git a/.agents/skills/plan-dag/SKILL.md b/.agents/skills/plan-dag/SKILL.md new file mode 100644 index 000000000..c157a8dcb --- /dev/null +++ b/.agents/skills/plan-dag/SKILL.md @@ -0,0 +1,180 @@ +--- +name: plan-dag +description: Render the current plan as a high-DPI PNG dependency DAG — nodes are issues / sub-issues / PRs, edges come from sub-issue links plus dependency-language prose ("Depends on", "Part of", "Blocks", "Closes") and PR / commit cross-references, and every node is color-coded done / in-progress / available-next / blocked so sequencing and critical path are obvious at a glance. Use when asked "plan as dag", "draw a dag", "dag diagram", "show the dependency graph", "what's blocking what", "what's the critical path", "what can be parallelized", "what's left for #N", or right after a `what's next` survey when sequencing the next pick is the actual question. +allowed-tools: Read, Bash(git log:*), Bash(git status:*), Bash(git branch:*), Bash(.claude/skills/plan-dag/scripts/plan-dag-render.py:*), Bash(~/.claude/skills/plan-dag/scripts/plan-dag-render.py:*), Bash(.claude/skills/plan-dag/scripts/plan-dag-render.test.sh:*), Bash(~/.claude/skills/plan-dag/scripts/plan-dag-render.test.sh:*), mcp__github__issue_read, mcp__github__list_issues, mcp__github__search_issues, mcp__github__list_pull_requests, mcp__github__pull_request_read +--- + +# plan-dag + +Render the current plan as a dependency DAG so sequencing, the critical path, and parallelizable work are visible at a glance. Output is a high-DPI PNG with status fills, an "available next" highlight, and a double-bordered close sentinel — rasterised from a styled graphviz layout through headless Chromium, then sent to the user via `SendUserFile`. + +This skill is repo-agnostic. It assumes a GitHub-backed issue tracker with sub-issue links and dependency-language prose; it makes no assumptions about specific labels, area taxonomies, or repo-specific dev-process skills. + +**Why PNG and not HTML / ASCII?** PNG is the only output that survives every chat surface the skill targets. ASCII / Unicode box-drawing loses column alignment when surfaces reflow whitespace or apply syntax highlighting, and it can't carry the color/status fills that make state legible at a glance. Self-contained HTML pages don't render inline in chat — they download as attachments, which defeats the point of an at-a-glance diagram. PNG renders inline, keeps color, and doesn't depend on the host's font or rendering quirks. + +## When to use + +- After a `what's next` survey, to commit to a sequence. +- When asked "what's left for #N" on an umbrella spec with sub-issues. +- Before picking the next branch — to see what unblocks the most downstream work. +- When a spec fans out into N sub-issues and the dependency edges aren't all linear. + +Skip when: + +- A single-PR spec — the plan *is* the Plan section, not a DAG. +- An unanswered design question — drawing a DAG before alignment is theater. +- The "graph" is one linear chain of three or fewer nodes — a sentence is shorter than a diagram. + +## Inputs + +| Parameter | Default | +|-----------|----------| +| **Scope** | Inferred — current branch's spec, the umbrella the user named, or the open issues just surveyed. | +| **Granularity** | Spec-level; drop to sub-issue / PR level for an umbrella that has fanned out. | +| **Output** | High-DPI PNG (only target). `--out ` is required; emoji status indicators are on by default and can be turned off with `--emoji=off` if the rendering system lacks a color emoji font. | + +## Workflow + +``` +1. Discover Pull issues / sub-issues / PRs in scope +2. Classify Mark each node done / in-progress / open +3. Edge-build Read dependencies from sub-issue links + body prose +4. Render Emit JSON IR → PNG → SendUserFile + prose commentary +``` + +### 1. Discover + +Resolve the scope from the conversation, not from a generic crawl. Typical triggers: + +- An umbrella spec → walk its sub-issue list (`mcp__github__issue_read` with `method: get_sub_issues`). +- A track of follow-ups from a recent merge → use the priority + area filter the user implied. +- The set of issues just surveyed → reuse that list verbatim, don't refetch. + +For each node, capture: + +- Issue state (`open` / `closed`) and `state_reason` (`completed` vs other). +- Status labels (`in-progress`, `planned`, `draft`) and `priority:*`. +- Linked PRs — look for "merged in PR #N" in the body or comments. +- Sub-issue list (only for umbrella nodes). + +### 2. Classify + +Each node gets exactly one status: + +| State | Definition | +|-------|------------| +| `done` | Closed + `state_reason: completed`, or merged PR. | +| `in_progress` | Open + `in-progress` label, or an open PR exists for it. | +| `open` | Open + `planned` / `draft`, no PR. | + +Don't render "blocked" as a separate status — the renderer derives it from the graph (any `open` node with a non-done predecessor) and styles it with a dashed muted fill. Conversely, an `open` node whose predecessors are all `done` is dual-encoded as the "available next" highlight. + +### 3. Edge-build + +Edges come from three sources, in this order of trust. **Don't draw an edge you can't cite from one of these** — speculation pollutes the DAG. + +1. **Sub-issue links** (`mcp__github__issue_read` + `method: get_sub_issues`). Edge direction is **child → parent** (prerequisite → dependent) — the parent closes when its children close, so the DAG flows toward closure. +2. **Explicit prose** in issue body: `Depends on #N`, `Hard depends on #N`, `Blocks #N`, `Part of #N`, `Closes #N`. +3. **PR/commit references**: `merged in PR #N`, `closed by #N`, `PR-A → PR-B` ordering inside an umbrella spec's Plan. + +If a dependency is "obvious to me but uncited", the node label can hint at it; the edge stays out. + +### 4. Render + +Emit a JSON IR matching the schema below, then invoke the renderer. **Do not hand-draw boxes** — the renderer produces deterministically correct layout that the AI's spatial reasoning will not match, especially with cross-edges and fan-out. + +**Schema:** + +```json +{ + "nodes": [ + {"id": "288", "label": "MCP", "status": "done"}, + {"id": "305", "label": "router", "status": "in_progress"}, + {"id": "306", "label": "cleanup","status": "open"} + ], + "edges": [ + {"from": "288", "to": "304", "source": "sub-issue"}, + {"from": "305", "to": "306", "source": "depends-on"} + ], + "close": "300", + "critical_path": ["301", "305", "306", "307", "close"] +} +``` + +- `status` ∈ `{done, in_progress, open}`; defaults to `open`. Status is rendered as a fill color plus a leading emoji — do not embed markers in `label`. +- `edges[].source` ∈ `{sub-issue, depends-on, pr-link, closes, part-of}`, required. This is the citation rule from Conventions made enforceable: no edge without a documented source on GitHub. +- Every `from` / `to` resolves to a declared node id, or the literal `"close"`. +- `critical_path` is optional; communicate it in prose alongside the rendered PNG, not inside the image. + +**Visual encoding.** Status is dual-encoded by fill and a leading emoji so the eye picks up state before reading the label: + +| State | Fill | Border | Emoji | +|-------|------|--------|-------| +| Done | muted green | green | ✅ | +| In-progress | amber | thicker amber | 🟡 | +| Open + all preds done ("available next") | cool blue | thick blue | 🎯 | +| Open + blocked | near-white | grey, dashed | ⬜ | +| Close sentinel | white | double | 🏁 | + +The "available next" highlight is computed from the graph (open + every predecessor is `done`) — no IR field for it. Critical-path edges are *not* bolded: which path is "the" critical path is a caller judgement, and elevating it visually would conflate the recommendation with the graph's topology. Keep the critical path in `ir.critical_path` and let prose carry the next-pick recommendation under the rendered PNG. + +The `--emoji` flag controls whether status emoji are emitted: `on` (default) shows ✅ / 🟡 / 🎯 / ⬜ / 🏁 emoji; `off` falls back to trailing ✓ / … text markers. Turn `off` if the target system lacks a color emoji font and the PNG shows tofu boxes. + +**Invocation.** The renderer ships inside the skill. Use the path that matches how the skill was installed: + +- **Project-scope install** (default for `npx skills add onsager-ai/onsager-skills` from a repo root): `.claude/skills/plan-dag/scripts/plan-dag-render.py` +- **User-global install** (`npx skills add -g …`): `~/.claude/skills/plan-dag/scripts/plan-dag-render.py` + +Pick whichever exists. If unsure, `test -x .claude/skills/plan-dag/scripts/plan-dag-render.py && echo project || echo global`. + +```bash +SCRIPT=.claude/skills/plan-dag/scripts/plan-dag-render.py # project install +# SCRIPT=~/.claude/skills/plan-dag/scripts/plan-dag-render.py # global install + +# default: high-DPI PNG with emoji status indicators +"$SCRIPT" /tmp/plan.json --out /tmp/plan-dag.png + +# emoji off — falls back to ✓ / … text markers in node labels +"$SCRIPT" /tmp/plan.json --out /tmp/plan-dag.png --emoji=off +``` + +The renderer needs `dot` (graphviz; `apt install graphviz` / `brew install graphviz`) on PATH for the SVG layout step, and `node` (≥18) + Playwright Chromium (`npm i -g playwright && npx playwright install chromium`) for the rasterisation step. Both checks run upfront and fail loudly with install guidance — there is no silent fallback to text or ASCII, by design (the formats removed had limitations the PNG output exists to avoid). + +If the renderer aborts with `IR validation failed`, fix the IR — do not work around it by hand-drawing. The validation surface is the citation rule (`Conventions › No invented edges`) made executable. + +**Response handling after running the renderer:** + +- Send the PNG via `SendUserFile` so it renders inline as part of the assistant message. +- Add prose commentary below the file — critical path, next pickable node, sequencing rationale. The PNG carries the topology; the prose carries the recommendation. +- Do **not** re-render the same plan in another format and attach both — one DAG per response. +- For very wide graphs (>10 nodes with cross-edges) where the single PNG becomes unwieldy, split the plan into per-track DAGs (one renderer call per track) and render the cross-edges as a final short prose list, per the existing "Cross-edges" convention in §3. + +## Conventions + +- **No invented edges.** If you can't cite the source (sub-issue link, body prose, PR header), don't draw it. +- **Summarize done nodes when dense.** More than ~3 done nodes in a track? Collapse to `Landed: #A #B #C ✓` in prose rather than enumerating them in the graph. +- **One DAG per response.** Don't render the same plan twice under different framings — pick the framing that answers the question that was actually asked. +- **End with the picked path.** A DAG without a recommended sequence is a wall of boxes. Close with the critical path and the next pickable node in prose, framed so the user can redirect. +- **Don't editorialize inside the diagram.** Commentary ("this looks risky", "we should reorder") goes in prose above or below, never inside a node label. + +## Tests + +Tests live next to the renderer at `scripts/plan-dag-render.test.sh` and exercise validation (bad IR, cycle detection, own-id-prefix boundary cases), the styled DOT structural checks (status fills, available-next, blocked-dashed, close double border), and the end-to-end PNG smoke. The PNG smoke is auto-skipped when Playwright Chromium is unavailable so the validator coverage still runs in restricted CI. Run with the same install-aware path the renderer uses: + +```bash +# project-scope install +.claude/skills/plan-dag/scripts/plan-dag-render.test.sh + +# user-global install +~/.claude/skills/plan-dag/scripts/plan-dag-render.test.sh +``` + +Both forms are in `allowed-tools` so Claude Code doesn't re-prompt for permission. The test script internally `cd`s into the skill root and invokes `scripts/plan-dag-render.py` as a child process — that child invocation runs inside the script's own shell, not through Claude Code's permission engine, so it doesn't need a separate allowlist entry. + +Requires `dot` (graphviz) on PATH; the PNG smoke additionally requires `node` + Playwright Chromium. + +## Related skills + +- The repo's spec-driven-development loop skill (e.g. `onsager-dev-process`, `duhem-dev-process`) — the parent / child / depends-on semantics the DAG visualizes. +- The repo's `issue-spec` skill — how parent / child / depends-on edges are persisted on GitHub. +- The repo's PR-lifecycle skill — how "in-progress" status flips on PR open / merge, which drives the amber fill on the rendered PNG. diff --git a/.claude/skills/plan-dag/fixtures/bad.json b/.agents/skills/plan-dag/fixtures/bad.json similarity index 88% rename from .claude/skills/plan-dag/fixtures/bad.json rename to .agents/skills/plan-dag/fixtures/bad.json index 4a22cbfd1..48793ec52 100644 --- a/.claude/skills/plan-dag/fixtures/bad.json +++ b/.agents/skills/plan-dag/fixtures/bad.json @@ -5,6 +5,7 @@ {"id": "304", "label": "chat", "status": "shipped"}, {"id": "305", "status": "open"}, {"id": "307", "label": "bad \"quote\""}, + {"id": "308", "label": "#308 dup-id-prefix", "status": "open"}, {"id": "close", "label": "reserved"}, "not-an-object" ], diff --git a/.claude/skills/plan-dag/fixtures/cycle.json b/.agents/skills/plan-dag/fixtures/cycle.json similarity index 100% rename from .claude/skills/plan-dag/fixtures/cycle.json rename to .agents/skills/plan-dag/fixtures/cycle.json diff --git a/.claude/skills/plan-dag/fixtures/happy.json b/.agents/skills/plan-dag/fixtures/happy.json similarity index 100% rename from .claude/skills/plan-dag/fixtures/happy.json rename to .agents/skills/plan-dag/fixtures/happy.json diff --git a/.agents/skills/plan-dag/scripts/plan-dag-render.py b/.agents/skills/plan-dag/scripts/plan-dag-render.py new file mode 100755 index 000000000..8f978710c --- /dev/null +++ b/.agents/skills/plan-dag/scripts/plan-dag-render.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""plan-dag-render — JSON DAG IR → high-DPI PNG. + +Single output target: PNG, rasterised from a styled graphviz SVG through +headless Chromium at deviceScaleFactor=2. PNG is the only format that +ships reliably on every surface the skill targets — chat clients render +it inline, status fills/emoji survive, and there's no column-alignment +fragility the way text/ASCII targets had. +""" + +import argparse +import json +import shutil +import subprocess +import sys +from collections import deque +from pathlib import Path + +STATUS_MARKER = {"done": " ✓", "in_progress": " …", "open": ""} +VALID_STATUS = set(STATUS_MARKER.keys()) +VALID_SOURCES = {"sub-issue", "depends-on", "pr-link", "closes", "part-of"} +FORBIDDEN_LABEL_CHARS = ('"', "\\", "[", "]", "\n", "\r") + +# Visual vocabulary. Fills encode status; an additional "available next" +# highlight is computed from the graph (open + all preds done). +_STYLE_DONE = {"fillcolor": "#d4edda", "color": "#52a566"} +_STYLE_IN_PROGRESS = {"fillcolor": "#fff3cd", "color": "#d39e00", "penwidth": "2.0"} +_STYLE_OPEN_BLOCKED = { + "fillcolor": "#f8f9fa", "color": "#adb5bd", + "style": "filled,rounded,dashed", +} +_STYLE_OPEN_AVAILABLE = { + "fillcolor": "#cfe2ff", "color": "#0d6efd", "penwidth": "2.5", +} +_STYLE_CLOSE = {"peripheries": "2", "fillcolor": "#ffffff", "color": "#495057"} + +_EMOJI = { + "done": "✅", + "in_progress": "🟡", + "open_blocked": "⬜", + "open_available": "🎯", + "close": "🏁", +} + + +def _dot_escape(s): + return s.replace("\\", "\\\\").replace('"', '\\"') + + +def validate(ir): + errors = [] + if not isinstance(ir, dict): + return [f"ir must be a JSON object, got {type(ir).__name__}"] + nodes = ir.get("nodes", []) + if not isinstance(nodes, list) or not nodes: + return ["ir.nodes is missing or empty"] + ids = set() + for i, n in enumerate(nodes): + if not isinstance(n, dict): + errors.append(f"nodes[{i}] must be an object, got {type(n).__name__}") + continue + if "id" not in n: + errors.append(f"nodes[{i}] missing id") + continue + nid = str(n["id"]) + if nid == "close": + errors.append( + f"nodes[{i}].id={nid!r} is reserved for the synthetic CLOSE sentinel; " + f"use a different id and set ir.close instead" + ) + continue + if nid in ids: + errors.append(f"duplicate node id: {nid}") + ids.add(nid) + status = n.get("status", "open") + if status not in VALID_STATUS: + errors.append(f"node #{nid}: invalid status {status!r}") + label = n.get("label") + if not label: + errors.append(f"node #{nid}: missing label") + elif not isinstance(label, str): + errors.append(f"node #{nid}: label must be a string, got {type(label).__name__}") + else: + for ch in FORBIDDEN_LABEL_CHARS: + if ch in label: + errors.append( + f"node #{nid}: label contains forbidden character {ch!r} " + f"(any of {FORBIDDEN_LABEL_CHARS} can break output rendering)" + ) + break + # The renderer always prepends "# " to the label. A label that + # already starts with "#" (followed by non-digit or end of + # string) renders as "#288 #288 MCP" — a common mistake when + # pasting GitHub titles verbatim. References to *other* issue + # numbers in the label are fine. + nid_prefix = f"#{nid}" + if label.startswith(nid_prefix) and ( + len(label) == len(nid_prefix) + or not label[len(nid_prefix)].isdigit() + ): + stripped = label[len(nid_prefix):].lstrip(" :-\t") or "" + errors.append( + f"node #{nid}: label {label!r} starts with own id; " + f"the renderer prepends '#{nid} ' automatically — " + f"use just the bare title (e.g. {stripped!r})" + ) + ids.add("close") + edges = ir.get("edges", []) + if not isinstance(edges, list): + errors.append(f"ir.edges must be a list, got {type(edges).__name__}") + return errors + references_close = False + for i, e in enumerate(edges): + if not isinstance(e, dict): + errors.append(f"edges[{i}] must be an object, got {type(e).__name__}") + continue + for end in ("from", "to"): + if end not in e: + errors.append(f"edges[{i}] missing {end}") + else: + val = str(e[end]) + if val == "close": + references_close = True + if val not in ids: + errors.append(f"edges[{i}].{end}={e[end]!r} not in declared nodes") + if not e.get("source"): + errors.append(f"edges[{i}] missing source (citation required)") + elif e["source"] not in VALID_SOURCES: + errors.append( + f"edges[{i}].source={e['source']!r} not in {sorted(VALID_SOURCES)}" + ) + close = ir.get("close") + if references_close and close is None: + errors.append( + "edges reference the CLOSE sentinel but ir.close is missing " + "(set ir.close to the closing issue id, e.g. 'ir.close': '300')" + ) + if close is not None: + if not isinstance(close, (str, int)): + errors.append( + f"ir.close must be a string or int, got {type(close).__name__}" + ) + elif isinstance(close, str) and not close.strip(): + errors.append( + "ir.close is an empty string; set it to the closing issue id, " + "or omit the key entirely" + ) + cp = ir.get("critical_path") + if cp is not None: + if not isinstance(cp, list): + errors.append( + f"ir.critical_path must be a list, got {type(cp).__name__}" + ) + else: + for i, node_id in enumerate(cp): + if not isinstance(node_id, (str, int)): + errors.append( + f"critical_path[{i}]: must be a string or int, " + f"got {type(node_id).__name__}" + ) + elif str(node_id) not in ids: + errors.append( + f"critical_path[{i}]={node_id!r} not in declared nodes" + ) + + # Cycle check — only run if edges are structurally sound, so we don't + # walk over malformed entries already reported above. + if not errors: + indeg = {nid: 0 for nid in ids} + adj = {nid: [] for nid in ids} + for e in edges: + u, v = str(e["from"]), str(e["to"]) + adj[u].append(v) + indeg[v] += 1 + queue = deque(n for n, d in indeg.items() if d == 0) + visited = 0 + while queue: + u = queue.popleft() + visited += 1 + for v in adj[u]: + indeg[v] -= 1 + if indeg[v] == 0: + queue.append(v) + if visited < len(ids): + cyclic = sorted(n for n, d in indeg.items() if d > 0) + errors.append( + f"graph contains a cycle; nodes still in-degree>0 after topo sort: " + f"{', '.join(cyclic)}" + ) + + return errors + + +def _available_next(ir): + """Open nodes whose predecessors are all `done` — i.e. unblocked picks.""" + status_by_id = {str(n["id"]): n.get("status", "open") for n in ir["nodes"]} + preds = {nid: [] for nid in status_by_id} + for e in ir.get("edges", []): + v = str(e["to"]) + if v in preds: + preds[v].append(str(e["from"])) + return { + nid for nid, st in status_by_id.items() + if st == "open" and all(status_by_id.get(p) == "done" for p in preds[nid]) + } + + +def _attrs_str(attrs): + return ", ".join(f'{k}="{v}"' for k, v in attrs.items()) + + +def render_dot(ir, emoji=True): + """Emit styled DOT source for the SVG/PNG pipeline. + + emoji=True (default): prepend a status emoji to each label. + emoji=False: append the legacy `✓` / `…` marker instead. Use when the + target system lacks a color emoji font. + """ + lines = [ + "digraph plan {", + " rankdir=TB;", + ' bgcolor="white";', + ' node [shape=box, style="filled,rounded", fontname="Helvetica", ' + 'fontsize=12, penwidth=1.2, color="#495057", fillcolor="#ffffff"];', + ' edge [color="#6c757d", penwidth=1.0, arrowsize=0.8];', + "", + ] + + available = _available_next(ir) + + for n in ir["nodes"]: + nid = str(n["id"]) + status = n.get("status", "open") + if status == "done": + attrs, em_key = _STYLE_DONE, "done" + elif status == "in_progress": + attrs, em_key = _STYLE_IN_PROGRESS, "in_progress" + elif nid in available: + attrs, em_key = _STYLE_OPEN_AVAILABLE, "open_available" + else: + attrs, em_key = _STYLE_OPEN_BLOCKED, "open_blocked" + if emoji: + label_text = f'{_EMOJI[em_key]} #{nid} {n["label"]}' + else: + label_text = f'#{nid} {n["label"]}{STATUS_MARKER[status]}' + label = _dot_escape(label_text) + lines.append( + f' "{_dot_escape(nid)}" [label="{label}", {_attrs_str(attrs)}];' + ) + + if ir.get("close") is not None: + close_text = ( + f'{_EMOJI["close"]} close #{ir["close"]}' + if emoji else f'close #{ir["close"]}' + ) + close_label = _dot_escape(close_text) + lines.append( + f' "close" [label="{close_label}", {_attrs_str(_STYLE_CLOSE)}];' + ) + lines.append("") + for e in ir.get("edges", []): + lines.append(f' "{_dot_escape(str(e["from"]))}" -> "{_dot_escape(str(e["to"]))}";') + lines.append("}") + return "\n".join(lines) + + +def _dot_to_svg(ir, emoji=True): + """Run `dot -Tsvg` on the styled DOT for this IR. Returns SVG source.""" + if shutil.which("dot") is None: + sys.exit( + "plan-dag-render requires `dot` (graphviz) on PATH. " + "Install: apt install graphviz, or brew install graphviz." + ) + dot_src = render_dot(ir, emoji=emoji) + try: + svg_res = subprocess.run( + ["dot", "-Tsvg"], input=dot_src, + capture_output=True, text=True, timeout=10, + encoding="utf-8", + ) + except subprocess.TimeoutExpired: + sys.exit("`dot -Tsvg` timed out after 10s.") + if svg_res.returncode != 0: + sys.stderr.write(svg_res.stderr) + sys.exit(svg_res.returncode or 1) + return svg_res.stdout + + +def render_png(ir, out_path, emoji=True): + """Render the IR as a high-quality PNG. + + Pipeline: dot -Tsvg → headless Chromium (via Playwright) → PNG. Going + through the browser instead of `dot -Tpng` gives sharper text and + correct anti-aliasing at high DPI, which matters when the PNG is + surfaced to the user as an inline chat image. + """ + # _dot_to_svg checks dot first (the upstream tool); after that we need + # node + Playwright for the rasterisation step. + svg = _dot_to_svg(ir, emoji=emoji) + if shutil.which("node") is None: + sys.exit( + "plan-dag-render requires Node (node ≥18) on PATH for Playwright." + ) + script_dir = Path(__file__).resolve().parent + svg_to_png = script_dir / "svg-to-png.mjs" + if not svg_to_png.exists(): + sys.exit(f"plan-dag-render: missing helper {svg_to_png}") + + try: + png_res = subprocess.run( + ["node", str(svg_to_png), out_path], + input=svg, capture_output=True, text=True, timeout=30, + ) + except subprocess.TimeoutExpired: + sys.exit("svg-to-png.mjs timed out after 30s.") + if png_res.returncode != 0: + sys.stderr.write(png_res.stderr) + sys.exit(png_res.returncode or 1) + sys.stderr.write(png_res.stderr) + + +def main(): + ap = argparse.ArgumentParser( + description="Render plan DAG IR as a high-DPI PNG." + ) + ap.add_argument("ir", help="path to JSON IR, or '-' for stdin") + ap.add_argument( + "--out", required=True, + help="output PNG file path (required).", + ) + ap.add_argument( + "--emoji", default="on", choices=["on", "off"], + help="emoji status indicators in node labels. on (default) shows " + "✅ / 🟡 / 🎯 / ⬜ / 🏁 status emoji; off falls back to ✓ / … " + "text markers. Turn off if the rendering system lacks a color " + "emoji font and you see tofu boxes.", + ) + args = ap.parse_args() + + text = sys.stdin.read() if args.ir == "-" else Path(args.ir).read_text() + try: + ir = json.loads(text) + except json.JSONDecodeError as e: + sys.exit(f"invalid JSON: {e}") + + errors = validate(ir) + if errors: + sys.stderr.write("IR validation failed:\n") + for err in errors: + sys.stderr.write(f" - {err}\n") + sys.exit(1) + + render_png(ir, args.out, emoji=(args.emoji == "on")) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/plan-dag/scripts/plan-dag-render.test.sh b/.agents/skills/plan-dag/scripts/plan-dag-render.test.sh new file mode 100755 index 000000000..f24cbe061 --- /dev/null +++ b/.agents/skills/plan-dag/scripts/plan-dag-render.test.sh @@ -0,0 +1,337 @@ +#!/usr/bin/env bash +# Tests for plan-dag-render. Exits non-zero on any failure or unexpected +# exit code. Suitable for CI. +# +# Required on PATH: +# - `dot` (graphviz) — for the SVG pipeline that feeds the PNG rasteriser. +# apt install graphviz, or brew install graphviz. +# - `node` (≥18) + Playwright Chromium — for the PNG rasteriser itself. +# npm i -g playwright && npx playwright install chromium. +# +# If `node` / Playwright are missing, the PNG smoke test is skipped (not +# failed) so the validator tests still run in restricted CI environments. + +set -u +cd "$(dirname "$0")/.." + +if ! command -v dot >/dev/null 2>&1; then + printf 'plan-dag-render.test: `dot` (graphviz) required on PATH.\n' >&2 + printf 'Install: apt install graphviz, or brew install graphviz.\n' >&2 + exit 2 +fi + +SCRIPT="scripts/plan-dag-render.py" +FIX="fixtures" + +fail=0 +pass=0 + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "happy.json — PNG smoke" +# Skip markers — any of these in the helper's stderr means the local env is +# missing a Playwright/Chromium piece and the test should be skipped rather +# than fail loudly. +png_skip_re='cannot load Playwright|Executable doesn.t exist|browserType\.launch|playwright install' +if command -v node >/dev/null 2>&1; then + png_out="$tmp/happy.png" + "$SCRIPT" "$FIX/happy.json" --out "$png_out" >"$tmp/png.out" 2>"$tmp/png.err" + rc=$? + if [ "$rc" -ne 0 ]; then + if grep -qE "$png_skip_re" "$tmp/png.err"; then + printf ' skip PNG render (Playwright/Chromium not available)\n' + else + fail=$((fail + 1)) + printf ' FAIL PNG render exited %d\n' "$rc" + sed 's/^/ /' < "$tmp/png.err" + fi + elif [ ! -s "$png_out" ]; then + fail=$((fail + 1)) + printf ' FAIL PNG render produced empty output\n' + elif ! file "$png_out" 2>/dev/null | grep -q 'PNG image'; then + fail=$((fail + 1)) + printf ' FAIL PNG output is not a PNG (file: %s)\n' \ + "$(file "$png_out" 2>/dev/null || echo unknown)" + else + pass=$((pass + 1)) + printf ' ok PNG render produced a PNG\n' + fi + + # stdin mode parity with file-arg mode. + cat "$FIX/happy.json" | "$SCRIPT" - --out "$tmp/stdin.png" >/dev/null 2>"$tmp/stdin.err" + rc=$? + if [ "$rc" -ne 0 ]; then + if grep -qE "$png_skip_re" "$tmp/stdin.err"; then + printf ' skip stdin PNG (Playwright/Chromium not available)\n' + else + fail=$((fail + 1)) + printf ' FAIL stdin PNG exited %d\n' "$rc" + sed 's/^/ /' < "$tmp/stdin.err" + fi + elif [ ! -s "$tmp/stdin.png" ]; then + fail=$((fail + 1)) + printf ' FAIL stdin PNG produced empty output\n' + elif ! file "$tmp/stdin.png" 2>/dev/null | grep -q 'PNG image'; then + fail=$((fail + 1)) + printf ' FAIL stdin output is not a PNG (file: %s)\n' \ + "$(file "$tmp/stdin.png" 2>/dev/null || echo unknown)" + else + pass=$((pass + 1)) + printf ' ok stdin mode produces PNG\n' + fi +else + printf ' skip PNG render (node not on PATH)\n' +fi + +echo "missing --out (must fail with guidance)" +"$SCRIPT" "$FIX/happy.json" >"$tmp/noout.out" 2>"$tmp/noout.err" +rc=$? +if [ "$rc" -eq 0 ]; then + fail=$((fail + 1)) + printf ' FAIL no-args succeeded; --out should be required\n' +elif ! grep -qE 'required.*--out|--out.*required|the following arguments are required' "$tmp/noout.err"; then + fail=$((fail + 1)) + printf ' FAIL no-args stderr missing --out guidance\n' + sed 's/^/ /' < "$tmp/noout.err" +else + pass=$((pass + 1)) + printf ' ok no --out fails with guidance\n' +fi + +echo "internal DOT structural checks (via render_dot)" +# We re-import render_dot to assert the visual encoding holds without +# requiring a full PNG render (which needs Chromium). This keeps coverage +# of the styled-DOT logic in CI environments where Playwright is missing. +py3="$(command -v python3)" +"$py3" - <<'PY' "$FIX/happy.json" > "$tmp/happy.dot" 2>"$tmp/happy.dot.err" +import importlib.util, json, sys +spec = importlib.util.spec_from_file_location("pdr", "scripts/plan-dag-render.py") +mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod) +ir = json.loads(open(sys.argv[1]).read()) +print(mod.render_dot(ir, emoji=True)) +PY +rc=$? +if [ "$rc" -ne 0 ]; then + fail=$((fail + 1)) + printf ' FAIL render_dot import exited %d\n' "$rc" + sed 's/^/ /' < "$tmp/happy.dot.err" +else + # Available-next: #304 is open, only pred (#288) is done → blue highlight. + if grep -q '"304"\s*\[.*fillcolor="#cfe2ff"' "$tmp/happy.dot"; then + pass=$((pass + 1)) + printf ' ok available-next (#304) gets blue highlight\n' + else + fail=$((fail + 1)) + printf ' FAIL #304 missing available-next highlight\n' + fi + # #306 is open but blocked (preds #304/#305 not done) → dashed muted style. + if grep -q '"306"\s*\[.*style="filled,rounded,dashed"' "$tmp/happy.dot"; then + pass=$((pass + 1)) + printf ' ok blocked-open (#306) gets dashed style\n' + else + fail=$((fail + 1)) + printf ' FAIL #306 missing dashed blocked-open style\n' + fi + # Close sentinel gets a double border. + if grep -q '"close"\s*\[.*peripheries="2"' "$tmp/happy.dot"; then + pass=$((pass + 1)) + printf ' ok close sentinel gets double border\n' + else + fail=$((fail + 1)) + printf ' FAIL close sentinel missing peripheries="2"\n' + fi + # Critical-path edges stay unbolded — caller judgement, not topology. + crit_edges=$(grep -E '"[^"]+"\s*->\s*"[^"]+"\s*\[' "$tmp/happy.dot" | grep -c 'penwidth' || true) + if [ "$crit_edges" -eq 0 ]; then + pass=$((pass + 1)) + printf ' ok no critical-path edge bolding\n' + else + fail=$((fail + 1)) + printf ' FAIL %d edges carry explicit penwidth (expected 0)\n' "$crit_edges" + fi +fi + +echo "--emoji=off (visible through PNG-or-skip pathway)" +if command -v node >/dev/null 2>&1; then + "$SCRIPT" "$FIX/happy.json" --emoji=off --out "$tmp/happy.off.png" \ + >/dev/null 2>"$tmp/off.err" + rc=$? + if [ "$rc" -ne 0 ]; then + if grep -qE "$png_skip_re" "$tmp/off.err"; then + printf ' skip --emoji=off PNG (Playwright/Chromium not available)\n' + else + fail=$((fail + 1)) + printf ' FAIL --emoji=off exited %d\n' "$rc" + sed 's/^/ /' < "$tmp/off.err" + fi + else + pass=$((pass + 1)) + printf ' ok --emoji=off renders a PNG\n' + fi +fi +# Independent of PNG availability: assert the underlying styled DOT is emoji-free. +"$py3" - <<'PY' "$FIX/happy.json" > "$tmp/happy.dot.off" 2>/dev/null +import importlib.util, json, sys +spec = importlib.util.spec_from_file_location("pdr", "scripts/plan-dag-render.py") +mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod) +ir = json.loads(open(sys.argv[1]).read()) +print(mod.render_dot(ir, emoji=False)) +PY +if grep -q '✅\|🟡\|⬜\|🎯\|🏁' "$tmp/happy.dot.off"; then + fail=$((fail + 1)) + printf ' FAIL --emoji=off leaked emoji into DOT\n' +else + pass=$((pass + 1)) + printf ' ok --emoji=off DOT is emoji-free\n' +fi +if grep -q '#288 MCP ✓' "$tmp/happy.dot.off"; then + pass=$((pass + 1)) + printf ' ok --emoji=off DOT retains ✓ / … text markers\n' +else + fail=$((fail + 1)) + printf ' FAIL --emoji=off DOT missing text marker\n' +fi + +echo "bad.json (must fail validation before rasterising)" +"$SCRIPT" "$FIX/bad.json" --out "$tmp/bad.png" > "$tmp/bad.out" 2>"$tmp/bad.err" +rc=$? +if [ "$rc" -ne 1 ]; then + fail=$((fail + 1)) + printf ' FAIL bad expected exit 1, got %d\n' "$rc" +else + pass=$((pass + 1)) + printf ' ok bad exit 1\n' +fi +for token in 'duplicate node id' 'invalid status' 'missing label' \ + 'not in declared nodes' 'missing source' 'not in [' \ + 'must be an object' 'forbidden character' \ + 'reserved for the synthetic CLOSE' 'ir.close is missing' \ + 'critical_path[1]' 'starts with own id'; do + if grep -qF "$token" "$tmp/bad.err"; then + pass=$((pass + 1)) + printf ' ok bad stderr contains: %s\n' "$token" + else + fail=$((fail + 1)) + printf ' FAIL bad stderr missing: %s\n' "$token" + fi +done +# Validation must short-circuit before invoking dot / node — no PNG should appear. +if [ -e "$tmp/bad.png" ]; then + fail=$((fail + 1)) + printf ' FAIL bad.json produced a PNG despite failing validation\n' +else + pass=$((pass + 1)) + printf ' ok bad.json does not produce a PNG\n' +fi + +echo "own-id-prefix boundary cases (must pass validation)" +# Labels that mention a *different* issue number must not be rejected. +"$py3" - <<'PY' > "$tmp/ref.out" 2>"$tmp/ref.err" +import importlib.util, json, sys +spec = importlib.util.spec_from_file_location("pdr", "scripts/plan-dag-render.py") +mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod) +ir = json.loads('{"nodes":[{"id":"288","label":"MCP (see #500)","status":"done"}],"edges":[]}') +errs = mod.validate(ir) +if errs: + print("VALIDATION ERRORS:") + for e in errs: print(" -", e) + sys.exit(1) +print(mod.render_dot(ir, emoji=False)) +PY +rc=$? +if [ "$rc" -ne 0 ]; then + fail=$((fail + 1)) + printf ' FAIL label referencing another issue rejected (rc=%d)\n' "$rc" + sed 's/^/ /' < "$tmp/ref.err" +elif ! grep -qF '#288 MCP (see #500) ✓' "$tmp/ref.out"; then + fail=$((fail + 1)) + printf ' FAIL expected "#288 MCP (see #500) ✓" in DOT, got:\n' + sed 's/^/ /' < "$tmp/ref.out" +else + pass=$((pass + 1)) + printf ' ok label mentioning a different issue passes (no false positive)\n' +fi +# Own id as a *prefix* of a longer id in the label must also pass. +"$py3" - <<'PY' > "$tmp/pref.out" 2>"$tmp/pref.err" +import importlib.util, json, sys +spec = importlib.util.spec_from_file_location("pdr", "scripts/plan-dag-render.py") +mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod) +ir = json.loads('{"nodes":[{"id":"28","label":"#288 different","status":"open"}],"edges":[]}') +errs = mod.validate(ir) +if errs: + for e in errs: print(" -", e) + sys.exit(1) +print(mod.render_dot(ir, emoji=False)) +PY +rc=$? +if [ "$rc" -ne 0 ]; then + fail=$((fail + 1)) + printf ' FAIL own-id-as-prefix-of-longer-id rejected (rc=%d)\n' "$rc" + sed 's/^/ /' < "$tmp/pref.err" +elif ! grep -qF '#28 #288 different' "$tmp/pref.out"; then + fail=$((fail + 1)) + printf ' FAIL expected "#28 #288 different" in DOT, got:\n' + sed 's/^/ /' < "$tmp/pref.out" +else + pass=$((pass + 1)) + printf ' ok own id as prefix of a longer id in label passes\n' +fi + +echo "empty-string close (must fail validation)" +# Edges can reference "close" + a falsy ir.close, but render_dot needs a +# real close id to emit the styled sentinel — validation catches the +# empty-string case before render does. +empty_close='{"nodes":[{"id":"1","label":"a","status":"done"}],"edges":[{"from":"1","to":"close","source":"closes"}],"close":""}' +echo "$empty_close" | "$SCRIPT" - --out "$tmp/empty-close.png" \ + > "$tmp/empty-close.out" 2>"$tmp/empty-close.err" +rc=$? +if [ "$rc" -ne 1 ]; then + fail=$((fail + 1)) + printf ' FAIL empty-string close expected exit 1, got %d\n' "$rc" +elif ! grep -qF 'empty string' "$tmp/empty-close.err"; then + fail=$((fail + 1)) + printf ' FAIL empty-string close stderr missing guidance:\n' + sed 's/^/ /' < "$tmp/empty-close.err" +else + pass=$((pass + 1)) + printf ' ok empty-string ir.close rejected with guidance\n' +fi + +echo "cycle.json (must fail validation with a cycle error)" +"$SCRIPT" "$FIX/cycle.json" --out "$tmp/cycle.png" > "$tmp/cycle.out" 2>"$tmp/cycle.err" +rc=$? +if [ "$rc" -ne 1 ]; then + fail=$((fail + 1)) + printf ' FAIL cycle expected exit 1, got %d\n' "$rc" +else + pass=$((pass + 1)) + printf ' ok cycle exit 1\n' +fi +if grep -qF 'contains a cycle' "$tmp/cycle.err"; then + pass=$((pass + 1)) + printf ' ok cycle stderr contains: contains a cycle\n' +else + fail=$((fail + 1)) + printf ' FAIL cycle stderr missing: contains a cycle\n' +fi + +echo "missing dot on PATH (must error, not silently fall back)" +PATH="" "$py3" "$SCRIPT" "$FIX/happy.json" --out "$tmp/nodot.png" \ + > "$tmp/nodot.out" 2>"$tmp/nodot.err" +rc=$? +if [ "$rc" -eq 0 ]; then + fail=$((fail + 1)) + printf ' FAIL render without dot succeeded; expected failure\n' +elif ! grep -qF 'requires `dot` (graphviz)' "$tmp/nodot.err"; then + fail=$((fail + 1)) + printf ' FAIL stderr without dot missing install guidance\n' + sed 's/^/ /' < "$tmp/nodot.err" +else + pass=$((pass + 1)) + printf ' ok missing dot errors with install guidance\n' +fi + +echo +printf 'plan-dag-render.test: %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/.claude/skills/plan-dag/scripts/svg-to-png.mjs b/.agents/skills/plan-dag/scripts/svg-to-png.mjs similarity index 100% rename from .claude/skills/plan-dag/scripts/svg-to-png.mjs rename to .agents/skills/plan-dag/scripts/svg-to-png.mjs diff --git a/.claude/skills/plan-dag b/.claude/skills/plan-dag new file mode 120000 index 000000000..52699aecf --- /dev/null +++ b/.claude/skills/plan-dag @@ -0,0 +1 @@ +../../.agents/skills/plan-dag \ No newline at end of file diff --git a/.claude/skills/plan-dag/.upstream-source b/.claude/skills/plan-dag/.upstream-source deleted file mode 100644 index ba9f0d37f..000000000 --- a/.claude/skills/plan-dag/.upstream-source +++ /dev/null @@ -1 +0,0 @@ -onsager-ai/onsager-skills diff --git a/.claude/skills/plan-dag/SKILL.md b/.claude/skills/plan-dag/SKILL.md deleted file mode 100644 index 0e2bfeb20..000000000 --- a/.claude/skills/plan-dag/SKILL.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -name: plan-dag -description: Render the current plan as a monospace-safe text dependency DAG (Unicode box-drawing glyphs) — nodes are issues / sub-issues / PRs, edges come from sub-issue links plus dependency-language prose ("Depends on", "Part of", "Blocks", "Closes") and PR / commit cross-references, and every node carries a done / in-progress / open marker so sequencing and critical path are obvious at a glance. Use when asked "plan as dag", "draw a dag", "dag diagram", "show the dependency graph", "what's blocking what", "what's the critical path", "what can be parallelized", "what's left for #N", or right after a `what's next` survey when sequencing the next pick is the actual question. -allowed-tools: Read, Bash(git log:*), Bash(git status:*), Bash(git branch:*), Bash(.claude/skills/plan-dag/scripts/plan-dag-render.py:*), Bash(~/.claude/skills/plan-dag/scripts/plan-dag-render.py:*), Bash(.claude/skills/plan-dag/scripts/plan-dag-render.test.sh:*), Bash(~/.claude/skills/plan-dag/scripts/plan-dag-render.test.sh:*), mcp__github__issue_read, mcp__github__list_issues, mcp__github__search_issues, mcp__github__list_pull_requests, mcp__github__pull_request_read ---- - -# plan-dag - -Render the current plan as a dependency DAG so sequencing, the critical path, and parallelizable work are visible at a glance. Default output is monospace text using Unicode box-drawing glyphs (`─`, `►`, `┐ ├ ┤ └`) so it lands cleanly in chat, terminals, and PR descriptions. - -This skill is repo-agnostic. It assumes a GitHub-backed issue tracker with sub-issue links and dependency-language prose; it makes no assumptions about specific labels, area taxonomies, or repo-specific dev-process skills. - -## When to use - -- After a `what's next` survey, to commit to a sequence. -- When asked "what's left for #N" on an umbrella spec with sub-issues. -- Before picking the next branch — to see what unblocks the most downstream work. -- When a spec fans out into N sub-issues and the dependency edges aren't all linear. - -Skip when: - -- A single-PR spec — the plan *is* the Plan section, not a DAG. -- An unanswered design question — drawing a DAG before alignment is theater. -- The "graph" is one linear chain of three or fewer nodes — a sentence is shorter than a diagram. - -## Inputs - -| Parameter | Default | -|-----------|----------| -| **Scope** | Inferred — current branch's spec, the umbrella the user named, or the open issues just surveyed. | -| **Granularity** | Spec-level; drop to sub-issue / PR level for an umbrella that has fanned out. | -| **Output format** | Monospace text (Unicode box-drawing) laid out top-to-bottom via graphviz (default). `--as=html --out <path>` for a self-contained HTML page with the SVG and critical-path footer (preferred on web surfaces that render HTML inline — Claude Code on the web, claude.ai); `--as=svg` for raw inline SVG; `--as=png --out <path>` for a high-DPI rasterised image; `--as=ascii` for a pure-ASCII tree; `--as=dot` for raw DOT. | - -## Workflow - -``` -1. Discover Pull issues / sub-issues / PRs in scope -2. Classify Mark each node done / in-progress / open -3. Edge-build Read dependencies from sub-issue links + body prose -4. Render Group by track; cross-edges last; critical path callout -``` - -### 1. Discover - -Resolve the scope from the conversation, not from a generic crawl. Typical triggers: - -- An umbrella spec → walk its sub-issue list (`mcp__github__issue_read` with `method: get_sub_issues`). -- A track of follow-ups from a recent merge → use the priority + area filter the user implied. -- The set of issues just surveyed → reuse that list verbatim, don't refetch. - -For each node, capture: - -- Issue state (`open` / `closed`) and `state_reason` (`completed` vs other). -- Status labels (`in-progress`, `planned`, `draft`) and `priority:*`. -- Linked PRs — look for "merged in PR #N" in the body or comments. -- Sub-issue list (only for umbrella nodes). - -### 2. Classify - -Each node gets exactly one marker: - -| State | Marker | Definition | -|-------|--------|------------| -| Done | `✓` | Closed + `state_reason: completed`, or merged PR. | -| In-progress | `…` | Open + `in-progress` label, or an open PR exists for it. | -| Open | _(none)_ | Open + `planned` / `draft`, no PR. | - -Don't render "blocked" as a separate marker — the inbound edge to a non-done node already shows it. The marker is for the reader's eye, not the graph topology. - -### 3. Edge-build - -Edges come from three sources, in this order of trust. **Don't draw an edge you can't cite from one of these** — speculation pollutes the DAG. - -1. **Sub-issue links** (`mcp__github__issue_read` + `method: get_sub_issues`). Edge direction is **child → parent** (prerequisite → dependent) — the parent closes when its children close, so the DAG flows toward closure. -2. **Explicit prose** in issue body: `Depends on #N`, `Hard depends on #N`, `Blocks #N`, `Part of #N`, `Closes #N`. -3. **PR/commit references**: `merged in PR #N`, `closed by #N`, `PR-A → PR-B` ordering inside an umbrella spec's Plan. - -If a dependency is "obvious to me but uncited", the node label can hint at it; the edge stays out. - -### 4. Render - -Emit a JSON IR matching the schema below, then invoke the renderer. **Do not hand-draw ASCII boxes** — the renderer produces deterministically correct layout that the AI's spatial reasoning will not match, especially with cross-edges and fan-out. - -**Schema:** - -```json -{ - "nodes": [ - {"id": "288", "label": "MCP", "status": "done"}, - {"id": "305", "label": "router", "status": "in_progress"}, - {"id": "306", "label": "cleanup","status": "open"} - ], - "edges": [ - {"from": "288", "to": "304", "source": "sub-issue"}, - {"from": "305", "to": "306", "source": "depends-on"} - ], - "close": "300", - "critical_path": ["301", "305", "306", "307", "close"] -} -``` - -- `status` ∈ `{done, in_progress, open}`; defaults to `open`. Status markers (`✓`, `…` in box-drawing mode; `[done]` / `[wip]` / `[open]` in ASCII mode) are added by the renderer — do not embed them in `label`. -- `edges[].source` ∈ `{sub-issue, depends-on, pr-link, closes, part-of}`, required. This is the citation rule from Conventions made enforceable: no edge without a documented source on GitHub. -- Every `from` / `to` resolves to a declared node id, or the literal `"close"`. -- `critical_path` is optional; renderer appends it as a callout footer under the box-drawing, ASCII, and HTML targets (and as `Critical path:` text in the ASCII output). - -**Invocation.** Default emits top-to-bottom box-drawing via graphviz (requires `dot` on PATH — `apt install graphviz`, or `brew install graphviz`) and is the right choice for terminal-only surfaces. `--as=html --out <path>` writes a self-contained HTML page that wraps the styled SVG with a critical-path footer — preferred on web surfaces that render HTML inline (Claude Code on the web, claude.ai web/mobile). `--as=svg` emits the same styled SVG without the HTML chrome — useful for embedding in markdown / GitHub / external docs. Both need only `dot`. `--as=png --out <path>` rasterises the same DOT through `dot -Tsvg` and a headless Chromium screenshot at deviceScaleFactor=2 — sharper than `dot -Tpng`, but heavier (needs `dot`, `node`, and Playwright Chromium); use it on surfaces that show inline PNGs but not inline HTML. `--as=ascii` produces a pure-ASCII indented tree with no external dependency — used explicitly for restricted terminals, and selected automatically (with a stderr note) when `dot` is missing. `--as=dot` emits raw DOT source for piping or debugging. - -**Visual encoding (DOT / SVG / HTML / PNG targets only).** Status is dual-encoded by fill and a leading emoji so the eye picks up state before reading the label: - -| State | Fill | Border | Emoji | -|-------|------|--------|-------| -| Done | muted green | green | ✅ | -| In-progress | amber | thicker amber | 🟡 | -| Open + all preds done ("available next") | cool blue | thick blue | 🎯 | -| Open + blocked | near-white | grey, dashed | ⬜ | -| Close sentinel | white | double | 🏁 | - -The "available next" highlight is computed from the graph (open + every predecessor is `done`) — no IR field for it. Critical-path edges are *not* bolded: which path is "the" critical path is a caller judgement, and elevating it visually would conflate the recommendation with the graph's topology. Keep the critical path in `ir.critical_path` and let the renderer print it as a footer / let prose carry the next-pick recommendation. - -The text box-drawing and ASCII targets stay glyph-only (`✓` / `…` markers) because their layout math counts characters, not visual columns, and emoji are East Asian Wide. The `--emoji` flag controls whether emoji are emitted in DOT / SVG / HTML / PNG labels: `auto` (default) is on for those four styled targets, off for text targets; `on` / `off` force it. Turn `off` if a target system lacks a color emoji font and you see tofu boxes in the rendered SVG / HTML / PNG. - -The renderer ships inside the skill. Use the path that matches how the skill was installed: - -- **Project-scope install** (default for `npx skills add onsager-ai/onsager-skills` from a repo root): `.claude/skills/plan-dag/scripts/plan-dag-render.py` -- **User-global install** (`npx skills add -g …`): `~/.claude/skills/plan-dag/scripts/plan-dag-render.py` - -Pick whichever exists. If unsure, `test -x .claude/skills/plan-dag/scripts/plan-dag-render.py && echo project || echo global`. - -```bash -SCRIPT=.claude/skills/plan-dag/scripts/plan-dag-render.py # project install -# SCRIPT=~/.claude/skills/plan-dag/scripts/plan-dag-render.py # global install - -# default: top-to-bottom box-drawing via graphviz -"$SCRIPT" /tmp/plan.json - -# self-contained HTML page (preferred on Claude Code on the web / claude.ai; then SendUserFile) -"$SCRIPT" /tmp/plan.json --as=html --out /tmp/plan-dag.html - -# raw inline SVG (embed in markdown / GitHub / docs; stdout by default) -"$SCRIPT" /tmp/plan.json --as=svg --out /tmp/plan-dag.svg - -# high-DPI PNG (fallback for surfaces that render PNGs inline but not HTML) -"$SCRIPT" /tmp/plan.json --as=png --out /tmp/plan-dag.png - -# pure ASCII tree (no external deps; auto-selected when `dot` is missing) -"$SCRIPT" /tmp/plan.json --as=ascii - -# raw DOT for piping / debugging (styled by default; --emoji=off for portability) -"$SCRIPT" /tmp/plan.json --as=dot -"$SCRIPT" /tmp/plan.json --as=dot --emoji=off -``` - -If the renderer aborts with `IR validation failed`, fix the IR — do not work around it by hand-drawing. The validation surface is the citation rule (`Conventions › No invented edges`) made executable. - -**Response handling after running the renderer:** - -- Wrap the renderer's stdout in a fenced block tagged ` ```text ` — never `bash` or unlabeled. Syntax highlighting recolors box-drawing characters (`─`, `│`, `┌`, `►`) and breaks the visual. -- Do **not** retype or paraphrase the renderer output. Copy the tool result verbatim. A single shifted character destroys column alignment, and the AI's spatial reasoning is the failure mode the script was introduced to eliminate. -- Surface rules: - - **Claude Code (terminal runtime):** the stdout is already visible in the terminal pane. Do not duplicate it in the reply. Add commentary only — critical path summary, next pickable node, sequencing rationale. - - **Claude Code on the web, claude.ai web / mobile (HTML-capable, no real terminal pane):** prefer the HTML path. Render with `--as=html --out /tmp/plan-dag.html` and send the file via `SendUserFile` so the user sees a scalable diagram with the critical-path footer baked in. Status is communicated by the per-node fill color plus a label marker — leading emoji by default, or trailing `✓` / `…` glyphs with `--emoji=off` — so the styled SVG is self-explanatory with no separate legend. PNG (`--as=png --out /tmp/plan-dag.png`) is a fine fallback on surfaces that render inline images but not HTML. Add prose commentary below the file — critical path, next pickable node — but don't echo the text-art version too. - - **Markdown / GitHub PR descriptions:** prefer the default text box-drawing in a ` ```text ` block — GitHub renders it faithfully and it stays diff-able. For richer rendering in external docs or notion-style surfaces, `--as=svg` produces a few-KB embeddable SVG. - - **No image surface available (raw terminals, restricted runtimes):** the default stdout box-drawing remains the right output. -- For very wide graphs (>10 nodes with cross-edges) where the default box-drawing is unwieldy, split the plan into per-track DAGs (one renderer call per track) and render the cross-edges as a final short prose list, per the existing "Cross-edges" convention in §3. The SVG / HTML / PNG targets scale further before they become unwieldy than the text target does. - -## Conventions - -- **No invented edges.** If you can't cite the source (sub-issue link, body prose, PR header), don't draw it. -- **Summarize done nodes when dense.** More than ~3 done nodes in a track? Collapse to `Landed: #A #B #C ✓` rather than enumerating. -- **One DAG per response.** Don't render the same plan twice under different framings — pick the framing that answers the question that was actually asked. -- **End with the picked path.** A DAG without a recommended sequence is a wall of boxes. Close with the critical path and the next pickable node, framed so the user can redirect. -- **Don't editorialize inside the diagram.** Commentary ("this looks risky", "we should reorder") goes in prose above or below, never inside a node label. - -## Tests - -Golden tests live next to the renderer at `scripts/plan-dag-render.test.sh` and exercise the validator, both render targets, and the auto-fallback path against fixtures in `fixtures/`. Run with the same install-aware path the renderer uses: - -```bash -# project-scope install -.claude/skills/plan-dag/scripts/plan-dag-render.test.sh - -# user-global install -~/.claude/skills/plan-dag/scripts/plan-dag-render.test.sh -``` - -Both forms are in `allowed-tools` so Claude Code doesn't re-prompt for permission. The test script internally `cd`s into the skill root and invokes `scripts/plan-dag-render.py` as a child process — that child invocation runs inside the script's own shell, not through Claude Code's permission engine, so it doesn't need a separate allowlist entry. - -Requires `dot` (graphviz) on PATH. - -## Related skills - -- The repo's spec-driven-development loop skill (e.g. `onsager-dev-process`, `duhem-dev-process`) — the parent / child / depends-on semantics the DAG visualizes. -- The repo's `issue-spec` skill — how parent / child / depends-on edges are persisted on GitHub. -- The repo's PR-lifecycle skill — how "in-progress" status flips on PR open / merge, which drives the `…` marker. diff --git a/.claude/skills/plan-dag/fixtures/expected/happy.ascii b/.claude/skills/plan-dag/fixtures/expected/happy.ascii deleted file mode 100644 index 3e2457a9f..000000000 --- a/.claude/skills/plan-dag/fixtures/expected/happy.ascii +++ /dev/null @@ -1,16 +0,0 @@ -#301 spine [done] - +- #305 router [wip] - +- #306 cleanup [open] - +- #307 docs [open] - +- close #300 - -#288 MCP [done] - +- #304 chat [open] - -#302 forge [done] - -Cross-edges: - #302 -> #305 (depends-on) - #304 -> #306 (sub-issue) - -Critical path: #301 -> #305 -> #306 -> #307 -> close diff --git a/.claude/skills/plan-dag/fixtures/expected/happy.dot b/.claude/skills/plan-dag/fixtures/expected/happy.dot deleted file mode 100644 index 7490beadf..000000000 --- a/.claude/skills/plan-dag/fixtures/expected/happy.dot +++ /dev/null @@ -1,23 +0,0 @@ -digraph plan { - rankdir=TB; - bgcolor="white"; - node [shape=box, style="filled,rounded", fontname="Helvetica", fontsize=12, penwidth=1.2, color="#495057", fillcolor="#ffffff"]; - edge [color="#6c757d", penwidth=1.0, arrowsize=0.8]; - - "288" [label="✅ #288 MCP", fillcolor="#d4edda", color="#52a566"]; - "301" [label="✅ #301 spine", fillcolor="#d4edda", color="#52a566"]; - "302" [label="✅ #302 forge", fillcolor="#d4edda", color="#52a566"]; - "304" [label="🎯 #304 chat", fillcolor="#cfe2ff", color="#0d6efd", penwidth="2.5"]; - "305" [label="🟡 #305 router", fillcolor="#fff3cd", color="#d39e00", penwidth="2.0"]; - "306" [label="⬜ #306 cleanup", fillcolor="#f8f9fa", color="#adb5bd", style="filled,rounded,dashed"]; - "307" [label="⬜ #307 docs", fillcolor="#f8f9fa", color="#adb5bd", style="filled,rounded,dashed"]; - "close" [label="🏁 close #300", peripheries="2", fillcolor="#ffffff", color="#495057"]; - - "288" -> "304"; - "301" -> "305"; - "302" -> "305"; - "304" -> "306"; - "305" -> "306"; - "306" -> "307"; - "307" -> "close"; -} diff --git a/.claude/skills/plan-dag/fixtures/expected/happy.dot.noemoji b/.claude/skills/plan-dag/fixtures/expected/happy.dot.noemoji deleted file mode 100644 index 046b93a8a..000000000 --- a/.claude/skills/plan-dag/fixtures/expected/happy.dot.noemoji +++ /dev/null @@ -1,23 +0,0 @@ -digraph plan { - rankdir=TB; - bgcolor="white"; - node [shape=box, style="filled,rounded", fontname="Helvetica", fontsize=12, penwidth=1.2, color="#495057", fillcolor="#ffffff"]; - edge [color="#6c757d", penwidth=1.0, arrowsize=0.8]; - - "288" [label="#288 MCP ✓", fillcolor="#d4edda", color="#52a566"]; - "301" [label="#301 spine ✓", fillcolor="#d4edda", color="#52a566"]; - "302" [label="#302 forge ✓", fillcolor="#d4edda", color="#52a566"]; - "304" [label="#304 chat", fillcolor="#cfe2ff", color="#0d6efd", penwidth="2.5"]; - "305" [label="#305 router …", fillcolor="#fff3cd", color="#d39e00", penwidth="2.0"]; - "306" [label="#306 cleanup", fillcolor="#f8f9fa", color="#adb5bd", style="filled,rounded,dashed"]; - "307" [label="#307 docs", fillcolor="#f8f9fa", color="#adb5bd", style="filled,rounded,dashed"]; - "close" [label="close #300", peripheries="2", fillcolor="#ffffff", color="#495057"]; - - "288" -> "304"; - "301" -> "305"; - "302" -> "305"; - "304" -> "306"; - "305" -> "306"; - "306" -> "307"; - "307" -> "close"; -} diff --git a/.claude/skills/plan-dag/fixtures/expected/happy.tb b/.claude/skills/plan-dag/fixtures/expected/happy.tb deleted file mode 100644 index aff5d998e..000000000 --- a/.claude/skills/plan-dag/fixtures/expected/happy.tb +++ /dev/null @@ -1,27 +0,0 @@ - - ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ - │ #288 MCP ✓ │ │ #301 spine ✓ │ │ #302 forge ✓ │ - └────────────────────┘ └────────────────────┘ └────────────────────┘ - │ │ │ - ▼ ▼ │ - ┌─────────────────┐ ┌───────────────────────┐ │ - │ #304 chat │ │ #305 router … │◄───────────┘ - └─────────────────┘ └───────────────────────┘ - │ │ - ▼ ▼ - ┌──────────────────────┐ - │ #306 cleanup │ - └──────────────────────┘ - │ - ▼ - ┌─────────────────┐ - │ #307 docs │ - └─────────────────┘ - │ - ▼ - ┌──────────────────┐ - │ close #300 │ - └──────────────────┘ - - -Critical path: #301 → #305 → #306 → #307 → close diff --git a/.claude/skills/plan-dag/fixtures/expected/wide.ascii b/.claude/skills/plan-dag/fixtures/expected/wide.ascii deleted file mode 100644 index 0227afb57..000000000 --- a/.claude/skills/plan-dag/fixtures/expected/wide.ascii +++ /dev/null @@ -1,18 +0,0 @@ -#250 schema [done] - +- #261 api [open] - +- #265 auth [open] - +- #266 ui [open] - +- #267 tests [open] - +- #268 docs [open] - +- close #260 - +- #262 worker [wip] - +- #264 metrics [open] - -#251 migrate [done] - +- #263 cache [open] - -Cross-edges: - #251 -> #262 (depends-on) - #261 -> #266 (sub-issue) - #263 -> #264 (sub-issue) - #264 -> #267 (sub-issue) diff --git a/.claude/skills/plan-dag/fixtures/expected/wide.tb b/.claude/skills/plan-dag/fixtures/expected/wide.tb deleted file mode 100644 index 5f32c6842..000000000 --- a/.claude/skills/plan-dag/fixtures/expected/wide.tb +++ /dev/null @@ -1,35 +0,0 @@ - - ┌───────────────────────┐ ┌────────────────────────┐ - │ #250 schema ✓ │ │ #251 migrate ✓ │ - └───────────────────────┘ └────────────────────────┘ - │ │ │ │ - ▼ ▼ ▼ ▼ - ┌──────────────┐ ┌───────────────────────┐ ┌──────────────────┐ - │ #261 api │ │ #262 worker … │ ┌─│ #263 cache │ - └──────────────┘ └───────────────────────┘ │ └──────────────────┘ - │ │ │ │ - ▼ │ ▼ ▼│ - ┌─────────────────┐│ ┌────────────────────┐ - │ #265 auth ││ │ #264 metrics │ - └─────────────────┘│ └────────────────────┘ - │ │ │ - ▼ ▼ │ - ┌─────────────┐ │ - │ #266 ui │ │ - └─────────────┘ │ - │ │ - ▼ ▼ - ┌────────────────┐ - │ #267 tests │ - └────────────────┘ - │ - ▼ - ┌─────────────────┐ - │ #268 docs │ - └─────────────────┘ - │ - ▼ - ┌──────────────────┐ - │ close #260 │ - └──────────────────┘ - diff --git a/.claude/skills/plan-dag/fixtures/wide.json b/.claude/skills/plan-dag/fixtures/wide.json deleted file mode 100644 index 4e49721b1..000000000 --- a/.claude/skills/plan-dag/fixtures/wide.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "nodes": [ - {"id": "250", "label": "schema", "status": "done"}, - {"id": "251", "label": "migrate", "status": "done"}, - {"id": "261", "label": "api", "status": "open"}, - {"id": "262", "label": "worker", "status": "in_progress"}, - {"id": "263", "label": "cache", "status": "open"}, - {"id": "264", "label": "metrics", "status": "open"}, - {"id": "265", "label": "auth", "status": "open"}, - {"id": "266", "label": "ui", "status": "open"}, - {"id": "267", "label": "tests", "status": "open"}, - {"id": "268", "label": "docs", "status": "open"} - ], - "edges": [ - {"from": "250", "to": "261", "source": "depends-on"}, - {"from": "250", "to": "262", "source": "depends-on"}, - {"from": "251", "to": "262", "source": "depends-on"}, - {"from": "251", "to": "263", "source": "depends-on"}, - {"from": "261", "to": "265", "source": "sub-issue"}, - {"from": "261", "to": "266", "source": "sub-issue"}, - {"from": "262", "to": "264", "source": "sub-issue"}, - {"from": "263", "to": "264", "source": "sub-issue"}, - {"from": "265", "to": "266", "source": "depends-on"}, - {"from": "264", "to": "267", "source": "sub-issue"}, - {"from": "266", "to": "267", "source": "sub-issue"}, - {"from": "267", "to": "268", "source": "sub-issue"}, - {"from": "268", "to": "close", "source": "closes"} - ], - "close": "260" -} diff --git a/.claude/skills/plan-dag/scripts/plan-dag-render.py b/.claude/skills/plan-dag/scripts/plan-dag-render.py deleted file mode 100755 index b3e64531c..000000000 --- a/.claude/skills/plan-dag/scripts/plan-dag-render.py +++ /dev/null @@ -1,800 +0,0 @@ -#!/usr/bin/env python3 -"""plan-dag-render — JSON DAG IR → graphviz box-drawing / raw ASCII tree / DOT / SVG / HTML / PNG.""" - -import argparse -import json -import math -import shlex -import shutil -import subprocess -import sys -from collections import deque -from html import escape as html_escape -from pathlib import Path - -STATUS_MARKER = {"done": " ✓", "in_progress": " …", "open": ""} -STATUS_WORD = {"done": "done", "in_progress": "wip", "open": "open"} -VALID_STATUS = set(STATUS_MARKER.keys()) -VALID_SOURCES = {"sub-issue", "depends-on", "pr-link", "closes", "part-of"} -FORBIDDEN_LABEL_CHARS = ('"', "\\", "[", "]", "\n", "\r") - -# Styled-DOT visual vocabulary. Fills encode status; an additional "available -# next" highlight is computed from the graph (open + all preds done). Used by -# --as=dot and --as=png only — text/ascii paths stay glyph-only because the -# layout math counts characters, not visual columns. -_STYLE_DONE = {"fillcolor": "#d4edda", "color": "#52a566"} -_STYLE_IN_PROGRESS = {"fillcolor": "#fff3cd", "color": "#d39e00", "penwidth": "2.0"} -_STYLE_OPEN_BLOCKED = { - "fillcolor": "#f8f9fa", "color": "#adb5bd", - "style": "filled,rounded,dashed", -} -_STYLE_OPEN_AVAILABLE = { - "fillcolor": "#cfe2ff", "color": "#0d6efd", "penwidth": "2.5", -} -_STYLE_CLOSE = {"peripheries": "2", "fillcolor": "#ffffff", "color": "#495057"} - -_EMOJI = { - "done": "✅", - "in_progress": "🟡", - "open_blocked": "⬜", - "open_available": "🎯", - "close": "🏁", -} - - -def _dot_escape(s): - return s.replace("\\", "\\\\").replace('"', '\\"') - - -def validate(ir): - errors = [] - if not isinstance(ir, dict): - return [f"ir must be a JSON object, got {type(ir).__name__}"] - nodes = ir.get("nodes", []) - if not isinstance(nodes, list) or not nodes: - return ["ir.nodes is missing or empty"] - ids = set() - for i, n in enumerate(nodes): - if not isinstance(n, dict): - errors.append(f"nodes[{i}] must be an object, got {type(n).__name__}") - continue - if "id" not in n: - errors.append(f"nodes[{i}] missing id") - continue - nid = str(n["id"]) - if nid == "close": - errors.append( - f"nodes[{i}].id={nid!r} is reserved for the synthetic CLOSE sentinel; " - f"use a different id and set ir.close instead" - ) - continue - if nid in ids: - errors.append(f"duplicate node id: {nid}") - ids.add(nid) - status = n.get("status", "open") - if status not in VALID_STATUS: - errors.append(f"node #{nid}: invalid status {status!r}") - label = n.get("label") - if not label: - errors.append(f"node #{nid}: missing label") - elif not isinstance(label, str): - errors.append(f"node #{nid}: label must be a string, got {type(label).__name__}") - else: - for ch in FORBIDDEN_LABEL_CHARS: - if ch in label: - errors.append( - f"node #{nid}: label contains forbidden character {ch!r} " - f"(any of {FORBIDDEN_LABEL_CHARS} can break output rendering)" - ) - break - ids.add("close") - edges = ir.get("edges", []) - if not isinstance(edges, list): - errors.append(f"ir.edges must be a list, got {type(edges).__name__}") - return errors - references_close = False - for i, e in enumerate(edges): - if not isinstance(e, dict): - errors.append(f"edges[{i}] must be an object, got {type(e).__name__}") - continue - for end in ("from", "to"): - if end not in e: - errors.append(f"edges[{i}] missing {end}") - else: - val = str(e[end]) - if val == "close": - references_close = True - if val not in ids: - errors.append(f"edges[{i}].{end}={e[end]!r} not in declared nodes") - if not e.get("source"): - errors.append(f"edges[{i}] missing source (citation required)") - elif e["source"] not in VALID_SOURCES: - errors.append( - f"edges[{i}].source={e['source']!r} not in {sorted(VALID_SOURCES)}" - ) - close = ir.get("close") - if references_close and close is None: - errors.append( - "edges reference the CLOSE sentinel but ir.close is missing " - "(set ir.close to the closing issue id, e.g. 'ir.close': '300')" - ) - if close is not None and not isinstance(close, (str, int)): - errors.append( - f"ir.close must be a string or int, got {type(close).__name__}" - ) - cp = ir.get("critical_path") - if cp is not None: - if not isinstance(cp, list): - errors.append( - f"ir.critical_path must be a list, got {type(cp).__name__}" - ) - else: - for i, node_id in enumerate(cp): - if not isinstance(node_id, (str, int)): - errors.append( - f"critical_path[{i}]: must be a string or int, " - f"got {type(node_id).__name__}" - ) - elif str(node_id) not in ids: - errors.append( - f"critical_path[{i}]={node_id!r} not in declared nodes" - ) - - # Cycle check — only run if edges are structurally sound, so we don't - # walk over malformed entries already reported above. - if not errors: - indeg = {nid: 0 for nid in ids} - adj = {nid: [] for nid in ids} - for e in edges: - u, v = str(e["from"]), str(e["to"]) - adj[u].append(v) - indeg[v] += 1 - queue = deque(n for n, d in indeg.items() if d == 0) - visited = 0 - while queue: - u = queue.popleft() - visited += 1 - for v in adj[u]: - indeg[v] -= 1 - if indeg[v] == 0: - queue.append(v) - if visited < len(ids): - cyclic = sorted(n for n, d in indeg.items() if d > 0) - errors.append( - f"graph contains a cycle; nodes still in-degree>0 after topo sort: " - f"{', '.join(cyclic)}" - ) - - return errors - - -def _available_next(ir): - """Open nodes whose predecessors are all `done` — i.e. unblocked picks. - - Used by the styled DOT path to highlight the next pickable node(s). - The close sentinel is excluded; it isn't a pickable task. - """ - status_by_id = {str(n["id"]): n.get("status", "open") for n in ir["nodes"]} - preds = {nid: [] for nid in status_by_id} - for e in ir.get("edges", []): - v = str(e["to"]) - if v in preds: - preds[v].append(str(e["from"])) - return { - nid for nid, st in status_by_id.items() - if st == "open" and all(status_by_id.get(p) == "done" for p in preds[nid]) - } - - -def _attrs_str(attrs): - return ", ".join(f'{k}="{v}"' for k, v in attrs.items()) - - -def render_dot(ir, rankdir="TB", extra_graph_attrs=(), styled=False, emoji=False): - """Emit DOT source. - - styled=False: plain boxes with `✓`/`…` status markers — keeps the - `dot -Tplain` geometry that render_tb_boxart parses, and matches - the existing tb/ascii golden output. - styled=True: status fills, available-next highlight, double-bordered - close sentinel. Used by --as=dot and --as=png. - emoji=True (only meaningful with styled=True): prepend a status emoji - to each label and drop the `✓`/`…` marker. Requires a color emoji - font on the rendering system. - """ - lines = ["digraph plan {", f" rankdir={rankdir};"] - for attr in extra_graph_attrs: - lines.append(f" {attr};") - if styled: - lines += [ - ' bgcolor="white";', - ' node [shape=box, style="filled,rounded", fontname="Helvetica", ' - 'fontsize=12, penwidth=1.2, color="#495057", fillcolor="#ffffff"];', - ' edge [color="#6c757d", penwidth=1.0, arrowsize=0.8];', - "", - ] - else: - lines += [" node [shape=box];", ""] - - available = _available_next(ir) if styled else set() - - for n in ir["nodes"]: - nid = str(n["id"]) - status = n.get("status", "open") - if styled: - if status == "done": - attrs, em_key = _STYLE_DONE, "done" - elif status == "in_progress": - attrs, em_key = _STYLE_IN_PROGRESS, "in_progress" - elif nid in available: - attrs, em_key = _STYLE_OPEN_AVAILABLE, "open_available" - else: - attrs, em_key = _STYLE_OPEN_BLOCKED, "open_blocked" - if emoji: - label_text = f'{_EMOJI[em_key]} #{nid} {n["label"]}' - else: - label_text = f'#{nid} {n["label"]}{STATUS_MARKER[status]}' - label = _dot_escape(label_text) - lines.append( - f' "{_dot_escape(nid)}" [label="{label}", {_attrs_str(attrs)}];' - ) - else: - label = _dot_escape(f'#{nid} {n["label"]}{STATUS_MARKER[status]}') - lines.append(f' "{_dot_escape(nid)}" [label="{label}"];') - - if ir.get("close"): - if styled: - close_text = ( - f'{_EMOJI["close"]} close #{ir["close"]}' - if emoji else f'close #{ir["close"]}' - ) - close_label = _dot_escape(close_text) - lines.append( - f' "close" [label="{close_label}", {_attrs_str(_STYLE_CLOSE)}];' - ) - else: - close_label = _dot_escape(f'close #{ir["close"]}') - lines.append(f' "close" [label="{close_label}"];') - lines.append("") - for e in ir.get("edges", []): - lines.append(f' "{_dot_escape(str(e["from"]))}" -> "{_dot_escape(str(e["to"]))}";') - lines.append("}") - return "\n".join(lines) - - -def render_dot_ortho(ir): - """Like render_dot but with rectilinear edge routing for grid rendering.""" - return render_dot(ir, extra_graph_attrs=("splines=ortho",)) - - -_TB_XS = 14.0 -_TB_YS = 5.0 -_TB_N, _TB_S, _TB_E, _TB_W = 1, 2, 4, 8 -_TB_GLYPHS = { - _TB_N | _TB_S: "│", _TB_E | _TB_W: "─", - _TB_N | _TB_E: "└", _TB_N | _TB_W: "┘", - _TB_S | _TB_E: "┌", _TB_S | _TB_W: "┐", - _TB_N | _TB_S | _TB_E: "├", _TB_N | _TB_S | _TB_W: "┤", - _TB_N | _TB_E | _TB_W: "┴", _TB_S | _TB_E | _TB_W: "┬", - _TB_N | _TB_S | _TB_E | _TB_W: "┼", - _TB_N: "│", _TB_S: "│", _TB_E: "─", _TB_W: "─", -} - - -def _parse_plain(text): - g = {"w": 0.0, "h": 0.0, "nodes": [], "edges": []} - for line in text.splitlines(): - toks = shlex.split(line) - if not toks: - continue - if toks[0] == "graph": - g["w"], g["h"] = float(toks[2]), float(toks[3]) - elif toks[0] == "node": - g["nodes"].append({ - "id": toks[1], - "cx": float(toks[2]), "cy": float(toks[3]), - "w": float(toks[4]), "h": float(toks[5]), - "label": toks[6], - }) - elif toks[0] == "edge": - n = int(toks[3]) - pts = [(float(toks[4 + 2 * i]), float(toks[5 + 2 * i])) for i in range(n)] - g["edges"].append({"from": toks[1], "to": toks[2], "pts": pts}) - return g - - -def _dedupe(seq): - out = [] - for p in seq: - if not out or out[-1] != p: - out.append(p) - return out - - -def render_tb_boxart(ir): - """Render the IR as a top-to-bottom box-drawing DAG via real graphviz layout.""" - dot = render_dot_ortho(ir) - try: - res = subprocess.run( - ["dot", "-Tplain"], input=dot, - capture_output=True, text=True, timeout=10, - ) - except subprocess.TimeoutExpired: - sys.exit("`dot -Tplain` timed out after 10s. Try a smaller IR, or --as=ascii.") - if res.returncode != 0: - sys.stderr.write(res.stderr) - sys.exit(res.returncode) - g = _parse_plain(res.stdout) - - Wt = int(math.ceil(g["w"] * _TB_XS)) + 4 - Ht = int(math.ceil(g["h"] * _TB_YS)) + 2 - - def px(x): return int(round(x * _TB_XS)) + 2 - def py(y): return int(round((g["h"] - y) * _TB_YS)) + 1 - - canvas = [[None] * Wt for _ in range(Ht)] - edge_dirs = [[0] * Wt for _ in range(Ht)] - arrows = {} - - boxes = {} - for n in g["nodes"]: - cx, cy = px(n["cx"]), py(n["cy"]) - bw = max(len(n["label"]) + 4, int(round(n["w"] * _TB_XS))) - if (bw - len(n["label"])) % 2: - bw += 1 - left = cx - bw // 2 - right = left + bw - 1 - top, bot = cy - 1, cy + 1 - boxes[n["id"]] = (top, left, bot, right) - canvas[top][left] = "┌" - canvas[top][right] = "┐" - canvas[bot][left] = "└" - canvas[bot][right] = "┘" - for c in range(left + 1, right): - canvas[top][c] = "─" - canvas[bot][c] = "─" - canvas[cy][left] = "│" - canvas[cy][right] = "│" - label = n["label"] - lpad = (bw - 2 - len(label)) // 2 - for i, ch in enumerate(label): - canvas[cy][left + 1 + lpad + i] = ch - - def is_box_cell(r, c): - return 0 <= r < Ht and 0 <= c < Wt and canvas[r][c] is not None - - def add(r, c, bit): - if 0 <= r < Ht and 0 <= c < Wt and canvas[r][c] is None: - edge_dirs[r][c] |= bit - - for e in g["edges"]: - pts = _dedupe(e["pts"]) - if len(pts) < 2: - continue - grid_pts = _dedupe([(py(y), px(x)) for x, y in pts]) - if len(grid_pts) < 2: - continue - for i in range(len(grid_pts) - 1): - r1, c1 = grid_pts[i] - r2, c2 = grid_pts[i + 1] - if r1 == r2: - lo, hi = sorted([c1, c2]) - for c in range(lo, hi + 1): - if c > lo: - add(r1, c, _TB_W) - if c < hi: - add(r1, c, _TB_E) - elif c1 == c2: - lo, hi = sorted([r1, r2]) - for r in range(lo, hi + 1): - if r > lo: - add(r, c1, _TB_N) - if r < hi: - add(r, c1, _TB_S) - - head_top, head_left, head_bot, head_right = boxes[e["to"]] - last_r, last_c = grid_pts[-1] - arrow = arrow_r = arrow_c = mask_bit = None - if last_r < head_top: - arrow, arrow_r = "▼", head_top - 1 - arrow_c = max(head_left + 1, min(head_right - 1, last_c)) - for r in range(last_r, arrow_r): - add(r, last_c, _TB_S); add(r + 1, last_c, _TB_N) - mask_bit = _TB_S - elif last_r > head_bot: - arrow, arrow_r = "▲", head_bot + 1 - arrow_c = max(head_left + 1, min(head_right - 1, last_c)) - for r in range(arrow_r, last_r): - add(r, last_c, _TB_S); add(r + 1, last_c, _TB_N) - mask_bit = _TB_N - elif last_c < head_left: - arrow, arrow_c = "►", head_left - 1 - arrow_r = max(head_top + 1, min(head_bot - 1, last_r)) - for c in range(last_c, arrow_c): - add(last_r, c, _TB_E); add(last_r, c + 1, _TB_W) - mask_bit = _TB_E - elif last_c > head_right: - arrow, arrow_c = "◄", head_right + 1 - arrow_r = max(head_top + 1, min(head_bot - 1, last_r)) - for c in range(arrow_c, last_c): - add(last_r, c, _TB_E); add(last_r, c + 1, _TB_W) - mask_bit = _TB_W - if arrow and 0 <= arrow_r < Ht and 0 <= arrow_c < Wt and not is_box_cell(arrow_r, arrow_c): - arrows[(arrow_r, arrow_c)] = arrow - edge_dirs[arrow_r][arrow_c] |= mask_bit - - for r in range(Ht): - for c in range(Wt): - if canvas[r][c] is not None: - continue - if (r, c) in arrows: - canvas[r][c] = arrows[(r, c)] - elif edge_dirs[r][c]: - canvas[r][c] = _TB_GLYPHS.get(edge_dirs[r][c], "·") - - return "\n".join( - "".join(ch if ch is not None else " " for ch in row).rstrip() - for row in canvas - ) - - -def render_ascii(ir): - """Render the IR as a pure-ASCII indented tree plus cross-edges and critical path. - - Tree shape: each non-root node hangs off the predecessor that lies on its longest - path. Remaining edges are listed under `Cross-edges:`. Critical path printed last. - """ - nodes_by_id = {str(n["id"]): n for n in ir["nodes"]} - node_order = [str(n["id"]) for n in ir["nodes"]] - close_id = ir.get("close") - has_close = close_id is not None - - all_ids = list(node_order) - if has_close: - all_ids.append("close") - id_pos = {nid: i for i, nid in enumerate(all_ids)} - - raw_edges = [(str(e["from"]), str(e["to"]), e["source"]) for e in ir.get("edges", [])] - - successors = {nid: [] for nid in all_ids} - predecessors = {nid: [] for nid in all_ids} - for u, v, _ in raw_edges: - successors[u].append(v) - predecessors[v].append(u) - - indeg = {nid: len(predecessors[nid]) for nid in all_ids} - queue = deque(nid for nid in all_ids if indeg[nid] == 0) - topo = [] - seen = set() - while queue: - u = queue.popleft() - if u in seen: - continue - seen.add(u) - topo.append(u) - for v in successors[u]: - indeg[v] -= 1 - if indeg[v] == 0: - queue.append(v) - for nid in all_ids: - if nid not in seen: - topo.append(nid) - - depth = {nid: 0 for nid in all_ids} - for u in topo: - for v in successors[u]: - if depth[u] + 1 > depth[v]: - depth[v] = depth[u] + 1 - - cp = ir.get("critical_path") or [] - cp_strs = [str(x) for x in cp] - cp_pairs = set(zip(cp_strs[:-1], cp_strs[1:])) - cp_nodes = set(cp_strs) - - # Parent selection: max depth wins; tie-break by critical-path membership, - # then by declaration order. This keeps the critical chain as the tree spine. - parent = {} - for nid in all_ids: - if not predecessors[nid]: - continue - parent[nid] = min( - predecessors[nid], - key=lambda p: (-depth[p], 0 if (p, nid) in cp_pairs else 1, id_pos[p]), - ) - - tree_edges = {(par, child) for child, par in parent.items()} - cross_edges = [(u, v, src) for u, v, src in raw_edges if (u, v) not in tree_edges] - - tree_children = {nid: [] for nid in all_ids} - for par, child in tree_edges: - tree_children[par].append(child) - for nid in tree_children: - tree_children[nid].sort( - key=lambda c: (0 if (nid, c) in cp_pairs else 1, -depth[c], id_pos[c]), - ) - - roots = sorted( - (nid for nid in all_ids if not predecessors[nid]), - key=lambda r: (0 if r in cp_nodes else 1, id_pos[r]), - ) - - def fmt(nid): - if nid == "close": - return f"close #{close_id}" - n = nodes_by_id[nid] - return f"#{nid} {n['label']} [{STATUS_WORD[n.get('status', 'open')]}]" - - def walk(nid, level, acc): - if level == 0: - acc.append(fmt(nid)) - else: - acc.append(f"{' ' * (level - 1)} +- {fmt(nid)}") - for child in tree_children[nid]: - walk(child, level + 1, acc) - - chunks = [] - for root in roots: - acc = [] - walk(root, 0, acc) - chunks.append("\n".join(acc)) - out = "\n\n".join(chunks) - - if cross_edges: - ce_lines = ["Cross-edges:"] - for u, v, src in cross_edges: - ulab = "close" if u == "close" else f"#{u}" - vlab = "close" if v == "close" else f"#{v}" - ce_lines.append(f" {ulab} -> {vlab} ({src})") - out += "\n\n" + "\n".join(ce_lines) - - if cp_strs: - path = " -> ".join("close" if n == "close" else f"#{n}" for n in cp_strs) - out += f"\n\nCritical path: {path}" - - return out - - -def _dot_to_svg(ir, emoji=True): - """Run `dot -Tsvg` on the styled DOT for this IR. Returns SVG source. - - Shared by --as=svg, --as=html, and --as=png. - """ - if shutil.which("dot") is None: - sys.exit( - "SVG / HTML / PNG targets require `dot` (graphviz) on PATH. " - "Install: apt install graphviz, or brew install graphviz." - ) - dot_src = render_dot(ir, styled=True, emoji=emoji) - try: - svg_res = subprocess.run( - ["dot", "-Tsvg"], input=dot_src, - capture_output=True, text=True, timeout=10, - encoding="utf-8", - ) - except subprocess.TimeoutExpired: - sys.exit("`dot -Tsvg` timed out after 10s.") - if svg_res.returncode != 0: - sys.stderr.write(svg_res.stderr) - sys.exit(svg_res.returncode or 1) - return svg_res.stdout - - -def render_svg(ir, emoji=True): - """Render the IR as inline SVG: graphviz output with the XML decl + - DOCTYPE stripped, so the result pastes cleanly into HTML / markdown / - GitHub without producing invalid markup. Scalable, embeddable, a few KB. - """ - return _strip_svg_prolog(_dot_to_svg(ir, emoji=emoji)) - - -_HTML_TEMPLATE = """<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1"> -<title>{title} - - - -

{heading}

-
-{svg}
-{footer} - -""" - - -def _strip_svg_prolog(svg): - """Drop the XML decl + DOCTYPE so the SVG embeds cleanly under HTML5.""" - start = svg.find(" 0 else svg - - -def render_html(ir, emoji=True): - """Render the IR as a self-contained HTML page wrapping the inline SVG. - - Adds: critical-path footer if `ir.critical_path` is set, max-width - centering, and responsive `svg { max-width: 100% }` so it scales to - the viewport. No legend — status is dual-encoded by the per-node fill - color plus a label marker (leading emoji when `emoji=True`, trailing - `✓` / `…` glyph when `emoji=False`), which is self-explanatory. - """ - svg = _strip_svg_prolog(_dot_to_svg(ir, emoji=emoji)) - - close_id = ir.get("close") - if close_id is not None: - title_text = f"plan-dag — close #{close_id}" - else: - title_text = "plan-dag" - title = html_escape(title_text) - - cp = ir.get("critical_path") - if cp: - path = " → ".join( - "close" if str(n) == "close" else f"#{n}" for n in cp - ) - footer = f'

Critical path: {html_escape(path)}

\n' - else: - footer = "" - - return _HTML_TEMPLATE.format( - title=title, heading=title, svg=svg, footer=footer, - ) - - -def render_png(ir, out_path, emoji=True): - """Render the IR as a high-quality PNG. - - Pipeline: dot -Tsvg → headless Chromium (via Playwright) → PNG. Going - through the browser instead of `dot -Tpng` gives sharper text and - correct anti-aliasing at high DPI, which matters when the PNG is - surfaced to the user as an inline chat image. - """ - if shutil.which("node") is None: - sys.exit( - "--as=png requires Node (node ≥18) on PATH for Playwright." - ) - script_dir = Path(__file__).resolve().parent - svg_to_png = script_dir / "svg-to-png.mjs" - if not svg_to_png.exists(): - sys.exit(f"--as=png: missing helper {svg_to_png}") - - svg = _dot_to_svg(ir, emoji=emoji) - try: - png_res = subprocess.run( - ["node", str(svg_to_png), out_path], - input=svg, capture_output=True, text=True, timeout=30, - ) - except subprocess.TimeoutExpired: - sys.exit("svg-to-png.mjs timed out after 30s.") - if png_res.returncode != 0: - sys.stderr.write(png_res.stderr) - sys.exit(png_res.returncode or 1) - sys.stderr.write(png_res.stderr) - - -def main(): - ap = argparse.ArgumentParser(description="Render plan DAG IR.") - ap.add_argument("ir", help="path to JSON IR, or '-' for stdin") - ap.add_argument( - "--as", dest="target", default=None, - choices=["ascii", "dot", "svg", "html", "png"], - help="output target. Default: top-to-bottom box-drawing via graphviz " - "(requires `dot`; auto-falls back to --as=ascii when missing). " - "--as=svg: styled inline SVG (requires `dot`; stdout by default, " - "or --out ). " - "--as=html: standalone HTML page wrapping the styled SVG with " - "an optional critical-path footer (requires `dot`; stdout by " - "default, or --out ). " - "--as=png: high-quality PNG via graphviz SVG + headless " - "Chromium (requires `dot`, `node`, and Playwright Chromium; " - "--out is required). " - "--as=ascii: pure-ASCII tree, no external deps. " - "--as=dot: raw DOT source.", - ) - ap.add_argument( - "--out", default=None, - help="output file path. Required for --as=png; optional for " - "--as=dot / --as=svg / --as=html (default stdout); " - "ignored for the default box-drawing and --as=ascii targets.", - ) - ap.add_argument( - "--emoji", default="auto", choices=["auto", "on", "off"], - help="emoji status indicators in node labels. auto (default): on for " - "--as=dot, --as=svg, --as=html, and --as=png; off for " - "text/ascii (whose layout math assumes single-width " - "characters). on/off forces it. Emoji requires a color emoji " - "font on the rendering system; turn off if you see tofu boxes.", - ) - args = ap.parse_args() - - text = sys.stdin.read() if args.ir == "-" else Path(args.ir).read_text() - try: - ir = json.loads(text) - except json.JSONDecodeError as e: - sys.exit(f"invalid JSON: {e}") - - errors = validate(ir) - if errors: - sys.stderr.write("IR validation failed:\n") - for err in errors: - sys.stderr.write(f" - {err}\n") - sys.exit(1) - - target = args.target - if target is None: - if shutil.which("dot") is None: - sys.stderr.write( - "plan-dag-render: `dot` not on PATH; falling back to --as=ascii. " - "Install graphviz for the default box-drawing renderer.\n" - ) - target = "ascii" - - # Emoji policy: on for styled / image-producing targets (dot/svg/html/png), - # off for text-producing targets (tb/ascii) whose layout math assumes - # single-width characters. `on` / `off` forces it either way. - if args.emoji == "on": - emoji_on = True - elif args.emoji == "off": - emoji_on = False - else: - emoji_on = target in ("dot", "svg", "html", "png") - - def _emit(text): - # Force UTF-8 on file writes so non-UTF-8 locales don't either - # corrupt the output (the HTML/SVG advertise UTF-8 and contain - # emoji + box-drawing glyphs) or raise UnicodeEncodeError. - if args.out: - Path(args.out).write_text(text, encoding="utf-8") - else: - print(text) - - if target == "dot": - _emit(render_dot(ir, styled=True, emoji=emoji_on)) - elif target == "ascii": - print(render_ascii(ir)) - elif target == "svg": - _emit(render_svg(ir, emoji=emoji_on)) - elif target == "html": - _emit(render_html(ir, emoji=emoji_on)) - elif target == "png": - if not args.out: - sys.exit("--as=png requires --out ") - render_png(ir, args.out, emoji=emoji_on) - else: - print(render_tb_boxart(ir)) - cp = ir.get("critical_path") - if cp: - path = " → ".join("close" if str(n) == "close" else f"#{n}" for n in cp) - print(f"\nCritical path: {path}") - - -if __name__ == "__main__": - main() diff --git a/.claude/skills/plan-dag/scripts/plan-dag-render.test.sh b/.claude/skills/plan-dag/scripts/plan-dag-render.test.sh deleted file mode 100755 index 9237b5a30..000000000 --- a/.claude/skills/plan-dag/scripts/plan-dag-render.test.sh +++ /dev/null @@ -1,382 +0,0 @@ -#!/usr/bin/env bash -# Golden tests for plan-dag-render. Exits non-zero on any diff or unexpected -# exit code. Suitable for CI. -# -# Required on PATH: -# - `dot` (graphviz) — for the default (top-to-bottom box-drawing) renderer. -# apt install graphviz, or brew install graphviz. - -set -u -cd "$(dirname "$0")/.." - -if ! command -v dot >/dev/null 2>&1; then - printf 'plan-dag-render.test: `dot` (graphviz) required on PATH.\n' >&2 - printf 'Install: apt install graphviz, or brew install graphviz.\n' >&2 - exit 2 -fi - -SCRIPT="scripts/plan-dag-render.py" -FIX="fixtures" -EXP="$FIX/expected" - -fail=0 -pass=0 - -assert_eq () { - local label="$1" got_file="$2" exp_file="$3" - if diff -u "$exp_file" "$got_file" >/dev/null; then - pass=$((pass + 1)) - printf ' ok %s\n' "$label" - else - fail=$((fail + 1)) - printf ' FAIL %s\n' "$label" - diff -u "$exp_file" "$got_file" | sed 's/^/ /' - fi -} - -run_and_compare () { - # $1 label, $2 fixture, $3 expected suffix (tb|ascii), $4... extra args - local label="$1" fix="$2" suf="$3"; shift 3 - local base; base="$(basename "$fix" .json)" - local out="$tmp/$base.$suf" - "$SCRIPT" "$fix" "$@" > "$out" 2>"$out.err" - local rc=$? - if [ "$rc" -ne 0 ]; then - fail=$((fail + 1)) - printf ' FAIL %s exited %d\n' "$label" "$rc" - sed 's/^/ /' < "$out.err" - return - fi - assert_eq "$label" "$out" "$EXP/$base.$suf" -} - -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT - -echo "happy.json" -run_and_compare "happy default (tb)" "$FIX/happy.json" tb -run_and_compare "happy --as=ascii" "$FIX/happy.json" ascii --as=ascii -run_and_compare "happy --as=dot (emoji on by default)" \ - "$FIX/happy.json" dot --as=dot -run_and_compare "happy --as=dot --emoji=off" \ - "$FIX/happy.json" dot.noemoji \ - --as=dot --emoji=off - -echo "wide.json" -run_and_compare "wide default (tb)" "$FIX/wide.json" tb -run_and_compare "wide --as=ascii" "$FIX/wide.json" ascii --as=ascii - -echo "styled DOT structural checks" -# Available-next: #304 is open, only pred (#288) is done → highlighted in blue. -"$SCRIPT" "$FIX/happy.json" --as=dot > "$tmp/happy.dot" 2>/dev/null -if grep -q '"304"\s*\[.*fillcolor="#cfe2ff"' "$tmp/happy.dot"; then - pass=$((pass + 1)) - printf ' ok available-next (#304) gets blue highlight\n' -else - fail=$((fail + 1)) - printf ' FAIL #304 missing available-next highlight\n' -fi -# #306 is open but blocked (preds #304/#305 not done) → dashed muted style. -if grep -q '"306"\s*\[.*style="filled,rounded,dashed"' "$tmp/happy.dot"; then - pass=$((pass + 1)) - printf ' ok blocked-open (#306) gets dashed style\n' -else - fail=$((fail + 1)) - printf ' FAIL #306 missing dashed blocked-open style\n' -fi -# Close sentinel gets a double border. -if grep -q '"close"\s*\[.*peripheries="2"' "$tmp/happy.dot"; then - pass=$((pass + 1)) - printf ' ok close sentinel gets double border\n' -else - fail=$((fail + 1)) - printf ' FAIL close sentinel missing peripheries="2"\n' -fi -# Edges stay uniform — no penwidth bolding on the declared critical_path. -# (We test by counting how many edge lines carry an explicit penwidth.) -crit_edges=$(grep -E '"[^"]+"\s*->\s*"[^"]+"\s*\[' "$tmp/happy.dot" | grep -c 'penwidth' || true) -if [ "$crit_edges" -eq 0 ]; then - pass=$((pass + 1)) - printf ' ok no critical-path edge bolding (subjective; footer-only)\n' -else - fail=$((fail + 1)) - printf ' FAIL %d edges carry explicit penwidth (expected 0)\n' "$crit_edges" -fi -# --emoji=off strips emoji from labels but keeps the ✓/… markers. -"$SCRIPT" "$FIX/happy.json" --as=dot --emoji=off > "$tmp/happy.dot.off" 2>/dev/null -if grep -q '✅\|🟡\|⬜\|🎯\|🏁' "$tmp/happy.dot.off"; then - fail=$((fail + 1)) - printf ' FAIL --emoji=off leaked emoji into output\n' -else - pass=$((pass + 1)) - printf ' ok --emoji=off strips emoji\n' -fi -if grep -q '#288 MCP ✓' "$tmp/happy.dot.off"; then - pass=$((pass + 1)) - printf ' ok --emoji=off retains text markers\n' -else - fail=$((fail + 1)) - printf ' FAIL --emoji=off missing text marker\n' -fi -# --emoji=on with default (tb) target must NOT affect tb output — the layout -# math assumes single-width chars. The flag is silently inert for tb/ascii. -"$SCRIPT" "$FIX/happy.json" --emoji=on > "$tmp/happy.tb.emoji-on" 2>/dev/null -if diff -u "$EXP/happy.tb" "$tmp/happy.tb.emoji-on" >/dev/null; then - pass=$((pass + 1)) - printf ' ok --emoji=on does not affect default (tb) output\n' -else - fail=$((fail + 1)) - printf ' FAIL --emoji=on altered tb output\n' -fi - -echo "bad.json (must fail validation)" -"$SCRIPT" "$FIX/bad.json" > "$tmp/bad.out" 2>"$tmp/bad.err" -rc=$? -if [ "$rc" -ne 1 ]; then - fail=$((fail + 1)) - printf ' FAIL bad expected exit 1, got %d\n' "$rc" -else - pass=$((pass + 1)) - printf ' ok bad exit 1\n' -fi -for token in 'duplicate node id' 'invalid status' 'missing label' \ - 'not in declared nodes' 'missing source' 'not in [' \ - 'must be an object' 'forbidden character' \ - 'reserved for the synthetic CLOSE' 'ir.close is missing' \ - 'critical_path[1]'; do - if grep -qF "$token" "$tmp/bad.err"; then - pass=$((pass + 1)) - printf ' ok bad stderr contains: %s\n' "$token" - else - fail=$((fail + 1)) - printf ' FAIL bad stderr missing: %s\n' "$token" - fi -done - -echo "cycle.json (must fail validation with a cycle error)" -"$SCRIPT" "$FIX/cycle.json" > "$tmp/cycle.out" 2>"$tmp/cycle.err" -rc=$? -if [ "$rc" -ne 1 ]; then - fail=$((fail + 1)) - printf ' FAIL cycle expected exit 1, got %d\n' "$rc" -else - pass=$((pass + 1)) - printf ' ok cycle exit 1\n' -fi -if grep -qF 'contains a cycle' "$tmp/cycle.err"; then - pass=$((pass + 1)) - printf ' ok cycle stderr contains: contains a cycle\n' -else - fail=$((fail + 1)) - printf ' FAIL cycle stderr missing: contains a cycle\n' -fi - -echo "stdin mode" -for tgt in tb ascii; do - if [ "$tgt" = "tb" ]; then - cat "$FIX/happy.json" | "$SCRIPT" - > "$tmp/stdin.$tgt" 2>/dev/null - else - cat "$FIX/happy.json" | "$SCRIPT" - --as="$tgt" > "$tmp/stdin.$tgt" 2>/dev/null - fi - rc=$? - if [ "$rc" -ne 0 ]; then - fail=$((fail + 1)) - printf ' FAIL stdin %s exited %d\n' "$tgt" "$rc" - continue - fi - assert_eq "stdin $tgt matches file-arg" "$tmp/stdin.$tgt" "$EXP/happy.$tgt" -done - -echo "--as=svg smoke" -"$SCRIPT" "$FIX/happy.json" --as=svg > "$tmp/happy.svg" 2>"$tmp/happy.svg.err" -rc=$? -if [ "$rc" -ne 0 ]; then - fail=$((fail + 1)) - printf ' FAIL --as=svg exited %d\n' "$rc" - sed 's/^/ /' < "$tmp/happy.svg.err" -elif ! grep -q ' root\n' -elif ! grep -q '' "$tmp/happy.svg"; then - fail=$((fail + 1)) - printf ' FAIL --as=svg output missing close\n' -elif ! grep -q '#d4edda' "$tmp/happy.svg"; then - fail=$((fail + 1)) - printf ' FAIL --as=svg missing done-state fill (#d4edda)\n' -elif ! grep -q '#cfe2ff' "$tmp/happy.svg"; then - fail=$((fail + 1)) - printf ' FAIL --as=svg missing available-next fill (#cfe2ff)\n' -elif ! grep -q '✅' "$tmp/happy.svg"; then - fail=$((fail + 1)) - printf ' FAIL --as=svg missing emoji (auto-on for SVG)\n' -else - pass=$((pass + 1)) - printf ' ok --as=svg emits styled SVG with status fills + emoji\n' -fi -# --as=svg strips the XML prolog (and everything before the `/dev/null 2>&1 -if [ -s "$tmp/happy.out.svg" ] && grep -q ' "$tmp/happy.svg.off" 2>/dev/null -if grep -q '✅\|🟡\|⬜\|🎯\|🏁' "$tmp/happy.svg.off"; then - fail=$((fail + 1)) - printf ' FAIL --as=svg --emoji=off leaked emoji\n' -else - pass=$((pass + 1)) - printf ' ok --as=svg --emoji=off strips emoji\n' -fi - -echo "--as=html smoke" -"$SCRIPT" "$FIX/happy.json" --as=html > "$tmp/happy.html" 2>"$tmp/happy.html.err" -rc=$? -if [ "$rc" -ne 0 ]; then - fail=$((fail + 1)) - printf ' FAIL --as=html exited %d\n' "$rc" - sed 's/^/ /' < "$tmp/happy.html.err" -else - html_ok=1 - for token in '' 'plan-dag — close #300' \ - 'name="viewport"' \ - 'class="dag"' '' \ - 'Critical path:' '#301 → #305 → #306 → #307 → close' \ - '@media (prefers-color-scheme: dark)'; do - if ! grep -qF -- "$token" "$tmp/happy.html"; then - fail=$((fail + 1)) - printf ' FAIL --as=html missing token: %s\n' "$token" - html_ok=0 - fi - done - if [ "$html_ok" -eq 1 ]; then - pass=$((pass + 1)) - printf ' ok --as=html emits self-contained page with critical-path footer\n' - fi - # Legend is intentionally not part of the HTML output — assert it stays gone. - if grep -qE 'class="legend"|>blocked<|>in-progress<|>available next<' "$tmp/happy.html"; then - fail=$((fail + 1)) - printf ' FAIL --as=html unexpectedly emitted legend markup\n' - else - pass=$((pass + 1)) - printf ' ok --as=html omits legend (status colors + emoji are self-explanatory)\n' - fi -fi -# --as=html --out writes to a file. -"$SCRIPT" "$FIX/happy.json" --as=html --out "$tmp/happy.out.html" >/dev/null 2>&1 -if [ -s "$tmp/happy.out.html" ] && grep -q '' "$tmp/happy.out.html"; then - pass=$((pass + 1)) - printf ' ok --as=html --out writes to file\n' -else - fail=$((fail + 1)) - printf ' FAIL --as=html --out did not write valid HTML\n' -fi -# A graph without a `close` sentinel drops the " — close #N" title suffix -# and the footer disappears when no critical_path is declared. -closeless_ir='{"nodes":[{"id":"1","label":"a","status":"done"},{"id":"2","label":"b","status":"open"}],"edges":[{"from":"1","to":"2","source":"depends-on"}]}' -echo "$closeless_ir" | "$SCRIPT" - --as=html > "$tmp/closeless.html" 2>/dev/null -if grep -q 'plan-dag' "$tmp/closeless.html"; then - pass=$((pass + 1)) - printf ' ok --as=html omits close suffix when ir.close is unset\n' -else - fail=$((fail + 1)) - printf ' FAIL --as=html title for closeless IR (got: %s)\n' \ - "$(grep -o '[^<]*' "$tmp/closeless.html" | head -1)" -fi -if grep -q 'Critical path:' "$tmp/closeless.html"; then - fail=$((fail + 1)) - printf ' FAIL --as=html emitted footer with no critical_path\n' -else - pass=$((pass + 1)) - printf ' ok --as=html omits footer when no critical_path declared\n' -fi -echo "--as=dot --out smoke" -"$SCRIPT" "$FIX/happy.json" --as=dot --out "$tmp/happy.out.dot" >/dev/null 2>&1 -if [ -s "$tmp/happy.out.dot" ] && grep -q '^digraph plan' "$tmp/happy.out.dot"; then - pass=$((pass + 1)) - printf ' ok --as=dot --out writes to file\n' -else - fail=$((fail + 1)) - printf ' FAIL --as=dot --out did not write valid DOT to file\n' -fi - -echo "--as=png smoke (skipped without node + Playwright Chromium)" -# Skip markers — any of these in the helper's stderr means the local env is -# missing a Playwright/Chromium piece and the test should be skipped rather -# than fail loudly. -png_skip_re='cannot load Playwright|Executable doesn.t exist|browserType\.launch|playwright install' -if command -v node >/dev/null 2>&1; then - png_out="$tmp/happy.png" - "$SCRIPT" "$FIX/happy.json" --as=png --out "$png_out" >"$tmp/png.out" 2>"$tmp/png.err" - rc=$? - if [ "$rc" -ne 0 ]; then - if grep -qE "$png_skip_re" "$tmp/png.err"; then - printf ' skip --as=png (Playwright/Chromium not available)\n' - else - fail=$((fail + 1)) - printf ' FAIL --as=png exited %d\n' "$rc" - sed 's/^/ /' < "$tmp/png.err" - fi - elif [ ! -s "$png_out" ]; then - fail=$((fail + 1)) - printf ' FAIL --as=png produced empty output\n' - elif ! file "$png_out" 2>/dev/null | grep -q 'PNG image'; then - fail=$((fail + 1)) - printf ' FAIL --as=png output is not a PNG (file: %s)\n' \ - "$(file "$png_out" 2>/dev/null || echo unknown)" - else - pass=$((pass + 1)) - printf ' ok --as=png produced a PNG\n' - fi - - # Also confirm --as=png errors clearly when --out is missing. - "$SCRIPT" "$FIX/happy.json" --as=png >"$tmp/png-noout.out" 2>"$tmp/png-noout.err" - rc=$? - if [ "$rc" -eq 0 ]; then - fail=$((fail + 1)) - printf ' FAIL --as=png without --out should fail, got 0\n' - elif ! grep -q 'requires --out' "$tmp/png-noout.err"; then - fail=$((fail + 1)) - printf ' FAIL --as=png without --out: stderr missing guidance\n' - else - pass=$((pass + 1)) - printf ' ok --as=png without --out fails with guidance\n' - fi -else - printf ' skip --as=png (node not on PATH)\n' -fi - -echo "auto-fallback (dot not on PATH → --as=ascii)" -py3="$(command -v python3)" -PATH="" "$py3" "$SCRIPT" "$FIX/happy.json" > "$tmp/fallback.out" 2>"$tmp/fallback.err" -rc=$? -if [ "$rc" -ne 0 ]; then - fail=$((fail + 1)) - printf ' FAIL fallback exited %d\n' "$rc" - sed 's/^/ /' < "$tmp/fallback.err" -elif ! grep -q 'falling back to --as=ascii' "$tmp/fallback.err"; then - fail=$((fail + 1)) - printf ' FAIL fallback: stderr missing fallback note\n' - sed 's/^/ /' < "$tmp/fallback.err" -else - pass=$((pass + 1)) - printf ' ok fallback emits stderr note\n' - assert_eq "fallback output matches --as=ascii golden" "$tmp/fallback.out" "$EXP/happy.ascii" -fi - -echo -printf 'plan-dag-render.test: %d passed, %d failed\n' "$pass" "$fail" -[ "$fail" -eq 0 ] diff --git a/skills-lock.json b/skills-lock.json index 90159166c..a567d0be3 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -15,7 +15,7 @@ "source": "onsager-ai/onsager-skills", "sourceType": "github", "skillPath": "skills/plan-dag/SKILL.md", - "computedHash": "90fb6f8d59d96d8f8eb17bdce7b94f32667804fdaa5d360eaa38889dd6e4c789" + "computedHash": "2dfad0d270654877aa3a5d663afcd80e69f8c238af0ccb170991f732075ed692" } } }