diff --git a/.agents/skills/plan-dag/SKILL.md b/.agents/skills/plan-dag/SKILL.md index c157a8dcb..9875bbb16 100644 --- a/.agents/skills/plan-dag/SKILL.md +++ b/.agents/skills/plan-dag/SKILL.md @@ -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 ` 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 ` 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 @@ -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). @@ -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 diff --git a/.agents/skills/plan-dag/scripts/plan-dag-render.py b/.agents/skills/plan-dag/scripts/plan-dag-render.py index 8f978710c..2b6fab16b 100755 --- a/.agents/skills/plan-dag/scripts/plan-dag-render.py +++ b/.agents/skills/plan-dag/scripts/plan-dag-render.py @@ -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, @@ -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 @@ -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." @@ -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: @@ -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__": diff --git a/.agents/skills/plan-dag/scripts/plan-dag-render.test.sh b/.agents/skills/plan-dag/scripts/plan-dag-render.test.sh index f24cbe061..6d00aa5c1 100755 --- a/.agents/skills/plan-dag/scripts/plan-dag-render.test.sh +++ b/.agents/skills/plan-dag/scripts/plan-dag-render.test.sh @@ -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" - < "$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" diff --git a/skills-lock.json b/skills-lock.json index a567d0be3..5f55abf61 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": "2dfad0d270654877aa3a5d663afcd80e69f8c238af0ccb170991f732075ed692" + "computedHash": "6787d3a8369eb736f269fa3acb6ec996af92e0acd98897545ba8059158b7d78f" } } }