diff --git a/AGENTS.md b/AGENTS.md index 69651022d..a64ba1a9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,7 @@ Invoke them by name (e.g., `/office-hours`). | `/context-restore` | Resume from a saved context, even across Conductor workspaces. | | `/learn` | Manage what gstack learned across sessions. | | `/retro` | Weekly retro with per-person breakdowns and shipping streaks. | +| `/dashboard` | Sprint board: compact terminal (default), `--full` all sections, `--web` HTML in browser. | | `/health` | Code quality dashboard (type checker, linter, tests, dead code). | | `/benchmark` | Performance regression detection (page load, Core Web Vitals). | | `/benchmark-models` | Cross-model benchmark for skills (Claude, GPT, Gemini side-by-side). | diff --git a/CLAUDE.md b/CLAUDE.md index 984844902..698a2dae6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -953,6 +953,7 @@ Key routing rules: - Ship/deploy/PR → invoke /ship or /land-and-deploy - Save progress → invoke /context-save - Resume context → invoke /context-restore +- Sprint status / project dashboard → invoke /dashboard ## Cross-session decision memory diff --git a/TODOS.md b/TODOS.md index f9753970b..57ba63621 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2439,6 +2439,26 @@ building once users hit multi-diagram docs; wedge perf is fine without it. **Effort:** S (human ~1d, CC ~30min). **Depends on:** diagram engine wedge shipping (lib/diagram-render bundle versioning). +### P3: Fix loadVelocity sparkline to show meaningful commit heights + +**What:** `loadVelocity()` in `lib/dashboard-data.ts` counts commits per version by +matching `git log --pretty=format:%s` against `/^v\d+\.\d+\.\d+\.\d+ /`. In gstack's +commit style, merge commits have this prefix (one per release), so every version always +gets `commitCount=1`. The sparkline in `renderFull` renders all uniform full blocks (█) +with no height variation. + +**Why:** The sparkline is meant to show release weight (bigger releases = taller bar). +With all heights equal it's visual noise, not signal. + +**Fix:** Use `git log --oneline ..` per version pair to count actual +commits per release, or use `git tag -l` to detect release tags and count commits +between them. Either approach produces meaningful height variation. + +**Context:** Surfaced in /plan-eng-review on 2026-06-26. Deferred because the sparkline +renders without crashing and the version list below it is still correct. + +**Effort:** S (human ~2h, CC ~10min). **Depends on:** None. + ### P3: Dedupe the make-pdf e2e gate-test harness **What:** Five e2e files (`combined-gate`, `emoji-gate`, `diagram-gate`, diff --git a/bin/gstack-dash-oneliner b/bin/gstack-dash-oneliner new file mode 100755 index 000000000..cd058e43b --- /dev/null +++ b/bin/gstack-dash-oneliner @@ -0,0 +1,17 @@ +#!/usr/bin/env bun +/** + * gstack-dash-oneliner — fast one-line project status for the PreToolUse hook. + * + * Disk-only (no gh, no decision-search). Exits 0 always — a missing project + * directory or malformed data silently produces no output rather than blocking. + */ + +import { buildOnelinerData } from "../lib/dashboard-data"; +import { renderOneliner } from "../lib/dashboard-cli"; + +try { + const data = buildOnelinerData(); + process.stdout.write(renderOneliner(data) + "\n"); +} catch { + // silent — best-effort, never block a skill +} diff --git a/bin/gstack-sprint-dashboard b/bin/gstack-sprint-dashboard index 460ee6c25..7b53afc37 100755 --- a/bin/gstack-sprint-dashboard +++ b/bin/gstack-sprint-dashboard @@ -1,89 +1,27 @@ #!/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. + * gstack-sprint-dashboard — sprint board in three output modes. * * 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 + * gstack-sprint-dashboard # compact terminal (default) + * gstack-sprint-dashboard --compact # compact terminal (explicit) + * gstack-sprint-dashboard --full # full terminal view + * gstack-sprint-dashboard --web # write sprint.html + open in browser + * gstack-sprint-dashboard --out # write HTML to (implies --web) + * gstack-sprint-dashboard --slug # override project slug * - * 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 + * Backwards-compat positional args (mapped to --web): + * gstack-sprint-dashboard open # alias for --web + * gstack-sprint-dashboard out # alias for --web --out */ -import { existsSync, readFileSync, writeFileSync, statSync, readdirSync } from "fs"; -import { join, basename, resolve } from "path"; +import { writeFileSync, readFileSync } from "fs"; +import { resolve } from "path"; import { execFileSync, spawn } from "child_process"; -import { homedir } from "os"; +import { buildDashboard } from "../lib/dashboard-data"; +import { renderCompact, renderFull } from "../lib/dashboard-cli"; -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; -} +// ─── HTML helpers (web mode only) ─────────────────────────────────────────── function escapeHtml(s: string): string { return s @@ -93,518 +31,151 @@ function escapeHtml(s: string): string { .replace(/"/g, """); } -interface ChangelogVersion { - version: string; - date: string; +function stageIcon(reached: boolean, isCurrent: boolean): string { + if (!reached) return ""; + return ``; } -// 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; -} +function renderHtml(data: ReturnType): string { + const { slug, branch, version, generatedAt, inFlightCount, features, activity, + velocity, topSkills, quality, designDocs, backlog, openDecisions, prMap } = data; -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; + // ── Stage constants for HTML ── + const STAGES = [ + { 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" }, + ] as const; 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 HISTORY_DAYS = 30; - 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)} + // ── Header stats ── + const statsBar = `
    + ${[ + ["Version", version ? `v${version}` : null], + ["In-flight", inFlightCount], + ["Ships this month", velocity.releasesThisMonth], + ["Avg days/release", velocity.avgDaysBetween !== null ? velocity.avgDaysBetween.toFixed(1) : null], + ["P1 open", backlog?.P1 ?? null], + ["Open decisions", openDecisions], + ].map(([label, value]) => + `
    +
    ${value === null || value === undefined ? "—" : escapeHtml(String(value))}
    +
    ${escapeHtml(String(label))}
    +
    ` + ).join("")}
    `; -} -// ─── HTML shell ────────────────────────────────────────────────────────── + // ── Pipeline ── + const stageHeaders = STAGES.map((s) => `${s.label}`).join(""); + const pipelineRows = features.map((f) => { + const stageCells = STAGES.map((s) => { + const reached = f.stagesReached.has(s.id as any); + const isCurrent = f.latestStage === s.id; + return `${stageIcon(reached, isCurrent)}`; + }).join(""); + const pr = prMap.get(f.branch); + const prCell = pr + ? `#${pr.number} ${pr.state}` + : "—"; + const lastAct = f.latestTs + ? `${escapeHtml(f.latestSkill ?? "")} · ${new Date(f.latestTs).toLocaleString()}` + : "—"; + return ` + ${escapeHtml(f.branch)}${f.latestStage === null ? ' (untracked)' : ""} + ${stageCells} + ${lastAct} + ${prCell} + `; + }).join("\n"); + + const pipeline = features.length === 0 + ? `
    no data yet — no local git branches found
    ` + : ` + ${stageHeaders} + ${pipelineRows} +
    BranchLast ActivityPR
    `; + + // ── Activity feed ── + const activityHtml = activity.length === 0 + ? `
    no data yet
    ` + : `
      ${activity.map((e) => + `
    • + ${new Date(e.ts).toLocaleString()} + ${escapeHtml(e.skill)} + ${escapeHtml(e.branch)} + ${escapeHtml(e.event)} +
    • `).join("\n")}
    `; + + // ── Top skills ── + const topSkillsHtml = topSkills.length === 0 + ? `
    no data yet
    ` + : (() => { + const max = topSkills[0]?.count || 1; + return `
    ${topSkills.map((s) => + `
    + ${escapeHtml(s.skill)} +
    + ${s.count} +
    `).join("\n")}
    `; + })(); + + // ── Quality ── + const qualityHtml = quality.length === 0 + ? `
    no data yet
    ` + : ` + + ${quality.map((e) => + ` + + + + + `).join("\n")} +
    DateSkillIterationsScore
    ${new Date(e.ts).toLocaleDateString()}${escapeHtml(e.skill)}${e.iterations}${e.score}/10
    `; + + // ── Design docs ── + const designDocsHtml = designDocs.length === 0 + ? `
    no data yet
    ` + : `
      ${designDocs.map((d) => + `
    • + ${escapeHtml(d.name)} + ${d.mtime ? new Date(d.mtime).toLocaleDateString() : "—"} +
    • `).join("\n")}
    `; + + // ── Release velocity ── + const releaseVelocityHtml = (() => { + const recent = velocity.recentVersions; + if (recent.length === 0) return `
    no releases in the last ${HISTORY_DAYS} days
    `; + const blocks = " ▁▂▃▄▅▆▇█"; + const max = Math.max(1, ...recent.map((v) => v.commitCount)); + const sparkline = recent + .map((v) => `${blocks[Math.min(8, Math.round((v.commitCount / max) * 8))]}`) + .join(""); + const versionList = recent + .map((v) => `
  • v${escapeHtml(v.version)} — ${v.date} — ${v.commitCount} commit(s)
  • `) + .join("\n"); + return `
    ${sparkline}
      ${versionList}
    `; + })(); + + // ── Backlog ── + const backlogHtml = !backlog + ? `
    no TODOS.md found
    ` + : (() => { + const total = Object.values(backlog).reduce((a, b) => a + b, 0); + if (total === 0) return `
    no level-3 headings in TODOS.md
    `; + const rows = (["P0", "P1", "P2", "P3", "P4", "unparsed"] as const) + .filter((p) => backlog[p] > 0) + .map((p) => `${p}${backlog[p]}`) + .join("\n"); + return `${rows}
    PriorityCount
    `; + })(); -function renderPage(slug: string, branch: string, sections: Record): string { - const generatedAt = new Date().toLocaleString(); return ` @@ -612,7 +183,7 @@ function renderPage(slug: string, branch: string, sections: Recordgstack sprint dashboard — ${escapeHtml(slug)}

    gstack sprint dashboard

    -
    project: ${escapeHtml(slug)} · branch: ${escapeHtml(branch)} · generated ${escapeHtml(generatedAt)}
    - ${sections.headerStats} +
    project: ${escapeHtml(slug)} · branch: ${escapeHtml(branch)} · generated ${escapeHtml(generatedAt.toLocaleString())}
    + ${statsBar}

    Pipeline board

    -
    ${sections.pipeline}
    +
    ${pipeline}

    Activity feed

    -
    ${sections.activity}
    +
    ${activityHtml}
    -

    Top skills (last ${HISTORY_WINDOW_DAYS}d)

    -
    ${sections.topSkills}
    +

    Top skills (last ${HISTORY_DAYS}d)

    +
    ${topSkillsHtml}
    -

    Quality scores (last ${HISTORY_WINDOW_DAYS}d)

    -
    ${sections.quality}
    +

    Quality scores (last ${HISTORY_DAYS}d)

    +
    ${qualityHtml}

    Active design docs

    -
    ${sections.designDocs}
    +
    ${designDocsHtml}
    -

    Release velocity (last ${HISTORY_WINDOW_DAYS}d)

    -
    ${sections.releaseVelocity}
    +

    Release velocity (last ${HISTORY_DAYS}d)

    +
    ${releaseVelocityHtml}

    TODOS backlog health

    -
    ${sections.backlog}
    +
    ${backlogHtml}
    @@ -771,7 +305,7 @@ function renderPage(slug: string, branch: string, sections: Record`; } -// ─── Open helper ───────────────────────────────────────────────────────── +// ─── Open helper ───────────────────────────────────────────────────────────── function isWsl(): boolean { if (process.platform !== "linux") return false; @@ -790,15 +324,9 @@ function openInBrowser(path: string) { 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], { @@ -815,61 +343,54 @@ function openInBrowser(path: string) { } 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.`); + console.error(`Could not open ${absPath} automatically — open it manually.`); }); child.unref(); } catch { - console.error(`Could not auto-open ${absPath} automatically — open it manually.`); + console.error(`Could not open ${absPath} automatically — open it manually.`); } } -// ─── Main ──────────────────────────────────────────────────────────────── +// ─── 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" + // Backwards-compat: positional "open" and "out " + const positionalOpen = args.includes("open"); + const positionalOutIdx = args.indexOf("out"); + const positionalOutPath = positionalOutIdx >= 0 ? args[positionalOutIdx + 1] : undefined; + + // Determine mode + let mode: "compact" | "full" | "web" = "compact"; + if (args.includes("--full")) mode = "full"; + if (args.includes("--web") || args.includes("--open") || positionalOpen || positionalOutPath) mode = "web"; + + // Options + const outArgIdx = args.indexOf("--out"); + const outPath = outArgIdx >= 0 ? args[outArgIdx + 1] : (positionalOutPath ?? "sprint.html"); + const slugArgIdx = args.indexOf("--slug"); + const slugArg = slugArgIdx >= 0 ? args[slugArgIdx + 1] : undefined; + + if (mode === "compact") { + const data = buildDashboard({ slug: slugArg, loadPr: false, loadDecisions: false }); + process.stdout.write(renderCompact(data)); + return; } - const timelinePath = join(gstackHome(), "projects", slug, "timeline.jsonl"); - const timeline = readJsonlSafe(timelinePath); - const branchData = computeBranchStageData(timeline); - const versions = getChangelogVersions(); - const backlogCounts = getBacklogCounts(); + if (mode === "full") { + const data = buildDashboard({ slug: slugArg, loadPr: true, loadDecisions: true }); + process.stdout.write(renderFull(data)); + return; + } - 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); + // web mode + const data = buildDashboard({ slug: slugArg, loadPr: true, loadDecisions: true }); + const html = renderHtml(data); writeFileSync(outPath, html, "utf-8"); console.log(`Wrote ${outPath}`); - - if (command === "open") { - openInBrowser(outPath); - } + openInBrowser(outPath); } main(); diff --git a/dashboard/SKILL.md b/dashboard/SKILL.md new file mode 100644 index 000000000..d01dde13d --- /dev/null +++ b/dashboard/SKILL.md @@ -0,0 +1,570 @@ +--- +name: dashboard +preamble-tier: 1 +version: 1.0.0 +description: "Sprint board: compact terminal (default) · --full · --web HTML · --slug . (gstack)" +allowed-tools: + - Bash +triggers: + - sprint status + - what's in flight + - project status + - dashboard +--- + + + + +## When to invoke this skill + +Use when asked for "sprint status", "what's in flight", "project status", or "dashboard". + +## Preamble (run first) + +```bash +_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true) +[ -n "$_UPD" ] && echo "$_UPD" || true +mkdir -p ~/.gstack/sessions +touch ~/.gstack/sessions/"$PPID" +_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ') +find ~/.gstack/sessions -mmin +120 -type f -exec rm {} + 2>/dev/null || true +_PROACTIVE=$(~/.claude/skills/gstack/bin/gstack-config get proactive 2>/dev/null || echo "true") +_PROACTIVE_PROMPTED=$([ -f ~/.gstack/.proactive-prompted ] && echo "yes" || echo "no") +_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") +echo "BRANCH: $_BRANCH" +_SKILL_PREFIX=$(~/.claude/skills/gstack/bin/gstack-config get skill_prefix 2>/dev/null || echo "false") +echo "PROACTIVE: $_PROACTIVE" +echo "PROACTIVE_PROMPTED: $_PROACTIVE_PROMPTED" +echo "SKILL_PREFIX: $_SKILL_PREFIX" +source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true +REPO_MODE=${REPO_MODE:-unknown} +echo "REPO_MODE: $REPO_MODE" +_SESSION_KIND=$(~/.claude/skills/gstack/bin/gstack-session-kind 2>/dev/null || echo "interactive") +case "$_SESSION_KIND" in spawned|headless|interactive) ;; *) _SESSION_KIND="interactive" ;; esac +echo "SESSION_KIND: $_SESSION_KIND" +# Conductor host: AskUserQuestion is unreliable here (native disabled, MCP +# variant flaky), so skills render decisions as prose instead of calling the +# tool. Gated on !headless so an eval/CI run INSIDE Conductor (GSTACK_HEADLESS) +# still BLOCKs rather than rendering prose to nobody. +if [ "$_SESSION_KIND" != "headless" ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then + echo "CONDUCTOR_SESSION: true" +fi +_ACTIVATED=$([ -f ~/.gstack/.activated ] && echo "yes" || echo "no") +_FIRST_LOOP_SHOWN=$([ -f ~/.gstack/.first-loop-tip-shown ] && echo "yes" || echo "no") +echo "ACTIVATED: $_ACTIVATED" +echo "FIRST_LOOP_SHOWN: $_FIRST_LOOP_SHOWN" +# First-run project detection: run the detector ONLY on the first-ever skill run +# (ACTIVATED=no, interactive) so it stays off the hot path for every run after. +_FIRST_TASK="" +if [ "$_ACTIVATED" = "no" ] && [ "$_SESSION_KIND" != "headless" ]; then + _FIRST_TASK=$(~/.claude/skills/gstack/bin/gstack-first-task-detect 2>/dev/null || true) +fi +echo "FIRST_TASK: $_FIRST_TASK" +_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") +echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" +_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default") +if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi +echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL" +_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false") +echo "QUESTION_TUNING: $_QUESTION_TUNING" +mkdir -p ~/.gstack/analytics +if [ "$_TEL" != "off" ]; then +echo '{"skill":"dashboard","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +fi +for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do + if [ -f "$_PF" ]; then + if [ "$_TEL" != "off" ] && [ -x "~/.claude/skills/gstack/bin/gstack-telemetry-log" ]; then + ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true + fi + rm -f "$_PF" 2>/dev/null || true + fi + break +done +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +_LEARN_FILE="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/learnings.jsonl" +if [ -f "$_LEARN_FILE" ]; then + _LEARN_COUNT=$(wc -l < "$_LEARN_FILE" 2>/dev/null | tr -d ' ') + echo "LEARNINGS: $_LEARN_COUNT entries loaded" + if [ "$_LEARN_COUNT" -gt 5 ] 2>/dev/null; then + ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 3 2>/dev/null || true + fi +else + echo "LEARNINGS: 0" +fi +~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"dashboard","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null & +_HAS_ROUTING="no" +if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then + _HAS_ROUTING="yes" +fi +_ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false") +echo "HAS_ROUTING: $_HAS_ROUTING" +echo "ROUTING_DECLINED: $_ROUTING_DECLINED" +_VENDORED="no" +if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then + if [ -f ".claude/skills/gstack/VERSION" ] || [ -d ".claude/skills/gstack/.git" ]; then + _VENDORED="yes" + fi +fi +echo "VENDORED_GSTACK: $_VENDORED" +echo "MODEL_OVERLAY: claude" +_CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode 2>/dev/null || echo "explicit") +_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false") +echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE" +echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH" +# Plan-mode hint for skills like /spec that branch behavior on plan-mode state. +# Claude Code exposes plan mode via system reminders; we detect best-effort +# from CLAUDE_PLAN_FILE (set by the harness when plan mode is active) and +# fall back to "inactive". Codex hosts and Claude execution mode both end up +# inactive, which is the safe default (defaults to file+execute pipeline). +if [ -n "${CLAUDE_PLAN_FILE:-}${GSTACK_PLAN_MODE_FORCE:-}" ]; then + export GSTACK_PLAN_MODE="active" +elif [ "${GSTACK_PLAN_MODE:-}" = "active" ]; then + export GSTACK_PLAN_MODE="active" +else + export GSTACK_PLAN_MODE="inactive" +fi +echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE" +[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true +``` + +## Plan Mode Safe Operations + +In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`codex review`, writes to `~/.gstack/`, writes to the plan file, and `open` for generated artifacts. + +## Skill Invocation During Plan Mode + +If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. + +If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?" + +If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`. + +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). + +If output shows `JUST_UPGRADED `: print "Running gstack v{to} (just updated!)". If `SPAWNED_SESSION` is true, skip feature discovery. + +Feature discovery, max one prompt per session: +- Missing `~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint`: AskUserQuestion for Continuous checkpoint auto-commits. If accepted, run `~/.claude/skills/gstack/bin/gstack-config set checkpoint_mode continuous`. Always touch marker. +- Missing `~/.claude/skills/gstack/.feature-prompted-model-overlay`: inform "Model overlays are active. MODEL_OVERLAY shows the patch." Always touch marker. + +After upgrade prompts, continue workflow. + +If `WRITING_STYLE_PENDING` is `yes`: ask once about writing style: + +> v1 prompts are simpler: first-use jargon glosses, outcome-framed questions, shorter prose. Keep default or restore terse? + +Options: +- A) Keep the new default (recommended — good writing helps everyone) +- B) Restore V0 prose — set `explain_level: terse` + +If A: leave `explain_level` unset (defaults to `default`). +If B: run `~/.claude/skills/gstack/bin/gstack-config set explain_level terse`. + +Always run (regardless of choice): +```bash +rm -f ~/.gstack/.writing-style-prompt-pending +touch ~/.gstack/.writing-style-prompted +``` + +Skip if `WRITING_STYLE_PENDING` is `no`. + +If `LAKE_INTRO` is `no`: say "gstack follows the **Boil the Ocean** principle — do the complete thing when AI makes marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean" Offer to open: + +```bash +open https://garryslist.org/posts/boil-the-ocean +touch ~/.gstack/.completeness-intro-seen +``` + +Only run `open` if yes. Always run `touch`. + +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion: + +> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload. + +Options: +- A) Help gstack get better! (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry community` + +If B: ask follow-up: + +> Anonymous mode sends only aggregate usage, no unique ID. + +Options: +- A) Sure, anonymous is fine +- B) No thanks, fully off + +If B→A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B→B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +Skip if `TEL_PROMPTED` is `yes`. + +If `PROACTIVE_PROMPTED` is `no` AND `TEL_PROMPTED` is `yes`: ask once: + +> Let gstack proactively suggest skills, like /qa for "does this work?" or /investigate for bugs? + +Options: +- A) Keep it on (recommended) +- B) Turn it off — I'll type /commands myself + +If A: run `~/.claude/skills/gstack/bin/gstack-config set proactive true` +If B: run `~/.claude/skills/gstack/bin/gstack-config set proactive false` + +Always run: +```bash +touch ~/.gstack/.proactive-prompted +``` + +Skip if `PROACTIVE_PROMPTED` is `yes`. + +## First-run guidance (one-time) + +If `ACTIVATED` is `no` (first skill run on this machine) AND the preamble printed a non-empty `FIRST_TASK:` value that is NOT `nongit`: show ONE short, project-specific line mapped from the token, as a heads-up, then CONTINUE with whatever the user actually asked — do NOT halt their task. Map the token: `greenfield` → "Fresh repo — shape it first with `/spec` or `/office-hours`." `code_node`/`code_python`/`code_rust`/`code_go`/`code_ruby`/`code_ios` → "There's code here — `/qa` to see it work, or `/investigate` if something's off." `branch_ahead` → "Unshipped work on this branch — `/review` then `/ship`." `dirty_default` → "Uncommitted changes — `/review` before committing." `clean_default` → "Pick one: `/spec`, `/investigate`, or `/qa`." Then substitute the token you saw for TASK_TOKEN and run (best-effort), and mark activated: +```bash +~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type first_task_scaffold_shown --skill "TASK_TOKEN" --outcome shown 2>/dev/null || true +touch ~/.gstack/.activated 2>/dev/null || true +``` + +If `ACTIVATED` is `no` but `FIRST_TASK:` is empty or `nongit` (headless, non-git, or nothing actionable): show nothing, just run `touch ~/.gstack/.activated 2>/dev/null || true`. + +Else if `ACTIVATED` is `yes` AND `FIRST_LOOP_SHOWN` is `no`: say once as a heads-up (then continue): + +> Tip: gstack pays off when you complete one loop — **plan → review → ship**. A common first loop: `/office-hours` or `/spec` to shape it, `/plan-eng-review` to lock it, then `/ship`. + +Then run `touch ~/.gstack/.first-loop-tip-shown 2>/dev/null || true`. + +Skip this section if `ACTIVATED` and `FIRST_LOOP_SHOWN` are both `yes`. + +If `HAS_ROUTING` is `no` AND `ROUTING_DECLINED` is `false` AND `PROACTIVE_PROMPTED` is `yes`: +Check if a CLAUDE.md file exists in the project root. If it does not exist, create it. + +Use AskUserQuestion: + +> gstack works best when your project's CLAUDE.md includes skill routing rules. + +Options: +- A) Add routing rules to CLAUDE.md (recommended) +- B) No thanks, I'll invoke skills manually + +If A: Append this section to the end of CLAUDE.md: + +```markdown + +## Skill routing + +When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill. + +Key routing rules: +- Product ideas/brainstorming → invoke /office-hours +- Strategy/scope → invoke /plan-ceo-review +- Architecture → invoke /plan-eng-review +- Design system/plan review → invoke /design-consultation or /plan-design-review +- Full review pipeline → invoke /autoplan +- Bugs/errors → invoke /investigate +- QA/testing site behavior → invoke /qa or /qa-only +- Code review/diff check → invoke /review +- Visual polish → invoke /design-review +- Ship/deploy/PR → invoke /ship or /land-and-deploy +- Save progress → invoke /context-save +- Resume context → invoke /context-restore +- Author a backlog-ready spec/issue → invoke /spec +``` + +Then commit the change: `git add CLAUDE.md && git commit -m "chore: add gstack skill routing rules to CLAUDE.md"` + +If B: run `~/.claude/skills/gstack/bin/gstack-config set routing_declined true` and say they can re-enable with `gstack-config set routing_declined false`. + +This only happens once per project. Skip if `HAS_ROUTING` is `yes` or `ROUTING_DECLINED` is `true`. + +If `VENDORED_GSTACK` is `yes`, warn once via AskUserQuestion unless `~/.gstack/.vendoring-warned-$SLUG` exists: + +> This project has gstack vendored in `.claude/skills/gstack/`. Vendoring is deprecated. +> Migrate to team mode? + +Options: +- A) Yes, migrate to team mode now +- B) No, I'll handle it myself + +If A: +1. Run `git rm -r .claude/skills/gstack/` +2. Run `echo '.claude/skills/gstack/' >> .gitignore` +3. Run `~/.claude/skills/gstack/bin/gstack-team-init required` (or `optional`) +4. Run `git add .claude/ .gitignore CLAUDE.md && git commit -m "chore: migrate gstack from vendored to team mode"` +5. Tell the user: "Done. Each developer now runs: `cd ~/.claude/skills/gstack && ./setup --team`" + +If B: say "OK, you're on your own to keep the vendored copy up to date." + +Always run (regardless of choice): +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +touch ~/.gstack/.vendoring-warned-${SLUG:-unknown} +``` + +If marker exists, skip. + +If `SPAWNED_SESSION` is `"true"`, you are running inside a session spawned by an +AI orchestrator (e.g., OpenClaw). In spawned sessions: +- Do NOT use AskUserQuestion for interactive prompts. Auto-choose the recommended option. +- Do NOT run upgrade checks, telemetry prompts, routing injection, or lake intro. +- Focus on completing the task and reporting results via prose output. +- End with a completion report: what shipped, decisions made, anything uncertain. + +## Artifacts Sync (skill start) + +```bash +_GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +# Prefer the v1.27.0.0 artifacts file; fall back to brain file for users +# upgrading mid-stream before the migration script runs. +if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then + _BRAIN_REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" +else + _BRAIN_REMOTE_FILE="$HOME/.gstack-brain-remote.txt" +fi +_BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" +_BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" + +# /sync-gbrain context-load: teach the agent to use gbrain when it's available. +# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the +# git toplevel to scope queries. Look for the pin in the worktree (not a global +# state file) so that opening worktree B without a pin doesn't claim "indexed" +# just because worktree A was synced. Empty string when gbrain is not +# configured (zero context cost for non-gbrain users). +_GBRAIN_CONFIG="$HOME/.gbrain/config.json" +if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then + _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) + if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then + _GBRAIN_PIN_PATH="" + _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") + if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then + _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" + fi + if [ -n "$_GBRAIN_PIN_PATH" ]; then + echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" + echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" + echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + echo "Run /sync-gbrain to refresh." + else + echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" + echo "before relying on \`gbrain search\` for code questions in this worktree." + echo "Falls back to Grep until pinned." + fi + fi +fi + +_BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) + +# Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is +# a no-op in remote mode; the brain server pulls from GitHub/GitLab on its +# own cadence. Read claude.json directly to keep this preamble fast (no +# subprocess to claude CLI on every skill start). +_GBRAIN_MCP_MODE="none" +if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then + _GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null) + case "$_GBRAIN_MCP_TYPE" in + url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;; + stdio) _GBRAIN_MCP_MODE="local-stdio" ;; + esac +fi + +if [ -f "$_BRAIN_REMOTE_FILE" ] && [ ! -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" = "off" ]; then + _BRAIN_NEW_URL=$(head -1 "$_BRAIN_REMOTE_FILE" 2>/dev/null | tr -d '[:space:]') + if [ -n "$_BRAIN_NEW_URL" ]; then + echo "ARTIFACTS_SYNC: artifacts repo detected: $_BRAIN_NEW_URL" + echo "ARTIFACTS_SYNC: run 'gstack-brain-restore' to pull your cross-machine artifacts (or 'gstack-config set artifacts_sync_mode off' to dismiss forever)" + fi +fi + +if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then + _BRAIN_LAST_PULL_FILE="$_GSTACK_HOME/.brain-last-pull" + _BRAIN_NOW=$(date +%s) + _BRAIN_DO_PULL=1 + if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then + _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) + [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 + fi + if [ "$_BRAIN_DO_PULL" = "1" ]; then + ( cd "$_GSTACK_HOME" && git fetch origin >/dev/null 2>&1 && git merge --ff-only "origin/$(git rev-parse --abbrev-ref HEAD)" >/dev/null 2>&1 ) || true + echo "$_BRAIN_NOW" > "$_BRAIN_LAST_PULL_FILE" + fi + "$_BRAIN_SYNC_BIN" --once 2>/dev/null || true +fi + +if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then + # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server + # pulls from GitHub/GitLab). Show the user this is by design, not broken. + _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" +elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then + _BRAIN_QUEUE_DEPTH=0 + [ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ') + _BRAIN_LAST_PUSH="never" + [ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never) + echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH" +else + echo "ARTIFACTS_SYNC: off" +fi +``` + + + +Privacy stop-gate: if output shows `ARTIFACTS_SYNC: off`, `artifacts_sync_mode_prompted` is `false`, and gbrain is on PATH or `gbrain doctor --fast --json` works, ask once: + +> gstack can publish your artifacts (CEO plans, designs, reports) to a private GitHub repo that GBrain indexes across machines. How much should sync? + +Options: +- A) Everything allowlisted (recommended) +- B) Only artifacts +- C) Decline, keep everything local + +After answer: + +```bash +# Chosen mode: full | artifacts-only | off +"$_BRAIN_CONFIG_BIN" set artifacts_sync_mode +"$_BRAIN_CONFIG_BIN" set artifacts_sync_mode_prompted true +``` + +If A/B and `~/.gstack/.git` is missing, ask whether to run `gstack-artifacts-init`. Do not block the skill. + +At skill END before telemetry: + +```bash +"~/.claude/skills/gstack/bin/gstack-brain-sync" --discover-new 2>/dev/null || true +"~/.claude/skills/gstack/bin/gstack-brain-sync" --once 2>/dev/null || true +``` + + +## Model-Specific Behavioral Patch (claude) + +The following nudges are tuned for the claude model family. They are +**subordinate** to skill workflow, STOP points, AskUserQuestion gates, plan-mode +safety, and /ship review gates. If a nudge below conflicts with skill instructions, +the skill wins. Treat these as preferences, not rules. + +**Todo-list discipline.** When working through a multi-step plan, mark each task +complete individually as you finish it. Do not batch-complete at the end. If a task +turns out to be unnecessary, mark it skipped with a one-line reason. + +**Think before heavy actions.** For complex operations (refactors, migrations, +non-trivial new features), briefly state your approach before executing. This lets +the user course-correct cheaply instead of mid-flight. + +**Dedicated tools over Bash.** Prefer Read, Edit, Write, Glob, Grep over shell +equivalents (cat, sed, find, grep). The dedicated tools are cheaper and clearer. + +## Voice + +Direct, concrete, builder-to-builder. Name the file, function, command, and user-visible impact. No filler. + +No em dashes. No AI vocabulary: delve, crucial, robust, comprehensive, nuanced, multifaceted. Never corporate or academic. Short paragraphs. End with what to do. + +The user has context you do not. Cross-model agreement is a recommendation, not a decision. The user decides. + +## Completion Status Protocol + +When completing a skill workflow, report status using one of: +- **DONE** — completed with evidence. +- **DONE_WITH_CONCERNS** — completed, but list concerns. +- **BLOCKED** — cannot proceed; state blocker and what was tried. +- **NEEDS_CONTEXT** — missing info; state exactly what is needed. + +Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope you cannot verify. Format: `STATUS`, `REASON`, `ATTEMPTED`, `RECOMMENDATION`. + +## Operational Self-Improvement + +Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it: + +```bash +~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}' +``` + +Do not log obvious facts or one-time transient errors. + +## Telemetry (run last) + +After workflow completion, log telemetry. Use skill `name:` from frontmatter. OUTCOME is success/error/abort/unknown. + +**PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes telemetry to +`~/.gstack/analytics/`, matching preamble analytics writes. + +Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +# Session timeline: record skill completion (local-only, never sent anywhere) +~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"SKILL_NAME","event":"completed","branch":"'$(git branch --show-current 2>/dev/null || echo unknown)'","outcome":"OUTCOME","duration_s":"'"$_TEL_DUR"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null || true +# Local analytics (gated on telemetry setting) +if [ "$_TEL" != "off" ]; then +echo '{"skill":"SKILL_NAME","duration_s":"'"$_TEL_DUR"'","outcome":"OUTCOME","browse":"USED_BROWSE","session":"'"$_SESSION_ID"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +fi +# Remote telemetry (opt-in, requires binary) +if [ "$_TEL" != "off" ] && [ -x ~/.claude/skills/gstack/bin/gstack-telemetry-log ]; then + ~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +fi +``` + +Replace `SKILL_NAME`, `OUTCOME`, and `USED_BROWSE` before running. + +## Plan Status Footer + +Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode. + +# /dashboard — sprint board + +Run `gstack-sprint-dashboard` (no flags) to display the compact sprint board. + +```bash +gstack-sprint-dashboard +``` + +Read the output and present it directly to the user. + +## Output modes + +If the user asks for **more detail**, run with `--full`: +```bash +gstack-sprint-dashboard --full +``` + +If the user asks to **open in browser** or wants the visual HTML board: +```bash +gstack-sprint-dashboard --web +``` + +If the user specifies a **different project**, pass `--slug`: +```bash +gstack-sprint-dashboard --slug +``` + +## Reading the compact output + +The compact view shows: +- **Header**: version, in-flight count, ships this month, avg days/release, P1 backlog +- **Pipeline**: each branch with stage checkmarks (✓ done, ▶ current, ○ not started) +- **Activity**: last 5 skill completions with timestamp and branch +- **Footer**: average quality score and top skills by usage count + +The `◀` marker on a pipeline row indicates the current branch. + +## What to say + +After displaying the output, offer the user a one-line summary of what stands out: +the branch closest to shipping, any P0/P1 backlog items, or the most active skill. +Keep it to one sentence. Don't repeat the data they can already see. diff --git a/dashboard/SKILL.md.tmpl b/dashboard/SKILL.md.tmpl new file mode 100644 index 000000000..93ae9f19d --- /dev/null +++ b/dashboard/SKILL.md.tmpl @@ -0,0 +1,60 @@ +--- +name: dashboard +preamble-tier: 1 +version: 1.0.0 +description: | + Sprint board: compact terminal (default) · --full · --web HTML · --slug . + Use when asked for "sprint status", "what's in flight", "project status", or "dashboard". (gstack) +allowed-tools: + - Bash +triggers: + - sprint status + - what's in flight + - project status + - dashboard +--- + +{{PREAMBLE}} + +# /dashboard — sprint board + +Run `gstack-sprint-dashboard` (no flags) to display the compact sprint board. + +```bash +gstack-sprint-dashboard +``` + +Read the output and present it directly to the user. + +## Output modes + +If the user asks for **more detail**, run with `--full`: +```bash +gstack-sprint-dashboard --full +``` + +If the user asks to **open in browser** or wants the visual HTML board: +```bash +gstack-sprint-dashboard --web +``` + +If the user specifies a **different project**, pass `--slug`: +```bash +gstack-sprint-dashboard --slug +``` + +## Reading the compact output + +The compact view shows: +- **Header**: version, in-flight count, ships this month, avg days/release, P1 backlog +- **Pipeline**: each branch with stage checkmarks (✓ done, ▶ current, ○ not started) +- **Activity**: last 5 skill completions with timestamp and branch +- **Footer**: average quality score and top skills by usage count + +The `◀` marker on a pipeline row indicates the current branch. + +## What to say + +After displaying the output, offer the user a one-line summary of what stands out: +the branch closest to shipping, any P0/P1 backlog items, or the most active skill. +Keep it to one sentence. Don't repeat the data they can already see. diff --git a/docs/skills.md b/docs/skills.md index 8e8cb7adc..fb79e2603 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -27,6 +27,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples. | [`/document-release`](#document-release) | **Technical Writer** | Update all project docs to match what you just shipped. Catches stale READMEs automatically. | | [`/document-generate`](#document-generate) | **Technical Writer** | Generate Diataxis docs (tutorial / how-to / reference / explanation) for a feature from code. | | [`/retro`](#retro) | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. | +| [`/dashboard`](#dashboard) | **Sprint Board** | Compact terminal sprint board showing branch pipeline stages, activity feed, and quality scores. `--full` for all sections, `--web` for HTML in browser. | | [`/browse`](#browse) | **QA Engineer** | Give the agent eyes. Real Chromium browser, real clicks, real screenshots. ~100ms per command. | | [`/setup-browser-cookies`](#setup-browser-cookies) | **Session Manager** | Import cookies from your real browser (Chrome, Arc, Brave, Edge) into the headless session. Test authenticated pages. | | [`/autoplan`](#autoplan) | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → eng → DX review automatically with encoded decision principles. Surfaces only taste decisions for your approval. | diff --git a/gstack/llms.txt b/gstack/llms.txt index efe522f90..89fbad8e9 100644 --- a/gstack/llms.txt +++ b/gstack/llms.txt @@ -21,6 +21,7 @@ Conventions: - [/context-restore](context-restore/SKILL.md): Restore working context saved earlier by /context-save. - [/context-save](context-save/SKILL.md): Save working context. - [/cso](cso/SKILL.md): Chief Security Officer mode. +- [/dashboard](dashboard/SKILL.md): Sprint board: compact terminal (default) · --full · --web HTML · --slug . - [/design-consultation](design-consultation/SKILL.md): Design consultation: understands your product, researches the landscape, proposes a complete design system (aesthetic, typography, color, layout, spacing, motion), and generates font+color preview pages. - [/design-html](design-html/SKILL.md): Design finalization: generates production-quality Pretext-native HTML/CSS. - [/design-review](design-review/SKILL.md): Designer's eye QA: finds visual inconsistency, spacing issues, hierarchy problems, AI slop patterns, and slow interactions — then fixes them. diff --git a/lib/dashboard-cli.ts b/lib/dashboard-cli.ts new file mode 100644 index 000000000..9ac0d2239 --- /dev/null +++ b/lib/dashboard-cli.ts @@ -0,0 +1,296 @@ +/** + * dashboard-cli — terminal renderers for the gstack sprint dashboard. + * + * Three modes: + * renderOneliner(data) — single line, ~80 chars, for the PreToolUse hook + * renderCompact(data) — ~30 lines, default for /dashboard skill + * renderFull(data) — ~80 lines, all sections + */ + +import type { DashboardData, OnelinerData, Stage } from "./dashboard-data"; +import { STAGE_ORDER, STAGE_LABELS } from "./dashboard-data"; + +// ─── Shared helpers ───────────────────────────────────────────────────────── + +function relativeTime(ts: string): string { + const diff = Date.now() - new Date(ts).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 2) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +function stageChar(reached: boolean, isCurrent: boolean): string { + if (!reached) return "○"; + if (isCurrent) return "▶"; + return "✓"; +} + +function avgQualityScore(quality: DashboardData["quality"]): string { + if (quality.length === 0) return "—"; + const avg = quality.reduce((s, q) => s + q.score, 0) / quality.length; + return `${avg.toFixed(1)}/10`; +} + +// ─── One-liner ────────────────────────────────────────────────────────────── + +export function renderOneliner(data: OnelinerData): string { + const parts: string[] = []; + + parts.push(`● ${data.branch}`); + if (data.version) parts.push(`v${data.version}`); + parts.push(`${data.inFlightCount} in-flight`); + + // Show stages: completed ones + current + first unstarted (pipeline context) + const latestIdx = data.currentBranchLatestStage + ? STAGE_ORDER.indexOf(data.currentBranchLatestStage) + : -1; + const showUpTo = Math.min(latestIdx + 2, STAGE_ORDER.length - 1); + const visibleStages = latestIdx >= 0 ? STAGE_ORDER.slice(0, showUpTo + 1) : []; + + if (visibleStages.length > 0) { + const stageParts = visibleStages.map((s: Stage) => { + const reached = data.currentBranchStages.has(s); + const isCurrent = data.currentBranchLatestStage === s; + return `${STAGE_LABELS[s]}:${stageChar(reached, isCurrent)}`; + }); + parts.push(stageParts.join(" ")); + } + + if (data.lastShipDate) { + const diffDays = Math.floor( + (Date.now() - new Date(data.lastShipDate).getTime()) / 86400000 + ); + parts.push(`shipped ${diffDays === 0 ? "today" : `${diffDays}d ago`}`); + } + if (data.p1Count !== null) parts.push(`P1:${data.p1Count}`); + + return parts.join(" "); +} + +// ─── Compact (~30 lines) ──────────────────────────────────────────────────── + +export function renderCompact(data: DashboardData): string { + const W = 72; + const heavy = "━".repeat(W); + const thin = "─".repeat(W); + const lines: string[] = []; + + // Header + const vStr = data.version ? `v${data.version}` : "—"; + const avgStr = + data.velocity.avgDaysBetween !== null + ? `avg ${data.velocity.avgDaysBetween.toFixed(1)}d` + : null; + const headerParts = [ + `gstack ${data.slug}`, + vStr, + `${data.inFlightCount} in-flight`, + `${data.velocity.releasesThisMonth} ships/mo`, + ...(avgStr ? [avgStr] : []), + ...(data.backlog?.P1 != null ? [`P1:${data.backlog.P1}`] : []), + ]; + lines.push(heavy); + lines.push(` ${headerParts.join(" · ")}`); + lines.push(heavy); + lines.push(""); + + // Pipeline + const stageHdr = STAGE_ORDER.map((s: Stage) => STAGE_LABELS[s].padEnd(6)).join(" "); + lines.push(` PIPELINE ${stageHdr}`); + lines.push(` ${thin}`); + + const tracked = data.features.filter((f) => f.latestStage !== null).slice(0, 8); + const untrackedCount = data.features.filter((f) => f.latestStage === null).length; + if (tracked.length === 0) { + lines.push(" no tracked branches yet"); + } else { + for (const f of tracked) { + const nameCol = f.branch.slice(0, 20).padEnd(20); + const stages = STAGE_ORDER.map((s: Stage) => + stageChar(f.stagesReached.has(s), f.latestStage === s).padEnd(6) + ).join(" "); + const pr = data.prMap.get(f.branch); + const prTag = pr ? ` #${pr.number}` : ""; + const current = f.branch === data.branch ? " ◀" : ""; + lines.push(` ${nameCol} ${stages}${prTag}${current}`); + } + } + if (untrackedCount > 0) { + lines.push(` (+${untrackedCount} untracked — no skill activity)`); + } + lines.push(""); + + // Activity + lines.push(" RECENT ACTIVITY"); + lines.push(` ${thin}`); + const acts = data.activity.slice(0, 5); + if (acts.length === 0) { + lines.push(" no activity yet"); + } else { + for (const a of acts) { + const time = relativeTime(a.ts).padEnd(9); + const skill = `/${a.skill}`.padEnd(20); + const branch = a.branch.slice(0, 22).padEnd(22); + lines.push(` ${time} ${skill} ${branch} ${a.event}`); + } + } + lines.push(""); + + // Quality + top skills footer + const qualStr = `QUALITY ${avgQualityScore(data.quality)}`; + const topStr = data.topSkills + .slice(0, 4) + .map((s) => `/${s.skill}:${s.count}`) + .join(" "); + lines.push(` ${qualStr} · TOP SKILLS ${topStr}`); + lines.push(""); + + return lines.join("\n"); +} + +// ─── Full (~80 lines) ─────────────────────────────────────────────────────── + +export function renderFull(data: DashboardData): string { + const W = 80; + const heavy = "━".repeat(W); + const thin = "─".repeat(W); + const lines: string[] = []; + + // Header + lines.push(heavy); + lines.push(` gstack sprint dashboard — ${data.slug}`); + const vStr = data.version ? `v${data.version}` : "—"; + const avgStr = + data.velocity.avgDaysBetween !== null + ? `${data.velocity.avgDaysBetween.toFixed(1)}d` + : "—"; + lines.push( + ` ${vStr} | ${data.inFlightCount} in-flight | ${data.velocity.releasesThisMonth} ships/mo` + + ` | avg ${avgStr}/release | P1:${data.backlog?.P1 ?? "—"} | decisions:${data.openDecisions ?? "—"}` + ); + lines.push(` generated ${data.generatedAt.toLocaleString()}`); + lines.push(heavy); + lines.push(""); + + // Pipeline + const stageHdr = STAGE_ORDER.map((s: Stage) => STAGE_LABELS[s].padEnd(7)).join(" "); + lines.push(` PIPELINE ${stageHdr} Last Activity`); + lines.push(` ${thin}`); + + if (data.features.length === 0) { + lines.push(" no local branches found"); + } else { + for (const f of data.features) { + const nameCol = f.branch.slice(0, 22).padEnd(22); + const stages = STAGE_ORDER.map((s: Stage) => + stageChar(f.stagesReached.has(s), f.latestStage === s).padEnd(7) + ).join(" "); + const lastAct = f.latestTs + ? `${relativeTime(f.latestTs)} /${f.latestSkill ?? ""}`.slice(0, 22).padEnd(22) + : "—".padEnd(22); + const pr = data.prMap.get(f.branch); + const prTag = pr ? ` #${pr.number} ${pr.state}` : ""; + const current = f.branch === data.branch ? " ◀" : ""; + lines.push(` ${nameCol} ${stages} ${lastAct}${prTag}${current}`); + } + } + lines.push(""); + + // Activity feed + lines.push(" ACTIVITY FEED"); + lines.push(` ${thin}`); + if (data.activity.length === 0) { + lines.push(" no activity yet"); + } else { + for (const a of data.activity) { + const time = relativeTime(a.ts).padEnd(10); + const skill = `/${a.skill}`.padEnd(22); + const branch = a.branch.slice(0, 24).padEnd(24); + lines.push(` ${time} ${skill} ${branch} ${a.event}`); + } + } + lines.push(""); + + // Top skills + lines.push(" TOP SKILLS (last 30d)"); + lines.push(` ${thin}`); + if (data.topSkills.length === 0) { + lines.push(" no data yet"); + } else { + const max = data.topSkills[0].count; + for (const s of data.topSkills) { + const barLen = Math.round((s.count / max) * 28); + const bar = "█".repeat(barLen) + "░".repeat(28 - barLen); + lines.push(` /${s.skill.padEnd(18)} ${bar} ${s.count}`); + } + } + lines.push(""); + + // Quality scores + lines.push(" QUALITY SCORES (last 30d)"); + lines.push(` ${thin}`); + if (data.quality.length === 0) { + lines.push(" no data yet"); + } else { + for (const q of data.quality) { + const date = new Date(q.ts).toLocaleDateString().padEnd(12); + const skill = `/${q.skill}`.padEnd(20); + const score = `${q.score}/10`.padStart(5); + lines.push(` ${date} ${skill} ${score} (${q.iterations} iter)`); + } + } + lines.push(""); + + // Release velocity + lines.push(" RELEASE VELOCITY (last 30d)"); + lines.push(` ${thin}`); + if (data.velocity.recentVersions.length === 0) { + lines.push(" no releases in the last 30 days"); + } else { + const blocks = " ▁▂▃▄▅▆▇█"; + const max = Math.max(1, ...data.velocity.recentVersions.map((v) => v.commitCount)); + const sparkline = data.velocity.recentVersions + .map((v) => blocks[Math.min(8, Math.round((v.commitCount / max) * 8))]) + .join(""); + lines.push(` ${sparkline}`); + for (const v of data.velocity.recentVersions) { + lines.push(` v${v.version.padEnd(16)} ${v.date} ${v.commitCount} commit(s)`); + } + } + lines.push(""); + + // Design docs + if (data.designDocs.length > 0) { + lines.push(" ACTIVE DESIGN DOCS"); + lines.push(` ${thin}`); + for (const d of data.designDocs) { + const date = d.mtime ? new Date(d.mtime).toLocaleDateString().padEnd(12) : "—".padEnd(12); + lines.push(` ${d.name.slice(0, 52).padEnd(52)} ${date}`); + } + lines.push(""); + } + + // Backlog + lines.push(" TODOS BACKLOG"); + lines.push(` ${thin}`); + if (!data.backlog) { + lines.push(" no TODOS.md found"); + } else { + const priorities = (["P0", "P1", "P2", "P3", "P4"] as const).filter( + (p) => data.backlog![p] > 0 + ); + if (priorities.length === 0) { + lines.push(" backlog is empty"); + } else { + for (const p of priorities) { + lines.push(` ${p}: ${data.backlog![p]}`); + } + } + } + lines.push(""); + + return lines.join("\n"); +} diff --git a/lib/dashboard-data.ts b/lib/dashboard-data.ts new file mode 100644 index 000000000..203b5bee6 --- /dev/null +++ b/lib/dashboard-data.ts @@ -0,0 +1,505 @@ +/** + * dashboard-data — shared data layer for gstack sprint dashboard. + * + * Exports typed loaders and two assemblers: + * buildDashboard(opts) — full data (may call gh/gstack-decision-search) + * buildOnelinerData(opts) — fast subset (disk only, no network) + */ + +import { existsSync, readFileSync, readdirSync, statSync } from "fs"; +import { join, basename } from "path"; +import { execFileSync } from "child_process"; +import { homedir } from "os"; +import { readJsonl } from "./jsonl-store"; +import { resolveSlug as resolveSlugBin, gitBranch } from "./bin-context"; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export type Stage = + | "office-hours" + | "spec" + | "plan-review" + | "implement" + | "review" + | "ship" + | "canary"; + +export interface TimelineEvent { + ts: string; + skill: string; + branch: string; + event: string; +} + +export interface FeatureRow { + branch: string; + stagesReached: Set; + latestStage: Stage | null; + latestTs: string | null; + latestSkill: string | null; +} + +export interface ActivityFeedItem { + ts: string; + skill: string; + branch: string; + event: string; +} + +export interface VelocityData { + releasesThisMonth: number; + avgDaysBetween: number | null; + recentVersions: Array<{ version: string; date: string; commitCount: number }>; +} + +export interface SkillUsageStat { + skill: string; + count: number; +} + +export interface QualityEntry { + ts: string; + skill: string; + score: number; + iterations: number; +} + +export interface DesignDoc { + name: string; + fullPath: string; + mtime: number; +} + +export interface BacklogStats { + P0: number; + P1: number; + P2: number; + P3: number; + P4: number; + unparsed: number; +} + +export interface DashboardData { + slug: string; + branch: string; + version: string | null; + generatedAt: Date; + inFlightCount: number; + features: FeatureRow[]; + activity: ActivityFeedItem[]; + velocity: VelocityData; + topSkills: SkillUsageStat[]; + quality: QualityEntry[]; + designDocs: DesignDoc[]; + backlog: BacklogStats | null; + openDecisions: number | null; + ghAvailable: boolean; + prMap: Map; + defaultBranch: string | null; +} + +export interface OnelinerData { + slug: string; + branch: string; + version: string | null; + inFlightCount: number; + currentBranchStages: Set; + currentBranchLatestStage: Stage | null; + p1Count: number | null; + lastShipDate: string | null; +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +export const STAGE_ORDER: Stage[] = [ + "office-hours", + "spec", + "plan-review", + "implement", + "review", + "ship", + "canary", +]; + +export const STAGE_LABELS: Record = { + "office-hours": "OH", + spec: "Spec", + "plan-review": "Plan", + implement: "Impl", + review: "Rev", + ship: "Ship", + canary: "Canary", +}; + +export 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", +}; + +const HISTORY_WINDOW_DAYS = 30; +const IN_FLIGHT_WINDOW_DAYS = 90; + +// ─── Private helpers ──────────────────────────────────────────────────────── + +function stageForSkill(skill: string): Stage { + return SKILL_TO_STAGE[skill] ?? "implement"; +} + +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 binDir(): string { + // import.meta.dir is this file's directory (lib/); go up to repo root then into bin/ + return join(import.meta.dir, "..", "bin"); +} + +// ─── Path resolvers ───────────────────────────────────────────────────────── + +export function resolveGstackHome(home?: string): string { + return home ?? process.env.GSTACK_HOME ?? join(homedir(), ".gstack"); +} + +export function resolveProjectSlug(slug?: string): string { + if (slug) return slug; + const slugBin = join(binDir(), "gstack-slug"); + return resolveSlugBin(slugBin); +} + +// ─── Individual loaders ───────────────────────────────────────────────────── + +export function loadVersion(cwd = process.cwd()): string | null { + const p = join(cwd, "VERSION"); + if (!existsSync(p)) return null; + const v = readFileSync(p, "utf-8").trim(); + return v || null; +} + +export function loadTimeline(projectDir: string): TimelineEvent[] { + return readJsonl(join(projectDir, "timeline.jsonl")); +} + +export function loadInFlightFeatures(timeline: TimelineEvent[]): FeatureRow[] { + 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 {} + + const completedEvents = timeline.filter( + (e) => e.event === "completed" && e.branch && e.ts && e.skill + ); + + const entriesByBranch = new Map(); + for (const e of completedEvents) { + const list = entriesByBranch.get(e.branch) ?? []; + list.push(e); + entriesByBranch.set(e.branch, list); + } + + return branches.map((branch) => { + const entries = entriesByBranch.get(branch) ?? []; + const recent = entries.filter((e) => withinWindow(e.ts, IN_FLIGHT_WINDOW_DAYS)); + const stagesReached = new Set(recent.map((e) => stageForSkill(e.skill))); + let latestEntry: TimelineEvent | null = null; + for (const e of entries) { + if (!latestEntry || new Date(e.ts) > new Date(latestEntry.ts)) latestEntry = e; + } + return { + branch, + stagesReached, + latestStage: latestEntry ? stageForSkill(latestEntry.skill) : null, + latestTs: latestEntry?.ts ?? null, + latestSkill: latestEntry?.skill ?? null, + }; + }); +} + +export function loadActivityFeed(timeline: TimelineEvent[], limit = 12): ActivityFeedItem[] { + return timeline + .filter((e) => e.ts && e.skill) + .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime()) + .slice(0, limit) + .map((e) => ({ ts: e.ts, skill: e.skill, branch: e.branch ?? "unknown", event: e.event ?? "" })); +} + +export function loadVelocity(cwd = process.cwd()): VelocityData { + const changelogPath = join(cwd, "CHANGELOG.md"); + if (!existsSync(changelogPath)) { + return { releasesThisMonth: 0, avgDaysBetween: null, recentVersions: [] }; + } + const changelog = readFileSync(changelogPath, "utf-8"); + const re = /^## \[(\d+\.\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})/gm; + const all: Array<{ version: string; date: string }> = []; + let m: RegExpExecArray | null; + while ((m = re.exec(changelog)) !== null) all.push({ version: m[1], date: m[2] }); + + const now = new Date(); + const releasesThisMonth = all.filter((v) => { + const d = new Date(v.date); + return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth(); + }).length; + + const recent = all + .filter((v) => withinWindow(v.date, HISTORY_WINDOW_DAYS)) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + let avgDaysBetween: number | null = null; + if (recent.length >= 2) { + const span = + new Date(recent[recent.length - 1].date).getTime() - new Date(recent[0].date).getTime(); + avgDaysBetween = span / (24 * 60 * 60 * 1000) / (recent.length - 1); + } + + 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 {} + + const commitCounts = new Map(); + for (const s of subjects) { + const vm = s.match(/^v(\d+\.\d+\.\d+\.\d+)\s/); + if (vm) commitCounts.set(vm[1], (commitCounts.get(vm[1]) ?? 0) + 1); + } + + return { + releasesThisMonth, + avgDaysBetween, + recentVersions: recent.map((v) => ({ ...v, commitCount: commitCounts.get(v.version) ?? 0 })), + }; +} + +export function loadOpenDecisions(projectDir: string): number | null { + const activePath = join(projectDir, "decisions.active.json"); + if (existsSync(activePath)) { + try { + const arr = JSON.parse(readFileSync(activePath, "utf-8")); + return Array.isArray(arr) ? arr.length : null; + } catch {} + } + try { + const out = execFileSync(join(binDir(), "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; + } +} + +export function loadPrData( + features: FeatureRow[], + defaultBranch: string | null +): { ghAvailable: boolean; prMap: Map } { + const prMap = new Map(); + try { + const out = execFileSync( + "gh", + ["pr", "list", "--json", "number,state,headRefName", "--limit", "100"], + { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] } + ); + const prs = JSON.parse(out) as { number: number; state: string; headRefName: string }[]; + const branchSet = new Set( + features.filter((f) => f.branch !== defaultBranch).map((f) => f.branch) + ); + for (const pr of prs) { + if (branchSet.has(pr.headRefName)) { + prMap.set(pr.headRefName, { number: pr.number, state: pr.state }); + } + } + return { ghAvailable: true, prMap }; + } catch { + return { ghAvailable: false, prMap }; + } +} + +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; + } + } +} + +export function loadTopSkills(analyticsDir: string, limit = 6): SkillUsageStat[] { + const entries = readJsonl<{ skill?: string; ts?: string }>( + join(analyticsDir, "skill-usage.jsonl") + ).filter((e) => e.skill && withinWindow(e.ts, HISTORY_WINDOW_DAYS)); + + const counts = new Map(); + for (const e of entries) counts.set(e.skill!, (counts.get(e.skill!) ?? 0) + 1); + return [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([skill, count]) => ({ skill, count })); +} + +export function loadQualityHistory(analyticsDir: string, limit = 8): QualityEntry[] { + return readJsonl<{ ts?: string; skill?: string; quality_score?: number; iterations?: number }>( + join(analyticsDir, "spec-review.jsonl") + ) + .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, limit) + .map((e) => ({ + ts: e.ts!, + skill: e.skill ?? "—", + score: e.quality_score!, + iterations: e.iterations ?? 0, + })); +} + +export function loadDesignDocs(projectDir: string, limit = 10): DesignDoc[] { + if (!existsSync(projectDir)) return []; + let files: string[]; + try { + files = readdirSync(projectDir).filter((f) => /-design-.*\.md$/.test(f)); + } catch { + return []; + } + return files + .map((name) => { + const fullPath = join(projectDir, name); + let mtime = 0; + try { + mtime = statSync(fullPath).mtimeMs; + } catch {} + return { name, fullPath, mtime }; + }) + .sort((a, b) => b.mtime - a.mtime) + .slice(0, limit); +} + +export function loadBacklog(cwd = process.cwd()): BacklogStats | null { + const todosPath = join(cwd, "TODOS.md"); + if (!existsSync(todosPath)) return null; + const todos = readFileSync(todosPath, "utf-8"); + const counts: BacklogStats = { P0: 0, P1: 0, P2: 0, P3: 0, P4: 0, unparsed: 0 }; + const re = /^### (P[0-4]):/gm; + let m: RegExpExecArray | null; + while ((m = re.exec(todos)) !== null) { + counts[m[1] as keyof BacklogStats]++; + } + return counts; +} + +// ─── Assemblers ───────────────────────────────────────────────────────────── + +export interface BuildOptions { + slug?: string; + home?: string; + loadPr?: boolean; + loadDecisions?: boolean; +} + +export function buildDashboard(opts: BuildOptions = {}): DashboardData { + const { loadPr = true, loadDecisions = true } = opts; + const slug = resolveProjectSlug(opts.slug); + const home = resolveGstackHome(opts.home); + const projectDir = join(home, "projects", slug); + const analyticsDir = join(home, "analytics"); + const branch = gitBranch() ?? "unknown"; + + const timeline = loadTimeline(projectDir); + const features = loadInFlightFeatures(timeline); + const inFlightCount = features.filter( + (f) => f.stagesReached.size > 0 && !f.stagesReached.has("ship") && !f.stagesReached.has("canary") + ).length; + + const defaultBranch = loadPr ? resolveDefaultBranch() : null; + const { ghAvailable, prMap } = loadPr + ? loadPrData(features, defaultBranch) + : { ghAvailable: false, prMap: new Map() }; + + return { + slug, + branch, + version: loadVersion(), + generatedAt: new Date(), + inFlightCount, + features, + activity: loadActivityFeed(timeline), + velocity: loadVelocity(), + topSkills: loadTopSkills(analyticsDir), + quality: loadQualityHistory(analyticsDir), + designDocs: loadDesignDocs(projectDir), + backlog: loadBacklog(), + openDecisions: loadDecisions ? loadOpenDecisions(projectDir) : null, + ghAvailable, + prMap, + defaultBranch, + }; +} + +export function buildOnelinerData(opts: { slug?: string; home?: string } = {}): OnelinerData { + const slug = resolveProjectSlug(opts.slug); + const home = resolveGstackHome(opts.home); + const projectDir = join(home, "projects", slug); + const branch = gitBranch() ?? "unknown"; + + const timeline = loadTimeline(projectDir); + const features = loadInFlightFeatures(timeline); + const inFlightCount = features.filter( + (f) => f.stagesReached.size > 0 && !f.stagesReached.has("ship") && !f.stagesReached.has("canary") + ).length; + + const currentFeature = features.find((f) => f.branch === branch); + const velocity = loadVelocity(); + const lastShipDate = + velocity.recentVersions.length > 0 + ? velocity.recentVersions[velocity.recentVersions.length - 1].date + : null; + + const backlog = loadBacklog(); + + return { + slug, + branch, + version: loadVersion(), + inFlightCount, + currentBranchStages: currentFeature?.stagesReached ?? new Set(), + currentBranchLatestStage: currentFeature?.latestStage ?? null, + p1Count: backlog?.P1 ?? null, + lastShipDate, + }; +} diff --git a/scripts/proactive-suggestions.json b/scripts/proactive-suggestions.json index d08c60853..bd60bfe0b 100644 --- a/scripts/proactive-suggestions.json +++ b/scripts/proactive-suggestions.json @@ -53,6 +53,11 @@ "routing": "Infrastructure-first security audit: secrets archaeology,\ndependency supply chain, CI/CD pipeline security, LLM/AI security, skill supply chain\nscanning, plus OWASP Top 10, STRIDE threat modeling, and active verification.\nTwo modes: daily (zero-noise, 8/10 confidence gate) and comprehensive (monthly deep\nscan, 2/10 bar). Trend tracking across audit runs.\nUse when: \"security audit\", \"threat model\", \"pentest review\", \"OWASP\", \"CSO review\".", "voice_line": "Voice triggers (speech-to-text aliases): \"see-so\", \"see so\", \"security review\", \"security check\", \"vulnerability scan\", \"run security\"." }, + "dashboard": { + "lead": "Sprint board: compact terminal (default) · --full · --web HTML · --slug .", + "routing": "Use when asked for \"sprint status\", \"what's in flight\", \"project status\", or \"dashboard\".", + "voice_line": null + }, "design-consultation": { "lead": "Design consultation: understands your product, researches the landscape, proposes a complete design system (aesthetic, typography, color, layout, spacing, motion), and generates font+color preview...", "routing": "Creates DESIGN.md as your project's design source\nof truth. For existing sites, use /plan-design-review to infer the system instead.\nUse when asked to \"design system\", \"brand guidelines\", or \"create DESIGN.md\".\nProactively suggest when starting a new project's UI with no existing\ndesign system or DESIGN.md.", diff --git a/test/dashboard-cli.test.ts b/test/dashboard-cli.test.ts new file mode 100644 index 000000000..0e8c55e3d --- /dev/null +++ b/test/dashboard-cli.test.ts @@ -0,0 +1,257 @@ +/** + * Unit tests for lib/dashboard-cli.ts — terminal renderers. + * + * Uses fabricated DashboardData/OnelinerData objects to test rendering + * in isolation — no file I/O, no network calls. + */ + +import { describe, it, expect } from "bun:test"; +import { renderOneliner, renderCompact, renderFull } from "../lib/dashboard-cli"; +import type { OnelinerData, DashboardData } from "../lib/dashboard-data"; +import { STAGE_ORDER } from "../lib/dashboard-data"; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +function makeOneliner(overrides: Partial = {}): OnelinerData { + return { + slug: "test-project", + branch: "feature/test", + version: "1.0.0.0", + inFlightCount: 2, + currentBranchStages: new Set(), + currentBranchLatestStage: null, + p1Count: 3, + lastShipDate: new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10), + ...overrides, + }; +} + +function makeDashboard(overrides: Partial = {}): DashboardData { + return { + slug: "test-project", + branch: "feature/test", + version: "1.2.3.0", + generatedAt: new Date("2026-01-01T12:00:00Z"), + inFlightCount: 1, + features: [], + activity: [], + velocity: { releasesThisMonth: 2, avgDaysBetween: 7.5, recentVersions: [] }, + topSkills: [], + quality: [], + designDocs: [], + backlog: { P0: 0, P1: 3, P2: 5, P3: 10, P4: 2, unparsed: 0 }, + openDecisions: 2, + ghAvailable: false, + prMap: new Map(), + defaultBranch: "main", + ...overrides, + }; +} + +// ─── renderOneliner ────────────────────────────────────────────────────────── + +describe("renderOneliner", () => { + it("includes branch name and version", () => { + const line = renderOneliner(makeOneliner()); + expect(line).toContain("feature/test"); + expect(line).toContain("v1.0.0.0"); + }); + + it("includes in-flight count", () => { + const line = renderOneliner(makeOneliner({ inFlightCount: 5 })); + expect(line).toContain("5 in-flight"); + }); + + it("shows no stage section when currentBranchLatestStage is null", () => { + const line = renderOneliner(makeOneliner({ + currentBranchLatestStage: null, + currentBranchStages: new Set(), + })); + // No stage labels should appear + expect(line).not.toContain("OH:"); + expect(line).not.toContain("Spec:"); + }); + + it("shows stage progress when latestStage is set", () => { + const line = renderOneliner(makeOneliner({ + currentBranchLatestStage: "spec", + currentBranchStages: new Set(["office-hours", "spec"]), + })); + expect(line).toContain("OH:"); + expect(line).toContain("Spec:"); + // plan-review should be visible (latestIdx+2 = spec is index 1, showUpTo = min(3, 6) = 3) + expect(line).toContain("Plan:"); + }); + + it("marks reached stages with ✓ and current with ▶", () => { + const line = renderOneliner(makeOneliner({ + currentBranchLatestStage: "spec", + currentBranchStages: new Set(["office-hours", "spec"]), + })); + expect(line).toContain("OH:✓"); + expect(line).toContain("Spec:▶"); + }); + + it("shows P1 count", () => { + const line = renderOneliner(makeOneliner({ p1Count: 7 })); + expect(line).toContain("P1:7"); + }); + + it("omits P1 when p1Count is null", () => { + const line = renderOneliner(makeOneliner({ p1Count: null })); + expect(line).not.toContain("P1:"); + }); + + it("shows 'today' when lastShipDate is today", () => { + const today = new Date().toISOString().slice(0, 10); + const line = renderOneliner(makeOneliner({ lastShipDate: today })); + expect(line).toContain("shipped today"); + }); + + it("omits ship date when lastShipDate is null", () => { + const line = renderOneliner(makeOneliner({ lastShipDate: null })); + expect(line).not.toContain("shipped"); + }); +}); + +// ─── renderCompact ─────────────────────────────────────────────────────────── + +describe("renderCompact", () => { + it("includes slug, version, in-flight, ships/mo in header", () => { + const out = renderCompact(makeDashboard()); + expect(out).toContain("test-project"); + expect(out).toContain("v1.2.3.0"); + expect(out).toContain("1 in-flight"); + expect(out).toContain("2 ships/mo"); + }); + + it("shows 'no tracked branches yet' when no tracked features", () => { + const out = renderCompact(makeDashboard({ features: [] })); + expect(out).toContain("no tracked branches yet"); + }); + + it("shows untracked count when branches have no stage activity", () => { + const features = [ + { + branch: "feature/untracked", + stagesReached: new Set() as Set, + latestStage: null, + latestTs: null, + latestSkill: null, + }, + { + branch: "feature/also-untracked", + stagesReached: new Set() as Set, + latestStage: null, + latestTs: null, + latestSkill: null, + }, + ]; + const out = renderCompact(makeDashboard({ features })); + expect(out).toContain("(+2 untracked"); + }); + + it("shows ◀ marker for current branch", () => { + const features = [ + { + branch: "feature/test", + stagesReached: new Set(["spec"]) as Set, + latestStage: "spec" as import("../lib/dashboard-data").Stage, + latestTs: new Date().toISOString(), + latestSkill: "spec", + }, + ]; + const out = renderCompact(makeDashboard({ features })); + expect(out).toContain("◀"); + }); + + it("shows PR badge when prMap has entry", () => { + const features = [ + { + branch: "feature/test", + stagesReached: new Set(["spec"]) as Set, + latestStage: "spec" as import("../lib/dashboard-data").Stage, + latestTs: new Date().toISOString(), + latestSkill: "spec", + }, + ]; + const prMap = new Map([["feature/test", { number: 42, state: "open" }]]); + const out = renderCompact(makeDashboard({ features, prMap })); + expect(out).toContain("#42"); + }); + + it("shows 'no activity yet' when activity is empty", () => { + const out = renderCompact(makeDashboard({ activity: [] })); + expect(out).toContain("no activity yet"); + }); + + it("shows avg days/release in header when available", () => { + const out = renderCompact(makeDashboard()); + expect(out).toContain("avg 7.5d"); + }); + + it("includes P1 backlog count in header", () => { + const out = renderCompact(makeDashboard()); + expect(out).toContain("P1:3"); + }); +}); + +// ─── renderFull ────────────────────────────────────────────────────────────── + +describe("renderFull", () => { + it("includes all section headers", () => { + const out = renderFull(makeDashboard()); + expect(out).toContain("PIPELINE"); + expect(out).toContain("ACTIVITY FEED"); + expect(out).toContain("TOP SKILLS"); + expect(out).toContain("QUALITY SCORES"); + expect(out).toContain("RELEASE VELOCITY"); + expect(out).toContain("TODOS BACKLOG"); + }); + + it("shows open decisions in header", () => { + const out = renderFull(makeDashboard({ openDecisions: 5 })); + expect(out).toContain("decisions:5"); + }); + + it("shows — when openDecisions is null", () => { + const out = renderFull(makeDashboard({ openDecisions: null })); + expect(out).toContain("decisions:—"); + }); + + it("shows 'no local branches found' when features is empty", () => { + const out = renderFull(makeDashboard({ features: [] })); + expect(out).toContain("no local branches found"); + }); + + it("shows 'no TODOS.md found' when backlog is null", () => { + const out = renderFull(makeDashboard({ backlog: null })); + expect(out).toContain("no TODOS.md found"); + }); + + it("shows backlog priority counts", () => { + const out = renderFull(makeDashboard()); + expect(out).toContain("P1: 3"); + expect(out).toContain("P2: 5"); + }); + + it("skips ACTIVE DESIGN DOCS section when designDocs is empty", () => { + const out = renderFull(makeDashboard({ designDocs: [] })); + expect(out).not.toContain("ACTIVE DESIGN DOCS"); + }); + + it("shows ACTIVE DESIGN DOCS section when docs exist", () => { + const docs = [{ name: "user-feature-design-2026.md", fullPath: "/tmp/foo.md", mtime: Date.now() }]; + const out = renderFull(makeDashboard({ designDocs: docs })); + expect(out).toContain("ACTIVE DESIGN DOCS"); + expect(out).toContain("user-feature-design-2026.md"); + }); + + it("shows 'no releases in the last 30 days' when recentVersions is empty", () => { + const dashboard = makeDashboard({ + velocity: { releasesThisMonth: 0, avgDaysBetween: null, recentVersions: [] }, + }); + const out = renderFull(dashboard); + expect(out).toContain("no releases in the last 30 days"); + }); +}); diff --git a/test/dashboard-data.test.ts b/test/dashboard-data.test.ts new file mode 100644 index 000000000..45d70abe9 --- /dev/null +++ b/test/dashboard-data.test.ts @@ -0,0 +1,346 @@ +/** + * Unit tests for lib/dashboard-data.ts — loaders and assemblers. + * + * Uses real temp directories (no mocks) so the file-reading paths are + * exercised exactly as they run in production. Network calls (gh, git log) + * are not exercised here — those paths degrade gracefully in the code. + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +import { + loadVersion, + loadBacklog, + loadVelocity, + loadDesignDocs, + loadActivityFeed, + loadInFlightFeatures, + STAGE_ORDER, + type TimelineEvent, +} from "../lib/dashboard-data"; + +function tmp(): string { + return mkdtempSync(join(tmpdir(), "gstack-dash-test-")); +} + +// ─── loadVersion ──────────────────────────────────────────────────────────── + +describe("loadVersion", () => { + let dir: string; + beforeEach(() => { dir = tmp(); }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("returns null when VERSION file is missing", () => { + expect(loadVersion(dir)).toBeNull(); + }); + + it("returns trimmed version string", () => { + writeFileSync(join(dir, "VERSION"), "1.58.5.0\n"); + expect(loadVersion(dir)).toBe("1.58.5.0"); + }); + + it("returns null for empty VERSION file", () => { + writeFileSync(join(dir, "VERSION"), " \n"); + expect(loadVersion(dir)).toBeNull(); + }); +}); + +// ─── loadBacklog ───────────────────────────────────────────────────────────── + +describe("loadBacklog", () => { + let dir: string; + beforeEach(() => { dir = tmp(); }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("returns null when TODOS.md is missing", () => { + expect(loadBacklog(dir)).toBeNull(); + }); + + it("counts only P0–P4 tagged headings", () => { + writeFileSync(join(dir, "TODOS.md"), [ + "### P0: urgent thing", + "", + "**What:** do the urgent thing.", + "", + "### P1: another thing", + "### P2: yet another", + "### P3: low priority", + "### P4: very low", + "", + "### Context: some context heading", + "### Eval harness: unrelated", + "### ✅ DONE (v1.0): old thing", + ].join("\n")); + + const result = loadBacklog(dir); + expect(result).not.toBeNull(); + expect(result!.P0).toBe(1); + expect(result!.P1).toBe(1); + expect(result!.P2).toBe(1); + expect(result!.P3).toBe(1); + expect(result!.P4).toBe(1); + expect(result!.unparsed).toBe(0); + }); + + it("counts multiple items per priority", () => { + writeFileSync(join(dir, "TODOS.md"), [ + "### P1: first", + "### P1: second", + "### P1: third", + "### P2: one", + ].join("\n")); + + const result = loadBacklog(dir); + expect(result!.P1).toBe(3); + expect(result!.P2).toBe(1); + expect(result!.P0).toBe(0); + }); + + it("returns zero counts (not null) for empty TODOS.md", () => { + writeFileSync(join(dir, "TODOS.md"), "# No priority items yet\n"); + const result = loadBacklog(dir); + expect(result).not.toBeNull(); + expect(result!.P0).toBe(0); + expect(result!.P1).toBe(0); + }); +}); + +// ─── loadVelocity ──────────────────────────────────────────────────────────── + +describe("loadVelocity", () => { + let dir: string; + beforeEach(() => { dir = tmp(); }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("returns zeroes when CHANGELOG.md is missing", () => { + const v = loadVelocity(dir); + expect(v.releasesThisMonth).toBe(0); + expect(v.avgDaysBetween).toBeNull(); + expect(v.recentVersions).toEqual([]); + }); + + it("parses releases and counts this month", () => { + const now = new Date(); + const thisMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`; + const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, "0")}-01`; + + writeFileSync(join(dir, "CHANGELOG.md"), [ + `## [1.2.0.0] - ${thisMonth}`, + "", + "Some changes.", + "", + `## [1.1.0.0] - ${prevMonthStr}`, + "", + "Earlier changes.", + ].join("\n")); + + const v = loadVelocity(dir); + expect(v.releasesThisMonth).toBe(1); + // recentVersions filtered to 30-day window — prevMonth may or may not be included + expect(v.recentVersions.length).toBeGreaterThanOrEqual(1); + }); + + it("returns null avgDaysBetween with fewer than 2 recent releases", () => { + const now = new Date(); + const today = now.toISOString().slice(0, 10); + writeFileSync(join(dir, "CHANGELOG.md"), `## [1.0.0.0] - ${today}\n\nOnly one.\n`); + const v = loadVelocity(dir); + expect(v.avgDaysBetween).toBeNull(); + }); + + it("computes avgDaysBetween for 2 releases", () => { + const now = new Date(); + const d1 = new Date(now.getTime() - 10 * 86400000).toISOString().slice(0, 10); + const d2 = now.toISOString().slice(0, 10); + writeFileSync(join(dir, "CHANGELOG.md"), [ + `## [1.1.0.0] - ${d2}`, + "", + `## [1.0.0.0] - ${d1}`, + ].join("\n")); + const v = loadVelocity(dir); + expect(v.avgDaysBetween).not.toBeNull(); + expect(v.avgDaysBetween!).toBeCloseTo(10, 0); + }); +}); + +// ─── loadDesignDocs ────────────────────────────────────────────────────────── + +describe("loadDesignDocs", () => { + let dir: string; + beforeEach(() => { dir = tmp(); }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("returns [] when directory does not exist", () => { + expect(loadDesignDocs(join(dir, "nonexistent"))).toEqual([]); + }); + + it("returns [] when directory exists but has no design docs", () => { + expect(loadDesignDocs(dir)).toEqual([]); + }); + + it("matches *-design-*.md files and sorts by mtime descending", () => { + writeFileSync(join(dir, "user-feature-design-20260601.md"), "# old design"); + writeFileSync(join(dir, "user-feature-design-20260620.md"), "# new design"); + writeFileSync(join(dir, "some-other-file.md"), "# not a design"); + + const docs = loadDesignDocs(dir); + expect(docs.length).toBe(2); + expect(docs.every((d) => d.name.includes("-design-"))).toBe(true); + // sorted newest first (by mtime — order of writes determines this in the test) + }); + + it("caps at limit", () => { + for (let i = 0; i < 15; i++) { + writeFileSync(join(dir, `branch-feature-design-2026060${i}.md`), `# design ${i}`); + } + expect(loadDesignDocs(dir, 5).length).toBe(5); + expect(loadDesignDocs(dir, 10).length).toBe(10); + }); +}); + +// ─── loadActivityFeed ──────────────────────────────────────────────────────── + +describe("loadActivityFeed", () => { + const now = new Date(); + const makeEvent = (skill: string, branch: string, minsAgo: number): TimelineEvent => ({ + ts: new Date(now.getTime() - minsAgo * 60000).toISOString(), + skill, + branch, + event: "completed", + }); + + it("returns empty array for no events", () => { + expect(loadActivityFeed([])).toEqual([]); + }); + + it("sorts descending by timestamp", () => { + const events = [ + makeEvent("spec", "main", 120), + makeEvent("ship", "feature/a", 10), + makeEvent("review", "feature/b", 60), + ]; + const feed = loadActivityFeed(events); + expect(feed[0].skill).toBe("ship"); + expect(feed[1].skill).toBe("review"); + expect(feed[2].skill).toBe("spec"); + }); + + it("respects limit", () => { + const events = Array.from({ length: 20 }, (_, i) => makeEvent(`skill${i}`, "main", i * 5)); + expect(loadActivityFeed(events, 5).length).toBe(5); + expect(loadActivityFeed(events, 10).length).toBe(10); + }); + + it("filters out events missing ts or skill", () => { + const events: TimelineEvent[] = [ + { ts: "", skill: "spec", branch: "main", event: "completed" }, + { ts: now.toISOString(), skill: "", branch: "main", event: "completed" }, + makeEvent("ship", "main", 5), + ]; + const feed = loadActivityFeed(events); + expect(feed.length).toBe(1); + expect(feed[0].skill).toBe("ship"); + }); +}); + +// ─── loadInFlightFeatures ──────────────────────────────────────────────────── + +describe("loadInFlightFeatures (timeline only — no git branch)", () => { + const recentTs = new Date(Date.now() - 5 * 86400000).toISOString(); // 5 days ago + const staleTs = new Date(Date.now() - 95 * 86400000).toISOString(); // 95 days ago + + it("returns empty stagesReached for branches with no completed events in window", () => { + const timeline: TimelineEvent[] = [ + { ts: staleTs, skill: "spec", branch: "feature/old", event: "completed" }, + ]; + const features = loadInFlightFeatures(timeline); + const old = features.find((f) => f.branch === "feature/old"); + // stagesReached uses 90-day window → should be empty for 95-day-old event + if (old) { + expect(old.stagesReached.size).toBe(0); + } + // latestEntry uses all-time → latestStage will still be set + if (old) { + expect(old.latestStage).toBe("spec"); + } + }); + + it("populates stagesReached for events within 90 days", () => { + const timeline: TimelineEvent[] = [ + { ts: recentTs, skill: "spec", branch: "feature/new", event: "completed" }, + { ts: recentTs, skill: "plan-eng-review", branch: "feature/new", event: "completed" }, + ]; + const features = loadInFlightFeatures(timeline); + const f = features.find((f) => f.branch === "feature/new"); + if (f) { + expect(f.stagesReached.has("spec")).toBe(true); + expect(f.stagesReached.has("plan-review")).toBe(true); + } + }); + + it("does not count stale-only branches as in-flight via stagesReached", () => { + const timeline: TimelineEvent[] = [ + { ts: staleTs, skill: "spec", branch: "feature/stale", event: "completed" }, + ]; + const features = loadInFlightFeatures(timeline); + const stale = features.find((f) => f.branch === "feature/stale"); + if (stale) { + // stagesReached is empty (outside 90-day window) → not counted in-flight + const isInFlight = + stale.stagesReached.size > 0 && + !stale.stagesReached.has("ship") && + !stale.stagesReached.has("canary"); + expect(isInFlight).toBe(false); + } + }); + + it("canary stage does not count as in-flight", () => { + const timeline: TimelineEvent[] = [ + { ts: recentTs, skill: "canary", branch: "feature/deployed", event: "completed" }, + ]; + const features = loadInFlightFeatures(timeline); + const f = features.find((f) => f.branch === "feature/deployed"); + if (f) { + const isInFlight = + f.stagesReached.size > 0 && + !f.stagesReached.has("ship") && + !f.stagesReached.has("canary"); + expect(isInFlight).toBe(false); + } + }); + + it("ship stage does not count as in-flight", () => { + const timeline: TimelineEvent[] = [ + { ts: recentTs, skill: "ship", branch: "feature/shipped", event: "completed" }, + ]; + const features = loadInFlightFeatures(timeline); + const f = features.find((f) => f.branch === "feature/shipped"); + if (f) { + const isInFlight = + f.stagesReached.size > 0 && + !f.stagesReached.has("ship") && + !f.stagesReached.has("canary"); + expect(isInFlight).toBe(false); + } + }); +}); + +// ─── STAGE_ORDER sanity ────────────────────────────────────────────────────── + +describe("STAGE_ORDER", () => { + it("has 7 stages in the correct order", () => { + expect(STAGE_ORDER).toEqual([ + "office-hours", + "spec", + "plan-review", + "implement", + "review", + "ship", + "canary", + ]); + }); +});