diff --git a/benchmark-models/SKILL.md.tmpl b/benchmark-models/SKILL.md.tmpl index 27a0413cd..2d804237f 100644 --- a/benchmark-models/SKILL.md.tmpl +++ b/benchmark-models/SKILL.md.tmpl @@ -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" diff --git a/diagram/SKILL.md.tmpl b/diagram/SKILL.md.tmpl index d36f93856..e6d3dc795 100644 --- a/diagram/SKILL.md.tmpl +++ b/diagram/SKILL.md.tmpl @@ -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 diff --git a/freeze/SKILL.md.tmpl b/freeze/SKILL.md.tmpl index b86711c62..efd888c25 100644 --- a/freeze/SKILL.md.tmpl +++ b/freeze/SKILL.md.tmpl @@ -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 "" +bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" set "" ``` 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. diff --git a/guard/SKILL.md.tmpl b/guard/SKILL.md.tmpl index f2f194920..206a38077 100644 --- a/guard/SKILL.md.tmpl +++ b/guard/SKILL.md.tmpl @@ -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 "" +bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" set "" ``` 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. diff --git a/hosts/codex.ts b/hosts/codex.ts index 0f2594626..54edf1f16 100644 --- a/hosts/codex.ts +++ b/hosts/codex.ts @@ -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'], }, diff --git a/hosts/define-host.ts b/hosts/define-host.ts index a90010e37..a4388a973 100644 --- a/hosts/define-host.ts +++ b/hosts/define-host.ts @@ -126,7 +126,7 @@ export function defineHost(overrides: HostOverrides): 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'], }, diff --git a/hosts/opencode.ts b/hosts/opencode.ts index 038267a0f..63980a28c 100644 --- a/hosts/opencode.ts +++ b/hosts/opencode.ts @@ -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'], }, diff --git a/investigate/SKILL.md.tmpl b/investigate/SKILL.md.tmpl index f29a1ed9e..e172cad9b 100644 --- a/investigate/SKILL.md.tmpl +++ b/investigate/SKILL.md.tmpl @@ -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 "" +bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" acquire "" ``` Substitute `` 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 "" +bash "{{RUNTIME_ROOT}}/freeze/bin/freeze-state.sh" release "" ``` 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. diff --git a/retro/SKILL.md.tmpl b/retro/SKILL.md.tmpl index 14923d200..9921fedc9 100644 --- a/retro/SKILL.md.tmpl +++ b/retro/SKILL.md.tmpl @@ -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 "" --since "" \ || echo "RETRO_METRICS: unavailable — stale install (read the helper source for manual computation)" diff --git a/scripts/resolvers/index.ts b/scripts/resolvers/index.ts index 37a53f5ab..7b5c1ed35 100644 --- a/scripts/resolvers/index.ts +++ b/scripts/resolvers/index.ts @@ -140,6 +140,10 @@ export const RESOLVERS: Record = { 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. diff --git a/setup b/setup index 8eb4264b5..31929f2d0 100755 --- a/setup +++ b/setup @@ -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 diff --git a/test/env-host-runtime-assets.test.ts b/test/env-host-runtime-assets.test.ts new file mode 100644 index 000000000..a63e7af65 --- /dev/null +++ b/test/env-host-runtime-assets.test.ts @@ -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//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('', 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 }); + } + }); +}); diff --git a/test/freeze-owned-lifecycle.test.ts b/test/freeze-owned-lifecycle.test.ts index e3a2cf6b3..72c9a8156 100644 --- a/test/freeze-owned-lifecycle.test.ts +++ b/test/freeze-owned-lifecycle.test.ts @@ -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, "'"); diff --git a/test/gstack-retro-metrics.test.ts b/test/gstack-retro-metrics.test.ts index d2aa0b8b3..5eacc5b7b 100644 --- a/test/gstack-retro-metrics.test.ts +++ b/test/gstack-retro-metrics.test.ts @@ -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 "" --since ""'); expect(tmpl).toContain( diff --git a/test/investigate-freeze-path.test.ts b/test/investigate-freeze-path.test.ts index fcd7f1b4a..97a82556d 100644 --- a/test/investigate-freeze-path.test.ts +++ b/test/investigate-freeze-path.test.ts @@ -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"'); }); } diff --git a/unfreeze/SKILL.md.tmpl b/unfreeze/SKILL.md.tmpl index f310b9e01..1e08fc7b5 100644 --- a/unfreeze/SKILL.md.tmpl +++ b/unfreeze/SKILL.md.tmpl @@ -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.