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, <the chosen file>, which the
  generated-bash lint (test/env-host-fence-conformance.test.ts) refuses on
  every host. It now uses the allowlisted <the FILE path printed above>, 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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-24 23:13:26 +00:00
1 parent 6e6b1520f7
commit 676a21376d
4 files changed
+44 -15

No files matched your search

+2 -2
View File
@@ -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
+9 -5
View File
@@ -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="<the chosen file>"
SAVED_HEAD=$(grep -m1 '^head:' "$FILE" 2>/dev/null | sed 's/^head:[[:space:]]*//' | tr -d '\r')
FILE="<the FILE path printed above>"
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
+9 -5
View File
@@ -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="<the chosen file>"
SAVED_HEAD=$(grep -m1 '^head:' "$FILE" 2>/dev/null | sed 's/^head:[[:space:]]*//' | tr -d '\r')
FILE="<the FILE path printed above>"
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
+24 -3
View File
@@ -93,7 +93,7 @@ function save(cwd: string): string {
}
function headCheck(cwd: string, file: string) {
const r = sh(HEAD_BLOCK.replace('FILE="<the chosen file>"', `FILE=${JSON.stringify(file)}`), cwd);
const r = sh(HEAD_BLOCK.replace('FILE="<the FILE path printed above>"', `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('`<n> 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');
});