feat(retro): absorb inline git/awk metrics into bin/gstack-retro-metrics + carve report format

RETRO_METRICS_PROTO: 1 contract, local git reads only (fetch stays in the
skill prose), degraded path documented in the skeleton.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-25 17:47:32 +00:00
co-authored by Claude Fable 5
parent b007814be0
commit e0250aa128
10 changed files with 1245 additions and 705 deletions
+357
View File
@@ -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 <default-branch> --since <git-since-expr> \
# [--until <git-until-expr>]
#
# --base the detected default branch (from BASE_BRANCH_DETECT). The script
# prefers origin/<base>, falls back to the local <base> (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 ~ /^(\/\/|#|\*|\/\*|<!--|--)/) next
n++
}
END { print n + 0 }
' || echo 0)
echo "LOGICAL_SLOC_ADDED: ${_LSLOC:-0}"
# ── PR/MR references in commit subjects (GitHub #NNN, GitLab !NNN) ──────────
_PRS=$(git log "$REF" "$_S" ${_U:+"$_U"} --format='%s' 2>/dev/null | grep -oE '[#!][0-9]+' | sort -u | tr '\n' ' ' | sed 's/ $//' || true)
echo "PRS_REFERENCED: $(printf '%s' "$_PRS" | wc -w | tr -d ' ')"
[ -n "$_PRS" ] && echo "PR_REFS: $_PRS" || true
# ── Test health (repo-wide + window) ─────────────────────────────────────────
_TF_TOTAL=$(git ls-files 2>/dev/null | grep -cE '(\.test\.|\.spec\.|_test\.|_spec\.)' || true)
case "$_TF_TOTAL" in ''|*[!0-9]*) _TF_TOTAL=0 ;; esac
echo "TEST_FILES_TOTAL: $_TF_TOTAL"
_REG=$(git log "$REF" "$_S" ${_U:+"$_U"} --oneline --grep="test(qa):" --grep="test(design):" --grep="test: coverage" 2>/dev/null || true)
if [ -n "$_REG" ]; then
echo "REGRESSION_TEST_COMMITS: $(printf '%s\n' "$_REG" | wc -l | tr -d ' ')"
printf '%s\n' "$_REG" | sed 's/^/REGRESSION_COMMIT: /'
else
echo "REGRESSION_TEST_COMMITS: 0"
fi
# ── Version range across the window (VERSION file, when tracked) ────────────
if git cat-file -e "$REF:VERSION" 2>/dev/null; then
_V_LAST_C=$(git log "$REF" "$_S" ${_U:+"$_U"} --format=%H -- VERSION 2>/dev/null | head -1)
_V_FIRST_C=$(git log "$REF" "$_S" ${_U:+"$_U"} --format=%H -- VERSION 2>/dev/null | tail -1)
if [ -n "$_V_LAST_C" ]; then
_V_NEW=$(git show "$_V_LAST_C:VERSION" 2>/dev/null | head -1 | tr -d '[:space:]')
_V_OLD=$(git show "$_V_FIRST_C^:VERSION" 2>/dev/null | head -1 | tr -d '[:space:]')
[ -n "$_V_OLD" ] || _V_OLD=$(git show "$_V_FIRST_C:VERSION" 2>/dev/null | head -1 | tr -d '[:space:]')
echo "VERSION_RANGE: v$_V_OLD → v$_V_NEW"
else
_V_CUR=$(git show "$REF:VERSION" 2>/dev/null | head -1 | tr -d '[:space:]')
echo "VERSION_RANGE: v$_V_CUR (unchanged this window)"
fi
fi
# ── Streaks: consecutive commit days, full history (Step 11) ─────────────────
# Anchored at the NEWEST commit date on the ref — the prose compares the anchor
# against the session-reminder "today" (never the system clock) to decide
# whether the streak is live or broken.
_streak_awk='
function jdn(y, m, d) {
a = int((14 - m) / 12); yy = y + 4800 - a; mm = m + 12 * a - 3
return d + int((153 * mm + 2) / 5) + 365 * yy + int(yy / 4) - int(yy / 100) + int(yy / 400) - 32045
}
{
split($0, p, "-")
j = jdn(p[1] + 0, p[2] + 0, p[3] + 0)
if (NR == 1) { anchor = $0; prev = j; streak = 1; next }
if (j == prev - 1) { streak++; prev = j } else if (j != prev) exit
}
END { if (NR > 0) printf "%d days (anchor %s)\n", streak, anchor; else print "0 days (no commits)" }
'
_TEAM_STREAK=$(git log "$REF" --date=format-local:'%Y-%m-%d' --format='%ad' 2>/dev/null | awk '!seen[$0]++' | awk "$_streak_awk" || true)
echo "TEAM_STREAK: ${_TEAM_STREAK:-0 days (no commits)}"
if [ -n "$_USER_NAME" ]; then
_USER_STREAK=$(git log "$REF" --author="$_USER_NAME" --date=format-local:'%Y-%m-%d' --format='%ad' 2>/dev/null | awk '!seen[$0]++' | awk "$_streak_awk" || true)
echo "USER_STREAK: ${_USER_STREAK:-0 days (no commits)}"
fi
# ── Aux inputs (presence only — the model Reads what exists) ─────────────────
[ -f "$_GH/retro-context.md" ] && echo "RETRO_CONTEXT: present ($_GH/retro-context.md)" || echo "RETRO_CONTEXT: absent"
[ -f "$_GH/greptile-history.md" ] && echo "GREPTILE_HISTORY: present ($_GH/greptile-history.md)" || echo "GREPTILE_HISTORY: absent"
[ -f "TODOS.md" ] && echo "TODOS_FILE: present (TODOS.md)" || echo "TODOS_FILE: absent"
[ -f "$_GH/analytics/skill-usage.jsonl" ] && echo "SKILL_USAGE_LOG: present ($_GH/analytics/skill-usage.jsonl)" || echo "SKILL_USAGE_LOG: absent"
[ -f "$_GH/analytics/eureka.jsonl" ] && echo "EUREKA_LOG: present ($_GH/analytics/eureka.jsonl)" || echo "EUREKA_LOG: absent"
echo "RETRO_METRICS_END: ok"
+95 -307
View File
@@ -468,11 +468,20 @@ When the user types `/retro`, run this skill.
## Section index — Read each section when its situation applies
This skill is a decision-tree skeleton. The steps below point to on-demand
sections. Read a section in full before doing its step; do not work from memory.
| When | Read this section |
|------|-------------------|
| writing the retrospective narrative (Step 14, after all metrics are computed and compared) | `sections/report-format.md` |
## Instructions
Parse the argument to determine the time window. Default to 7 days if no argument given. All times should be reported in the user's **local timezone** (use the system default — do NOT set `TZ`).
**Midnight-aligned windows:** For day (`d`) and week (`w`) units, compute an absolute start date at local midnight, not a relative string. For example, if today is 2026-03-18 and the window is 7 days: the start date is 2026-03-11. Use `--since="2026-03-11T00:00:00"` for git log queries — the explicit `T00:00:00` suffix ensures git starts from midnight. Without it, git uses the current wall-clock time (e.g., `--since="2026-03-11"` at 11pm means 11pm, not midnight). For week units, multiply by 7 to get days (e.g., `2w` = 14 days back). For hour (`h`) units, use `--since="N hours ago"` since midnight alignment does not apply to sub-day windows.
**Midnight-aligned windows:** For day (`d`) and week (`w`) units, compute an absolute start date at local midnight, not a relative string. For example, if today is 2026-03-18 and the window is 7 days: the start date is 2026-03-11. Use `--since "2026-03-11T00:00:00"` — the explicit `T00:00:00` suffix ensures git starts from midnight. Without it, git uses the current wall-clock time (e.g., `--since "2026-03-11"` at 11pm means 11pm, not midnight). For week units, multiply by 7 to get days (e.g., `2w` = 14 days back). For hour (`h`) units, use `--since "N hours ago"` since midnight alignment does not apply to sub-day windows. Compute "today" from the user-visible `## currentDate` tag in the session reminder — NEVER from `date` (the system clock can be hours off in containerized harnesses). If you cannot reliably compute "today", stop and ask the user via AskUserQuestion rather than proceeding.
**Argument validation:** If the argument doesn't match a number followed by `d`, `h`, or `w`, the word `compare` (optionally followed by a window), or the word `global` (optionally followed by a window), show this usage and stop:
```
@@ -527,142 +536,89 @@ matches a past learning, display:
This makes the compounding visible. The user should see that gstack is getting
smarter on their codebase over time.
### Non-git context (optional)
### Step 0.5: Freshness pre-flight (fetch)
Check for non-git context that should be included in the retro:
Refresh `origin/<default>` so the retro doesn't misreport against a stale local ref. If the repo has no `origin` remote this fails harmlessly — the metrics script (Step 1) falls back to the local branch and its guard lines disclose it:
```bash
[ -f ~/.gstack/retro-context.md ] && echo "RETRO_CONTEXT_FOUND" || echo "NO_RETRO_CONTEXT"
git fetch origin <default> --quiet 2>/dev/null \
|| echo "RETRO_FETCH: failed (offline or no remote) — proceeding against last-known refs"
```
If `RETRO_CONTEXT_FOUND`: read `~/.gstack/retro-context.md`. This file is user-authored and may contain meeting notes, calendar events, decisions, and other context that doesn't appear in git history. Incorporate this context into the retro narrative where relevant.
Remember whether the fetch succeeded — the stale-base guard in Step 1 only BLOCKs when it did.
### Step 0.5: Stale-base + bad-today-anchor pre-flight guard
### Step 1: Gather Metrics (one command)
The retro skill computes a window from "today" and queries `git log --since=<window> origin/<default>`. If "today" drifts (model session-context error) or the local worktree's `origin/<default>` is materially behind the actual remote, the window can return zero or near-zero commits and the retro will fabricate a coherent-looking narrative from nothing. This guard prevents silent confidently-wrong output.
Run the pre-flight in this exact order. The first branch that matches wins:
All raw data gathering and metric computation runs through `gstack-retro-metrics` — one command instead of a dozen git pipelines. Substitute the base branch detected in Step 0 and the midnight-aligned start computed above:
```bash
# Pre-check A: no remote configured?
_RETRO_HAS_REMOTE=$(git remote 2>/dev/null | grep -c '^origin$' || echo 0)
if [ "$_RETRO_HAS_REMOTE" = "0" ]; then
echo "RETRO_GUARD: no 'origin' remote, base freshness not verified — proceeding"
_RETRO_GUARD_VERDICT="skip-no-remote"
fi
# Pre-check B: detached HEAD or no current base?
if [ -z "$_RETRO_GUARD_VERDICT" ]; then
_RETRO_HEAD_REF=$(git symbolic-ref --quiet HEAD 2>/dev/null || echo "")
if [ -z "$_RETRO_HEAD_REF" ]; then
echo "RETRO_GUARD: detached HEAD, base freshness not verified — proceeding"
_RETRO_GUARD_VERDICT="skip-detached"
fi
fi
# Pre-check C: fetch origin <default>; if it fails, warn but proceed.
if [ -z "$_RETRO_GUARD_VERDICT" ]; then
if ! git fetch origin <default> --quiet 2>/dev/null; then
echo "RETRO_GUARD: 'git fetch origin <default>' failed (offline?) — proceeding against last-known origin/<default>"
_RETRO_GUARD_VERDICT="warn-fetch-failed"
fi
fi
# Pre-check D: BLOCK only when fetch succeeded AND the latest origin/<default>
# commit predates the retro window. Today's date should be loaded from the
# user-visible "## currentDate" tag in the session reminder; if the gap between
# origin/<default>'s newest commit and today exceeds the window, the model's
# "today" is almost certainly stale (or the worktree is wildly behind).
if [ -z "$_RETRO_GUARD_VERDICT" ]; then
_RETRO_LATEST_ISO=$(git log -1 --format=%ci origin/<default> 2>/dev/null | awk '{print $1}')
if [ -n "$_RETRO_LATEST_ISO" ]; then
# The model computes today from the session reminder (NEVER from `date` —
# the system clock can be hours off in containerized harnesses).
# Compute window in DAYS (default 7): if today - latest-commit-date > window-days,
# BLOCK. If the model cannot reliably compute "today", it MUST stop here and
# ask the user via AskUserQuestion rather than proceeding.
echo "RETRO_GUARD: latest origin/<default> commit on $_RETRO_LATEST_ISO"
_RETRO_GUARD_VERDICT="check-gap"
fi
fi
_RM="$HOME/.claude/skills/gstack/bin/gstack-retro-metrics"
[ -x "$_RM" ] || _RM=".claude/skills/gstack/bin/gstack-retro-metrics"
"$_RM" --base "<default>" --since "<since>" \
|| echo "RETRO_METRICS: unavailable — stale install (compute metrics manually from the steps below)"
```
After running the bash block, the model evaluates `RETRO_GUARD: latest origin/<default> commit on <DATE>` against today and the window:
Read the labeled `METRIC_NAME: value` lines — they feed every step below. **Degraded mode:** if `RETRO_METRICS_PROTO: 1` is missing from the output, the install is stale; compute each metric manually with git commands, using the metric definitions in Steps 2-11 as the spec.
- If the **latest-commit date is older than (today window-days)**, BLOCK with: "Retro window is stale. Latest commit on `origin/<default>` was `<DATE>`, but the window covers `<since>` to `<today>`. This usually means either (a) today's date is wrong in this session or (b) `origin/<default>` is materially behind the remote. Confirm today's date via the session reminder; if today is correct, run `git fetch origin <default>` manually and re-run /retro." Stop the skill until the user resolves.
- Otherwise, write: "RETRO_GUARD: latest commit `<DATE>` within window — proceeding."
**Identity:** `USER_NAME` is **"you"** — the person reading this retro. All other authors are teammates. Orient the narrative around this: "your" commits vs teammate contributions.
Skip paths (`skip-no-remote`, `skip-detached`, `warn-fetch-failed`) all proceed to Step 1 with the cited reason on a single stderr line so the retro narrative carries the disclosure ("offline run, window not freshness-verified") rather than silently misreporting.
**Stale-base + bad-today-anchor guard.** The script echoes `GUARD_LATEST_COMMIT: <DATE>` (newest commit on the analyzed ref). If "today" drifts (model session-context error) or the local `origin/<default>` is materially behind the remote, the window returns zero or near-zero commits and the retro would fabricate a coherent-looking narrative from nothing. Evaluate in this order:
### Step 1: Gather Raw Data
1. If `GUARD_REMOTE: none` or `GUARD_HEAD: detached` or the Step 0.5 fetch failed: proceed, but carry the disclosure into the narrative ("offline run, window not freshness-verified") rather than silently misreporting.
2. If the Step 0.5 fetch succeeded AND the `GUARD_LATEST_COMMIT` date is **older than (today window-days)**: BLOCK with: "Retro window is stale. Latest commit on `origin/<default>` was `<DATE>`, but the window covers `<since>` to `<today>`. This usually means either (a) today's date is wrong in this session or (b) `origin/<default>` is materially behind the remote. Confirm today's date via the session reminder; if today is correct, run `git fetch origin <default>` manually and re-run /retro." Stop the skill until the user resolves.
3. Otherwise, write: "RETRO_GUARD: latest commit `<DATE>` within window — proceeding."
First, fetch origin and identify the current user:
```bash
git fetch origin <default> --quiet
# Identify who is running the retro
git config user.name
git config user.email
```
Also check `RETRO_REF`: if it is not `origin/<default>` (local-only repo, missing remote branch), disclose which ref the retro analyzed.
The name returned by `git config user.name` is **"you"** — the person reading this retro. All other authors are teammates. Use this to orient the narrative: "your" commits vs teammate contributions.
**Metric line reference** (what the script emits):
Run ALL of these git commands in parallel (they are independent):
| Line | Meaning |
|------|---------|
| `COMMIT: hash\|author\|datetime\|+ins/-del\|subject` | One per commit, newest first (capped at 300) — the raw material for narrative anchoring |
| `COMMITS` / `MERGE_COMMITS` / `CONTRIBUTORS` | Window totals on the analyzed ref |
| `INSERTIONS` / `DELETIONS` / `NET_LOC` | Raw LOC |
| `LOGICAL_SLOC_ADDED` | Non-blank, non-comment added lines — the primary code-volume metric |
| `TEST_INSERTIONS` / `TEST_RATIO` | Test LOC (test/spec paths + .test./.spec. suffixes) and its share of insertions |
| `WEIGHTED_COMMITS` | Commits × files-touched, capped at 20 per commit |
| `ACTIVE_DAYS` | Distinct local dates with commits |
| `SESSIONS` / `DEEP_SESSIONS` / `MEDIUM_SESSIONS` / `MICRO_SESSIONS` | 45-minute-gap session detection: deep 50+ min, medium 20-50, micro <20 |
| `TOTAL_ACTIVE_MINUTES` / `AVG_SESSION_MINUTES` / `LOC_PER_SESSION_HOUR` | Session time aggregates (LOC/hour pre-rounded to nearest 50) |
| `COMMIT_TYPES` / `FIX_RATIO` | Conventional-commit prefix mix |
| `COMMIT_SIZE_BUCKETS` | small <100 / medium 100-500 / large 500-1500 / xl 1500+ LOC per commit |
| `HOURS` / `PEAK_HOUR` | Hourly commit histogram (local time), nonzero hours only |
| `FOCUS_SCORE` | % of file changes in the single busiest top-level directory |
| `BIGGEST_COMMIT` | Highest-LOC commit in the window (ship-of-the-week candidate) |
| `HOTSPOT: count file` | Top 10 most-changed files |
| `AUTHOR: name\|commits\|ins\|del\|test_ratio\|top_areas\|types\|peak_hour` | Per-contributor rollup, sorted by commits desc |
| `AUTHOR_BIGGEST: name\|hash\|loc\|subject` | Each contributor's biggest ship |
| `COAUTHOR: hash\|name` / `AI_ASSISTED_COMMITS` | Human co-author credit lines; count of commits with AI trailers |
| `WEEK: wN\|commits\|ins\|del\|test_ratio` | Weekly buckets, w0 = newest (for Step 10 trends) |
| `PR_REFS` / `PRS_REFERENCED` | PR/MR numbers from commit subjects (GitHub #NNN, GitLab !NNN) |
| `TEST_FILES_TOTAL` / `TEST_FILES_CHANGED` / `REGRESSION_TEST_COMMITS` / `REGRESSION_COMMIT` | Test health: repo-wide test file count, test files changed in window, `test(qa):` / `test(design):` / `test: coverage` commits |
| `VERSION_RANGE` | First → last VERSION file value in the window (when tracked) |
| `TEAM_STREAK` / `USER_STREAK` | Consecutive commit days with anchor date (Step 11) |
| `RETRO_CONTEXT` / `GREPTILE_HISTORY` / `TODOS_FILE` / `SKILL_USAGE_LOG` / `EUREKA_LOG` | Presence of optional inputs — Read the ones marked present |
```bash
# 1. All commits in window with timestamps, subject, hash, AUTHOR, files changed, insertions, deletions
git log origin/<default> --since="<window>" --format="%H|%aN|%ae|%ai|%s" --shortstat
**Optional inputs** (Read each file the script marks `present`):
# 2. Per-commit test vs total LOC breakdown with author
# Each commit block starts with COMMIT:<hash>|<author>, followed by numstat lines.
# Separate test files (matching test/|spec/|__tests__/) from production files.
git log origin/<default> --since="<window>" --format="COMMIT:%H|%aN" --numstat
# 3. Commit timestamps for session detection and hourly distribution (with author)
git log origin/<default> --since="<window>" --format="%at|%aN|%ai|%s" | sort -n
# 4. Files most frequently changed (hotspot analysis)
git log origin/<default> --since="<window>" --format="" --name-only | grep -v '^$' | sort | uniq -c | sort -rn
# 5. PR/MR numbers from commit messages (GitHub #NNN, GitLab !NNN)
git log origin/<default> --since="<window>" --format="%s" | grep -oE '[#!][0-9]+' | sort -t'#' -k1 | uniq
# 6. Per-author file hotspots (who touches what)
git log origin/<default> --since="<window>" --format="AUTHOR:%aN" --name-only
# 7. Per-author commit counts (quick summary)
git shortlog origin/<default> --since="<window>" -sn --no-merges
# 8. Greptile triage history (if available)
cat ~/.gstack/greptile-history.md 2>/dev/null || true
# 9. TODOS.md backlog (if available)
cat TODOS.md 2>/dev/null || true
# 10. Test file count
git ls-files 2>/dev/null | grep -E '(\.test\.|\.spec\.|_test\.|_spec\.)' | wc -l
# 11. Regression test commits in window
git log origin/<default> --since="<window>" --oneline --grep="test(qa):" --grep="test(design):" --grep="test: coverage"
# 12. gstack skill usage telemetry (if available)
cat ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
# 12. Test files changed in window
git log origin/<default> --since="<window>" --format="" --name-only | grep -E '\.(test|spec)\.' | sort -u | wc -l
```
- `RETRO_CONTEXT: present` → Read `~/.gstack/retro-context.md`. It is user-authored and may contain meeting notes, calendar events, decisions, and other context that doesn't appear in git history. Incorporate it into the retro narrative where relevant.
- `GREPTILE_HISTORY: present` → Read `~/.gstack/greptile-history.md`. Filter entries to the retro window by date. Count by type: `fix`, `fp`, `already-fixed`. Signal ratio = `(fix + already-fixed) / (fix + already-fixed + fp)`. Skip unparseable lines silently; if no entries fall in the window, skip the Greptile metric row.
- `TODOS_FILE: present` → Read `TODOS.md`. Compute: total open TODOs (exclude the `## Completed` section), P0/P1 count, P2 count, items completed this period (Completed entries dated within the window), items added this period (cross-reference `COMMIT:` lines that touched TODOS.md).
- `SKILL_USAGE_LOG: present` → Read `~/.gstack/analytics/skill-usage.jsonl`. Filter to the window by `ts`. Separate skill activations (no `event` field) from hook fires (`event: "hook_fire"`). Aggregate by skill name.
- `EUREKA_LOG: present` → Read `~/.gstack/analytics/eureka.jsonl`. Filter to the window by `ts`. For each eureka moment note the skill that flagged it, the branch, and a one-line summary of the insight.
### Step 2: Compute Metrics
Calculate and present these metrics in a summary table:
Present these metrics in a summary table, straight from the metric lines:
| Metric | Value |
|--------|-------|
| **Features shipped** (from CHANGELOG + merged PR titles) | N |
| Commits to main | N |
| Weighted commits (commits × avg files-touched, capped at 20 per commit) | N |
| Weighted commits (`WEIGHTED_COMMITS`) | N |
| Contributors | N |
| PRs merged | N |
| **Logical SLOC added** (non-blank, non-comment — primary code-volume metric) | N |
| **Logical SLOC added** (`LOGICAL_SLOC_ADDED` — primary code-volume metric) | N |
| Raw LOC: insertions | N |
| Raw LOC: deletions | N |
| Raw LOC: net | N |
@@ -681,7 +637,7 @@ new functionality. Raw LOC is demoted to context because AI inflates it; ten
lines of a good fix is not less shipping than ten thousand lines of scaffold.
See docs/designs/PLAN_TUNING_V1.md §Workstream C.
Then show a **per-author leaderboard** immediately below:
Then show a **per-author leaderboard** immediately below, from the `AUTHOR:` lines:
```
Contributor Commits +/- Top area
@@ -690,49 +646,25 @@ alice 12 +800/-150 app/services/
bob 3 +120/-40 tests/
```
Sort by commits descending. The current user (from `git config user.name`) always appears first, labeled "You (name)".
Sort by commits descending. The current user (`USER_NAME`) always appears first, labeled "You (name)".
**Greptile signal (if history exists):** Read `~/.gstack/greptile-history.md` (fetched in Step 1, command 8). Filter entries within the retro time window by date. Count entries by type: `fix`, `fp`, `already-fixed`. Compute signal ratio: `(fix + already-fixed) / (fix + already-fixed + fp)`. If no entries exist in the window or the file doesn't exist, skip the Greptile metric row. Skip unparseable lines silently.
Conditional rows (skip each when its input is absent or empty in the window):
**Backlog Health (if TODOS.md exists):** Read `TODOS.md` (fetched in Step 1, command 9). Compute:
- Total open TODOs (exclude items in `## Completed` section)
- P0/P1 count (critical/urgent items)
- P2 count (important items)
- Items completed this period (items in Completed section with dates within the retro window)
- Items added this period (cross-reference git log for commits that modified TODOS.md within the window)
Include in the metrics table:
```
| Backlog Health | N open (X P0/P1, Y P2) · Z completed this period |
```
If TODOS.md doesn't exist, skip the Backlog Health row.
**Skill Usage (if analytics exist):** Read `~/.gstack/analytics/skill-usage.jsonl` if it exists. Filter entries within the retro time window by `ts` field. Separate skill activations (no `event` field) from hook fires (`event: "hook_fire"`). Aggregate by skill name. Present as:
```
| Skill Usage | /ship(12) /qa(8) /review(5) · 3 safety hook fires |
```
If the JSONL file doesn't exist or has no entries in the window, skip the Skill Usage row.
**Eureka Moments (if logged):** Read `~/.gstack/analytics/eureka.jsonl` if it exists. Filter entries within the retro time window by `ts` field. For each eureka moment, show the skill that flagged it, the branch, and a one-line summary of the insight. Present as:
```
| Eureka Moments | 2 this period |
```
If moments exist, list them:
If eureka moments exist, list them:
```
EUREKA /office-hours (branch: garrytan/auth-rethink): "Session tokens don't need server storage — browser crypto API makes client-side JWT validation viable"
EUREKA /plan-eng-review (branch: garrytan/cache-layer): "Redis isn't needed here — Bun's built-in LRU cache handles this workload"
```
If the JSONL file doesn't exist or has no entries in the window, skip the Eureka Moments row.
### Step 3: Commit Time Distribution
Show hourly histogram in local time using bar chart:
Render the `HOURS` line as an hourly histogram in local time:
```
Hour Commits ████████████████
@@ -749,24 +681,14 @@ Identify and call out:
### Step 4: Work Session Detection
Detect sessions using **45-minute gap** threshold between consecutive commits. For each session report:
- Start/end time (Pacific)
- Number of commits
- Duration in minutes
Classify sessions:
- **Deep sessions** (50+ min)
- **Medium sessions** (20-50 min)
- **Micro sessions** (<20 min, typically single-commit fire-and-forget)
Calculate:
- Total active coding time (sum of session durations)
- Average session length
- LOC per hour of active time
Sessions are pre-computed with a **45-minute gap** threshold between consecutive commits (`SESSIONS`, `DEEP_SESSIONS` 50+ min, `MEDIUM_SESSIONS` 20-50 min, `MICRO_SESSIONS` <20 min — typically single-commit fire-and-forget). Report:
- Session count and the deep/medium/micro split
- Total active coding time (`TOTAL_ACTIVE_MINUTES`) and average session length
- LOC per hour of active time (`LOC_PER_SESSION_HOUR`)
### Step 5: Commit Type Breakdown
Categorize by conventional commit prefix (feat/fix/refactor/test/chore/docs). Show as percentage bar:
Render `COMMIT_TYPES` (feat/fix/refactor/test/chore/docs) as a percentage bar:
```
feat: 20 (40%) ████████████████████
@@ -774,18 +696,18 @@ fix: 27 (54%) ███████████████████
refactor: 2 ( 4%) ██
```
Flag if fix ratio exceeds 50% — this signals a "ship fast, fix fast" pattern that may indicate review gaps.
Flag if `FIX_RATIO` exceeds 50% — this signals a "ship fast, fix fast" pattern that may indicate review gaps.
### Step 6: Hotspot Analysis
Show top 10 most-changed files. Flag:
Show the `HOTSPOT` lines (top 10 most-changed files). Flag:
- Files changed 5+ times (churn hotspots)
- Test files vs production files in the hotspot list
- VERSION/CHANGELOG frequency (version discipline indicator)
### Step 7: PR Size Distribution
From commit diffs, estimate PR sizes and bucket them:
Report `COMMIT_SIZE_BUCKETS`:
- **Small** (<100 LOC)
- **Medium** (100-500 LOC)
- **Large** (500-1500 LOC)
@@ -793,23 +715,16 @@ From commit diffs, estimate PR sizes and bucket them:
### Step 8: Focus Score + Ship of the Week
**Focus score:** Calculate the percentage of commits touching the single most-changed top-level directory (e.g., `app/services/`, `app/views/`). Higher score = deeper focused work. Lower score = scattered context-switching. Report as: "Focus score: 62% (app/services/)"
**Focus score:** `FOCUS_SCORE` is the percentage of file changes touching the single most-changed top-level directory (e.g., `app/services/`). Higher score = deeper focused work. Lower score = scattered context-switching. Report as: "Focus score: 62% (app/services/)"
**Ship of the week:** Auto-identify the single highest-LOC PR in the window. Highlight it:
- PR number and title
**Ship of the week:** `BIGGEST_COMMIT` is the highest-LOC change in the window. Highlight it:
- PR number (match against `PR_REFS` / the subject) and title
- LOC changed
- Why it matters (infer from commit messages and files touched)
### Step 9: Team Member Analysis
For each contributor (including the current user), compute:
1. **Commits and LOC** — total commits, insertions, deletions, net LOC
2. **Areas of focus** — which directories/files they touched most (top 3)
3. **Commit type mix** — their personal feat/fix/refactor/test breakdown
4. **Session patterns** — when they code (their peak hours), session count
5. **Test discipline** — their personal test LOC ratio
6. **Biggest ship** — their single highest-impact commit or PR in the window
For each contributor (including the current user), the `AUTHOR:` line carries commits, insertions, deletions, test ratio, top areas, commit type mix, and peak hour; `AUTHOR_BIGGEST:` carries their single highest-impact commit. Use the `COMMIT:` lines to anchor everything in actual work.
**For the current user ("You"):** This section gets the deepest treatment. Include all the detail from the solo retro — session analysis, time patterns, focus score. Frame it in first person: "Your peak hours...", "Your biggest ship..."
@@ -820,7 +735,7 @@ For each contributor (including the current user), compute:
**If only one contributor (solo repo):** Skip the team breakdown and proceed as before — the retro is personal.
**If there are Co-Authored-By trailers:** Parse `Co-Authored-By:` lines in commit messages. Credit those authors for the commit alongside the primary author. Note AI co-authors (e.g., `noreply@anthropic.com`) but do not include them as team members — instead, track "AI-assisted commits" as a separate metric.
**Co-author credit:** `COAUTHOR:` lines carry human `Co-Authored-By:` trailers — credit those authors for the commit alongside the primary author. AI co-authors (e.g., `noreply@anthropic.com`) are counted in `AI_ASSISTED_COMMITS` instead track "AI-assisted commits" as a separate metric, never as a team member.
## Capture Learnings
@@ -851,28 +766,17 @@ already knows. A good test: would this insight save time in a future session? If
### Step 10: Week-over-Week Trends (if window >= 14d)
If the time window is 14 days or more, split into weekly buckets and show trends:
- Commits per week (total and per-author)
If the time window is 14 days or more, use the `WEEK:` lines (w0 = the week containing the newest commit) to show trends:
- Commits per week (total; per-author from the `COMMIT:` lines)
- LOC per week
- Test ratio per week
- Fix ratio per week
- Session count per week
### Step 11: Streak Tracking
Count consecutive days with at least 1 commit to origin/<default>, going back from today. Track both team streak and personal streak:
```bash
# Team streak: all unique commit dates (local time) — no hard cutoff
git log origin/<default> --format="%ad" --date=format:"%Y-%m-%d" | sort -u
# Personal streak: only the current user's commits
git log origin/<default> --author="<user_name>" --format="%ad" --date=format:"%Y-%m-%d" | sort -u
```
Count backward from today — how many consecutive days have at least one commit? This queries the full history so streaks of any length are reported accurately. Display both:
- "Team shipping streak: 47 consecutive days"
- "Your shipping streak: 32 consecutive days"
`TEAM_STREAK` and `USER_STREAK` count consecutive days with at least 1 commit (full history, no cutoff), anchored at the **newest commit date** — not at today, because the script never trusts the system clock. Interpret against today from the session reminder:
- If the anchor date is today or yesterday, the streak is live: "Team shipping streak: 47 consecutive days" / "Your shipping streak: 32 consecutive days"
- If the anchor is older, the streak is broken: report 0 days and note the last shipping day.
### Step 12: Load History & Compare
@@ -954,7 +858,7 @@ Use the Write tool to save the JSON file with this schema:
}
```
**Note:** Only include the `greptile` field if `~/.gstack/greptile-history.md` exists and has entries within the time window. Only include the `backlog` field if `TODOS.md` exists. Only include the `test_health` field if test files were found (command 10 returns > 0). If any has no data, omit the field entirely.
**Note:** Only include the `greptile` field if `~/.gstack/greptile-history.md` exists and has entries within the time window. Only include the `backlog` field if `TODOS.md` exists. Only include the `test_health` field if test files were found (`TEST_FILES_TOTAL` > 0). If any has no data, omit the field entirely.
Include test health data in the JSON when test files exist:
```json
@@ -979,124 +883,8 @@ Include backlog data in the JSON when TODOS.md exists:
### Step 14: Write the Narrative
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 11, 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 (from command 10)
- Tests added this period: M (from command 12 — test files changed)
- Regression test commits: list `test(qa):` and `test(design):` and `test: coverage` commits from command 11
- 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)
> **STOP.** Before writing the retrospective narrative (Step 14, after all metrics are computed and compared), Read `~/.claude/skills/gstack/retro/sections/report-format.md` and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
---
@@ -1393,8 +1181,8 @@ Use the Write tool to save JSON to `~/.gstack/retros/global-${today}-${next}.jso
When the user runs `/retro compare` (or `/retro compare 14d`):
1. Compute metrics for the current window (default 7d) using the midnight-aligned start date (same logic as the main retro — e.g., if today is 2026-03-18 and window is 7d, use `--since="2026-03-11T00:00:00"`)
2. Compute metrics for the immediately prior same-length window using both `--since` and `--until` with midnight-aligned dates to avoid overlap (e.g., for a 7d window starting 2026-03-11: prior window is `--since="2026-03-04T00:00:00" --until="2026-03-11T00:00:00"`)
1. Run Steps 0.5-1 for the current window (default 7d) using the midnight-aligned start date (same logic as the main retro — e.g., if today is 2026-03-18 and window is 7d, `--since "2026-03-11T00:00:00"`)
2. Run `gstack-retro-metrics` a second time for the immediately prior same-length window, using both `--since` and `--until` with midnight-aligned dates to avoid overlap (e.g., for a 7d window starting 2026-03-11: `--since "2026-03-04T00:00:00" --until "2026-03-11T00:00:00"`)
3. Show a side-by-side comparison table with deltas and arrows
4. Write a brief narrative highlighting the biggest improvements and regressions
5. Save only the current-window snapshot to `.context/retros/` (same as a normal retro run); do **not** persist the prior-window metrics.
@@ -1415,10 +1203,10 @@ When the user runs `/retro compare` (or `/retro compare 14d`):
## Important Rules
- ALL narrative output goes directly to the user in the conversation. The ONLY file written is the `.context/retros/` JSON snapshot.
- Use `origin/<default>` for all git queries (not local main which may be stale)
- The metrics script analyzes `origin/<default>` (not local main which may be stale); when `RETRO_REF` says otherwise, disclose it
- Display all timestamps in the user's local timezone (do not override `TZ`)
- If the window has zero commits, say so and suggest a different window
- Round LOC/hour to nearest 50
- If `COMMITS: 0`, say so and suggest a different window
- Round LOC/hour to nearest 50 (the script pre-rounds `LOC_PER_SESSION_HOUR`)
- Treat merge commits as PR boundaries
- Do not read CLAUDE.md or other docs — this skill is self-contained
- On first run (no prior retros), skip comparison sections gracefully
+87 -307
View File
@@ -65,11 +65,13 @@ When the user types `/retro`, run this skill.
{{GBRAIN_CONTEXT_LOAD}}
{{SECTION_INDEX:retro}}
## Instructions
Parse the argument to determine the time window. Default to 7 days if no argument given. All times should be reported in the user's **local timezone** (use the system default — do NOT set `TZ`).
**Midnight-aligned windows:** For day (`d`) and week (`w`) units, compute an absolute start date at local midnight, not a relative string. For example, if today is 2026-03-18 and the window is 7 days: the start date is 2026-03-11. Use `--since="2026-03-11T00:00:00"` for git log queries — the explicit `T00:00:00` suffix ensures git starts from midnight. Without it, git uses the current wall-clock time (e.g., `--since="2026-03-11"` at 11pm means 11pm, not midnight). For week units, multiply by 7 to get days (e.g., `2w` = 14 days back). For hour (`h`) units, use `--since="N hours ago"` since midnight alignment does not apply to sub-day windows.
**Midnight-aligned windows:** For day (`d`) and week (`w`) units, compute an absolute start date at local midnight, not a relative string. For example, if today is 2026-03-18 and the window is 7 days: the start date is 2026-03-11. Use `--since "2026-03-11T00:00:00"` — the explicit `T00:00:00` suffix ensures git starts from midnight. Without it, git uses the current wall-clock time (e.g., `--since "2026-03-11"` at 11pm means 11pm, not midnight). For week units, multiply by 7 to get days (e.g., `2w` = 14 days back). For hour (`h`) units, use `--since "N hours ago"` since midnight alignment does not apply to sub-day windows. Compute "today" from the user-visible `## currentDate` tag in the session reminder — NEVER from `date` (the system clock can be hours off in containerized harnesses). If you cannot reliably compute "today", stop and ask the user via AskUserQuestion rather than proceeding.
**Argument validation:** If the argument doesn't match a number followed by `d`, `h`, or `w`, the word `compare` (optionally followed by a window), or the word `global` (optionally followed by a window), show this usage and stop:
```
@@ -88,142 +90,89 @@ Usage: /retro [window | compare | global]
{{LEARNINGS_SEARCH}}
### Non-git context (optional)
### Step 0.5: Freshness pre-flight (fetch)
Check for non-git context that should be included in the retro:
Refresh `origin/<default>` so the retro doesn't misreport against a stale local ref. If the repo has no `origin` remote this fails harmlessly — the metrics script (Step 1) falls back to the local branch and its guard lines disclose it:
```bash
[ -f ~/.gstack/retro-context.md ] && echo "RETRO_CONTEXT_FOUND" || echo "NO_RETRO_CONTEXT"
git fetch origin <default> --quiet 2>/dev/null \
|| echo "RETRO_FETCH: failed (offline or no remote) — proceeding against last-known refs"
```
If `RETRO_CONTEXT_FOUND`: read `~/.gstack/retro-context.md`. This file is user-authored and may contain meeting notes, calendar events, decisions, and other context that doesn't appear in git history. Incorporate this context into the retro narrative where relevant.
Remember whether the fetch succeeded — the stale-base guard in Step 1 only BLOCKs when it did.
### Step 0.5: Stale-base + bad-today-anchor pre-flight guard
### Step 1: Gather Metrics (one command)
The retro skill computes a window from "today" and queries `git log --since=<window> origin/<default>`. If "today" drifts (model session-context error) or the local worktree's `origin/<default>` is materially behind the actual remote, the window can return zero or near-zero commits and the retro will fabricate a coherent-looking narrative from nothing. This guard prevents silent confidently-wrong output.
Run the pre-flight in this exact order. The first branch that matches wins:
All raw data gathering and metric computation runs through `gstack-retro-metrics` — one command instead of a dozen git pipelines. Substitute the base branch detected in Step 0 and the midnight-aligned start computed above:
```bash
# Pre-check A: no remote configured?
_RETRO_HAS_REMOTE=$(git remote 2>/dev/null | grep -c '^origin$' || echo 0)
if [ "$_RETRO_HAS_REMOTE" = "0" ]; then
echo "RETRO_GUARD: no 'origin' remote, base freshness not verified — proceeding"
_RETRO_GUARD_VERDICT="skip-no-remote"
fi
# Pre-check B: detached HEAD or no current base?
if [ -z "$_RETRO_GUARD_VERDICT" ]; then
_RETRO_HEAD_REF=$(git symbolic-ref --quiet HEAD 2>/dev/null || echo "")
if [ -z "$_RETRO_HEAD_REF" ]; then
echo "RETRO_GUARD: detached HEAD, base freshness not verified — proceeding"
_RETRO_GUARD_VERDICT="skip-detached"
fi
fi
# Pre-check C: fetch origin <default>; if it fails, warn but proceed.
if [ -z "$_RETRO_GUARD_VERDICT" ]; then
if ! git fetch origin <default> --quiet 2>/dev/null; then
echo "RETRO_GUARD: 'git fetch origin <default>' failed (offline?) — proceeding against last-known origin/<default>"
_RETRO_GUARD_VERDICT="warn-fetch-failed"
fi
fi
# Pre-check D: BLOCK only when fetch succeeded AND the latest origin/<default>
# commit predates the retro window. Today's date should be loaded from the
# user-visible "## currentDate" tag in the session reminder; if the gap between
# origin/<default>'s newest commit and today exceeds the window, the model's
# "today" is almost certainly stale (or the worktree is wildly behind).
if [ -z "$_RETRO_GUARD_VERDICT" ]; then
_RETRO_LATEST_ISO=$(git log -1 --format=%ci origin/<default> 2>/dev/null | awk '{print $1}')
if [ -n "$_RETRO_LATEST_ISO" ]; then
# The model computes today from the session reminder (NEVER from `date` —
# the system clock can be hours off in containerized harnesses).
# Compute window in DAYS (default 7): if today - latest-commit-date > window-days,
# BLOCK. If the model cannot reliably compute "today", it MUST stop here and
# ask the user via AskUserQuestion rather than proceeding.
echo "RETRO_GUARD: latest origin/<default> commit on $_RETRO_LATEST_ISO"
_RETRO_GUARD_VERDICT="check-gap"
fi
fi
_RM="$HOME/.claude/skills/gstack/bin/gstack-retro-metrics"
[ -x "$_RM" ] || _RM=".claude/skills/gstack/bin/gstack-retro-metrics"
"$_RM" --base "<default>" --since "<since>" \
|| echo "RETRO_METRICS: unavailable — stale install (compute metrics manually from the steps below)"
```
After running the bash block, the model evaluates `RETRO_GUARD: latest origin/<default> commit on <DATE>` against today and the window:
Read the labeled `METRIC_NAME: value` lines — they feed every step below. **Degraded mode:** if `RETRO_METRICS_PROTO: 1` is missing from the output, the install is stale; compute each metric manually with git commands, using the metric definitions in Steps 2-11 as the spec.
- If the **latest-commit date is older than (today window-days)**, BLOCK with: "Retro window is stale. Latest commit on `origin/<default>` was `<DATE>`, but the window covers `<since>` to `<today>`. This usually means either (a) today's date is wrong in this session or (b) `origin/<default>` is materially behind the remote. Confirm today's date via the session reminder; if today is correct, run `git fetch origin <default>` manually and re-run /retro." Stop the skill until the user resolves.
- Otherwise, write: "RETRO_GUARD: latest commit `<DATE>` within window — proceeding."
**Identity:** `USER_NAME` is **"you"** — the person reading this retro. All other authors are teammates. Orient the narrative around this: "your" commits vs teammate contributions.
Skip paths (`skip-no-remote`, `skip-detached`, `warn-fetch-failed`) all proceed to Step 1 with the cited reason on a single stderr line so the retro narrative carries the disclosure ("offline run, window not freshness-verified") rather than silently misreporting.
**Stale-base + bad-today-anchor guard.** The script echoes `GUARD_LATEST_COMMIT: <DATE>` (newest commit on the analyzed ref). If "today" drifts (model session-context error) or the local `origin/<default>` is materially behind the remote, the window returns zero or near-zero commits and the retro would fabricate a coherent-looking narrative from nothing. Evaluate in this order:
### Step 1: Gather Raw Data
1. If `GUARD_REMOTE: none` or `GUARD_HEAD: detached` or the Step 0.5 fetch failed: proceed, but carry the disclosure into the narrative ("offline run, window not freshness-verified") rather than silently misreporting.
2. If the Step 0.5 fetch succeeded AND the `GUARD_LATEST_COMMIT` date is **older than (today window-days)**: BLOCK with: "Retro window is stale. Latest commit on `origin/<default>` was `<DATE>`, but the window covers `<since>` to `<today>`. This usually means either (a) today's date is wrong in this session or (b) `origin/<default>` is materially behind the remote. Confirm today's date via the session reminder; if today is correct, run `git fetch origin <default>` manually and re-run /retro." Stop the skill until the user resolves.
3. Otherwise, write: "RETRO_GUARD: latest commit `<DATE>` within window — proceeding."
First, fetch origin and identify the current user:
```bash
git fetch origin <default> --quiet
# Identify who is running the retro
git config user.name
git config user.email
```
Also check `RETRO_REF`: if it is not `origin/<default>` (local-only repo, missing remote branch), disclose which ref the retro analyzed.
The name returned by `git config user.name` is **"you"** — the person reading this retro. All other authors are teammates. Use this to orient the narrative: "your" commits vs teammate contributions.
**Metric line reference** (what the script emits):
Run ALL of these git commands in parallel (they are independent):
| Line | Meaning |
|------|---------|
| `COMMIT: hash\|author\|datetime\|+ins/-del\|subject` | One per commit, newest first (capped at 300) — the raw material for narrative anchoring |
| `COMMITS` / `MERGE_COMMITS` / `CONTRIBUTORS` | Window totals on the analyzed ref |
| `INSERTIONS` / `DELETIONS` / `NET_LOC` | Raw LOC |
| `LOGICAL_SLOC_ADDED` | Non-blank, non-comment added lines — the primary code-volume metric |
| `TEST_INSERTIONS` / `TEST_RATIO` | Test LOC (test/spec paths + .test./.spec. suffixes) and its share of insertions |
| `WEIGHTED_COMMITS` | Commits × files-touched, capped at 20 per commit |
| `ACTIVE_DAYS` | Distinct local dates with commits |
| `SESSIONS` / `DEEP_SESSIONS` / `MEDIUM_SESSIONS` / `MICRO_SESSIONS` | 45-minute-gap session detection: deep 50+ min, medium 20-50, micro <20 |
| `TOTAL_ACTIVE_MINUTES` / `AVG_SESSION_MINUTES` / `LOC_PER_SESSION_HOUR` | Session time aggregates (LOC/hour pre-rounded to nearest 50) |
| `COMMIT_TYPES` / `FIX_RATIO` | Conventional-commit prefix mix |
| `COMMIT_SIZE_BUCKETS` | small <100 / medium 100-500 / large 500-1500 / xl 1500+ LOC per commit |
| `HOURS` / `PEAK_HOUR` | Hourly commit histogram (local time), nonzero hours only |
| `FOCUS_SCORE` | % of file changes in the single busiest top-level directory |
| `BIGGEST_COMMIT` | Highest-LOC commit in the window (ship-of-the-week candidate) |
| `HOTSPOT: count file` | Top 10 most-changed files |
| `AUTHOR: name\|commits\|ins\|del\|test_ratio\|top_areas\|types\|peak_hour` | Per-contributor rollup, sorted by commits desc |
| `AUTHOR_BIGGEST: name\|hash\|loc\|subject` | Each contributor's biggest ship |
| `COAUTHOR: hash\|name` / `AI_ASSISTED_COMMITS` | Human co-author credit lines; count of commits with AI trailers |
| `WEEK: wN\|commits\|ins\|del\|test_ratio` | Weekly buckets, w0 = newest (for Step 10 trends) |
| `PR_REFS` / `PRS_REFERENCED` | PR/MR numbers from commit subjects (GitHub #NNN, GitLab !NNN) |
| `TEST_FILES_TOTAL` / `TEST_FILES_CHANGED` / `REGRESSION_TEST_COMMITS` / `REGRESSION_COMMIT` | Test health: repo-wide test file count, test files changed in window, `test(qa):` / `test(design):` / `test: coverage` commits |
| `VERSION_RANGE` | First → last VERSION file value in the window (when tracked) |
| `TEAM_STREAK` / `USER_STREAK` | Consecutive commit days with anchor date (Step 11) |
| `RETRO_CONTEXT` / `GREPTILE_HISTORY` / `TODOS_FILE` / `SKILL_USAGE_LOG` / `EUREKA_LOG` | Presence of optional inputs — Read the ones marked present |
```bash
# 1. All commits in window with timestamps, subject, hash, AUTHOR, files changed, insertions, deletions
git log origin/<default> --since="<window>" --format="%H|%aN|%ae|%ai|%s" --shortstat
**Optional inputs** (Read each file the script marks `present`):
# 2. Per-commit test vs total LOC breakdown with author
# Each commit block starts with COMMIT:<hash>|<author>, followed by numstat lines.
# Separate test files (matching test/|spec/|__tests__/) from production files.
git log origin/<default> --since="<window>" --format="COMMIT:%H|%aN" --numstat
# 3. Commit timestamps for session detection and hourly distribution (with author)
git log origin/<default> --since="<window>" --format="%at|%aN|%ai|%s" | sort -n
# 4. Files most frequently changed (hotspot analysis)
git log origin/<default> --since="<window>" --format="" --name-only | grep -v '^$' | sort | uniq -c | sort -rn
# 5. PR/MR numbers from commit messages (GitHub #NNN, GitLab !NNN)
git log origin/<default> --since="<window>" --format="%s" | grep -oE '[#!][0-9]+' | sort -t'#' -k1 | uniq
# 6. Per-author file hotspots (who touches what)
git log origin/<default> --since="<window>" --format="AUTHOR:%aN" --name-only
# 7. Per-author commit counts (quick summary)
git shortlog origin/<default> --since="<window>" -sn --no-merges
# 8. Greptile triage history (if available)
cat ~/.gstack/greptile-history.md 2>/dev/null || true
# 9. TODOS.md backlog (if available)
cat TODOS.md 2>/dev/null || true
# 10. Test file count
git ls-files 2>/dev/null | grep -E '(\.test\.|\.spec\.|_test\.|_spec\.)' | wc -l
# 11. Regression test commits in window
git log origin/<default> --since="<window>" --oneline --grep="test(qa):" --grep="test(design):" --grep="test: coverage"
# 12. gstack skill usage telemetry (if available)
cat ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
# 12. Test files changed in window
git log origin/<default> --since="<window>" --format="" --name-only | grep -E '\.(test|spec)\.' | sort -u | wc -l
```
- `RETRO_CONTEXT: present` → Read `~/.gstack/retro-context.md`. It is user-authored and may contain meeting notes, calendar events, decisions, and other context that doesn't appear in git history. Incorporate it into the retro narrative where relevant.
- `GREPTILE_HISTORY: present` → Read `~/.gstack/greptile-history.md`. Filter entries to the retro window by date. Count by type: `fix`, `fp`, `already-fixed`. Signal ratio = `(fix + already-fixed) / (fix + already-fixed + fp)`. Skip unparseable lines silently; if no entries fall in the window, skip the Greptile metric row.
- `TODOS_FILE: present` → Read `TODOS.md`. Compute: total open TODOs (exclude the `## Completed` section), P0/P1 count, P2 count, items completed this period (Completed entries dated within the window), items added this period (cross-reference `COMMIT:` lines that touched TODOS.md).
- `SKILL_USAGE_LOG: present` → Read `~/.gstack/analytics/skill-usage.jsonl`. Filter to the window by `ts`. Separate skill activations (no `event` field) from hook fires (`event: "hook_fire"`). Aggregate by skill name.
- `EUREKA_LOG: present` → Read `~/.gstack/analytics/eureka.jsonl`. Filter to the window by `ts`. For each eureka moment note the skill that flagged it, the branch, and a one-line summary of the insight.
### Step 2: Compute Metrics
Calculate and present these metrics in a summary table:
Present these metrics in a summary table, straight from the metric lines:
| Metric | Value |
|--------|-------|
| **Features shipped** (from CHANGELOG + merged PR titles) | N |
| Commits to main | N |
| Weighted commits (commits × avg files-touched, capped at 20 per commit) | N |
| Weighted commits (`WEIGHTED_COMMITS`) | N |
| Contributors | N |
| PRs merged | N |
| **Logical SLOC added** (non-blank, non-comment — primary code-volume metric) | N |
| **Logical SLOC added** (`LOGICAL_SLOC_ADDED` — primary code-volume metric) | N |
| Raw LOC: insertions | N |
| Raw LOC: deletions | N |
| Raw LOC: net | N |
@@ -242,7 +191,7 @@ new functionality. Raw LOC is demoted to context because AI inflates it; ten
lines of a good fix is not less shipping than ten thousand lines of scaffold.
See docs/designs/PLAN_TUNING_V1.md §Workstream C.
Then show a **per-author leaderboard** immediately below:
Then show a **per-author leaderboard** immediately below, from the `AUTHOR:` lines:
```
Contributor Commits +/- Top area
@@ -251,49 +200,25 @@ alice 12 +800/-150 app/services/
bob 3 +120/-40 tests/
```
Sort by commits descending. The current user (from `git config user.name`) always appears first, labeled "You (name)".
Sort by commits descending. The current user (`USER_NAME`) always appears first, labeled "You (name)".
**Greptile signal (if history exists):** Read `~/.gstack/greptile-history.md` (fetched in Step 1, command 8). Filter entries within the retro time window by date. Count entries by type: `fix`, `fp`, `already-fixed`. Compute signal ratio: `(fix + already-fixed) / (fix + already-fixed + fp)`. If no entries exist in the window or the file doesn't exist, skip the Greptile metric row. Skip unparseable lines silently.
Conditional rows (skip each when its input is absent or empty in the window):
**Backlog Health (if TODOS.md exists):** Read `TODOS.md` (fetched in Step 1, command 9). Compute:
- Total open TODOs (exclude items in `## Completed` section)
- P0/P1 count (critical/urgent items)
- P2 count (important items)
- Items completed this period (items in Completed section with dates within the retro window)
- Items added this period (cross-reference git log for commits that modified TODOS.md within the window)
Include in the metrics table:
```
| Backlog Health | N open (X P0/P1, Y P2) · Z completed this period |
```
If TODOS.md doesn't exist, skip the Backlog Health row.
**Skill Usage (if analytics exist):** Read `~/.gstack/analytics/skill-usage.jsonl` if it exists. Filter entries within the retro time window by `ts` field. Separate skill activations (no `event` field) from hook fires (`event: "hook_fire"`). Aggregate by skill name. Present as:
```
| Skill Usage | /ship(12) /qa(8) /review(5) · 3 safety hook fires |
```
If the JSONL file doesn't exist or has no entries in the window, skip the Skill Usage row.
**Eureka Moments (if logged):** Read `~/.gstack/analytics/eureka.jsonl` if it exists. Filter entries within the retro time window by `ts` field. For each eureka moment, show the skill that flagged it, the branch, and a one-line summary of the insight. Present as:
```
| Eureka Moments | 2 this period |
```
If moments exist, list them:
If eureka moments exist, list them:
```
EUREKA /office-hours (branch: garrytan/auth-rethink): "Session tokens don't need server storage — browser crypto API makes client-side JWT validation viable"
EUREKA /plan-eng-review (branch: garrytan/cache-layer): "Redis isn't needed here — Bun's built-in LRU cache handles this workload"
```
If the JSONL file doesn't exist or has no entries in the window, skip the Eureka Moments row.
### Step 3: Commit Time Distribution
Show hourly histogram in local time using bar chart:
Render the `HOURS` line as an hourly histogram in local time:
```
Hour Commits ████████████████
@@ -310,24 +235,14 @@ Identify and call out:
### Step 4: Work Session Detection
Detect sessions using **45-minute gap** threshold between consecutive commits. For each session report:
- Start/end time (Pacific)
- Number of commits
- Duration in minutes
Classify sessions:
- **Deep sessions** (50+ min)
- **Medium sessions** (20-50 min)
- **Micro sessions** (<20 min, typically single-commit fire-and-forget)
Calculate:
- Total active coding time (sum of session durations)
- Average session length
- LOC per hour of active time
Sessions are pre-computed with a **45-minute gap** threshold between consecutive commits (`SESSIONS`, `DEEP_SESSIONS` 50+ min, `MEDIUM_SESSIONS` 20-50 min, `MICRO_SESSIONS` <20 min — typically single-commit fire-and-forget). Report:
- Session count and the deep/medium/micro split
- Total active coding time (`TOTAL_ACTIVE_MINUTES`) and average session length
- LOC per hour of active time (`LOC_PER_SESSION_HOUR`)
### Step 5: Commit Type Breakdown
Categorize by conventional commit prefix (feat/fix/refactor/test/chore/docs). Show as percentage bar:
Render `COMMIT_TYPES` (feat/fix/refactor/test/chore/docs) as a percentage bar:
```
feat: 20 (40%) ████████████████████
@@ -335,18 +250,18 @@ fix: 27 (54%) ███████████████████
refactor: 2 ( 4%) ██
```
Flag if fix ratio exceeds 50% — this signals a "ship fast, fix fast" pattern that may indicate review gaps.
Flag if `FIX_RATIO` exceeds 50% — this signals a "ship fast, fix fast" pattern that may indicate review gaps.
### Step 6: Hotspot Analysis
Show top 10 most-changed files. Flag:
Show the `HOTSPOT` lines (top 10 most-changed files). Flag:
- Files changed 5+ times (churn hotspots)
- Test files vs production files in the hotspot list
- VERSION/CHANGELOG frequency (version discipline indicator)
### Step 7: PR Size Distribution
From commit diffs, estimate PR sizes and bucket them:
Report `COMMIT_SIZE_BUCKETS`:
- **Small** (<100 LOC)
- **Medium** (100-500 LOC)
- **Large** (500-1500 LOC)
@@ -354,23 +269,16 @@ From commit diffs, estimate PR sizes and bucket them:
### Step 8: Focus Score + Ship of the Week
**Focus score:** Calculate the percentage of commits touching the single most-changed top-level directory (e.g., `app/services/`, `app/views/`). Higher score = deeper focused work. Lower score = scattered context-switching. Report as: "Focus score: 62% (app/services/)"
**Focus score:** `FOCUS_SCORE` is the percentage of file changes touching the single most-changed top-level directory (e.g., `app/services/`). Higher score = deeper focused work. Lower score = scattered context-switching. Report as: "Focus score: 62% (app/services/)"
**Ship of the week:** Auto-identify the single highest-LOC PR in the window. Highlight it:
- PR number and title
**Ship of the week:** `BIGGEST_COMMIT` is the highest-LOC change in the window. Highlight it:
- PR number (match against `PR_REFS` / the subject) and title
- LOC changed
- Why it matters (infer from commit messages and files touched)
### Step 9: Team Member Analysis
For each contributor (including the current user), compute:
1. **Commits and LOC** — total commits, insertions, deletions, net LOC
2. **Areas of focus** — which directories/files they touched most (top 3)
3. **Commit type mix** — their personal feat/fix/refactor/test breakdown
4. **Session patterns** — when they code (their peak hours), session count
5. **Test discipline** — their personal test LOC ratio
6. **Biggest ship** — their single highest-impact commit or PR in the window
For each contributor (including the current user), the `AUTHOR:` line carries commits, insertions, deletions, test ratio, top areas, commit type mix, and peak hour; `AUTHOR_BIGGEST:` carries their single highest-impact commit. Use the `COMMIT:` lines to anchor everything in actual work.
**For the current user ("You"):** This section gets the deepest treatment. Include all the detail from the solo retro — session analysis, time patterns, focus score. Frame it in first person: "Your peak hours...", "Your biggest ship..."
@@ -381,7 +289,7 @@ For each contributor (including the current user), compute:
**If only one contributor (solo repo):** Skip the team breakdown and proceed as before — the retro is personal.
**If there are Co-Authored-By trailers:** Parse `Co-Authored-By:` lines in commit messages. Credit those authors for the commit alongside the primary author. Note AI co-authors (e.g., `noreply@anthropic.com`) but do not include them as team members — instead, track "AI-assisted commits" as a separate metric.
**Co-author credit:** `COAUTHOR:` lines carry human `Co-Authored-By:` trailers — credit those authors for the commit alongside the primary author. AI co-authors (e.g., `noreply@anthropic.com`) are counted in `AI_ASSISTED_COMMITS` instead track "AI-assisted commits" as a separate metric, never as a team member.
{{LEARNINGS_LOG}}
@@ -389,28 +297,17 @@ For each contributor (including the current user), compute:
### Step 10: Week-over-Week Trends (if window >= 14d)
If the time window is 14 days or more, split into weekly buckets and show trends:
- Commits per week (total and per-author)
If the time window is 14 days or more, use the `WEEK:` lines (w0 = the week containing the newest commit) to show trends:
- Commits per week (total; per-author from the `COMMIT:` lines)
- LOC per week
- Test ratio per week
- Fix ratio per week
- Session count per week
### Step 11: Streak Tracking
Count consecutive days with at least 1 commit to origin/<default>, going back from today. Track both team streak and personal streak:
```bash
# Team streak: all unique commit dates (local time) — no hard cutoff
git log origin/<default> --format="%ad" --date=format:"%Y-%m-%d" | sort -u
# Personal streak: only the current user's commits
git log origin/<default> --author="<user_name>" --format="%ad" --date=format:"%Y-%m-%d" | sort -u
```
Count backward from today — how many consecutive days have at least one commit? This queries the full history so streaks of any length are reported accurately. Display both:
- "Team shipping streak: 47 consecutive days"
- "Your shipping streak: 32 consecutive days"
`TEAM_STREAK` and `USER_STREAK` count consecutive days with at least 1 commit (full history, no cutoff), anchored at the **newest commit date** — not at today, because the script never trusts the system clock. Interpret against today from the session reminder:
- If the anchor date is today or yesterday, the streak is live: "Team shipping streak: 47 consecutive days" / "Your shipping streak: 32 consecutive days"
- If the anchor is older, the streak is broken: report 0 days and note the last shipping day.
### Step 12: Load History & Compare
@@ -492,7 +389,7 @@ Use the Write tool to save the JSON file with this schema:
}
```
**Note:** Only include the `greptile` field if `~/.gstack/greptile-history.md` exists and has entries within the time window. Only include the `backlog` field if `TODOS.md` exists. Only include the `test_health` field if test files were found (command 10 returns > 0). If any has no data, omit the field entirely.
**Note:** Only include the `greptile` field if `~/.gstack/greptile-history.md` exists and has entries within the time window. Only include the `backlog` field if `TODOS.md` exists. Only include the `test_health` field if test files were found (`TEST_FILES_TOTAL` > 0). If any has no data, omit the field entirely.
Include test health data in the JSON when test files exist:
```json
@@ -517,124 +414,7 @@ Include backlog data in the JSON when TODOS.md exists:
### Step 14: Write the Narrative
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 11, 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 (from command 10)
- Tests added this period: M (from command 12 — test files changed)
- Regression test commits: list `test(qa):` and `test(design):` and `test: coverage` commits from command 11
- 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)
{{SECTION:report-format}}
---
@@ -931,8 +711,8 @@ Use the Write tool to save JSON to `~/.gstack/retros/global-${today}-${next}.jso
When the user runs `/retro compare` (or `/retro compare 14d`):
1. Compute metrics for the current window (default 7d) using the midnight-aligned start date (same logic as the main retro — e.g., if today is 2026-03-18 and window is 7d, use `--since="2026-03-11T00:00:00"`)
2. Compute metrics for the immediately prior same-length window using both `--since` and `--until` with midnight-aligned dates to avoid overlap (e.g., for a 7d window starting 2026-03-11: prior window is `--since="2026-03-04T00:00:00" --until="2026-03-11T00:00:00"`)
1. Run Steps 0.5-1 for the current window (default 7d) using the midnight-aligned start date (same logic as the main retro — e.g., if today is 2026-03-18 and window is 7d, `--since "2026-03-11T00:00:00"`)
2. Run `gstack-retro-metrics` a second time for the immediately prior same-length window, using both `--since` and `--until` with midnight-aligned dates to avoid overlap (e.g., for a 7d window starting 2026-03-11: `--since "2026-03-04T00:00:00" --until "2026-03-11T00:00:00"`)
3. Show a side-by-side comparison table with deltas and arrows
4. Write a brief narrative highlighting the biggest improvements and regressions
5. Save only the current-window snapshot to `.context/retros/` (same as a normal retro run); do **not** persist the prior-window metrics.
@@ -953,10 +733,10 @@ When the user runs `/retro compare` (or `/retro compare 14d`):
## Important Rules
- ALL narrative output goes directly to the user in the conversation. The ONLY file written is the `.context/retros/` JSON snapshot.
- Use `origin/<default>` for all git queries (not local main which may be stale)
- The metrics script analyzes `origin/<default>` (not local main which may be stale); when `RETRO_REF` says otherwise, disclose it
- Display all timestamps in the user's local timezone (do not override `TZ`)
- If the window has zero commits, say so and suggest a different window
- Round LOC/hour to nearest 50
- If `COMMITS: 0`, say so and suggest a different window
- Round LOC/hour to nearest 50 (the script pre-rounds `LOC_PER_SESSION_HOUR`)
- Treat merge commits as PR boundaries
- Do not read CLAUDE.md or other docs — this skill is self-contained
- On first run (no prior retros), skip comparison sections gracefully
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://gstack.dev/schemas/section-manifest.json",
"skill": "retro",
"version": 1,
"note": "PASSIVE registry (v2 plan T9 / CM2). id/file/title/trigger text ONLY. The skeleton's decision-tree prose decides WHEN to read. No machine predicate here.",
"sections": [
{
"id": "report-format",
"file": "report-format.md",
"title": "Narrative report structure — tweetable summary, section-by-section output template, Test Health, Plan Completion, personal deep-dive, team breakdown, wins/improvements/habits (Step 14)",
"trigger": "writing the retrospective narrative (Step 14, after all metrics are computed and compared)"
}
]
}
+120
View File
@@ -0,0 +1,120 @@
<!-- AUTO-GENERATED from report-format.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
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)
+118
View File
@@ -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)
+314
View File
@@ -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<string, string> {
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<string, string> = {
...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 <noreply@anthropic.com>\nCo-Authored-By: Alice Smith <alice@example.com>',
'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 "<default>" --since "<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 <alice@example\.com>$/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/);
});
});
+75 -75
View File
@@ -2,20 +2,24 @@
* Regression tests for #1624 /retro silently produced empty/misleading
* output when "today" anchor was wrong or origin/<default> 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 <default> 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 <default>|git fetch origin <default>[^\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 <default> --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/<default> 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\/<default>/);
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 <default>/);
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:");
});
});
+51 -12
View File
@@ -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(() => {
+14 -4
View File
@@ -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', () => {