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
11 changes: 10 additions & 1 deletion skills/issue-tracker/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,22 @@ checkout's repo.
| `board-edge.sh <n> --block N \| --unblock N \| --parent N \| --orphan` | re-cut edges after birth (one op per call): add/cut a dependency, move under another epic, or leave one. Rejects self-edges, cycles, ancestor-epic blockers; runs the same epic sweeps as transition |
| `board-relate.sh <a> <b> [--cut]` | symmetric relates annotation (board:meta) — rendered by board-map, no effect on eligibility |
| `board-list.sh [state]` | board view; `ELIGIBLE` tag = dispatchable |
| `board-map.sh [--write]` | human telemetry. `--write` renders **`BOARD.html`** (interactive layered-DAG: pan/zoom, node detail, state filter, epic collapse) and **`BOARD.md`** (table) into the gitignored render dir. No argument prints the table |
| `board-map.sh [--write]` | human telemetry. `--write` renders **`BOARD.html`** (interactive layered-DAG: pan/zoom, node detail, state filter, epic collapse — plus a kanban view toggle) and **`BOARD.md`** (table) into the gitignored render dir. No argument prints the table |
| `board-show.sh <n>` | node + issue URL + bound daemon |
| `board-bind.sh <uuid> <n>` | record which daemon owns the ticket (in the daemon registry) |
| `board-reconcile.sh` | read-only catch-up: unapplied proposals, orphaned tickets, dispatchables, then a lint pass |
| `board-lint.sh` | schema invariants over the live board: one status label per open issue, none on closed, notes where required, no dependency cycles. `FAIL … FIX: …` lines, exit 1 |
| `board-migrate-gh.sh [--board FILE] [--apply]` | one-shot v6→v7 migration: push a legacy `board.json` into GitHub (dry-run by default) |

## Remote board (GitHub Pages)

`board-map.sh --write` renders locally on demand. For an always-current hosted
view, copy `references/board-pages.yml` into the consumer repo's
`.github/workflows/` and set Pages → Source to "GitHub Actions": every issue
event (plus a cron safety net — sub-issue/dependency edits fire no webhook)
re-renders BOARD.html on a runner and deploys it. Read the template's header
first — on non-Enterprise plans a Pages site is public even for a private repo.

## The dispatch loop

1. `board-list.sh` → pick an `ELIGIBLE` ticket.
Expand Down
79 changes: 79 additions & 0 deletions skills/issue-tracker/references/board-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# board-pages.yml — publish the live issue board (BOARD.html) to GitHub Pages.
#
# The v7 board's single source of truth is the repo's GitHub Issues;
# `board-map.sh --write` renders it locally on demand. This workflow is the
# hosted-visibility analog (the same shape agent-harness uses for its Linear
# board: poll the remote tracker → persist a snapshot → serve a read-only
# view): every issue event re-renders the board on a runner and deploys it to
# the repo's Pages site, with a cron safety net because sub-issue/dependency
# edge mutations fire no `issues` webhook.
#
# Install (consumer repo):
# 1. copy this file to .github/workflows/board-pages.yml
# 2. repo Settings → Pages → Source: "GitHub Actions"
# 3. pin PLUGIN_REPO/PLUGIN_REF below to the doperpowers checkout you trust
# (a private plugin repo needs a token with read access passed to the
# second checkout step — the default github.token only reads THIS repo)
#
# PRIVACY: on Free/Pro plans a Pages site is PUBLIC even when the repo is
# private — ticket titles and notes become world-readable at the Pages URL.
# Only GitHub Enterprise Cloud can restrict Pages visibility. If that is not
# acceptable, drop the deploy job and download the artifact instead.

name: issue-board-pages

on:
issues:
types: [opened, edited, deleted, transferred, closed, reopened, labeled, unlabeled]
schedule:
- cron: "23 */6 * * *" # edge (sub-issue/dependency) changes emit no issues event
workflow_dispatch:

concurrency:
group: issue-board-pages
cancel-in-progress: true

permissions:
contents: read
issues: read
pages: write
id-token: write

env:
PLUGIN_REPO: SSFSKIM/doperpowers
PLUGIN_REF: main

jobs:
render-and-deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
# The board scripts anchor their render cache at a repo root, so check
# out the consumer repo even though the board itself lives on GitHub.
- uses: actions/checkout@v4

- name: Check out the issue-tracker toolkit
uses: actions/checkout@v4
with:
repository: ${{ env.PLUGIN_REPO }}
ref: ${{ env.PLUGIN_REF }}
path: .board-plugin

- name: Render BOARD.html from the live issue board
env:
GH_TOKEN: ${{ github.token }}
BOARD_REPO: ${{ github.repository }}
run: |
.board-plugin/skills/issue-tracker/scripts/board-map.sh --write
mkdir -p _site
cp doperpowers/issue-tracker/BOARD.html _site/index.html
cp doperpowers/issue-tracker/BOARD.md _site/BOARD.md

- uses: actions/upload-pages-artifact@v3
with:
path: _site

- id: deploy
uses: actions/deploy-pages@v4
91 changes: 52 additions & 39 deletions skills/issue-tracker/scripts/board-map.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
# doperpowers/issue-tracker/ (gitignored — render caches never
# commit): BOARD.html — the primary view: an interactive
# layered-DAG (pan/zoom, click a node for detail, filter by state,
# collapse epics), opened in a browser; and BOARD.md — a minimal
# node/state table.
# collapse epics) with a kanban toggle (the same tickets pivoted
# into state columns), opened in a browser; and BOARD.md — a
# minimal node/state table.
#
# Reading BOARD.html: node color = state; ELIGIBLE (ready-for-agent + all blockers
# done, not an epic) gets a thick green border; a solid arrow is an ACTIVE block,
# a dotted one a satisfied dependency; labeled dotted lines carry spawned/relates
# lineage; each epic is a labeled box around its members (click to collapse).
# Reading BOARD.html: dependencies flow left→right (a blocker sits left of its
# dependents). Card color = state; ELIGIBLE (ready-for-agent + all blockers done,
# not an epic) glows blue; in-progress pulses green. An amber arrow is an ACTIVE
# block (green while its blocker is itself being worked), a dim dashed one a
# satisfied dependency; labeled dashed lines carry spawned/relates lineage; each
# epic is a labeled box around its members (click the epic's card to collapse).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_lib.sh
Expand Down Expand Up @@ -73,7 +76,8 @@ with open(env["BOARD_DIR"] + "/BOARD.md", "w") as f:

# --- the interactive graph (BOARD.html) ---
CLASS = {"done": "s_done", "in-progress": "s_prog", "in-review": "s_rev", "blocked": "s_blk",
"needs-info": "s_info", "deferred": "s_def", "wontfix": "s_wf"}
"needs-info": "s_info", "deferred": "s_def", "wontfix": "s_wf",
"conflict": "s_conflict", "untracked": "s_untracked"}
def cls(tid, n):
if n["state"] == "ready-for-agent":
return "s_elig" if eligible(tid, n) else "s_wait"
Expand Down Expand Up @@ -191,13 +195,18 @@ def order_cluster(members):
break
return best

# Coordinates: give each top-level cluster (epic tree or lone node) its own
# disjoint column band, so an epic's bounding box can only ever enclose its own
# members. Band width is the max nodes-per-layer (order-independent). Two levers
# cut crossings without ever breaking that containment: WITHIN a band, the slot a
# node takes (order_cluster, above); and the left-to-right ORDER of the bands
# themselves (below), which cuts crossings among the edges that span clusters.
COL, ROW = 210, 110
# Coordinates: the board renders as a left→right layered DAG (the agent-harness
# orientation: layer = x, so a blocker sits LEFT of its dependents). Each
# top-level cluster (epic tree or lone node) gets its own disjoint band on the
# slot axis, so an epic's bounding box can only ever enclose its own members.
# Band width is the max nodes-per-layer (order-independent). Two levers cut
# crossings without ever breaking that containment: WITHIN a band, the slot a
# node takes (order_cluster, above); and the ORDER of the bands themselves
# (below), which cuts crossings among the edges that span clusters. All of the
# ordering math runs in abstract (slot, layer) units — the transpose to screen
# px happens only at the final pos emit (segment crossings are invariant under
# swapping axes, so the minimization is orientation-blind).
XCOL, YROW = 240, 100 # px per layer step (card 168 + edge gap) / per slot step (card 74 + gap)
clusters = {}
for t in order:
clusters.setdefault(root(t), []).append(t)
Expand All @@ -215,8 +224,8 @@ for rt in clusters:
slot[t] = i
local[rt], width[rt] = slot, w

# Band order: reorder the swimlanes left-to-right to cut crossings among the
# edges that span two clusters. Same discipline as the within-band pass — pure,
# Band order: reorder the swimlanes along the slot axis to cut crossings among
# the edges that span two clusters. Same discipline as the within-band pass — pure,
# deterministic, never worse than the id-stable baseline (id-order is the first
# candidate and we only ever keep a strictly-fewer-crossings order). The classic
# Sugiyama recipe: a barycenter sweep for a good global order, then a transpose
Expand Down Expand Up @@ -297,17 +306,18 @@ if any(cl_nbr.values()):
if not moved:
break

# Shelf-pack the top-level bands into rows instead of one long ribbon, so the
# board fills vertical space. Laid out in a single row, a board of N mostly
# independent tickets is an N-wide, ~1-tall strip that auto-fits to a thin
# sliver (huge empty margins above and below); wrapping it into an ~M-wide grid
# fills the frame and multiplies the readable zoom. Each cluster stays a
# contiguous block placed at one shelf origin, so the disjoint-column guarantee
# now also holds across rows (different shelves occupy different row bands) —
# an epic's bounding box still encloses only its own members. best_seq's
# crossing-minimized order is preserved; we only wrap it. Shelf width = the one
# whose packed bounding box best matches a landscape aspect (fitView in the
# template does the final viewport scaling).
# Shelf-pack the top-level bands into a grid instead of one long strip, so the
# board fills the frame. In the transposed (left→right) orientation a band is a
# ROW of the screen: N mostly independent tickets laid in a single column of
# bands is an N-tall, ~1-wide sliver that auto-fits tiny; wrapping the bands
# into shelves (stacked vertically, shelves side by side) fills the frame and
# multiplies the readable zoom. Each cluster stays a contiguous block placed at
# one shelf origin, so the disjoint-band guarantee still holds — an epic's
# bounding box encloses only its own members. best_seq's crossing-minimized
# order is preserved; we only wrap it. Shelf capacity = the one whose packed
# bounding box best matches a landscape aspect (fitView in the template does
# the final viewport scaling). The packer itself works in abstract
# (slot, layer) units; only the score and the pos emit apply the transpose.
cheight = {rt: 1 + max(LAYER[t] for t in clusters[rt]) for rt in clusters}
TARGET_ASPECT = 2.4 # packed width:height — landscape, near a typical viewport

Expand All @@ -321,20 +331,23 @@ def _pack(shelf_cols):
maxx = max(maxx, cx + w); cx += w; sh = max(sh, cheight[rt])
return place, maxx, top + sh

_widest, _total = max(width.values()), sum(width.values())
_best = None
for _sc in range(_widest, _total + 1): # widest single cluster … full single row
_place, _cols, _rows = _pack(_sc)
_score = abs((_cols * COL) / (_rows * ROW) - TARGET_ASPECT)
if _best is None or _score < _best[0]:
_best = (_score, _place)
shelf = _best[1]

pos = {}
shelf = {}
if clusters: # an empty board still renders (the Pages workflow runs pre-first-ticket)
_widest, _total = max(width.values()), sum(width.values())
_best = None
for _sc in range(_widest, _total + 1): # widest single cluster … one full shelf
_place, _slots, _layers = _pack(_sc)
# transposed screen extents: layers run along x, slots along y
_score = abs((_layers * XCOL) / (_slots * YROW) - TARGET_ASPECT)
if _best is None or _score < _best[0]:
_best = (_score, _place)
shelf = _best[1]

pos = {} # the one transpose point: x ← layer axis, y ← slot axis
for rt in best_seq:
bx, by = shelf[rt]
b_slot, b_layer = shelf[rt]
for t in clusters[rt]:
pos[t] = ((bx + local[rt][t]) * COL, (by + LAYER[t]) * ROW)
pos[t] = ((b_layer + LAYER[t]) * XCOL, (b_slot + local[rt][t]) * YROW)

# Payload ids are display ids ("#42") — nodes, edges, and epics use them
# consistently, so the template needs no notion of the raw number.
Expand Down
Loading