diff --git a/CHANGELOG.md b/CHANGELOG.md index 234ee019f..eb48cbce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.91.224.0] - 2026-10-25 + +**`/context-restore` now says when commits landed after a checkpoint was saved, instead of offering finished steps as the plan.** `/context-save` recorded the branch, a timestamp and the modified files, but not the commit, so a checkpoint saved before a step was committed came back with that step still listed under Remaining Work. Ported from #3316 by @soyames, with their commit kept. + +### Fixed +- **`bin/gstack-slug --stamp-checkpoint`** also writes `head:`, the commit the save describes, next to `remote:`, `project_root:` and `worktree:`. A re-stamp replaces it rather than adding a second line. +- **`/context-restore`** compares `head:` with `HEAD` before presenting. When commits landed after the save it prints how many and lists up to ten, and puts every Remaining Work item under Verify first. A saved commit that is not in this checkout's history (saved on another branch, rebased, reset, or not fetched) is reported the same way. A malformed `head:` is checked as 40 or 64 hex digits before any git command sees it, `head:` is read from the frontmatter only, and outside git the comparison uses the saved `worktree:` before `project_root:`. Checkpoints saved before this keep their usual sorting with a one-line note. + +### Tests +- `test/context-restore-head.test.ts` runs the real save-path, stamp and head-check blocks from the generated SKILL.md files in fixture repos: unchanged, two commits landed, diverged after a reset, resumed from another branch, a nested linked worktree restored from outside git, a re-stamp after a new commit, pre-tracking, a `head:` that appears only in the body, and a `head: $(touch pwned)` that must never execute. + ## [1.91.223.0] - 2026-10-25 **/plan-tune's inferred profile finally moves: a clicked answer is logged as the question's registered option key, not its label.** #2623 showed every inferred dimension stuck at 0.5 on a 1,286-event log: the AskUserQuestion hook recorded labels like "A) Fix it now (recommended)", while the signal map that turns answers into dimension nudges is keyed by registered option keys like `fix-now`, so 0 of 1,286 answers matched. Now five hook-captured label answers to the registered architecture-finding question move `architecture_care` from 0.5 to 0.632, and `--trace` lists all five. diff --git a/VERSION b/VERSION index 999c96dfa..b2bda2069 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.223.0 +1.91.224.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index fc6fd4bae..31e965f93 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.223.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.224.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-slug b/bin/gstack-slug index 313b31c85..824b9bb27 100755 --- a/bin/gstack-slug +++ b/bin/gstack-slug @@ -8,7 +8,7 @@ # Code sessions refuse eval/source) # Modes (not on the hot path; see `gstack-slug --help`): # --identity also print PROJECT_REMOTE, PROJECT_ROOT, LEGACY_SLUG -# --stamp-checkpoint write remote:/project_root: into a checkpoint's frontmatter +# --stamp-checkpoint write remote:/project_root:/head: 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) @@ -138,7 +138,7 @@ gstack-slug — print SLUG= and BRANCH= for the current di field); no eval needed gstack-slug --identity also print PROJECT_REMOTE (canonical, credential-free), PROJECT_ROOT and LEGACY_SLUG (pre-#3003 bucket, if different) - gstack-slug --stamp-checkpoint FILE write remote:/project_root: into FILE's frontmatter + gstack-slug --stamp-checkpoint FILE write remote:/project_root:/head: 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 @@ -682,15 +682,18 @@ case "$MODE" in STAMP_TMP=$(mktemp "${MODE_ARG}.stamp-XXXXXX") || exit 1 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 ' + # The commit the save describes, so /context-restore can tell when work landed after it. + STAMP_HEAD=$(git -C "$PROJECT_DIR" rev-parse --verify -q HEAD 2>/dev/null) || STAMP_HEAD="" + if GRI_REMOTE="$ID_REMOTE" GRI_ROOT="$ID_ROOT" GRI_WORKTREE="$STAMP_WORKTREE" GRI_HEAD="$STAMP_HEAD" 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"] + if (ENVIRON["GRI_HEAD"] != "") print "head: " ENVIRON["GRI_HEAD"] } NR == 1 { if ($0 == "---") { infm = 1; print; next } print "---"; ids(); print "---" } infm && $0 == "---" { ids(); print; infm = 0; next } - infm && (/^remote:/ || /^project_root:/ || /^worktree:/) { next } + infm && (/^remote:/ || /^project_root:/ || /^worktree:/ || /^head:/) { next } { print } END { if (NR == 0) { print "---"; ids(); print "---" } }' "$MODE_ARG" > "$STAMP_TMP"; then mv "$STAMP_TMP" "$MODE_ARG" diff --git a/context-restore/SKILL.md b/context-restore/SKILL.md index 4238273ad..833dd0f5b 100644 --- a/context-restore/SKILL.md +++ b/context-restore/SKILL.md @@ -527,8 +527,50 @@ checkpoint(s) from another project; only this project's are listed." If it print `LEGACY_BUCKET`, relay that line. If the chosen file is `ROOT_DIFFERS`, add "Saved from another checkout of this repository at `{project_root}`." as info. +**Check whether the branch moved since the save.** The stamp records the commit +the save was made at in `head:`. Before presenting, compare it with this +checkout. FILE is the checkpoint path chosen in Step 2, one of the paths Step 1 +printed: + +```bash +FILE="" +SAVED_HEAD=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^head:/{s/^head:[[:space:]]*//p;q;}') +REPO_DIR=. +git rev-parse --git-dir >/dev/null 2>&1 || REPO_DIR=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^worktree:/{s/^worktree:[[:space:]]*//p;q;}') +[ -n "$REPO_DIR" ] || REPO_DIR=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^project_root:/{s/^project_root:[[:space:]]*//p;q;}') +CURRENT_HEAD=$(git -C "${REPO_DIR:-.}" rev-parse --verify -q HEAD 2>/dev/null) +if [ -z "$SAVED_HEAD" ]; then + echo "HEAD_UNTRACKED" +elif [ -z "$CURRENT_HEAD" ]; then + echo "HEAD_NOT_CHECKED" +elif [ "$SAVED_HEAD" = "$CURRENT_HEAD" ]; then + echo "HEAD_UNCHANGED" +elif ! printf '%s\n' "$SAVED_HEAD" | grep -Eqx '[0-9a-f]{40}([0-9a-f]{24})?' \ + || ! git -C "$REPO_DIR" cat-file -e "$SAVED_HEAD^{commit}" 2>/dev/null; then + echo "HEAD_UNKNOWN $SAVED_HEAD" +elif git -C "$REPO_DIR" merge-base --is-ancestor "$SAVED_HEAD" "$CURRENT_HEAD"; then + echo "HEAD_MOVED $(git -C "$REPO_DIR" rev-list --count "$SAVED_HEAD..$CURRENT_HEAD")" + git -C "$REPO_DIR" log --oneline -10 "$SAVED_HEAD..$CURRENT_HEAD" +else + echo "HEAD_DIVERGED $SAVED_HEAD" +fi +``` + +- `HEAD_UNCHANGED` or `HEAD_NOT_CHECKED`: nothing to add. +- `HEAD_MOVED `: print ` commit(s) landed since this save:` and the commit + lines above the Remaining Work groups, and list **every** Remaining Work item + under **Verify first**: the saving session could not know about those commits, + so any step may already be done. +- `HEAD_DIVERGED` or `HEAD_UNKNOWN`: print `This checkpoint was saved at {first 7 + characters of head}, which is not in this checkout's history (saved on another + branch, rebased, reset, or not fetched).` and list every item under Verify first. +- `HEAD_UNTRACKED`: print `This checkpoint predates commit tracking; restore cannot tell whether work landed after it.` + and sort as usual. + **Sort Remaining Work by provenance.** Keep every item's original text and saved -order, and drop nothing. Put an item under **Verify first** when it: +order, and drop nothing. When the head check printed `HEAD_MOVED`, `HEAD_DIVERGED` +or `HEAD_UNKNOWN`, skip the rules below: every item goes under Verify first, +whatever its marker. Put an item under **Verify first** when it: - ends in `(path assumed)` or `(code read)`; - ends in `(path run)` but its text reports a failure; - ends in a parenthesised trailing marker that is **not** one of the five English @@ -563,6 +605,7 @@ Status: {status} {summary from saved file} ### Remaining Work +{commit-tracking lines, if any} {legacy banner line, if it applies} Next steps @@ -616,6 +659,8 @@ in the old bucket until the user copies them with `gstack-slug --adopt-legacy`. - **Never present another project's checkpoint as "latest".** Identity is the canonical remote (the root only when there is no remote); a mismatch is reported before any summary. +- **A checkpoint describes the commit it was saved at.** When commits landed + after the save, every Remaining Work item is verified before it is run. - **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not `ls -1t` (filesystem mtime). Filenames are stable across file-system operations; mtime is not. diff --git a/context-restore/SKILL.md.tmpl b/context-restore/SKILL.md.tmpl index dbebf92e5..c964a88f0 100644 --- a/context-restore/SKILL.md.tmpl +++ b/context-restore/SKILL.md.tmpl @@ -203,8 +203,50 @@ checkpoint(s) from another project; only this project's are listed." If it print `LEGACY_BUCKET`, relay that line. If the chosen file is `ROOT_DIFFERS`, add "Saved from another checkout of this repository at `{project_root}`." as info. +**Check whether the branch moved since the save.** The stamp records the commit +the save was made at in `head:`. Before presenting, compare it with this +checkout. FILE is the checkpoint path chosen in Step 2, one of the paths Step 1 +printed: + +```bash +FILE="" +SAVED_HEAD=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^head:/{s/^head:[[:space:]]*//p;q;}') +REPO_DIR=. +git rev-parse --git-dir >/dev/null 2>&1 || REPO_DIR=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^worktree:/{s/^worktree:[[:space:]]*//p;q;}') +[ -n "$REPO_DIR" ] || REPO_DIR=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^project_root:/{s/^project_root:[[:space:]]*//p;q;}') +CURRENT_HEAD=$(git -C "${REPO_DIR:-.}" rev-parse --verify -q HEAD 2>/dev/null) +if [ -z "$SAVED_HEAD" ]; then + echo "HEAD_UNTRACKED" +elif [ -z "$CURRENT_HEAD" ]; then + echo "HEAD_NOT_CHECKED" +elif [ "$SAVED_HEAD" = "$CURRENT_HEAD" ]; then + echo "HEAD_UNCHANGED" +elif ! printf '%s\n' "$SAVED_HEAD" | grep -Eqx '[0-9a-f]{40}([0-9a-f]{24})?' \ + || ! git -C "$REPO_DIR" cat-file -e "$SAVED_HEAD^{commit}" 2>/dev/null; then + echo "HEAD_UNKNOWN $SAVED_HEAD" +elif git -C "$REPO_DIR" merge-base --is-ancestor "$SAVED_HEAD" "$CURRENT_HEAD"; then + echo "HEAD_MOVED $(git -C "$REPO_DIR" rev-list --count "$SAVED_HEAD..$CURRENT_HEAD")" + git -C "$REPO_DIR" log --oneline -10 "$SAVED_HEAD..$CURRENT_HEAD" +else + echo "HEAD_DIVERGED $SAVED_HEAD" +fi +``` + +- `HEAD_UNCHANGED` or `HEAD_NOT_CHECKED`: nothing to add. +- `HEAD_MOVED `: print ` commit(s) landed since this save:` and the commit + lines above the Remaining Work groups, and list **every** Remaining Work item + under **Verify first**: the saving session could not know about those commits, + so any step may already be done. +- `HEAD_DIVERGED` or `HEAD_UNKNOWN`: print `This checkpoint was saved at {first 7 + characters of head}, which is not in this checkout's history (saved on another + branch, rebased, reset, or not fetched).` and list every item under Verify first. +- `HEAD_UNTRACKED`: print `This checkpoint predates commit tracking; restore cannot tell whether work landed after it.` + and sort as usual. + **Sort Remaining Work by provenance.** Keep every item's original text and saved -order, and drop nothing. Put an item under **Verify first** when it: +order, and drop nothing. When the head check printed `HEAD_MOVED`, `HEAD_DIVERGED` +or `HEAD_UNKNOWN`, skip the rules below: every item goes under Verify first, +whatever its marker. Put an item under **Verify first** when it: - ends in `(path assumed)` or `(code read)`; - ends in `(path run)` but its text reports a failure; - ends in a parenthesised trailing marker that is **not** one of the five English @@ -239,6 +281,7 @@ Status: {status} {summary from saved file} ### Remaining Work +{commit-tracking lines, if any} {legacy banner line, if it applies} Next steps @@ -292,6 +335,8 @@ in the old bucket until the user copies them with `gstack-slug --adopt-legacy`. - **Never present another project's checkpoint as "latest".** Identity is the canonical remote (the root only when there is no remote); a mismatch is reported before any summary. +- **A checkpoint describes the commit it was saved at.** When commits landed + after the save, every Remaining Work item is verified before it is run. - **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not `ls -1t` (filesystem mtime). Filenames are stable across file-system operations; mtime is not. diff --git a/context-save/SKILL.md b/context-save/SKILL.md index 851260191..29032e6c2 100644 --- a/context-save/SKILL.md +++ b/context-save/SKILL.md @@ -542,12 +542,13 @@ 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:`, -`project_root:` or `worktree:` yourself; the stamp step below adds them. +`project_root:`, `worktree:` or `head:` 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`), `project_root:` and, in a -linked worktree, `worktree:` to the frontmatter. When the session started in +lowercased host, no credentials, port or `.git`), `project_root:`, in a linked +worktree `worktree:`, and `head:` (the commit this save describes, so +`/context-restore` can tell when work landed after it) 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: diff --git a/context-save/SKILL.md.tmpl b/context-save/SKILL.md.tmpl index 91fce9edf..4166cd159 100644 --- a/context-save/SKILL.md.tmpl +++ b/context-save/SKILL.md.tmpl @@ -217,12 +217,13 @@ 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:`, -`project_root:` or `worktree:` yourself; the stamp step below adds them. +`project_root:`, `worktree:` or `head:` 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`), `project_root:` and, in a -linked worktree, `worktree:` to the frontmatter. When the session started in +lowercased host, no credentials, port or `.git`), `project_root:`, in a linked +worktree `worktree:`, and `head:` (the commit this save describes, so +`/context-restore` can tell when work landed after it) 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: diff --git a/package.json b/package.json index 28dbec666..723b9014a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.223", + "version": "1.91.224", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/context-restore-head.test.ts b/test/context-restore-head.test.ts new file mode 100644 index 000000000..d7195574d --- /dev/null +++ b/test/context-restore-head.test.ts @@ -0,0 +1,202 @@ +/** + * /context-save records the commit a checkpoint describes, and /context-restore + * notices when commits landed after it, end to end. + * + * Runs the REAL save-path, stamp and head-check bash blocks extracted from the + * generated context-save and context-restore SKILL.md files (with + * ~/.claude/skills/gstack pointed at this checkout) inside fixture repos. The bug + * these pin: a checkpoint saved before work landed came back as the current plan, + * with nothing saying HEAD had moved, so restore offered to redo finished steps. + * + * Value: protects=save stamps head: and restore reports unchanged, moved, diverged, untracked and unknown commits; fails_when=the stamp drops head: or restore stops comparing it with HEAD; why_new=no test compares a checkpoint with commits made after it; seam=none; no_claim=fixture repos and a hand-written checkpoint body stand in for a live /context-save, so green does not prove the agent follows the prompt + */ +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, '..'); +// Forward slashes keep the path intact inside bash on Windows (Git Bash); a no-op elsewhere. +const BASH_ROOT = ROOT.split(path.sep).join('/'); + +function bashBlockAfter(skillFile: string, heading: string, contains?: string): string { + const md = fs.readFileSync(path.join(ROOT, skillFile), 'utf-8'); + const start = md.indexOf(heading); + if (start < 0) throw new Error(`${skillFile}: heading not found: ${heading}`); + const re = /```bash\n([\s\S]*?)```/g; + re.lastIndex = start; + for (let m = re.exec(md); m; m = re.exec(md)) { + if (!contains || m[1].includes(contains)) return m[1].replaceAll('~/.claude/skills/gstack', BASH_ROOT); + } + throw new Error(`${skillFile}: no bash block after ${heading}`); +} + +const SAVE_PATH_BLOCK = bashBlockAfter('context-save/SKILL.md', '### Step 4: Write saved-context file').replace( + 'TIMESTAMP=$(date +%Y%m%d-%H%M%S)', + 'TIMESTAMP="$TEST_TIMESTAMP"', +); +const STAMP_BLOCK = bashBlockAfter('context-save/SKILL.md', '### Step 4: Write saved-context file', '--stamp-checkpoint'); +const HEAD_BLOCK = bashBlockAfter('context-restore/SKILL.md', '**Check whether the branch moved since the save.**', 'SAVED_HEAD'); + +let tmp: string; +let home: string; + +function env(extra: Record = {}): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(process.env)) if (v !== undefined) out[k] = v; + delete out.GSTACK_PROJECT_SLUG; + 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 }; +} + +function sh(script: string, cwd: string, extra: Record = {}) { + const r = spawnSync('bash', ['-c', script], { cwd, env: env(extra), encoding: 'utf-8', timeout: 30_000 }); + return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 }; +} + +function git(cwd: string, ...args: string[]): 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}`); + return r.stdout.trim(); +} + +function commit(cwd: string, subject: string) { + fs.appendFileSync(path.join(cwd, 'app.txt'), `${subject}\n`); + git(cwd, 'add', 'app.txt'); + git(cwd, 'commit', '-q', '-m', subject); +} + +function repo(): string { + const dir = path.join(tmp, 'app'); + fs.mkdirSync(dir, { recursive: true }); + git(dir, 'init', '-q', '-b', 'main'); + git(dir, 'remote', 'add', 'origin', 'https://github.com/example/head-check.git'); + git(dir, 'config', 'user.name', 'gstack test'); + git(dir, 'config', 'user.email', 'gstack-test@example.invalid'); + commit(dir, 'baseline'); + return dir; +} + +/** The context-save flow: path block, the agent's Write, then the stamp block. */ +function save(cwd: string): string { + const paths = sh(SAVE_PATH_BLOCK, cwd, { TITLE_RAW: 'login flow', TEST_TIMESTAMP: '20261020-090000' }); + expect(paths.status).toBe(0); + const file = paths.stdout.match(/^FILE=(.+)$/m)![1]; + fs.writeFileSync(file, '---\nstatus: in-progress\nbranch: main\n---\n\n## Working on: login flow\n'); + const stamp = sh(STAMP_BLOCK.replace('FILE=""', `FILE=${JSON.stringify(file)}`), cwd); + expect(stamp.stdout).toContain('STAMPED'); + return file; +} + +function headCheck(cwd: string, file: string) { + const r = sh(HEAD_BLOCK.replace('FILE=""', `FILE=${JSON.stringify(file)}`), cwd); + expect(r.status).toBe(0); + return r.stdout; +} + +beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ctx-head-'))); + home = path.join(tmp, '.gstack'); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('context-save records the commit a checkpoint describes', () => { + test('the stamp writes head: once, and re-stamping does not duplicate it', () => { + const app = repo(); + const file = save(app); + const fm = fs.readFileSync(file, 'utf-8').split('---')[1]; + expect(fm).toContain(`\nhead: ${git(app, 'rev-parse', 'HEAD')}\n`); + commit(app, 'later work'); + const restamp = spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-slug'), '--stamp-checkpoint', file], { cwd: app, env: env(), timeout: 30_000 }); + expect(restamp.status).toBe(0); + expect(fs.readFileSync(file, 'utf-8').split('---')[1].match(/^head:.*$/gm)).toEqual([`head: ${git(app, 'rev-parse', 'HEAD')}`]); + }); +}); + +describe('context-restore compares the checkpoint with HEAD', () => { + test('nothing to add when no commit landed after the save', () => { + const app = repo(); + expect(headCheck(app, save(app)).trim()).toBe('HEAD_UNCHANGED'); + }); + + test('commits that landed after the save are counted and listed', () => { + const app = repo(); + const file = save(app); + commit(app, 'add the login route'); + commit(app, 'add login tests'); + const out = headCheck(app, file); + expect(out).toStartWith('HEAD_MOVED 2\n'); + expect(out).toContain('add the login route'); + expect(out).toContain('add login tests'); + }); + + test('a checkpoint whose commit left the branch history is reported, not trusted', () => { + const app = repo(); + commit(app, 'work in progress'); + const file = save(app); + git(app, 'reset', '-q', '--hard', 'HEAD~1'); + commit(app, 'rewritten work'); + expect(headCheck(app, file)).toStartWith('HEAD_DIVERGED '); + }); + + test('a checkpoint resumed from another branch is reported, and the prompt names that cause', () => { + const app = repo(); + git(app, 'checkout', '-q', '-b', 'feature'); + commit(app, 'feature work'); + const file = save(app); + git(app, 'checkout', '-q', 'main'); + commit(app, 'unrelated main work'); + expect(headCheck(app, file)).toStartWith('HEAD_DIVERGED '); + const restore = fs.readFileSync(path.join(ROOT, 'context-restore/SKILL.md'), 'utf-8'); + expect(restore).toContain('(saved on another\n branch, rebased, reset, or not fetched)'); + }); + + test('checkpoints saved before head tracking, or with a malformed head, are never treated as current', () => { + const app = repo(); + const legacy = path.join(tmp, 'legacy.md'); + fs.writeFileSync(legacy, '---\nstatus: in-progress\nbranch: main\n---\n'); + expect(headCheck(app, legacy).trim()).toBe('HEAD_UNTRACKED'); + const bogus = path.join(tmp, 'bogus.md'); + fs.writeFileSync(bogus, '---\nbranch: main\nhead: $(touch pwned)\n---\n'); + expect(headCheck(app, bogus)).toStartWith('HEAD_UNKNOWN '); + expect(fs.existsSync(path.join(app, 'pwned'))).toBe(false); + const bodyOnly = path.join(tmp, 'body-only.md'); + fs.writeFileSync(bodyOnly, `---\nstatus: in-progress\nbranch: main\n---\n\nhead: ${git(app, 'rev-parse', 'HEAD~0')}\n`); + expect(headCheck(app, bodyOnly).trim()).toBe('HEAD_UNTRACKED'); + }); + + test('outside git, a save from a nested linked worktree is compared with that worktree, not the main checkout', () => { + const app = repo(); + const wt = path.join(app, '.worktrees', 'side'); + git(app, 'worktree', 'add', '-q', '-b', 'side', wt); + const file = save(wt); + expect(fs.readFileSync(file, 'utf-8')).toContain(`\nworktree: ${wt}\n`); + commit(app, 'main moves on'); + commit(app, 'and again'); + const outside = path.join(tmp, 'outside'); + fs.mkdirSync(outside); + expect(headCheck(outside, file).trim()).toBe('HEAD_UNCHANGED'); + commit(wt, 'worktree work'); + expect(headCheck(outside, file)).toStartWith('HEAD_MOVED 1\n'); + }); + + test('the prompt maps every status to what the user sees', () => { + const restore = fs.readFileSync(path.join(ROOT, 'context-restore/SKILL.md'), 'utf-8'); + for (const status of ['HEAD_UNCHANGED', 'HEAD_NOT_CHECKED', 'HEAD_MOVED ', 'HEAD_DIVERGED', 'HEAD_UNKNOWN', 'HEAD_UNTRACKED']) { + expect(restore).toContain(`\`${status}\``); + } + expect(restore).toContain('` commit(s) landed since this save:`'); + expect(restore).toContain('under **Verify first**'); + expect(restore).toContain('{commit-tracking lines, if any}'); + expect(restore).toContain('When the head check printed `HEAD_MOVED`, `HEAD_DIVERGED`\nor `HEAD_UNKNOWN`, skip the rules below: every item goes under Verify first,\nwhatever its marker.'); + const save = fs.readFileSync(path.join(ROOT, 'context-save/SKILL.md'), 'utf-8'); + expect(save).toContain('`project_root:`, `worktree:` or `head:` yourself'); + }); +});