#!/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, resolve } 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, """); } interface ChangelogVersion { version: string; date: string; } // Shared by Release Velocity and the header stats bar — parsed once. function getChangelogVersions(): ChangelogVersion[] { const changelogPath = join(process.cwd(), "CHANGELOG.md"); if (!existsSync(changelogPath)) return []; const changelog = readFileSync(changelogPath, "utf-8"); const versionHeadingRe = /^## \[(\d+\.\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})/gm; const versions: ChangelogVersion[] = []; let m: RegExpExecArray | null; while ((m = versionHeadingRe.exec(changelog)) !== null) { versions.push({ version: m[1], date: m[2] }); } return versions; } interface BacklogCounts { P0: number; P1: number; P2: number; P3: number; P4: number; unparsed: number; } // Shared by the TODOS backlog-health widget and the header stats bar. function getBacklogCounts(): BacklogCounts | null { const todosPath = join(process.cwd(), "TODOS.md"); if (!existsSync(todosPath)) return null; const todos = readFileSync(todosPath, "utf-8"); const headingRe = /^### (.+)$/gm; const counts: BacklogCounts = { P0: 0, P1: 0, P2: 0, P3: 0, P4: 0, unparsed: 0 }; let m: RegExpExecArray | null; while ((m = headingRe.exec(todos)) !== null) { const pm = m[1].match(/^(P[0-4]):/); if (pm) counts[pm[1] as keyof BacklogCounts]++; else counts.unparsed++; } return counts; } // Best-effort: shells out to the sibling gstack-decision-search binary rather // than re-implementing the active-decisions snapshot format. Returns null // (renders "—") if the binary is missing, errors, or returns unparseable // output — open decisions is a "nice to have" stat, never a hard dependency. function getOpenDecisionsCount(): number | null { try { const out = execFileSync(join(import.meta.dir, "gstack-decision-search"), ["--json"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], }); const rows = JSON.parse(out || "[]"); return Array.isArray(rows) ? rows.length : null; } catch { return null; } } function getCurrentVersion(): string | null { const versionPath = join(process.cwd(), "VERSION"); if (!existsSync(versionPath)) return null; const v = readFileSync(versionPath, "utf-8").trim(); return v || null; } // ─── 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", canary: "canary", }; // Ordered left-to-right in the pipeline board grid. const STAGES: { id: string; label: string }[] = [ { id: "office-hours", label: "OH" }, { id: "spec", label: "Spec" }, { id: "plan-review", label: "Plan" }, { id: "implement", label: "Impl" }, { id: "review", label: "Review" }, { id: "ship", label: "Ship" }, { id: "canary", label: "Canary" }, ]; function stageForSkill(skill: string): string { return SKILL_TO_STAGE[skill] || "implement"; } interface BranchStageData { branch: string; stagesReached: Set; latestStage: string | null; latestEntry: Record | null; } // Shared by the pipeline board and the header stats bar — computed once so // both widgets agree on what "in-flight" means and don't re-walk the // timeline/git-branch list twice. function computeBranchStageData(timeline: Record[]): BranchStageData[] { 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 entriesByBranch = new Map[]>(); for (const entry of timeline) { if (entry.event !== "completed" || !entry.branch || !entry.ts || !entry.skill) continue; const list = entriesByBranch.get(entry.branch) || []; list.push(entry); entriesByBranch.set(entry.branch, list); } return branches.map((branch) => { const entries = entriesByBranch.get(branch) || []; const stagesReached = new Set(entries.map((e) => stageForSkill(e.skill))); let latestEntry: Record | null = null; for (const e of entries) { if (!latestEntry || new Date(e.ts).getTime() > new Date(latestEntry.ts).getTime()) latestEntry = e; } const latestStage = latestEntry ? stageForSkill(latestEntry.skill) : null; return { branch, stagesReached, latestStage, latestEntry }; }); } 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(branchData: BranchStageData[]): string { if (branchData.length === 0) { return `
no data yet — no local git branches found
`; } const defaultBranch = resolveDefaultBranch(); const stageHeaders = STAGES.map((s) => `${escapeHtml(s.label)}`).join(""); const rows = branchData .map(({ branch, stagesReached, latestStage, latestEntry }) => { const lastActivity = latestEntry ? `${escapeHtml(latestEntry.skill)} · ${new Date(latestEntry.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}` : "—"; const stageCells = STAGES.map((s) => { if (!stagesReached.has(s.id)) return ``; const isCurrent = s.id === latestStage; return ``; }).join(""); return ` ${escapeHtml(branch)}${latestStage === null ? ' (untracked)' : ""} ${stageCells} ${lastActivity} ${prCell} `; }) .join("\n"); return `${stageHeaders}${rows}
BranchLast 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(versions: ChangelogVersion[]): string { if (versions.length === 0) { return `
    no data yet — no CHANGELOG.md in this repo
    `; } 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(counts: BacklogCounts | null): string { if (!counts) { return `
    no data yet — no TODOS.md in this repo
    `; } 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
    `; } // ─── Widget: Header stats bar ─────────────────────────────────────────────── function buildHeaderStats( branchData: BranchStageData[], versions: ChangelogVersion[], backlogCounts: BacklogCounts | null ): string { const version = getCurrentVersion(); const inFlight = branchData.filter( (b) => b.latestStage !== null && b.latestStage !== "ship" ).length; const now = new Date(); const shipsThisMonth = versions.filter((v) => { const d = new Date(v.date); return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth(); }).length; const recentVersions = versions .filter((v) => withinWindow(v.date, HISTORY_WINDOW_DAYS)) .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); let avgDaysPerRelease: string = "—"; if (recentVersions.length >= 2) { const first = new Date(recentVersions[0].date).getTime(); const last = new Date(recentVersions[recentVersions.length - 1].date).getTime(); const spanDays = (last - first) / (24 * 60 * 60 * 1000); avgDaysPerRelease = (spanDays / (recentVersions.length - 1)).toFixed(1); } const p1Open = backlogCounts ? backlogCounts.P1 : null; const openDecisions = getOpenDecisionsCount(); const stat = (label: string, value: string | number | null) => `
    ${value === null ? "—" : escapeHtml(String(value))}
    ${escapeHtml(label)}
    `; return `
    ${stat("Version", version ? `v${version}` : null)} ${stat("In-flight", inFlight)} ${stat("Ships this month", shipsThisMonth)} ${stat("Avg days/release", avgDaysPerRelease === "—" ? null : avgDaysPerRelease)} ${stat("P1 open", p1Open)} ${stat("Open decisions", openDecisions)}
    `; } // ─── 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)}
    ${sections.headerStats}

    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 isWsl(): boolean { if (process.platform !== "linux") return false; try { return /microsoft/i.test(readFileSync("/proc/version", "utf-8")); } catch { return false; } } function openInBrowser(path: string) { const absPath = resolve(path); let cmd: string; let args: string[]; if (process.platform === "darwin") { cmd = "open"; args = [absPath]; } 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 = [absPath]; } else if (isWsl()) { // WSL has no desktop environment / MIME handlers on the Linux side — // xdg-open is typically not even installed. Hand off to the Windows // side via WSL interop instead. explorer.exe needs a Windows-style // path (C:\...), not the WSL /mnt/c/... or native Linux path. cmd = "explorer.exe"; try { const winPath = execFileSync("wslpath", ["-w", absPath], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], }).trim(); args = [winPath]; } catch { args = [absPath]; } } else { cmd = "xdg-open"; args = [absPath]; } try { const child = spawn(cmd, args, { stdio: "ignore", detached: true }); // ENOENT (missing binary) surfaces asynchronously via the 'error' event, // not as a synchronous throw — without this handler it's an unhandled // exception that crashes the whole process (observed: xdg-open missing // on a WSL box with no desktop environment). child.on("error", () => { console.error(`Could not auto-open ${absPath} automatically — open it manually.`); }); child.unref(); } catch { console.error(`Could not auto-open ${absPath} automatically — 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 branchData = computeBranchStageData(timeline); const versions = getChangelogVersions(); const backlogCounts = getBacklogCounts(); const sections = { headerStats: buildHeaderStats(branchData, versions, backlogCounts), pipeline: buildPipelineBoard(branchData), activity: buildActivityFeed(timeline), topSkills: buildTopSkillsChart(slug), quality: buildQualityScores(), designDocs: buildActiveDesignDocs(slug), releaseVelocity: buildReleaseVelocity(versions), backlog: buildBacklogHealth(backlogCounts), }; const html = renderPage(slug, branch, sections); writeFileSync(outPath, html, "utf-8"); console.log(`Wrote ${outPath}`); if (command === "open") { openInBrowser(outPath); } } main();