Skip to content

fix(investigate): resolve the scope lock to an absolute path and release it in Phase 5 (#2845) - #2846

Open
aegixx wants to merge 1 commit into
garrytan:mainfrom
aegixx:fix/investigate-scope-lock
Open

fix(investigate): resolve the scope lock to an absolute path and release it in Phase 5 (#2845)#2846
aegixx wants to merge 1 commit into
garrytan:mainfrom
aegixx:fix/investigate-scope-lock

Conversation

@aegixx

@aegixx aegixx commented Sep 10, 2026

Copy link
Copy Markdown

Why (in your own words)

/investigate's Scope Lock writes the debug boundary into freeze-dir.txt as a relative path (src/, its own example is src/auth/) and no phase ever removes it. That file is one machine-global state file, and check-freeze.sh resolves a relative boundary against the working directory of whatever tool call comes next. So a lock set while debugging one repo re-anchors to <other-repo>/src/ in every later Claude Code session on the machine, blocking edits nobody froze and permitting edits to the wrong tree, until someone happens to run /unfreeze. On my machine a src/ lock from 2026-09-07 blocked a scratchpad write in an unrelated session on 2026-09-10.

This PR makes the Scope Lock resolve an absolute path the same way /freeze and /guard already do (skipping loudly when the directory does not resolve, rather than freezing /), adds a Phase 5 step that releases the lock this run wrote by compare-and-delete (so a /freeze the user set beforehand is left alone), and corrects the /freeze and /guard prose that told users the boundary ends with the session.

Fixes #2845.

Live evidence

The defect, against the real hook. Temp state root, two directories standing in for two worktrees, state file holding src/, exactly what the skill wrote. Same Write, two working directories:

$ HOOK=~/.claude/skills/gstack/freeze/bin/check-freeze.sh
$ T=$(mktemp -d); mkdir -p $T/state $T/wt-a/src $T/wt-b/src $T/wt-b/scratch
$ echo "src/" > $T/state/freeze-dir.txt
$ (cd $T/wt-a && printf '{"tool_input":{"file_path":"%s/wt-a/src/a.ts"}}' $T | GSTACK_HOME=$T/state bash $HOOK)
{}
$ (cd $T/wt-b && printf '{"tool_input":{"file_path":"%s/wt-a/src/a.ts"}}' $T | GSTACK_HOME=$T/state bash $HOOK)
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Blocked: .../wt-a/src/a.ts is outside the freeze boundary (.../wt-b/src). Only edits within the frozen directory are allowed."}}
$ (cd $T/wt-b && printf '{"tool_input":{"file_path":"%s/wt-b/scratch/notes.md"}}' $T | GSTACK_HOME=$T/state bash $HOOK)
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Blocked: .../wt-b/scratch/notes.md is outside the freeze boundary (.../wt-b/src). ..."}}

# state file holding the absolute path instead: verdicts no longer depend on cwd
$ echo "$T/wt-a/src/" > $T/state/freeze-dir.txt
$ (cd $T/wt-b && printf '{"tool_input":{"file_path":"%s/wt-a/src/a.ts"}}' $T | GSTACK_HOME=$T/state bash $HOOK)
{}
$ (cd $T/wt-b && printf '{"tool_input":{"file_path":"%s/wt-b/src/b.ts"}}' $T | GSTACK_HOME=$T/state bash $HOOK)
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Blocked: .../wt-b/src/b.ts is outside the freeze boundary (.../wt-a/src). ..."}}

Regression test, failing first. Templates edited, generated files not yet regenerated:

$ bun test test/investigate-freeze-path.test.ts
(fail) investigate scope lock is absolute and released (#2845) > investigate/SKILL.md resolves the lock directory to an absolute path before writing it
(fail) investigate scope lock is absolute and released (#2845) > investigate/SKILL.md skips the lock loudly when the directory does not resolve
(fail) investigate scope lock is absolute and released (#2845) > investigate/SKILL.md releases its own lock in Phase 5, by compare-and-delete
(fail) freeze prose does not promise a session-scoped boundary (#2845) > freeze/SKILL.md
(fail) freeze prose does not promise a session-scoped boundary (#2845) > guard/SKILL.md
 11 pass
 5 fail

After bun run gen:skill-docs:

$ bun test test/investigate-freeze-path.test.ts
 16 pass
 0 fail
Ran 16 tests across 1 file. [1.78s]
$ bun run gen:skill-docs --dry-run | tail -3
FRESH: spec/sections/gate-and-file.md
FRESH: review/design-checklist.md
FRESH: lib/dom-dump.js

The new tests pin the template and the generated SKILL.md (absolute resolution, SCOPE_LOCK_SKIPPED, release block after the report, compare-and-delete guard), drive the real check-freeze.sh from two working directories to show the relative boundary flipping verdicts and the absolute one holding, and run the rendered release block against a state file it wrote, one it did not write, and no file at all.

Full free suite. bun run test:

$ bun run test
[test:free] FAIL — 6 failing test(s) in 2 file(s), 0 crashed worker(s)
  ✗ test/gstack-design-detect.test.ts (3)
  ✗ test/gstack-artifacts-init.test.ts (3)
6 shards, 6 failures. The other 4 shards: PASS (1542 / 1212 / 1619 / ... tests).

# The same 6 fail on an untouched main checkout (71f6048) on this machine, and neither
# file references investigate/, freeze/ or guard/:
$ cd <clean main checkout> && bun test test/gstack-artifacts-init.test.ts test/gstack-design-detect.test.ts
 81 pass
 6 fail
Ran 87 tests across 2 files. [49.54s]
# artifacts-init: this machine's gh git_protocol is ssh; design-detect: local impeccable install state.
# Machine-specific, pre-existing, unrelated to this diff.

Scope

  • Changed: investigate/SKILL.md.tmpl (Scope Lock resolves absolute, skips loudly on a non-directory; Phase 5 release block; one Important Rules bullet), freeze/SKILL.md.tmpl and guard/SKILL.md.tmpl (prose: the boundary is a state file that survives the session), the three regenerated SKILL.md, test/investigate-freeze-path.test.ts.
  • Verified live by: the hook reproduction above (macOS 15, gstack 1.84.1.0, global install); the regression test failing then passing; gen:skill-docs --dry-run clean.
  • Did NOT test: a full /investigate run under claude -p (the paid E2E lane). check-freeze.sh itself is unchanged.

Liveness proof (required)

image

Checklist

…ase it in Phase 5

The Scope Lock wrote `<detected-directory>/` verbatim (its own example is the
relative `src/auth/`) into the machine-global freeze-dir.txt, and no later
phase removed it. check-freeze.sh resolves a relative boundary against the
working directory of whatever tool call comes next, so one debug session's
lock re-anchored to `<other-repo>/src/` in every later session on the machine
and stayed there until someone ran /unfreeze.

- Scope Lock resolves the directory with `cd ... && pwd` before writing, the
  way /freeze and /guard already do, and prints SCOPE_LOCK_SKIPPED instead of
  freezing "/" when the directory does not resolve.
- Phase 5 releases the boundary this run wrote, on every completion status,
  by compare-and-delete so a /freeze the user set beforehand is left alone.
- /freeze and /guard prose no longer claim the boundary ends with the session.
- test/investigate-freeze-path.test.ts pins the template and generated files,
  drives the real hook from two working directories to show the relative
  boundary flipping verdicts, and exercises the rendered release block.

Fixes garrytan#2845
@trunk-io

trunk-io Bot commented Sep 10, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@aegixx
aegixx marked this pull request as ready for review September 10, 2026 23:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

investigate: Scope Lock writes a relative freeze boundary and never clears it, so one debug session's lock governs every later session on the machine

2 participants