#!/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();