Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .agents/skills/plan-dag/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Skip when:
|-----------|----------|
| **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 <path>` 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. |
| **Output** | High-DPI PNG (only target). `--out <path>` 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. Wide ranks (many siblings sharing a successor) are staggered across multiple chains by default via graphviz `unflatten`; tune with `--stagger N` (default 5; 0 disables). |

## Workflow

Expand Down Expand Up @@ -136,6 +136,9 @@ SCRIPT=.claude/skills/plan-dag/scripts/plan-dag-render.py # project install

# emoji off — falls back to ✓ / … text markers in node labels
"$SCRIPT" /tmp/plan.json --out /tmp/plan-dag.png --emoji=off

# stagger off — accept the raw `dot` layout (one rank can be 10+ nodes wide)
"$SCRIPT" /tmp/plan.json --out /tmp/plan-dag.png --stagger=0
```

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).
Expand All @@ -147,7 +150,7 @@ If the renderer aborts with `IR validation failed`, fix the IR — do not work a
- 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.
- The renderer already shrinks wide layouts (siblings that all flow into CLOSE, or fan-out from a hub) by piping DOT through `unflatten -f -l 5`, trading height for width. For *truly* wide graphs (>10 nodes with many cross-edges) where even the staggered PNG is still 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

Expand Down
48 changes: 43 additions & 5 deletions .agents/skills/plan-dag/scripts/plan-dag-render.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,14 +264,42 @@ def render_dot(ir, emoji=True):
return "\n".join(lines)


def _dot_to_svg(ir, emoji=True):
def _unflatten_dot(dot_src, stagger):
"""Stagger wide ranks across multiple levels via graphviz `unflatten`.

`unflatten -f -l N` adds invisible edges so siblings that share a
successor (typical for many specs all flowing into CLOSE) get pushed
onto chains up to N deep, trading height for width. Without it, a plan
with N parallel sub-issues renders as one rank N nodes wide — readable
at N=3 but a horizontal scroll bar by N=8.

`stagger=0` disables. If `unflatten` is missing from PATH (older or
stripped graphviz), fall through to the original DOT — the diagram
still renders, just wider.
"""
if stagger <= 0 or shutil.which("unflatten") is None:
return dot_src
try:
res = subprocess.run(
["unflatten", "-f", "-l", str(stagger)],
input=dot_src, capture_output=True, text=True, timeout=5,
encoding="utf-8",
)
except subprocess.TimeoutExpired:
return dot_src
if res.returncode != 0 or not res.stdout.strip():
return dot_src
return res.stdout


def _dot_to_svg(ir, emoji=True, stagger=5):
"""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)
dot_src = _unflatten_dot(render_dot(ir, emoji=emoji), stagger)
try:
svg_res = subprocess.run(
["dot", "-Tsvg"], input=dot_src,
Expand All @@ -286,7 +314,7 @@ def _dot_to_svg(ir, emoji=True):
return svg_res.stdout


def render_png(ir, out_path, emoji=True):
def render_png(ir, out_path, emoji=True, stagger=5):
"""Render the IR as a high-quality PNG.

Pipeline: dot -Tsvg → headless Chromium (via Playwright) → PNG. Going
Expand All @@ -296,7 +324,7 @@ def render_png(ir, out_path, emoji=True):
"""
# _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)
svg = _dot_to_svg(ir, emoji=emoji, stagger=stagger)
if shutil.which("node") is None:
sys.exit(
"plan-dag-render requires Node (node ≥18) on PATH for Playwright."
Expand Down Expand Up @@ -335,7 +363,17 @@ def main():
"text markers. Turn off if the rendering system lacks a color "
"emoji font and you see tofu boxes.",
)
ap.add_argument(
"--stagger", type=int, default=5, metavar="N",
help="cap the horizontal width of wide ranks by piping DOT through "
"graphviz `unflatten -f -l N`. Siblings that share a successor "
"get pushed onto chains up to N deep, trading height for width "
"so the rendered PNG fits readable aspect ratios. Default 5; "
"use 0 to disable and accept the raw `dot` layout (wider).",
)
args = ap.parse_args()
if args.stagger < 0:
ap.error("--stagger must be >= 0")

text = sys.stdin.read() if args.ir == "-" else Path(args.ir).read_text()
try:
Expand All @@ -350,7 +388,7 @@ def main():
sys.stderr.write(f" - {err}\n")
sys.exit(1)

render_png(ir, args.out, emoji=(args.emoji == "on"))
render_png(ir, args.out, emoji=(args.emoji == "on"), stagger=args.stagger)


if __name__ == "__main__":
Expand Down
71 changes: 71 additions & 0 deletions .agents/skills/plan-dag/scripts/plan-dag-render.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,77 @@ else
printf ' FAIL cycle stderr missing: contains a cycle\n'
fi

echo "--stagger shrinks width of wide-fanout layouts"
# Build a wide-fanout IR inline (9 siblings → close). With unflatten on PATH,
# the default --stagger=5 should produce a measurably narrower SVG than
# --stagger=0 (raw dot). If unflatten isn't available, we expect identical
# widths — the renderer falls through silently and the test reports "skip".
wide_ir='{"nodes":[
{"id":"n1","label":"a","status":"open"},{"id":"n2","label":"b","status":"open"},
{"id":"n3","label":"c","status":"open"},{"id":"n4","label":"d","status":"open"},
{"id":"n5","label":"e","status":"open"},{"id":"n6","label":"f","status":"open"},
{"id":"n7","label":"g","status":"open"},{"id":"n8","label":"h","status":"open"},
{"id":"n9","label":"i","status":"open"}],
"edges":[
{"from":"n1","to":"close","source":"closes"},{"from":"n2","to":"close","source":"closes"},
{"from":"n3","to":"close","source":"closes"},{"from":"n4","to":"close","source":"closes"},
{"from":"n5","to":"close","source":"closes"},{"from":"n6","to":"close","source":"closes"},
{"from":"n7","to":"close","source":"closes"},{"from":"n8","to":"close","source":"closes"},
{"from":"n9","to":"close","source":"closes"}],
"close":"300"}'
if ! command -v unflatten >/dev/null 2>&1; then
printf ' skip --stagger width comparison (unflatten not on PATH)\n'
else
"$py3" - <<PY > "$tmp/wide.dot" 2>&1
import importlib.util, json
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('''$wide_ir''')
print(mod.render_dot(ir, emoji=False))
PY
raw_w=$(dot -Tsvg < "$tmp/wide.dot" | grep -oE 'width="[0-9.]+pt"' | head -1 | grep -oE '[0-9.]+')
stag_w=$(unflatten -f -l 5 < "$tmp/wide.dot" | dot -Tsvg | grep -oE 'width="[0-9.]+pt"' | head -1 | grep -oE '[0-9.]+')
# awk for float comparison; require >=20% width reduction so we catch a
# complete no-op (e.g. unflatten silently fails) but tolerate engine drift.
if [ -z "$raw_w" ] || [ -z "$stag_w" ]; then
fail=$((fail + 1))
printf ' FAIL could not parse SVG widths (raw=%s stag=%s)\n' "$raw_w" "$stag_w"
elif awk -v r="$raw_w" -v s="$stag_w" 'BEGIN{ exit !(s < 0.8 * r) }'; then
pass=$((pass + 1))
printf ' ok --stagger shrinks width %s → %s pt\n' "$raw_w" "$stag_w"
else
fail=$((fail + 1))
printf ' FAIL --stagger did not measurably shrink width (%s → %s pt)\n' "$raw_w" "$stag_w"
fi
fi
# --stagger=0 must produce a valid PNG (same shape as the happy-path PNG smoke
# above): gated on node being on PATH, capture the exit code, treat a
# Playwright-skip stderr as skip, and fail loudly for anything else.
if command -v node >/dev/null 2>&1; then
"$SCRIPT" "$FIX/happy.json" --stagger=0 --out "$tmp/stag0.png" \
>/dev/null 2>"$tmp/stag0.err"
rc=$?
if [ "$rc" -ne 0 ]; then
if grep -qE "$png_skip_re" "$tmp/stag0.err"; then
printf ' skip --stagger=0 PNG (Playwright/Chromium not available)\n'
else
fail=$((fail + 1))
printf ' FAIL --stagger=0 exited %d\n' "$rc"
sed 's/^/ /' < "$tmp/stag0.err"
fi
elif [ ! -s "$tmp/stag0.png" ]; then
fail=$((fail + 1))
printf ' FAIL --stagger=0 produced empty output\n'
elif ! file "$tmp/stag0.png" 2>/dev/null | grep -q 'PNG image'; then
fail=$((fail + 1))
printf ' FAIL --stagger=0 output is not a PNG (file: %s)\n' \
"$(file "$tmp/stag0.png" 2>/dev/null || echo unknown)"
else
pass=$((pass + 1))
printf ' ok --stagger=0 renders a PNG\n'
fi
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"
Expand Down
2 changes: 1 addition & 1 deletion skills-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"source": "onsager-ai/onsager-skills",
"sourceType": "github",
"skillPath": "skills/plan-dag/SKILL.md",
"computedHash": "2dfad0d270654877aa3a5d663afcd80e69f8c238af0ccb170991f732075ed692"
"computedHash": "6787d3a8369eb736f269fa3acb6ec996af92e0acd98897545ba8059158b7d78f"
}
}
}
Loading