diff --git a/context-restore/SKILL.md b/context-restore/SKILL.md index 11b95b4e2..c7eaefc9a 100644 --- a/context-restore/SKILL.md +++ b/context-restore/SKILL.md @@ -23,8 +23,8 @@ triggers: ## When to invoke this skill Loads the most recent -saved state (across all branches by default) so you can pick up where you -left off — even across Conductor workspace handoffs. +saved state (preferring the current branch, falling back across branches) so +you can pick up where you left off — even across Conductor workspace handoffs. Use when asked to "resume", "restore context", "where was I", or "pick up where I left off". Pair with /context-save. Formerly /checkpoint resume — renamed because Claude Code treats /checkpoint @@ -762,13 +762,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 context files and presents the summary. -**Default: load the most recent saved context across ALL branches.** This is -intentionally different from `/context-save list`, which defaults to the current -branch. `/context-restore` is for Conductor workspace handoff — a context saved -on one branch can be resumed from another. +**Default: prefer the most recent checkpoint saved on the CURRENT branch; if +this branch has none, fall back to the most recent across ALL branches.** The +fallback is for Conductor workspace handoff — a context saved on one branch can +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 -that; `/context-restore` does not. +**Do NOT hard-filter the candidate set to the current branch** — other-branch +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.) --- @@ -799,27 +805,53 @@ else # copies/rsync). Filesystem mtime drifts and is not authoritative. # 2. On macOS, `find ... | xargs ls -1t` with zero results falls back to # 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 - # context window just listing them. /context-save list handles pagination. - FILES=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -20) - if [ -z "$FILES" ]; then + # Scan the 200 newest so a current-branch checkpoint sitting below a burst of + # sibling-worktree saves can still be found; the result is capped at 20 below. + ALL=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -200) + if [ -z "$ALL" ]; then echo "NO_CHECKPOINTS" 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 </dev/null | sort -r | head -20) - if [ -z "$FILES" ]; then + # Scan the 200 newest so a current-branch checkpoint sitting below a burst of + # sibling-worktree saves can still be found; the result is capped at 20 below. + ALL=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -200) + if [ -z "$ALL" ]; then echo "NO_CHECKPOINTS" 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 </dev/null | sort -r | head -20) - if [ -z "$FILES" ]; then + ALL=$(find "$CHECKPOINT_DIR" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort -r | head -200) + if [ -z "$ALL" ]; then echo "NO_CHECKPOINTS" 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 < { }); }); +// ─── 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 = { 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 ────────────────────────────────────────────────── describe('migration v1.1.3.0: HOME guard', () => {