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 <date>] now resolves the history
directory as <state root>/projects/<slug>/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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-01 16:16:58 +00:00
1 parent 1cac25cb8e
commit 56ced9d1c1
5 files changed
+202 -43

No files matched your search

+49
View File
@@ -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 <YYYY-MM-DD>]
#
# Retro history for this project (#3749): prints RETRO_HISTORY_DIR (the
# state root's projects/<slug>/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 <date>-<n>.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
+1 -1
View File
@@ -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/<slug>/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.
---
+11 -21
View File
@@ -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 `<today>`):
```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 "<today>"
```
`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 `<today>`):
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
today="<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/<slug>/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".
+11 -21
View File
@@ -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 `<today>`):
```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 "<today>"
```
`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 `<today>`):
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
today="<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/<slug>/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".
@@ -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('<today>', '2026-11-01');
let tmp: string;
let home: string;
function sh(script: string, cwd: string) {
const env: Record<string, string> = {};
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"');
});
});