This commit is contained in:
Suhail 2026-07-14 17:01:39 -07:00 committed by GitHub
commit dfcb81c7be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 2476 additions and 0 deletions

View File

@ -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). |

View File

@ -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

View File

@ -2469,6 +2469,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 <prev-tag>..<tag>` 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`,

17
bin/gstack-dash-oneliner Executable file
View File

@ -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
}

396
bin/gstack-sprint-dashboard Executable file
View File

@ -0,0 +1,396 @@
#!/usr/bin/env bun
/**
* gstack-sprint-dashboard — sprint board in three output modes.
*
* Usage:
* 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 <path> # write HTML to <path> (implies --web)
* gstack-sprint-dashboard --slug <s> # override project slug
*
* Backwards-compat positional args (mapped to --web):
* gstack-sprint-dashboard open # alias for --web
* gstack-sprint-dashboard out <path> # alias for --web --out <path>
*/
import { writeFileSync, readFileSync } from "fs";
import { resolve } from "path";
import { execFileSync, spawn } from "child_process";
import { buildDashboard } from "../lib/dashboard-data";
import { renderCompact, renderFull } from "../lib/dashboard-cli";
// ─── HTML helpers (web mode only) ───────────────────────────────────────────
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function stageIcon(reached: boolean, isCurrent: boolean): string {
if (!reached) return "";
return `<span class="tick ${isCurrent ? "tick-current" : "tick-passed"}">✓</span>`;
}
function renderHtml(data: ReturnType<typeof buildDashboard>): string {
const { slug, branch, version, generatedAt, inFlightCount, features, activity,
velocity, topSkills, quality, designDocs, backlog, openDecisions, prMap } = data;
// ── 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 HISTORY_DAYS = 30;
// ── Header stats ──
const statsBar = `<div class="stats-bar">
${[
["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]) =>
`<div class="stat-card">
<div class="stat-value">${value === null || value === undefined ? "—" : escapeHtml(String(value))}</div>
<div class="stat-label">${escapeHtml(String(label))}</div>
</div>`
).join("")}
</div>`;
// ── Pipeline ──
const stageHeaders = STAGES.map((s) => `<th class="stage-col">${s.label}</th>`).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 `<td class="stage-col">${stageIcon(reached, isCurrent)}</td>`;
}).join("");
const pr = prMap.get(f.branch);
const prCell = pr
? `<span class="pr-badge pr-${pr.state.toLowerCase()}">#${pr.number} ${pr.state}</span>`
: "—";
const lastAct = f.latestTs
? `${escapeHtml(f.latestSkill ?? "")} · ${new Date(f.latestTs).toLocaleString()}`
: "—";
return `<tr>
<td class="mono">${escapeHtml(f.branch)}${f.latestStage === null ? ' <span class="dim">(untracked)</span>' : ""}</td>
${stageCells}
<td class="mono dim">${lastAct}</td>
<td>${prCell}</td>
</tr>`;
}).join("\n");
const pipeline = features.length === 0
? `<div class="empty">no data yet — no local git branches found</div>`
: `<table>
<thead><tr><th>Branch</th>${stageHeaders}<th>Last Activity</th><th>PR</th></tr></thead>
<tbody>${pipelineRows}</tbody>
</table>`;
// ── Activity feed ──
const activityHtml = activity.length === 0
? `<div class="empty">no data yet</div>`
: `<ul class="activity-feed">${activity.map((e) =>
`<li>
<span class="mono dim">${new Date(e.ts).toLocaleString()}</span>
<span class="skill">${escapeHtml(e.skill)}</span>
<span class="mono dim">${escapeHtml(e.branch)}</span>
<span class="event-${escapeHtml(e.event)}">${escapeHtml(e.event)}</span>
</li>`).join("\n")}</ul>`;
// ── Top skills ──
const topSkillsHtml = topSkills.length === 0
? `<div class="empty">no data yet</div>`
: (() => {
const max = topSkills[0]?.count || 1;
return `<div class="bar-chart">${topSkills.map((s) =>
`<div class="bar-row">
<span class="bar-label mono">${escapeHtml(s.skill)}</span>
<div class="bar-track"><div class="bar-fill" style="width:${(s.count / max) * 100}%"></div></div>
<span class="bar-count mono dim">${s.count}</span>
</div>`).join("\n")}</div>`;
})();
// ── Quality ──
const qualityHtml = quality.length === 0
? `<div class="empty">no data yet</div>`
: `<table>
<thead><tr><th>Date</th><th>Skill</th><th>Iterations</th><th>Score</th></tr></thead>
<tbody>${quality.map((e) =>
`<tr>
<td class="mono dim">${new Date(e.ts).toLocaleDateString()}</td>
<td>${escapeHtml(e.skill)}</td>
<td class="mono">${e.iterations}</td>
<td class="mono score">${e.score}/10</td>
</tr>`).join("\n")}</tbody>
</table>`;
// ── Design docs ──
const designDocsHtml = designDocs.length === 0
? `<div class="empty">no data yet</div>`
: `<ul class="design-docs">${designDocs.map((d) =>
`<li>
<a class="mono" href="file://${escapeHtml(d.fullPath)}">${escapeHtml(d.name)}</a>
<span class="mono dim">${d.mtime ? new Date(d.mtime).toLocaleDateString() : "—"}</span>
</li>`).join("\n")}</ul>`;
// ── Release velocity ──
const releaseVelocityHtml = (() => {
const recent = velocity.recentVersions;
if (recent.length === 0) return `<div class="empty">no releases in the last ${HISTORY_DAYS} days</div>`;
const blocks = " ▁▂▃▄▅▆▇█";
const max = Math.max(1, ...recent.map((v) => v.commitCount));
const sparkline = recent
.map((v) => `<span class="sparkline-block" title="v${escapeHtml(v.version)} — ${v.commitCount} commit(s)">${blocks[Math.min(8, Math.round((v.commitCount / max) * 8))]}</span>`)
.join("");
const versionList = recent
.map((v) => `<li class="mono">v${escapeHtml(v.version)} <span class="dim">— ${v.date} — ${v.commitCount} commit(s)</span></li>`)
.join("\n");
return `<div class="release-velocity"><div class="sparkline">${sparkline}</div><ul>${versionList}</ul></div>`;
})();
// ── Backlog ──
const backlogHtml = !backlog
? `<div class="empty">no TODOS.md found</div>`
: (() => {
const total = Object.values(backlog).reduce((a, b) => a + b, 0);
if (total === 0) return `<div class="empty">no level-3 headings in TODOS.md</div>`;
const rows = (["P0", "P1", "P2", "P3", "P4", "unparsed"] as const)
.filter((p) => backlog[p] > 0)
.map((p) => `<tr><td class="mono">${p}</td><td class="mono">${backlog[p]}</td></tr>`)
.join("\n");
return `<table><thead><tr><th>Priority</th><th>Count</th></tr></thead><tbody>${rows}</tbody></table>`;
})();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>gstack sprint dashboard — ${escapeHtml(slug)}</title>
<style>
:root {
--amber: #F59E0B;
--zinc-400: #A1A1AA;
--zinc-600: #52525B;
--bg-base: #0C0C0C;
--bg-surface: #141414;
--bg-hover: #1a1a1a;
--border: #262626;
--text-heading: #FAFAFA;
--text-body: #e0e0e0;
--text-label: #A1A1AA;
--text-meta: #52525B;
--success: #22C55E;
--error: #EF4444;
--font: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg-base);
color: var(--text-body);
font-family: var(--font);
font-size: 13px;
line-height: 1.5;
padding: 32px;
background-image:
repeating-linear-gradient(0deg, rgba(255,255,255,0.015) 0px, transparent 1px, transparent 2px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.015) 0px, transparent 1px, transparent 2px);
}
header { margin-bottom: 32px; }
h1 { color: var(--text-heading); font-size: 20px; font-weight: 600; }
.meta { color: var(--text-meta); margin-top: 4px; }
h2 {
color: var(--text-label);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
border-left: 2px solid var(--amber);
padding-left: 8px;
margin: 28px 0 12px;
}
section { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; color: var(--text-label); font-size: 11px; text-transform: uppercase; letter-spacing: 0.03em; padding: 6px 8px; border-bottom: 1px solid var(--border); }
td { padding: 6px 8px; border-bottom: 1px solid #1f1f1f; }
tr:hover { background: var(--bg-hover); }
.mono { font-family: var(--font); }
.dim { color: var(--text-meta); }
.empty { color: var(--text-meta); padding: 8px; font-style: italic; }
.pr-badge { color: var(--success); font-size: 12px; }
.pr-closed { color: var(--error); }
.activity-feed, .design-docs { list-style: none; }
.activity-feed li, .design-docs li { display: flex; gap: 12px; padding: 6px 0; border-bottom: 1px solid #1f1f1f; }
.activity-feed li:last-child, .design-docs li:last-child { border-bottom: none; }
.skill { color: var(--text-heading); }
.design-docs a { color: var(--amber); text-decoration: none; }
.bar-chart { display: flex; flex-direction: column; gap: 8px; }
.bar-row { display: flex; align-items: center; gap: 8px; }
.bar-label { width: 160px; color: var(--text-label); }
.bar-track { flex: 1; background: var(--bg-hover); border-radius: 4px; height: 14px; overflow: hidden; }
.bar-fill { background: var(--amber); height: 100%; }
.bar-count { width: 32px; text-align: right; }
.sparkline { font-size: 24px; letter-spacing: 2px; color: var(--amber); margin-bottom: 12px; }
.release-velocity ul { list-style: none; margin-top: 8px; }
.release-velocity li { padding: 2px 0; }
.score { color: var(--success); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 16px; }
.stats-bar { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-top: 16px; }
.stat-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; }
.stat-value { color: var(--amber); font-size: 22px; font-weight: 600; }
.stat-label { color: var(--text-label); font-size: 11px; text-transform: uppercase; letter-spacing: 0.03em; margin-top: 4px; }
.stage-col { text-align: center; width: 56px; }
.tick { font-weight: 600; }
.tick-current { color: var(--amber); }
.tick-passed { color: var(--text-meta); }
</style>
</head>
<body>
<header>
<h1>gstack sprint dashboard</h1>
<div class="meta">project: ${escapeHtml(slug)} · branch: ${escapeHtml(branch)} · generated ${escapeHtml(generatedAt.toLocaleString())}</div>
${statsBar}
</header>
<h2>Pipeline board</h2>
<section>${pipeline}</section>
<div class="grid">
<div>
<h2>Activity feed</h2>
<section>${activityHtml}</section>
</div>
<div>
<h2>Top skills (last ${HISTORY_DAYS}d)</h2>
<section>${topSkillsHtml}</section>
</div>
</div>
<div class="grid">
<div>
<h2>Quality scores (last ${HISTORY_DAYS}d)</h2>
<section>${qualityHtml}</section>
</div>
<div>
<h2>Active design docs</h2>
<section>${designDocsHtml}</section>
</div>
</div>
<div class="grid">
<div>
<h2>Release velocity (last ${HISTORY_DAYS}d)</h2>
<section>${releaseVelocityHtml}</section>
</div>
<div>
<h2>TODOS backlog health</h2>
<section>${backlogHtml}</section>
</div>
</div>
</body>
</html>`;
}
// ─── Open helper ─────────────────────────────────────────────────────────────
function isWsl(): boolean {
if (process.platform !== "linux") return false;
try {
return /microsoft/i.test(readFileSync("/proc/version", "utf-8"));
} catch {
return false;
}
}
function openInBrowser(path: string) {
const absPath = resolve(path);
let cmd: string;
let args: string[];
if (process.platform === "darwin") {
cmd = "open";
args = [absPath];
} else if (process.platform === "win32") {
cmd = "explorer.exe";
args = [absPath];
} else if (isWsl()) {
cmd = "explorer.exe";
try {
const winPath = execFileSync("wslpath", ["-w", absPath], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
args = [winPath];
} catch {
args = [absPath];
}
} else {
cmd = "xdg-open";
args = [absPath];
}
try {
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
child.on("error", () => {
console.error(`Could not open ${absPath} automatically — open it manually.`);
});
child.unref();
} catch {
console.error(`Could not open ${absPath} automatically — open it manually.`);
}
}
// ─── Main ─────────────────────────────────────────────────────────────────────
function main() {
const args = process.argv.slice(2).filter((a) => a !== "--");
// Backwards-compat: positional "open" and "out <path>"
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;
}
if (mode === "full") {
const data = buildDashboard({ slug: slugArg, loadPr: true, loadDecisions: true });
process.stdout.write(renderFull(data));
return;
}
// web mode
const data = buildDashboard({ slug: slugArg, loadPr: true, loadDecisions: true });
const html = renderHtml(data);
writeFileSync(outPath, html, "utf-8");
console.log(`Wrote ${outPath}`);
openInBrowser(outPath);
}
main();

570
dashboard/SKILL.md Normal file
View File

@ -0,0 +1,570 @@
---
name: dashboard
preamble-tier: 1
version: 1.0.0
description: "Sprint board: compact terminal (default) · --full · --web HTML · --slug <project>. (gstack)"
allowed-tools:
- Bash
triggers:
- sprint status
- what's in flight
- project status
- dashboard
---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
## 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 <old> <new>`: 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 <from> <to>`: 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 <choice>
"$_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 <project-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.

60
dashboard/SKILL.md.tmpl Normal file
View File

@ -0,0 +1,60 @@
---
name: dashboard
preamble-tier: 1
version: 1.0.0
description: |
Sprint board: compact terminal (default) · --full · --web HTML · --slug <project>.
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 <project-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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

View File

@ -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. |

View File

@ -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 <project>.
- [/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.

296
lib/dashboard-cli.ts Normal file
View File

@ -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");
}

505
lib/dashboard-data.ts Normal file
View File

@ -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<Stage>;
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<string, { number: number; state: string }>;
defaultBranch: string | null;
}
export interface OnelinerData {
slug: string;
branch: string;
version: string | null;
inFlightCount: number;
currentBranchStages: Set<Stage>;
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<Stage, string> = {
"office-hours": "OH",
spec: "Spec",
"plan-review": "Plan",
implement: "Impl",
review: "Rev",
ship: "Ship",
canary: "Canary",
};
export const SKILL_TO_STAGE: Record<string, Stage> = {
"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<TimelineEvent>(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<string, TimelineEvent[]>();
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<Stage>(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<string, number>();
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<string, { number: number; state: string }> } {
const prMap = new Map<string, { number: number; state: string }>();
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<string, number>();
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,
};
}

View File

@ -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 <project>.",
"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.",

257
test/dashboard-cli.test.ts Normal file
View File

@ -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> = {}): 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> = {}): 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<import("../lib/dashboard-data").Stage>,
latestStage: null,
latestTs: null,
latestSkill: null,
},
{
branch: "feature/also-untracked",
stagesReached: new Set() as Set<import("../lib/dashboard-data").Stage>,
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<import("../lib/dashboard-data").Stage>,
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<import("../lib/dashboard-data").Stage>,
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");
});
});

346
test/dashboard-data.test.ts Normal file
View File

@ -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 P0P4 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",
]);
});
});