From 676a21376d2961d39153f22462fb5682e360aaeb Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 24 Oct 2026 23:11:03 +0000 Subject: [PATCH] fix(context-restore): head check passes the fence lint, reads frontmatter only, prefers worktree:, and overrides provenance sorting - The block used a free-text placeholder, , which the generated-bash lint (test/env-host-fence-conformance.test.ts) refuses on every host. It now uses the allowlisted , with prose naming the Step 2 choice. - head:, worktree: and project_root: are read from the leading frontmatter only (sed, no positional $N tokens Claude Code would rewrite), so a head: line in a checkpoint's notes no longer counts. - Outside git, the comparison uses the saved worktree: before project_root:, so a save from a nested linked worktree is not compared with the main tree. - The provenance rules now say they are skipped after HEAD_MOVED, HEAD_DIVERGED or HEAD_UNKNOWN, instead of contradicting the head check. - The re-stamp test checks the exit status and that the head moves. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 ++-- context-restore/SKILL.md | 14 +++++++++----- context-restore/SKILL.md.tmpl | 14 +++++++++----- test/context-restore-head.test.ts | 27 ++++++++++++++++++++++++--- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9ec886f..f25de48df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,10 @@ ### 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. Checkpoints saved before this keep their usual sorting with a one-line note. +- **`/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, pre-tracking, and a `head: $(touch pwned)` that must never execute. +- `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.186.0] - 2026-10-24 diff --git a/context-restore/SKILL.md b/context-restore/SKILL.md index 2cfcc402f..833dd0f5b 100644 --- a/context-restore/SKILL.md +++ b/context-restore/SKILL.md @@ -529,13 +529,15 @@ checkpoint(s) from another project; only this project's are listed." If it print **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: +checkout. FILE is the checkpoint path chosen in Step 2, one of the paths Step 1 +printed: ```bash -FILE="" -SAVED_HEAD=$(grep -m1 '^head:' "$FILE" 2>/dev/null | sed 's/^head:[[:space:]]*//' | tr -d '\r') +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=$(grep -m1 '^project_root:' "$FILE" 2>/dev/null | sed 's/^project_root:[[:space:]]*//' | tr -d '\r') +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" @@ -566,7 +568,9 @@ fi 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 diff --git a/context-restore/SKILL.md.tmpl b/context-restore/SKILL.md.tmpl index 0f4d974ec..c964a88f0 100644 --- a/context-restore/SKILL.md.tmpl +++ b/context-restore/SKILL.md.tmpl @@ -205,13 +205,15 @@ checkpoint(s) from another project; only this project's are listed." If it print **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: +checkout. FILE is the checkpoint path chosen in Step 2, one of the paths Step 1 +printed: ```bash -FILE="" -SAVED_HEAD=$(grep -m1 '^head:' "$FILE" 2>/dev/null | sed 's/^head:[[:space:]]*//' | tr -d '\r') +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=$(grep -m1 '^project_root:' "$FILE" 2>/dev/null | sed 's/^project_root:[[:space:]]*//' | tr -d '\r') +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" @@ -242,7 +244,9 @@ fi 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 diff --git a/test/context-restore-head.test.ts b/test/context-restore-head.test.ts index 7365cb039..d7195574d 100644 --- a/test/context-restore-head.test.ts +++ b/test/context-restore-head.test.ts @@ -93,7 +93,7 @@ function save(cwd: string): string { } function headCheck(cwd: string, file: string) { - const r = sh(HEAD_BLOCK.replace('FILE=""', `FILE=${JSON.stringify(file)}`), cwd); + const r = sh(HEAD_BLOCK.replace('FILE=""', `FILE=${JSON.stringify(file)}`), cwd); expect(r.status).toBe(0); return r.stdout; } @@ -113,8 +113,10 @@ describe('context-save records the commit a checkpoint describes', () => { const file = save(app); const fm = fs.readFileSync(file, 'utf-8').split('---')[1]; expect(fm).toContain(`\nhead: ${git(app, 'rev-parse', 'HEAD')}\n`); - spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-slug'), '--stamp-checkpoint', file], { cwd: app, env: env(), timeout: 30_000 }); - expect(fs.readFileSync(file, 'utf-8').split('---')[1].match(/^head:/gm)?.length).toBe(1); + 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')}`]); }); }); @@ -165,6 +167,24 @@ describe('context-restore compares the checkpoint with HEAD', () => { 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', () => { @@ -175,6 +195,7 @@ describe('context-restore compares the checkpoint with HEAD', () => { 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'); });