From 56ced9d1c126e89df3962ba385991fea55efe5f2 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:16:58 +0000 Subject: [PATCH] fix(retro): keep retro history in the state root, shared across worktrees (#3749) /retro saved snapshots under .context/retros inside the working tree, so a run in a linked worktree saw none of the main checkout's history, reported "First retro recorded", and wrote a snapshot that git worktree remove deletes. gstack-retro-metrics --history [--next ] now resolves the history directory as /projects//retros (the slug is keyed on the git common dir, so every worktree shares it), moves any legacy .context/retros from this worktree and the main checkout into it, lists prior snapshots and prints the next snapshot path. Step 12/13 and the gbrain prior-retros query use it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-retro-metrics | 49 +++++++ docs/skills.md | 2 +- retro/SKILL.md | 32 ++--- retro/SKILL.md.tmpl | 32 ++--- ...ession-3749-retro-history-worktree.test.ts | 130 ++++++++++++++++++ 5 files changed, 202 insertions(+), 43 deletions(-) create mode 100644 test/regression-3749-retro-history-worktree.test.ts diff --git a/bin/gstack-retro-metrics b/bin/gstack-retro-metrics index 40bb4f43c..11c30f4d4 100755 --- a/bin/gstack-retro-metrics +++ b/bin/gstack-retro-metrics @@ -28,6 +28,16 @@ # --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). +# +# gstack-retro-metrics --history [--next ] +# +# Retro history for this project (#3749): prints RETRO_HISTORY_DIR (the +# state root's projects//retros, shared by every worktree of the +# repository) and one RETRO_PRIOR line per snapshot, newest first. Moves any +# legacy .context/retros/*.json from this worktree and the main checkout +# into it first: identical copies collapse, a same-name snapshot that +# differs takes the next free number for its date. --next also prints +# RETRO_FILE, the next unused -.json path. GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac @@ -39,6 +49,8 @@ while [ $# -gt 0 ]; do --base) BASE="$2"; shift 2 ;; --since) SINCE="$2"; shift 2 ;; --until) UNTIL="$2"; shift 2 ;; + --history) HISTORY=1; shift ;; + --next) NEXT_DATE="$2"; shift 2 ;; *) shift ;; esac done @@ -51,6 +63,43 @@ _BIN="$_SCRIPT_DIR" gstack_state_root_select _GH="$_gstack_sr_root" +if [ -n "${HISTORY:-}" ]; then + _SLUG=$("$_BIN/gstack-slug" --get SLUG 2>/dev/null || true) + if [ -z "$_SLUG" ]; then + echo "RETRO_HISTORY_ERROR: could not resolve the project slug (gstack-slug failed). fix: reinstall with ./setup or /gstack-upgrade" + exit 0 + fi + _HD="$_GH/projects/$_SLUG/retros" + mkdir -p "$_HD" || { echo "RETRO_HISTORY_ERROR: cannot create $_HD"; exit 0; } + _TOP=$(git rev-parse --show-toplevel 2>/dev/null || true) + _MAIN=$(git worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p') + _MOVED=0 + for _TREE in "$_TOP" "$_MAIN"; do + [ -n "$_TREE" ] && [ -d "$_TREE/.context/retros" ] || continue + for _F in "$_TREE"/.context/retros/*.json; do + [ -f "$_F" ] || continue + _B=$(basename "$_F") + _DEST="$_HD/$_B" + if [ -e "$_DEST" ] && cmp -s "$_F" "$_DEST"; then + rm -f "$_F"; continue + fi + _N=1 + while [ -e "$_DEST" ]; do _N=$((_N + 1)); _DEST="$_HD/${_B%-*}-$_N.json"; done + mv "$_F" "$_DEST" && _MOVED=$((_MOVED + 1)) + done + rmdir "$_TREE/.context/retros" 2>/dev/null || true + done + echo "RETRO_HISTORY_DIR: $_HD" + [ "$_MOVED" -gt 0 ] && echo "RETRO_HISTORY_MIGRATED: $_MOVED from .context/retros" + ls -t "$_HD"/*.json 2>/dev/null | sed 's/^/RETRO_PRIOR: /' + if [ -n "${NEXT_DATE:-}" ]; then + _N=1 + while [ -e "$_HD/$NEXT_DATE-$_N.json" ]; do _N=$((_N + 1)); done + echo "RETRO_FILE: $_HD/$NEXT_DATE-$_N.json" + fi + exit 0 +fi + echo "RETRO_METRICS_PROTO: 1" if ! git rev-parse --git-dir >/dev/null 2>&1; then diff --git a/docs/skills.md b/docs/skills.md index dde699409..e56183572 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -1115,7 +1115,7 @@ Claude: Week of Mar 1: 47 commits (3 contributors), 3.2k LOC, 38% tests, 12 PRs, [Top 3 team wins, 3 things to improve, 3 habits for next week] ``` -It saves a JSON snapshot to `.context/retros/` so the next run can show trends. +It saves a JSON snapshot under `~/.gstack/projects//retros/`, shared by every worktree of the repository, so the next run can show trends from any checkout. An older `.context/retros/` is moved there on the first run. --- diff --git a/retro/SKILL.md b/retro/SKILL.md index 0b71c2dfd..f8d286db1 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -18,8 +18,8 @@ gbrain: context_queries: - id: prior-retros kind: filesystem - # /retro writes .context/retros/*.json (repo-local; see Step 13). - glob: ".context/retros/*.json" + # /retro writes its history here, shared by every worktree (Step 13). + glob: "{gstack_state_root}/projects/{repo_slug}/retros/*.json" sort: mtime_desc limit: 5 render_as: "## Prior retros for this project" @@ -504,13 +504,16 @@ End the section with: `N markers, M with no trigger.` If none: `No shortcut debt ### Step 12: Load History & Compare -Before saving the new snapshot, check for prior retro history: +Check prior history, shared by every worktree of this repo (substitute the session-reminder date for ``): ```bash -setopt +o nomatch 2>/dev/null || true # zsh compat -ls -t .context/retros/*.json 2>/dev/null +_RM="$HOME/.claude/skills/gstack/bin/gstack-retro-metrics" +[ -x "$_RM" ] || _RM=".claude/skills/gstack/bin/gstack-retro-metrics" +"$_RM" --history --next "" ``` +`RETRO_PRIOR:` lines are prior snapshots, newest first. No `RETRO_FILE:` line means a stale helper: skip history and suggest `/gstack-upgrade`. + **If prior retros exist:** Load the most recent one with the same `window` using the Read tool; if none matches, disclose that and skip historical deltas. Calculate deltas for available key metrics and include a **Trends vs Last Retro** section (in `compare` mode use the freshly computed prior period instead): ``` Last Now Delta @@ -530,20 +533,7 @@ Deep sessions: 3 → 5 ↑2 After computing all metrics (including streak) and loading any prior history for comparison, draft the tweetable summary and the "3 Things to Improve" items using the format in Step 14, then save a JSON snapshot. The Step 14 narrative must reuse this exact summary and these exact items. `streak_days` is the live **team** streak from Step 11 (0 when broken); put the personal streak in `user_streak_days`. -```bash -mkdir -p .context/retros -``` - -Determine the next unused sequence number for today (substitute the session-reminder date for ``): -```bash -setopt +o nomatch 2>/dev/null || true # zsh compat -today="" -next=1 -while [ -e ".context/retros/${today}-${next}.json" ]; do next=$((next + 1)); done -# Save as .context/retros/${today}-${next}.json -``` - -Use the Write tool to save the JSON file with this schema: +Use the Write tool to save it to Step 12's `RETRO_FILE` with this schema: ```json { "date": "2026-03-08", @@ -945,7 +935,7 @@ When the user runs `/retro compare` (or `/retro compare 14d`): - Never compare teammates against each other negatively. Each person's section stands on its own. - Size the report to the window and the team: every section carries specific evidence, and a section with nothing to say is cut, not padded - Use markdown tables and code blocks for data, prose for narrative -- Output the narrative directly to the conversation; the only report file is the JSON snapshot (`.context/retros/` for repo retros, `$GSTACK_STATE_ROOT/retros/` for global) +- Output the narrative directly to the conversation; the only report file is the JSON snapshot (`$GSTACK_STATE_ROOT/projects//retros/` for repo retros, `$GSTACK_STATE_ROOT/retros/` for global) ## Important Rules @@ -957,4 +947,4 @@ When the user runs `/retro compare` (or `/retro compare 14d`): - Treat merge commits as PR boundaries - Do not read CLAUDE.md or unrelated docs — this skill is self-contained; the CHANGELOG and optional inputs explicitly named above are exceptions - On first run (no prior retros), skip saved-history comparisons gracefully; explicit `compare` mode still computes its prior window -- **Global mode:** Does NOT require being inside a git repo. Saves snapshots to `~/.gstack/retros/` (not `.context/retros/`). Gracefully skip AI tools that aren't installed. Only compare against prior global retros with the same window value. If streak hits 365d cap, display as "365+ days". +- **Global mode:** Does NOT require being inside a git repo. Saves snapshots to `~/.gstack/retros/` (not the per-project history). Gracefully skip AI tools that aren't installed. Only compare against prior global retros with the same window value. If streak hits 365d cap, display as "365+ days". diff --git a/retro/SKILL.md.tmpl b/retro/SKILL.md.tmpl index 32dd21b7a..61c04cc3b 100644 --- a/retro/SKILL.md.tmpl +++ b/retro/SKILL.md.tmpl @@ -22,8 +22,8 @@ gbrain: context_queries: - id: prior-retros kind: filesystem - # /retro writes .context/retros/*.json (repo-local; see Step 13). - glob: ".context/retros/*.json" + # /retro writes its history here, shared by every worktree (Step 13). + glob: "{gstack_state_root}/projects/{repo_slug}/retros/*.json" sort: mtime_desc limit: 5 render_as: "## Prior retros for this project" @@ -349,13 +349,16 @@ End the section with: `N markers, M with no trigger.` If none: `No shortcut debt ### Step 12: Load History & Compare -Before saving the new snapshot, check for prior retro history: +Check prior history, shared by every worktree of this repo (substitute the session-reminder date for ``): ```bash -setopt +o nomatch 2>/dev/null || true # zsh compat -ls -t .context/retros/*.json 2>/dev/null +_RM="{{RUNTIME_ROOT}}/bin/gstack-retro-metrics" +[ -x "$_RM" ] || _RM=".claude/skills/gstack/bin/gstack-retro-metrics" +"$_RM" --history --next "" ``` +`RETRO_PRIOR:` lines are prior snapshots, newest first. No `RETRO_FILE:` line means a stale helper: skip history and suggest `/gstack-upgrade`. + **If prior retros exist:** Load the most recent one with the same `window` using the Read tool; if none matches, disclose that and skip historical deltas. Calculate deltas for available key metrics and include a **Trends vs Last Retro** section (in `compare` mode use the freshly computed prior period instead): ``` Last Now Delta @@ -375,20 +378,7 @@ Deep sessions: 3 → 5 ↑2 After computing all metrics (including streak) and loading any prior history for comparison, draft the tweetable summary and the "3 Things to Improve" items using the format in Step 14, then save a JSON snapshot. The Step 14 narrative must reuse this exact summary and these exact items. `streak_days` is the live **team** streak from Step 11 (0 when broken); put the personal streak in `user_streak_days`. -```bash -mkdir -p .context/retros -``` - -Determine the next unused sequence number for today (substitute the session-reminder date for ``): -```bash -setopt +o nomatch 2>/dev/null || true # zsh compat -today="" -next=1 -while [ -e ".context/retros/${today}-${next}.json" ]; do next=$((next + 1)); done -# Save as .context/retros/${today}-${next}.json -``` - -Use the Write tool to save the JSON file with this schema: +Use the Write tool to save it to Step 12's `RETRO_FILE` with this schema: ```json { "date": "2026-03-08", @@ -766,7 +756,7 @@ When the user runs `/retro compare` (or `/retro compare 14d`): - Never compare teammates against each other negatively. Each person's section stands on its own. - Size the report to the window and the team: every section carries specific evidence, and a section with nothing to say is cut, not padded - Use markdown tables and code blocks for data, prose for narrative -- Output the narrative directly to the conversation; the only report file is the JSON snapshot (`.context/retros/` for repo retros, `$GSTACK_STATE_ROOT/retros/` for global) +- Output the narrative directly to the conversation; the only report file is the JSON snapshot (`$GSTACK_STATE_ROOT/projects//retros/` for repo retros, `$GSTACK_STATE_ROOT/retros/` for global) ## Important Rules @@ -778,4 +768,4 @@ When the user runs `/retro compare` (or `/retro compare 14d`): - Treat merge commits as PR boundaries - Do not read CLAUDE.md or unrelated docs — this skill is self-contained; the CHANGELOG and optional inputs explicitly named above are exceptions - On first run (no prior retros), skip saved-history comparisons gracefully; explicit `compare` mode still computes its prior window -- **Global mode:** Does NOT require being inside a git repo. Saves snapshots to `~/.gstack/retros/` (not `.context/retros/`). Gracefully skip AI tools that aren't installed. Only compare against prior global retros with the same window value. If streak hits 365d cap, display as "365+ days". +- **Global mode:** Does NOT require being inside a git repo. Saves snapshots to `~/.gstack/retros/` (not the per-project history). Gracefully skip AI tools that aren't installed. Only compare against prior global retros with the same window value. If streak hits 365d cap, display as "365+ days". diff --git a/test/regression-3749-retro-history-worktree.test.ts b/test/regression-3749-retro-history-worktree.test.ts new file mode 100644 index 000000000..96ee42589 --- /dev/null +++ b/test/regression-3749-retro-history-worktree.test.ts @@ -0,0 +1,130 @@ +/** + * Regression for #3749: /retro kept its history in .context/retros/ inside the + * working tree, so a run in a linked worktree (Conductor, Claude Code) found + * none of the main checkout's snapshots, reported "First retro recorded", and + * wrote its own snapshot where `git worktree remove` deletes it. History now + * lives in the state root, keyed by project slug (shared by every worktree of + * a repository), and an existing .context/retros/ is migrated on first use. + * + * Runs the real Step 12 bash block (history and the Step 13 snapshot path) from the generated + * retro/SKILL.md with the gstack install pointed at this checkout. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { 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 SKILL = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8'); +const TMPL = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md.tmpl'), 'utf-8'); + +function bashBlockAfter(heading: string, contains: string): string { + const start = SKILL.indexOf(heading); + if (start < 0) throw new Error(`retro/SKILL.md: heading not found: ${heading}`); + const end = SKILL.indexOf('\n### ', start + heading.length); + const re = /```bash\n([\s\S]*?)```/g; + re.lastIndex = start; + for (let m = re.exec(SKILL); m && m.index < end; m = re.exec(SKILL)) { + if (m[1].includes(contains)) { + return m[1].replaceAll('$HOME/.claude/skills/gstack', ROOT).replaceAll('~/.claude/skills/gstack', ROOT); + } + } + throw new Error(`retro/SKILL.md: no bash block containing ${contains} under ${heading}`); +} + +const LOAD = bashBlockAfter('### Step 12: Load History & Compare', 'retro').replace('', '2026-11-01'); + +let tmp: string; +let home: string; + +function sh(script: string, cwd: string) { + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v; + for (const k of ['GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR', 'GSTACK_PROJECT_SLUG', 'CLAUDE_PLUGIN_DATA', 'CLAUDE_PROJECT_DIR']) delete env[k]; + const r = spawnSync('bash', ['-c', script], { cwd, env: { ...env, HOME: tmp, GSTACK_HOME: home }, encoding: 'utf-8', timeout: 30_000 }); + return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 }; +} + +function git(cwd: string, ...args: string[]) { + const r = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 30_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); +} + +function snapshot(dir: string, name: string, body: object): string { + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, name); + fs.writeFileSync(file, JSON.stringify(body)); + return file; +} + +/** A main checkout with one commit (fixture-local identity) and a linked worktree under it. */ +function layout() { + const main = path.join(tmp, 'widget'); + fs.mkdirSync(main); + git(main, 'init', '-q', '-b', 'main'); + git(main, 'remote', 'add', 'origin', 'https://github.com/acme/widget.git'); + git(main, 'config', 'user.name', 'Fixture'); + git(main, 'config', 'user.email', 'fixture@example.com'); + git(main, 'commit', '-q', '--allow-empty', '-m', 'init'); + git(main, 'worktree', 'add', '-q', '-b', 'demo', path.join(main, '.claude', 'worktrees', 'demo')); + const wt = fs.realpathSync(path.join(main, '.claude', 'worktrees', 'demo')); + const slug = sh(`'${ROOT}/bin/gstack-slug' --get SLUG`, main).stdout.trim(); + expect(slug).not.toBe(''); + return { main, wt, history: path.join(home, 'projects', slug, 'retros') }; +} + +beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'retro-3749-'))); + home = path.join(tmp, '.gstack'); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('#3749 retro history is shared across linked worktrees', () => { + test('Step 12 in a linked worktree finds the main checkout\'s earlier snapshot', () => { + const { main, wt, history } = layout(); + snapshot(path.join(main, '.context', 'retros'), '2026-10-25-1.json', { date: '2026-10-25', window: '7d' }); + + const r = sh(LOAD, wt); + expect(r.status).toBe(0); + expect(r.stdout).toContain(path.join(history, '2026-10-25-1.json')); + expect(fs.existsSync(path.join(history, '2026-10-25-1.json'))).toBe(true); + expect(fs.existsSync(path.join(main, '.context', 'retros'))).toBe(false); + }); + + test('the snapshot path from a linked worktree is outside the worktree, and the main checkout sees it', () => { + const { main, wt, history } = layout(); + const save = sh(LOAD, wt); + expect(save.status).toBe(0); + const file = save.stdout.match(/^RETRO_FILE: (.+)$/m)?.[1]; + expect(file).toBe(path.join(history, '2026-11-01-1.json')); + expect(file!.startsWith(wt)).toBe(false); + fs.writeFileSync(file!, JSON.stringify({ date: '2026-11-01', window: '7d' })); + + git(main, 'worktree', 'remove', '--force', wt); + expect(sh(LOAD, main).stdout).toContain(file!); + expect(sh(LOAD, main).stdout).toContain(`RETRO_FILE: ${path.join(history, '2026-11-01-2.json')}`); + }); + + test('migration keeps every snapshot: duplicates collapse, same-name differing snapshots get the next number', () => { + const { main, wt, history } = layout(); + for (const tree of [main, wt]) snapshot(path.join(tree, '.context', 'retros'), '2026-10-18-1.json', { date: '2026-10-18', window: '7d' }); + snapshot(path.join(main, '.context', 'retros'), '2026-10-25-1.json', { date: '2026-10-25', from: 'main' }); + snapshot(path.join(wt, '.context', 'retros'), '2026-10-25-1.json', { date: '2026-10-25', from: 'worktree' }); + + const r = sh(LOAD, wt); + expect(r.status).toBe(0); + expect(fs.readdirSync(history).sort()).toEqual(['2026-10-18-1.json', '2026-10-25-1.json', '2026-10-25-2.json']); + const froms = ['2026-10-25-1.json', '2026-10-25-2.json'].map((f) => JSON.parse(fs.readFileSync(path.join(history, f), 'utf-8')).from).sort(); + expect(froms).toEqual(['main', 'worktree']); + for (const tree of [main, wt]) expect(fs.existsSync(path.join(tree, '.context', 'retros'))).toBe(false); + }); + + test('the gbrain prior-retros query reads the same state-root history', () => { + expect(TMPL).toContain('glob: "{gstack_state_root}/projects/{repo_slug}/retros/*.json"'); + expect(TMPL).not.toContain('glob: ".context/retros/*.json"'); + }); +});