fix(context-restore): prefer the current branch's own checkpoint (#2052)

All worktrees of a repo share one origin-derived slug, so they share one
`~/.gstack/projects/<slug>/checkpoints/` dir. `/context-restore` loaded the
newest checkpoint across the whole dir, so in one worktree it could silently
restore a *sibling worktree's* newer checkpoint.

Step 1 now orders candidates current-branch-first (read from each file's
`branch:` frontmatter), keeping other branches as a fallback. A branch is
checked out in at most one worktree, so this stops cross-worktree contamination
while preserving Conductor cross-branch handoff: when the current branch has no
checkpoint of its own, the full newest-first set is still used.

- scan the 200 newest before partitioning so a current-branch checkpoint sitting
  below a burst of sibling saves is still found; output still capped at 20
- non-git / detached HEAD / branchless legacy saves fall back to the old
  newest-first behavior (back-compat)
- +5 regression tests in context-save-hardening.test.ts (the #2052 bug case
  fails on the old pipeline); regenerated SKILL.md + proactive-suggestions.json

Fixes #2052

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jayesh Betala 2026-06-19 11:33:20 +05:30 committed by Garry Tan
parent 5d23ccab56
commit 9adb8ad4ea
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
4 changed files with 195 additions and 44 deletions

View File

@ -23,8 +23,8 @@ triggers:
## When to invoke this skill ## When to invoke this skill
Loads the most recent Loads the most recent
saved state (across all branches by default) so you can pick up where you saved state (preferring the current branch, falling back across branches) so
left off — even across Conductor workspace handoffs. you can pick up where you left off — even across Conductor workspace handoffs.
Use when asked to "resume", "restore context", "where was I", or Use when asked to "resume", "restore context", "where was I", or
"pick up where I left off". Pair with /context-save. "pick up where I left off". Pair with /context-save.
Formerly /checkpoint resume — renamed because Claude Code treats /checkpoint Formerly /checkpoint resume — renamed because Claude Code treats /checkpoint
@ -791,13 +791,19 @@ context and present it clearly so the user can resume work without losing a beat
**HARD GATE:** Do NOT implement code changes. This skill only reads saved **HARD GATE:** Do NOT implement code changes. This skill only reads saved
context files and presents the summary. context files and presents the summary.
**Default: load the most recent saved context across ALL branches.** This is **Default: prefer the most recent checkpoint saved on the CURRENT branch; if
intentionally different from `/context-save list`, which defaults to the current this branch has none, fall back to the most recent across ALL branches.** The
branch. `/context-restore` is for Conductor workspace handoff — a context saved fallback is for Conductor workspace handoff — a context saved on one branch can
on one branch can be resumed from another. be resumed from another. The current-branch preference exists because every
worktree of a repo shares one checkpoints directory (same origin-derived slug),
so without it `/context-restore` in one worktree could silently load a sibling
worktree's newer checkpoint.
**Do NOT filter the candidate set by current branch.** The `list` flow does **Do NOT hard-filter the candidate set to the current branch** — other-branch
that; `/context-restore` does not. checkpoints stay in the set as a fallback. They are just ordered *after* the
current branch's own, so a current-branch save is never shadowed by a newer
sibling-worktree save. (`/context-save list` is the flow that hard-scopes to one
branch.)
--- ---
@ -828,27 +834,53 @@ else
# copies/rsync). Filesystem mtime drifts and is not authoritative. # copies/rsync). Filesystem mtime drifts and is not authoritative.
# 2. On macOS, `find ... | xargs ls -1t` with zero results falls back to # 2. On macOS, `find ... | xargs ls -1t` with zero results falls back to
# listing cwd. `sort -r` on empty input cleanly returns nothing. # listing cwd. `sort -r` on empty input cleanly returns nothing.
# Cap at 20 most recent: a user with 10k saved files shouldn't blow the # Scan the 200 newest so a current-branch checkpoint sitting below a burst of
# context window just listing them. /context-save list handles pagination. # sibling-worktree saves can still be found; the result is capped at 20 below.
FILES=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -20) ALL=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -200)
if [ -z "$FILES" ]; then if [ -z "$ALL" ]; then
echo "NO_CHECKPOINTS" echo "NO_CHECKPOINTS"
else else
# Order current-branch checkpoints first, other branches after. A git branch
# is checked out in at most one worktree, and all worktrees of a repo share
# one checkpoints dir (same origin-derived slug), so without this preference
# `/context-restore` in worktree A could load worktree B's newer checkpoint.
# Cross-branch resume (Conductor handoff) is preserved as the fallback: when
# the current branch has no checkpoint, the full newest-first set is used.
# CURRENT_BRANCH may be pre-set (tests); otherwise resolve it from git.
: "${CURRENT_BRANCH:=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)}"
SAME=""; OTHER=""
while IFS= read -r f; do
[ -n "$f" ] || continue
b=$(grep -m1 '^branch:' "$f" 2>/dev/null | sed 's/^branch:[[:space:]]*//')
if [ -n "$CURRENT_BRANCH" ] && [ "$b" = "$CURRENT_BRANCH" ]; then
SAME="${SAME}${f}
"
else
OTHER="${OTHER}${f}
"
fi
done <<EOF
$ALL
EOF
# Cap at 20: a user with 10k saved files shouldn't blow the context window.
FILES=$(printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20)
echo "$FILES" echo "$FILES"
fi fi
fi fi
``` ```
**Candidates include every `.md` file in the directory, regardless of branch** **Candidates include every `.md` file in the directory**, but they are ordered
(the branch is recorded in frontmatter, not used for filtering here). This **current-branch-first** (the branch is read from each file's `branch:`
enables Conductor workspace handoff. frontmatter). Other-branch files stay in the set as a fallback, which preserves
Conductor workspace handoff when the current branch has no checkpoint of its own.
### Step 2: Load the right file ### Step 2: Load the right file
- If the user specified a title fragment or number: find the matching file among - If the user specified a title fragment or number: find the matching file among
the candidates. the candidates.
- Otherwise: load the **first file returned by the `sort -r` above** — that is - Otherwise: load the **first file returned by Step 1 above** — that is the
the newest `YYYYMMDD-HHMMSS` prefix, which is the canonical "most recent." newest `YYYYMMDD-HHMMSS` checkpoint for the current branch, or, if the current
branch has none, the newest across all branches.
Read the chosen file and present a summary: Read the chosen file and present a summary:
@ -900,9 +932,10 @@ state, then `/context-restore` will find it."
## Important Rules ## Important Rules
- **Never modify code.** This skill only reads saved files and presents them. - **Never modify code.** This skill only reads saved files and presents them.
- **Always search across all branches by default.** Cross-branch resume is the - **Prefer the current branch's own checkpoint, but keep all branches in the
whole point. Only filter by branch if the user explicitly asks via a fallback set.** Cross-branch resume (Conductor handoff) still works when the
title-fragment match that happens to be branch-specific. current branch has no checkpoint; it just no longer lets a sibling worktree's
newer save shadow this branch's own.
- **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not - **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not
`ls -1t` (filesystem mtime). Filenames are stable across file-system `ls -1t` (filesystem mtime). Filenames are stable across file-system
operations; mtime is not. operations; mtime is not.

View File

@ -4,8 +4,8 @@ preamble-tier: 2
version: 1.0.0 version: 1.0.0
description: | description: |
Restore working context saved earlier by /context-save. Loads the most recent Restore working context saved earlier by /context-save. Loads the most recent
saved state (across all branches by default) so you can pick up where you saved state (preferring the current branch, falling back across branches) so
left off — even across Conductor workspace handoffs. you can pick up where you left off — even across Conductor workspace handoffs.
Use when asked to "resume", "restore context", "where was I", or Use when asked to "resume", "restore context", "where was I", or
"pick up where I left off". Pair with /context-save. "pick up where I left off". Pair with /context-save.
Formerly /checkpoint resume — renamed because Claude Code treats /checkpoint Formerly /checkpoint resume — renamed because Claude Code treats /checkpoint
@ -35,13 +35,19 @@ context and present it clearly so the user can resume work without losing a beat
**HARD GATE:** Do NOT implement code changes. This skill only reads saved **HARD GATE:** Do NOT implement code changes. This skill only reads saved
context files and presents the summary. context files and presents the summary.
**Default: load the most recent saved context across ALL branches.** This is **Default: prefer the most recent checkpoint saved on the CURRENT branch; if
intentionally different from `/context-save list`, which defaults to the current this branch has none, fall back to the most recent across ALL branches.** The
branch. `/context-restore` is for Conductor workspace handoff — a context saved fallback is for Conductor workspace handoff — a context saved on one branch can
on one branch can be resumed from another. be resumed from another. The current-branch preference exists because every
worktree of a repo shares one checkpoints directory (same origin-derived slug),
so without it `/context-restore` in one worktree could silently load a sibling
worktree's newer checkpoint.
**Do NOT filter the candidate set by current branch.** The `list` flow does **Do NOT hard-filter the candidate set to the current branch** — other-branch
that; `/context-restore` does not. checkpoints stay in the set as a fallback. They are just ordered *after* the
current branch's own, so a current-branch save is never shadowed by a newer
sibling-worktree save. (`/context-save list` is the flow that hard-scopes to one
branch.)
--- ---
@ -72,27 +78,53 @@ else
# copies/rsync). Filesystem mtime drifts and is not authoritative. # copies/rsync). Filesystem mtime drifts and is not authoritative.
# 2. On macOS, `find ... | xargs ls -1t` with zero results falls back to # 2. On macOS, `find ... | xargs ls -1t` with zero results falls back to
# listing cwd. `sort -r` on empty input cleanly returns nothing. # listing cwd. `sort -r` on empty input cleanly returns nothing.
# Cap at 20 most recent: a user with 10k saved files shouldn't blow the # Scan the 200 newest so a current-branch checkpoint sitting below a burst of
# context window just listing them. /context-save list handles pagination. # sibling-worktree saves can still be found; the result is capped at 20 below.
FILES=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -20) ALL=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -200)
if [ -z "$FILES" ]; then if [ -z "$ALL" ]; then
echo "NO_CHECKPOINTS" echo "NO_CHECKPOINTS"
else else
# Order current-branch checkpoints first, other branches after. A git branch
# is checked out in at most one worktree, and all worktrees of a repo share
# one checkpoints dir (same origin-derived slug), so without this preference
# `/context-restore` in worktree A could load worktree B's newer checkpoint.
# Cross-branch resume (Conductor handoff) is preserved as the fallback: when
# the current branch has no checkpoint, the full newest-first set is used.
# CURRENT_BRANCH may be pre-set (tests); otherwise resolve it from git.
: "${CURRENT_BRANCH:=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)}"
SAME=""; OTHER=""
while IFS= read -r f; do
[ -n "$f" ] || continue
b=$(grep -m1 '^branch:' "$f" 2>/dev/null | sed 's/^branch:[[:space:]]*//')
if [ -n "$CURRENT_BRANCH" ] && [ "$b" = "$CURRENT_BRANCH" ]; then
SAME="${SAME}${f}
"
else
OTHER="${OTHER}${f}
"
fi
done <<EOF
$ALL
EOF
# Cap at 20: a user with 10k saved files shouldn't blow the context window.
FILES=$(printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20)
echo "$FILES" echo "$FILES"
fi fi
fi fi
``` ```
**Candidates include every `.md` file in the directory, regardless of branch** **Candidates include every `.md` file in the directory**, but they are ordered
(the branch is recorded in frontmatter, not used for filtering here). This **current-branch-first** (the branch is read from each file's `branch:`
enables Conductor workspace handoff. frontmatter). Other-branch files stay in the set as a fallback, which preserves
Conductor workspace handoff when the current branch has no checkpoint of its own.
### Step 2: Load the right file ### Step 2: Load the right file
- If the user specified a title fragment or number: find the matching file among - If the user specified a title fragment or number: find the matching file among
the candidates. the candidates.
- Otherwise: load the **first file returned by the `sort -r` above** — that is - Otherwise: load the **first file returned by Step 1 above** — that is the
the newest `YYYYMMDD-HHMMSS` prefix, which is the canonical "most recent." newest `YYYYMMDD-HHMMSS` checkpoint for the current branch, or, if the current
branch has none, the newest across all branches.
Read the chosen file and present a summary: Read the chosen file and present a summary:
@ -144,9 +176,10 @@ state, then `/context-restore` will find it."
## Important Rules ## Important Rules
- **Never modify code.** This skill only reads saved files and presents them. - **Never modify code.** This skill only reads saved files and presents them.
- **Always search across all branches by default.** Cross-branch resume is the - **Prefer the current branch's own checkpoint, but keep all branches in the
whole point. Only filter by branch if the user explicitly asks via a fallback set.** Cross-branch resume (Conductor handoff) still works when the
title-fragment match that happens to be branch-specific. current branch has no checkpoint; it just no longer lets a sibling worktree's
newer save shadow this branch's own.
- **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not - **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not
`ls -1t` (filesystem mtime). Filenames are stable across file-system `ls -1t` (filesystem mtime). Filenames are stable across file-system
operations; mtime is not. operations; mtime is not.

View File

@ -40,7 +40,7 @@
}, },
"context-restore": { "context-restore": {
"lead": "Restore working context saved earlier by /context-save.", "lead": "Restore working context saved earlier by /context-save.",
"routing": "Loads the most recent\nsaved state (across all branches by default) so you can pick up where you\nleft off — even across Conductor workspace handoffs.\nUse when asked to \"resume\", \"restore context\", \"where was I\", or\n\"pick up where I left off\". Pair with /context-save.\nFormerly /checkpoint resume — renamed because Claude Code treats /checkpoint\nas a native rewind alias in current environments.", "routing": "Loads the most recent\nsaved state (preferring the current branch, falling back across branches) so\nyou can pick up where you left off — even across Conductor workspace handoffs.\nUse when asked to \"resume\", \"restore context\", \"where was I\", or\n\"pick up where I left off\". Pair with /context-save.\nFormerly /checkpoint resume — renamed because Claude Code treats /checkpoint\nas a native rewind alias in current environments.",
"voice_line": null "voice_line": null
}, },
"context-save": { "context-save": {

View File

@ -36,15 +36,33 @@ echo "TITLE_SLUG=$TITLE_SLUG"
echo "FILE=$FILE" echo "FILE=$FILE"
`; `;
// The exact find + sort + head used by context-restore/SKILL.md Step 1. // The exact selection used by context-restore/SKILL.md Step 1: scan newest 200,
// order current-branch checkpoints first (fallback: all branches), cap at 20.
// CURRENT_BRANCH is injected via env in tests; the skill resolves it from git.
const RESTORE_FIND_BASH = ` const RESTORE_FIND_BASH = `
if [ ! -d "$CHECKPOINT_DIR" ]; then if [ ! -d "$CHECKPOINT_DIR" ]; then
echo "NO_CHECKPOINTS" echo "NO_CHECKPOINTS"
else else
FILES=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -20) ALL=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -200)
if [ -z "$FILES" ]; then if [ -z "$ALL" ]; then
echo "NO_CHECKPOINTS" echo "NO_CHECKPOINTS"
else else
: "\${CURRENT_BRANCH:=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)}"
SAME=""; OTHER=""
while IFS= read -r f; do
[ -n "$f" ] || continue
b=$(grep -m1 '^branch:' "$f" 2>/dev/null | sed 's/^branch:[[:space:]]*//')
if [ -n "$CURRENT_BRANCH" ] && [ "$b" = "$CURRENT_BRANCH" ]; then
SAME="\${SAME}\${f}
"
else
OTHER="\${OTHER}\${f}
"
fi
done <<EOF
$ALL
EOF
FILES=$(printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20)
echo "$FILES" echo "$FILES"
fi fi
fi fi
@ -312,6 +330,73 @@ describe('context-restore: find + sort + head cap', () => {
}); });
}); });
// ─── Current-branch preference (#2052) ──────────────────────────────────────
//
// All worktrees of a repo share one origin-derived slug → one checkpoints dir.
// Restore must prefer the CURRENT branch's own checkpoint so a sibling
// worktree's newer save can't shadow it, while still falling back across
// branches (Conductor handoff) when the current branch has none.
describe('context-restore: current-branch preference (#2052)', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ctx-branch-')); });
afterEach(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} });
function writeCheckpoint(ts: string, branch: string | null): string {
const file = `${tmp}/${ts}-work.md`;
const fm = branch === null
? `---\nstatus: in-progress\n---\n`
: `---\nstatus: in-progress\nbranch: ${branch}\n---\n`;
fs.writeFileSync(file, fm);
return file;
}
function firstCandidate(currentBranch?: string): string {
const env: Record<string, string> = { CHECKPOINT_DIR: tmp };
if (currentBranch !== undefined) env.CURRENT_BRANCH = currentBranch;
const out = runBash(RESTORE_FIND_BASH, env).stdout;
return out.trim().split('\n').filter(Boolean)[0] ?? '';
}
test('the bug: current-branch save is NOT shadowed by a newer sibling-worktree save', () => {
const mine = writeCheckpoint('20260101-120000', 'feature-a'); // older, my branch
writeCheckpoint('20260619-120000', 'feature-b'); // newer, sibling worktree
// On feature-a, restore must load feature-a's own (older) checkpoint.
expect(firstCandidate('feature-a')).toBe(mine);
});
test('fallback: current branch has no checkpoint → newest across all branches (Conductor handoff)', () => {
writeCheckpoint('20260101-120000', 'feature-a');
const newest = writeCheckpoint('20260619-120000', 'feature-b');
// On feature-c (no own checkpoint), cross-branch resume still works.
expect(firstCandidate('feature-c')).toBe(newest);
});
test('back-compat: empty current branch (non-git) → newest across all', () => {
writeCheckpoint('20260101-120000', 'feature-a');
const newest = writeCheckpoint('20260619-120000', 'feature-b');
expect(firstCandidate('')).toBe(newest);
});
test('checkpoints without a branch frontmatter still rank as fallback, never lost', () => {
const mine = writeCheckpoint('20260101-120000', 'feature-a');
writeCheckpoint('20260301-120000', null); // legacy save, no branch field
const out = runBash(RESTORE_FIND_BASH, { CHECKPOINT_DIR: tmp, CURRENT_BRANCH: 'feature-a' }).stdout;
const lines = out.trim().split('\n').filter(Boolean);
expect(lines[0]).toBe(mine); // current branch first
expect(lines.length).toBe(2); // legacy file is still present
});
test('within the current branch, ordering stays newest-first', () => {
const older = writeCheckpoint('20260101-120000', 'feature-a');
const newer = writeCheckpoint('20260619-120000', 'feature-a');
const out = runBash(RESTORE_FIND_BASH, { CHECKPOINT_DIR: tmp, CURRENT_BRANCH: 'feature-a' }).stdout;
const lines = out.trim().split('\n').filter(Boolean);
expect(lines[0]).toBe(newer);
expect(lines[1]).toBe(older);
});
});
// ─── Migration HOME guard ────────────────────────────────────────────────── // ─── Migration HOME guard ──────────────────────────────────────────────────
describe('migration v1.1.3.0: HOME guard', () => { describe('migration v1.1.3.0: HOME guard', () => {