diff --git a/bin/gstack-slug b/bin/gstack-slug index d7b1b815d..313b31c85 100755 --- a/bin/gstack-slug +++ b/bin/gstack-slug @@ -10,6 +10,9 @@ # --identity also print PROJECT_REMOTE, PROJECT_ROOT, LEGACY_SLUG # --stamp-checkpoint write remote:/project_root: into a checkpoint's frontmatter # --classify-checkpoints read checkpoint paths on stdin, print "\t" +# --checkpoints-below list other buckets' checkpoints saved below the cwd, plus pointers +# --point-from leave a pointer to in 's bucket (nested-repo saves) +# --newer-task read restore candidates on stdin, name a newer same-task checkpoint # --adopt-legacy [--copy ...] review/copy files from the pre-#3003 bucket # --adopt-legacy --from merge a former bucket (e.g. a worktree's) into this one # --adopt-legacy --dismiss 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 " >&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 " >&2; exit 2; } ;; --adopt-legacy) MODE=adopt; shift if [[ "${1:-}" == "--copy" ]]; then shift @@ -133,6 +141,12 @@ gstack-slug — print SLUG= and 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 = 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 " / "POINTER " + 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 " 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 "\037\037\037": 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." diff --git a/context-restore/SKILL.md b/context-restore/SKILL.md index 6df0d3965..56166ebba 100644 --- a/context-restore/SKILL.md +++ b/context-restore/SKILL.md @@ -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." diff --git a/context-restore/SKILL.md.tmpl b/context-restore/SKILL.md.tmpl index b95a8c59d..066594e6e 100644 --- a/context-restore/SKILL.md.tmpl +++ b/context-restore/SKILL.md.tmpl @@ -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." diff --git a/context-save/SKILL.md b/context-save/SKILL.md index db781f7c4..c8c933816 100644 --- a/context-save/SKILL.md +++ b/context-save/SKILL.md @@ -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: diff --git a/context-save/SKILL.md.tmpl b/context-save/SKILL.md.tmpl index d8313809e..87dc9b428 100644 --- a/context-save/SKILL.md.tmpl +++ b/context-save/SKILL.md.tmpl @@ -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: diff --git a/test/context-identity.test.ts b/test/context-identity.test.ts index be934c045..1ce221d29 100644 --- a/test/context-identity.test.ts +++ b/test/context-identity.test.ts @@ -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'); + }); + }); +});