fix(context-restore): offer newer checkpoints from nested repos and task worktrees

/context-restore could present an older checkpoint as the latest with no
error in two layouts:

1. A session started in a non-git parent saved from a nested repo, so the
   checkpoint went to the nested repo's bucket. Restore in the parent now
   lists checkpoints of other buckets whose project_root lies below the cwd
   (BELOW_CWD; the 200 newest are read, and from $HOME only each project's
   newest). /context-save also leaves a JSON pointer (checkpoint path, bucket,
   project_root) in the starting directory's bucket when $CLAUDE_PROJECT_DIR
   is set and files elsewhere; restore lists those as POINTER, in or out of git.
2. A plan checkpoint on the main tree's branch shadowed a newer checkpoint
   saved in a ticket worktree. When another branch holds a newer checkpoint
   for the same task (a worktree below the main tree, read from the new
   worktree: stamp; equal normalized titles; or a shared #123 / ABC-123
   token in titles or branch names), restore prints NEWER_TASK and the skill
   shows both and proposes the newer one. Generic titles (wip, checkpoint,
   save, notes, todo) never match on title alone, and the worktree rule
   applies only when restoring from the main tree, so sibling worktrees
   still never shadow each other.

The branch-first ordering itself is unchanged.

Refs #3065.

Co-authored-by: Thomas Gernhardt <t.gernhardt@softwareprojekt.net>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
committed 2026-10-07 19:44:41 +00:00
1 parent 35a8284e58
commit 1902f4c87d
6 files changed
+391 -19

No files matched your search

+178 -3
View File
@@ -10,6 +10,9 @@
# --identity also print PROJECT_REMOTE, PROJECT_ROOT, LEGACY_SLUG
# --stamp-checkpoint <file> write remote:/project_root: into a checkpoint's frontmatter
# --classify-checkpoints read checkpoint paths on stdin, print "<status>\t<path>"
# --checkpoints-below list other buckets' checkpoints saved below the cwd, plus pointers
# --point-from <dir> <file> leave a pointer to <file> in <dir>'s bucket (nested-repo saves)
# --newer-task <branch> read restore candidates on stdin, name a newer same-task checkpoint
# --adopt-legacy [--copy <relpath>...] review/copy files from the pre-#3003 bucket
# --adopt-legacy --from <slug> merge a former bucket (e.g. a worktree's) into this one
# --adopt-legacy --dismiss <slug> stop naming a former bucket at skill start
@@ -99,6 +102,7 @@ gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
MODE=default
MODE_ARG=""
MODE_ARG2=""
COPY_PATHS=()
case "${1:-}" in
"") ;;
@@ -110,6 +114,10 @@ case "${1:-}" in
--stamp-checkpoint) MODE=stamp; MODE_ARG="${2:-}"
[[ -n "$MODE_ARG" ]] || { echo "usage: gstack-slug --stamp-checkpoint <file>" >&2; exit 2; } ;;
--classify-checkpoints) MODE=classify ;;
--checkpoints-below) MODE=below ;;
--newer-task) MODE=newer-task; MODE_ARG="${2:-}" ;;
--point-from) MODE=point; MODE_ARG="${2:-}"; MODE_ARG2="${3:-}"
[[ -n "$MODE_ARG" && -n "$MODE_ARG2" ]] || { echo "usage: gstack-slug --point-from <start dir> <checkpoint file>" >&2; exit 2; } ;;
--adopt-legacy) MODE=adopt; shift
if [[ "${1:-}" == "--copy" ]]; then
shift
@@ -133,6 +141,12 @@ gstack-slug — print SLUG=<project slug> and BRANCH=<branch> for the current di
gstack-slug --stamp-checkpoint FILE write remote:/project_root: into FILE's frontmatter
gstack-slug --classify-checkpoints read checkpoint paths on stdin; print "<status><TAB><path>",
status = match | match-root | foreign | unstamped
gstack-slug --checkpoints-below outside git: other buckets' checkpoints whose project_root
is below the cwd (newest 200 read); always: this bucket's
pointers. Prints "BELOW_CWD <path>" / "POINTER <path>"
gstack-slug --point-from DIR FILE record FILE in DIR's bucket when DIR files elsewhere
gstack-slug --newer-task BRANCH read restore candidates on stdin (BRANCH's first); print
"NEWER_TASK <path>" for a newer same-task other-branch save
gstack-slug --adopt-legacy list the old bucket's files (checkpoints marked
matching/foreign/unknown); asks per file on a terminal
gstack-slug --adopt-legacy --copy P… copy the named files (paths relative to the old bucket)
@@ -577,7 +591,74 @@ _classify_checkpoint() {
fi
}
# Physical root of the checkout holding $1 (nearest .git entry), or nothing.
_git_toplevel() {
local dir="$1" parent="" depth=0
while [[ -n "$dir" && $depth -lt 64 ]]; do
if [[ -e "$dir/.git" ]]; then (cd "$dir" 2>/dev/null && pwd -P); return 0; fi
parent=$(dirname "$dir")
[[ "$parent" == "$dir" ]] && break
dir="$parent"
depth=$((depth + 1))
done
return 0
}
# Prints "<branch>\037<project_root>\037<worktree>\037<title>": frontmatter
# fields and the "## Working on:" heading (the filename's title part without it).
_checkpoint_meta() {
local name
name=$(basename "$1" .md)
awk -v fallback="$name" '
function val(key) { v = $0; sub("^" key ":[ \t]*", "", v); sub(/[ \t\r]+$/, "", v); return v }
NR == 1 && $0 == "---" { fm = 1; next }
fm && $0 == "---" { fm = 0; next }
fm && /^branch:/ && !hb { b = val("branch"); hb = 1 }
fm && /^project_root:/ && !hr { r = val("project_root"); hr = 1 }
fm && /^worktree:/ && !hw { w = val("worktree"); hw = 1 }
!fm && /^## Working on:/ { t = val("## Working on"); exit }
END {
if (t == "") { t = fallback; sub(/^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-?/, "", t) }
printf "%s\037%s\037%s\037%s\n", b, r, w, t
}' "$1" 2>/dev/null || true
}
# Task-title form: lowercased, a leading date removed, punctuation and
# whitespace collapsed to single spaces. Generic titles normalize to nothing.
_norm_title() {
local t
t=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
| sed -E 's/^[[:space:]]*[0-9]{4}-?[0-9]{2}-?[0-9]{2}([-_t ]?[0-9]{2}:?[0-9]{2}(:?[0-9]{2})?)?//; s/[[:punct:][:space:]]+/ /g; s/^ //; s/ $//')
case "$t" in wip|checkpoint|save|notes|todo) t="" ;; esac
printf '%s' "$t"
}
# Ticket tokens (#123, ABC-123), one per line.
_ticket_tokens() {
printf '%s\n' "$@" | grep -oE '#[0-9]+|[A-Z][A-Z0-9]+-[0-9]+' 2>/dev/null | sort -u || true
}
_json_str() {
local v="${1//\\/\\\\}"
v="${v//\"/\\\"}"
printf '"%s"' "$v"
}
# Prints the checkpoint path recorded in pointer $1 when it names a regular
# file inside a bucket's checkpoints directory; nothing otherwise.
_pointer_target() {
local v dir
v=$(sed -n 's/^ "checkpoint": "\(.*\)",$/\1/p' "$1" 2>/dev/null | head -1)
v="${v//\\\"/\"}"
v="${v//\\\\/\\}"
[[ -n "$v" && -f "$v" && ! -L "$v" ]] || return 0
dir=$(cd "$(dirname "$v")" 2>/dev/null && pwd -P) || return 0
[[ "$(basename "$dir")" == checkpoints && "$(dirname "$(dirname "$dir")")" == "$STATE_PHYS/projects" ]] || return 0
printf '%s\n' "$v"
}
_resolve_identity
STATE_PHYS=$(cd "$GSTACK_STATE_ROOT" 2>/dev/null && pwd -P) || STATE_PHYS="$GSTACK_STATE_ROOT"
case "$MODE" in
get)
@@ -599,11 +680,17 @@ case "$MODE" in
stamp)
[[ -f "$MODE_ARG" ]] || { echo "gstack-slug: no such checkpoint file: $MODE_ARG" >&2; exit 1; }
STAMP_TMP=$(mktemp "${MODE_ARG}.stamp-XXXXXX") || exit 1
if GRI_REMOTE="$ID_REMOTE" GRI_ROOT="$ID_ROOT" awk '
function ids() { if (ENVIRON["GRI_REMOTE"] != "") print "remote: " ENVIRON["GRI_REMOTE"]; print "project_root: " ENVIRON["GRI_ROOT"] }
STAMP_WORKTREE=$(_git_toplevel "$PROJECT_DIR")
[[ "$STAMP_WORKTREE" != "$ID_ROOT" ]] || STAMP_WORKTREE=""
if GRI_REMOTE="$ID_REMOTE" GRI_ROOT="$ID_ROOT" GRI_WORKTREE="$STAMP_WORKTREE" awk '
function ids() {
if (ENVIRON["GRI_REMOTE"] != "") print "remote: " ENVIRON["GRI_REMOTE"]
print "project_root: " ENVIRON["GRI_ROOT"]
if (ENVIRON["GRI_WORKTREE"] != "") print "worktree: " ENVIRON["GRI_WORKTREE"]
}
NR == 1 { if ($0 == "---") { infm = 1; print; next } print "---"; ids(); print "---" }
infm && $0 == "---" { ids(); print; infm = 0; next }
infm && (/^remote:/ || /^project_root:/) { next }
infm && (/^remote:/ || /^project_root:/ || /^worktree:/) { next }
{ print }
END { if (NR == 0) { print "---"; ids(); print "---" } }' "$MODE_ARG" > "$STAMP_TMP"; then
mv "$STAMP_TMP" "$MODE_ARG"
@@ -621,6 +708,94 @@ case "$MODE" in
done
;;
below)
# Checkpoints a session saved in a repository below this directory: the
# pointers a /context-save started here left, and (outside git) other
# buckets' checkpoints whose project_root lies below the cwd. Only the 200
# newest are read; from $HOME each project shows only its newest.
BELOW_CWD=$(cd "$PROJECT_DIR" 2>/dev/null && pwd -P) || BELOW_CWD="$PROJECT_DIR"
BELOW_HOME=$(cd "${HOME:-/nonexistent}" 2>/dev/null && pwd -P) || BELOW_HOME=""
BELOW_SEEN=$'\n'; BELOW_ROOTS=$'\n'; BELOW_OUT=""
while IFS= read -r ptr; do
[[ -n "$ptr" ]] || continue
target=$(_pointer_target "$ptr")
[[ -n "$target" && "$BELOW_SEEN" != *$'\n'"$target"$'\n'* ]] || continue
BELOW_SEEN="$BELOW_SEEN$target"$'\n'
BELOW_OUT="$BELOW_OUT$(basename "$target")"$'\t'"POINTER $target"$'\n'
done < <(find "$STATE_PHYS/projects/$SLUG/checkpoint-pointers" -maxdepth 1 -name '*.json' -type f 2>/dev/null | LC_ALL=C sort -r | head -200)
if [[ -z "$GIT_COMMON_DIR" ]]; then
while IFS= read -r f; do
[[ -n "$f" && "$BELOW_SEEN" != *$'\n'"$f"$'\n'* ]] || continue
meta=$(_checkpoint_meta "$f")
IFS=$'\037' read -r _b root _w _t <<<"$meta"
[[ -n "$root" && "$root" == "$BELOW_CWD"/* ]] || continue
if [[ "$BELOW_CWD" == "$BELOW_HOME" ]]; then
[[ "$BELOW_ROOTS" != *$'\n'"$root"$'\n'* ]] || continue
BELOW_ROOTS="$BELOW_ROOTS$root"$'\n'
fi
BELOW_OUT="$BELOW_OUT$(basename "$f")"$'\t'"BELOW_CWD $f"$'\n'
done < <(find "$STATE_PHYS/projects" -mindepth 3 -maxdepth 3 -path '*/checkpoints/*.md' -type f \
! -path "$STATE_PHYS/projects/$SLUG/*" 2>/dev/null \
| awk -F/ '{ print $NF "\t" $0 }' | LC_ALL=C sort -r | head -200 | cut -f2-)
fi
printf '%s' "$BELOW_OUT" | LC_ALL=C sort -r | head -20 | cut -f2-
;;
point)
# A /context-save that started in DIR but filed elsewhere (a nested repo)
# leaves a JSON pointer in DIR's bucket so /context-restore there finds it.
[[ -d "$MODE_ARG" ]] || { echo "gstack-slug: no such start directory: $MODE_ARG" >&2; exit 1; }
[[ -f "$MODE_ARG2" && ! -L "$MODE_ARG2" ]] || { echo "gstack-slug: no such checkpoint file: $MODE_ARG2" >&2; exit 1; }
PT_FILE="$(cd "$(dirname "$MODE_ARG2")" && pwd -P)/$(basename "$MODE_ARG2")"
PT_BUCKET=$(basename "$(dirname "$(dirname "$PT_FILE")")")
[[ "$(basename "$(dirname "$PT_FILE")")" == checkpoints && "$(dirname "$(dirname "$(dirname "$PT_FILE")")")" == "$STATE_PHYS/projects" ]] \
|| { echo "gstack-slug: $MODE_ARG2 is not a saved checkpoint under $GSTACK_STATE_ROOT/projects" >&2; exit 1; }
PT_START=$(cd "$MODE_ARG" && "$0" --get SLUG 2>/dev/null) || exit 1
case "$PT_START" in ""|.|..) exit 1 ;; esac
[[ "$PT_START" != "$PT_BUCKET" ]] || exit 0
IFS=$'\037' read -r _b PT_ROOT _w _t <<<"$(_checkpoint_meta "$PT_FILE")"
PT_DIR="$GSTACK_STATE_ROOT/projects/$PT_START/checkpoint-pointers"
mkdir -p "$PT_DIR" || exit 1
PT_OUT="$PT_DIR/$(basename "$PT_FILE" .md)--$PT_BUCKET.json"
printf '{\n "checkpoint": %s,\n "bucket": %s,\n "project_root": %s\n}\n' \
"$(_json_str "$PT_FILE")" "$(_json_str "$PT_BUCKET")" "$(_json_str "$PT_ROOT")" > "$PT_OUT" || exit 1
echo "POINTER_WRITTEN $PT_OUT"
;;
newer-task)
# Restore candidates arrive current-branch first. When another branch of
# this repository holds a newer checkpoint for the same task (a worktree
# below the main tree, the same title, or the same ticket token), name it
# so /context-restore shows both instead of silently taking the branch match.
NT_FIRST=""; NT_FB=""; NT_FR=""; NT_FW=""; NT_FT=""; NT_FTOK=""
NT_HERE=$(_git_toplevel "$PROJECT_DIR")
NT_MAIN=""
[[ "$(basename "$GIT_COMMON_DIR")" != .git ]] || NT_MAIN=$(dirname "$GIT_COMMON_DIR")
while IFS= read -r f || [[ -n "$f" ]]; do
[[ -n "$f" ]] || continue
if [[ -n "$NT_FIRST" ]]; then
nb=$(basename "$f"); fb=$(basename "$NT_FIRST")
[[ "${nb:0:15}" > "${fb:0:15}" ]] || continue
fi
IFS=$'\037' read -r b root wt title <<<"$(_checkpoint_meta "$f")"
if [[ -z "$NT_FIRST" ]]; then
[[ -n "$MODE_ARG" && "$b" == "$MODE_ARG" ]] || exit 0
NT_FIRST="$f"; NT_FB="$b"; NT_FT=$(_norm_title "$title"); NT_FTOK=$(_ticket_tokens "$title" "$b")
continue
fi
[[ "$b" != "$NT_FB" ]] || continue
wt="${wt:-$root}"
if [[ -n "$NT_MAIN" && "$NT_HERE" == "$NT_MAIN" && "$wt" == "$NT_MAIN"/* ]]; then
echo "NEWER_TASK $f"; exit 0
fi
nt=$(_norm_title "$title")
if [[ -n "$nt" && "$nt" == "$NT_FT" ]]; then echo "NEWER_TASK $f"; exit 0; fi
if [[ -n "$NT_FTOK" ]] && [[ -n "$(comm -12 <(printf '%s\n' "$NT_FTOK") <(_ticket_tokens "$title" "$b") 2>/dev/null)" ]]; then
echo "NEWER_TASK $f"; exit 0
fi
done
;;
adopt)
if [[ -z "$ID_LEGACY" ]]; then
echo "Nothing to adopt: this project's slug ($SLUG) did not change."
+20 -3
View File
@@ -452,10 +452,13 @@ EOF
printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20 | sed 's/^/UNVERIFIED /'
else
FOREIGN_N=$(printf '%s\n' "$CLASSIFIED" | grep -c '^foreign' || true)
VERIFIED=$(printf '%s\n' "$CLASSIFIED" | awk -F '\t' -v f="$FOREIGN_N" \
'$(1) == "match" || $(1) == "match-root" || ($(1) == "unstamped" && f == 0) { print $(2) }')
# Cap at 20: a user with 10k saved files shouldn't blow the context window.
FILES=$(printf '%s\n' "$CLASSIFIED" | awk -F '\t' -v f="$FOREIGN_N" \
'$(1) == "match" || $(1) == "match-root" || ($(1) == "unstamped" && f == 0) { print $(2) }' | head -20)
FILES=$(printf '%s\n' "$VERIFIED" | head -20)
if [ -n "$FILES" ]; then echo "$FILES"; else echo "NO_VERIFIED_CHECKPOINTS"; fi
# A newer checkpoint of another branch that names the same task.
printf '%s\n' "$VERIFIED" | ~/.claude/skills/gstack/bin/gstack-slug --newer-task "$CURRENT_BRANCH" 2>/dev/null || true
printf '%s\n' "$CLASSIFIED" | awk -F '\t' -v f="$FOREIGN_N" \
'$(1) == "match-root" { print "ROOT_DIFFERS " $(2) }
$(1) == "foreign" { print "FOREIGN " $(2) }
@@ -466,6 +469,8 @@ EOF
fi
fi
fi
# Checkpoints saved in a repository below this directory (BELOW_CWD, POINTER).
~/.claude/skills/gstack/bin/gstack-slug --checkpoints-below 2>/dev/null || true
```
**Candidates include every `.md` file in the directory**, but they are ordered
@@ -482,6 +487,12 @@ has written to). Prefixed lines are never candidates for "latest":
- `ROOT_DIFFERS <path>`: same repository, different checkout (Conductor workspace
handoff or a moved clone). This is information, not a mismatch; the file is
also listed as a plain path line.
- `NEWER_TASK <path>`: another branch of this repository saved a newer checkpoint
for the same task (its worktree is below this main tree, or the title or ticket
matches). The file is also a plain path line, after the current branch's own.
- `BELOW_CWD <path>` / `POINTER <path>`: saved by a session in a repository below
this directory (`POINTER`: a /context-save started here recorded it). These are
candidates, not mismatches.
### Step 2: Load the right file
@@ -491,6 +502,12 @@ has written to). Prefixed lines are never candidates for "latest":
- Otherwise: load the **first plain path line returned by Step 1 above**, which
is the newest `YYYYMMDD-HHMMSS` checkpoint of this project for the current
branch, or, if the current branch has none, the newest across all branches.
- **Do not silently take an older checkpoint.** If Step 1 printed `NEWER_TASK`,
or a `BELOW_CWD`/`POINTER` file is newer than the first plain path line (or
there is none), show both (title, branch, saved time, and `project_root` for a
file below this directory), propose the newer one as the continuation, and ask
via AskUserQuestion which to load. A `BELOW_CWD`/`POINTER` file is not a
PROJECT MISMATCH: say "Saved in `{project_root}`, below this directory."
- If Step 1 printed `NO_VERIFIED_CHECKPOINTS` or `IDENTITY_CHECK_FAILED`, do not
present any checkpoint as the latest one. Say "No checkpoint in this directory
is verified as this project's", list the `FOREIGN`/`UNVERIFIED` titles with the
@@ -571,7 +588,7 @@ unverified earlier one.
## If no saved contexts exist
If Step 1 printed `NO_CHECKPOINTS`, tell the user:
If Step 1 printed `NO_CHECKPOINTS` and no `BELOW_CWD` or `POINTER` line, tell the user:
"No saved contexts yet. Run `/context-save` first to save your current working
state, then `/context-restore` will find it."
+20 -3
View File
@@ -126,10 +126,13 @@ EOF
printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20 | sed 's/^/UNVERIFIED /'
else
FOREIGN_N=$(printf '%s\n' "$CLASSIFIED" | grep -c '^foreign' || true)
VERIFIED=$(printf '%s\n' "$CLASSIFIED" | awk -F '\t' -v f="$FOREIGN_N" \
'$(1) == "match" || $(1) == "match-root" || ($(1) == "unstamped" && f == 0) { print $(2) }')
# Cap at 20: a user with 10k saved files shouldn't blow the context window.
FILES=$(printf '%s\n' "$CLASSIFIED" | awk -F '\t' -v f="$FOREIGN_N" \
'$(1) == "match" || $(1) == "match-root" || ($(1) == "unstamped" && f == 0) { print $(2) }' | head -20)
FILES=$(printf '%s\n' "$VERIFIED" | head -20)
if [ -n "$FILES" ]; then echo "$FILES"; else echo "NO_VERIFIED_CHECKPOINTS"; fi
# A newer checkpoint of another branch that names the same task.
printf '%s\n' "$VERIFIED" | ~/.claude/skills/gstack/bin/gstack-slug --newer-task "$CURRENT_BRANCH" 2>/dev/null || true
printf '%s\n' "$CLASSIFIED" | awk -F '\t' -v f="$FOREIGN_N" \
'$(1) == "match-root" { print "ROOT_DIFFERS " $(2) }
$(1) == "foreign" { print "FOREIGN " $(2) }
@@ -140,6 +143,8 @@ EOF
fi
fi
fi
# Checkpoints saved in a repository below this directory (BELOW_CWD, POINTER).
~/.claude/skills/gstack/bin/gstack-slug --checkpoints-below 2>/dev/null || true
```
**Candidates include every `.md` file in the directory**, but they are ordered
@@ -156,6 +161,12 @@ has written to). Prefixed lines are never candidates for "latest":
- `ROOT_DIFFERS <path>`: same repository, different checkout (Conductor workspace
handoff or a moved clone). This is information, not a mismatch; the file is
also listed as a plain path line.
- `NEWER_TASK <path>`: another branch of this repository saved a newer checkpoint
for the same task (its worktree is below this main tree, or the title or ticket
matches). The file is also a plain path line, after the current branch's own.
- `BELOW_CWD <path>` / `POINTER <path>`: saved by a session in a repository below
this directory (`POINTER`: a /context-save started here recorded it). These are
candidates, not mismatches.
### Step 2: Load the right file
@@ -165,6 +176,12 @@ has written to). Prefixed lines are never candidates for "latest":
- Otherwise: load the **first plain path line returned by Step 1 above**, which
is the newest `YYYYMMDD-HHMMSS` checkpoint of this project for the current
branch, or, if the current branch has none, the newest across all branches.
- **Do not silently take an older checkpoint.** If Step 1 printed `NEWER_TASK`,
or a `BELOW_CWD`/`POINTER` file is newer than the first plain path line (or
there is none), show both (title, branch, saved time, and `project_root` for a
file below this directory), propose the newer one as the continuation, and ask
via AskUserQuestion which to load. A `BELOW_CWD`/`POINTER` file is not a
PROJECT MISMATCH: say "Saved in `{project_root}`, below this directory."
- If Step 1 printed `NO_VERIFIED_CHECKPOINTS` or `IDENTITY_CHECK_FAILED`, do not
present any checkpoint as the latest one. Say "No checkpoint in this directory
is verified as this project's", list the `FOREIGN`/`UNVERIFIED` titles with the
@@ -245,7 +262,7 @@ unverified earlier one.
## If no saved contexts exist
If Step 1 printed `NO_CHECKPOINTS`, tell the user:
If Step 1 printed `NO_CHECKPOINTS` and no `BELOW_CWD` or `POINTER` line, tell the user:
"No saved contexts yet. Run `/context-save` first to save your current working
state, then `/context-restore` will find it."
+7 -4
View File
@@ -527,17 +527,20 @@ files_modified:
```
The `files_modified` list comes from `git status --short` (both staged and unstaged
modified files). Use relative paths from the repo root. Do not write `remote:` or
`project_root:` yourself; the stamp step below adds them.
modified files). Use relative paths from the repo root. Do not write `remote:`,
`project_root:` or `worktree:` yourself; the stamp step below adds them.
Then stamp the project identity. Several projects can share one checkpoints
directory, so `/context-restore` needs to know which project wrote each file. The helper adds `remote:` (canonical origin:
lowercased host, no credentials, port or `.git`) and `project_root:` to the
frontmatter:
lowercased host, no credentials, port or `.git`), `project_root:` and, in a
linked worktree, `worktree:` to the frontmatter. When the session started in
another directory that files elsewhere (a non-git parent of this repo), it also
leaves a pointer there so `/context-restore` in that directory finds this save:
```bash
FILE="<the FILE path printed above>"
~/.claude/skills/gstack/bin/gstack-slug --stamp-checkpoint "$FILE" && echo "STAMPED"
[ -z "${CLAUDE_PROJECT_DIR:-}" ] || ~/.claude/skills/gstack/bin/gstack-slug --point-from "$CLAUDE_PROJECT_DIR" "$FILE" 2>/dev/null || true
```
After writing, confirm to the user:
+7 -4
View File
@@ -201,17 +201,20 @@ files_modified:
```
The `files_modified` list comes from `git status --short` (both staged and unstaged
modified files). Use relative paths from the repo root. Do not write `remote:` or
`project_root:` yourself; the stamp step below adds them.
modified files). Use relative paths from the repo root. Do not write `remote:`,
`project_root:` or `worktree:` yourself; the stamp step below adds them.
Then stamp the project identity. Several projects can share one checkpoints
directory, so `/context-restore` needs to know which project wrote each file. The helper adds `remote:` (canonical origin:
lowercased host, no credentials, port or `.git`) and `project_root:` to the
frontmatter:
lowercased host, no credentials, port or `.git`), `project_root:` and, in a
linked worktree, `worktree:` to the frontmatter. When the session started in
another directory that files elsewhere (a non-git parent of this repo), it also
leaves a pointer there so `/context-restore` in that directory finds this save:
```bash
FILE="<the FILE path printed above>"
~/.claude/skills/gstack/bin/gstack-slug --stamp-checkpoint "$FILE" && echo "STAMPED"
[ -z "${CLAUDE_PROJECT_DIR:-}" ] || ~/.claude/skills/gstack/bin/gstack-slug --point-from "$CLAUDE_PROJECT_DIR" "$FILE" 2>/dev/null || true
```
After writing, confirm to the user:
+159 -2
View File
@@ -47,6 +47,7 @@ function env(extra: Record<string, string> = {}): Record<string, string> {
delete out.GSTACK_STATE_ROOT;
delete out.GSTACK_STATE_DIR;
delete out.CURRENT_BRANCH;
delete out.CLAUDE_PROJECT_DIR;
return { ...out, HOME: tmp, GSTACK_HOME: home, ...extra };
}
@@ -66,12 +67,12 @@ function repo(name: string, origin?: string): string {
}
/** The context-save flow: path block, the agent's Write, then the stamp block. */
function save(cwd: string, title: string, timestamp: string, branch = 'main'): string {
function save(cwd: string, title: string, timestamp: string, branch = 'main', extra: Record<string, string> = {}): string {
const paths = sh(SAVE_PATH_BLOCK, cwd, { TITLE_RAW: title, TEST_TIMESTAMP: timestamp });
expect(paths.status).toBe(0);
const file = paths.stdout.match(/^FILE=(.+)$/m)![1];
fs.writeFileSync(file, `---\nstatus: in-progress\nbranch: ${branch}\ntimestamp: ${timestamp}\n---\n\n## Working on: ${title}\n`);
const stamp = sh(STAMP_BLOCK.replace('FILE="<the FILE path printed above>"', `FILE=${JSON.stringify(file)}`), cwd);
const stamp = sh(STAMP_BLOCK.replace('FILE="<the FILE path printed above>"', `FILE=${JSON.stringify(file)}`), cwd, extra);
expect(stamp.stdout).toContain('STAMPED');
return file;
}
@@ -279,3 +280,159 @@ describe('gstack-slug --adopt-legacy', () => {
expect(r.stdout).toContain("Nothing to adopt: this project's slug (acme-app) did not change.");
});
});
describe('context-restore offers newer checkpoints it used to skip (#3065)', () => {
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}`);
}
/** A main tree with one commit (fixture-local identity) so worktrees can be added. */
function mainTree(name: string, origin: string): string {
const dir = repo(name, origin);
git(dir, 'config', 'user.name', 'Fixture');
git(dir, 'config', 'user.email', 'fixture@example.com');
git(dir, 'commit', '-q', '--allow-empty', '-m', 'init');
return dir;
}
function worktree(main: string, at: string, branch: string): string {
git(main, 'worktree', 'add', '-q', '-b', branch, at);
return fs.realpathSync(at);
}
const pointers = (bucket: string) => {
const dir = path.join(home, 'projects', bucket, 'checkpoint-pointers');
return fs.existsSync(dir) ? fs.readdirSync(dir).map((f) => path.join(dir, f)) : [];
};
describe('case 1: saved in a nested repo, restored in its non-git parent', () => {
function layout() {
const mono = path.join(tmp, 'mono');
fs.mkdirSync(mono);
const pkg = repo('mono/pkg', 'git@gitlab.example.com:grp/sub/repo.git');
const stale = save(mono, 'old mono notes', '20260920-100000');
return { mono, pkg, stale };
}
test('with $CLAUDE_PROJECT_DIR set, the save leaves a pointer and restore offers the newer checkpoint', () => {
const { mono, pkg, stale } = layout();
const fresh = save(pkg, 'pkg feature', '20261001-100000', 'main', { CLAUDE_PROJECT_DIR: mono });
expect(path.basename(path.dirname(path.dirname(fresh)))).toMatch(/^sub-repo-[0-9a-f]{16}$/);
const [ptr] = pointers('mono');
expect(ptr).toBeDefined();
expect(JSON.parse(fs.readFileSync(ptr, 'utf-8'))).toEqual({
checkpoint: fresh,
bucket: path.basename(path.dirname(path.dirname(fresh))),
project_root: pkg,
});
const r = restore(mono);
expect(r.latest).toBe(stale);
expect(r.lines).toContain(`POINTER ${fresh}`);
});
test('without $CLAUDE_PROJECT_DIR no pointer is written, and the below-cwd scan still finds it', () => {
const { mono, pkg, stale } = layout();
const fresh = save(pkg, 'pkg feature', '20261001-100000');
expect(pointers('mono')).toEqual([]);
const r = restore(mono);
expect(r.latest).toBe(stale);
expect(r.lines).toContain(`BELOW_CWD ${fresh}`);
expect(r.out).not.toContain('POINTER');
});
test('a save from the starting directory itself writes no pointer', () => {
const { pkg } = layout();
save(pkg, 'pkg feature', '20261001-100000', 'main', { CLAUDE_PROJECT_DIR: pkg });
expect(fs.existsSync(path.join(home, 'projects'))).toBe(true);
for (const bucket of fs.readdirSync(path.join(home, 'projects'))) expect(pointers(bucket)).toEqual([]);
});
test('checkpoints not below the cwd are not offered, and the scan reads only the 200 newest', () => {
const { mono, pkg } = layout();
const sibling = repo('elsewhere', 'git@github.com:acme/elsewhere.git');
const outside = save(sibling, 'sibling work', '20261002-100000');
const below = save(pkg, 'pkg feature', '20261001-100000');
expect(restore(mono).out).not.toContain(outside);
expect(restore(mono).lines).toContain(`BELOW_CWD ${below}`);
// 200 newer checkpoints of another project push the below-cwd one out of the read window.
const dir = path.dirname(outside);
for (let i = 0; i < 200; i++) {
fs.writeFileSync(path.join(dir, `20261003-${String(100000 + i)}-n${i}.md`), `---\nbranch: main\nproject_root: ${sibling}\n---\n`);
}
expect(restore(mono).out).not.toContain('BELOW_CWD');
});
test('from $HOME, each project below shows only its newest checkpoint', () => {
const { pkg } = layout();
const older = save(pkg, 'first pass', '20261001-100000');
const newer = save(pkg, 'second pass', '20261002-100000');
const r = sh(RESTORE_BLOCK, tmp, { CURRENT_BRANCH: 'main' });
expect(r.stdout).toContain(`BELOW_CWD ${newer}`);
expect(r.stdout).not.toContain(older);
});
test('inside a git repo the below-cwd scan does not run', () => {
const outer = repo('outer');
const inner = repo('outer/inner', 'git@github.com:acme/inner.git');
const file = save(inner, 'inner work', '20261001-100000');
expect(restore(outer).out).not.toContain(file);
// A pointer from a session that started in the outer repo still surfaces it.
save(inner, 'inner work 2', '20261002-100000', 'main', { CLAUDE_PROJECT_DIR: outer });
expect(restore(outer).out).toMatch(/^POINTER .*20261002-100000-inner-work-2\.md$/m);
});
});
describe('case 2: another branch of the same repository holds a newer checkpoint for the task', () => {
test('a ticket worktree below the main tree: restore shows the newer continuation, branch order unchanged', () => {
const main = mainTree('app', 'git@github.com:acme/app.git');
const plan = save(main, 'Login plan', '20261001-100000', 'master');
const wt = worktree(main, path.join(main, '.worktrees', 't12'), 'feature/12-login');
const impl = save(wt, 'Implement login', '20261003-100000', 'feature/12-login');
expect(fs.readFileSync(impl, 'utf-8')).toContain(`\nworktree: ${wt}\n`);
expect(fs.readFileSync(plan, 'utf-8')).not.toContain('worktree:');
const r = restore(main, 'master');
expect(r.latest).toBe(plan);
expect(r.lines).toContain(`NEWER_TASK ${impl}`);
});
test('same normalized title, or the same ticket token, on a worktree outside the main tree', () => {
const main = mainTree('app', 'git@github.com:acme/app.git');
const plan = save(main, 'Auth refactor', '20261001-100000', 'master');
const wt = worktree(main, path.join(tmp, 'app-auth'), 'auth-work');
const cont = save(wt, '2026-10-03 auth-refactor!', '20261003-100000', 'auth-work');
expect(restore(main, 'master').lines).toContain(`NEWER_TASK ${cont}`);
fs.rmSync(cont);
fs.rmSync(plan);
save(main, 'PROJ-42 wip', '20261001-110000', 'master');
const ticket = save(wt, 'wip', '20261003-110000', 'feature/PROJ-42-x');
expect(restore(main, 'master').lines).toContain(`NEWER_TASK ${ticket}`);
});
test('negative controls: generic titles alone, older saves, unrelated sibling worktrees', () => {
const main = mainTree('app', 'git@github.com:acme/app.git');
const wtA = worktree(main, path.join(main, '.worktrees', 'a'), 'feat-a');
const wtB = worktree(main, path.join(main, '.worktrees', 'b'), 'feat-b');
const outside = worktree(main, path.join(tmp, 'app-x'), 'feat-x');
save(main, 'wip', '20261001-100000', 'master');
save(outside, 'WIP', '20261003-100000', 'feat-x');
expect(restore(main, 'master').out).not.toContain('NEWER_TASK');
// An older matching checkpoint is not a continuation.
save(outside, 'Payments', '20260901-100000', 'feat-x');
save(main, 'payments', '20261002-100000', 'master');
expect(restore(main, 'master').out).not.toContain('NEWER_TASK');
// A sibling worktree's own save is never displaced by another worktree's newer one.
const own = save(wtA, 'Search box', '20261004-100000', 'feat-a');
save(wtB, 'Billing export', '20261005-100000', 'feat-b');
const r = restore(wtA, 'feat-a');
expect(r.latest).toBe(own);
expect(r.out).not.toContain('NEWER_TASK');
});
});
});