fix: /freeze, /guard, /unfreeze and /investigate work on Codex and other env-var hosts

Templates quoted "$HOME/.claude/skills/gstack/...", which the env-var hosts'
rewrites turned into "$HOME/.agents/skills/gstack/..." on Codex (a path a
global install never has; same class on the other hosts), and setup's
runtime roots had no freeze/bin. Quoted runtime paths now use
{{RUNTIME_ROOT}}: $GSTACK_ROOT on env-var hosts (the fence gets the
runtime prelude), the unchanged $HOME path on Claude. _link_runtime_dists
links freeze/bin and the careful/bin helper it sources into every
env-var host's root. Also fixes the same guess in /benchmark-models,
/retro and /diagram. test/env-host-runtime-assets.test.ts fails on the
previous code: no fence guesses a $HOME/<dir>/skills/gstack path, every
$GSTACK_ROOT path a fence uses is in the host's runtime root, and the
installed Codex /freeze and /unfreeze fences run from a fresh shell.
Template-reading tests now read the Claude render or expect the
placeholder (deliberate change).
This commit is contained in:
garrytan committed 2026-10-04 19:15:52 +00:00
1 parent c03236e40d
commit 5d2e6e76fb
16 files changed
+155 -20

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ Different from `/benchmark` — that skill measures web page performance (Core W
## Step 0: Locate the binary
```bash
BIN="$HOME/.claude/skills/gstack/bin/gstack-model-benchmark"
BIN="{{RUNTIME_ROOT}}/bin/gstack-model-benchmark"
[ -x "$BIN" ] || BIN=".claude/skills/gstack/bin/gstack-model-benchmark"
[ -x "$BIN" ] || { echo "ERROR: gstack-model-benchmark not found. Run ./setup in the gstack install dir." >&2; exit 1; }
echo "BIN: $BIN"
+1 -1
View File
@@ -73,7 +73,7 @@ gstack versions never clobber each other.
```bash
BUNDLE=""
for c in "$HOME/.claude/skills/gstack/lib/diagram-render/dist/diagram-render.html" \
for c in "{{RUNTIME_ROOT}}/lib/diagram-render/dist/diagram-render.html" \
"$(git rev-parse --show-toplevel 2>/dev/null)/lib/diagram-render/dist/diagram-render.html"; do
[ -f "$c" ] && BUNDLE="$c" && break
done
+1 -1
View File
@@ -52,7 +52,7 @@ Once the user provides a directory path:
Set the user-selected boundary with the shared state writer. It resolves the physical absolute path and serializes replacement with investigation cleanup:
```bash
bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" set "<user-provided-path>"
bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" set "<user-provided-path>"
```
Only report success if the helper succeeds. On `FREEZE_BUSY` or unexpected state, preserve it and ask the user to inspect recovery after any active writer finishes; never write or delete the state file directly.
+1 -1
View File
@@ -61,7 +61,7 @@ Once the user provides a directory path:
Set the user-selected boundary with the shared writer, which resolves a physical absolute path and serializes replacement with investigation cleanup:
```bash
bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" set "<user-provided-path>"
bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" set "<user-provided-path>"
```
On helper failure, do not claim the boundary is active. Preserve the state and report recovery; never bypass the shared writer with a direct write or deletion.
+1 -1
View File
@@ -42,7 +42,7 @@ const codex = defineHost({
// Mirrors create_codex_runtime_root in setup. design/dist and make-pdf/dist
// back $GSTACK_DESIGN and $GSTACK_MAKE_PDF (#2891).
runtimeRoot: {
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md'],
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'freeze/bin', 'careful/bin', 'gstack-upgrade', 'ETHOS.md'],
globalFiles: {
'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
},
+1 -1
View File
@@ -126,7 +126,7 @@ export function defineHost<const N extends string>(overrides: HostOverrides<N>):
toolRewrites,
suppressedResolvers = [...GBRAIN_RESOLVERS],
runtimeRoot = {
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md'],
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'freeze/bin', 'careful/bin', 'gstack-upgrade', 'ETHOS.md'],
globalFiles: {
'review': ['checklist.md', 'TODOS-format.md'],
},
+1 -1
View File
@@ -14,7 +14,7 @@ const opencode = defineHost({
// OpenCode links a wider runtime asset set than the shared default
// (design binary, review specialists, qa templates/references, DX hall of fame).
runtimeRoot: {
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'],
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'freeze/bin', 'careful/bin', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'],
globalFiles: {
'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
},
+3 -3
View File
@@ -121,14 +121,14 @@ After forming your root cause hypothesis, lock edits to the affected module to p
```bash
# $HOME-anchored like the careful/freeze frontmatter hooks: early skill bash runs
# before CLAUDE_SKILL_DIR exists, so a relative path would never resolve.
_FREEZE_SCRIPT="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"
_FREEZE_SCRIPT="{{RUNTIME_ROOT}}/freeze/bin/check-freeze.sh"
[ -x "$_FREEZE_SCRIPT" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE"
```
**If FREEZE_AVAILABLE:** Identify the narrowest directory containing the affected files. Acquire a run-owned boundary; the helper resolves its physical absolute path and leaves any pre-existing user or other-run boundary untouched:
```bash
bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" acquire "<detected-directory>"
bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" acquire "<detected-directory>"
```
Substitute `<detected-directory>` with the actual path (e.g., `src/auth/`). Retain the exact returned `FREEZE_OWNER` token in this run's context (including any checkpoint); never reconstruct it from the current state file. Only a returned token means this run owns a new lock. `FREEZE_PRESERVED` means keep the existing boundary and do not clean it up. On acquisition error, pause before edits and report it; never claim a lock was acquired. Relative legacy state is ambiguous: ask the user to re-establish an absolute boundary via `/freeze`, rather than guessing its original cwd.
@@ -138,7 +138,7 @@ Tell the user the boundary and its owner disposition. Hooks enforce Edit/Write r
**Terminal cleanup:** On completion, explicit abort, or any known error that ends this investigation, run the following with this run's retained token, before the final response. Skip it when this run acquired no token:
```bash
bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" release "<retained-owner-token>"
bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" release "<retained-owner-token>"
```
The helper compares ownership and removes state under the same mutation lock used by `/freeze`, `/guard`, and `/unfreeze`; a replacement boundary is preserved, even at the same path. Report cleanup errors or `FREEZE_PRESERVED`, never retry by deleting state directly. A hard-killed session cannot run this cleanup: recovery is explicit `/unfreeze` (user-requested removal) or `/freeze` (user-selected replacement). If a mutation lock was abandoned, inspect it with the user after confirming no writer is active; never automatically delete an ambiguous lock.
+1 -1
View File
@@ -106,7 +106,7 @@ Remember whether the fetch succeeded — the stale-base guard in Step 1 only BLO
Run `gstack-retro-metrics` with the detected base branch and computed start:
```bash
_RM="$HOME/.claude/skills/gstack/bin/gstack-retro-metrics"
_RM="{{RUNTIME_ROOT}}/bin/gstack-retro-metrics"
[ -x "$_RM" ] || _RM=".claude/skills/gstack/bin/gstack-retro-metrics"
"$_RM" --base "<default>" --since "<since>" \
|| echo "RETRO_METRICS: unavailable — stale install (read the helper source for manual computation)"
+4
View File
@@ -140,6 +140,10 @@ export const RESOLVERS: Record<string, ResolverFn> = {
DX_FRAMEWORK: generateDxFramework,
TASTE_PROFILE: generateTasteProfile,
BIN_DIR: (ctx) => ctx.paths.binDir,
// Quotable runtime root for paths under gstack's install (freeze/bin, bin, lib):
// $GSTACK_ROOT on env-var hosts (each fence gets the runtime prelude), the
// literal $HOME path on Claude, so a quoted path keeps working with spaces.
RUNTIME_ROOT: (ctx) => getHostConfig(ctx.host).usesEnvVars ? '$GSTACK_ROOT' : `$HOME/${getHostConfig(ctx.host).globalRoot}`,
// Literal bin dir for skills that skip the shared preamble, where
// $GSTACK_BIN is never set (#2906): the per-install root when rendering for
// one, else the host's default global root.
+8 -5
View File
@@ -621,15 +621,18 @@ _link_or_copy() {
}
# _link_runtime_dists SOURCE ROOT — the compiled design and make-pdf binaries
# behind $GSTACK_DESIGN and $GSTACK_MAKE_PDF on every env-var host (#2891).
# dist/ only, never the whole directory: make-pdf/ ships a Claude SKILL.md a
# host scanning its skills root would discover as a duplicate skill.
# behind $GSTACK_DESIGN and $GSTACK_MAKE_PDF on every env-var host (#2891),
# and freeze/bin (with the careful/bin helper it sources), whose state and
# check scripts /freeze, /guard, /unfreeze and /investigate run as
# "$GSTACK_ROOT/freeze/bin/...". Subdirectories only, never the whole skill
# directory: make-pdf/, freeze/ and careful/ ship a Claude SKILL.md a host
# scanning its skills root would discover as a duplicate skill.
_link_runtime_dists() {
local d
for d in design/dist make-pdf/dist; do
for d in design/dist make-pdf/dist freeze/bin careful/bin; do
[ -d "$1/$d" ] || continue
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$2/$d" ] || [ ! -e "$2/$d" ]; then
mkdir -p "$2/${d%/dist}"
mkdir -p "$2/${d%/*}"
_link_or_copy "$1/$d" "$2/$d"
fi
done
+123
View File
@@ -0,0 +1,123 @@
/**
* Env-var hosts reach gstack's runtime files only through their installed
* runtime root (found in Codex integration, lane W, 2026-10-04).
*
* Templates that quoted "$HOME/.claude/skills/gstack/..." were rewritten to
* "$HOME/.agents/skills/gstack/..." on Codex, a directory a global install
* does not have, and the runtime roots setup builds had no freeze/bin, so
* /freeze, /guard, /unfreeze and /investigate's edit boundary failed on every
* env-var host. Fences now use $GSTACK_ROOT (with the runtime prelude) and
* setup links freeze/bin plus the careful/bin helper it sources.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ALL_HOST_CONFIGS } from '../hosts';
import { runGeneration } from '../scripts/gen-skill-docs';
const ROOT = path.resolve(import.meta.dir, '..');
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const ENV_HOSTS = ALL_HOST_CONFIGS.filter(h => h.usesEnvVars);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-runtime-assets-'));
const renderDir = path.join(tmp, 'render');
// Lookups that probe Claude-layout installs on purpose and fall through when
// absent: /gstack-upgrade's install-type detection and the optional Chrome
// extension path. design-html's vendored pretext.js is an optional probe too.
const PROBE_FILES = /\/gstack-(?:upgrade|open-gstack-browser|connect-chrome)\/SKILL\.md$/;
const OPTIONAL_ROOT_PATHS = ['design-html/vendor'];
function walk(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e =>
e.isDirectory() ? walk(path.join(dir, e.name)) : e.name.endsWith('.md') ? [path.join(dir, e.name)] : []);
}
const fences = (text: string) => [...text.matchAll(/```bash\n([\s\S]*?)\n```/g)].map(m => m[1]!);
function extractFunction(name: string): string {
const start = SETUP_SRC.indexOf(`\n${name}() {`);
const end = SETUP_SRC.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
return SETUP_SRC.slice(start + 1, end + 2);
}
beforeAll(async () => {
const result = await runGeneration({ host: 'all', outputRoot: renderDir });
if (result.exitCode !== 0) throw new Error(result.diagnostics.filter(d => d.kind === 'error').map(d => d.message).join('\n'));
});
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
describe('env-var host fences reach runtime files through their runtime root', () => {
test('no fence guesses a $HOME/<dir>/skills/gstack path', () => {
const problems: string[] = [];
for (const host of ENV_HOSTS) {
const base = path.join(renderDir, host.hostSubdir, 'skills');
for (const file of walk(base).filter(f => !PROBE_FILES.test(f))) {
for (const body of fences(fs.readFileSync(file, 'utf8'))) {
for (const m of body.matchAll(/\$(?:HOME|\{HOME\})\/\.[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/skills\/gstack\/[^\s"']*/g)) {
problems.push(`${host.name}: ${path.relative(renderDir, file)}: ${m[0]}`);
}
}
}
}
expect(problems.slice(0, 20)).toEqual([]);
});
test("every $GSTACK_ROOT path a fence uses is in the host's runtime root", () => {
const problems: string[] = [];
for (const host of ENV_HOSTS) {
const provided = [...host.runtimeRoot.globalSymlinks,
...Object.entries(host.runtimeRoot.globalFiles ?? {}).flatMap(([dir, files]) => files.map(f => `${dir}/${f}`))];
const base = path.join(renderDir, host.hostSubdir, 'skills');
for (const file of walk(base)) {
for (const body of fences(fs.readFileSync(file, 'utf8'))) {
for (const m of body.matchAll(/\$\{?GSTACK_ROOT\}?\/([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)/g)) {
const rel = m[1]!;
const covered = [...provided, ...OPTIONAL_ROOT_PATHS].some(p => rel === p || rel.startsWith(`${p}/`));
if (!covered) problems.push(`${host.name}: ${path.relative(renderDir, file)}: $GSTACK_ROOT/${rel}`);
}
}
}
}
expect([...new Set(problems)].slice(0, 20)).toEqual([]);
});
});
describe.skipIf(process.platform === 'win32')('Codex global install: the freeze boundary works from a fresh shell', () => {
test('setup links freeze/bin and careful/bin, and /freeze then /unfreeze run as installed', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-freeze-'));
try {
const home = path.join(sandbox, 'home');
const codexHome = path.join(home, '.codex');
const rootDir = path.join(codexHome, 'skills', 'gstack');
const project = path.join(sandbox, 'project', 'src');
fs.mkdirSync(project, { recursive: true });
const functions = ['_link_or_copy', '_link_runtime_dists', '_copy_skill_md', '_gstack_generated_header', 'create_codex_runtime_root']
.filter(name => SETUP_SRC.includes(`\n${name}() {`)).map(extractFunction).join('\n');
const built = spawnSync('bash', ['-c', `IS_WINDOWS=0\n_CODEX_RENDER_ROOT="${renderDir}"\n${functions}\ncreate_codex_runtime_root "${ROOT}" "${rootDir}"`],
{ encoding: 'utf8', timeout: 30_000, env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home } });
expect(built.stderr).toBe('');
expect(built.status).toBe(0);
for (const rel of ['freeze/bin/freeze-state.sh', 'freeze/bin/check-freeze.sh', 'careful/bin/hook-extract.sh']) {
expect(fs.existsSync(path.join(rootDir, rel)), rel).toBe(true);
}
expect(fs.existsSync(path.join(rootDir, 'freeze', 'SKILL.md'))).toBe(false);
const skill = (name: string) => fs.readFileSync(path.join(renderDir, '.agents', 'skills', name, 'SKILL.md'), 'utf8');
const fence = (name: string, marker: string) => fences(skill(name)).find(body => body.includes(marker))!;
const run = (script: string) => spawnSync('bash', ['-c', script], {
cwd: path.dirname(project), encoding: 'utf8', timeout: 30_000,
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home, CODEX_HOME: codexHome },
});
const set = run(fence('gstack-freeze', 'freeze-state.sh" set').replace('<user-provided-path>', project));
expect(set.stderr).toBe('');
expect(set.status).toBe(0);
expect(set.stdout).toContain(`FREEZE_DIR=${fs.realpathSync(project)}`);
const clear = run(fence('gstack-unfreeze', 'freeze-state.sh" clear'));
expect(clear.status, clear.stderr).toBe(0);
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});
});
+2 -1
View File
@@ -5,7 +5,8 @@ import { join, resolve, dirname } from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
const ROOT = resolve(import.meta.dir, '..');
const template = readFileSync(join(ROOT, 'investigate/SKILL.md.tmpl'), 'utf8');
// The Claude render: the template names the root as {{RUNTIME_ROOT}} ($HOME/.claude/skills/gstack on Claude).
const template = readFileSync(join(ROOT, 'investigate/SKILL.md'), 'utf8');
const scope = template.split('## Scope Lock')[1].split('\n---')[0];
const acquisition = [...scope.matchAll(/```bash\n([\s\S]*?)```/g)][1][1];
const registered = template.match(/command: '(.*check-freeze\.sh.*)'/)![1].replace(/''/g, "'");
+2 -1
View File
@@ -173,7 +173,8 @@ describe('gstack-retro-metrics contract', () => {
test('the skill fence invokes the script with primary path + degraded fallback', () => {
const tmpl = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md.tmpl'), 'utf-8');
expect(tmpl).toContain('$HOME/.claude/skills/gstack/bin/gstack-retro-metrics');
expect(tmpl).toContain('"{{RUNTIME_ROOT}}/bin/gstack-retro-metrics"');
expect(fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8')).toContain('$HOME/.claude/skills/gstack/bin/gstack-retro-metrics');
expect(tmpl).toContain('".claude/skills/gstack/bin/gstack-retro-metrics"');
expect(tmpl).toContain('--base "<default>" --since "<since>"');
expect(tmpl).toContain(
+4 -1
View File
@@ -29,7 +29,10 @@ describe('investigate freeze path resolution', () => {
});
test(`${rel} scope lock availability probe uses the $HOME anchor`, () => {
expect(content).toContain('_FREEZE_SCRIPT="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"');
// The template names the host's runtime root; Claude's render keeps the $HOME anchor.
expect(content).toContain(rel.endsWith('.tmpl')
? '_FREEZE_SCRIPT="{{RUNTIME_ROOT}}/freeze/bin/check-freeze.sh"'
: '_FREEZE_SCRIPT="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"');
expect(content).toContain('[ -x "$_FREEZE_SCRIPT" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE"');
});
}
+1 -1
View File
@@ -29,7 +29,7 @@ echo '{"skill":"unfreeze","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(bas
## Clear the boundary
```bash
bash "$HOME/.claude/skills/gstack/freeze/bin/freeze-state.sh" clear
bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" clear
```
This is explicit user-requested removal, not investigation cleanup. The shared writer serializes it with acquisition, replacement and owner-checked release. On `FREEZE_BUSY` or unexpected state, leave everything untouched and report recovery; never delete state or a possibly active mutation lock directly.