wave review-e7b551ebd865: merge #3813 (5b6258151a)

This commit is contained in:
garrytan committed 2000-01-01 00:00:00 +00:00
commit 586199c19f
6 files changed
+264 -46

No files matched your search

+74 -3
View File
@@ -28,6 +28,19 @@
# --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: a copy identical to a stored snapshot of the same date
# collapses, a same-name snapshot that differs takes the next free number
# for its date, and a .context/retros that resolves outside its worktree
# is left alone. --next <YYYY-MM-DD> also reserves the next unused
# <date>-<n>.json (written as {} so a concurrent run cannot take it) and
# prints it as RETRO_FILE.
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
@@ -36,9 +49,11 @@ 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 ;;
--base) BASE="$2"; shift 2 || break ;;
--since) SINCE="$2"; shift 2 || break ;;
--until) UNTIL="$2"; shift 2 || break ;;
--history) HISTORY=1; shift ;;
--next) NEXT_DATE="$2"; shift 2 || break ;;
*) shift ;;
esac
done
@@ -51,6 +66,62 @@ _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; }
if [ -n "${NEXT_DATE:-}" ]; then
case "$NEXT_DATE" in
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;;
*) echo "RETRO_HISTORY_ERROR: --next takes a YYYY-MM-DD date, got '$NEXT_DATE'"; exit 0 ;;
esac
fi
_HDR=$(cd -P "$_HD" && pwd)
_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
_SRC=$(cd -P "$_TREE/.context/retros" 2>/dev/null && pwd) || continue
_TR=$(cd -P "$_TREE" 2>/dev/null && pwd) || continue
case "$_SRC" in "$_TR"/*) ;; *) continue ;; esac
[ "$_SRC" != "$_HDR" ] || continue
for _F in "$_SRC"/*.json; do
[ -f "$_F" ] && [ ! -L "$_F" ] || continue
_B=$(basename "$_F")
_DUP=""
for _E in "$_HD/$_B" "$_HD/${_B%-*}"-*.json; do
[ -f "$_E" ] && cmp -s "$_F" "$_E" && { _DUP=1; break; }
done
if [ -n "$_DUP" ]; then rm -f "$_F"; continue; fi
_DEST="$_HD/$_B"
_N=1
while [ -e "$_F" ] && [ "$_N" -le 1000 ]; do
[ -e "$_DEST" ] || mv -n "$_F" "$_DEST" 2>/dev/null
[ -e "$_F" ] || { _MOVED=$((_MOVED + 1)); break; }
_N=$((_N + 1)); _DEST="$_HD/${_B%-*}-$_N.json"
done
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
until ( set -C; printf '{}\n' > "$_HD/$NEXT_DATE-$_N.json" ) 2>/dev/null; do
_N=$((_N + 1))
[ "$_N" -le 1000 ] || { echo "RETRO_HISTORY_ERROR: cannot reserve a snapshot file in $_HD"; exit 0; }
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
@@ -0,0 +1,10 @@
**/retro now remembers its history across git worktrees, so a run in a linked worktree compares against last week instead of announcing "First retro recorded".** Retro snapshots used to live in `.context/retros/` inside the working tree. A worktree (Conductor, Claude Code) saw none of the main checkout's history, and its own snapshot was deleted along with the worktree (#3749).
### Fixed
- **`bin/gstack-retro-metrics --history [--next <YYYY-MM-DD>]`**: keeps retro history in `~/.gstack/projects/<slug>/retros/` (the state root), which every worktree of a repository shares. It lists earlier snapshots as `RETRO_PRIOR` lines, newest first, and reserves the next `<date>-<n>.json` as `RETRO_FILE`, so two retros running at once in different worktrees never write the same file.
- **Moving old history**: snapshots in `.context/retros/` of the current worktree and the main checkout move to the shared folder on the first run. A copy identical to a stored snapshot from the same date is dropped, and a different snapshot with the same name takes the next free number, so nothing is overwritten. A `.context/retros` that points outside its worktree (a symlink to the shared folder or anywhere else) is left alone, and moves never replace an existing file.
- **`retro/SKILL.md`**: Steps 12 and 13 use the helper, the gbrain prior-retros query reads the shared folder, and a history error is reported to the user instead of being mistaken for an outdated helper. `docs/skills.md` says where snapshots are saved.
- `gstack-retro-metrics` no longer loops forever when an option such as `--next` is given without a value.
### Tests
- `test/regression-3749-retro-history-worktree.test.ts` runs the real Step 12 block in a main checkout with a linked worktree: the worktree sees the main checkout's snapshot, its new snapshot path is outside the worktree and survives `git worktree remove`, migration keeps every snapshot, a symlinked `.context/retros` cannot delete history, and two runs get different snapshot paths. It failed 4 of 4 on the code before the fix.
+1 -1
View File
@@ -1119,7 +1119,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.
---
+12 -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,17 @@ 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:
Load history, shared across worktrees (`<today>`: session-reminder date):
```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"
today="<today>"
"$_RM" --history --next "$today"
```
`RETRO_PRIOR:` lists snapshots, newest first (skip `{}` stubs). On `RETRO_HISTORY_ERROR:` show its fix, skip history and Step 13. No `RETRO_FILE:` otherwise: stale helper; 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 +534,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:
Read Step 12's reserved `RETRO_FILE`, then Write it with this schema:
```json
{
"date": "2026-03-08",
@@ -945,7 +936,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 +948,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".
+12 -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,17 @@ 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:
Load history, shared across worktrees (`<today>`: session-reminder date):
```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"
today="<today>"
"$_RM" --history --next "$today"
```
`RETRO_PRIOR:` lists snapshots, newest first (skip `{}` stubs). On `RETRO_HISTORY_ERROR:` show its fix, skip history and Step 13. No `RETRO_FILE:` otherwise: stale helper; 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 +379,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:
Read Step 12's reserved `RETRO_FILE`, then Write it with this schema:
```json
{
"date": "2026-03-08",
@@ -766,7 +757,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 +769,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,155 @@
/**
* 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;
/** Git Bash on Windows prints the state root as given plus `/projects/...`; compare with forward slashes. */
const fwd = (p: string) => p.replaceAll('\\', '/');
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: fwd(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(fwd(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(fwd(path.join(history, '2026-11-01-1.json')));
expect(file!.startsWith(fwd(wt))).toBe(false);
fs.writeFileSync(file!, JSON.stringify({ date: '2026-11-01', window: '7d' }));
git(main, 'worktree', 'remove', '--force', wt);
const fromMain = sh(LOAD, main).stdout;
expect(fromMain).toContain(file!);
expect(fromMain).toContain(`RETRO_FILE: ${fwd(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', '2026-11-01-1.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('a .context/retros symlinked to the shared history is left alone, so migration cannot delete it', () => {
const { wt, history } = layout();
const kept = snapshot(history, '2026-10-25-1.json', { date: '2026-10-25', window: '7d' });
fs.mkdirSync(path.join(wt, '.context'), { recursive: true });
fs.symlinkSync(history, path.join(wt, '.context', 'retros'));
const r = sh(LOAD, wt);
expect(r.status).toBe(0);
expect(fs.readFileSync(kept, 'utf-8')).toContain('2026-10-25');
expect(r.stdout).toContain(`RETRO_PRIOR: ${fwd(kept)}`);
});
test('RETRO_FILE is reserved, so two runs in different worktrees never get the same path', () => {
const { main, wt, history } = layout();
const first = sh(LOAD, wt).stdout.match(/^RETRO_FILE: (.+)$/m)?.[1];
const second = sh(LOAD, main).stdout.match(/^RETRO_FILE: (.+)$/m)?.[1];
expect(first).toBe(fwd(path.join(history, '2026-11-01-1.json')));
expect(second).toBe(fwd(path.join(history, '2026-11-01-2.json')));
expect(fs.readFileSync(first!, 'utf-8').trim()).toBe('{}');
});
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"');
});
});