From 6c9770db151f18d2cada2331bc3f4aef1957db79 Mon Sep 17 00:00:00 2001 From: M T Date: Tue, 23 Jun 2026 11:38:57 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20gstack-sprint-dashboard=20=E2=80=94=20i?= =?UTF-8?q?n-flight=20branch/stage=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only CLI that renders a self-contained HTML sprint board from gstack's own on-disk state (timeline.jsonl, analytics/*.jsonl, CHANGELOG.md, TODOS.md, git, gh pr list). No new instrumentation. Fills a gap /retro (backward-looking) and /landing-report (VERSION-queue-scoped) don't cover: what stage is each in-flight branch at right now, across a whole project. Design doc + 3-round adversarial review (10/10): /office-hours session. Co-Authored-By: Claude Sonnet 4.6 --- bin/gstack-sprint-dashboard | 686 ++++++++++++++++++++++++++++++++++++ 1 file changed, 686 insertions(+) create mode 100755 bin/gstack-sprint-dashboard diff --git a/bin/gstack-sprint-dashboard b/bin/gstack-sprint-dashboard new file mode 100755 index 000000000..78a342668 --- /dev/null +++ b/bin/gstack-sprint-dashboard @@ -0,0 +1,686 @@ +#!/usr/bin/env bun +/** + * gstack-sprint-dashboard — in-flight branch/stage visibility. + * + * Read-only. Generates a self-contained HTML sprint board from gstack's own + * on-disk state (timeline.jsonl, analytics/*.jsonl, CHANGELOG.md, TODOS.md, + * git, gh pr list) — no new instrumentation, no new user input. + * + * Usage: + * gstack-sprint-dashboard # write ./sprint.html, don't open + * gstack-sprint-dashboard open # write ./sprint.html, open it + * gstack-sprint-dashboard out # write to , don't open + * + * A leading "--" (e.g. `bun run bin/gstack-sprint-dashboard -- open`) is + * stripped before parsing, so both invocation styles work. + * + * Design doc: ~/.gstack/projects/garrytan-gstack/mst-feature-ui-workflow-design-20260623-105354.md + */ + +import { existsSync, readFileSync, writeFileSync, statSync, readdirSync } from "fs"; +import { join, basename } from "path"; +import { execFileSync, spawn } from "child_process"; +import { homedir } from "os"; + +const HISTORY_WINDOW_DAYS = 30; +const ACTIVITY_FEED_LIMIT = 20; +const DESIGN_DOCS_LIMIT = 10; + +// ─── Slug + paths ────────────────────────────────────────────────────────── + +function resolveSlug(): string { + const scriptDir = import.meta.dir; + const slugBin = join(scriptDir, "gstack-slug"); + try { + let output: string; + if (process.platform === "win32") { + // Windows: gstack-slug is a bash script; run it via bash with a + // forward-slash path (import.meta.dir gives backslashes on Windows, + // which bash on Windows chokes on). + const forwardSlashPath = slugBin.replace(/\\/g, "/"); + output = execFileSync("bash", [forwardSlashPath], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }); + } else { + output = execFileSync(slugBin, [], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }); + } + const match = output.match(/^SLUG=(.*)$/m); + if (match && match[1]) return match[1].trim(); + } catch { + // fall through to basename fallback + } + return basename(process.cwd()).replace(/[^a-zA-Z0-9._-]/g, ""); +} + +function gstackHome(): string { + return process.env.GSTACK_HOME || join(homedir(), ".gstack"); +} + +// ─── Generic helpers ─────────────────────────────────────────────────────── + +function readJsonlSafe(path: string): Record[] { + if (!existsSync(path)) return []; + const lines = readFileSync(path, "utf-8").split("\n"); + const out: Record[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + out.push(JSON.parse(trimmed)); + } catch { + // skip unparseable/torn lines (concurrent Conductor writes) — not fatal + } + } + return out; +} + +function withinWindow(ts: string | undefined, days: number): boolean { + if (!ts) return false; + const t = new Date(ts).getTime(); + if (Number.isNaN(t)) return false; + return Date.now() - t <= days * 24 * 60 * 60 * 1000; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +// ─── Widget: Pipeline board ──────────────────────────────────────────────── + +const SKILL_TO_STAGE: Record = { + "office-hours": "office-hours", + spec: "spec", + "plan-ceo-review": "plan-review", + "plan-eng-review": "plan-review", + "plan-design-review": "plan-review", + "plan-devex-review": "plan-review", + autoplan: "plan-review", + review: "review", + codex: "review", + "design-review": "review", + "devex-review": "review", + qa: "implement", + "qa-only": "implement", + investigate: "implement", + ship: "ship", + "land-and-deploy": "ship", +}; + +function stageForSkill(skill: string): string { + return SKILL_TO_STAGE[skill] || "implement"; +} + +interface PrBadge { + number: number; + state: string; +} + +let cachedGhUser: string | null | undefined; // undefined = not resolved yet + +// Owner-scoped: `gh pr list --head ` alone matches by branch name +// across the WHOLE repo, including every fork's PRs. A repo with hundreds of +// open community PRs has multiple unrelated forks sharing common branch names +// (verified: 3 separate fork PRs all named "main" in this repo) — a bare +// branch-name match silently shows someone else's PR badge on your own +// branch. Scoping to ":" via one cached `gh api user` +// call disambiguates correctly. +function resolveGhUser(): string | null { + if (cachedGhUser !== undefined) return cachedGhUser; + try { + cachedGhUser = execFileSync("gh", ["api", "user", "--jq", ".login"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + cachedGhUser = null; // not authenticated, or gh unavailable + } + return cachedGhUser; +} + +function fetchPrBadge(branch: string): PrBadge | undefined { + const user = resolveGhUser(); + const head = user ? `${user}:${branch}` : branch; + try { + const out = execFileSync( + "gh", + ["pr", "list", "--head", head, "--json", "number,state", "--limit", "1"], + { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] } + ); + const prs = JSON.parse(out) as { number: number; state: string }[]; + return prs[0] ? { number: prs[0].number, state: prs[0].state } : undefined; + } catch { + // gh not installed, not authenticated, or errored — best-effort, no PR badge + return undefined; + } +} + +// The default branch (main/master) is the trunk, never a PR head — skip the +// PR lookup for it entirely rather than relying on owner-scoping alone. +function resolveDefaultBranch(): string | null { + try { + return execFileSync( + "gh", + ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], + { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] } + ).trim(); + } catch { + try { + const ref = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return ref.replace(/^refs\/remotes\/origin\//, ""); + } catch { + return null; + } + } +} + +function buildPipelineBoard(timeline: Record[]): string { + let branches: string[] = []; + try { + const out = execFileSync("git", ["branch", "--format=%(refname:short)"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }); + branches = out.split("\n").map((l) => l.trim()).filter(Boolean); + } catch { + branches = []; + } + + const latestByBranch = new Map>(); + for (const entry of timeline) { + if (entry.event !== "completed" || !entry.branch || !entry.ts) continue; + const prev = latestByBranch.get(entry.branch); + if (!prev || new Date(entry.ts).getTime() > new Date(prev.ts).getTime()) { + latestByBranch.set(entry.branch, entry); + } + } + + if (branches.length === 0) { + return `
no data yet — no local git branches found
`; + } + + const defaultBranch = resolveDefaultBranch(); + + const rows = branches + .map((branch) => { + const latest = latestByBranch.get(branch); + const stage = latest ? stageForSkill(latest.skill) : "untracked"; + const lastActivity = latest + ? `${escapeHtml(latest.skill)} · ${new Date(latest.ts).toLocaleString()}` + : "—"; + // The default branch is trunk, never a PR head — skip the lookup + // rather than relying on owner-scoping alone to avoid a false match. + const pr = branch === defaultBranch ? undefined : fetchPrBadge(branch); + const prCell = pr ? `#${pr.number} ${pr.state}` : "—"; + return ` + ${escapeHtml(branch)} + ${escapeHtml(stage)} + ${lastActivity} + ${prCell} + `; + }) + .join("\n"); + + return ` + + ${rows} +
BranchStageLast ActivityPR
`; +} + +// ─── Widget: Activity feed ───────────────────────────────────────────────── + +function buildActivityFeed(timeline: Record[]): string { + const events = timeline + .filter((e) => e.ts && e.skill) + .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime()) + .slice(0, ACTIVITY_FEED_LIMIT); + + if (events.length === 0) { + return `
no data yet — timeline.jsonl has no events
`; + } + + const items = events + .map( + (e) => `
  • + ${new Date(e.ts).toLocaleString()} + ${escapeHtml(e.skill)} + ${escapeHtml(e.branch || "unknown")} + ${escapeHtml(e.event || "")} +
  • ` + ) + .join("\n"); + + return `
      ${items}
    `; +} + +// ─── Widget: Top skills chart ─────────────────────────────────────────────── + +function buildTopSkillsChart(slug: string): string { + const path = join(gstackHome(), "analytics", "skill-usage.jsonl"); + const entries = readJsonlSafe(path).filter((e) => e.skill && withinWindow(e.ts, HISTORY_WINDOW_DAYS)); + + if (entries.length === 0) { + return `
    no data yet — analytics/skill-usage.jsonl has no recent entries
    `; + } + + const counts = new Map(); + for (const e of entries) counts.set(e.skill, (counts.get(e.skill) || 0) + 1); + const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]); + const max = sorted[0]?.[1] || 1; + + const bars = sorted + .map( + ([skill, count]) => `
    + ${escapeHtml(skill)} +
    + ${count} +
    ` + ) + .join("\n"); + + return `
    ${bars}
    `; +} + +// ─── Widget: Quality scores ───────────────────────────────────────────────── + +function buildQualityScores(): string { + const path = join(gstackHome(), "analytics", "spec-review.jsonl"); + const entries = readJsonlSafe(path) + .filter((e) => e.ts && withinWindow(e.ts, HISTORY_WINDOW_DAYS) && e.quality_score !== undefined) + .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime()) + .slice(0, 10); + + if (entries.length === 0) { + return `
    no data yet — analytics/spec-review.jsonl has no recent entries
    `; + } + + const rows = entries + .map( + (e) => ` + ${new Date(e.ts).toLocaleDateString()} + ${escapeHtml(e.skill || "—")} + ${e.iterations ?? "—"} + ${e.quality_score}/10 + ` + ) + .join("\n"); + + return ` + + ${rows} +
    DateSkillIterationsScore
    `; +} + +// ─── Widget: Active design docs ───────────────────────────────────────────── + +function buildActiveDesignDocs(slug: string): string { + const projectDir = join(gstackHome(), "projects", slug); + if (!existsSync(projectDir)) { + return `
    no data yet — no project directory for this repo
    `; + } + + let files: string[]; + try { + files = readdirSync(projectDir).filter((f) => /-design-.*\.md$/.test(f)); + } catch { + return `
    no data yet — could not read project directory
    `; + } + + if (files.length === 0) { + return `
    no data yet — no design docs for this project
    `; + } + + const withMtime = files.map((f) => { + const fullPath = join(projectDir, f); + let mtime = 0; + try { + mtime = statSync(fullPath).mtimeMs; + } catch { + // leave mtime at 0 + } + return { name: f, fullPath, mtime }; + }); + + withMtime.sort((a, b) => b.mtime - a.mtime); + + const items = withMtime + .slice(0, DESIGN_DOCS_LIMIT) + .map( + (f) => `
  • + ${escapeHtml(f.name)} + ${f.mtime ? new Date(f.mtime).toLocaleDateString() : "—"} +
  • ` + ) + .join("\n"); + + return `
      ${items}
    `; +} + +// ─── Widget: Release velocity ─────────────────────────────────────────────── + +function buildReleaseVelocity(): string { + const changelogPath = join(process.cwd(), "CHANGELOG.md"); + if (!existsSync(changelogPath)) { + return `
    no data yet — no CHANGELOG.md in this repo
    `; + } + + const changelog = readFileSync(changelogPath, "utf-8"); + const versionHeadingRe = /^## \[(\d+\.\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})/gm; + const versions: { version: string; date: string }[] = []; + let m: RegExpExecArray | null; + while ((m = versionHeadingRe.exec(changelog)) !== null) { + versions.push({ version: m[1], date: m[2] }); + } + + const recentVersions = versions.filter((v) => withinWindow(v.date, HISTORY_WINDOW_DAYS)); + + if (recentVersions.length === 0) { + return `
    no data yet — no versions shipped in the last ${HISTORY_WINDOW_DAYS} days
    `; + } + + // Commits per version: PR-merge commit subjects are conventionally + // prefixed "vX.Y.Z.W " in this repo's commit log. + let subjects: string[] = []; + try { + const out = execFileSync( + "git", + ["log", `--since=${HISTORY_WINDOW_DAYS} days ago`, "--pretty=format:%s"], + { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] } + ); + subjects = out.split("\n").filter(Boolean); + } catch { + subjects = []; + } + + const commitCounts = new Map(); + for (const subject of subjects) { + const vm = subject.match(/^v(\d+\.\d+\.\d+\.\d+)\s/); + if (vm) commitCounts.set(vm[1], (commitCounts.get(vm[1]) || 0) + 1); + } + + // Chronological order, oldest first, for a left-to-right sparkline. + recentVersions.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + const counts = recentVersions.map((v) => commitCounts.get(v.version) || 0); + const max = Math.max(1, ...counts); + const blocks = " ▁▂▃▄▅▆▇█"; + + const sparkline = recentVersions + .map((v, i) => { + const count = counts[i]; + const level = Math.min(8, Math.round((count / max) * 8)); + return `${blocks[level]}`; + }) + .join(""); + + const versionList = recentVersions + .map((v, i) => `
  • v${escapeHtml(v.version)} — ${v.date} — ${counts[i]} commit(s)
  • `) + .join("\n"); + + return `
    +
    ${sparkline}
    +
      ${versionList}
    +
    `; +} + +// ─── Widget: TODOS backlog health ─────────────────────────────────────────── + +function buildBacklogHealth(): string { + const todosPath = join(process.cwd(), "TODOS.md"); + if (!existsSync(todosPath)) { + return `
    no data yet — no TODOS.md in this repo
    `; + } + + const todos = readFileSync(todosPath, "utf-8"); + const headingRe = /^### (.+)$/gm; + const counts: Record = { P0: 0, P1: 0, P2: 0, P3: 0, P4: 0, unparsed: 0 }; + let m: RegExpExecArray | null; + while ((m = headingRe.exec(todos)) !== null) { + const heading = m[1]; + const pm = heading.match(/^(P[0-4]):/); + if (pm) counts[pm[1]]++; + else counts.unparsed++; + } + + const total = Object.values(counts).reduce((a, b) => a + b, 0); + if (total === 0) { + return `
    no data yet — TODOS.md has no level-3 headings
    `; + } + + const rows = (["P0", "P1", "P2", "P3", "P4", "unparsed"] as const) + .filter((p) => counts[p] > 0) + .map((p) => `${p}${counts[p]}`) + .join("\n"); + + return ` + + ${rows} +
    PriorityCount
    `; +} + +// ─── HTML shell ────────────────────────────────────────────────────────── + +function renderPage(slug: string, branch: string, sections: Record): string { + const generatedAt = new Date().toLocaleString(); + return ` + + + +gstack sprint dashboard — ${escapeHtml(slug)} + + + +
    +

    gstack sprint dashboard

    +
    project: ${escapeHtml(slug)} · branch: ${escapeHtml(branch)} · generated ${escapeHtml(generatedAt)}
    +
    + +

    Pipeline board

    +
    ${sections.pipeline}
    + +
    +
    +

    Activity feed

    +
    ${sections.activity}
    +
    +
    +

    Top skills (last ${HISTORY_WINDOW_DAYS}d)

    +
    ${sections.topSkills}
    +
    +
    + +
    +
    +

    Quality scores (last ${HISTORY_WINDOW_DAYS}d)

    +
    ${sections.quality}
    +
    +
    +

    Active design docs

    +
    ${sections.designDocs}
    +
    +
    + +
    +
    +

    Release velocity (last ${HISTORY_WINDOW_DAYS}d)

    +
    ${sections.releaseVelocity}
    +
    +
    +

    TODOS backlog health

    +
    ${sections.backlog}
    +
    +
    + + +`; +} + +// ─── Open helper ───────────────────────────────────────────────────────── + +function openInBrowser(path: string) { + let cmd: string; + let args: string[]; + if (process.platform === "darwin") { + cmd = "open"; + args = [path]; + } else if (process.platform === "win32") { + // explorer.exe, not `cmd /c start` — start mishandles paths with + // special characters and pops a console window on some configs. + cmd = "explorer.exe"; + args = [path]; + } else { + cmd = "xdg-open"; + args = [path]; + } + try { + const child = spawn(cmd, args, { stdio: "ignore", detached: true }); + child.unref(); + } catch { + console.error(`Could not auto-open ${path} — open it manually.`); + } +} + +// ─── Main ──────────────────────────────────────────────────────────────── + +function main() { + const args = process.argv.slice(2).filter((a) => a !== "--"); + const command = args[0] || "out"; + const outPath = command === "out" ? args[1] || "sprint.html" : "sprint.html"; + + const slug = resolveSlug(); + let branch = "unknown"; + try { + branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + // leave as "unknown" + } + + const timelinePath = join(gstackHome(), "projects", slug, "timeline.jsonl"); + const timeline = readJsonlSafe(timelinePath); + + const sections = { + pipeline: buildPipelineBoard(timeline), + activity: buildActivityFeed(timeline), + topSkills: buildTopSkillsChart(slug), + quality: buildQualityScores(), + designDocs: buildActiveDesignDocs(slug), + releaseVelocity: buildReleaseVelocity(), + backlog: buildBacklogHealth(), + }; + + const html = renderPage(slug, branch, sections); + writeFileSync(outPath, html, "utf-8"); + console.log(`Wrote ${outPath}`); + + if (command === "open") { + openInBrowser(outPath); + } +} + +main();