Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
55 changes: 55 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,61 @@ 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 always produce identical output to the no-unflatten baseline.
"$SCRIPT" "$FIX/happy.json" --stagger=0 --out "$tmp/stag0.png" \
>/dev/null 2>"$tmp/stag0.err" || true
if grep -qE "$png_skip_re" "$tmp/stag0.err" 2>/dev/null; then
printf ' skip --stagger=0 PNG (Playwright/Chromium not available)\n'
elif [ ! -s "$tmp/stag0.png" ] && [ ! -e "$tmp/stag0.png" ]; then
: # nothing to assert
elif file "$tmp/stag0.png" 2>/dev/null | grep -q 'PNG image'; then
pass=$((pass + 1))
printf ' ok --stagger=0 renders a PNG\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"
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": "832e649a4efa93b178636a5e11b0cf1fc4441931ab82493baeb87540448e63cc"
}
}
}
Loading