diff --git a/skills/issue-tracker/SKILL.md b/skills/issue-tracker/SKILL.md index 85777de11a..cf56d4b786 100644 --- a/skills/issue-tracker/SKILL.md +++ b/skills/issue-tracker/SKILL.md @@ -84,13 +84,22 @@ checkout's repo. | `board-edge.sh --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 [--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 ` | node + issue URL + bound daemon | | `board-bind.sh ` | 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. diff --git a/skills/issue-tracker/references/board-pages.yml b/skills/issue-tracker/references/board-pages.yml new file mode 100644 index 0000000000..82da28fb4d --- /dev/null +++ b/skills/issue-tracker/references/board-pages.yml @@ -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 diff --git a/skills/issue-tracker/scripts/board-map.sh b/skills/issue-tracker/scripts/board-map.sh index 0ba04a6703..392fe629f8 100755 --- a/skills/issue-tracker/scripts/board-map.sh +++ b/skills/issue-tracker/scripts/board-map.sh @@ -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 @@ -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" @@ -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) @@ -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 @@ -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 @@ -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. diff --git a/skills/issue-tracker/scripts/board-map.template.html b/skills/issue-tracker/scripts/board-map.template.html index fbcf85234c..db13098f81 100644 --- a/skills/issue-tracker/scripts/board-map.template.html +++ b/skills/issue-tracker/scripts/board-map.template.html @@ -3,122 +3,203 @@ -Issue Board Map +Issue Board -
- +
+ issue-board +
+ + - -
+ + + + + + + + + +
- - - - - - +
+
-
+