mirror of https://github.com/garrytan/gstack.git
feat: dashboard skill — compact terminal + full + web modes
Extracts the monolithic sprint dashboard into lib/dashboard-data.ts and lib/dashboard-cli.ts, adds terminal-first output (compact default, --full), and keeps --web for HTML. Adds /dashboard skill, routing rules, tests, and proactive suggestions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ccf72d1497
commit
30e9b249e7
|
|
@ -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). |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
20
TODOS.md
20
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 <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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue