diff --git a/bin/gstack-retro-metrics b/bin/gstack-retro-metrics new file mode 100755 index 000000000..fa1801bdd --- /dev/null +++ b/bin/gstack-retro-metrics @@ -0,0 +1,357 @@ +#!/usr/bin/env bash +# gstack-retro-metrics — /retro's metric pipelines, consolidated (token-reduction). +# +# Absorbs the inline git/awk pipelines retro/SKILL.md used to carry in Steps +# 0.5-9 and 11 (~13KB of fences in every install). The skill now runs ONE +# fence; this script emits labeled `METRIC_NAME: value` lines the prose +# interprets. The contract is pinned by test/gstack-retro-metrics.test.ts. +# +# LOCAL READS ONLY — no network ops of any kind. The freshness fetch stays in +# the skill's own Step 0.5 fence (skill prose, same as before this script +# existed), so this script never needs egress receipts. +# +# Conventions mirror bin/gstack-skill-start: +# - Paths resolve $0-relative (works for every host + install layout). +# - State paths honor ${GSTACK_HOME:-$HOME/.gstack}. +# - Error style: per-line `|| true`, never `set -e` — a mid-script failure +# must not drop later METRIC lines. +# - No heredocs (nothing to BASH_COMPAT-guard; see +# test/heredoc-pipe-deadlock.test.ts if one is ever added). +# +# Usage: +# gstack-retro-metrics --base --since \ +# [--until ] +# +# --base the detected default branch (from BASE_BRANCH_DETECT). The script +# prefers origin/, falls back to the local (local-only +# repos), then HEAD. The ref actually used is echoed as RETRO_REF. +# --since midnight-aligned ISO date ("2026-03-11T00:00:00") or a relative +# expression ("24 hours ago"). Default: "7 days ago". +# --until optional window end (compare mode's prior window). + +BASE="" +SINCE="7 days ago" +UNTIL="" +while [ $# -gt 0 ]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + --since) SINCE="$2"; shift 2 ;; + --until) UNTIL="$2"; shift 2 ;; + *) shift ;; + esac +done + +# $0-relative resolution (parity with gstack-skill-start; any future +# sibling-bin call must go through $_BIN, never bare PATH lookup). +_SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) +_BIN="$_SCRIPT_DIR" +_GH="${GSTACK_HOME:-$HOME/.gstack}" + +echo "RETRO_METRICS_PROTO: 1" + +if ! git rev-parse --git-dir >/dev/null 2>&1; then + echo "RETRO_METRICS_ERROR: not inside a git repository" + exit 0 +fi + +# ── Guard + ref resolution (Step 0.5's local checks; the fetch stays in prose) ─ +_HAS_ORIGIN=$(git remote 2>/dev/null | grep -c '^origin$' || true) +case "$_HAS_ORIGIN" in ''|*[!0-9]*) _HAS_ORIGIN=0 ;; esac +[ "$_HAS_ORIGIN" -gt 0 ] && echo "GUARD_REMOTE: origin" || echo "GUARD_REMOTE: none" + +_HEAD_REF=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true) +[ -n "$_HEAD_REF" ] && echo "GUARD_HEAD: $_HEAD_REF" || echo "GUARD_HEAD: detached" + +if [ -z "$BASE" ]; then + BASE=$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||') +fi +if [ -z "$BASE" ]; then + for _CAND in main master; do + if git rev-parse --verify --quiet "refs/heads/$_CAND" >/dev/null 2>&1 \ + || git rev-parse --verify --quiet "refs/remotes/origin/$_CAND" >/dev/null 2>&1; then + BASE="$_CAND"; break + fi + done +fi +[ -n "$BASE" ] || BASE="$_HEAD_REF" + +REF="HEAD" +if [ -n "$BASE" ] && git rev-parse --verify --quiet "refs/remotes/origin/$BASE" >/dev/null 2>&1; then + REF="origin/$BASE" +elif [ -n "$BASE" ] && git rev-parse --verify --quiet "refs/heads/$BASE" >/dev/null 2>&1; then + REF="$BASE" +fi +echo "RETRO_REF: $REF" + +_LATEST=$(git log -1 --format=%ci "$REF" 2>/dev/null | cut -d' ' -f1) +echo "GUARD_LATEST_COMMIT: ${_LATEST:-unknown}" + +echo "WINDOW_SINCE: $SINCE" +echo "WINDOW_UNTIL: ${UNTIL:-(now)}" + +_USER_NAME=$(git config user.name 2>/dev/null || true) +_USER_EMAIL=$(git config user.email 2>/dev/null || true) +echo "USER_NAME: ${_USER_NAME:-unknown}" +echo "USER_EMAIL: ${_USER_EMAIL:-unknown}" + +# Window args, applied uniformly to every windowed git query below. +_S="--since=$SINCE" +_U="" +[ -n "$UNTIL" ] && _U="--until=$UNTIL" + +# ── Main pass: one numstat walk computes the whole per-commit metric family ── +# Emits COMMIT: lines (newest first, capped) plus every aggregate. Subjects may +# contain '|', so fields are re-joined from index 6 on. Test-file detection is +# the union of the historical patterns (dir-based test/|spec/|__tests__/ and +# suffix-based .test./.spec./_test./_spec.). +git log "$REF" "$_S" ${_U:+"$_U"} --date=format-local:'%Y-%m-%d %H:%M' \ + --format='C|%h|%aN|%at|%ad|%s' --numstat 2>/dev/null | awk ' + function is_test(p) { + return (p ~ /(^|\/)(tests?|spec|__tests__)\//) || (p ~ /(\.(test|spec)\.|_test\.|_spec\.)/) + } + function type_of(s) { + if (s ~ /^Merge /) return "merge" + sub(/^v[0-9][0-9.]* /, "", s) # squash-merge convention: "v1.2.3.0 fix: ..." + if (match(s, /^(feat|fix|refactor|test|chore|docs|perf|style|build|ci|revert)(\(|!|:)/)) { + t = substr(s, 1, RLENGTH - 1); sub(/[(!:]$/, "", t); return t + } + return "other" + } + function bucket_of(loc) { + if (loc < 100) return "small"; if (loc < 500) return "medium" + if (loc < 1500) return "large"; return "xl" + } + function flush() { + if (h == "") return + commits++ + ins += cins; del += cdel; tins += ctins + files_sum = (cfiles > 20 ? 20 : cfiles); weighted += files_sum + t = type_of(subj); types[t]++; atypes[author "|" t]++ + if (t == "merge") merges++ + hour = substr(dt, 12, 2); hours[hour]++; ahours[author "|" hour]++ + day = substr(dt, 1, 10); days[day] = 1 + wk = int((anchor - at) / 604800) + wcommits[wk]++; wins[wk] += cins; wdel[wk] += cdel; wtins[wk] += ctins + if (wk > maxwk) maxwk = wk + acommits[author]++; ains[author] += cins; adel[author] += cdel; atins[author] += ctins + loc = cins + cdel; sizes[bucket_of(loc)]++ + if (loc > bigloc) { bigloc = loc; bigline = h "|" loc "|" author "|" subj } + if (loc > abigloc[author]) { abigloc[author] = loc; abig[author] = h "|" loc "|" subj } + # 45-minute session gaps (walked newest→oldest; equivalent to ascending). + if (prev_at == 0) { sess = 1; sess_end = at; } + else if (prev_at - at > 2700) { + sess_dur = (sess_end - sess_start_at) / 60; classify(sess_dur) + sess++; sess_end = at + } + sess_start_at = at; prev_at = at + if (commits <= 300) printf "COMMIT: %s|%s|%s|+%d/-%d|%s\n", h, author, dt, cins, cdel, subj + h = "" + } + function classify(mins) { + total_mins += mins + if (mins >= 50) deep++; else if (mins >= 20) medium++; else micro++ + } + /^C\|/ { + flush() + n = split($0, a, "|") + h = a[2]; author = a[3]; at = a[4] + 0; dt = a[5] + subj = a[6]; for (i = 7; i <= n; i++) subj = subj "|" a[i] + if (anchor == 0) anchor = at + cins = 0; cdel = 0; ctins = 0; cfiles = 0 + next + } + /^[0-9-]+\t/ { + if ($1 != "-") cins += $1; if ($2 != "-") cdel += $2 + if ($1 != "-" && is_test($3)) ctins += $1 + cfiles++ + filecount[$3]++ + d = ($3 ~ /\//) ? substr($3, 1, index($3, "/") - 1) "/" : "(root)" + dircount[d]++; adir[author "|" d]++ + if ($3 ~ /(\.(test|spec)\.|_test\.|_spec\.)/) testfiles[$3] = 1 + } + END { + flush() + if (commits > 0 && prev_at != 0) { sess_dur = (sess_end - sess_start_at) / 60; classify(sess_dur) } + if (commits > 300) printf "COMMIT_LIST_TRUNCATED: showing 300 of %d (newest first; git log for the rest)\n", commits + printf "COMMITS: %d\n", commits + printf "MERGE_COMMITS: %d\n", merges + nauth = 0; for (au in acommits) nauth++ + printf "CONTRIBUTORS: %d\n", nauth + printf "INSERTIONS: %d\n", ins + printf "DELETIONS: %d\n", del + printf "NET_LOC: %d\n", ins - del + printf "TEST_INSERTIONS: %d\n", tins + printf "TEST_RATIO: %s\n", (ins > 0 ? sprintf("%d%%", tins * 100 / ins) : "n/a") + printf "WEIGHTED_COMMITS: %d\n", weighted + nd = 0; for (d in days) nd++ + printf "ACTIVE_DAYS: %d\n", nd + ntf = 0; for (f in testfiles) ntf++ + printf "TEST_FILES_CHANGED: %d\n", ntf + printf "SESSIONS: %d\n", sess + printf "DEEP_SESSIONS: %d\n", deep + printf "MEDIUM_SESSIONS: %d\n", medium + printf "MICRO_SESSIONS: %d\n", micro + printf "TOTAL_ACTIVE_MINUTES: %d\n", total_mins + printf "AVG_SESSION_MINUTES: %d\n", (sess > 0 ? total_mins / sess : 0) + if (total_mins >= 5) printf "LOC_PER_SESSION_HOUR: %d\n", int(ins / (total_mins / 60) / 50 + 0.5) * 50 + else printf "LOC_PER_SESSION_HOUR: n/a (too little session time)\n" + line = "" + for (t in types) line = line (line == "" ? "" : " ") t "=" types[t] + printf "COMMIT_TYPES: %s\n", (line == "" ? "none" : line) + printf "FIX_RATIO: %s\n", (commits > 0 ? sprintf("%d%%", types["fix"] * 100 / commits) : "n/a") + printf "COMMIT_SIZE_BUCKETS: small=%d medium=%d large=%d xl=%d\n", sizes["small"], sizes["medium"], sizes["large"], sizes["xl"] + # Hour histogram (nonzero hours only, chronological). + line = "" + for (i = 0; i < 24; i++) { hh = sprintf("%02d", i); if (hours[hh] > 0) line = line (line == "" ? "" : " ") hh "=" hours[hh] } + printf "HOURS: %s\n", (line == "" ? "none" : line) + peak = ""; pc = -1 + for (hh in hours) if (hours[hh] > pc) { pc = hours[hh]; peak = hh } + printf "PEAK_HOUR: %s\n", (peak == "" ? "n/a" : peak) + # Focus score: share of file changes in the single busiest top-level dir. + tot = 0; for (d in dircount) tot += dircount[d] + fd = ""; fc = -1 + for (d in dircount) if (dircount[d] > fc) { fc = dircount[d]; fd = d } + if (tot > 0) printf "FOCUS_SCORE: %d%% (%s)\n", fc * 100 / tot, fd + else printf "FOCUS_SCORE: n/a\n" + if (bigline != "") printf "BIGGEST_COMMIT: %s\n", bigline + # Top-10 hotspots by change count. + for (k = 0; k < 10; k++) { + bf = ""; bc = 0 + for (f in filecount) if (filecount[f] > bc) { bc = filecount[f]; bf = f } + if (bf == "") break + printf "HOTSPOT: %d %s\n", bc, bf + delete filecount[bf] + } + # Per-author lines, sorted by commits desc (selection sort — small n). + while (1) { + ba = ""; bc = -1 + for (au in acommits) if (!(au in done) && acommits[au] > bc) { bc = acommits[au]; ba = au } + if (ba == "") break + done[ba] = 1 + tr = (ains[ba] > 0 ? sprintf("%d%%", atins[ba] * 100 / ains[ba]) : "n/a") + # top-3 areas for this author + areas = "" + for (k = 0; k < 3; k++) { + bd = ""; bdc = 0 + for (key in adir) { + split(key, kk, "|") + if (kk[1] == ba && !((key) in adone) && adir[key] > bdc) { bdc = adir[key]; bd = key } + } + if (bd == "") break + adone[bd] = 1 + split(bd, kk, "|") + areas = areas (areas == "" ? "" : ",") kk[2] + } + tl = "" + for (key in atypes) { split(key, kk, "|"); if (kk[1] == ba) tl = tl (tl == "" ? "" : ",") kk[2] ":" atypes[key] } + ph = ""; phc = -1 + for (key in ahours) { split(key, kk, "|"); if (kk[1] == ba && ahours[key] > phc) { phc = ahours[key]; ph = kk[2] } } + printf "AUTHOR: %s|commits=%d|ins=%d|del=%d|test_ratio=%s|top_areas=%s|types=%s|peak_hour=%s\n", ba, acommits[ba], ains[ba], adel[ba], tr, areas, tl, ph + if (abig[ba] != "") printf "AUTHOR_BIGGEST: %s|%s\n", ba, abig[ba] + } + # Weekly buckets, newest week first (w0 = week containing the newest commit). + for (w = 0; w <= maxwk; w++) { + if (wcommits[w] == 0) continue + wr = (wins[w] > 0 ? sprintf("%d%%", wtins[w] * 100 / wins[w]) : "n/a") + printf "WEEK: w%d|commits=%d|ins=%d|del=%d|test_ratio=%s\n", w, wcommits[w], wins[w], wdel[w], wr + } + } +' || true + +# ── Co-author trailers: AI-assist count + human co-author credit lines ────── +git log "$REF" "$_S" ${_U:+"$_U"} \ + --format='%h %(trailers:key=Co-Authored-By,valueonly,separator=;)' 2>/dev/null | awk ' + { + if (NF < 2) next + rest = substr($0, index($0, " ") + 1) + n = split(rest, tr, ";") + for (i = 1; i <= n; i++) { + t = tr[i] + if (t ~ /^[ \t]*$/) continue + if (tolower(t) ~ /(claude|copilot|codex|gemini|gpt|anthropic|openai|cursor|devin|\[bot\])/) { ai[$1] = 1 } + else { human++; if (human <= 40) printf "COAUTHOR: %s|%s\n", $1, t } + } + } + END { + if (human > 40) printf "COAUTHOR_LIST_TRUNCATED: showing 40 of %d\n", human + c = 0; for (h in ai) c++ + printf "AI_ASSISTED_COMMITS: %d\n", c + } +' || true + +# ── Logical SLOC added: non-blank, non-comment added lines in the window ──── +_LSLOC=$(git log "$REF" "$_S" ${_U:+"$_U"} -p --format= 2>/dev/null | awk ' + /^\+/ && !/^\+\+\+/ { + l = substr($0, 2); gsub(/^[ \t]+|[ \t]+$/, "", l) + if (l == "") next + if (l ~ /^(\/\/|#|\*|\/\*| + +Structure the output as: + +--- + +**Tweetable summary** (first line, before everything else): +``` +Week of Mar 1: 47 commits (3 contributors), 3.2k LOC, 38% tests, 12 PRs, peak: 10pm | Streak: 47d +``` + +## Engineering Retro: [date range] + +### Summary Table +(from Step 2) + +### Trends vs Last Retro +(from Step 12, loaded before save — skip if first retro) + +### Time & Session Patterns +(from Steps 3-4) + +Narrative interpreting what the team-wide patterns mean: +- When the most productive hours are and what drives them +- Whether sessions are getting longer or shorter over time +- Estimated hours per day of active coding (team aggregate) +- Notable patterns: do team members code at the same time or in shifts? + +### Shipping Velocity +(from Steps 5-7) + +Narrative covering: +- Commit type mix and what it reveals +- PR size distribution and what it reveals about shipping cadence +- Fix-chain detection (sequences of fix commits on the same subsystem) +- Version bump discipline + +### Code Quality Signals +- Test LOC ratio trend +- Hotspot analysis (are the same files churning?) +- Greptile signal ratio and trend (if history exists): "Greptile: X% signal (Y valid catches, Z false positives)" + +### Test Health +- Total test files: N (`TEST_FILES_TOTAL`) +- Tests added this period: M (`TEST_FILES_CHANGED` — test files changed in the window) +- Regression test commits: list the `REGRESSION_COMMIT` lines (`test(qa):`, `test(design):`, and `test: coverage` commits) +- If prior retro exists and has `test_health`: show delta "Test count: {last} → {now} (+{delta})" +- If test ratio < 20%: flag as growth area — "100% test coverage is the goal. Tests make vibe coding safe." + +### Plan Completion +Check review JSONL logs for plan completion data from /ship runs this period: + +```bash +setopt +o nomatch 2>/dev/null || true # zsh compat +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +cat ~/.gstack/projects/$SLUG/*-reviews.jsonl 2>/dev/null | grep '"skill":"ship"' | grep '"plan_items_total"' || echo "NO_PLAN_DATA" +``` + +If plan completion data exists within the retro time window: +- Count branches shipped with plans (entries that have `plan_items_total` > 0) +- Compute average completion: sum of `plan_items_done` / sum of `plan_items_total` +- Identify most-skipped item category if data supports it + +Output: +``` +Plan Completion This Period: + {N} branches shipped with plans + Average completion: {X}% ({done}/{total} items) +``` + +If no plan data exists, skip this section silently. + +### Focus & Highlights +(from Step 8) +- Focus score with interpretation +- Ship of the week callout + +### Your Week (personal deep-dive) +(from Step 9, for the current user only) + +This is the section the user cares most about. Include: +- Their personal commit count, LOC, test ratio +- Their session patterns and peak hours +- Their focus areas +- Their biggest ship +- **What you did well** (2-3 specific things anchored in commits) +- **Where to level up** (1-2 specific, actionable suggestions) + +### Team Breakdown +(from Step 9, for each teammate — skip if solo repo) + +For each teammate (sorted by commits descending), write a section: + +#### [Name] +- **What they shipped**: 2-3 sentences on their contributions, areas of focus, and commit patterns +- **Praise**: 1-2 specific things they did well, anchored in actual commits. Be genuine — what would you actually say in a 1:1? Examples: + - "Cleaned up the entire auth module in 3 small, reviewable PRs — textbook decomposition" + - "Added integration tests for every new endpoint, not just happy paths" + - "Fixed the N+1 query that was causing 2s load times on the dashboard" +- **Opportunity for growth**: 1 specific, constructive suggestion. Frame as investment, not criticism. Examples: + - "Test coverage on the payment module is at 8% — worth investing in before the next feature lands on top of it" + - "Most commits land in a single burst — spacing work across the day could reduce context-switching fatigue" + - "All commits land between 1-4am — sustainable pace matters for code quality long-term" + +**AI collaboration note:** If many commits have `Co-Authored-By` AI trailers (e.g., Claude, Copilot), note the AI-assisted commit percentage as a team metric. Frame it neutrally — "N% of commits were AI-assisted" — without judgment. + +### Top 3 Team Wins +Identify the 3 highest-impact things shipped in the window across the whole team. For each: +- What it was +- Who shipped it +- Why it matters (product/architecture impact) + +### 3 Things to Improve +Specific, actionable, anchored in actual commits. Mix personal and team-level suggestions. Phrase as "to get even better, the team could..." + +### 3 Habits for Next Week +Small, practical, realistic. Each must be something that takes <5 minutes to adopt. At least one should be team-oriented (e.g., "review each other's PRs same-day"). + +### Week-over-Week Trends +(if applicable, from Step 10) diff --git a/retro/sections/report-format.md.tmpl b/retro/sections/report-format.md.tmpl new file mode 100644 index 000000000..06ec9de91 --- /dev/null +++ b/retro/sections/report-format.md.tmpl @@ -0,0 +1,118 @@ +Structure the output as: + +--- + +**Tweetable summary** (first line, before everything else): +``` +Week of Mar 1: 47 commits (3 contributors), 3.2k LOC, 38% tests, 12 PRs, peak: 10pm | Streak: 47d +``` + +## Engineering Retro: [date range] + +### Summary Table +(from Step 2) + +### Trends vs Last Retro +(from Step 12, loaded before save — skip if first retro) + +### Time & Session Patterns +(from Steps 3-4) + +Narrative interpreting what the team-wide patterns mean: +- When the most productive hours are and what drives them +- Whether sessions are getting longer or shorter over time +- Estimated hours per day of active coding (team aggregate) +- Notable patterns: do team members code at the same time or in shifts? + +### Shipping Velocity +(from Steps 5-7) + +Narrative covering: +- Commit type mix and what it reveals +- PR size distribution and what it reveals about shipping cadence +- Fix-chain detection (sequences of fix commits on the same subsystem) +- Version bump discipline + +### Code Quality Signals +- Test LOC ratio trend +- Hotspot analysis (are the same files churning?) +- Greptile signal ratio and trend (if history exists): "Greptile: X% signal (Y valid catches, Z false positives)" + +### Test Health +- Total test files: N (`TEST_FILES_TOTAL`) +- Tests added this period: M (`TEST_FILES_CHANGED` — test files changed in the window) +- Regression test commits: list the `REGRESSION_COMMIT` lines (`test(qa):`, `test(design):`, and `test: coverage` commits) +- If prior retro exists and has `test_health`: show delta "Test count: {last} → {now} (+{delta})" +- If test ratio < 20%: flag as growth area — "100% test coverage is the goal. Tests make vibe coding safe." + +### Plan Completion +Check review JSONL logs for plan completion data from /ship runs this period: + +```bash +setopt +o nomatch 2>/dev/null || true # zsh compat +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +cat ~/.gstack/projects/$SLUG/*-reviews.jsonl 2>/dev/null | grep '"skill":"ship"' | grep '"plan_items_total"' || echo "NO_PLAN_DATA" +``` + +If plan completion data exists within the retro time window: +- Count branches shipped with plans (entries that have `plan_items_total` > 0) +- Compute average completion: sum of `plan_items_done` / sum of `plan_items_total` +- Identify most-skipped item category if data supports it + +Output: +``` +Plan Completion This Period: + {N} branches shipped with plans + Average completion: {X}% ({done}/{total} items) +``` + +If no plan data exists, skip this section silently. + +### Focus & Highlights +(from Step 8) +- Focus score with interpretation +- Ship of the week callout + +### Your Week (personal deep-dive) +(from Step 9, for the current user only) + +This is the section the user cares most about. Include: +- Their personal commit count, LOC, test ratio +- Their session patterns and peak hours +- Their focus areas +- Their biggest ship +- **What you did well** (2-3 specific things anchored in commits) +- **Where to level up** (1-2 specific, actionable suggestions) + +### Team Breakdown +(from Step 9, for each teammate — skip if solo repo) + +For each teammate (sorted by commits descending), write a section: + +#### [Name] +- **What they shipped**: 2-3 sentences on their contributions, areas of focus, and commit patterns +- **Praise**: 1-2 specific things they did well, anchored in actual commits. Be genuine — what would you actually say in a 1:1? Examples: + - "Cleaned up the entire auth module in 3 small, reviewable PRs — textbook decomposition" + - "Added integration tests for every new endpoint, not just happy paths" + - "Fixed the N+1 query that was causing 2s load times on the dashboard" +- **Opportunity for growth**: 1 specific, constructive suggestion. Frame as investment, not criticism. Examples: + - "Test coverage on the payment module is at 8% — worth investing in before the next feature lands on top of it" + - "Most commits land in a single burst — spacing work across the day could reduce context-switching fatigue" + - "All commits land between 1-4am — sustainable pace matters for code quality long-term" + +**AI collaboration note:** If many commits have `Co-Authored-By` AI trailers (e.g., Claude, Copilot), note the AI-assisted commit percentage as a team metric. Frame it neutrally — "N% of commits were AI-assisted" — without judgment. + +### Top 3 Team Wins +Identify the 3 highest-impact things shipped in the window across the whole team. For each: +- What it was +- Who shipped it +- Why it matters (product/architecture impact) + +### 3 Things to Improve +Specific, actionable, anchored in actual commits. Mix personal and team-level suggestions. Phrase as "to get even better, the team could..." + +### 3 Habits for Next Week +Small, practical, realistic. Each must be something that takes <5 minutes to adopt. At least one should be team-oriented (e.g., "review each other's PRs same-day"). + +### Week-over-Week Trends +(if applicable, from Step 10) diff --git a/test/gstack-retro-metrics.test.ts b/test/gstack-retro-metrics.test.ts new file mode 100644 index 000000000..c1cf2450c --- /dev/null +++ b/test/gstack-retro-metrics.test.ts @@ -0,0 +1,314 @@ +/** + * Contract + behavior tests for bin/gstack-retro-metrics (retro + * token-reduction wave — the inline git/awk pipelines from retro/SKILL.md + * Steps 0.5-9 and 11, consolidated into one script). + * + * Three layers: + * 1. CONTRACT — every labeled `KEY:` line the rendered retro prose + * interprets must be emitted (hermetic temp HOME + GSTACK_HOME, synthetic + * git repo fixture with pinned author AND committer dates). + * 2. BEHAVIOR — deterministic values on the fixture: commit/type/session + * counts, streak anchoring, window --until, local-branch fallback, + * AI-trailer vs human co-author split, VERSION range, aux-file presence. + * 3. EDGES — a 1-commit repo and a non-repo dir both survive (exit 0, no + * dropped lines); the skill fence shape stays pinned in the template. + * + * All hermetic: HOME + GSTACK_HOME point at throwaway temp dirs; the script + * runs from the live worktree bin/ (the subject under test). + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { execFileSync, spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SCRIPT = path.join(ROOT, 'bin', 'gstack-retro-metrics'); + +let tmpHome: string; +let tmpGstackHome: string; +let repoDir: string; + +function hermeticEnv(): Record { + return { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome }; +} + +function runMetrics(args: string[], cwd: string = repoDir): string { + return execFileSync(SCRIPT, args, { encoding: 'utf-8', cwd, env: hermeticEnv() }); +} + +/** Commit with pinned author AND committer dates (guard + --until read %ci). */ +function commit(dir: string, msg: string, date: string, author?: { name: string; email: string }): void { + const env: Record = { + ...hermeticEnv(), + GIT_COMMITTER_DATE: date, + ...(author ? { GIT_AUTHOR_NAME: author.name, GIT_AUTHOR_EMAIL: author.email } : {}), + }; + const r = spawnSync('git', ['commit', '-m', msg, '--date', date], { + cwd: dir, stdio: 'pipe', timeout: 10_000, env, + }); + if (r.status !== 0) throw new Error(`fixture commit failed: ${r.stderr}`); +} + +function git(dir: string, args: string[]): void { + const r = spawnSync('git', args, { cwd: dir, stdio: 'pipe', timeout: 10_000, env: hermeticEnv() }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`); +} + +function write(dir: string, file: string, content: string): void { + fs.writeFileSync(path.join(dir, file), content); + git(dir, ['add', file]); +} + +beforeAll(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rm-home-')); + tmpGstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rm-gh-')); + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rm-repo-')); + + git(repoDir, ['init', '-b', 'main']); + git(repoDir, ['config', 'user.email', 'dev@example.com']); + git(repoDir, ['config', 'user.name', 'Dev']); + + // Day 1 — one 20-minute session (2 commits) + one solo commit later. + write(repoDir, 'app.ts', 'console.log("hello");\n'); + commit(repoDir, 'feat: initial app', '2026-03-10T09:00:00'); + write(repoDir, 'auth.ts', 'export function login() {}\n'); + commit(repoDir, 'feat: add auth (#12)', '2026-03-10T09:20:00'); + write(repoDir, 'foo.test.ts', 'test("login", () => {});\n'); + commit(repoDir, 'test(qa): add regression test', '2026-03-10T11:00:00'); + + // Day 2 — 5-minute session; first commit carries an AI trailer AND a human + // co-author trailer. + write(repoDir, 'app.ts', '// wire auth\nimport "./auth";\nconsole.log("hello");\n'); + commit( + repoDir, + 'fix: wire auth\n\nCo-Authored-By: Claude Opus \nCo-Authored-By: Alice Smith ', + '2026-03-11T10:00:00', + ); + write(repoDir, 'VERSION', '1.0.0.0\n'); + commit(repoDir, 'chore: add VERSION', '2026-03-11T10:05:00'); + + // Day 3 — a second author bumps VERSION (contributors=2, team streak=3). + write(repoDir, 'VERSION', '1.1.0.0\n'); + commit(repoDir, 'chore: bump VERSION', '2026-03-12T09:30:00', { name: 'Bob', email: 'bob@example.com' }); +}); + +afterAll(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpGstackHome, { recursive: true, force: true }); + fs.rmSync(repoDir, { recursive: true, force: true }); +}); + +/** Labeled keys the rendered retro prose interprets (Steps 1-11). */ +const REQUIRED_KEYS = [ + 'RETRO_METRICS_PROTO', + 'GUARD_REMOTE', + 'GUARD_HEAD', + 'RETRO_REF', + 'GUARD_LATEST_COMMIT', + 'WINDOW_SINCE', + 'WINDOW_UNTIL', + 'USER_NAME', + 'USER_EMAIL', + 'COMMIT', + 'COMMITS', + 'MERGE_COMMITS', + 'CONTRIBUTORS', + 'INSERTIONS', + 'DELETIONS', + 'NET_LOC', + 'TEST_INSERTIONS', + 'TEST_RATIO', + 'WEIGHTED_COMMITS', + 'ACTIVE_DAYS', + 'TEST_FILES_CHANGED', + 'SESSIONS', + 'DEEP_SESSIONS', + 'MEDIUM_SESSIONS', + 'MICRO_SESSIONS', + 'TOTAL_ACTIVE_MINUTES', + 'AVG_SESSION_MINUTES', + 'LOC_PER_SESSION_HOUR', + 'COMMIT_TYPES', + 'FIX_RATIO', + 'COMMIT_SIZE_BUCKETS', + 'HOURS', + 'PEAK_HOUR', + 'FOCUS_SCORE', + 'BIGGEST_COMMIT', + 'HOTSPOT', + 'AUTHOR', + 'AUTHOR_BIGGEST', + 'WEEK', + 'COAUTHOR', + 'AI_ASSISTED_COMMITS', + 'LOGICAL_SLOC_ADDED', + 'PRS_REFERENCED', + 'PR_REFS', + 'TEST_FILES_TOTAL', + 'REGRESSION_TEST_COMMITS', + 'REGRESSION_COMMIT', + 'VERSION_RANGE', + 'TEAM_STREAK', + 'USER_STREAK', + 'RETRO_CONTEXT', + 'GREPTILE_HISTORY', + 'TODOS_FILE', + 'SKILL_USAGE_LOG', + 'EUREKA_LOG', + 'RETRO_METRICS_END', +] as const; + +describe('gstack-retro-metrics contract', () => { + test('emits every labeled key the retro prose interprets', () => { + const out = runMetrics(['--base', 'main', '--since', '2026-03-09T00:00:00']); + const missing = REQUIRED_KEYS.filter((k) => !new RegExp(`^${k}: `, 'm').test(out)); + expect(missing, `Script stopped emitting: ${missing.join(', ')} — the prose contract broke`).toEqual([]); + }); + + test('proto handshake is the FIRST line', () => { + const out = runMetrics(['--base', 'main', '--since', '2026-03-09T00:00:00']); + expect(out.split('\n')[0]).toBe('RETRO_METRICS_PROTO: 1'); + }); + + test('the skill fence invokes the script with primary path + degraded fallback', () => { + const tmpl = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md.tmpl'), 'utf-8'); + expect(tmpl).toContain('$HOME/.claude/skills/gstack/bin/gstack-retro-metrics'); + expect(tmpl).toContain('".claude/skills/gstack/bin/gstack-retro-metrics"'); + expect(tmpl).toContain('--base "" --since ""'); + expect(tmpl).toContain( + 'RETRO_METRICS: unavailable — stale install (compute metrics manually from the steps below)', + ); + // Degraded-mode prose keys off the proto handshake. + expect(tmpl).toContain('RETRO_METRICS_PROTO: 1'); + }); + + test('script is executable', () => { + expect(fs.statSync(SCRIPT).mode & 0o111).toBeTruthy(); + }); +}); + +describe('gstack-retro-metrics behavior', () => { + test('deterministic aggregates on the fixture', () => { + const out = runMetrics(['--base', 'main', '--since', '2026-03-09T00:00:00']); + expect(out).toMatch(/^COMMITS: 6$/m); + expect(out).toMatch(/^CONTRIBUTORS: 2$/m); + expect(out).toMatch(/^ACTIVE_DAYS: 3$/m); + // No origin remote: guard discloses, ref falls back to the local branch. + expect(out).toMatch(/^GUARD_REMOTE: none$/m); + expect(out).toMatch(/^GUARD_HEAD: main$/m); + expect(out).toMatch(/^RETRO_REF: main$/m); + expect(out).toMatch(/^GUARD_LATEST_COMMIT: 2026-03-12$/m); + // Conventional-commit mix (feat 2, fix 1, test 1, chore 2). + const types = out.match(/^COMMIT_TYPES: (.*)$/m)![1]; + expect(types).toContain('feat=2'); + expect(types).toContain('fix=1'); + expect(types).toContain('test=1'); + expect(types).toContain('chore=2'); + // Session detection: [09:00,09:20]=medium, [11:00]=micro, [10:00,10:05]=micro, [09:30]=micro. + expect(out).toMatch(/^SESSIONS: 4$/m); + expect(out).toMatch(/^MEDIUM_SESSIONS: 1$/m); + expect(out).toMatch(/^MICRO_SESSIONS: 3$/m); + expect(out).toMatch(/^DEEP_SESSIONS: 0$/m); + expect(out).toMatch(/^TOTAL_ACTIVE_MINUTES: 25$/m); + // Test health. + expect(out).toMatch(/^TEST_FILES_TOTAL: 1$/m); + expect(out).toMatch(/^TEST_FILES_CHANGED: 1$/m); + expect(out).toMatch(/^REGRESSION_TEST_COMMITS: 1$/m); + expect(out).toMatch(/^REGRESSION_COMMIT: \w+ test\(qa\): add regression test$/m); + // PR refs from subjects. + expect(out).toMatch(/^PRS_REFERENCED: 1$/m); + expect(out).toMatch(/^PR_REFS: #12$/m); + // AI trailer counted separately from the human co-author credit. + expect(out).toMatch(/^AI_ASSISTED_COMMITS: 1$/m); + expect(out).toMatch(/^COAUTHOR: \w+\|Alice Smith $/m); + expect(out).not.toMatch(/^COAUTHOR: .*anthropic\.com/m); + // VERSION range across the window. + expect(out).toMatch(/^VERSION_RANGE: v1\.0\.0\.0 → v1\.1\.0\.0$/m); + // Streaks anchored at the newest commit date, never the wall clock. + expect(out).toMatch(/^TEAM_STREAK: 3 days \(anchor 2026-03-12\)$/m); + expect(out).toMatch(/^USER_STREAK: 2 days \(anchor 2026-03-11\)$/m); + // Hour histogram carries the fixture's commit hours. + const hours = out.match(/^HOURS: (.*)$/m)![1]; + expect(hours).toContain('09='); + expect(hours).toContain('10='); + }); + + test('--until bounds the window (compare mode prior window)', () => { + const out = runMetrics([ + '--base', 'main', + '--since', '2026-03-09T00:00:00', + '--until', '2026-03-11T00:00:00', + ]); + expect(out).toMatch(/^COMMITS: 3$/m); + expect(out).toMatch(/^ACTIVE_DAYS: 1$/m); + expect(out).toMatch(/^WINDOW_UNTIL: 2026-03-11T00:00:00$/m); + }); + + test('aux inputs report present when the files exist under GSTACK_HOME', () => { + fs.writeFileSync(path.join(tmpGstackHome, 'greptile-history.md'), '# history\n'); + fs.mkdirSync(path.join(tmpGstackHome, 'analytics'), { recursive: true }); + fs.writeFileSync(path.join(tmpGstackHome, 'analytics', 'skill-usage.jsonl'), '{}\n'); + try { + const out = runMetrics(['--base', 'main', '--since', '2026-03-09T00:00:00']); + expect(out).toMatch(/^GREPTILE_HISTORY: present /m); + expect(out).toMatch(/^SKILL_USAGE_LOG: present /m); + expect(out).toMatch(/^RETRO_CONTEXT: absent$/m); + expect(out).toMatch(/^EUREKA_LOG: absent$/m); + } finally { + fs.rmSync(path.join(tmpGstackHome, 'greptile-history.md'), { force: true }); + fs.rmSync(path.join(tmpGstackHome, 'analytics'), { recursive: true, force: true }); + } + }); + + test('zero-commit window still emits the full labeled surface', () => { + const out = runMetrics([ + '--base', 'main', + '--since', '2020-01-01T00:00:00', + '--until', '2020-01-08T00:00:00', + ]); + expect(out).toMatch(/^COMMITS: 0$/m); + expect(out).toMatch(/^SESSIONS: 0$/m); + expect(out).toMatch(/^RETRO_METRICS_END: ok$/m); + }); +}); + +describe('gstack-retro-metrics edges', () => { + test('survives a repo with exactly 1 commit', () => { + const oneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rm-one-')); + try { + git(oneDir, ['init', '-b', 'main']); + git(oneDir, ['config', 'user.email', 'solo@example.com']); + git(oneDir, ['config', 'user.name', 'Solo']); + write(oneDir, 'a.txt', 'hi\n'); + commit(oneDir, 'feat: first', '2026-03-10T09:00:00'); + const out = runMetrics(['--base', 'main', '--since', '2026-03-09T00:00:00'], oneDir); + expect(out).toMatch(/^COMMITS: 1$/m); + expect(out).toMatch(/^CONTRIBUTORS: 1$/m); + expect(out).toMatch(/^SESSIONS: 1$/m); + expect(out).toMatch(/^MICRO_SESSIONS: 1$/m); + expect(out).toMatch(/^TEAM_STREAK: 1 days \(anchor 2026-03-10\)$/m); + expect(out).toMatch(/^BIGGEST_COMMIT: \w+\|1\|Solo\|feat: first$/m); + expect(out).toMatch(/^RETRO_METRICS_END: ok$/m); + } finally { + fs.rmSync(oneDir, { recursive: true, force: true }); + } + }); + + test('non-repo dir reports RETRO_METRICS_ERROR and exits 0', () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rm-empty-')); + try { + const out = runMetrics(['--since', '7 days ago'], emptyDir); + expect(out).toContain('RETRO_METRICS_PROTO: 1'); + expect(out).toContain('RETRO_METRICS_ERROR: not inside a git repository'); + } finally { + fs.rmSync(emptyDir, { recursive: true, force: true }); + } + }); + + test('local reads only: no network git ops or curl anywhere in the script', () => { + const script = fs.readFileSync(SCRIPT, 'utf-8'); + expect(script).not.toMatch(/(^|[;|&`($!]|\s)git(\s+-C\s+\S+)?\s+(push|pull|fetch|clone|ls-remote)\b/m); + expect(script).not.toMatch(/(^|[|&;(`]|\s|\$\()curl\s/); + }); +}); diff --git a/test/regression-1624-retro-stale-base.test.ts b/test/regression-1624-retro-stale-base.test.ts index 0e4800c86..67d84b0f2 100644 --- a/test/regression-1624-retro-stale-base.test.ts +++ b/test/regression-1624-retro-stale-base.test.ts @@ -2,20 +2,24 @@ * Regression tests for #1624 — /retro silently produced empty/misleading * output when "today" anchor was wrong or origin/ was stale. * - * The fix is Step 0.5 in retro/SKILL.md.tmpl: four ordered pre-check - * branches before any window analysis. These tests are static invariants - * against the template body — they fail the build if the guard is removed, - * weakened, or its ordering broken. + * The guard survived the retro token-reduction wave in two halves: + * - LOCAL checks (remote present? detached HEAD? newest commit date on the + * analyzed ref) live in bin/gstack-retro-metrics, emitted as + * GUARD_REMOTE / GUARD_HEAD / GUARD_LATEST_COMMIT lines. + * - The FETCH (network op — kept in skill prose, never in the script) and + * the ordered skip/BLOCK decision rules live in retro/SKILL.md.tmpl + * (Step 0.5 fetch fence + Step 1 guard prose). * - * Branches under test: - * 1. no-remote skip — git remote returns empty - * 2. detached-HEAD skip — git symbolic-ref --quiet HEAD returns empty - * 3. fetch-fail warn — git fetch origin exits non-zero - * 4. stale-base BLOCK — fetch ok, latest commit older than window + * These static invariants fail the build if the guard is removed, weakened, + * or its ordering broken: + * 1. no-remote skip — script emits GUARD_REMOTE: none + * 2. detached-HEAD skip — script emits GUARD_HEAD: detached + * 3. fetch-fail warn — Step 0.5 fence discloses and proceeds + * 4. stale-base BLOCK — fetch ok + latest commit older than window * - * Each branch must short-circuit further checks (only one verdict wins) and - * must surface a disclosure line on stderr so the narrative carries the - * reason rather than silently misreporting. + * Skip paths must carry a disclosure into the narrative; BLOCK must cite the + * date and the remediation. Behavioral coverage of the script's guard + * emissions lives in test/gstack-retro-metrics.test.ts. */ import { describe, expect, test } from "bun:test"; import * as fs from "node:fs"; @@ -23,124 +27,120 @@ import * as path from "node:path"; const ROOT = path.resolve(import.meta.dir, ".."); const RETRO_TMPL = path.join(ROOT, "retro", "SKILL.md.tmpl"); -const RETRO_MD = path.join(ROOT, "retro", "SKILL.md"); +const METRICS_SCRIPT = path.join(ROOT, "bin", "gstack-retro-metrics"); function readTmpl(): string { return fs.readFileSync(RETRO_TMPL, "utf-8"); } -function readMd(): string { - return fs.readFileSync(RETRO_MD, "utf-8"); +function readScript(): string { + return fs.readFileSync(METRICS_SCRIPT, "utf-8"); } -describe("#1624 retro stale-base guard — Step 0.5 exists and is ordered before Step 1", () => { - test("Step 0.5 header is present in template", () => { - const body = readTmpl(); - expect(body).toMatch(/### Step 0\.5: Stale-base \+ bad-today-anchor pre-flight guard/); - }); - - test("Step 0.5 appears before Step 1: Gather Raw Data", () => { +describe("#1624 retro stale-base guard — pre-flight ordered before analysis", () => { + test("Step 0.5 fetch pre-flight is present and precedes Step 1", () => { const body = readTmpl(); const step05 = body.indexOf("### Step 0.5:"); - const step1 = body.indexOf("### Step 1: Gather Raw Data"); + const step1 = body.indexOf("### Step 1: Gather"); expect(step05).toBeGreaterThan(-1); expect(step1).toBeGreaterThan(-1); expect(step05).toBeLessThan(step1); }); - test("regenerated SKILL.md carries the Step 0.5 guard", () => { - const md = readMd(); - expect(md).toMatch(/Step 0\.5: Stale-base \+ bad-today-anchor pre-flight guard/); + test("guard evaluation prose sits in Step 1 before the metric interpretation steps", () => { + const body = readTmpl(); + const guard = body.indexOf("Stale-base + bad-today-anchor guard"); + const step2 = body.indexOf("### Step 2: Compute Metrics"); + expect(guard).toBeGreaterThan(-1); + expect(step2).toBeGreaterThan(-1); + expect(guard).toBeLessThan(step2); }); }); describe("#1624 retro guard — branch A: no-remote skip", () => { - test("template checks for 'origin' remote absence and skips with disclosure", () => { - const body = readTmpl(); - // Must check git remote for 'origin' and short-circuit - expect(body).toMatch(/git remote[^|]*\|\s*grep -c '\^origin\$'/); - expect(body).toMatch(/RETRO_GUARD: no 'origin' remote/); + test("script checks for 'origin' remote absence and emits GUARD_REMOTE", () => { + const script = readScript(); + expect(script).toMatch(/git remote[^|]*\|\s*grep -c '\^origin\$'/); + expect(script).toContain("GUARD_REMOTE: none"); + expect(script).toContain("GUARD_REMOTE: origin"); }); - test("no-remote skip sets a verdict variable that gates later checks", () => { + test("template prose treats GUARD_REMOTE: none as proceed-with-disclosure", () => { const body = readTmpl(); - // The verdict variable must be set so later branches short-circuit - expect(body).toMatch(/_RETRO_GUARD_VERDICT="skip-no-remote"/); + expect(body).toMatch(/GUARD_REMOTE: none/); }); }); describe("#1624 retro guard — branch B: detached-HEAD skip", () => { - test("template checks for detached HEAD via git symbolic-ref", () => { - const body = readTmpl(); - expect(body).toMatch(/git symbolic-ref --quiet HEAD/); - expect(body).toMatch(/RETRO_GUARD: detached HEAD/); + test("script checks for detached HEAD via git symbolic-ref and emits GUARD_HEAD", () => { + const script = readScript(); + expect(script).toMatch(/git symbolic-ref --quiet --short HEAD/); + expect(script).toContain("GUARD_HEAD: detached"); }); - test("detached-HEAD branch is gated by prior verdict check (ordering)", () => { + test("template prose treats GUARD_HEAD: detached as proceed-with-disclosure", () => { const body = readTmpl(); - // The detached-HEAD block must be guarded by the verdict check so - // no-remote always wins if both are true. - const branchBStart = body.indexOf("# Pre-check B: detached HEAD"); - expect(branchBStart).toBeGreaterThan(-1); - const branchBSlice = body.slice(branchBStart, branchBStart + 500); - expect(branchBSlice).toMatch(/if \[ -z "\$_RETRO_GUARD_VERDICT" \]/); + expect(body).toMatch(/GUARD_HEAD: detached/); }); }); describe("#1624 retro guard — branch C: fetch-fail warn", () => { - test("template warns and proceeds against last-known origin when fetch fails", () => { + test("fetch stays in skill prose (never in the script) and warns on failure", () => { const body = readTmpl(); - // Match either `git fetch ... ||` or `if ! git fetch ...` shape. - expect(body).toMatch(/(?:if !\s+|[^\n]*\|\|\s*)git fetch origin |git fetch origin [^\n]*--quiet 2>\/dev\/null; then/); - expect(body).toMatch(/fetch[^\n]*failed[^\n]*offline/); - expect(body).toMatch(/_RETRO_GUARD_VERDICT="warn-fetch-failed"/); + expect(body).toMatch(/git fetch origin --quiet/); + expect(body).toMatch(/RETRO_FETCH: failed[^\n]*offline/); + // The script must stay local-reads-only: no fetch/pull/push/clone. + const script = readScript(); + expect(script).not.toMatch(/(^|[;|&`($!]|\s)git(\s+-C\s+\S+)?\s+(push|pull|fetch|clone|ls-remote)\b/m); }); - test("fetch-fail warn is gated by prior verdict check (ordering)", () => { + test("fetch failure downgrades BLOCK to proceed (ordering)", () => { const body = readTmpl(); - const branchCStart = body.indexOf("# Pre-check C: fetch origin"); - expect(branchCStart).toBeGreaterThan(-1); - const branchCSlice = body.slice(branchCStart, branchCStart + 500); - expect(branchCSlice).toMatch(/if \[ -z "\$_RETRO_GUARD_VERDICT" \]/); + // Rule 1 (skip paths incl. fetch-fail) must be evaluated before rule 2 + // (BLOCK), and BLOCK must be conditioned on the fetch having succeeded. + const skipRule = body.indexOf("the Step 0.5 fetch failed"); + const blockRule = body.indexOf("Retro window is stale"); + expect(skipRule).toBeGreaterThan(-1); + expect(blockRule).toBeGreaterThan(-1); + expect(skipRule).toBeLessThan(blockRule); + expect(body).toMatch(/fetch succeeded AND/); }); }); describe("#1624 retro guard — branch D: stale-base BLOCK", () => { - test("template extracts latest origin/ commit date via git log -1 --format=%ci", () => { - const body = readTmpl(); - // The BLOCK check must read the actual latest-commit date so the - // disclosure is concrete (not generic). - expect(body).toMatch(/git log -1 --format=%ci origin\//); + test("script extracts the latest analyzed-ref commit date via git log -1 --format=%ci", () => { + const script = readScript(); + expect(script).toMatch(/git log -1 --format=%ci/); + expect(script).toContain("GUARD_LATEST_COMMIT:"); }); test("BLOCK prose names latest-commit date and instructs user remediation", () => { const body = readTmpl(); - // The BLOCK message must cite the date AND tell the user how to recover. - // "Retro window is stale" is the canonical first line. expect(body).toMatch(/Retro window is stale/); expect(body).toMatch(/git fetch origin /); expect(body).toMatch(/Confirm today's date/); }); - test("BLOCK branch is gated by prior verdict checks (ordering)", () => { + test("today comes from the session reminder, never the system clock", () => { const body = readTmpl(); - const branchDStart = body.indexOf("# Pre-check D:"); - expect(branchDStart).toBeGreaterThan(-1); - const branchDSlice = body.slice(branchDStart, branchDStart + 800); - expect(branchDSlice).toMatch(/if \[ -z "\$_RETRO_GUARD_VERDICT" \]/); + expect(body).toMatch(/session reminder/); + expect(body).toMatch(/NEVER from `date`/); }); }); describe("#1624 retro guard — disclosure must reach the narrative", () => { - test("template names the skip paths that must carry a disclosure line", () => { + test("skip paths carry a disclosure line into the retro output", () => { const body = readTmpl(); - // The post-bash prose must explicitly tell the model to surface - // these reasons in the retro output rather than silently dropping them. - expect(body).toMatch(/skip-no-remote/); - expect(body).toMatch(/skip-detached/); - expect(body).toMatch(/warn-fetch-failed/); - // The prose names disclosure + narrative together (either order) so the - // retro output is never silently confidently-wrong. + // The prose ties disclosure + narrative together so the retro output is + // never silently confidently-wrong on offline/local-only runs. + expect(body).toMatch(/offline run, window not freshness-verified/); expect(body).toMatch(/(?:disclosure[\s\S]{0,200}narrative|narrative[\s\S]{0,200}disclosure)/); }); + + test("non-default analyzed ref is disclosed (RETRO_REF)", () => { + const body = readTmpl(); + expect(body).toMatch(/RETRO_REF/); + const script = readScript(); + expect(script).toContain("RETRO_REF:"); + }); }); diff --git a/test/skill-e2e-retro.test.ts b/test/skill-e2e-retro.test.ts index d8ff31b07..49f774ac0 100644 --- a/test/skill-e2e-retro.test.ts +++ b/test/skill-e2e-retro.test.ts @@ -6,7 +6,7 @@ import { logCost, recordE2E, createEvalCollector, finalizeEvalCollector, } from './helpers/e2e-helpers'; -import { extractSkillSections, RETRO_E2E_SECTIONS } from './helpers/skill-fixture'; +import { extractSkillSections } from './helpers/skill-fixture'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; @@ -14,6 +14,53 @@ import * as os from 'os'; const evalCollector = createEvalCollector('e2e-retro'); +// Carved-skill fixture (retro wave): the repo-scoped retro flow lives in the +// skeleton's H2 sections below, and the narrative report format lives in +// retro/sections/report-format.md (the skeleton Step 14 is a STOP-Read +// pointer). The fixture ships skeleton + section + bin/gstack-retro-metrics — +// still an extraction, not a full-file copy (sections ARE the minimal +// on-demand units; same pattern as skill-e2e-review-army.test.ts). +const RETRO_SKELETON_SECTIONS = [ + 'When to invoke this skill', + 'Step 0: Detect platform and base branch', + 'User-invocable', + 'Arguments', + 'Instructions', + 'Prior Learnings', + 'Capture Learnings', + 'Tone', + 'Important Rules', +]; + +/** Write retro/SKILL.md + sections + the metrics script into a fixture dir. */ +function buildRetroFixture(dir: string): void { + let skillMd = extractSkillSections(path.join(ROOT, 'retro'), RETRO_SKELETON_SECTIONS); + // The skeleton's STOP-Read points at the installed absolute section path + // (~/.claude/skills/gstack/retro/sections/...), which doesn't exist under + // the hermetic temp HOME — repoint it at the fixture copy. + skillMd = skillMd.replace( + /[^\s`]*\/retro\/sections\/report-format\.md/g, + path.join(dir, 'retro', 'sections', 'report-format.md'), + ); + fs.mkdirSync(path.join(dir, 'retro', 'sections'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'retro', 'SKILL.md'), skillMd); + fs.copyFileSync( + path.join(ROOT, 'retro', 'sections', 'report-format.md'), + path.join(dir, 'retro', 'sections', 'report-format.md'), + ); + // The Step 1 fence resolves bin/gstack-retro-metrics via + // $HOME/.claude/skills/gstack/bin first (absent in the hermetic HOME), then + // the cwd-relative .claude/skills/gstack/bin fallback — satisfy the fallback + // so the run exercises the real script instead of the degraded path. + const binDir = path.join(dir, '.claude', 'skills', 'gstack', 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + fs.copyFileSync( + path.join(ROOT, 'bin', 'gstack-retro-metrics'), + path.join(binDir, 'gstack-retro-metrics'), + ); + fs.chmodSync(path.join(binDir, 'gstack-retro-metrics'), 0o755); +} + // --- Retro base branch detection smoke test --- describeIfSelected('Base branch detection', ['retro-base-branch'], () => { @@ -52,11 +99,7 @@ describeIfSelected('Base branch detection', ['retro-base-branch'], () => { // Retro skill — extract the repo-scoped retro flow only (drops the shared // preamble + global/compare modes; CLAUDE.md: "extract, don't copy"). - fs.mkdirSync(path.join(dir, 'retro'), { recursive: true }); - fs.writeFileSync( - path.join(dir, 'retro', 'SKILL.md'), - extractSkillSections(path.join(ROOT, 'retro'), RETRO_E2E_SECTIONS), - ); + buildRetroFixture(dir); const result = await runSkillTest({ prompt: `Read retro/SKILL.md for instructions on how to run a retrospective. @@ -137,12 +180,8 @@ describeIfSelected('Retro E2E', ['retro'], () => { run('git', ['add', 'README.md']); run('git', ['commit', '-m', 'docs: add README', '--date', '2026-03-12T16:00:00']); - // Retro skill — extracted repo-scoped flow, not the full 1820-line file. - fs.mkdirSync(path.join(retroDir, 'retro'), { recursive: true }); - fs.writeFileSync( - path.join(retroDir, 'retro', 'SKILL.md'), - extractSkillSections(path.join(ROOT, 'retro'), RETRO_E2E_SECTIONS), - ); + // Retro skill — extracted repo-scoped flow, not the full file. + buildRetroFixture(retroDir); }); afterAll(() => { diff --git a/test/skill-fixture.test.ts b/test/skill-fixture.test.ts index cae2da5e2..138a6b205 100644 --- a/test/skill-fixture.test.ts +++ b/test/skill-fixture.test.ts @@ -195,14 +195,24 @@ describe('real-skill pins: section lists used by E2E fixtures', () => { expect(army).toContain('MULTI-SPECIALIST CONFIRMED'); }); - test('RETRO_E2E_SECTIONS extracts from retro/SKILL.md', () => { - const out = extractSkillSections(path.join(ROOT, 'retro'), RETRO_E2E_SECTIONS); + test('RETRO_E2E_SECTIONS skeleton extracts from retro/SKILL.md + carved section', () => { + // Carved (retro wave): the '## Engineering Retro: [date range]' report + // format lives in retro/sections/report-format.md; the E2E fixture builds + // skeleton sections + the section file (see skill-e2e-retro.test.ts). + const skeletonSections = RETRO_E2E_SECTIONS.filter( + (s) => s !== 'Engineering Retro: [date range]', + ); + const out = extractSkillSections(path.join(ROOT, 'retro'), skeletonSections); // Steps 0.5-14 live under Prior Learnings / Capture Learnings. - expect(out).toContain('### Step 1: Gather Raw Data'); + expect(out).toContain('### Step 1: Gather'); expect(out).toContain('### Step 14: Write the Narrative'); - expect(out).toContain('## Engineering Retro: [date range]'); expect(out).not.toContain('## Global Retrospective Mode'); expect(out).not.toContain('## Telemetry (run last)'); + + const reportFormat = fs.readFileSync( + path.join(ROOT, 'retro', 'sections', 'report-format.md'), 'utf-8'); + expect(reportFormat).toContain('## Engineering Retro: [date range]'); + expect(reportFormat).toContain('### Team Breakdown'); }); test('CODEX_REVIEW_E2E_SECTIONS extracts from the Codex host variant when present', () => {